diff --git a/backend/src/entities/user-secret/application/data-structures/found-secret.ds.ts b/backend/src/entities/user-secret/application/data-structures/found-secret.ds.ts index 8e119df6..fbd6320c 100644 --- a/backend/src/entities/user-secret/application/data-structures/found-secret.ds.ts +++ b/backend/src/entities/user-secret/application/data-structures/found-secret.ds.ts @@ -1,7 +1,6 @@ export class FoundSecretDS { id: string; slug: string; - value?: string; companyId: string; createdAt: Date; updatedAt: Date; diff --git a/backend/src/entities/user-secret/application/data-structures/get-secret.ds.ts b/backend/src/entities/user-secret/application/data-structures/get-secret.ds.ts index 9ad4fcd6..739d0d1c 100644 --- a/backend/src/entities/user-secret/application/data-structures/get-secret.ds.ts +++ b/backend/src/entities/user-secret/application/data-structures/get-secret.ds.ts @@ -1,5 +1,4 @@ export class GetSecretDS { userId: string; slug: string; - masterPassword?: string; } diff --git a/backend/src/entities/user-secret/application/dto/found-secret.dto.ts b/backend/src/entities/user-secret/application/dto/found-secret.dto.ts index 65d32668..5bd60eb5 100644 --- a/backend/src/entities/user-secret/application/dto/found-secret.dto.ts +++ b/backend/src/entities/user-secret/application/dto/found-secret.dto.ts @@ -15,14 +15,6 @@ export class FoundSecretDto { }) slug: string; - @ApiProperty({ - type: String, - required: false, - description: 'Decrypted secret value (only included when retrieving a specific secret)', - example: 'my-secret-value-123', - }) - value?: string; - @ApiProperty({ type: String, description: 'Company ID that owns this secret', diff --git a/backend/src/entities/user-secret/use-cases/get-secret-by-slug.use.case.ts b/backend/src/entities/user-secret/use-cases/get-secret-by-slug.use.case.ts index 3e73793d..1091a4b9 100644 --- a/backend/src/entities/user-secret/use-cases/get-secret-by-slug.use.case.ts +++ b/backend/src/entities/user-secret/use-cases/get-secret-by-slug.use.case.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, GoneException, Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +import { GoneException, Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; import AbstractUseCase from '../../../common/abstract-use.case.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; @@ -6,8 +6,6 @@ import { GetSecretDS } from '../application/data-structures/get-secret.ds.js'; import { FoundSecretDS } from '../application/data-structures/found-secret.ds.js'; import { IGetSecretBySlug } from './user-secret-use-cases.interface.js'; import { buildFoundSecretDS } from '../utils/build-found-secret.ds.js'; -import { Encryptor } from '../../../helpers/encryption/encryptor.js'; -import { SecretActionEnum } from '../../secret-access-log/secret-access-log.entity.js'; import { Messages } from '../../../exceptions/text/messages.js'; @Injectable({ scope: Scope.REQUEST }) @@ -20,7 +18,7 @@ export class GetSecretBySlugUseCase extends AbstractUseCase { - const { userId, slug, masterPassword } = inputData; + const { userId, slug } = inputData; const user = await this._dbContext.userRepository.findOne({ where: { id: userId }, @@ -41,35 +39,6 @@ export class GetSecretBySlugUseCase extends AbstractUseCase { + async getSecretBySlug(@UserId() userId: string, @Param('slug') slug: string): Promise { const foundSecret = await this.getSecretBySlugUseCase.execute({ userId, slug, - masterPassword, }); return buildFoundSecretDto(foundSecret); } diff --git a/backend/src/entities/user-secret/utils/build-created-secret.dto.ts b/backend/src/entities/user-secret/utils/build-created-secret.dto.ts index fdfbdc9d..2e92ca02 100644 --- a/backend/src/entities/user-secret/utils/build-created-secret.dto.ts +++ b/backend/src/entities/user-secret/utils/build-created-secret.dto.ts @@ -5,7 +5,6 @@ export function buildCreatedSecretDto(ds: CreatedSecretDS): FoundSecretDto { return { id: ds.id, slug: ds.slug, - value: undefined, companyId: ds.companyId, createdAt: ds.createdAt, updatedAt: ds.updatedAt, diff --git a/backend/src/entities/user-secret/utils/build-found-secret.ds.ts b/backend/src/entities/user-secret/utils/build-found-secret.ds.ts index 898800d6..d51fc57c 100644 --- a/backend/src/entities/user-secret/utils/build-found-secret.ds.ts +++ b/backend/src/entities/user-secret/utils/build-found-secret.ds.ts @@ -1,11 +1,10 @@ import { FoundSecretDS } from '../application/data-structures/found-secret.ds.js'; import { UserSecretEntity } from '../user-secret.entity.js'; -export function buildFoundSecretDS(secret: UserSecretEntity, decryptedValue?: string): FoundSecretDS { +export function buildFoundSecretDS(secret: UserSecretEntity): FoundSecretDS { return { id: secret.id, slug: secret.slug, - value: decryptedValue, companyId: secret.companyId, createdAt: secret.createdAt, updatedAt: secret.updatedAt, diff --git a/backend/src/entities/user-secret/utils/build-found-secret.dto.ts b/backend/src/entities/user-secret/utils/build-found-secret.dto.ts index 81504154..eb542c24 100644 --- a/backend/src/entities/user-secret/utils/build-found-secret.dto.ts +++ b/backend/src/entities/user-secret/utils/build-found-secret.dto.ts @@ -5,7 +5,6 @@ export function buildFoundSecretDto(ds: FoundSecretDS): FoundSecretDto { return { id: ds.id, slug: ds.slug, - value: ds.value, companyId: ds.companyId, createdAt: ds.createdAt, updatedAt: ds.updatedAt, diff --git a/backend/src/entities/user-secret/utils/build-updated-secret.dto.ts b/backend/src/entities/user-secret/utils/build-updated-secret.dto.ts index 3fd9dce0..6826ca3e 100644 --- a/backend/src/entities/user-secret/utils/build-updated-secret.dto.ts +++ b/backend/src/entities/user-secret/utils/build-updated-secret.dto.ts @@ -5,7 +5,6 @@ export function buildUpdatedSecretDto(ds: UpdatedSecretDS): FoundSecretDto { return { id: ds.id, slug: ds.slug, - value: undefined, companyId: ds.companyId, createdAt: ds.createdAt, updatedAt: ds.updatedAt, diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-secrets-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-secrets-e2e.test.ts index 34b73191..4c580da7 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-secrets-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-secrets-e2e.test.ts @@ -197,7 +197,7 @@ test.serial(`${currentTest}?search=test - should filter secrets by slug`, async }); currentTest = 'GET /secrets/:slug'; -test.serial(`${currentTest} - should return secret with value`, async (t) => { +test.serial(`${currentTest} - should return secret metadata without value`, async (t) => { // Create user with a secret to retrieve const { token } = await setupUserWithSecrets([{ slug: 'get-test-api-key', value: 'sk-get-test-value' }]); @@ -210,8 +210,7 @@ test.serial(`${currentTest} - should return secret with value`, async (t) => { t.is(response.status, 200, response.text); const responseBody = JSON.parse(response.text); t.is(responseBody.slug, 'get-test-api-key'); - t.truthy(responseBody.value); - t.truthy(responseBody.lastAccessedAt); + t.falsy(responseBody.value); }); test.serial(`${currentTest} - should return 404 for non-existent secret`, async (t) => { @@ -252,30 +251,34 @@ test.serial(`${currentTest} - should create secret with master password`, async }); currentTest = 'GET /secrets/:slug'; -test.serial(`${currentTest} - should require master password for protected secret`, async (t) => { +test.serial(`${currentTest} - should return protected secret metadata without master password`, async (t) => { // Create user with a protected secret const { token } = await setupUserWithSecrets([ - { slug: 'protected-secret-403', value: 'sensitive-data', masterEncryption: true, masterPassword: 'SecretPass123!' }, + { slug: 'protected-secret-200', value: 'sensitive-data', masterEncryption: true, masterPassword: 'SecretPass123!' }, ]); - // Try to access without master password + // Access without master password should return metadata (no value is returned anyway) const response = await request(app.getHttpServer()) - .get('/secrets/protected-secret-403') + .get('/secrets/protected-secret-200') .set('Cookie', token) .set('Content-Type', 'application/json') .set('Accept', 'application/json'); - t.is(response.status, 403, response.text); + t.is(response.status, 200, response.text); + const responseBody = JSON.parse(response.text); + t.is(responseBody.slug, 'protected-secret-200'); + t.falsy(responseBody.value); + t.is(responseBody.masterEncryption, true); }); -test.serial(`${currentTest} - should return protected secret with correct master password`, async (t) => { +test.serial(`${currentTest} - should return protected secret metadata with master password (no value returned)`, async (t) => { // Create user with a protected secret const { token } = await setupUserWithSecrets([ - { slug: 'protected-secret-200', value: 'sensitive-data', masterEncryption: true, masterPassword: 'MasterPass123!' }, + { slug: 'protected-secret-with-pwd', value: 'sensitive-data', masterEncryption: true, masterPassword: 'MasterPass123!' }, ]); const response = await request(app.getHttpServer()) - .get('/secrets/protected-secret-200') + .get('/secrets/protected-secret-with-pwd') .set('Cookie', token) .set('masterpwd', 'MasterPass123!') .set('Content-Type', 'application/json') @@ -283,8 +286,8 @@ test.serial(`${currentTest} - should return protected secret with correct master t.is(response.status, 200, response.text); const responseBody = JSON.parse(response.text); - t.is(responseBody.slug, 'protected-secret-200'); - t.truthy(responseBody.value); + t.is(responseBody.slug, 'protected-secret-with-pwd'); + t.falsy(responseBody.value); }); currentTest = 'PUT /secrets/:slug'; diff --git a/frontend/buildspec.yml b/frontend/buildspec.yml deleted file mode 100644 index ac0a88ec..00000000 --- a/frontend/buildspec.yml +++ /dev/null @@ -1,34 +0,0 @@ -version: 0.2 -env: - variables: - S3_BUCKET: "autoadmin-front-v2" -APP_NAME: "dissendium" -BUILD_ENV : "prod" -phases: - install: - commands: - - n 16 - - yarn global add @angular/cli - # Install node dependancies. - - cd frontend && yarn install - - echo '#!/bin/bash' > /usr/local/bin/ok; echo 'if [[ "$CODEBUILD_BUILD_SUCCEEDING" == "0" ]]; then exit 1; else exit 0; fi' >> /usr/local/bin/ok; chmod +x /usr/local/bin/ok - build: - commands: - # Builds Angular application. You can also build using custom environment here like mock or staging - - echo Build started on `date` - - ng build --configuration=saas - - aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/o1a6p9d1 - - docker build -t frontend . - - docker tag frontend:latest public.ecr.aws/o1a6p9d1/frontend:latest - - docker push public.ecr.aws/o1a6p9d1/frontend:latest - - post_build: - commands: - # Clear S3 bucket. - - ok && aws s3 rm s3://${S3_BUCKET} --recursive - - echo S3 bucket is cleared. - # Copy dist folder to S3 bucket, As of Angular 6, builds are stored inside an app folder in distribution and not at the root of the dist folder - - ok && aws s3 cp --acl=public-read dist s3://${S3_BUCKET}/${APP_NAME} --recursive - - ok && aws cloudfront create-invalidation --distribution-id=E1OBEHQ0QC01GL "--paths=/*" - - echo Build completed on `date` - diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 4b6e4f98..882bf4b4 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -1,69 +1,42 @@ import { RouterModule, Routes } from '@angular/router'; -import { AuditComponent } from './components/audit/audit.component'; import { AuthGuard } from './auth.guard'; -import { CompanyComponent } from './components/company/company.component'; -import { CompanyMemberInvitationComponent } from './components/company-member-invitation/company-member-invitation.component'; -import { ConnectDBComponent } from './components/connect-db/connect-db.component' -import { ConnectionSettingsComponent } from './components/connection-settings/connection-settings.component'; -import { ConnectionsListComponent } from './components/connections-list/connections-list.component'; -import { DashboardComponent } from './components/dashboard/dashboard.component' -import { DbTableActionsComponent } from './components/dashboard/db-table-view/db-table-actions/db-table-actions.component'; -import { DbTableRowEditComponent } from './components/db-table-row-edit/db-table-row-edit.component'; -import { DbTableSettingsComponent } from './components/dashboard/db-table-view/db-table-settings/db-table-settings.component'; -import { DbTableWidgetsComponent } from './components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component'; -import { EmailChangeComponent } from './components/email-change/email-change.component'; -import { EmailVerificationComponent } from './components/email-verification/email-verification.component'; -import { LoginComponent } from './components/login/login.component'; import { NgModule } from '@angular/core'; -import { PageLoaderComponent } from './components/page-loader/page-loader.component'; -import { PageNotFoundComponent } from './components/page-not-found/page-not-found.component'; -import { PasswordChangeComponent } from './components/password-change/password-change.component'; -import { PasswordRequestComponent } from './components/password-request/password-request.component'; -import { PasswordResetComponent } from './components/password-reset/password-reset.component'; -import { PaymentFormComponent } from './components/payment-form/payment-form.component'; -import { RegistrationComponent } from './components/registration/registration.component'; -import { UpgradeComponent } from './components/upgrade/upgrade.component'; -import { UpgradeSuccessComponent } from './components/upgrade-success/upgrade-success.component'; -import { UserDeletedSuccessComponent } from './components/user-deleted-success/user-deleted-success.component'; -import { UserSettingsComponent } from './components/user-settings/user-settings.component'; -import { UsersComponent } from './components/users/users.component'; -import { ZapierComponent } from './components/zapier/zapier.component'; -import { SsoComponent } from './components/sso/sso.component'; const routes: Routes = [ {path: '', redirectTo: '/connections-list', pathMatch: 'full'}, - {path: 'loader', component: PageLoaderComponent}, - {path: 'registration', component: RegistrationComponent, title: 'Sign up | Rocketadmin'}, - {path: 'login', component: LoginComponent, title: 'Login | Rocketadmin'}, - {path: 'forget-password', component: PasswordRequestComponent, title: 'Request password | Rocketadmin'}, - {path: 'external/user/password/reset/verify/:verification-token', component: PasswordResetComponent, title: 'Reset password | Rocketadmin'}, - {path: 'external/user/email/verify/:verification-token', component: EmailVerificationComponent, title: 'Email verification | Rocketadmin'}, - {path: 'external/user/email/change/verify/:change-token', component: EmailChangeComponent, title: 'Email updating | Rocketadmin'}, - {path: 'deleted', component: UserDeletedSuccessComponent, title: 'User deleted | Rocketadmin'}, - {path: 'connect-db', component: ConnectDBComponent, canActivate: [AuthGuard]}, - {path: 'connections-list', component: ConnectionsListComponent, canActivate: [AuthGuard]}, - {path: 'user-settings', component: UserSettingsComponent, canActivate: [AuthGuard]}, + {path: 'loader', loadComponent: () => import('./components/page-loader/page-loader.component').then(m => m.PageLoaderComponent)}, + {path: 'registration', loadChildren: () => import('./routes/registration.routes').then(m => m.REGISTRATION_ROUTES)}, + {path: 'login', loadComponent: () => import('./components/login/login.component').then(m => m.LoginComponent), title: 'Login | Rocketadmin'}, + {path: 'forget-password', loadComponent: () => import('./components/password-request/password-request.component').then(m => m.PasswordRequestComponent), title: 'Request password | Rocketadmin'}, + {path: 'external/user/password/reset/verify/:verification-token', loadChildren: () => import('./routes/password-reset.routes').then(m => m.PASSWORD_RESET_ROUTES)}, + {path: 'external/user/email/verify/:verification-token', loadComponent: () => import('./components/email-verification/email-verification.component').then(m => m.EmailVerificationComponent), title: 'Email verification | Rocketadmin'}, + {path: 'external/user/email/change/verify/:change-token', loadComponent: () => import('./components/email-change/email-change.component').then(m => m.EmailChangeComponent), title: 'Email updating | Rocketadmin'}, + {path: 'deleted', loadComponent: () => import('./components/user-deleted-success/user-deleted-success.component').then(m => m.UserDeletedSuccessComponent), title: 'User deleted | Rocketadmin'}, + {path: 'connect-db', loadComponent: () => import('./components/connect-db/connect-db.component').then(m => m.ConnectDBComponent), canActivate: [AuthGuard]}, + {path: 'connections-list', loadComponent: () => import('./components/connections-list/connections-list.component').then(m => m.ConnectionsListComponent), canActivate: [AuthGuard]}, + {path: 'user-settings', loadComponent: () => import('./components/user-settings/user-settings.component').then(m => m.UserSettingsComponent), canActivate: [AuthGuard]}, // company routes have to be in this specific order - {path: 'company/:company-id/verify/:verification-token', pathMatch: 'full', component: CompanyMemberInvitationComponent, title: 'Invitation | Rocketadmin'}, - {path: 'company', pathMatch: 'full', component: CompanyComponent, canActivate: [AuthGuard]}, - {path: 'sso/:company-id', pathMatch: 'full', component: SsoComponent, canActivate: [AuthGuard]}, - {path: 'change-password', component: PasswordChangeComponent, canActivate: [AuthGuard]}, - {path: 'upgrade', component: UpgradeComponent, canActivate: [AuthGuard], title: 'Upgrade | Rocketadmin'}, - {path: 'upgrade/payment', component: PaymentFormComponent, canActivate: [AuthGuard], title: 'Payment | Rocketadmin'}, - {path: 'subscription/success', component: UpgradeSuccessComponent, canActivate: [AuthGuard], title: 'Upgraded successfully | Rocketadmin'}, - {path: 'edit-db/:connection-id', component: ConnectDBComponent, canActivate: [AuthGuard]}, - {path: 'connection-settings/:connection-id', component: ConnectionSettingsComponent, canActivate: [AuthGuard]}, - {path: 'dashboard/:connection-id', component: DashboardComponent, canActivate: [AuthGuard]}, - {path: 'audit/:connection-id', component: AuditComponent, canActivate: [AuthGuard]}, - {path: 'dashboard/:connection-id/:table-name', pathMatch: 'full', component: DashboardComponent, canActivate: [AuthGuard]}, - {path: 'dashboard/:connection-id/:table-name/entry', pathMatch: 'full', component: DbTableRowEditComponent, canActivate: [AuthGuard]}, - {path: 'dashboard/:connection-id/:table-name/widgets', pathMatch: 'full', component: DbTableWidgetsComponent, canActivate: [AuthGuard]}, - {path: 'dashboard/:connection-id/:table-name/settings', pathMatch: 'full', component: DbTableSettingsComponent, canActivate: [AuthGuard]}, - {path: 'dashboard/:connection-id/:table-name/actions', pathMatch: 'full', component: DbTableActionsComponent, canActivate: [AuthGuard]}, - {path: 'permissions/:connection-id', component: UsersComponent, canActivate: [AuthGuard]}, - {path: 'zapier', component: ZapierComponent, canActivate: [AuthGuard]}, - {path: '**', component: PageNotFoundComponent}, + {path: 'company/:company-id/verify/:verification-token', pathMatch: 'full', loadChildren: () => import('./routes/company-invitation.routes').then(m => m.COMPANY_INVITATION_ROUTES)}, + {path: 'company', pathMatch: 'full', loadComponent: () => import('./components/company/company.component').then(m => m.CompanyComponent), canActivate: [AuthGuard]}, + {path: 'secrets', pathMatch: 'full', loadComponent: () => import('./components/secrets/secrets.component').then(m => m.SecretsComponent), canActivate: [AuthGuard], title: 'Secrets | Rocketadmin'}, + {path: 'sso/:company-id', pathMatch: 'full', loadComponent: () => import('./components/sso/sso.component').then(m => m.SsoComponent), canActivate: [AuthGuard]}, + {path: 'change-password', loadChildren: () => import('./routes/password-change.routes').then(m => m.PASSWORD_CHANGE_ROUTES)}, + {path: 'upgrade', loadComponent: () => import('./components/upgrade/upgrade.component').then(m => m.UpgradeComponent), canActivate: [AuthGuard], title: 'Upgrade | Rocketadmin'}, + {path: 'upgrade/payment', loadComponent: () => import('./components/payment-form/payment-form.component').then(m => m.PaymentFormComponent), canActivate: [AuthGuard], title: 'Payment | Rocketadmin'}, + {path: 'subscription/success', loadComponent: () => import('./components/upgrade-success/upgrade-success.component').then(m => m.UpgradeSuccessComponent), canActivate: [AuthGuard], title: 'Upgraded successfully | Rocketadmin'}, + {path: 'edit-db/:connection-id', loadComponent: () => import('./components/connect-db/connect-db.component').then(m => m.ConnectDBComponent), canActivate: [AuthGuard]}, + {path: 'connection-settings/:connection-id', loadComponent: () => import('./components/connection-settings/connection-settings.component').then(m => m.ConnectionSettingsComponent), canActivate: [AuthGuard]}, + {path: 'dashboard/:connection-id', loadComponent: () => import('./components/dashboard/dashboard.component').then(m => m.DashboardComponent), canActivate: [AuthGuard]}, + {path: 'audit/:connection-id', loadComponent: () => import('./components/audit/audit.component').then(m => m.AuditComponent), canActivate: [AuthGuard]}, + {path: 'dashboard/:connection-id/:table-name', pathMatch: 'full', loadComponent: () => import('./components/dashboard/dashboard.component').then(m => m.DashboardComponent), canActivate: [AuthGuard]}, + {path: 'dashboard/:connection-id/:table-name/entry', pathMatch: 'full', loadComponent: () => import('./components/db-table-row-edit/db-table-row-edit.component').then(m => m.DbTableRowEditComponent), canActivate: [AuthGuard]}, + {path: 'dashboard/:connection-id/:table-name/widgets', pathMatch: 'full', loadComponent: () => import('./components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component').then(m => m.DbTableWidgetsComponent), canActivate: [AuthGuard]}, + {path: 'dashboard/:connection-id/:table-name/settings', pathMatch: 'full', loadComponent: () => import('./components/dashboard/db-table-view/db-table-settings/db-table-settings.component').then(m => m.DbTableSettingsComponent), canActivate: [AuthGuard]}, + {path: 'dashboard/:connection-id/:table-name/actions', pathMatch: 'full', loadComponent: () => import('./components/dashboard/db-table-view/db-table-actions/db-table-actions.component').then(m => m.DbTableActionsComponent), canActivate: [AuthGuard]}, + {path: 'permissions/:connection-id', loadComponent: () => import('./components/users/users.component').then(m => m.UsersComponent), canActivate: [AuthGuard]}, + {path: 'zapier', loadComponent: () => import('./components/zapier/zapier.component').then(m => m.ZapierComponent), canActivate: [AuthGuard]}, + {path: '**', loadComponent: () => import('./components/page-not-found/page-not-found.component').then(m => m.PageNotFoundComponent)}, ]; @NgModule({ diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 7e19e03a..ac5197fb 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -36,6 +36,12 @@
Company
+ + + key + +
Secrets
+
help_outlined
Help center
@@ -170,6 +176,12 @@ Company
+ + + key + + Secrets + help_outlined Help center diff --git a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.css b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.css new file mode 100644 index 00000000..bd4b5d06 --- /dev/null +++ b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.css @@ -0,0 +1,176 @@ +h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: 8px; +} + +.title-icon { + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .title-icon { + color: rgba(255, 255, 255, 0.7); + } +} + +mat-dialog-content { + min-width: 600px; + max-height: 60vh; +} + +@media (width <= 800px) { + mat-dialog-content { + min-width: auto; + } +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + text-align: center; +} + +.loading-container p { + margin-top: 16px; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .loading-container p { + color: rgba(255, 255, 255, 0.54); + } +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; +} + +.empty-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: rgba(0, 0, 0, 0.26); + margin-bottom: 16px; +} + +@media (prefers-color-scheme: dark) { + .empty-icon { + color: rgba(255, 255, 255, 0.3); + } +} + +.empty-state h3 { + margin: 0 0 8px 0; +} + +.empty-state p { + margin: 0; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .empty-state p { + color: rgba(255, 255, 255, 0.54); + } +} + +.audit-log-table { + width: 100%; +} + +.action-cell { + display: flex; + align-items: center; + gap: 8px; +} + +.action-cell mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +.action-create { + color: #2e7d32; +} + +@media (prefers-color-scheme: dark) { + .action-create { + color: #81c784; + } +} + +.action-view, +.action-copy { + color: #1976d2; +} + +@media (prefers-color-scheme: dark) { + .action-view, + .action-copy { + color: #64b5f6; + } +} + +.action-update { + color: #ef6c00; +} + +@media (prefers-color-scheme: dark) { + .action-update { + color: #ffb74d; + } +} + +.action-delete { + color: #c62828; +} + +@media (prefers-color-scheme: dark) { + .action-delete { + color: #ef5350; + } +} + +.user-email { + font-size: 14px; +} + +.success-icon { + color: #2e7d32; +} + +@media (prefers-color-scheme: dark) { + .success-icon { + color: #81c784; + } +} + +.failure-icon { + color: #c62828; +} + +@media (prefers-color-scheme: dark) { + .failure-icon { + color: #ef5350; + } +} + +.failed-row { + background-color: rgba(198, 40, 40, 0.05); +} + +@media (prefers-color-scheme: dark) { + .failed-row { + background-color: rgba(239, 83, 80, 0.1); + } +} diff --git a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.html b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.html new file mode 100644 index 00000000..ac68a809 --- /dev/null +++ b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.html @@ -0,0 +1,73 @@ +

+ history + Audit Log: {{data.secret.slug}} +

+ + + +
+ +

Loading audit log...

+
+ + +
+ history_toggle_off +

No audit log entries

+

No actions have been recorded for this secret yet.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Action +
+ {{actionIcons[log.action]}} + {{actionLabels[log.action]}} +
+
User + {{log.user.email}} + Time + {{log.accessedAt | date:'medium'}} + Status + check_circle + error +
+ + + +
+ + + + diff --git a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.spec.ts b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.spec.ts new file mode 100644 index 00000000..cbb09b9e --- /dev/null +++ b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.spec.ts @@ -0,0 +1,311 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { Angulartics2Module } from 'angulartics2'; +import { of } from 'rxjs'; +import { PageEvent } from '@angular/material/paginator'; + +import { AuditLogDialogComponent } from './audit-log-dialog.component'; +import { SecretsService } from 'src/app/services/secrets.service'; +import { Secret, AuditLogEntry, AuditLogResponse } from 'src/app/models/secret'; + +describe('AuditLogDialogComponent', () => { + let component: AuditLogDialogComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj>; + + const mockSecret: Secret = { + id: '1', + slug: 'test-secret', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + }; + + const mockAuditLogEntry: AuditLogEntry = { + id: '1', + action: 'create', + user: { id: '1', email: 'user@example.com' }, + accessedAt: '2024-01-01T00:00:00Z', + success: true, + }; + + const createMockAuditLogResponse = (): AuditLogResponse => ({ + data: [mockAuditLogEntry], + pagination: { total: 1, currentPage: 1, perPage: 20, lastPage: 1 }, + }); + + const createMockMultipleLogsResponse = (): AuditLogResponse => ({ + data: [ + mockAuditLogEntry, + { + id: '2', + action: 'view', + user: { id: '2', email: 'viewer@example.com' }, + accessedAt: '2024-01-02T00:00:00Z', + success: true, + }, + { + id: '3', + action: 'update', + user: { id: '1', email: 'user@example.com' }, + accessedAt: '2024-01-03T00:00:00Z', + success: true, + }, + { + id: '4', + action: 'copy', + user: { id: '3', email: 'copier@example.com' }, + accessedAt: '2024-01-04T00:00:00Z', + success: true, + }, + { + id: '5', + action: 'delete', + user: { id: '1', email: 'user@example.com' }, + accessedAt: '2024-01-05T00:00:00Z', + success: false, + errorMessage: 'Deletion failed', + }, + ], + pagination: { total: 5, currentPage: 1, perPage: 20, lastPage: 1 }, + }); + + beforeEach(async () => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['getAuditLog']); + mockSecretsService.getAuditLog.and.callFake(() => of(createMockAuditLogResponse())); + + mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [ + AuditLogDialogComponent, + BrowserAnimationsModule, + MatSnackBarModule, + Angulartics2Module.forRoot(), + ], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: SecretsService, useValue: mockSecretsService }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: { secret: mockSecret } }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(AuditLogDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('component initialization', () => { + it('should load audit log on init', () => { + expect(mockSecretsService.getAuditLog).toHaveBeenCalledWith('test-secret', 1, 20); + }); + + it('should display audit log entries', () => { + expect(component.logs.length).toBe(1); + expect(component.logs[0].action).toBe('create'); + }); + + it('should initialize with default pagination', () => { + expect(component.pagination).toEqual({ + total: 1, + currentPage: 1, + perPage: 20, + lastPage: 1 + }); + }); + + it('should initialize loading as false after load', () => { + expect(component.loading).toBeFalse(); + }); + + it('should have correct displayed columns', () => { + expect(component.displayedColumns).toEqual(['action', 'user', 'accessedAt', 'success']); + }); + }); + + describe('action labels', () => { + it('should have label for create action', () => { + expect(component.actionLabels['create']).toBe('Created'); + }); + + it('should have label for view action', () => { + expect(component.actionLabels['view']).toBe('Viewed'); + }); + + it('should have label for copy action', () => { + expect(component.actionLabels['copy']).toBe('Copied'); + }); + + it('should have label for update action', () => { + expect(component.actionLabels['update']).toBe('Updated'); + }); + + it('should have label for delete action', () => { + expect(component.actionLabels['delete']).toBe('Deleted'); + }); + }); + + describe('action icons', () => { + it('should have icon for create action', () => { + expect(component.actionIcons['create']).toBe('add_circle'); + }); + + it('should have icon for view action', () => { + expect(component.actionIcons['view']).toBe('visibility'); + }); + + it('should have icon for copy action', () => { + expect(component.actionIcons['copy']).toBe('content_copy'); + }); + + it('should have icon for update action', () => { + expect(component.actionIcons['update']).toBe('edit'); + }); + + it('should have icon for delete action', () => { + expect(component.actionIcons['delete']).toBe('delete'); + }); + }); + + describe('action colors', () => { + it('should have color for create action', () => { + expect(component.actionColors['create']).toBe('primary'); + }); + + it('should have color for view action', () => { + expect(component.actionColors['view']).toBe('accent'); + }); + + it('should have color for copy action', () => { + expect(component.actionColors['copy']).toBe('accent'); + }); + + it('should have color for update action', () => { + expect(component.actionColors['update']).toBe('primary'); + }); + + it('should have color for delete action', () => { + expect(component.actionColors['delete']).toBe('warn'); + }); + }); + + describe('loadAuditLog', () => { + it('should set loading to true while fetching', () => { + component.loading = false; + mockSecretsService.getAuditLog.and.callFake(() => of(createMockAuditLogResponse())); + + component.loadAuditLog(); + + expect(component.loading).toBeFalse(); + }); + + it('should update logs on successful fetch', () => { + mockSecretsService.getAuditLog.and.callFake(() => of(createMockMultipleLogsResponse())); + + component.loadAuditLog(); + + expect(component.logs.length).toBe(5); + }); + + it('should update pagination on successful fetch', () => { + mockSecretsService.getAuditLog.and.callFake(() => of(createMockMultipleLogsResponse())); + + component.loadAuditLog(); + + expect(component.pagination.total).toBe(5); + }); + + it('should call getAuditLog with current pagination', () => { + mockSecretsService.getAuditLog.calls.reset(); + component.pagination.currentPage = 2; + component.pagination.perPage = 10; + + component.loadAuditLog(); + + expect(mockSecretsService.getAuditLog).toHaveBeenCalledWith('test-secret', 2, 10); + }); + }); + + describe('onPageChange', () => { + it('should update pagination and reload audit log', () => { + const pageEvent: PageEvent = { + pageIndex: 2, + pageSize: 50, + length: 100 + }; + + // Update mock to return pagination matching the page change + mockSecretsService.getAuditLog.and.callFake(() => of({ + data: [mockAuditLogEntry], + pagination: { total: 100, currentPage: 3, perPage: 50, lastPage: 2 } + })); + mockSecretsService.getAuditLog.calls.reset(); + component.onPageChange(pageEvent); + + expect(component.pagination.currentPage).toBe(3); + expect(component.pagination.perPage).toBe(50); + expect(mockSecretsService.getAuditLog).toHaveBeenCalledWith('test-secret', 3, 50); + }); + + it('should handle first page correctly', () => { + const pageEvent: PageEvent = { + pageIndex: 0, + pageSize: 20, + length: 100 + }; + + mockSecretsService.getAuditLog.calls.reset(); + component.onPageChange(pageEvent); + + expect(component.pagination.currentPage).toBe(1); + }); + }); + + describe('with multiple audit log entries', () => { + beforeEach(() => { + mockSecretsService.getAuditLog.and.callFake(() => of(createMockMultipleLogsResponse())); + component.loadAuditLog(); + }); + + it('should display all entries', () => { + expect(component.logs.length).toBe(5); + }); + + it('should include failed actions', () => { + const failedAction = component.logs.find(log => !log.success); + expect(failedAction).toBeTruthy(); + expect(failedAction?.errorMessage).toBe('Deletion failed'); + }); + + it('should include all action types', () => { + const actions = component.logs.map(log => log.action); + expect(actions).toContain('create'); + expect(actions).toContain('view'); + expect(actions).toContain('update'); + expect(actions).toContain('copy'); + expect(actions).toContain('delete'); + }); + }); + + describe('data binding', () => { + it('should have access to secret data', () => { + expect(component.data.secret).toEqual(mockSecret); + }); + + it('should have access to secret slug', () => { + expect(component.data.secret.slug).toBe('test-secret'); + }); + }); +}); diff --git a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.ts b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.ts new file mode 100644 index 00000000..ad93a75a --- /dev/null +++ b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.ts @@ -0,0 +1,96 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTableModule } from '@angular/material/table'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +import { SecretsService } from 'src/app/services/secrets.service'; +import { Secret, AuditLogEntry, SecretPagination } from 'src/app/models/secret'; + +@Component({ + selector: 'app-audit-log-dialog', + templateUrl: './audit-log-dialog.component.html', + styleUrls: ['./audit-log-dialog.component.css'], + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatTableModule, + MatPaginatorModule, + MatIconModule, + MatChipsModule, + MatProgressSpinnerModule, + MatTooltipModule, + ] +}) +export class AuditLogDialogComponent implements OnInit { + public logs: AuditLogEntry[] = []; + public pagination: SecretPagination = { + total: 0, + currentPage: 1, + perPage: 20, + lastPage: 1, + }; + public loading = true; + public displayedColumns = ['action', 'user', 'accessedAt', 'success']; + + public actionLabels: Record = { + create: 'Created', + view: 'Viewed', + copy: 'Copied', + update: 'Updated', + delete: 'Deleted', + }; + + public actionIcons: Record = { + create: 'add_circle', + view: 'visibility', + copy: 'content_copy', + update: 'edit', + delete: 'delete', + }; + + public actionColors: Record = { + create: 'primary', + view: 'accent', + copy: 'accent', + update: 'primary', + delete: 'warn', + }; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: { secret: Secret }, + private dialogRef: MatDialogRef, + private _secrets: SecretsService + ) {} + + ngOnInit(): void { + this.loadAuditLog(); + } + + loadAuditLog(): void { + this.loading = true; + this._secrets.getAuditLog( + this.data.secret.slug, + this.pagination.currentPage, + this.pagination.perPage + ).subscribe(response => { + if (response) { + this.logs = response.data; + this.pagination = response.pagination; + } + this.loading = false; + }); + } + + onPageChange(event: PageEvent): void { + this.pagination.currentPage = event.pageIndex + 1; + this.pagination.perPage = event.pageSize; + this.loadAuditLog(); + } +} diff --git a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.css b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.css new file mode 100644 index 00000000..8a7f2ee2 --- /dev/null +++ b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.css @@ -0,0 +1,43 @@ +.full-width { + width: 100%; + margin-bottom: 16px; +} + +.master-encryption-section { + margin-top: 8px; + padding: 16px; + background-color: rgba(0, 0, 0, 0.02); + border-radius: 8px; +} + +@media (prefers-color-scheme: dark) { + .master-encryption-section { + background-color: rgba(255, 255, 255, 0.05); + } +} + +.encryption-hint { + font-size: 12px; + color: rgba(0, 0, 0, 0.54); + margin: 8px 0 16px 0; +} + +@media (prefers-color-scheme: dark) { + .encryption-hint { + color: rgba(255, 255, 255, 0.54); + } +} + +mat-dialog-content { + min-width: 400px; +} + +@media (width <= 600px) { + mat-dialog-content { + min-width: auto; + } +} + +.secret-masked { + -webkit-text-security: disc; +} diff --git a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.html b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.html new file mode 100644 index 00000000..5b6ac3a4 --- /dev/null +++ b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.html @@ -0,0 +1,79 @@ +

Create Secret

+ + +
+ + Slug + + Unique identifier (letters, numbers, hyphens, underscores) + {{slugError}} + + + + Secret Value + + + {{valueError}} + + + + Expiration Date (Optional) + + + + Leave empty for no expiration + + +
+ + Enable master password encryption + +

+ Add an extra layer of security with a master password. + You'll need this password to view or edit the secret. +

+ + + Master Password + + + {{masterPasswordError}} + Remember this password - it cannot be recovered! + +
+
+
+ + + + + diff --git a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts new file mode 100644 index 00000000..b23ea0ac --- /dev/null +++ b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts @@ -0,0 +1,376 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { Angulartics2Module } from 'angulartics2'; +import { of, throwError } from 'rxjs'; + +import { CreateSecretDialogComponent } from './create-secret-dialog.component'; +import { SecretsService } from 'src/app/services/secrets.service'; +import { Secret } from 'src/app/models/secret'; + +describe('CreateSecretDialogComponent', () => { + let component: CreateSecretDialogComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj>; + + const mockSecret: Secret = { + id: '1', + slug: 'test', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + }; + + beforeEach(async () => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['createSecret']); + mockSecretsService.createSecret.and.returnValue(of(mockSecret)); + + mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [ + CreateSecretDialogComponent, + BrowserAnimationsModule, + MatSnackBarModule, + Angulartics2Module.forRoot(), + ], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: SecretsService, useValue: mockSecretsService }, + { provide: MatDialogRef, useValue: mockDialogRef }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(CreateSecretDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('form initialization', () => { + it('should have invalid form initially', () => { + expect(component.form.invalid).toBeTrue(); + }); + + it('should have all required form controls', () => { + expect(component.form.get('slug')).toBeTruthy(); + expect(component.form.get('value')).toBeTruthy(); + expect(component.form.get('expiresAt')).toBeTruthy(); + expect(component.form.get('masterEncryption')).toBeTruthy(); + expect(component.form.get('masterPassword')).toBeTruthy(); + }); + + it('should initialize with default values', () => { + expect(component.form.get('slug')?.value).toBe(''); + expect(component.form.get('value')?.value).toBe(''); + expect(component.form.get('expiresAt')?.value).toBeNull(); + expect(component.form.get('masterEncryption')?.value).toBeFalse(); + expect(component.form.get('masterPassword')?.value).toBe(''); + }); + + it('should initialize component properties', () => { + expect(component.submitting).toBeFalse(); + expect(component.showValue).toBeFalse(); + expect(component.showMasterPassword).toBeFalse(); + expect(component.minDate).toBeTruthy(); + }); + }); + + describe('slug validation', () => { + it('should require slug', () => { + const slugControl = component.form.get('slug'); + slugControl?.setValue(''); + expect(slugControl?.hasError('required')).toBeTrue(); + }); + + it('should validate slug pattern - reject spaces', () => { + const slugControl = component.form.get('slug'); + slugControl?.setValue('invalid slug'); + expect(slugControl?.hasError('pattern')).toBeTrue(); + }); + + it('should validate slug pattern - reject special characters', () => { + const slugControl = component.form.get('slug'); + slugControl?.setValue('invalid!slug'); + expect(slugControl?.hasError('pattern')).toBeTrue(); + + slugControl?.setValue('invalid@slug'); + expect(slugControl?.hasError('pattern')).toBeTrue(); + + slugControl?.setValue('invalid#slug'); + expect(slugControl?.hasError('pattern')).toBeTrue(); + }); + + it('should validate slug pattern - accept valid slugs', () => { + const slugControl = component.form.get('slug'); + + slugControl?.setValue('valid-slug'); + expect(slugControl?.hasError('pattern')).toBeFalse(); + + slugControl?.setValue('valid_slug'); + expect(slugControl?.hasError('pattern')).toBeFalse(); + + slugControl?.setValue('ValidSlug123'); + expect(slugControl?.hasError('pattern')).toBeFalse(); + + slugControl?.setValue('valid-slug_123'); + expect(slugControl?.hasError('pattern')).toBeFalse(); + }); + + it('should validate max length', () => { + const slugControl = component.form.get('slug'); + slugControl?.setValue('a'.repeat(256)); + expect(slugControl?.hasError('maxlength')).toBeTrue(); + + slugControl?.setValue('a'.repeat(255)); + expect(slugControl?.hasError('maxlength')).toBeFalse(); + }); + }); + + describe('value validation', () => { + it('should require value', () => { + const valueControl = component.form.get('value'); + valueControl?.setValue(''); + expect(valueControl?.hasError('required')).toBeTrue(); + }); + + it('should validate max length', () => { + const valueControl = component.form.get('value'); + valueControl?.setValue('a'.repeat(10001)); + expect(valueControl?.hasError('maxlength')).toBeTrue(); + + valueControl?.setValue('a'.repeat(10000)); + expect(valueControl?.hasError('maxlength')).toBeFalse(); + }); + }); + + describe('master encryption', () => { + it('should require master password when encryption is enabled', () => { + component.form.get('masterEncryption')?.setValue(true); + const masterPasswordControl = component.form.get('masterPassword'); + expect(masterPasswordControl?.hasError('required')).toBeTrue(); + }); + + it('should validate master password min length', () => { + component.form.get('masterEncryption')?.setValue(true); + const masterPasswordControl = component.form.get('masterPassword'); + + masterPasswordControl?.setValue('short'); + expect(masterPasswordControl?.hasError('minlength')).toBeTrue(); + + masterPasswordControl?.setValue('12345678'); + expect(masterPasswordControl?.hasError('minlength')).toBeFalse(); + }); + + it('should clear master password validators when encryption is disabled', () => { + component.form.get('masterEncryption')?.setValue(true); + component.form.get('masterPassword')?.setValue('password123'); + + component.form.get('masterEncryption')?.setValue(false); + + const masterPasswordControl = component.form.get('masterPassword'); + expect(masterPasswordControl?.value).toBe(''); + expect(masterPasswordControl?.valid).toBeTrue(); + }); + + it('should accept valid master password', () => { + component.form.get('masterEncryption')?.setValue(true); + const masterPasswordControl = component.form.get('masterPassword'); + + masterPasswordControl?.setValue('validpassword123'); + expect(masterPasswordControl?.valid).toBeTrue(); + }); + }); + + describe('error messages', () => { + it('should return correct slug error message for required', () => { + component.form.get('slug')?.setValue(''); + component.form.get('slug')?.markAsTouched(); + expect(component.slugError).toBe('Slug is required'); + }); + + it('should return correct slug error message for maxlength', () => { + component.form.get('slug')?.setValue('a'.repeat(256)); + expect(component.slugError).toBe('Slug must be 255 characters or less'); + }); + + it('should return correct slug error message for pattern', () => { + component.form.get('slug')?.setValue('invalid slug!'); + expect(component.slugError).toBe('Only letters, numbers, hyphens, and underscores allowed'); + }); + + it('should return correct value error message for required', () => { + component.form.get('value')?.setValue(''); + component.form.get('value')?.markAsTouched(); + expect(component.valueError).toBe('Value is required'); + }); + + it('should return correct value error message for maxlength', () => { + component.form.get('value')?.setValue('a'.repeat(10001)); + expect(component.valueError).toBe('Value must be 10000 characters or less'); + }); + + it('should return correct master password error for required', () => { + component.form.get('masterEncryption')?.setValue(true); + component.form.get('masterPassword')?.markAsTouched(); + expect(component.masterPasswordError).toBe('Master password is required for encryption'); + }); + + it('should return correct master password error for minlength', () => { + component.form.get('masterEncryption')?.setValue(true); + component.form.get('masterPassword')?.setValue('short'); + expect(component.masterPasswordError).toBe('Master password must be at least 8 characters'); + }); + }); + + describe('visibility toggles', () => { + it('should toggle value visibility', () => { + expect(component.showValue).toBeFalse(); + component.toggleValueVisibility(); + expect(component.showValue).toBeTrue(); + component.toggleValueVisibility(); + expect(component.showValue).toBeFalse(); + }); + + it('should toggle master password visibility', () => { + expect(component.showMasterPassword).toBeFalse(); + component.toggleMasterPasswordVisibility(); + expect(component.showMasterPassword).toBeTrue(); + component.toggleMasterPasswordVisibility(); + expect(component.showMasterPassword).toBeFalse(); + }); + }); + + describe('form submission', () => { + it('should not submit invalid form', () => { + component.onSubmit(); + expect(mockSecretsService.createSecret).not.toHaveBeenCalled(); + }); + + it('should submit valid form', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + component.onSubmit(); + expect(mockSecretsService.createSecret).toHaveBeenCalled(); + }); + + it('should submit form with correct basic payload', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + component.onSubmit(); + + expect(mockSecretsService.createSecret).toHaveBeenCalledWith(jasmine.objectContaining({ + slug: 'test-secret', + value: 'secret-value', + })); + }); + + it('should submit form with expiration date', () => { + const futureDate = new Date('2025-12-31'); + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + expiresAt: futureDate, + }); + + component.onSubmit(); + + expect(mockSecretsService.createSecret).toHaveBeenCalledWith(jasmine.objectContaining({ + slug: 'test-secret', + value: 'secret-value', + expiresAt: jasmine.any(String), + })); + }); + + it('should submit form with master encryption', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + masterEncryption: true, + masterPassword: 'password123', + }); + + component.onSubmit(); + + expect(mockSecretsService.createSecret).toHaveBeenCalledWith(jasmine.objectContaining({ + slug: 'test-secret', + value: 'secret-value', + masterEncryption: true, + masterPassword: 'password123', + })); + }); + + it('should set submitting to true during submission', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + expect(component.submitting).toBeFalse(); + component.onSubmit(); + }); + + it('should close dialog on successful submission', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + component.onSubmit(); + + expect(mockDialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should reset submitting on successful submission', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + component.onSubmit(); + + expect(component.submitting).toBeFalse(); + }); + + it('should reset submitting on error', () => { + mockSecretsService.createSecret.and.returnValue(throwError(() => new Error('Error'))); + + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + component.onSubmit(); + + expect(component.submitting).toBeFalse(); + }); + + it('should not include master password when encryption is disabled', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + masterEncryption: false, + }); + + component.onSubmit(); + + const callArgs = mockSecretsService.createSecret.calls.mostRecent().args[0]; + expect(callArgs.masterPassword).toBeUndefined(); + }); + }); +}); diff --git a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.ts b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.ts new file mode 100644 index 00000000..2b052589 --- /dev/null +++ b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.ts @@ -0,0 +1,130 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { provideNativeDateAdapter } from '@angular/material/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Angulartics2 } from 'angulartics2'; + +import { SecretsService } from 'src/app/services/secrets.service'; + +@Component({ + selector: 'app-create-secret-dialog', + templateUrl: './create-secret-dialog.component.html', + styleUrls: ['./create-secret-dialog.component.css'], + providers: [provideNativeDateAdapter()], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatCheckboxModule, + MatDatepickerModule, + MatIconModule, + MatTooltipModule, + ] +}) +export class CreateSecretDialogComponent { + public form: FormGroup; + public submitting = false; + public showValue = false; + public showMasterPassword = false; + public minDate = new Date(); + + constructor( + private fb: FormBuilder, + private dialogRef: MatDialogRef, + private _secrets: SecretsService, + private angulartics2: Angulartics2 + ) { + this.form = this.fb.group({ + slug: ['', [ + Validators.required, + Validators.maxLength(255), + Validators.pattern(/^[a-zA-Z0-9_-]+$/) + ]], + value: ['', [Validators.required, Validators.maxLength(10000)]], + expiresAt: [null], + masterEncryption: [false], + masterPassword: [''], + }); + + this.form.get('masterEncryption')?.valueChanges.subscribe(enabled => { + const masterPasswordControl = this.form.get('masterPassword'); + if (enabled) { + masterPasswordControl?.setValidators([Validators.required, Validators.minLength(8)]); + } else { + masterPasswordControl?.clearValidators(); + masterPasswordControl?.setValue(''); + } + masterPasswordControl?.updateValueAndValidity(); + }); + } + + get slugError(): string { + const control = this.form.get('slug'); + if (control?.hasError('required')) return 'Slug is required'; + if (control?.hasError('maxlength')) return 'Slug must be 255 characters or less'; + if (control?.hasError('pattern')) return 'Only letters, numbers, hyphens, and underscores allowed'; + return ''; + } + + get valueError(): string { + const control = this.form.get('value'); + if (control?.hasError('required')) return 'Value is required'; + if (control?.hasError('maxlength')) return 'Value must be 10000 characters or less'; + return ''; + } + + get masterPasswordError(): string { + const control = this.form.get('masterPassword'); + if (control?.hasError('required')) return 'Master password is required for encryption'; + if (control?.hasError('minlength')) return 'Master password must be at least 8 characters'; + return ''; + } + + toggleValueVisibility(): void { + this.showValue = !this.showValue; + } + + toggleMasterPasswordVisibility(): void { + this.showMasterPassword = !this.showMasterPassword; + } + + onSubmit(): void { + if (this.form.invalid) return; + + this.submitting = true; + const formValue = this.form.value; + + const payload = { + slug: formValue.slug, + value: formValue.value, + expiresAt: formValue.expiresAt ? new Date(formValue.expiresAt).toISOString() : undefined, + masterEncryption: formValue.masterEncryption || undefined, + masterPassword: formValue.masterEncryption ? formValue.masterPassword : undefined, + }; + + this._secrets.createSecret(payload).subscribe({ + next: () => { + this.angulartics2.eventTrack.next({ + action: 'Secrets: secret created successfully', + }); + this.submitting = false; + this.dialogRef.close(true); + }, + error: () => { + this.submitting = false; + } + }); + } +} diff --git a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.css b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.css new file mode 100644 index 00000000..88501b0c --- /dev/null +++ b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.css @@ -0,0 +1,46 @@ +.warning-container { + display: flex; + gap: 16px; + padding: 16px; + background-color: #fff3e0; + border-radius: 8px; +} + +@media (prefers-color-scheme: dark) { + .warning-container { + background-color: rgba(255, 152, 0, 0.15); + } +} + +.warning-icon { + font-size: 32px; + width: 32px; + height: 32px; + color: #ef6c00; + flex-shrink: 0; +} + +@media (prefers-color-scheme: dark) { + .warning-icon { + color: #ffb74d; + } +} + +.warning-content p { + margin: 0; +} + +.warning-content p:first-child { + margin-bottom: 8px; +} + +.warning-details { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .warning-details { + color: rgba(255, 255, 255, 0.54); + } +} diff --git a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.html b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.html new file mode 100644 index 00000000..aa82f371 --- /dev/null +++ b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.html @@ -0,0 +1,23 @@ +

Delete Secret

+ + +
+ warning +
+

Are you sure you want to delete the secret {{data.secret.slug}}?

+

+ This action cannot be undone. The secret value and all associated audit logs will be permanently removed. +

+
+
+
+ + + + + diff --git a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.spec.ts b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.spec.ts new file mode 100644 index 00000000..de5ef959 --- /dev/null +++ b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.spec.ts @@ -0,0 +1,153 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { Angulartics2Module } from 'angulartics2'; +import { of, throwError } from 'rxjs'; + +import { DeleteSecretDialogComponent } from './delete-secret-dialog.component'; +import { SecretsService } from 'src/app/services/secrets.service'; +import { Secret, DeleteSecretResponse } from 'src/app/models/secret'; + +describe('DeleteSecretDialogComponent', () => { + let component: DeleteSecretDialogComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj>; + + const mockSecret: Secret = { + id: '1', + slug: 'test-secret', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + }; + + const mockSecretWithEncryption: Secret = { + ...mockSecret, + masterEncryption: true, + }; + + const mockDeleteResponse: DeleteSecretResponse = { + message: 'Secret deleted successfully', + deletedAt: '2024-01-01T00:00:00Z', + }; + + const createComponent = async (secret: Secret = mockSecret) => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['deleteSecret']); + mockSecretsService.deleteSecret.and.returnValue(of(mockDeleteResponse)); + + mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [ + DeleteSecretDialogComponent, + BrowserAnimationsModule, + MatSnackBarModule, + Angulartics2Module.forRoot(), + ], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: SecretsService, useValue: mockSecretsService }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: { secret } }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(DeleteSecretDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + beforeEach(async () => { + await createComponent(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('component initialization', () => { + it('should initialize with submitting false', () => { + expect(component.submitting).toBeFalse(); + }); + + it('should have access to secret data', () => { + expect(component.data.secret).toEqual(mockSecret); + }); + + it('should have access to secret slug', () => { + expect(component.data.secret.slug).toBe('test-secret'); + }); + }); + + describe('onDelete', () => { + it('should call deleteSecret with correct slug', () => { + component.onDelete(); + expect(mockSecretsService.deleteSecret).toHaveBeenCalledWith('test-secret'); + }); + + it('should set submitting to true during deletion', () => { + expect(component.submitting).toBeFalse(); + component.onDelete(); + }); + + it('should close dialog with true after successful deletion', () => { + component.onDelete(); + expect(mockDialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should reset submitting after successful deletion', () => { + component.onDelete(); + expect(component.submitting).toBeFalse(); + }); + + it('should reset submitting on error', () => { + mockSecretsService.deleteSecret.and.returnValue(throwError(() => new Error('Error'))); + + component.onDelete(); + + expect(component.submitting).toBeFalse(); + }); + + it('should not close dialog on error', () => { + mockSecretsService.deleteSecret.and.returnValue(throwError(() => new Error('Error'))); + + component.onDelete(); + + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); + }); + + describe('with encrypted secret', () => { + beforeEach(async () => { + await TestBed.resetTestingModule(); + await createComponent(mockSecretWithEncryption); + }); + + it('should have access to encrypted secret data', () => { + expect(component.data.secret.masterEncryption).toBeTrue(); + }); + + it('should delete encrypted secret with correct slug', () => { + component.onDelete(); + expect(mockSecretsService.deleteSecret).toHaveBeenCalledWith('test-secret'); + }); + }); + + describe('dialog interactions', () => { + it('should only call deleteSecret once per click', () => { + component.onDelete(); + expect(mockSecretsService.deleteSecret).toHaveBeenCalledTimes(1); + }); + + it('should close dialog only once after successful deletion', () => { + component.onDelete(); + expect(mockDialogRef.close).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.ts b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.ts new file mode 100644 index 00000000..bab015df --- /dev/null +++ b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.ts @@ -0,0 +1,47 @@ +import { Component, Inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { Angulartics2 } from 'angulartics2'; + +import { SecretsService } from 'src/app/services/secrets.service'; +import { Secret } from 'src/app/models/secret'; + +@Component({ + selector: 'app-delete-secret-dialog', + templateUrl: './delete-secret-dialog.component.html', + styleUrls: ['./delete-secret-dialog.component.css'], + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + ] +}) +export class DeleteSecretDialogComponent { + public submitting = false; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: { secret: Secret }, + private dialogRef: MatDialogRef, + private _secrets: SecretsService, + private angulartics2: Angulartics2 + ) {} + + onDelete(): void { + this.submitting = true; + this._secrets.deleteSecret(this.data.secret.slug).subscribe({ + next: () => { + this.angulartics2.eventTrack.next({ + action: 'Secrets: secret deleted successfully', + }); + this.submitting = false; + this.dialogRef.close(true); + }, + error: () => { + this.submitting = false; + } + }); + } +} diff --git a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.css b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.css new file mode 100644 index 00000000..8471b462 --- /dev/null +++ b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.css @@ -0,0 +1,60 @@ +mat-dialog-content { + min-width: 400px; + min-height: 200px; +} + +@media (width <= 600px) { + mat-dialog-content { + min-width: auto; + } +} + +.full-width { + width: 100%; + margin-bottom: 16px; +} + +.info-note { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px 16px; + background-color: rgba(25, 118, 210, 0.08); + border-radius: 8px; + color: #1565c0; + font-size: 14px; + margin-bottom: 20px; +} + +.info-note mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + flex-shrink: 0; +} + +@media (prefers-color-scheme: dark) { + .info-note { + background-color: rgba(100, 181, 246, 0.15); + color: #64b5f6; + } +} + +.expiration-section { + margin-bottom: 16px; +} + +.master-password-section { + margin-top: 16px; +} + +.master-password-error { + color: #f44336; + font-size: 12px; + margin-top: -12px; + margin-bottom: 8px; +} + +.secret-masked { + -webkit-text-security: disc; +} diff --git a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.html b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.html new file mode 100644 index 00000000..bcd9e00f --- /dev/null +++ b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.html @@ -0,0 +1,75 @@ +

Edit Secret: {{data.secret.slug}}

+ + +
+

+ info + Enter a new value for this secret. The current value cannot be viewed for security reasons. +

+ + + New Secret Value + + + {{valueError}} + + +
+ + Expiration Date + + + + + + + Remove expiration date + +
+ +
+ + Master Password + + + Required to encrypt the new value + + {{masterPasswordError}} +
+
+
+ + + + + diff --git a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts new file mode 100644 index 00000000..4ca93068 --- /dev/null +++ b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts @@ -0,0 +1,345 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { Angulartics2Module } from 'angulartics2'; +import { of, throwError } from 'rxjs'; + +import { EditSecretDialogComponent } from './edit-secret-dialog.component'; +import { SecretsService } from 'src/app/services/secrets.service'; +import { Secret } from 'src/app/models/secret'; + +describe('EditSecretDialogComponent', () => { + let component: EditSecretDialogComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj>; + + const mockSecret: Secret = { + id: '1', + slug: 'test-secret', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + }; + + const mockSecretWithExpiration: Secret = { + ...mockSecret, + expiresAt: '2025-12-31T00:00:00Z', + }; + + const mockSecretWithEncryption: Secret = { + ...mockSecret, + masterEncryption: true, + }; + + const createComponent = async (secret: Secret = mockSecret) => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['updateSecret']); + mockSecretsService.updateSecret.and.returnValue(of(secret)); + + mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [ + EditSecretDialogComponent, + BrowserAnimationsModule, + MatSnackBarModule, + Angulartics2Module.forRoot(), + ], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: SecretsService, useValue: mockSecretsService }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: { secret } }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(EditSecretDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + beforeEach(async () => { + await createComponent(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('form initialization', () => { + it('should initialize form with empty value', () => { + expect(component.form.get('value')?.value).toBe(''); + }); + + it('should have all required form controls', () => { + expect(component.form.get('value')).toBeTruthy(); + expect(component.form.get('expiresAt')).toBeTruthy(); + }); + + it('should initialize component properties', () => { + expect(component.submitting).toBeFalse(); + expect(component.showValue).toBeFalse(); + expect(component.masterPassword).toBe(''); + expect(component.masterPasswordError).toBe(''); + expect(component.showMasterPassword).toBeFalse(); + expect(component.clearExpiration).toBeFalse(); + expect(component.minDate).toBeTruthy(); + }); + + it('should initialize with null expiresAt when secret has no expiration', () => { + expect(component.form.get('expiresAt')?.value).toBeNull(); + }); + }); + + describe('form initialization with expiration', () => { + beforeEach(async () => { + await TestBed.resetTestingModule(); + await createComponent(mockSecretWithExpiration); + }); + + it('should initialize with existing expiration date', () => { + const expiresAt = component.form.get('expiresAt')?.value; + expect(expiresAt).toBeTruthy(); + expect(expiresAt instanceof Date).toBeTrue(); + }); + }); + + describe('value validation', () => { + it('should require value field', () => { + expect(component.form.get('value')?.valid).toBeFalse(); + component.form.patchValue({ value: 'some-value' }); + expect(component.form.get('value')?.valid).toBeTrue(); + }); + + it('should validate max length', () => { + const valueControl = component.form.get('value'); + valueControl?.setValue('a'.repeat(10001)); + expect(valueControl?.hasError('maxlength')).toBeTrue(); + + valueControl?.setValue('a'.repeat(10000)); + expect(valueControl?.hasError('maxlength')).toBeFalse(); + }); + }); + + describe('error messages', () => { + it('should return correct value error message for required', () => { + component.form.get('value')?.setValue(''); + component.form.get('value')?.markAsTouched(); + expect(component.valueError).toBe('New value is required'); + }); + + it('should return correct value error message for maxlength', () => { + component.form.get('value')?.setValue('a'.repeat(10001)); + expect(component.valueError).toBe('Value must be 10000 characters or less'); + }); + + it('should return empty string when no errors', () => { + component.form.get('value')?.setValue('valid-value'); + expect(component.valueError).toBe(''); + }); + }); + + describe('visibility toggles', () => { + it('should toggle value visibility', () => { + expect(component.showValue).toBeFalse(); + component.toggleValueVisibility(); + expect(component.showValue).toBeTrue(); + component.toggleValueVisibility(); + expect(component.showValue).toBeFalse(); + }); + + it('should toggle master password visibility', () => { + expect(component.showMasterPassword).toBeFalse(); + component.toggleMasterPasswordVisibility(); + expect(component.showMasterPassword).toBeTrue(); + component.toggleMasterPasswordVisibility(); + expect(component.showMasterPassword).toBeFalse(); + }); + }); + + describe('clear expiration', () => { + it('should disable expiresAt control when clearExpiration is true', () => { + component.onClearExpirationChange(true); + + expect(component.clearExpiration).toBeTrue(); + expect(component.form.get('expiresAt')?.disabled).toBeTrue(); + }); + + it('should enable expiresAt control when clearExpiration is false', () => { + component.onClearExpirationChange(true); + component.onClearExpirationChange(false); + + expect(component.clearExpiration).toBeFalse(); + expect(component.form.get('expiresAt')?.enabled).toBeTrue(); + }); + }); + + describe('form submission', () => { + it('should not submit invalid form', () => { + component.onSubmit(); + expect(mockSecretsService.updateSecret).not.toHaveBeenCalled(); + }); + + it('should submit updated secret', () => { + component.form.patchValue({ value: 'new-value' }); + component.onSubmit(); + expect(mockSecretsService.updateSecret).toHaveBeenCalled(); + }); + + it('should submit with correct payload', () => { + component.form.patchValue({ value: 'new-value' }); + component.onSubmit(); + + expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( + 'test-secret', + jasmine.objectContaining({ value: 'new-value' }), + undefined + ); + }); + + it('should submit with expiration date', () => { + const futureDate = new Date('2026-01-01'); + component.form.patchValue({ + value: 'new-value', + expiresAt: futureDate, + }); + + component.onSubmit(); + + expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( + 'test-secret', + jasmine.objectContaining({ + value: 'new-value', + expiresAt: jasmine.any(String), + }), + undefined + ); + }); + + it('should submit with null expiration when clearExpiration is true', () => { + component.form.patchValue({ value: 'new-value' }); + component.onClearExpirationChange(true); + + component.onSubmit(); + + expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( + 'test-secret', + jasmine.objectContaining({ + value: 'new-value', + expiresAt: null, + }), + undefined + ); + }); + + it('should set submitting to true during submission', () => { + component.form.patchValue({ value: 'new-value' }); + + expect(component.submitting).toBeFalse(); + component.onSubmit(); + }); + + it('should close dialog on successful submission', () => { + component.form.patchValue({ value: 'new-value' }); + + component.onSubmit(); + + expect(mockDialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should reset submitting on successful submission', () => { + component.form.patchValue({ value: 'new-value' }); + + component.onSubmit(); + + expect(component.submitting).toBeFalse(); + }); + }); + + describe('master encryption', () => { + beforeEach(async () => { + await TestBed.resetTestingModule(); + await createComponent(mockSecretWithEncryption); + }); + + it('should require master password for encrypted secrets', () => { + component.form.patchValue({ value: 'new-value' }); + component.masterPassword = ''; + + component.onSubmit(); + + expect(component.masterPasswordError).toBe('Master password is required'); + expect(mockSecretsService.updateSecret).not.toHaveBeenCalled(); + }); + + it('should submit with master password for encrypted secrets', () => { + component.form.patchValue({ value: 'new-value' }); + component.masterPassword = 'my-master-password'; + + component.onSubmit(); + + expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( + 'test-secret', + jasmine.objectContaining({ value: 'new-value' }), + 'my-master-password' + ); + }); + + it('should show error on 403 response (invalid master password)', () => { + mockSecretsService.updateSecret.and.returnValue( + throwError(() => ({ status: 403 })) + ); + + component.form.patchValue({ value: 'new-value' }); + component.masterPassword = 'wrong-password'; + + component.onSubmit(); + + expect(component.masterPasswordError).toBe('Invalid master password'); + expect(component.submitting).toBeFalse(); + }); + + it('should clear master password error on new submission attempt', () => { + component.masterPasswordError = 'Previous error'; + component.form.patchValue({ value: 'new-value' }); + component.masterPassword = ''; + + component.onSubmit(); + + expect(component.masterPasswordError).toBe('Master password is required'); + }); + }); + + describe('non-encrypted secret submission', () => { + it('should not send master password for non-encrypted secrets', () => { + component.form.patchValue({ value: 'new-value' }); + component.masterPassword = 'some-password'; + + component.onSubmit(); + + expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( + 'test-secret', + jasmine.objectContaining({ value: 'new-value' }), + undefined + ); + }); + }); + + describe('error handling', () => { + it('should reset submitting on non-403 error', () => { + mockSecretsService.updateSecret.and.returnValue( + throwError(() => ({ status: 500 })) + ); + + component.form.patchValue({ value: 'new-value' }); + component.onSubmit(); + + expect(component.submitting).toBeFalse(); + }); + }); +}); diff --git a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.ts b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.ts new file mode 100644 index 00000000..0bff581e --- /dev/null +++ b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.ts @@ -0,0 +1,122 @@ +import { Component, Inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { provideNativeDateAdapter } from '@angular/material/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Angulartics2 } from 'angulartics2'; + +import { SecretsService } from 'src/app/services/secrets.service'; +import { Secret } from 'src/app/models/secret'; + +@Component({ + selector: 'app-edit-secret-dialog', + templateUrl: './edit-secret-dialog.component.html', + styleUrls: ['./edit-secret-dialog.component.css'], + providers: [provideNativeDateAdapter()], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatDatepickerModule, + MatIconModule, + MatCheckboxModule, + MatTooltipModule, + ] +}) +export class EditSecretDialogComponent { + public form: FormGroup; + public submitting = false; + public showValue = false; + public masterPassword = ''; + public masterPasswordError = ''; + public showMasterPassword = false; + public clearExpiration = false; + public minDate = new Date(); + + constructor( + @Inject(MAT_DIALOG_DATA) public data: { secret: Secret }, + private fb: FormBuilder, + private dialogRef: MatDialogRef, + private _secrets: SecretsService, + private angulartics2: Angulartics2 + ) { + this.form = this.fb.group({ + value: ['', [Validators.required, Validators.maxLength(10000)]], + expiresAt: [data.secret.expiresAt ? new Date(data.secret.expiresAt) : null], + }); + } + + toggleValueVisibility(): void { + this.showValue = !this.showValue; + } + + toggleMasterPasswordVisibility(): void { + this.showMasterPassword = !this.showMasterPassword; + } + + get valueError(): string { + const control = this.form.get('value'); + if (control?.hasError('required')) return 'New value is required'; + if (control?.hasError('maxlength')) return 'Value must be 10000 characters or less'; + return ''; + } + + onClearExpirationChange(checked: boolean): void { + this.clearExpiration = checked; + if (checked) { + this.form.get('expiresAt')?.disable(); + } else { + this.form.get('expiresAt')?.enable(); + } + } + + onSubmit(): void { + if (this.form.invalid) return; + + if (this.data.secret.masterEncryption && !this.masterPassword) { + this.masterPasswordError = 'Master password is required'; + return; + } + + this.submitting = true; + const formValue = this.form.getRawValue(); + + const payload = { + value: formValue.value, + expiresAt: this.clearExpiration + ? null + : (formValue.expiresAt ? new Date(formValue.expiresAt).toISOString() : undefined), + }; + + this._secrets.updateSecret( + this.data.secret.slug, + payload, + this.data.secret.masterEncryption ? this.masterPassword : undefined + ).subscribe({ + next: () => { + this.angulartics2.eventTrack.next({ + action: 'Secrets: secret updated successfully', + }); + this.submitting = false; + this.dialogRef.close(true); + }, + error: (err) => { + this.submitting = false; + if (err.status === 403) { + this.masterPasswordError = 'Invalid master password'; + } + } + }); + } +} diff --git a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.css b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.css new file mode 100644 index 00000000..ee5f2543 --- /dev/null +++ b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.css @@ -0,0 +1,40 @@ +h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: 8px; +} + +.title-icon { + color: #1976d2; +} + +@media (prefers-color-scheme: dark) { + .title-icon { + color: #64b5f6; + } +} + +mat-dialog-content { + min-width: 350px; +} + +@media (width <= 600px) { + mat-dialog-content { + min-width: auto; + } +} + +.description { + color: rgba(0, 0, 0, 0.54); + margin: 0 0 24px 0; +} + +@media (prefers-color-scheme: dark) { + .description { + color: rgba(255, 255, 255, 0.54); + } +} + +.full-width { + width: 100%; +} diff --git a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.html b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.html new file mode 100644 index 00000000..a5a13ce3 --- /dev/null +++ b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.html @@ -0,0 +1,37 @@ +

+ enhanced_encryption + Master Password Required +

+ + +

+ This secret is protected with master password encryption. + Enter the master password to continue. +

+ + + Master Password + + + {{error}} + +
+ + + + + diff --git a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.spec.ts b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.spec.ts new file mode 100644 index 00000000..9b1a69eb --- /dev/null +++ b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.spec.ts @@ -0,0 +1,50 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatDialogRef } from '@angular/material/dialog'; + +import { MasterPasswordDialogComponent } from './master-password-dialog.component'; + +describe('MasterPasswordDialogComponent', () => { + let component: MasterPasswordDialogComponent; + let fixture: ComponentFixture; + let mockDialogRef: jasmine.SpyObj>; + + beforeEach(async () => { + mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [ + MasterPasswordDialogComponent, + BrowserAnimationsModule, + ], + providers: [ + { provide: MatDialogRef, useValue: mockDialogRef }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(MasterPasswordDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should toggle password visibility', () => { + expect(component.showPassword).toBeFalse(); + component.togglePasswordVisibility(); + expect(component.showPassword).toBeTrue(); + }); + + it('should show error when submitting empty password', () => { + component.onSubmit(); + expect(component.error).toBe('Please enter the master password'); + }); + + it('should close dialog with password when submitting valid password', () => { + component.masterPassword = 'testpassword'; + component.onSubmit(); + expect(mockDialogRef.close).toHaveBeenCalledWith('testpassword'); + }); +}); diff --git a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.ts b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.ts new file mode 100644 index 00000000..1c789bdd --- /dev/null +++ b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.ts @@ -0,0 +1,46 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +@Component({ + selector: 'app-master-password-dialog', + templateUrl: './master-password-dialog.component.html', + styleUrls: ['./master-password-dialog.component.css'], + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatIconModule, + MatTooltipModule, + ] +}) +export class MasterPasswordDialogComponent { + public masterPassword = ''; + public showPassword = false; + public error = ''; + + constructor( + private dialogRef: MatDialogRef + ) {} + + togglePasswordVisibility(): void { + this.showPassword = !this.showPassword; + } + + onSubmit(): void { + if (!this.masterPassword) { + this.error = 'Please enter the master password'; + return; + } + this.dialogRef.close(this.masterPassword); + } +} diff --git a/frontend/src/app/components/secrets/secrets.component.css b/frontend/src/app/components/secrets/secrets.component.css new file mode 100644 index 00000000..c62246a4 --- /dev/null +++ b/frontend/src/app/components/secrets/secrets.component.css @@ -0,0 +1,257 @@ +.secrets-page { + margin: 3em auto; + padding: 0 clamp(200px, 20vw, 300px); +} + +@media (width <= 600px) { + .secrets-page { + padding: 0 9vw; + } +} + +.secrets-header { + margin-bottom: 32px; +} + +.secrets-description { + color: rgba(0, 0, 0, 0.64); + margin-top: 8px; +} + +@media (prefers-color-scheme: dark) { + .secrets-description { + color: rgba(255, 255, 255, 0.7); + } +} + +.secrets-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + +@media (width <= 600px) { + .secrets-toolbar { + flex-direction: column; + align-items: stretch; + } +} + +.search-field { + flex: 1; + max-width: 400px; +} + +@media (width <= 600px) { + .search-field { + max-width: 100%; + } +} + +.secrets-table { + width: 100%; + margin-bottom: 16px; +} + +.secrets-cell_slug { + max-width: 250px; +} + +.slug-text { + font-weight: 500; +} + +.secrets-cell_actions { + text-align: right; +} + +.encryption-icon { + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .encryption-icon { + color: rgba(255, 255, 255, 0.7); + } +} + +.encryption-icon_master { + color: #1976d2; +} + +@media (prefers-color-scheme: dark) { + .encryption-icon_master { + color: #64b5f6; + } +} + +.no-expiry { + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .no-expiry { + color: rgba(255, 255, 255, 0.5); + } +} + +.expired-badge { + display: inline-flex; + align-items: center; + gap: 4px; + color: #c62828; + font-weight: 500; +} + +.expired-badge mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +@media (prefers-color-scheme: dark) { + .expired-badge { + color: #ef5350; + } +} + +.expiring-soon-badge { + display: inline-flex; + align-items: center; + gap: 4px; + color: #ef6c00; + font-weight: 500; +} + +.expiring-soon-badge mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +@media (prefers-color-scheme: dark) { + .expiring-soon-badge { + color: #ffb74d; + } +} + +.no-secrets { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + text-align: center; + background-color: rgba(0, 0, 0, 0.02); + border-radius: 8px; +} + +@media (prefers-color-scheme: dark) { + .no-secrets { + background-color: rgba(255, 255, 255, 0.05); + } +} + +.no-secrets-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: rgba(0, 0, 0, 0.26); + margin-bottom: 16px; +} + +@media (prefers-color-scheme: dark) { + .no-secrets-icon { + color: rgba(255, 255, 255, 0.3); + } +} + +.no-secrets h3 { + margin: 0 0 8px 0; + color: rgba(0, 0, 0, 0.87); +} + +@media (prefers-color-scheme: dark) { + .no-secrets h3 { + color: rgba(255, 255, 255, 0.87); + } +} + +.no-secrets p { + margin: 0 0 16px 0; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .no-secrets p { + color: rgba(255, 255, 255, 0.54); + } +} + +.delete-action { + color: #c62828; +} + +@media (prefers-color-scheme: dark) { + .delete-action { + color: #ef5350; + } +} + +/* Responsive table styles */ +@media (width <= 600px) { + .secrets-table { + display: grid; + grid-template-columns: auto 1fr; + max-width: 100%; + } + + .secrets-table-heading { + display: none; + } + + .secrets-table ::ng-deep tbody { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / 3; + } + + .secrets-row { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / 3; + grid-gap: 12px 28px; + border-bottom-color: var(--mat-table-row-item-outline-color, rgba(0, 0, 0, 0.12)); + border-bottom-width: var(--mat-table-row-item-outline-width, 1px); + border-bottom-style: solid; + height: auto; + padding: 20px 0; + } + + .secrets-cell { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / 3; + border-bottom: none; + } + + .secrets-cell::before { + content: attr(data-label); + display: inline-block; + font-weight: bold; + white-space: nowrap; + } + + .secrets-cell_actions { + grid-column: 1 / span 3; + display: flex; + justify-content: flex-end; + border-bottom: none; + } + + .secrets-cell_actions::before { + display: none; + } +} diff --git a/frontend/src/app/components/secrets/secrets.component.html b/frontend/src/app/components/secrets/secrets.component.html new file mode 100644 index 00000000..6cea1138 --- /dev/null +++ b/frontend/src/app/components/secrets/secrets.component.html @@ -0,0 +1,151 @@ + + +
+
+

Company Secrets

+

+ Securely store and manage sensitive information like API keys, passwords, and tokens. +

+
+ +
+ + Search secrets + + search + + + +
+ + + +
+ key_off +

No secrets found

+

No secrets match your search criteria.

+

Create your first secret to securely store sensitive data.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Slug + {{secret.slug}} + Encryption + + enhanced_encryption + + + lock + + Expires + Never + + error + Expired + + + warning + {{secret.expiresAt | date:'mediumDate'}} + + + {{secret.expiresAt | date:'mediumDate'}} + + Last Updated + {{secret.updatedAt | date:'medium'}} + + + + + + + + +
+ + + +
diff --git a/frontend/src/app/components/secrets/secrets.component.spec.ts b/frontend/src/app/components/secrets/secrets.component.spec.ts new file mode 100644 index 00000000..d75d5634 --- /dev/null +++ b/frontend/src/app/components/secrets/secrets.component.spec.ts @@ -0,0 +1,325 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { Angulartics2Module } from 'angulartics2'; +import { BehaviorSubject, of } from 'rxjs'; +import { PageEvent } from '@angular/material/paginator'; + +import { SecretsComponent } from './secrets.component'; +import { SecretsService } from 'src/app/services/secrets.service'; +import { CompanyService } from 'src/app/services/company.service'; +import { CreateSecretDialogComponent } from './create-secret-dialog/create-secret-dialog.component'; +import { EditSecretDialogComponent } from './edit-secret-dialog/edit-secret-dialog.component'; +import { DeleteSecretDialogComponent } from './delete-secret-dialog/delete-secret-dialog.component'; +import { AuditLogDialogComponent } from './audit-log-dialog/audit-log-dialog.component'; +import { Secret } from 'src/app/models/secret'; + +describe('SecretsComponent', () => { + let component: SecretsComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockCompanyService: jasmine.SpyObj; + let mockDialog: jasmine.SpyObj; + let secretsUpdatedSubject: BehaviorSubject; + + const mockSecret: Secret = { + id: '1', + slug: 'test-secret', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + }; + + const createMockSecretsResponse = () => ({ + data: [mockSecret], + pagination: { total: 1, currentPage: 1, perPage: 20, lastPage: 1 } + }); + + beforeEach(async () => { + secretsUpdatedSubject = new BehaviorSubject(''); + + mockSecretsService = jasmine.createSpyObj('SecretsService', ['fetchSecrets'], { + cast: secretsUpdatedSubject.asObservable() + }); + mockSecretsService.fetchSecrets.and.callFake(() => of(createMockSecretsResponse())); + + mockCompanyService = jasmine.createSpyObj('CompanyService', ['getCurrentTabTitle']); + mockCompanyService.getCurrentTabTitle.and.returnValue(of('Test Company')); + + mockDialog = jasmine.createSpyObj('MatDialog', ['open']); + + await TestBed.configureTestingModule({ + imports: [ + SecretsComponent, + BrowserAnimationsModule, + MatSnackBarModule, + Angulartics2Module.forRoot(), + ], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: SecretsService, useValue: mockSecretsService }, + { provide: CompanyService, useValue: mockCompanyService }, + { provide: MatDialog, useValue: mockDialog }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(SecretsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load secrets on init', () => { + expect(mockSecretsService.fetchSecrets).toHaveBeenCalled(); + }); + + it('should set page title on init', () => { + expect(mockCompanyService.getCurrentTabTitle).toHaveBeenCalled(); + }); + + it('should initialize with pagination from response', () => { + // The pagination comes from the mock service response + expect(component.pagination.total).toBe(1); + expect(component.pagination.lastPage).toBe(1); + }); + + it('should initialize with loading true then false after load', () => { + expect(component.loading).toBeFalse(); + }); + + it('should have correct displayed columns', () => { + expect(component.displayedColumns).toEqual(['slug', 'masterEncryption', 'expiresAt', 'updatedAt', 'actions']); + }); + + describe('isExpired', () => { + it('should return true for expired secrets', () => { + const expiredSecret: Secret = { + ...mockSecret, + expiresAt: '2024-01-01', + }; + expect(component.isExpired(expiredSecret)).toBeTrue(); + }); + + it('should return false for non-expired secrets', () => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + const validSecret: Secret = { + ...mockSecret, + expiresAt: futureDate.toISOString(), + }; + expect(component.isExpired(validSecret)).toBeFalse(); + }); + + it('should return false for secrets without expiration', () => { + expect(component.isExpired(mockSecret)).toBeFalse(); + }); + }); + + describe('isExpiringSoon', () => { + it('should return true for secrets expiring within 7 days', () => { + const soonDate = new Date(); + soonDate.setDate(soonDate.getDate() + 3); + const expiringSoonSecret: Secret = { + ...mockSecret, + expiresAt: soonDate.toISOString(), + }; + expect(component.isExpiringSoon(expiringSoonSecret)).toBeTrue(); + }); + + it('should return false for secrets expiring beyond 7 days', () => { + const laterDate = new Date(); + laterDate.setDate(laterDate.getDate() + 14); + const notExpiringSoonSecret: Secret = { + ...mockSecret, + expiresAt: laterDate.toISOString(), + }; + expect(component.isExpiringSoon(notExpiringSoonSecret)).toBeFalse(); + }); + + it('should return false for already expired secrets', () => { + const expiredSecret: Secret = { + ...mockSecret, + expiresAt: '2024-01-01', + }; + expect(component.isExpiringSoon(expiredSecret)).toBeFalse(); + }); + + it('should return false for secrets without expiration', () => { + expect(component.isExpiringSoon(mockSecret)).toBeFalse(); + }); + + it('should return true for secrets expiring exactly in 7 days', () => { + const exactlySevenDays = new Date(); + exactlySevenDays.setDate(exactlySevenDays.getDate() + 7); + const secret: Secret = { + ...mockSecret, + expiresAt: exactlySevenDays.toISOString(), + }; + expect(component.isExpiringSoon(secret)).toBeTrue(); + }); + }); + + describe('openCreateDialog', () => { + it('should open create dialog with correct configuration', () => { + component.openCreateDialog(); + expect(mockDialog.open).toHaveBeenCalledWith(CreateSecretDialogComponent, { + width: '500px', + }); + }); + }); + + describe('openEditDialog', () => { + it('should open edit dialog with secret data', () => { + component.openEditDialog(mockSecret); + expect(mockDialog.open).toHaveBeenCalledWith(EditSecretDialogComponent, { + width: '500px', + data: { secret: mockSecret }, + }); + }); + }); + + describe('openDeleteDialog', () => { + it('should open delete dialog with secret data', () => { + component.openDeleteDialog(mockSecret); + expect(mockDialog.open).toHaveBeenCalledWith(DeleteSecretDialogComponent, { + width: '400px', + data: { secret: mockSecret }, + }); + }); + }); + + describe('openAuditLogDialog', () => { + it('should open audit log dialog with secret data', () => { + component.openAuditLogDialog(mockSecret); + expect(mockDialog.open).toHaveBeenCalledWith(AuditLogDialogComponent, { + width: '800px', + maxHeight: '80vh', + data: { secret: mockSecret }, + }); + }); + }); + + describe('onPageChange', () => { + it('should update pagination and reload secrets', () => { + const pageEvent: PageEvent = { + pageIndex: 1, + pageSize: 10, + length: 100 + }; + + // Update mock to return pagination matching the page change + mockSecretsService.fetchSecrets.and.callFake(() => of({ + data: [mockSecret], + pagination: { total: 100, currentPage: 2, perPage: 10, lastPage: 10 } + })); + mockSecretsService.fetchSecrets.calls.reset(); + component.onPageChange(pageEvent); + + expect(component.pagination.currentPage).toBe(2); + expect(component.pagination.perPage).toBe(10); + expect(mockSecretsService.fetchSecrets).toHaveBeenCalledWith(2, 10, undefined); + }); + }); + + describe('onSearchChange', () => { + it('should debounce search and reload secrets', fakeAsync(() => { + mockSecretsService.fetchSecrets.calls.reset(); + + component.onSearchChange('api'); + component.onSearchChange('api-'); + component.onSearchChange('api-key'); + + tick(300); + + expect(mockSecretsService.fetchSecrets).toHaveBeenCalledTimes(1); + })); + + it('should reset to page 1 on search', fakeAsync(() => { + component.pagination.currentPage = 3; + mockSecretsService.fetchSecrets.calls.reset(); + + component.onSearchChange('test'); + tick(300); + + expect(component.pagination.currentPage).toBe(1); + })); + }); + + describe('secretsUpdated subscription', () => { + it('should reload secrets when secretsUpdated emits', () => { + mockSecretsService.fetchSecrets.calls.reset(); + + secretsUpdatedSubject.next('created'); + + expect(mockSecretsService.fetchSecrets).toHaveBeenCalled(); + }); + + it('should not reload secrets when secretsUpdated emits empty string', () => { + mockSecretsService.fetchSecrets.calls.reset(); + + secretsUpdatedSubject.next(''); + + expect(mockSecretsService.fetchSecrets).not.toHaveBeenCalled(); + }); + }); + + describe('loadSecrets', () => { + it('should set loading to true while fetching', () => { + component.loading = false; + mockSecretsService.fetchSecrets.and.callFake(() => of(createMockSecretsResponse())); + + component.loadSecrets(); + + expect(component.loading).toBeFalse(); + }); + + it('should update secrets and pagination on successful fetch', () => { + const newResponse = { + data: [mockSecret, { ...mockSecret, id: '2', slug: 'test-2' }], + pagination: { total: 2, currentPage: 1, perPage: 20, lastPage: 1 } + }; + mockSecretsService.fetchSecrets.and.returnValue(of(newResponse)); + + component.loadSecrets(); + + expect(component.secrets).toEqual(newResponse.data); + expect(component.pagination).toEqual(newResponse.pagination); + }); + + it('should pass search query to fetchSecrets', () => { + mockSecretsService.fetchSecrets.calls.reset(); + component.searchQuery = 'api-key'; + + component.loadSecrets(); + + expect(mockSecretsService.fetchSecrets).toHaveBeenCalledWith(1, 20, 'api-key'); + }); + + it('should pass undefined for empty search query', () => { + mockSecretsService.fetchSecrets.calls.reset(); + component.searchQuery = ''; + + component.loadSecrets(); + + expect(mockSecretsService.fetchSecrets).toHaveBeenCalledWith(1, 20, undefined); + }); + }); + + describe('ngOnDestroy', () => { + it('should unsubscribe from all subscriptions', () => { + const unsubscribeSpy = spyOn(component['subscriptions'][0], 'unsubscribe'); + + component.ngOnDestroy(); + + expect(unsubscribeSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/app/components/secrets/secrets.component.ts b/frontend/src/app/components/secrets/secrets.component.ts new file mode 100644 index 00000000..f060706d --- /dev/null +++ b/frontend/src/app/components/secrets/secrets.component.ts @@ -0,0 +1,179 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatTableModule } from '@angular/material/table'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatDialog } from '@angular/material/dialog'; +import { MatDividerModule } from '@angular/material/divider'; +import { Subscription, Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { Angulartics2 } from 'angulartics2'; +import { Title } from '@angular/platform-browser'; + +import { SecretsService } from 'src/app/services/secrets.service'; +import { CompanyService } from 'src/app/services/company.service'; +import { Secret, SecretPagination } from 'src/app/models/secret'; +import { CreateSecretDialogComponent } from './create-secret-dialog/create-secret-dialog.component'; +import { EditSecretDialogComponent } from './edit-secret-dialog/edit-secret-dialog.component'; +import { DeleteSecretDialogComponent } from './delete-secret-dialog/delete-secret-dialog.component'; +import { AuditLogDialogComponent } from './audit-log-dialog/audit-log-dialog.component'; +import { PlaceholderTableDataComponent } from '../skeletons/placeholder-table-data/placeholder-table-data.component'; +import { AlertComponent } from '../ui-components/alert/alert.component'; + +@Component({ + selector: 'app-secrets', + templateUrl: './secrets.component.html', + styleUrls: ['./secrets.component.css'], + imports: [ + CommonModule, + FormsModule, + MatTableModule, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatInputModule, + MatFormFieldModule, + MatPaginatorModule, + MatTooltipModule, + MatChipsModule, + MatDividerModule, + PlaceholderTableDataComponent, + AlertComponent, + ] +}) +export class SecretsComponent implements OnInit, OnDestroy { + public secrets: Secret[] = []; + public pagination: SecretPagination = { + total: 0, + currentPage: 1, + perPage: 20, + lastPage: 1, + }; + public loading = true; + public searchQuery = ''; + public displayedColumns = ['slug', 'masterEncryption', 'expiresAt', 'updatedAt', 'actions']; + + private searchSubject = new Subject(); + private subscriptions: Subscription[] = []; + + constructor( + private _secrets: SecretsService, + private _company: CompanyService, + private dialog: MatDialog, + private angulartics2: Angulartics2, + private title: Title + ) {} + + ngOnInit(): void { + this._company.getCurrentTabTitle().subscribe(tabTitle => { + this.title.setTitle(`Secrets | ${tabTitle || 'Rocketadmin'}`); + }); + + this.loadSecrets(); + + const searchSub = this.searchSubject.pipe( + debounceTime(300), + distinctUntilChanged() + ).subscribe(() => { + this.pagination.currentPage = 1; + this.loadSecrets(); + }); + this.subscriptions.push(searchSub); + + const updateSub = this._secrets.cast.subscribe(action => { + if (action) { + this.loadSecrets(); + } + }); + this.subscriptions.push(updateSub); + } + + ngOnDestroy(): void { + this.subscriptions.forEach(sub => sub.unsubscribe()); + } + + loadSecrets(): void { + this.loading = true; + this._secrets.fetchSecrets( + this.pagination.currentPage, + this.pagination.perPage, + this.searchQuery || undefined + ).subscribe(response => { + if (response) { + this.secrets = response.data; + this.pagination = response.pagination; + } + this.loading = false; + }); + } + + onSearchChange(query: string): void { + this.searchSubject.next(query); + } + + onPageChange(event: PageEvent): void { + this.pagination.currentPage = event.pageIndex + 1; + this.pagination.perPage = event.pageSize; + this.loadSecrets(); + } + + isExpired(secret: Secret): boolean { + if (!secret.expiresAt) return false; + return new Date(secret.expiresAt) < new Date(); + } + + isExpiringSoon(secret: Secret): boolean { + if (!secret.expiresAt) return false; + const expiresAt = new Date(secret.expiresAt); + const now = new Date(); + const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + return expiresAt > now && expiresAt <= sevenDaysFromNow; + } + + openCreateDialog(): void { + this.dialog.open(CreateSecretDialogComponent, { + width: '500px', + }); + this.angulartics2.eventTrack.next({ + action: 'Secrets: create secret dialog opened', + }); + } + + openEditDialog(secret: Secret): void { + this.dialog.open(EditSecretDialogComponent, { + width: '500px', + data: { secret }, + }); + this.angulartics2.eventTrack.next({ + action: 'Secrets: edit secret dialog opened', + }); + } + + openDeleteDialog(secret: Secret): void { + this.dialog.open(DeleteSecretDialogComponent, { + width: '400px', + data: { secret }, + }); + this.angulartics2.eventTrack.next({ + action: 'Secrets: delete secret dialog opened', + }); + } + + openAuditLogDialog(secret: Secret): void { + this.dialog.open(AuditLogDialogComponent, { + width: '800px', + maxHeight: '80vh', + data: { secret }, + }); + this.angulartics2.eventTrack.next({ + action: 'Secrets: audit log dialog opened', + }); + } +} diff --git a/frontend/src/app/models/secret.ts b/frontend/src/app/models/secret.ts new file mode 100644 index 00000000..4bdff7ca --- /dev/null +++ b/frontend/src/app/models/secret.ts @@ -0,0 +1,61 @@ +export interface Secret { + id: string; + slug: string; + companyId: string; + createdAt: string; + updatedAt: string; + lastAccessedAt?: string; + expiresAt?: string; + masterEncryption: boolean; +} + +export interface SecretListResponse { + data: Secret[]; + pagination: SecretPagination; +} + +export interface SecretPagination { + total: number; + currentPage: number; + perPage: number; + lastPage: number; +} + +export interface AuditLogEntry { + id: string; + action: SecretAction; + user: { + id: string; + email: string; + }; + accessedAt: string; + ipAddress?: string; + userAgent?: string; + success: boolean; + errorMessage?: string; +} + +export interface AuditLogResponse { + data: AuditLogEntry[]; + pagination: SecretPagination; +} + +export type SecretAction = 'create' | 'view' | 'copy' | 'update' | 'delete'; + +export interface CreateSecretPayload { + slug: string; + value: string; + expiresAt?: string; + masterEncryption?: boolean; + masterPassword?: string; +} + +export interface UpdateSecretPayload { + value: string; + expiresAt?: string | null; +} + +export interface DeleteSecretResponse { + message: string; + deletedAt: string; +} diff --git a/frontend/src/app/routes/company-invitation.routes.ts b/frontend/src/app/routes/company-invitation.routes.ts new file mode 100644 index 00000000..0b135aeb --- /dev/null +++ b/frontend/src/app/routes/company-invitation.routes.ts @@ -0,0 +1,11 @@ +import { Routes } from '@angular/router'; +import { provideZxvbnServiceForPSM } from 'angular-password-strength-meter/zxcvbn'; + +export const COMPANY_INVITATION_ROUTES: Routes = [ + { + path: '', + loadComponent: () => import('../components/company-member-invitation/company-member-invitation.component').then(m => m.CompanyMemberInvitationComponent), + title: 'Invitation | Rocketadmin', + providers: [provideZxvbnServiceForPSM()] + } +]; diff --git a/frontend/src/app/routes/password-change.routes.ts b/frontend/src/app/routes/password-change.routes.ts new file mode 100644 index 00000000..a3759771 --- /dev/null +++ b/frontend/src/app/routes/password-change.routes.ts @@ -0,0 +1,12 @@ +import { Routes } from '@angular/router'; +import { provideZxvbnServiceForPSM } from 'angular-password-strength-meter/zxcvbn'; +import { AuthGuard } from '../auth.guard'; + +export const PASSWORD_CHANGE_ROUTES: Routes = [ + { + path: '', + loadComponent: () => import('../components/password-change/password-change.component').then(m => m.PasswordChangeComponent), + canActivate: [AuthGuard], + providers: [provideZxvbnServiceForPSM()] + } +]; diff --git a/frontend/src/app/routes/password-reset.routes.ts b/frontend/src/app/routes/password-reset.routes.ts new file mode 100644 index 00000000..8cc370cb --- /dev/null +++ b/frontend/src/app/routes/password-reset.routes.ts @@ -0,0 +1,11 @@ +import { Routes } from '@angular/router'; +import { provideZxvbnServiceForPSM } from 'angular-password-strength-meter/zxcvbn'; + +export const PASSWORD_RESET_ROUTES: Routes = [ + { + path: '', + loadComponent: () => import('../components/password-reset/password-reset.component').then(m => m.PasswordResetComponent), + title: 'Reset password | Rocketadmin', + providers: [provideZxvbnServiceForPSM()] + } +]; diff --git a/frontend/src/app/routes/registration.routes.ts b/frontend/src/app/routes/registration.routes.ts new file mode 100644 index 00000000..b83cf5d2 --- /dev/null +++ b/frontend/src/app/routes/registration.routes.ts @@ -0,0 +1,11 @@ +import { Routes } from '@angular/router'; +import { provideZxvbnServiceForPSM } from 'angular-password-strength-meter/zxcvbn'; + +export const REGISTRATION_ROUTES: Routes = [ + { + path: '', + loadComponent: () => import('../components/registration/registration.component').then(m => m.RegistrationComponent), + title: 'Sign up | Rocketadmin', + providers: [provideZxvbnServiceForPSM()] + } +]; diff --git a/frontend/src/app/services/secrets.service.spec.ts b/frontend/src/app/services/secrets.service.spec.ts new file mode 100644 index 00000000..033281f1 --- /dev/null +++ b/frontend/src/app/services/secrets.service.spec.ts @@ -0,0 +1,522 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; + +import { SecretsService } from './secrets.service'; +import { NotificationsService } from './notifications.service'; +import { + Secret, + SecretListResponse, + AuditLogResponse, + CreateSecretPayload, + UpdateSecretPayload, + DeleteSecretResponse, +} from '../models/secret'; + +describe('SecretsService', () => { + let service: SecretsService; + let httpMock: HttpTestingController; + let fakeNotifications: jasmine.SpyObj; + + const mockSecret: Secret = { + id: '1', + slug: 'test-secret', + companyId: 'company-1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + masterEncryption: false, + }; + + const mockSecretWithExpiration: Secret = { + ...mockSecret, + expiresAt: '2025-01-01T00:00:00Z', + }; + + const mockSecretListResponse: SecretListResponse = { + data: [mockSecret, mockSecretWithExpiration], + pagination: { + total: 2, + currentPage: 1, + perPage: 20, + lastPage: 1, + }, + }; + + const mockAuditLogResponse: AuditLogResponse = { + data: [ + { + id: '1', + action: 'create', + user: { id: 'user-1', email: 'user@example.com' }, + accessedAt: '2024-01-01T00:00:00Z', + success: true, + }, + { + id: '2', + action: 'view', + user: { id: 'user-1', email: 'user@example.com' }, + accessedAt: '2024-01-02T00:00:00Z', + success: true, + }, + ], + pagination: { + total: 2, + currentPage: 1, + perPage: 50, + lastPage: 1, + }, + }; + + const mockDeleteResponse: DeleteSecretResponse = { + message: 'Secret deleted successfully', + deletedAt: '2024-01-01T00:00:00Z', + }; + + const fakeError = { + message: 'Something went wrong', + statusCode: 400, + }; + + beforeEach(() => { + fakeNotifications = jasmine.createSpyObj('NotificationsService', [ + 'showErrorSnackbar', + 'showSuccessSnackbar', + ]); + + TestBed.configureTestingModule({ + imports: [MatSnackBarModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + SecretsService, + { provide: NotificationsService, useValue: fakeNotifications }, + ], + }); + + service = TestBed.inject(SecretsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('fetchSecrets', () => { + it('should fetch secrets with default pagination', () => { + let result: SecretListResponse | undefined; + + service.fetchSecrets().subscribe((res) => { + result = res; + }); + + const req = httpMock.expectOne('/secrets?page=1&limit=20'); + expect(req.request.method).toBe('GET'); + req.flush(mockSecretListResponse); + + expect(result).toEqual(mockSecretListResponse); + }); + + it('should fetch secrets with custom pagination', () => { + let result: SecretListResponse | undefined; + + service.fetchSecrets(2, 10).subscribe((res) => { + result = res; + }); + + const req = httpMock.expectOne('/secrets?page=2&limit=10'); + expect(req.request.method).toBe('GET'); + req.flush(mockSecretListResponse); + + expect(result).toEqual(mockSecretListResponse); + }); + + it('should fetch secrets with search query', () => { + let result: SecretListResponse | undefined; + + service.fetchSecrets(1, 20, 'api-key').subscribe((res) => { + result = res; + }); + + const req = httpMock.expectOne('/secrets?page=1&limit=20&search=api-key'); + expect(req.request.method).toBe('GET'); + req.flush(mockSecretListResponse); + + expect(result).toEqual(mockSecretListResponse); + }); + + it('should show error snackbar on fetch failure', async () => { + const promise = service.fetchSecrets().toPromise(); + + const req = httpMock.expectOne('/secrets?page=1&limit=20'); + req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); + + await promise; + + expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); + }); + + it('should show default error message when error has no message', async () => { + const promise = service.fetchSecrets().toPromise(); + + const req = httpMock.expectOne('/secrets?page=1&limit=20'); + req.flush({}, { status: 500, statusText: 'Internal Server Error' }); + + await promise; + + expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith('Failed to fetch secrets'); + }); + }); + + describe('createSecret', () => { + const createPayload: CreateSecretPayload = { + slug: 'new-secret', + value: 'secret-value', + }; + + it('should create a secret successfully', () => { + let result: Secret | undefined; + + service.createSecret(createPayload).subscribe((res) => { + result = res; + }); + + const req = httpMock.expectOne('/secrets'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(createPayload); + req.flush(mockSecret); + + expect(result).toEqual(mockSecret); + expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Secret created successfully'); + }); + + it('should create a secret with expiration', () => { + const payloadWithExpiration: CreateSecretPayload = { + ...createPayload, + expiresAt: '2025-01-01T00:00:00Z', + }; + + service.createSecret(payloadWithExpiration).subscribe(); + + const req = httpMock.expectOne('/secrets'); + expect(req.request.body).toEqual(payloadWithExpiration); + req.flush(mockSecretWithExpiration); + }); + + it('should create a secret with master encryption', () => { + const payloadWithEncryption: CreateSecretPayload = { + ...createPayload, + masterEncryption: true, + masterPassword: 'my-master-password', + }; + + service.createSecret(payloadWithEncryption).subscribe(); + + const req = httpMock.expectOne('/secrets'); + expect(req.request.body).toEqual(payloadWithEncryption); + req.flush({ ...mockSecret, masterEncryption: true }); + }); + + it('should emit secretsUpdated on successful creation', () => { + let updateAction: string | undefined; + service.cast.subscribe((action) => { + updateAction = action; + }); + + service.createSecret(createPayload).subscribe(); + + const req = httpMock.expectOne('/secrets'); + req.flush(mockSecret); + + expect(updateAction).toBe('created'); + }); + + it('should show conflict error when slug already exists', async () => { + const promise = service.createSecret(createPayload).toPromise(); + + const req = httpMock.expectOne('/secrets'); + req.flush({ message: 'Conflict' }, { status: 409, statusText: 'Conflict' }); + + await promise; + + expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith( + 'A secret with this slug already exists' + ); + }); + + it('should show generic error on other failures', async () => { + const promise = service.createSecret(createPayload).toPromise(); + + const req = httpMock.expectOne('/secrets'); + req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); + + await promise; + + expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); + }); + }); + + describe('updateSecret', () => { + const updatePayload: UpdateSecretPayload = { + value: 'new-value', + }; + + it('should update a secret successfully', () => { + let result: Secret | undefined; + + service.updateSecret('test-secret', updatePayload).subscribe((res) => { + result = res; + }); + + const req = httpMock.expectOne('/secrets/test-secret'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual(updatePayload); + req.flush(mockSecret); + + expect(result).toEqual(mockSecret); + expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Secret updated successfully'); + }); + + it('should update a secret with new expiration', () => { + const payloadWithExpiration: UpdateSecretPayload = { + ...updatePayload, + expiresAt: '2026-01-01T00:00:00Z', + }; + + service.updateSecret('test-secret', payloadWithExpiration).subscribe(); + + const req = httpMock.expectOne('/secrets/test-secret'); + expect(req.request.body).toEqual(payloadWithExpiration); + req.flush(mockSecretWithExpiration); + }); + + it('should clear expiration when expiresAt is null', () => { + const payloadClearExpiration: UpdateSecretPayload = { + ...updatePayload, + expiresAt: null, + }; + + service.updateSecret('test-secret', payloadClearExpiration).subscribe(); + + const req = httpMock.expectOne('/secrets/test-secret'); + expect(req.request.body).toEqual(payloadClearExpiration); + req.flush(mockSecret); + }); + + it('should send master password in header when provided', () => { + service.updateSecret('test-secret', updatePayload, 'master-password-123').subscribe(); + + const req = httpMock.expectOne('/secrets/test-secret'); + expect(req.request.headers.get('masterpwd')).toBe('master-password-123'); + req.flush(mockSecret); + }); + + it('should not send master password header when not provided', () => { + service.updateSecret('test-secret', updatePayload).subscribe(); + + const req = httpMock.expectOne('/secrets/test-secret'); + expect(req.request.headers.has('masterpwd')).toBeFalse(); + req.flush(mockSecret); + }); + + it('should emit secretsUpdated on successful update', () => { + let updateAction: string | undefined; + service.cast.subscribe((action) => { + updateAction = action; + }); + + service.updateSecret('test-secret', updatePayload).subscribe(); + + const req = httpMock.expectOne('/secrets/test-secret'); + req.flush(mockSecret); + + expect(updateAction).toBe('updated'); + }); + + it('should throw error on 403 (invalid master password)', async () => { + let errorThrown = false; + + service.updateSecret('test-secret', updatePayload, 'wrong-password').subscribe({ + error: (err) => { + errorThrown = true; + expect(err.status).toBe(403); + }, + }); + + const req = httpMock.expectOne('/secrets/test-secret'); + req.flush({ message: 'Invalid master password' }, { status: 403, statusText: 'Forbidden' }); + + expect(errorThrown).toBeTrue(); + }); + + it('should show error for expired secret (410)', async () => { + const promise = service.updateSecret('test-secret', updatePayload).toPromise(); + + const req = httpMock.expectOne('/secrets/test-secret'); + req.flush({ message: 'Secret expired' }, { status: 410, statusText: 'Gone' }); + + await promise; + + expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith( + 'Cannot update an expired secret' + ); + }); + + it('should show generic error on other failures', async () => { + const promise = service.updateSecret('test-secret', updatePayload).toPromise(); + + const req = httpMock.expectOne('/secrets/test-secret'); + req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); + + await promise; + + expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); + }); + }); + + describe('deleteSecret', () => { + it('should delete a secret successfully', () => { + let result: DeleteSecretResponse | undefined; + + service.deleteSecret('test-secret').subscribe((res) => { + result = res; + }); + + const req = httpMock.expectOne('/secrets/test-secret'); + expect(req.request.method).toBe('DELETE'); + req.flush(mockDeleteResponse); + + expect(result).toEqual(mockDeleteResponse); + expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Secret deleted successfully'); + }); + + it('should emit secretsUpdated on successful deletion', () => { + let updateAction: string | undefined; + service.cast.subscribe((action) => { + updateAction = action; + }); + + service.deleteSecret('test-secret').subscribe(); + + const req = httpMock.expectOne('/secrets/test-secret'); + req.flush(mockDeleteResponse); + + expect(updateAction).toBe('deleted'); + }); + + it('should show error on delete failure', async () => { + const promise = service.deleteSecret('test-secret').toPromise(); + + const req = httpMock.expectOne('/secrets/test-secret'); + req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); + + await promise; + + expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); + }); + + it('should show default error message when error has no message', async () => { + const promise = service.deleteSecret('test-secret').toPromise(); + + const req = httpMock.expectOne('/secrets/test-secret'); + req.flush({}, { status: 500, statusText: 'Internal Server Error' }); + + await promise; + + expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith('Failed to delete secret'); + }); + }); + + describe('getAuditLog', () => { + it('should fetch audit log with default pagination', () => { + let result: AuditLogResponse | undefined; + + service.getAuditLog('test-secret').subscribe((res) => { + result = res; + }); + + const req = httpMock.expectOne('/secrets/test-secret/audit-log?page=1&limit=50'); + expect(req.request.method).toBe('GET'); + req.flush(mockAuditLogResponse); + + expect(result).toEqual(mockAuditLogResponse); + }); + + it('should fetch audit log with custom pagination', () => { + let result: AuditLogResponse | undefined; + + service.getAuditLog('test-secret', 2, 25).subscribe((res) => { + result = res; + }); + + const req = httpMock.expectOne('/secrets/test-secret/audit-log?page=2&limit=25'); + expect(req.request.method).toBe('GET'); + req.flush(mockAuditLogResponse); + + expect(result).toEqual(mockAuditLogResponse); + }); + + it('should show error on audit log fetch failure', async () => { + const promise = service.getAuditLog('test-secret').toPromise(); + + const req = httpMock.expectOne('/secrets/test-secret/audit-log?page=1&limit=50'); + req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); + + await promise; + + expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); + }); + + it('should show default error message when error has no message', async () => { + const promise = service.getAuditLog('test-secret').toPromise(); + + const req = httpMock.expectOne('/secrets/test-secret/audit-log?page=1&limit=50'); + req.flush({}, { status: 500, statusText: 'Internal Server Error' }); + + await promise; + + expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith('Failed to fetch audit log'); + }); + }); + + describe('cast observable', () => { + it('should initially emit empty string', () => { + let emittedValue: string | undefined; + + service.cast.subscribe((value) => { + emittedValue = value; + }); + + expect(emittedValue).toBe(''); + }); + + it('should emit actions when secrets are modified', () => { + const emittedValues: string[] = []; + + service.cast.subscribe((value) => { + emittedValues.push(value); + }); + + // Create a secret + service.createSecret({ slug: 'test', value: 'value' }).subscribe(); + const createReq = httpMock.expectOne('/secrets'); + createReq.flush(mockSecret); + + // Update a secret + service.updateSecret('test', { value: 'new-value' }).subscribe(); + const updateReq = httpMock.expectOne('/secrets/test'); + updateReq.flush(mockSecret); + + // Delete a secret + service.deleteSecret('test').subscribe(); + const deleteReq = httpMock.expectOne('/secrets/test'); + deleteReq.flush(mockDeleteResponse); + + expect(emittedValues).toEqual(['', 'created', 'updated', 'deleted']); + }); + }); +}); diff --git a/frontend/src/app/services/secrets.service.ts b/frontend/src/app/services/secrets.service.ts new file mode 100644 index 00000000..1cce0ff2 --- /dev/null +++ b/frontend/src/app/services/secrets.service.ts @@ -0,0 +1,126 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { BehaviorSubject, EMPTY, Observable, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { NotificationsService } from './notifications.service'; +import { + Secret, + SecretListResponse, + AuditLogResponse, + CreateSecretPayload, + UpdateSecretPayload, + DeleteSecretResponse, +} from '../models/secret'; + +@Injectable({ + providedIn: 'root' +}) +export class SecretsService { + private secretsUpdated = new BehaviorSubject(''); + public cast = this.secretsUpdated.asObservable(); + + constructor( + private _http: HttpClient, + private _notifications: NotificationsService + ) {} + + fetchSecrets(page: number = 1, limit: number = 20, search?: string): Observable { + let params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + if (search) { + params = params.set('search', search); + } + + return this._http.get('/secrets', { params }) + .pipe( + map(res => res), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch secrets'); + return EMPTY; + }) + ); + } + + createSecret(payload: CreateSecretPayload): Observable { + return this._http.post('/secrets', payload) + .pipe( + map(res => { + this._notifications.showSuccessSnackbar('Secret created successfully'); + this.secretsUpdated.next('created'); + return res; + }), + catchError((err) => { + console.log(err); + if (err.status === 409) { + this._notifications.showErrorSnackbar('A secret with this slug already exists'); + } else { + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to create secret'); + } + return EMPTY; + }) + ); + } + + updateSecret(slug: string, payload: UpdateSecretPayload, masterPassword?: string): Observable { + let headers = new HttpHeaders(); + if (masterPassword) { + headers = headers.set('masterpwd', masterPassword); + } + + return this._http.put(`/secrets/${slug}`, payload, { headers }) + .pipe( + map(res => { + this._notifications.showSuccessSnackbar('Secret updated successfully'); + this.secretsUpdated.next('updated'); + return res; + }), + catchError((err) => { + console.log(err); + if (err.status === 403) { + return throwError(() => err); + } + if (err.status === 410) { + this._notifications.showErrorSnackbar('Cannot update an expired secret'); + } else { + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to update secret'); + } + return EMPTY; + }) + ); + } + + deleteSecret(slug: string): Observable { + return this._http.delete(`/secrets/${slug}`) + .pipe( + map(res => { + this._notifications.showSuccessSnackbar('Secret deleted successfully'); + this.secretsUpdated.next('deleted'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to delete secret'); + return EMPTY; + }) + ); + } + + getAuditLog(slug: string, page: number = 1, limit: number = 50): Observable { + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + return this._http.get(`/secrets/${slug}/audit-log`, { params }) + .pipe( + map(res => res), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch audit log'); + return EMPTY; + }) + ); + } +} diff --git a/frontend/src/custom-theme.scss b/frontend/src/custom-theme.scss index 173a13ad..697e8e93 100644 --- a/frontend/src/custom-theme.scss +++ b/frontend/src/custom-theme.scss @@ -31,6 +31,11 @@ html { --mat-table-background-color: #202020 !important; --mat-paginator-container-background-color: #202020 !important; } + + .mat-datepicker-content { + --mat-datepicker-calendar-date-hover-state-background-color: rgba(255, 255, 255, 0.1) !important; + --mat-datepicker-calendar-date-focus-state-background-color: rgba(255, 255, 255, 0.1) !important; + } } // .main-menu-container_native .mat-mdc-unelevated-button.mat-accent { @@ -38,6 +43,11 @@ html { // } @media (prefers-color-scheme: light) { + .mat-datepicker-content { + --mat-datepicker-calendar-date-hover-state-background-color: rgba(0, 0, 0, 0.04) !important; + --mat-datepicker-calendar-date-focus-state-background-color: rgba(0, 0, 0, 0.04) !important; + } + .main-menu-container_native .mat-mdc-unelevated-button.mat-accent { --mdc-filled-button-label-text-color: #fff !important; } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 1213eb3d..dcc71a5b 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -21,14 +21,12 @@ import { DynamicModule } from "ng-dynamic-component"; import { EncodeUrlParamsSafelyInterceptor } from "./app/services/url-params.interceptor"; import { NgxStripeModule } from "ngx-stripe"; import { NotificationsService } from "./app/services/notifications.service"; -import { PasswordStrengthMeterComponent } from "angular-password-strength-meter"; import { TablesService } from "./app/services/tables.service"; import { TokenInterceptor } from "./app/services/token.interceptor"; import { UsersService } from "./app/services/users.service"; import { environment } from './environments/environment'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { provideAnimations } from "@angular/platform-browser/animations"; -import { provideZxvbnServiceForPSM } from "angular-password-strength-meter/zxcvbn"; const saasExtraProviders = (environment as any).saas ? [ { @@ -84,7 +82,6 @@ bootstrapApplication(AppComponent, { provideCodeEditor({ baseUrl: 'assets/monaco' }), - PasswordStrengthMeterComponent, ConnectionsService, UsersService, NotificationsService, @@ -92,7 +89,6 @@ bootstrapApplication(AppComponent, { CookieService, provideMarkdown(), Title, - provideZxvbnServiceForPSM(), { provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor,