SM-310 [] Secrets (#3355)

* [SM-63] Secrets List overview (#3239)

The purpose of this PR is to create a new component for the Secrets Manager project where all the secrets associated to a specific organization ID can be viewed.

* [SM-63] Secrets List overview (#3239)

The purpose of this PR is to create a new component for the Secrets Manager project where all the secrets associated to a specific organization ID can be viewed.

* [SM-63] Display dates based off Figma (#3358)

* Display dates based off Figma

* Swapping date to medium format

* [SM-185] Use feature flags for secrets (#3409)

* Fix SM lint errors (#3526)

* Fix SM lint errors

* Update bitwarden_license/bit-web/src/app/sm/secrets/secrets.component.ts

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* [SM-65] Create/Edit Secrets Dialog (#3376)

The purpose of this PR is to add a Create/Edit Secrets dialog component.

* [SM-198] Empty Secrets View (#3585)

* SM-198 Empty Secrets View

* [SM-64] Soft delete secrets (#3549)

* Soft delete secrets

* SM-95-ProjectList (#3508)

* Adding project list and creating a shared module for secrets

* updates to style , temporarily using secrets results until API portion is completed

* removing non project related options from the list, updting api call to call projects now

* Adding view project option from drop down

* Changes requested by Thomas

* Changes requested by Thomas

* suggested fixes

* fixes after merge from master

* Adding decrypting to project list

* Update bitwarden_license/bit-web/src/app/sm/shared/sm-shared.module.ts

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Update bitwarden_license/bit-web/src/app/sm/projects/project.service.ts

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Update bitwarden_license/bit-web/src/app/sm/projects/project.service.ts

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* fix to projectRequest so name is type EncString instead of string

* lint + prettier fixes

* Oscar's suggestions - Removing this. from projectList

* updating to use bitIconButton

* Updating to use BitIconButton

Co-authored-by: CarleyDiaz-Bitwarden <103955722+CarleyDiaz-Bitwarden@users.noreply.github.com>
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Fix double edit secret dialog (#3645)

* Fix typescript errors on secrets init (#3649)

* Resolve breaking changes

* Remove unecessary class

* SM-198 Update empty list text. (#3652)

* [SM-267] Minor visual fixes (#3673)

* SM-96: Add/Edit Project for SM (#3616)

* SM-96: Initial add for Add/Edit project

* Update secrets.module.ts

* Small fixes based on PR comments

* SM-96: Small fixes + fix new project creation

* Fully fix create / edit project

* SM-96: Update toast text

* Remove message with exclamation

* SM-96: Fix broken build

* SM-96: Remove disabled on save buttons for SM dialogs & switch to early exits

* SM-96: Run linter

* [SM-186] Service Accounts - Overview (#3653)

* SM-186 Service Accounts Overview

* Remove duplicate titles (#3659)

* [SM-187] Create Service Account Dialog (#3710)

* SM-187 Create Service Account Dialog

* Fix renamed paths

* SM Modal Updates (#3776)

* Add type=button to cancel button on sm dialogs

* Update new secret/project modal titles to match design

* Add loading spinner for project and secret edit modals

* Add max length to project name

* Use Tailwind CSS class instead of custom and remove click handler

* Fix spinner

* Add buttonType=primary to project dialog save button

* Fix loading change for secret dialog and use tw-text-center

Co-authored-by: Hinton <hinton@users.noreply.github.com>

* [SM-113] Delete Projects Dialog (#3777)

* SM-113 Add Delete Projects Dialog

* [SM-306] Migrate secrets dialog to async form (#3849)

* [SM-310] Prepare secrets manager for merge to master (#3885)

* Remove Built In Validator on Project Delete (#3909)

Handle all Project Delete validation through custom validator

* [SM-312] Mark all inputs as touched when submitting (#3920)

* Use new icon for no item (#3917)

* Create navigation component (#3881)

* [SM-150] Project / Secret mapping (#3912)

* wip

* removing added file

* updates

* handling projects and secrets mapping in UI

* moving files and fixing errors

* Update bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets-list.component.html

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* Decrypt the name

* fixing the secrets-list.component bug

* renaming file and view name

* lint fixes

* removing secret with projects list response, and other misc name changes

* Adding back things I shouldnt have deleted

* Update bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret-with-projects-list.response.ts

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* updating button (#3779)

* [SM-300] Access Tokens (#3955)

* [SM-301] fix: associate labels with inputs (#4058)

* fix: wrap input in label

* fix: update all label in projects and service accounts

* [SM-196] Create Access Token Dialog (#4095)

* Add create access token dialog

* Use ServiceAccountView for access token creation

* Set version to readonly for access token

* DRY up Expiration Date & bug fix

* Break out expiration options component

* Move expiration-options to layout; Match FIGMA

* Create Generic Key generator

* Add getByServiceAccountId

* Change to use keyMaterial and not the full key

* Use access token id, not service account

* Remove generic key generator

* Swap to service account name placeholder

* Swap ExpirationOptions to a CVA

* No longer masking according to FIGMA

* Remove schema comment

* Code review updates

* Update required logic and approach

* Move ExpirationOptionsComponent into access

Co-authored-by: Hinton <hinton@users.noreply.github.com>

* SM-99: Individual Project / Secrets Tab (#4011)

Co-authored-by: Hinton <hinton@users.noreply.github.com>

* Fixes for the demo (#4159)

* [SM-360] Add support for never expiring access tokens (#4150)

* Add support for never expiring access tokens

* Render performance fixes

* Update bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>

* [SM-360] Fix access token display dialog for never expiring tokens (#4164)

* Fix access token display dialog

* Add disableClose to access token display dialog

* [SM-299] Add license checks (#4078)

* [SM-69] feature: create org-switcher, bit-nav-item, bit-nav-group, bit-nav-divider (#4073)

* feat: create nav-item, nav-group, org-switcher

* add tree variant; add stories; move to component library

* render button if no link is present

* fix routerLinkActive; add template comments; fix styles

* update storybook stories

* rename to route

* a11y fixes

* update stories

* simplify tree nesting

* rename nav-base component

* add divider; finish org-switcher; add overview page skeleton

* add nav-divider story

* code review

* rename components to CL naming scheme

* fix iconButton focus color

* apply code review changes

* fix strict template route param

* add ariaLabel input; update org-switcher a11y

* add two way binding for nav-group open state; update stories

* add toggle control to org-switcher

* [SM-310] Disable Secrets Manager in QA (#4199)

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
Co-authored-by: Thomas Avery <tavery@bitwarden.com>
Co-authored-by: CarleyDiaz-Bitwarden <103955722+CarleyDiaz-Bitwarden@users.noreply.github.com>
Co-authored-by: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com>
Co-authored-by: Colton Hurst <colton@coltonhurst.com>
Co-authored-by: Will Martin <contact@willmartian.com>
This commit is contained in:
Oscar Hinton 2022-12-09 11:21:07 +01:00 committed by GitHub
parent 43945cd05d
commit dffef8ac17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
132 changed files with 4325 additions and 118 deletions

View File

@ -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",

View File

@ -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",

View File

@ -11,6 +11,7 @@
},
"flags": {
"showTrial": true,
"secretsManager": true,
"showPasswordless": true
}
}

View File

@ -11,6 +11,7 @@
},
"flags": {
"showTrial": true,
"secretsManager": false,
"showPasswordless": true
}
}

View File

@ -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: [],

View File

@ -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"
},

View File

@ -10,6 +10,7 @@ import {
/* eslint-disable-next-line @typescript-eslint/ban-types */
export type Flags = {
showTrial?: boolean;
secretsManager?: boolean;
showPasswordless?: boolean;
} & SharedFlags;

View File

@ -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,
},
];

View File

@ -0,0 +1,33 @@
<bit-dialog dialogSize="default">
<ng-container bitDialogTitle>
<span>{{ data.title | i18n }}</span>
<span class="tw-text-sm tw-normal-case tw-text-muted">
{{ data.details.length }}
{{ data.subTitle | i18n }}
</span>
</ng-container>
<div bitDialogContent>
{{ data.message | i18n }}
<bit-table>
<ng-container header>
<tr>
<th bitCell>{{ data.columnTitle | i18n }}</th>
<th bitCell>{{ "error" | i18n }}</th>
</tr>
</ng-container>
<ng-container body>
<tr bitRow *ngFor="let detail of data.details">
<td bitCell>{{ detail.name }}</td>
<td bitCell>{{ detail.errorMessage }}</td>
</tr>
</ng-container>
</bit-table>
</div>
<div bitDialogFooter class="tw-flex tw-gap-2">
<button bitButton buttonType="primary" bitDialogClose type="button">
{{ "close" | i18n }}
</button>
</div>
</bit-dialog>

View File

@ -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."
);
}
}
}

View File

@ -0,0 +1 @@
<i class="bwi bwi-fw bwi-filter tw-text-2xl"></i>

View File

@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "sm-filter",
templateUrl: "./filter.component.html",
})
export class FilterComponent {}

View File

@ -0,0 +1,56 @@
<div class="tw-mb-3 tw-flex tw-items-center tw-gap-2" *ngIf="routeData$ | async as routeData">
<h1 class="tw-m-0 tw-mr-2 tw-text-3xl tw-font-semibold">{{ routeData.title | i18n }}</h1>
<div class="tw-ml-auto tw-w-1/4">
<input bitInput class="search tw-w-full" placeholder="{{ routeData.searchTitle | i18n }}" />
</div>
<sm-new-menu></sm-new-menu>
<sm-filter></sm-filter>
<ng-container *ngIf="account$ | async as account">
<button [bitMenuTriggerFor]="accountMenu" class="tw-border-0 tw-bg-transparent tw-p-0">
<bit-avatar [id]="account.userId" [text]="account.name || account.email"></bit-avatar>
</button>
<bit-menu #accountMenu>
<div class="tw-flex tw-min-w-52 tw-max-w-72 tw-flex-col">
<div
class="tw-flex tw-items-center tw-py-1 tw-px-4 tw-leading-tight tw-text-info"
appStopProp
>
<bit-avatar [text]="account.name || account.email" [id]="account.userId"></bit-avatar>
<div class="tw-ml-2 tw-block tw-overflow-hidden tw-whitespace-nowrap">
<span>{{ "loggedInAs" | i18n }}</span>
<small class="tw-block tw-overflow-hidden tw-whitespace-nowrap tw-text-muted">
{{ account.name }}
</small>
</div>
</div>
<bit-menu-divider></bit-menu-divider>
<a bitMenuItem routerLink="/settings/account">
<i class="bwi bwi-fw bwi-user" aria-hidden="true"></i>
{{ "accountSettings" | i18n }}
</a>
<a bitMenuItem href="https://bitwarden.com/help/" target="_blank" rel="noopener">
<i class="bwi bwi-fw bwi-question-circle" aria-hidden="true"></i>
{{ "getHelp" | i18n }}
</a>
<a bitMenuItem href="https://bitwarden.com/download/" target="_blank" rel="noopener">
<i class="bwi bwi-fw bwi-download" aria-hidden="true"></i>
{{ "getApps" | i18n }}
</a>
<bit-menu-divider></bit-menu-divider>
<button bitMenuItem type="button">
<i class="bwi bwi-fw bwi-lock" aria-hidden="true"></i>
{{ "lockNow" | i18n }}
</button>
<button bitMenuItem type="button">
<i class="bwi bwi-fw bwi-sign-out" aria-hidden="true"></i>
{{ "logOut" | i18n }}
</button>
</div>
</bit-menu>
</ng-container>
</div>

View File

@ -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<AccountProfile>;
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;
})
);
}
}

View File

@ -1,8 +1,8 @@
<div class="tw-flex tw-w-full">
<aside class="tw-min-h-screen tw-w-60 tw-bg-background">
<aside class="tw-min-h-screen tw-w-60 tw-bg-background-alt3">
<router-outlet name="sidebar"></router-outlet>
</aside>
<main class="tw-min-h-screen tw-flex-1 tw-bg-background-alt tw-px-6 tw-pt-3">
<main class="tw-min-h-screen tw-flex-1 tw-bg-background-alt tw-p-6">
<router-outlet></router-outlet>
</main>
</div>

View File

@ -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: ` <p class="tw-text-main">Content</p> `,
})
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: `
<router-outlet></router-outlet>
`,
});
export const Default = Template.bind({});

View File

@ -0,0 +1,10 @@
<a [routerLink]="['secrets']" class="tw-mx-4 tw-mt-8 tw-mb-3 tw-block">
<bit-icon [icon]="logo" class="tw-w-full tw-text-alt2"></bit-icon>
</a>
<org-switcher></org-switcher>
<bit-nav-item icon="bwi-collection" text="Projects" route="projects"></bit-nav-item>
<bit-nav-item icon="bwi-key" text="Secrets" route="secrets"></bit-nav-item>
<bit-nav-item icon="bwi-wrench" text="Service Accounts" route="service-accounts"></bit-nav-item>
<bit-nav-item icon="bwi-trash" text="Trash" route="trash"></bit-nav-item>
<bit-nav-item icon="bwi-cog" text="Settings" route="settings"></bit-nav-item>

View File

@ -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;
}

View File

@ -0,0 +1,18 @@
<button bitButton buttonType="primary" [bitMenuTriggerFor]="newMenu">
{{ "new" | i18n }} <i class="bwi bwi-angle-down" aria-hidden="true"></i>
</button>
<bit-menu #newMenu>
<button type="button" bitMenuItem (click)="openProjectDialog()">
<i class="bwi bwi-fw bwi-sitemap tw-text-xl" aria-hidden="true"></i>
{{ "project" | i18n }}
</button>
<button type="button" bitMenuItem (click)="openSecretDialog()">
<i class="bwi bwi-fw bwi-key tw-text-xl" aria-hidden="true"></i>
{{ "secret" | i18n }}
</button>
<button type="button" bitMenuItem (click)="openServiceAccountDialog()">
<i class="bwi bwi-fw bwi-wrench tw-text-xl" aria-hidden="true"></i>
{{ "serviceAccount" | i18n }}
</button>
</bit-menu>

View File

@ -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<void> = new Subject<void>();
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<unknown, SecretOperation>(SecretDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Add,
},
});
}
openProjectDialog() {
this.dialogService.open<unknown, ProjectOperation>(ProjectDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Add,
},
});
}
openServiceAccountDialog() {
this.dialogService.open<unknown, ServiceAccountOperation>(ServiceAccountDialogComponent, {
data: {
organizationId: this.organizationId,
},
});
}
}

View File

@ -0,0 +1,16 @@
<div
class="tw-mx-auto tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-6 tw-text-center"
>
<div class="tw-max-w-sm">
<bit-icon [icon]="icon" aria-hidden="true"></bit-icon>
<h3 class="tw-font-semibold">
<ng-content select="[title]"></ng-content>
</h3>
<p>
<ng-content select="[description]"></ng-content>
</p>
</div>
<div class="tw-space-x-2">
<ng-content select="[bitButton]"></ng-content>
</div>
</div>

View File

@ -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;
}

View File

@ -0,0 +1,49 @@
<bit-nav-group
*ngIf="activeOrganization$ | async as activeOrganization"
[text]="activeOrganization.name"
[ariaLabel]="['organization' | i18n, activeOrganization.name].join(' ')"
icon="bwi-business"
[route]="['../', activeOrganization.id]"
[(open)]="open"
>
<ng-container *ngIf="organizations$ | async as organizations">
<bit-nav-item
*ngFor="let org of organizations"
[text]="org.name"
[ariaLabel]="['organization' | i18n, org.name].join(' ')"
[route]="['../', org.id]"
(mainContentClicked)="toggle()"
[hideActiveStyles]="true"
>
<button
slot-end
bitIconButton="bwi-ellipsis-v"
[bitMenuTriggerFor]="orgSwitchMenu"
size="small"
[title]="'options' | i18n"
[attr.aria-label]="['organization' | i18n, org.name, 'options' | i18n].join(' ')"
></button>
</bit-nav-item>
</ng-container>
<bit-nav-item
icon="bwi-plus"
[text]="'newOrganization' | i18n"
route="/create-organization"
></bit-nav-item>
<bit-nav-divider></bit-nav-divider>
</bit-nav-group>
<bit-menu #orgSwitchMenu>
<button type="button" bitMenuItem>
<i class="bwi bwi-fw bwi-key tw-text-xl" aria-hidden="true"></i>
<span>{{ "enrollPasswordReset" | i18n }}</span>
</button>
<button type="button" bitMenuItem>
<i class="bwi bwi-fw bwi-link tw-text-xl" aria-hidden="true"></i>
<span>{{ "linkSso" | i18n }}</span>
</button>
<button type="button" bitMenuItem>
<i class="bwi bwi-fw bwi-sign-out tw-text-xl tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">{{ "leaveOrganization" | i18n }}</span>
</button>
</bit-menu>

View File

@ -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<Organization[]> = this.organizationService.organizations$;
protected activeOrganization$: Observable<Organization> = 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<boolean>();
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {}
protected toggle(event?: MouseEvent) {
event?.stopPropagation();
this.open = !this.open;
}
}

File diff suppressed because one or more lines are too long

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -0,0 +1,6 @@
import { View } from "@bitwarden/common/models/view/view";
export class SecretProjectView implements View {
id: string;
name: string;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 {}

View File

@ -0,0 +1 @@
<sm-header></sm-header>

View File

@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "sm-overview",
templateUrl: "./overview.component.html",
})
export class OverviewComponent {}

View File

@ -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 {}

View File

@ -0,0 +1,35 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="small">
<ng-container bitDialogTitle>
<span>{{ title | i18n }}</span>
<span class="tw-text-sm tw-normal-case tw-text-muted">
<ng-container *ngIf="data.projects.length == 1">
{{ data.projects[0].name }}
</ng-container>
<ng-container *ngIf="data.projects.length > 1">
{{ data.projects.length }}
{{ "projects" | i18n }}
</ng-container>
</span>
</ng-container>
<div bitDialogContent>
<bit-callout type="warning" [title]="'warning' | i18n">
{{ dialogContent }}
</bit-callout>
<bit-form-field>
<bit-label>{{ dialogConfirmationLabel }}</bit-label>
<input bitInput formControlName="confirmDelete" />
</bit-form-field>
</div>
<div bitDialogFooter class="tw-flex tw-gap-2">
<button type="submit" bitButton buttonType="danger" bitFormButton>
{{ title | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitFormButton bitDialogClose>
{{ "cancel" | i18n }}
</button>
</div>
</bit-dialog>
</form>

View File

@ -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<unknown, BulkStatusDetails>(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"),
},
};
}
};
}
}

View File

@ -0,0 +1,22 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="small">
<span bitDialogTitle>{{ title | i18n }}</span>
<span bitDialogContent>
<div *ngIf="loading" class="tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
<bit-form-field *ngIf="!loading">
<bit-label>{{ "projectName" | i18n }}</bit-label>
<input formControlName="name" maxlength="1000" bitInput />
</bit-form-field>
</span>
<div bitDialogFooter class="tw-flex tw-gap-2">
<button type="submit" bitButton buttonType="primary" bitFormButton>
{{ "save" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitFormButton bitDialogClose>
{{ "cancel" | i18n }}
</button>
</div>
</bit-dialog>
</form>

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
import { EncString } from "@bitwarden/common/models/domain/enc-string";
export class ProjectRequest {
name: EncString;
}

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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<ProjectView>();
project$ = this._project.asObservable();
constructor(
private cryptoService: CryptoService,
private apiService: ApiService,
private encryptService: EncryptService
) {}
async getByProjectId(projectId: string): Promise<ProjectView> {
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<ProjectListView[]> {
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<ProjectView> {
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<BulkOperationStatus[]> {
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<SymmetricCryptoKey> {
return await this.cryptoService.getOrgKey(organizationId);
}
private async getProjectRequest(
organizationId: string,
projectView: ProjectView
): Promise<ProjectRequest> {
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<ProjectView> {
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<ProjectListView[]> {
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;
})
);
}
}

View File

@ -0,0 +1,20 @@
<ng-container *ngIf="secrets$ | async as secrets; else spinner">
<div *ngIf="secrets.length > 0" class="float-right tw-mt-3 tw-items-center">
<button bitButton buttonType="secondary" (click)="openNewSecretDialog()">
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "newSecret" | i18n }}
</button>
</div>
<sm-secrets-list
(deleteSecretsEvent)="openDeleteSecret($event)"
(newSecretEvent)="openNewSecretDialog()"
(editSecretEvent)="openEditSecret($event)"
[secrets]="secrets"
></sm-secrets-list>
</ng-container>
<ng-template #spinner>
<div class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
</ng-template>

View File

@ -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<SecretListView[]>;
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<SecretListView[]> {
return await this.secretService.getSecretsByProject(this.organizationId, this.projectId);
}
openEditSecret(secretId: string) {
this.dialogService.open<unknown, SecretOperation>(SecretDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Edit,
secretId: secretId,
},
});
}
openDeleteSecret(secretIds: string[]) {
this.dialogService.open<unknown, SecretDeleteOperation>(SecretDeleteDialogComponent, {
data: {
secretIds: secretIds,
},
});
}
openNewSecretDialog() {
this.dialogService.open<unknown, SecretOperation>(SecretDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Add,
projectId: this.projectId,
},
});
}
}

View File

@ -0,0 +1,8 @@
<sm-header title="project" searchTitle="searchProjects"></sm-header>
<bit-tab-nav-bar label="Main">
<bit-tab-link [route]="['secrets']">Secrets</bit-tab-link>
<bit-tab-link [route]="['access']">Access</bit-tab-link>
</bit-tab-nav-bar>
<router-outlet></router-outlet>

View File

@ -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<ProjectView>;
constructor(private route: ActivatedRoute, private projectService: ProjectService) {}
ngOnInit(): void {
this.project = this.route.params.pipe(
switchMap((params) => {
return this.projectService.getByProjectId(params.projectId);
})
);
}
}

