From 48d5b82828ef70c41976c0e7aac9824c1410fa01 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 5 Dec 2025 10:24:35 +0000 Subject: [PATCH 1/9] frontend secrets v0 --- frontend/src/app/app-routing.module.ts | 2 + frontend/src/app/app.component.html | 12 + .../audit-log-dialog.component.css | 176 ++++++++++++ .../audit-log-dialog.component.html | 73 +++++ .../audit-log-dialog.component.spec.ts | 84 ++++++ .../audit-log-dialog.component.ts | 96 +++++++ .../create-secret-dialog.component.css | 39 +++ .../create-secret-dialog.component.html | 77 ++++++ .../create-secret-dialog.component.spec.ts | 90 ++++++ .../create-secret-dialog.component.ts | 130 +++++++++ .../delete-secret-dialog.component.css | 46 ++++ .../delete-secret-dialog.component.html | 23 ++ .../delete-secret-dialog.component.spec.ts | 71 +++++ .../delete-secret-dialog.component.ts | 47 ++++ .../edit-secret-dialog.component.css | 102 +++++++ .../edit-secret-dialog.component.html | 96 +++++++ .../edit-secret-dialog.component.spec.ts | 76 ++++++ .../edit-secret-dialog.component.ts | 158 +++++++++++ .../master-password-dialog.component.css | 40 +++ .../master-password-dialog.component.html | 37 +++ .../master-password-dialog.component.spec.ts | 50 ++++ .../master-password-dialog.component.ts | 46 ++++ .../components/secrets/secrets.component.css | 258 ++++++++++++++++++ .../components/secrets/secrets.component.html | 158 +++++++++++ .../secrets/secrets.component.spec.ts | 111 ++++++++ .../components/secrets/secrets.component.ts | 190 +++++++++++++ .../view-secret-dialog.component.css | 223 +++++++++++++++ .../view-secret-dialog.component.html | 111 ++++++++ .../view-secret-dialog.component.spec.ts | 81 ++++++ .../view-secret-dialog.component.ts | 111 ++++++++ frontend/src/app/models/secret.ts | 65 +++++ frontend/src/app/services/secrets.service.ts | 152 +++++++++++ 32 files changed, 3031 insertions(+) create mode 100644 frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.css create mode 100644 frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.html create mode 100644 frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.spec.ts create mode 100644 frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.ts create mode 100644 frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.css create mode 100644 frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.html create mode 100644 frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts create mode 100644 frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.ts create mode 100644 frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.css create mode 100644 frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.html create mode 100644 frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.spec.ts create mode 100644 frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.ts create mode 100644 frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.css create mode 100644 frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.html create mode 100644 frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts create mode 100644 frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.ts create mode 100644 frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.css create mode 100644 frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.html create mode 100644 frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.spec.ts create mode 100644 frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.ts create mode 100644 frontend/src/app/components/secrets/secrets.component.css create mode 100644 frontend/src/app/components/secrets/secrets.component.html create mode 100644 frontend/src/app/components/secrets/secrets.component.spec.ts create mode 100644 frontend/src/app/components/secrets/secrets.component.ts create mode 100644 frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.css create mode 100644 frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.html create mode 100644 frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.spec.ts create mode 100644 frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.ts create mode 100644 frontend/src/app/models/secret.ts create mode 100644 frontend/src/app/services/secrets.service.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 4b6e4f98a..b728a9462 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -29,6 +29,7 @@ import { UserDeletedSuccessComponent } from './components/user-deleted-success/u import { UserSettingsComponent } from './components/user-settings/user-settings.component'; import { UsersComponent } from './components/users/users.component'; import { ZapierComponent } from './components/zapier/zapier.component'; +import { SecretsComponent } from './components/secrets/secrets.component'; import { SsoComponent } from './components/sso/sso.component'; const routes: Routes = [ @@ -47,6 +48,7 @@ const routes: Routes = [ // 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: 'secrets', pathMatch: 'full', component: SecretsComponent, canActivate: [AuthGuard], title: 'Secrets | Rocketadmin'}, {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'}, diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 7e19e03a0..ac5197fba 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 000000000..bd4b5d068 --- /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 000000000..ac68a8098 --- /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 000000000..d040824b2 --- /dev/null +++ b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.spec.ts @@ -0,0 +1,84 @@ +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 { AuditLogDialogComponent } from './audit-log-dialog.component'; +import { SecretsService } from 'src/app/services/secrets.service'; + +describe('AuditLogDialogComponent', () => { + let component: AuditLogDialogComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj>; + + const mockSecret = { + id: '1', + slug: 'test-secret', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + }; + + beforeEach(async () => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['getAuditLog']); + mockSecretsService.getAuditLog.and.returnValue(of({ + data: [ + { + id: '1', + action: 'create', + user: { id: '1', email: 'user@example.com' }, + accessedAt: '2024-01-01T00:00:00Z', + success: true, + }, + ], + pagination: { total: 1, currentPage: 1, perPage: 20, lastPage: 1 }, + })); + + 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(); + }); + + 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 have action labels', () => { + expect(component.actionLabels['create']).toBe('Created'); + expect(component.actionLabels['view']).toBe('Viewed'); + expect(component.actionLabels['delete']).toBe('Deleted'); + }); +}); 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 000000000..ad93a75a8 --- /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 000000000..dc1a95544 --- /dev/null +++ b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.css @@ -0,0 +1,39 @@ +.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; + } +} 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 000000000..2cacc5729 --- /dev/null +++ b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.html @@ -0,0 +1,77 @@ +

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 000000000..78f36c8b9 --- /dev/null +++ b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts @@ -0,0 +1,90 @@ +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 } from 'rxjs'; + +import { CreateSecretDialogComponent } from './create-secret-dialog.component'; +import { SecretsService } from 'src/app/services/secrets.service'; + +describe('CreateSecretDialogComponent', () => { + let component: CreateSecretDialogComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj>; + + beforeEach(async () => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['createSecret']); + mockSecretsService.createSecret.and.returnValue(of({ + id: '1', + slug: 'test', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + })); + + 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(); + }); + + it('should have invalid form initially', () => { + expect(component.form.invalid).toBeTrue(); + }); + + it('should validate slug pattern', () => { + const slugControl = component.form.get('slug'); + slugControl?.setValue('invalid slug!'); + expect(slugControl?.hasError('pattern')).toBeTrue(); + + slugControl?.setValue('valid-slug_123'); + expect(slugControl?.hasError('pattern')).toBeFalse(); + }); + + 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(); + + masterPasswordControl?.setValue('short'); + expect(masterPasswordControl?.hasError('minlength')).toBeTrue(); + + masterPasswordControl?.setValue('validpassword123'); + expect(masterPasswordControl?.valid).toBeTrue(); + }); + + it('should submit valid form', () => { + component.form.patchValue({ + slug: 'test-secret', + value: 'secret-value', + }); + + component.onSubmit(); + expect(mockSecretsService.createSecret).toHaveBeenCalled(); + }); +}); 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 000000000..9ca3dd9f5 --- /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 { MatNativeDateModule } 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'], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatCheckboxModule, + MatDatepickerModule, + MatNativeDateModule, + 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 000000000..88501b0c3 --- /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 000000000..aa82f371f --- /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 000000000..9dbcc53a0 --- /dev/null +++ b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.spec.ts @@ -0,0 +1,71 @@ +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 { DeleteSecretDialogComponent } from './delete-secret-dialog.component'; +import { SecretsService } from 'src/app/services/secrets.service'; + +describe('DeleteSecretDialogComponent', () => { + let component: DeleteSecretDialogComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj>; + + const mockSecret = { + id: '1', + slug: 'test-secret', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + }; + + beforeEach(async () => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['deleteSecret']); + mockSecretsService.deleteSecret.and.returnValue(of({ + message: 'Secret deleted successfully', + deletedAt: '2024-01-01', + })); + + 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: mockSecret } }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(DeleteSecretDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call deleteSecret on confirm', () => { + component.onDelete(); + expect(mockSecretsService.deleteSecret).toHaveBeenCalledWith('test-secret'); + }); + + it('should close dialog after successful deletion', () => { + component.onDelete(); + expect(mockDialogRef.close).toHaveBeenCalledWith(true); + }); +}); 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 000000000..bab015df6 --- /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 000000000..834733f9e --- /dev/null +++ b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.css @@ -0,0 +1,102 @@ +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; +} + +.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); + } +} + +.master-password-container { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 24px; +} + +.lock-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: #1976d2; + margin-bottom: 16px; +} + +@media (prefers-color-scheme: dark) { + .lock-icon { + color: #64b5f6; + } +} + +.master-password-container h3 { + margin: 0 0 8px 0; +} + +.master-password-container p { + color: rgba(0, 0, 0, 0.54); + margin: 0 0 24px 0; +} + +@media (prefers-color-scheme: dark) { + .master-password-container p { + color: rgba(255, 255, 255, 0.54); + } +} + +.expiration-section { + margin-bottom: 16px; +} + +.encryption-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-top: 16px; +} + +.encryption-note mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + flex-shrink: 0; +} + +@media (prefers-color-scheme: dark) { + .encryption-note { + background-color: rgba(100, 181, 246, 0.15); + color: #64b5f6; + } +} 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 000000000..21b45dde8 --- /dev/null +++ b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.html @@ -0,0 +1,96 @@ +

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

