mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-21 11:35:34 +01:00
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:
parent
43945cd05d
commit
dffef8ac17
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -11,6 +11,7 @@
|
||||
},
|
||||
"flags": {
|
||||
"showTrial": true,
|
||||
"secretsManager": true,
|
||||
"showPasswordless": true
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@
|
||||
},
|
||||
"flags": {
|
||||
"showTrial": true,
|
||||
"secretsManager": false,
|
||||
"showPasswordless": true
|
||||
}
|
||||
}
|
||||
|
@ -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: [],
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
/* eslint-disable-next-line @typescript-eslint/ban-types */
|
||||
export type Flags = {
|
||||
showTrial?: boolean;
|
||||
secretsManager?: boolean;
|
||||
showPasswordless?: boolean;
|
||||
} & SharedFlags;
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -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>
|
@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
<i class="bwi bwi-fw bwi-filter tw-text-2xl"></i>
|
@ -0,0 +1,7 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "sm-filter",
|
||||
templateUrl: "./filter.component.html",
|
||||
})
|
||||
export class FilterComponent {}
|
@ -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>
|
@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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({});
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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>
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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>
|
@ -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
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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[];
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
|
||||
export class SecretProjectView implements View {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 {}
|
@ -0,0 +1 @@
|
||||
<sm-header></sm-header>
|
@ -0,0 +1,7 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "sm-overview",
|
||||
templateUrl: "./overview.component.html",
|
||||
})
|
||||
export class OverviewComponent {}
|
@ -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 {}
|
@ -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>
|
@ -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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
|
||||
export class ProjectRequest {
|
||||
name: EncString;
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -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>
|
@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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))
|
||||
);
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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 {}
|
@ -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>
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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>
|
@ -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));
|
||||
};
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export class SecretRequest {
|
||||
key: string;
|
||||
value: string;
|
||||
note: string;
|
||||
projectId?: string;
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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>
|
@ -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";
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
<sm-access-list
|
||||
[tokens]="accessTokens$ | async"
|
||||
(newAccessTokenEvent)="openNewAccessTokenDialog()"
|
||||
></sm-access-list>
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@ -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>
|
@ -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();
|
||||
}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
|
||||
export class AccessTokenRequest {
|
||||
name: EncString;
|
||||
encryptedPayload: EncString;
|
||||
key: EncString;
|
||||
expireAt: Date;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { EncString } from "@bitwarden/common/models/domain/enc-string";
|
||||
|
||||
export class ServiceAccountRequest {
|
||||
name: EncString;
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
@ -0,0 +1,7 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "sm-service-account",
|
||||
templateUrl: "./service-account.component.html",
|
||||
})
|
||||
export class ServiceAccountComponent {}
|
@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -0,0 +1,5 @@
|
||||
<sm-header></sm-header>
|
||||
<sm-service-accounts-list
|
||||
[serviceAccounts]="serviceAccounts$ | async"
|
||||
(newServiceAccountEvent)="openNewServiceAccountDialog()"
|
||||
></sm-service-accounts-list>
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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
Loading…
Reference in New Issue
Block a user