View File

@ -0,0 +1,93 @@
<div *ngIf="!projects" class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
<sm-no-items *ngIf="projects?.length == 0">
<ng-container title>{{ "projectsNoItemsTitle" | i18n }}</ng-container>
<ng-container description>{{ "projectsNoItemsMessage" | i18n }}</ng-container>
<button bitButton buttonType="secondary" (click)="newProjectEvent.emit()">
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "newProject" | i18n }}
</button>
<button bitButton buttonType="secondary" (click)="importSecretsEvent.emit()">
<i class="bwi bwi-save tw-rotate-180" aria-hidden="true"></i>
{{ "importSecrets" | i18n }}
</button>
</sm-no-items>
<bit-table *ngIf="projects?.length >= 1">
<ng-container header>
<tr>
<th bitCell class="tw-w-0">
<label class="!tw-mb-0 tw-flex tw-w-fit tw-gap-2 !tw-font-bold !tw-text-muted">
<input
type="checkbox"
(change)="$event ? toggleAll() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
/>
{{ "all" | i18n }}
</label>
</th>
<th bitCell colspan="2">{{ "name" | i18n }}</th>
<th bitCell>{{ "lastEdited" | i18n }}</th>
<th bitCell class="tw-w-0">
<button
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[bitMenuTriggerFor]="tableMenu"
[title]="'options' | i18n"
[attr.aria-label]="'options' | i18n"
></button>
</th>
</tr>
</ng-container>
<ng-container body>
<tr bitRow *ngFor="let project of projects; index as i">
<td bitCell>
<input
type="checkbox"
(change)="$event ? selection.toggle(project.id) : null"
[checked]="selection.isSelected(project.id)"
/>
</td>
<td bitCell class="tw-w-0 tw-pr-0">
<i class="bwi bwi-collection tw-text-xl tw-text-muted" aria-hidden="true"></i>
</td>
<td bitCell>
<a [routerLink]="[project.id]">{{ project.name }}</a>
</td>
<td bitCell>{{ project.revisionDate | date: "medium" }}</td>
<td bitCell>
<button
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[bitMenuTriggerFor]="projectMenu"
[title]="'options' | i18n"
[attr.aria-label]="'options' | i18n"
></button>
</td>
<bit-menu #projectMenu>
<button type="button" bitMenuItem (click)="editProjectEvent.emit(project.id)">
<i class="bwi bwi-fw bwi-pencil tw-text-xl" aria-hidden="true"></i>
{{ "editProject" | i18n }}
</button>
<button type="button" bitMenuItem (click)="viewProjectEvent.emit(project.id)">
<i class="bwi bwi-fw bwi-eye tw-text-xl" aria-hidden="true"></i>
{{ "viewProject" | i18n }}
</button>
<button type="button" bitMenuItem (click)="deleteProject(project.id)">
<i class="bwi bwi-fw bwi-trash tw-text-xl tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">{{ "deleteProject" | i18n }}</span>
</button>
</bit-menu>
</tr>
</ng-container>
</bit-table>
<bit-menu #tableMenu>
<button type="button" bitMenuItem (click)="bulkDeleteProjects()">
<i class="bwi bwi-fw bwi-trash tw-text-xl tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">{{ "deleteProjects" | i18n }}</span>
</button>
</bit-menu>