+ + + +
+ +

Loading secret...

+
+ + +
+ enhanced_encryption +

Master Password Required

+

This secret is protected with master password encryption.

+ + + Master Password + + + {{masterPasswordError}} + + + +
+ + +
+ + + Secret Value + + + {{valueError}} + + +
+ + Expiration Date + + + + + + + Remove expiration date + +
+ +

+ info + This secret uses master password encryption. Your changes will be encrypted with the same master password. +

+
+
+ + + + + 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 000000000..c1a4c8eec --- /dev/null +++ b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts @@ -0,0 +1,76 @@ +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 { EditSecretDialogComponent } from './edit-secret-dialog.component'; +import { SecretsService } from 'src/app/services/secrets.service'; + +describe('EditSecretDialogComponent', () => { + let component: EditSecretDialogComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj>; + + const mockSecret = { + id: '1', + slug: 'test-secret', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + }; + + beforeEach(async () => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['getSecret', 'updateSecret']); + mockSecretsService.getSecret.and.returnValue(of({ + ...mockSecret, + value: 'secret-value', + })); + mockSecretsService.updateSecret.and.returnValue(of(mockSecret)); + + 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: mockSecret } }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(EditSecretDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load secret on init', () => { + expect(mockSecretsService.getSecret).toHaveBeenCalledWith('test-secret', undefined); + }); + + it('should populate form with secret value', () => { + expect(component.form.get('value')?.value).toBe('secret-value'); + }); + + it('should submit updated secret', () => { + component.form.patchValue({ value: 'new-value' }); + component.onSubmit(); + expect(mockSecretsService.updateSecret).toHaveBeenCalled(); + }); +}); 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 000000000..7e969ca9b --- /dev/null +++ b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.ts @@ -0,0 +1,158 @@ +import { Component, Inject, OnInit } 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 { MatNativeDateModule } from '@angular/material/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Angulartics2 } from 'angulartics2'; + +import { SecretsService } from 'src/app/services/secrets.service'; +import { Secret, SecretWithValue } from 'src/app/models/secret'; + +@Component({ + selector: 'app-edit-secret-dialog', + templateUrl: './edit-secret-dialog.component.html', + styleUrls: ['./edit-secret-dialog.component.css'], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatDatepickerModule, + MatNativeDateModule, + MatIconModule, + MatCheckboxModule, + MatProgressSpinnerModule, + MatTooltipModule, + ] +}) +export class EditSecretDialogComponent implements OnInit { + public form: FormGroup; + public loading = true; + public submitting = false; + public showValue = false; + public requiresMasterPassword = false; + public masterPassword = ''; + public masterPasswordError = ''; + public showMasterPassword = false; + public currentSecret: SecretWithValue | null = null; + 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: [null], + }); + } + + ngOnInit(): void { + this.loadSecret(); + } + + loadSecret(masterPassword?: string): void { + this.loading = true; + this.masterPasswordError = ''; + + this._secrets.getSecret(this.data.secret.slug, masterPassword).subscribe({ + next: (secret) => { + this.currentSecret = secret; + this.form.patchValue({ + value: secret.value, + expiresAt: secret.expiresAt ? new Date(secret.expiresAt) : null, + }); + this.loading = false; + this.requiresMasterPassword = false; + this.masterPassword = masterPassword || ''; + }, + error: (err) => { + this.loading = false; + if (err.status === 403) { + this.requiresMasterPassword = true; + if (masterPassword) { + this.masterPasswordError = 'Invalid master password'; + } + } + } + }); + } + + submitMasterPassword(): void { + if (!this.masterPassword) { + this.masterPasswordError = 'Please enter the master password'; + return; + } + this.loadSecret(this.masterPassword); + } + + 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 '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; + + 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: () => { + this.submitting = false; + } + }); + } +} 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 000000000..ee5f25435 --- /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 000000000..a5a13ce30 --- /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 000000000..9b1a69eb9 --- /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 000000000..1c789bddb --- /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 000000000..3e3c46754 --- /dev/null +++ b/frontend/src/app/components/secrets/secrets.component.css @@ -0,0 +1,258 @@ +.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-button { + text-transform: none; + 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 000000000..9b670d24c --- /dev/null +++ b/frontend/src/app/components/secrets/secrets.component.html @@ -0,0 +1,158 @@ + + +
+
+

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 + + 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 000000000..1da66542b --- /dev/null +++ b/frontend/src/app/components/secrets/secrets.component.spec.ts @@ -0,0 +1,111 @@ +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 { MatDialog } from '@angular/material/dialog'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { Angulartics2Module } from 'angulartics2'; +import { of } from 'rxjs'; + +import { SecretsComponent } from './secrets.component'; +import { SecretsService } from 'src/app/services/secrets.service'; +import { CompanyService } from 'src/app/services/company.service'; + +describe('SecretsComponent', () => { + let component: SecretsComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockCompanyService: jasmine.SpyObj; + let mockDialog: jasmine.SpyObj; + + beforeEach(async () => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['fetchSecrets'], { + cast: of('') + }); + mockSecretsService.fetchSecrets.and.returnValue(of({ + data: [], + pagination: { total: 0, currentPage: 1, perPage: 20, lastPage: 1 } + })); + + 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 detect expired secrets', () => { + const expiredSecret = { + id: '1', + slug: 'test', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + expiresAt: '2024-01-01', + masterEncryption: false, + }; + expect(component.isExpired(expiredSecret)).toBeTrue(); + }); + + it('should detect non-expired secrets', () => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + const validSecret = { + id: '1', + slug: 'test', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + expiresAt: futureDate.toISOString(), + masterEncryption: false, + }; + expect(component.isExpired(validSecret)).toBeFalse(); + }); + + it('should detect secrets expiring soon', () => { + const soonDate = new Date(); + soonDate.setDate(soonDate.getDate() + 3); + const expiringSoonSecret = { + id: '1', + slug: 'test', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + expiresAt: soonDate.toISOString(), + masterEncryption: false, + }; + expect(component.isExpiringSoon(expiringSoonSecret)).toBeTrue(); + }); + + it('should open create dialog', () => { + component.openCreateDialog(); + expect(mockDialog.open).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 000000000..63483a087 --- /dev/null +++ b/frontend/src/app/components/secrets/secrets.component.ts @@ -0,0 +1,190 @@ +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 { ViewSecretDialogComponent } from './view-secret-dialog/view-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', + }); + } + + openViewDialog(secret: Secret): void { + this.dialog.open(ViewSecretDialogComponent, { + width: '600px', + data: { secret }, + }); + this.angulartics2.eventTrack.next({ + action: 'Secrets: view 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/components/secrets/view-secret-dialog/view-secret-dialog.component.css b/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.css new file mode 100644 index 000000000..1f269dac5 --- /dev/null +++ b/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.css @@ -0,0 +1,223 @@ +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: 500px; + min-height: 200px; +} + +@media (width <= 600px) { + 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); + } +} + +.master-password-container { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 24px; +} + +.lock-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: #1976d2; + margin-bottom: 16px; +} + +@media (prefers-color-scheme: dark) { + .lock-icon { + color: #64b5f6; + } +} + +.master-password-container h3 { + margin: 0 0 8px 0; +} + +.master-password-container p { + color: rgba(0, 0, 0, 0.54); + margin: 0 0 24px 0; +} + +@media (prefers-color-scheme: dark) { + .master-password-container p { + color: rgba(255, 255, 255, 0.54); + } +} + +.full-width { + width: 100%; + max-width: 300px; +} + +.secret-details { + padding: 8px 0; +} + +.expired-warning { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background-color: #ffebee; + color: #c62828; + border-radius: 8px; + margin-bottom: 24px; +} + +@media (prefers-color-scheme: dark) { + .expired-warning { + background-color: rgba(198, 40, 40, 0.2); + color: #ef5350; + } +} + +.value-section { + margin-bottom: 24px; +} + +.section-label { + display: block; + font-size: 12px; + font-weight: 500; + color: rgba(0, 0, 0, 0.54); + margin-bottom: 8px; + text-transform: uppercase; +} + +@media (prefers-color-scheme: dark) { + .section-label { + color: rgba(255, 255, 255, 0.54); + } +} + +.value-container { + display: flex; + align-items: flex-start; + gap: 8px; + background-color: rgba(0, 0, 0, 0.04); + border-radius: 8px; + padding: 12px 16px; +} + +@media (prefers-color-scheme: dark) { + .value-container { + background-color: rgba(255, 255, 255, 0.08); + } +} + +.value-display { + flex: 1; + font-family: monospace; + word-break: break-all; + white-space: pre-wrap; +} + +.value-display.masked { + letter-spacing: 2px; +} + +.value-actions { + display: flex; + flex-shrink: 0; +} + +.metadata-section { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +@media (width <= 600px) { + .metadata-section { + grid-template-columns: 1fr; + } +} + +.metadata-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metadata-item label { + font-size: 12px; + font-weight: 500; + color: rgba(0, 0, 0, 0.54); + text-transform: uppercase; +} + +@media (prefers-color-scheme: dark) { + .metadata-item label { + color: rgba(255, 255, 255, 0.54); + } +} + +.metadata-item span { + display: flex; + align-items: center; + gap: 4px; +} + +.metadata-item mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +.no-expiry { + color: rgba(0, 0, 0, 0.38); +} + +@media (prefers-color-scheme: dark) { + .no-expiry { + color: rgba(255, 255, 255, 0.38); + } +} + +.master-encrypted { + color: #1976d2; +} + +@media (prefers-color-scheme: dark) { + .master-encrypted { + color: #64b5f6; + } +} diff --git a/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.html b/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.html new file mode 100644 index 000000000..8e8a6b453 --- /dev/null +++ b/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.html @@ -0,0 +1,111 @@ +