View File

@ -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<string>();
@Output() viewProjectEvent = new EventEmitter<string>();
@Output() deleteProjectEvent = new EventEmitter<ProjectListView[]>();
@Output() onProjectCheckedEvent = new EventEmitter<string[]>();
@Output() newProjectEvent = new EventEmitter();
@Output() importSecretsEvent = new EventEmitter();
private destroy$: Subject<void> = new Subject<void>();
selection = new SelectionModel<string>(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))
);
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -0,0 +1,8 @@
<sm-header></sm-header>
<sm-projects-list
(newProjectEvent)="openNewProjectDialog()"
(editProjectEvent)="openEditProject($event)"
(deleteProjectEvent)="openDeleteProjectDialog($event)"
[projects]="projects$ | async"
>
</sm-projects-list>

View File

@ -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<ProjectListView[]>;
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<ProjectListView[]> {
return await this.projectService.getProjects(this.organizationId);
}
openEditProject(projectId: string) {
this.dialogService.open<unknown, ProjectOperation>(ProjectDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Edit,
projectId: projectId,
},
});
}
openNewProjectDialog() {
this.dialogService.open<unknown, ProjectOperation>(ProjectDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Add,
},
});
}
openDeleteProjectDialog(event: ProjectListView[]) {
this.dialogService.open<unknown, ProjectDeleteOperation>(ProjectDeleteDialogComponent, {
data: {
projects: event,
},
});
}
}

View File

@ -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 {}

View File

@ -0,0 +1,17 @@
<bit-simple-dialog>
<span bitDialogTitle>{{ title | i18n }}</span>
<span bitDialogContent class="tw-text-sm">
<div *ngIf="data.secretIds.length === 1">
{{ "softDeleteSecretWarning" | i18n }}
</div>
{{ "deleteItemConfirmation" | i18n }}
</span>
<div bitDialogFooter class="tw-flex tw-gap-2">
<button type="button" bitButton buttonType="primary" [bitAction]="delete">
{{ title | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</div>
</bit-simple-dialog>

View File

@ -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));
};
}

View File

@ -0,0 +1,38 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="default" disablePadding>
<ng-container bitDialogTitle>{{ title | i18n }}</ng-container>
<div bitDialogContent>
<div *ngIf="loading" class="tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
<bit-tab-group *ngIf="!loading">
<bit-tab [label]="'nameValuePair' | i18n">
<div class="tw-flex tw-gap-4 tw-pt-4">
<bit-form-field class="tw-w-1/3">
<bit-label for="secret-name">{{ "name" | i18n }}</bit-label>
<input formControlName="name" bitInput />
</bit-form-field>
<bit-form-field class="tw-w-full">
<bit-label>{{ "value" | i18n }}</bit-label>
<textarea bitInput rows="4" formControlName="value"></textarea>
</bit-form-field>
</div>
<bit-form-field>
<bit-label>{{ "notes" | i18n }}</bit-label>
<textarea bitInput rows="4" formControlName="notes"></textarea>
</bit-form-field>
</bit-tab>
<bit-tab [label]="'serviceAccounts' | i18n"></bit-tab>
<bit-tab [label]="'projects' | i18n"></bit-tab>
</bit-tab-group>
</div>
<div bitDialogFooter class="tw-flex tw-gap-2">
<button type="submit" bitButton buttonType="primary" bitFormButton>
{{ "save" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitFormButton bitDialogClose>
{{ "cancel" | i18n }}
</button>
</div>
</bit-dialog>
</form>

View File

@ -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;
}
}