+ key + {{data.secret.slug}} +

+ + + +
+ +

Loading secret...

+
+ + +
+ enhanced_encryption +

Master Password Required

+

This secret is protected with master password encryption.

+ + + Master Password + + + {{masterPasswordError}} + + + +
+ + +
+ +
+ error + This secret has expired on {{secret.expiresAt | date:'medium'}} +
+ + +
+ +
+
+ {{showValue ? secret.value : '••••••••••••••••••••'}} +
+
+ + +
+
+
+ + + +
+
+ + + + diff --git a/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.spec.ts b/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.spec.ts new file mode 100644 index 000000000..dc5df37ad --- /dev/null +++ b/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.spec.ts @@ -0,0 +1,81 @@ +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 { ViewSecretDialogComponent } from './view-secret-dialog.component'; +import { SecretsService } from 'src/app/services/secrets.service'; +import { NotificationsService } from 'src/app/services/notifications.service'; + +describe('ViewSecretDialogComponent', () => { + let component: ViewSecretDialogComponent; + let fixture: ComponentFixture; + let mockSecretsService: jasmine.SpyObj; + let mockNotificationsService: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj>; + + const mockSecret = { + id: '1', + slug: 'test-secret', + companyId: '1', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + masterEncryption: false, + }; + + beforeEach(async () => { + mockSecretsService = jasmine.createSpyObj('SecretsService', ['getSecret']); + mockSecretsService.getSecret.and.returnValue(of({ + ...mockSecret, + value: 'secret-value', + })); + + mockNotificationsService = jasmine.createSpyObj('NotificationsService', ['showSuccessSnackbar']); + mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [ + ViewSecretDialogComponent, + BrowserAnimationsModule, + MatSnackBarModule, + Angulartics2Module.forRoot(), + ], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: SecretsService, useValue: mockSecretsService }, + { provide: NotificationsService, useValue: mockNotificationsService }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: { secret: mockSecret } }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ViewSecretDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load secret on init', () => { + expect(mockSecretsService.getSecret).toHaveBeenCalledWith('test-secret', undefined); + }); + + it('should toggle value visibility', () => { + expect(component.showValue).toBeFalse(); + component.toggleValueVisibility(); + expect(component.showValue).toBeTrue(); + }); + + it('should copy to clipboard', () => { + component.secret = { ...mockSecret, value: 'secret-value' }; + component.copyToClipboard(); + expect(mockNotificationsService.showSuccessSnackbar).toHaveBeenCalledWith('Secret copied to clipboard'); + }); +}); diff --git a/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.ts b/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.ts new file mode 100644 index 000000000..a7a12120e --- /dev/null +++ b/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.ts @@ -0,0 +1,111 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } 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 { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Clipboard } from '@angular/cdk/clipboard'; +import { Angulartics2 } from 'angulartics2'; + +import { SecretsService } from 'src/app/services/secrets.service'; +import { NotificationsService } from 'src/app/services/notifications.service'; +import { Secret, SecretWithValue } from 'src/app/models/secret'; + +@Component({ + selector: 'app-view-secret-dialog', + templateUrl: './view-secret-dialog.component.html', + styleUrls: ['./view-secret-dialog.component.css'], + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatIconModule, + MatProgressSpinnerModule, + MatTooltipModule, + ] +}) +export class ViewSecretDialogComponent implements OnInit { + public secret: SecretWithValue | null = null; + public loading = true; + public showValue = false; + public requiresMasterPassword = false; + public masterPassword = ''; + public masterPasswordError = ''; + public showMasterPassword = false; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: { secret: Secret }, + private dialogRef: MatDialogRef, + private _secrets: SecretsService, + private _notifications: NotificationsService, + private clipboard: Clipboard, + private angulartics2: Angulartics2 + ) {} + + ngOnInit(): void { + this.loadSecret(); + } + + loadSecret(masterPassword?: string): void { + this.loading = true; + this.masterPasswordError = ''; + + this._secrets.getSecret(this.data.secret.slug, masterPassword).subscribe({ + next: (secret) => { + this.secret = secret; + this.loading = false; + this.requiresMasterPassword = false; + this.angulartics2.eventTrack.next({ + action: 'Secrets: secret viewed', + }); + }, + error: (err) => { + this.loading = false; + if (err.status === 403) { + this.requiresMasterPassword = true; + if (masterPassword) { + this.masterPasswordError = 'Invalid master password'; + } + } + } + }); + } + + submitMasterPassword(): void { + if (!this.masterPassword) { + this.masterPasswordError = 'Please enter the master password'; + return; + } + this.loadSecret(this.masterPassword); + } + + toggleValueVisibility(): void { + this.showValue = !this.showValue; + } + + toggleMasterPasswordVisibility(): void { + this.showMasterPassword = !this.showMasterPassword; + } + + copyToClipboard(): void { + if (this.secret?.value) { + this.clipboard.copy(this.secret.value); + this._notifications.showSuccessSnackbar('Secret copied to clipboard'); + this.angulartics2.eventTrack.next({ + action: 'Secrets: secret copied to clipboard', + }); + } + } + + isExpired(): boolean { + if (!this.secret?.expiresAt) return false; + return new Date(this.secret.expiresAt) < new Date(); + } +} diff --git a/frontend/src/app/models/secret.ts b/frontend/src/app/models/secret.ts new file mode 100644 index 000000000..5aeb715b2 --- /dev/null +++ b/frontend/src/app/models/secret.ts @@ -0,0 +1,65 @@ +export interface Secret { + id: string; + slug: string; + companyId: string; + createdAt: string; + updatedAt: string; + lastAccessedAt?: string; + expiresAt?: string; + masterEncryption: boolean; +} + +export interface SecretWithValue extends Secret { + value: string; +} + +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/services/secrets.service.ts b/frontend/src/app/services/secrets.service.ts new file mode 100644 index 000000000..0e93f81e9 --- /dev/null +++ b/frontend/src/app/services/secrets.service.ts @@ -0,0 +1,152 @@ +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, + SecretWithValue, + 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; + }) + ); + } + + getSecret(slug: string, masterPassword?: string): Observable { + let headers = new HttpHeaders(); + if (masterPassword) { + headers = headers.set('masterPassword', masterPassword); + } + + return this._http.get(`/secrets/${slug}`, { headers }) + .pipe( + map(res => res), + catchError((err) => { + console.log(err); + if (err.status === 403) { + // Master password required or invalid - let component handle + return throwError(() => err); + } + if (err.status === 410) { + this._notifications.showErrorSnackbar('This secret has expired'); + } else { + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch secret'); + } + 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('masterPassword', 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; + }) + ); + } +} From 42a5d0ffe0b68056293e2bbf602461bc231c64a6 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 5 Dec 2025 13:30:14 +0000 Subject: [PATCH 2/9] code splitting --- frontend/src/app/app-routing.module.ts | 91 +++++++------------ .../app/routes/company-invitation.routes.ts | 11 +++ .../src/app/routes/password-change.routes.ts | 12 +++ .../src/app/routes/password-reset.routes.ts | 11 +++ .../src/app/routes/registration.routes.ts | 11 +++ frontend/src/main.ts | 4 - 6 files changed, 76 insertions(+), 64 deletions(-) create mode 100644 frontend/src/app/routes/company-invitation.routes.ts create mode 100644 frontend/src/app/routes/password-change.routes.ts create mode 100644 frontend/src/app/routes/password-reset.routes.ts create mode 100644 frontend/src/app/routes/registration.routes.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index b728a9462..882bf4b46 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -1,71 +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 { SecretsComponent } from './components/secrets/secrets.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: 'secrets', pathMatch: 'full', component: SecretsComponent, canActivate: [AuthGuard], title: 'Secrets | Rocketadmin'}, - {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/routes/company-invitation.routes.ts b/frontend/src/app/routes/company-invitation.routes.ts new file mode 100644 index 000000000..0b135aeba --- /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 000000000..a37597715 --- /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 000000000..8cc370cbd --- /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 000000000..b83cf5d27 --- /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/main.ts b/frontend/src/main.ts index 1213eb3d9..dcc71a5b6 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, From 80bd4fef325f34902e8335f48d3cce66649619c7 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 12 Dec 2025 10:31:29 +0000 Subject: [PATCH 3/9] remove viewing secrets --- .../data-structures/found-secret.ds.ts | 1 - .../data-structures/get-secret.ds.ts | 1 - .../application/dto/found-secret.dto.ts | 8 - .../use-cases/get-secret-by-slug.use.case.ts | 37 +-- .../user-secret/user-secret.controller.ts | 13 +- .../utils/build-created-secret.dto.ts | 1 - .../utils/build-found-secret.ds.ts | 3 +- .../utils/build-found-secret.dto.ts | 1 - .../utils/build-updated-secret.dto.ts | 1 - .../edit-secret-dialog.component.css | 84 ++----- .../edit-secret-dialog.component.html | 73 ++---- .../edit-secret-dialog.component.spec.ts | 20 +- .../edit-secret-dialog.component.ts | 64 ++--- .../components/secrets/secrets.component.css | 3 +- .../components/secrets/secrets.component.html | 9 +- .../components/secrets/secrets.component.ts | 11 - .../view-secret-dialog.component.css | 223 ------------------ .../view-secret-dialog.component.html | 111 --------- .../view-secret-dialog.component.spec.ts | 81 ------- .../view-secret-dialog.component.ts | 111 --------- frontend/src/app/models/secret.ts | 4 - frontend/src/app/services/secrets.service.ts | 26 -- 22 files changed, 75 insertions(+), 811 deletions(-) delete mode 100644 frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.css delete mode 100644 frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.html delete mode 100644 frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.spec.ts delete mode 100644 frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.ts 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 8e119df65..fbd6320ce 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 9ad4fcd6e..739d0d1c6 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 65d32668c..5bd60eb59 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 3e73793da..1091a4b90 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 fdfbdc9db..2e92ca020 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 898800d64..d51fc57cc 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 81504154f..eb542c247 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 3fd9dce0c..6826ca3ed 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/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 index 834733f9e..c796706b2 100644 --- 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 @@ -14,68 +14,7 @@ mat-dialog-content { margin-bottom: 16px; } -.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); - } -} - -.master-password-container { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: 24px; -} - -.lock-icon { - font-size: 48px; - width: 48px; - height: 48px; - color: #1976d2; - margin-bottom: 16px; -} - -@media (prefers-color-scheme: dark) { - .lock-icon { - color: #64b5f6; - } -} - -.master-password-container h3 { - margin: 0 0 8px 0; -} - -.master-password-container p { - color: rgba(0, 0, 0, 0.54); - margin: 0 0 24px 0; -} - -@media (prefers-color-scheme: dark) { - .master-password-container p { - color: rgba(255, 255, 255, 0.54); - } -} - -.expiration-section { - margin-bottom: 16px; -} - -.encryption-note { +.info-note { display: flex; align-items: flex-start; gap: 8px; @@ -84,10 +23,10 @@ mat-dialog-content { border-radius: 8px; color: #1565c0; font-size: 14px; - margin-top: 16px; + margin-bottom: 20px; } -.encryption-note mat-icon { +.info-note mat-icon { font-size: 20px; width: 20px; height: 20px; @@ -95,8 +34,23 @@ mat-dialog-content { } @media (prefers-color-scheme: dark) { - .encryption-note { + .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; +} 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 index 21b45dde8..ad737973c 100644 --- 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 @@ -1,54 +1,18 @@

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