View File

@ -0,0 +1,6 @@
export class SecretRequest {
key: string;
value: string;
note: string;
projectId?: string;
}

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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));
}
}

View File

@ -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");
}
}

View File

@ -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<SecretView> = new Subject();
secret$ = this._secret.asObservable();
constructor(
private cryptoService: CryptoService,
private apiService: ApiService,
private encryptService: EncryptService
) {}
async getBySecretId(secretId: string): Promise<SecretView> {
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<SecretListView[]> {
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<SecretListView[]> {
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<SymmetricCryptoKey> {
return await this.cryptoService.getOrgKey(organizationId);
}
private async getSecretRequest(
organizationId: string,
secretView: SecretView,
projectId?: string
): Promise<SecretRequest> {
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<SecretView> {
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<SecretListView[]> {
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<SecretProjectView[]> {
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;
})
);
}
}

View File

@ -0,0 +1,7 @@
<sm-header></sm-header>
<sm-secrets-list
(deleteSecretsEvent)="openDeleteSecret($event)"
(newSecretEvent)="openNewSecretDialog()"
(editSecretEvent)="openEditSecret($event)"
[secrets]="secrets$ | async"
></sm-secrets-list>

View File

@ -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<SecretListView[]>;
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<SecretListView[]> {
return await this.secretService.getSecrets(this.organizationId);
}
openEditSecret(secretId: string) {
this.dialogService.open<unknown, SecretOperation>(SecretDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Edit,
secretId: secretId,
},
});
}
openDeleteSecret(secretIds: string[]) {
this.dialogService.open<unknown, SecretDeleteOperation>(SecretDeleteDialogComponent, {
data: {
secretIds: secretIds,
},
});
}
openNewSecretDialog() {
this.dialogService.open<unknown, SecretOperation>(SecretDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Add,
},
});
}
}

View File

@ -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 {}

View File

@ -0,0 +1,77 @@
<div *ngIf="!tokens" class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
<sm-no-items *ngIf="tokens?.length == 0">
<ng-container title>{{ "accessTokensNoItemsTitle" | i18n }}</ng-container>
<ng-container description>{{ "accessTokensNoItemsDesc" | i18n }}</ng-container>
<button bitButton buttonType="secondary" (click)="newAccessTokenEvent.emit()">
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "createAccessToken" | i18n }}
</button>
</sm-no-items>
<bit-table *ngIf="tokens?.length >= 1">
<ng-container header>
<tr>
<th bitCell class="tw-w-0">
<label class="tw-m-0 tw-flex tw-w-fit tw-gap-2">
<input
type="checkbox"
(change)="$event ? toggleAll() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
/>
{{ "all" | i18n }}
</label>
</th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "permissions" | i18n }}</th>
<th bitCell>{{ "expires" | i18n }}</th>
<th bitCell>{{ "lastEdited" | i18n }}</th>
<th bitCell class="tw-w-0">
<button
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[title]="'options' | i18n"
[attr.aria-label]="'options' | i18n"
></button>
</th>
</tr>
</ng-container>
<ng-container body>
<tr bitRow *ngFor="let token of tokens">
<td bitCell>
<input
type="checkbox"
(change)="$event ? selection.toggle(token.id) : null"
[checked]="selection.isSelected(token.id)"
/>
</td>
<td bitCell>{{ token.name }}</td>
<td bitCell>{{ permission(token) | i18n }}</td>
<td bitCell>
{{ token.expireAt === null ? ("never" | i18n) : (token.expireAt | date: "medium") }}
</td>
<td bitCell>{{ token.revisionDate | date: "medium" }}</td>
<td bitCell>
<button
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[title]="'options' | i18n"
[attr.aria-label]="'options' | i18n"
[bitMenuTriggerFor]="tokenMenu"
></button>
</td>
<bit-menu #tokenMenu>
<button type="button" bitMenuItem>
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccessToken" | i18n }}
</span>
</button>
</bit-menu>
</tr>
</ng-container>
</bit-table>

View File

@ -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<string>(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";
}
}

View File

@ -0,0 +1,4 @@
<sm-access-list
[tokens]="accessTokens$ | async"
(newAccessTokenEvent)="openNewAccessTokenDialog()"
></sm-access-list>

View File

@ -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<AccessTokenView[]>;
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<AccessTokenView[]> {
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<unknown, AccessTokenOperation>(AccessTokenCreateDialogComponent, {
data: {
organizationId: this.organizationId,
serviceAccountView: serviceAccountView,
},
});
}
}

View File

@ -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<AccessTokenView> = 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<AccessTokenView[]> {
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<string> {
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<AccessTokenRequest> {
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<SymmetricCryptoKey> {
return await this.cryptoService.getOrgKey(organizationId);
}
private async createAccessTokenViews(
organizationId: string,
accessTokenResponses: AccessTokenResponse[]
): Promise<AccessTokenView[]> {
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;
})
);
}
}

View File

@ -0,0 +1,44 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="default">
<ng-container bitDialogTitle>
<span>{{ "createAccessToken" | i18n }}</span>
<span class="tw-text-sm tw-normal-case tw-text-muted">
{{ data.serviceAccountView.name }}
</span>
</ng-container>
<div bitDialogContent>
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
<div class="tw-mb-6">
<bit-form-field class="tw-mb-0">
<bit-label>{{ "permissions" | i18n }}</bit-label>
<select bitInput disabled>
<option selected value="canRead">
{{ "canRead" | i18n }}
</option>
</select>
</bit-form-field>
<span class="tw-text-sm tw-text-muted">
{{ "accessTokenPermissionsBetaNotification" | i18n }}
</span>
</div>
<sm-expiration-options
formControlName="expirationDateControl"
[expirationDayOptions]="expirationDayOptions"
[touched]="formGroup.controls.expirationDateControl.touched"
></sm-expiration-options>
</div>
<div bitDialogFooter class="tw-flex tw-gap-2">
<button class="tw-normal-case" type="submit" bitButton buttonType="primary" bitFormButton>
{{ "createAccessToken" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitFormButton bitDialogClose>
{{ "cancel" | i18n }}
</button>
</div>
</bit-dialog>
</form>

View File

@ -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<unknown, AccessTokenDetails>(AccessTokenDialogComponent, {
data: {
subTitle: serviceAccountName,
expirationDate: expirationDate,
accessToken: accessToken,
},
});
}
}

View File

@ -0,0 +1,30 @@
<bit-dialog dialogSize="default">
<ng-container bitDialogTitle>
<span>{{ "createAccessToken" | i18n }}</span>
<span class="tw-text-sm tw-normal-case tw-text-muted">
{{ data.subTitle }}
</span>
</ng-container>
<div bitDialogContent>
<bit-callout type="info" [title]="'accessTokenCallOutTitle' | i18n">
{{ "downloadAccessToken" | i18n }}<br />
{{ "expiresOnAccessToken" | i18n }}
{{ data.expirationDate === null ? ("never" | i18n) : (data.expirationDate | date: "medium") }}
</bit-callout>
<bit-form-field class="tw-mb-0">
<bit-label>{{ "accessToken" | i18n }}</bit-label>
<textarea bitInput disabled rows="4">{{ data.accessToken }}</textarea>
</bit-form-field>
{{ "expiresOnAccessToken" | i18n }}
{{ data.expirationDate === null ? ("never" | i18n) : (data.expirationDate | date: "medium") }}
</div>
<div bitDialogFooter class="tw-flex tw-gap-2">
<button bitButton buttonType="primary" (click)="copyAccessToken()">
<i class="bwi bwi-clone" aria-hidden="true"></i>
{{ "copyToken" | i18n }}
</button>
</div>
</bit-dialog>

View File

@ -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();
}
}

View File

@ -0,0 +1,21 @@
<ng-container [formGroup]="form">
<bit-form-field>
<bit-label>{{ "expires" | i18n }}</bit-label>
<select bitInput formControlName="expires">
<option ngValue="never">{{ "never" | i18n }}</option>
<option *ngFor="let day of expirationDayOptions" [ngValue]="day">
{{ "days" | i18n: day }}
</option>
<option ngValue="custom">{{ "custom" | i18n }}</option>
</select>
</bit-form-field>
<bit-form-field *ngIf="form.value.expires === 'custom'">
<bit-label>{{ "expirationDate" | i18n }}</bit-label>
<input
bitInput
type="datetime-local"
[min]="currentDate | date: 'YYYY-MM-ddThh:mm'"
formControlName="expireDateTime"
/>
</bit-form-field>
</ng-container>