- -
- -

Loading secret...

-
- - -
- enhanced_encryption -

Master Password Required

-

This secret is protected with master password encryption.

- - - Master Password - - - {{masterPasswordError}} - - - -
- - -
+ +

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

- Secret Value + New Secret Value + Required to encrypt the new value + + {{masterPasswordError}} +
+ {{secret.slug}} @@ -117,11 +115,6 @@

No secrets found

more_vert - - {{masterPasswordError}} - - - - - - -
- -
- error - This secret has expired on {{secret.expiresAt | date:'medium'}} -
- - -
- -
-
- {{showValue ? secret.value : '••••••••••••••••••••'}} -
-
- - -
-
-
- - - -
- - - - - diff --git a/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.spec.ts b/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.spec.ts deleted file mode 100644 index dc5df37ad..000000000 --- a/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 { ViewSecretDialogComponent } from './view-secret-dialog.component'; -import { SecretsService } from 'src/app/services/secrets.service'; -import { NotificationsService } from 'src/app/services/notifications.service'; - -describe('ViewSecretDialogComponent', () => { - let component: ViewSecretDialogComponent; - let fixture: ComponentFixture; - let mockSecretsService: jasmine.SpyObj; - let mockNotificationsService: jasmine.SpyObj; - let mockDialogRef: jasmine.SpyObj>; - - const mockSecret = { - id: '1', - slug: 'test-secret', - companyId: '1', - createdAt: '2024-01-01', - updatedAt: '2024-01-01', - masterEncryption: false, - }; - - beforeEach(async () => { - mockSecretsService = jasmine.createSpyObj('SecretsService', ['getSecret']); - mockSecretsService.getSecret.and.returnValue(of({ - ...mockSecret, - value: 'secret-value', - })); - - mockNotificationsService = jasmine.createSpyObj('NotificationsService', ['showSuccessSnackbar']); - mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); - - await TestBed.configureTestingModule({ - imports: [ - ViewSecretDialogComponent, - BrowserAnimationsModule, - MatSnackBarModule, - Angulartics2Module.forRoot(), - ], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SecretsService, useValue: mockSecretsService }, - { provide: NotificationsService, useValue: mockNotificationsService }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: { secret: mockSecret } }, - ] - }).compileComponents(); - - fixture = TestBed.createComponent(ViewSecretDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load secret on init', () => { - expect(mockSecretsService.getSecret).toHaveBeenCalledWith('test-secret', undefined); - }); - - it('should toggle value visibility', () => { - expect(component.showValue).toBeFalse(); - component.toggleValueVisibility(); - expect(component.showValue).toBeTrue(); - }); - - it('should copy to clipboard', () => { - component.secret = { ...mockSecret, value: 'secret-value' }; - component.copyToClipboard(); - expect(mockNotificationsService.showSuccessSnackbar).toHaveBeenCalledWith('Secret copied to clipboard'); - }); -}); diff --git a/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.ts b/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.ts deleted file mode 100644 index a7a12120e..000000000 --- a/frontend/src/app/components/secrets/view-secret-dialog/view-secret-dialog.component.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Component, Inject, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } 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 { MatIconModule } from '@angular/material/icon'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Clipboard } from '@angular/cdk/clipboard'; -import { Angulartics2 } from 'angulartics2'; - -import { SecretsService } from 'src/app/services/secrets.service'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { Secret, SecretWithValue } from 'src/app/models/secret'; - -@Component({ - selector: 'app-view-secret-dialog', - templateUrl: './view-secret-dialog.component.html', - styleUrls: ['./view-secret-dialog.component.css'], - imports: [ - CommonModule, - FormsModule, - MatDialogModule, - MatButtonModule, - MatInputModule, - MatFormFieldModule, - MatIconModule, - MatProgressSpinnerModule, - MatTooltipModule, - ] -}) -export class ViewSecretDialogComponent implements OnInit { - public secret: SecretWithValue | null = null; - public loading = true; - public showValue = false; - public requiresMasterPassword = false; - public masterPassword = ''; - public masterPasswordError = ''; - public showMasterPassword = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: { secret: Secret }, - private dialogRef: MatDialogRef, - private _secrets: SecretsService, - private _notifications: NotificationsService, - private clipboard: Clipboard, - private angulartics2: Angulartics2 - ) {} - - ngOnInit(): void { - this.loadSecret(); - } - - loadSecret(masterPassword?: string): void { - this.loading = true; - this.masterPasswordError = ''; - - this._secrets.getSecret(this.data.secret.slug, masterPassword).subscribe({ - next: (secret) => { - this.secret = secret; - this.loading = false; - this.requiresMasterPassword = false; - this.angulartics2.eventTrack.next({ - action: 'Secrets: secret viewed', - }); - }, - error: (err) => { - this.loading = false; - if (err.status === 403) { - this.requiresMasterPassword = true; - if (masterPassword) { - this.masterPasswordError = 'Invalid master password'; - } - } - } - }); - } - - submitMasterPassword(): void { - if (!this.masterPassword) { - this.masterPasswordError = 'Please enter the master password'; - return; - } - this.loadSecret(this.masterPassword); - } - - toggleValueVisibility(): void { - this.showValue = !this.showValue; - } - - toggleMasterPasswordVisibility(): void { - this.showMasterPassword = !this.showMasterPassword; - } - - copyToClipboard(): void { - if (this.secret?.value) { - this.clipboard.copy(this.secret.value); - this._notifications.showSuccessSnackbar('Secret copied to clipboard'); - this.angulartics2.eventTrack.next({ - action: 'Secrets: secret copied to clipboard', - }); - } - } - - isExpired(): boolean { - if (!this.secret?.expiresAt) return false; - return new Date(this.secret.expiresAt) < new Date(); - } -} diff --git a/frontend/src/app/models/secret.ts b/frontend/src/app/models/secret.ts index 5aeb715b2..4bdff7ca7 100644 --- a/frontend/src/app/models/secret.ts +++ b/frontend/src/app/models/secret.ts @@ -9,10 +9,6 @@ export interface Secret { masterEncryption: boolean; } -export interface SecretWithValue extends Secret { - value: string; -} - export interface SecretListResponse { data: Secret[]; pagination: SecretPagination; diff --git a/frontend/src/app/services/secrets.service.ts b/frontend/src/app/services/secrets.service.ts index 0e93f81e9..3254c9ac0 100644 --- a/frontend/src/app/services/secrets.service.ts +++ b/frontend/src/app/services/secrets.service.ts @@ -5,7 +5,6 @@ import { catchError, map } from 'rxjs/operators'; import { NotificationsService } from './notifications.service'; import { Secret, - SecretWithValue, SecretListResponse, AuditLogResponse, CreateSecretPayload, @@ -45,31 +44,6 @@ export class SecretsService { ); } - getSecret(slug: string, masterPassword?: string): Observable { - let headers = new HttpHeaders(); - if (masterPassword) { - headers = headers.set('masterPassword', masterPassword); - } - - return this._http.get(`/secrets/${slug}`, { headers }) - .pipe( - map(res => res), - catchError((err) => { - console.log(err); - if (err.status === 403) { - // Master password required or invalid - let component handle - return throwError(() => err); - } - if (err.status === 410) { - this._notifications.showErrorSnackbar('This secret has expired'); - } else { - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch secret'); - } - return EMPTY; - }) - ); - } - createSecret(payload: CreateSecretPayload): Observable { return this._http.post('/secrets', payload) .pipe( From 0d8fe7f22205bf26465d491ca08cf028e0f2ba13 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 12 Dec 2025 10:51:19 +0000 Subject: [PATCH 4/9] date picker ui fix --- .../create-secret-dialog.component.ts | 4 ++-- .../edit-secret-dialog/edit-secret-dialog.component.ts | 4 ++-- frontend/src/custom-theme.scss | 10 ++++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) 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 index 9ca3dd9f5..2b052589c 100644 --- 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 @@ -7,7 +7,7 @@ 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 { MatNativeDateModule } from '@angular/material/core'; +import { provideNativeDateAdapter } from '@angular/material/core'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Angulartics2 } from 'angulartics2'; @@ -18,6 +18,7 @@ import { SecretsService } from 'src/app/services/secrets.service'; selector: 'app-create-secret-dialog', templateUrl: './create-secret-dialog.component.html', styleUrls: ['./create-secret-dialog.component.css'], + providers: [provideNativeDateAdapter()], imports: [ CommonModule, FormsModule, @@ -28,7 +29,6 @@ import { SecretsService } from 'src/app/services/secrets.service'; MatFormFieldModule, MatCheckboxModule, MatDatepickerModule, - MatNativeDateModule, MatIconModule, MatTooltipModule, ] 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 index 8e7a8e393..0bff581eb 100644 --- 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 @@ -6,7 +6,7 @@ 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 { MatNativeDateModule } from '@angular/material/core'; +import { provideNativeDateAdapter } from '@angular/material/core'; import { MatIconModule } from '@angular/material/icon'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -19,6 +19,7 @@ import { Secret } from 'src/app/models/secret'; selector: 'app-edit-secret-dialog', templateUrl: './edit-secret-dialog.component.html', styleUrls: ['./edit-secret-dialog.component.css'], + providers: [provideNativeDateAdapter()], imports: [ CommonModule, FormsModule, @@ -28,7 +29,6 @@ import { Secret } from 'src/app/models/secret'; MatInputModule, MatFormFieldModule, MatDatepickerModule, - MatNativeDateModule, MatIconModule, MatCheckboxModule, MatTooltipModule, diff --git a/frontend/src/custom-theme.scss b/frontend/src/custom-theme.scss index 173a13add..697e8e938 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; } From bed87ef5521f7ba94c6e328de609a8894dc547e9 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 12 Dec 2025 10:57:32 +0000 Subject: [PATCH 5/9] properly mask passwords --- .../create-secret-dialog/create-secret-dialog.component.css | 4 ++++ .../create-secret-dialog/create-secret-dialog.component.html | 4 +++- .../edit-secret-dialog/edit-secret-dialog.component.css | 4 ++++ .../edit-secret-dialog/edit-secret-dialog.component.html | 4 +++- 4 files changed, 14 insertions(+), 2 deletions(-) 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 index dc1a95544..8a7f2ee2f 100644 --- 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 @@ -37,3 +37,7 @@ 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 index 2cacc5729..5b6ac3a45 100644 --- 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 @@ -14,9 +14,11 @@

Create Secret

Secret Value