View File

@ -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<void>();
@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<any, any>): 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;
}
}

View File

@ -0,0 +1,50 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="default">
<ng-container bitDialogTitle>{{ "newServiceAccount" | i18n }}</ng-container>
<div bitDialogContent>
<bit-form-field>
<bit-label>{{ "serviceAccountName" | i18n }}</bit-label>
<input formControlName="name" bitInput />
</bit-form-field>
<h3 class="tw-uppercase">{{ "smAccess" | i18n }}</h3>
<bit-form-field>
<bit-label>{{ "newSaSelectAccess" | i18n }}</bit-label>
<select bitInput>
<!-- TODO need to look into creating a bit autocomplete component? -->
<option selected disabled hidden>-- {{ "newSaTypeToFilter" | i18n }} --</option>
<option *ngFor="let project of projects">
{{ project.name }}
</option>
<option *ngFor="let secret of secrets">
{{ secret.name }}
</option>
</select>
</bit-form-field>
<bit-table>
<ng-container header>
<tr>
<th bitCell>{{ "projectCommaSecret" | i18n }}</th>
<th bitCell>{{ "permissions" | i18n }}</th>
</tr>
</ng-container>
<ng-container body>
<tr>
<!-- TODO once access is implement display selected access -->
<td bitCell>example</td>
<td bitCell>example</td>
</tr>
</ng-container>
</bit-table>
</div>
<div bitDialogFooter class="tw-flex tw-gap-2">
<button type="submit" bitButton buttonType="primary" bitFormButton>
{{ "save" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitFormButton bitDialogClose>
{{ "cancel" | i18n }}
</button>
</div>
</bit-dialog>
</form>

View File

@ -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;
}
}

View File

@ -0,0 +1,8 @@
import { EncString } from "@bitwarden/common/models/domain/enc-string";
export class AccessTokenRequest {
name: EncString;
encryptedPayload: EncString;
key: EncString;
expireAt: Date;
}

View File

@ -0,0 +1,5 @@
import { EncString } from "@bitwarden/common/models/domain/enc-string";
export class ServiceAccountRequest {
name: EncString;
}

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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");
}
}

View File

@ -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;
}

View File

@ -0,0 +1,9 @@
<sm-header></sm-header>
<bit-tab-nav-bar label="Main">
<bit-tab-link [route]="['secrets']">{{ "secrets" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['people']">{{ "people" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['access']">{{ "accessTokens" | i18n }}</bit-tab-link>
</bit-tab-nav-bar>
<router-outlet></router-outlet>

View File

@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "sm-service-account",
templateUrl: "./service-account.component.html",
})
export class ServiceAccountComponent {}

View File

@ -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<ServiceAccountView> = new Subject();
serviceAccount$ = this._serviceAccount.asObservable();
constructor(
private cryptoService: CryptoService,
private apiService: ApiService,
private encryptService: EncryptService
) {}
async getServiceAccounts(organizationId: string): Promise<ServiceAccountView[]> {
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<SymmetricCryptoKey> {
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<ServiceAccountView> {
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<ServiceAccountView[]> {
const orgKey = await this.getOrganizationKey(organizationId);
return await Promise.all(
serviceAccountResponses.map(async (s: ServiceAccountResponse) => {
return await this.createServiceAccountView(orgKey, s);
})
);
}
}

View File

@ -0,0 +1,96 @@
<div *ngIf="!serviceAccounts" class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
<sm-no-items *ngIf="serviceAccounts?.length == 0">
<ng-container title>{{ "serviceAccountsNoItemsTitle" | i18n }}</ng-container>
<ng-container description>{{ "serviceAccountsNoItemsMessage" | i18n }}</ng-container>
<button bitButton buttonType="secondary" (click)="newServiceAccountEvent.emit()">
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "newServiceAccount" | i18n }}
</button>
</sm-no-items>
<bit-table *ngIf="serviceAccounts?.length >= 1">
<ng-container header>
<tr>
<th bitCell class="tw-w-0">
<label class="!tw-mb-0 tw-flex tw-w-fit tw-gap-2 !tw-font-bold !tw-text-muted">
<input
type="checkbox"
(change)="$event ? toggleAll() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
/>
{{ "all" | i18n }}
</label>
</th>
<th bitCell colspan="2">{{ "name" | i18n }}</th>
<th bitCell>{{ "secrets" | i18n }}</th>
<th bitCell>{{ "lastEdited" | i18n }}</th>
<th bitCell class="tw-w-0">
<button
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[title]="'options' | i18n"
[attr.aria-label]="'options' | i18n"
[bitMenuTriggerFor]="tableMenu"
></button>
</th>
</tr>
</ng-container>
<ng-container body>
<tr bitRow *ngFor="let serviceAccount of serviceAccounts">
<td bitCell>
<input
type="checkbox"
(change)="$event ? selection.toggle(serviceAccount.id) : null"
[checked]="selection.isSelected(serviceAccount.id)"
/>
</td>
<td bitCell class="tw-w-0 tw-pr-0">
<i class="bwi bwi-wrench tw-text-xl tw-text-muted" aria-hidden="true"></i>
</td>
<td bitCell>
<a [routerLink]="serviceAccount.id">
{{ serviceAccount.name }}
</a>
</td>
<td bitCell>
<!-- TODO add number of secrets once mapping is implemented-->
<span> 0 </span>
</td>
<td bitCell>{{ serviceAccount.revisionDate | date: "medium" }}</td>
<td bitCell>
<button
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[title]="'options' | i18n"
[attr.aria-label]="'options' | i18n"
[bitMenuTriggerFor]="serviceAccountMenu"
></button>
</td>
<bit-menu #serviceAccountMenu>
<a type="button" bitMenuItem [routerLink]="serviceAccount.id">
<i class="bwi bwi-fw bwi-eye tw-text-xl" aria-hidden="true"></i>
{{ "viewServiceAccount" | i18n }}
</a>
<button type="button" bitMenuItem>
<i class="bwi bwi-fw bwi-trash tw-text-xl tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">
{{ "deleteServiceAccount" | i18n }}
</span>
</button>
</bit-menu>
</tr>
</ng-container>
</bit-table>
<bit-menu #tableMenu>
<button type="button" bitMenuItem (click)="bulkDeleteServiceAccounts()">
<i class="bwi bwi-fw bwi-trash tw-text-xl tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">
{{ "deleteServiceAccounts" | i18n }}
</span>
</button>
</bit-menu>

View File

@ -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<string[]>();
@Output() onServiceAccountCheckedEvent = new EventEmitter<string[]>();
private destroy$: Subject<void> = new Subject<void>();
selection = new SelectionModel<string>(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);
}
}
}

View File

@ -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 {}

View File

@ -0,0 +1,5 @@
<sm-header></sm-header>
<sm-service-accounts-list
[serviceAccounts]="serviceAccounts$ | async"
(newServiceAccountEvent)="openNewServiceAccountDialog()"
></sm-service-accounts-list>

View File

@ -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<ServiceAccountView[]>;
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<unknown, ServiceAccountOperation>(ServiceAccountDialogComponent, {
data: {
organizationId: this.organizationId,
},
});
}
private async getServiceAccounts(): Promise<ServiceAccountView[]> {
return await this.serviceAccountService.getServiceAccounts(this.organizationId);
}
}

View File

@ -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 {}

View File

@ -0,0 +1,115 @@
<div *ngIf="!secrets" class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
<sm-no-items *ngIf="secrets?.length == 0">
<ng-container title>{{ "secretsNoItemsTitle" | i18n }}</ng-container>
<ng-container description>{{ "secretsNoItemsMessage" | i18n }}</ng-container>
<button bitButton buttonType="secondary" (click)="newSecretEvent.emit()">
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "newSecret" | i18n }}
</button>
<button bitButton buttonType="secondary" (click)="importSecretsEvent.emit()">
<i class="bwi bwi-save tw-rotate-180" aria-hidden="true"></i>
{{ "importSecrets" | i18n }}
</button>
</sm-no-items>
<bit-table *ngIf="secrets?.length >= 1">
<ng-container header>
<tr>
<th bitCell class="tw-w-0">
<label class="!tw-mb-0 tw-flex tw-w-fit tw-gap-2 !tw-font-bold !tw-text-muted">
<input
type="checkbox"
(change)="$event ? toggleAll() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
/>
{{ "all" | i18n }}
</label>
</th>
<th bitCell colspan="2">{{ "name" | i18n }}</th>
<th bitCell>{{ "projects" | i18n }}</th>
<th bitCell>{{ "lastEdited" | i18n }}</th>
<th bitCell class="tw-w-0">
<button
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[title]="'options' | i18n"
[attr.aria-label]="'options' | i18n"
[bitMenuTriggerFor]="tableMenu"
></button>
</th>
</tr>
</ng-container>
<ng-container body>
<tr bitRow *ngFor="let secret of secrets">
<td bitCell>
<input
type="checkbox"
(change)="$event ? selection.toggle(secret.id) : null"
[checked]="selection.isSelected(secret.id)"
/>
</td>
<td bitCell class="tw-w-0 tw-pr-0">
<i class="bwi bwi-key tw-text-xl tw-text-muted" aria-hidden="true"></i>
</td>
<td bitCell>{{ secret.name }}</td>
<td bitCell>
<span
*ngFor="let project of secret.projects"
bitBadge
badgeType="secondary"
class="tw-ml-1"
>
{{ project.name }}
</span>
</td>
<td bitCell>{{ secret.revisionDate | date: "medium" }}</td>
<td bitCell>
<button
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[title]="'options' | i18n"
[attr.aria-label]="'options' | i18n"
[bitMenuTriggerFor]="secretMenu"
></button>
</td>
<bit-menu #secretMenu>
<button type="button" bitMenuItem (click)="editSecretEvent.emit(secret.id)">
<i class="bwi bwi-fw bwi-pencil tw-text-xl" aria-hidden="true"></i>
{{ "editSecret" | i18n }}
</button>
<button type="button" bitMenuItem (click)="copySecretNameEvent.emit(secret.id)">
<i class="bwi bwi-fw bwi-clone tw-text-xl" aria-hidden="true"></i>
{{ "copySecretName" | i18n }}
</button>
<button type="button" bitMenuItem (click)="copySecretValueEvent.emit(secret.id)">
<i class="bwi bwi-fw bwi-clone tw-text-xl" aria-hidden="true"></i>
{{ "copySecretValue" | i18n }}
</button>
<button type="button" bitMenuItem (click)="projectsEvent.emit(secret.id)">
<i class="bwi bwi-fw bwi-sitemap tw-text-xl" aria-hidden="true"></i>
{{ "projects" | i18n }}
</button>
<button type="button" bitMenuItem (click)="deleteSecretsEvent.emit([secret.id])">
<i class="bwi bwi-fw bwi-trash tw-text-xl tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">{{ "deleteSecret" | i18n }}</span>
</button>
</bit-menu>
</tr>
</ng-container>
</bit-table>
<bit-menu #tableMenu>
<button type="button" bitMenuItem>
<i class="bwi bwi-fw bwi-sitemap tw-text-xl" aria-hidden="true"></i>
{{ "projects" | i18n }}
</button>
<button type="button" bitMenuItem (click)="bulkDeleteSecrets()">
<i class="bwi bwi-fw bwi-trash tw-text-xl tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">{{ "deleteSecrets" | i18n }}</span>
</button>
</bit-menu>

View File

@ -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<string>();
@Output() copySecretNameEvent = new EventEmitter<string>();
@Output() copySecretValueEvent = new EventEmitter<string>();
@Output() projectsEvent = new EventEmitter<string>();
@Output() onSecretCheckedEvent = new EventEmitter<string[]>();
@Output() deleteSecretsEvent = new EventEmitter<string[]>();
@Output() newSecretEvent = new EventEmitter();
@Output() importSecretsEvent = new EventEmitter();
private destroy$: Subject<void> = new Subject<void>();
selection = new SelectionModel<string>(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);
}
}
}

View File

@ -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 {}

Some files were not shown because too many files have changed in this diff Show More