Security hardening and edge case fixes across frontend
Security Improvements: - Add input sanitization utilities (XSS, SQL injection prevention) - Add token validation with JWT structure verification - Add secure form validators with pattern enforcement - Implement proper token storage with encryption support Service Hardening: - Add timeout (30s) and retry logic (3 attempts) to all API calls - Add UUID validation for all ID parameters - Add null/undefined checks with defensive defaults - Proper error propagation with typed error handling Component Fixes: - Fix memory leaks with takeUntilDestroyed pattern - Remove mock data fallbacks in error handlers - Add proper loading/error state management - Add form field length limits and validation Files affected: 51 (6000+ lines added for security)
This commit is contained in:
@@ -14,7 +14,16 @@ import { PageHeaderComponent } from '../../../shared/components/page-header/page
|
||||
import { WorkflowService } from '../services/workflow.service';
|
||||
import { DepartmentService } from '../../departments/services/department.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { DepartmentResponseDto } from '../../../api/models';
|
||||
import {
|
||||
INPUT_LIMITS,
|
||||
noScriptValidator,
|
||||
noNullBytesValidator,
|
||||
notOnlyWhitespaceValidator,
|
||||
normalizeWhitespace,
|
||||
} from '../../../shared/utils/form-validators';
|
||||
import { createSubmitDebounce } from '../../../shared/utils/form-utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workflow-form',
|
||||
@@ -58,15 +67,24 @@ import { DepartmentResponseDto } from '../../../api/models';
|
||||
<div class="form-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Workflow Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="e.g., Resort License Workflow" />
|
||||
<input matInput formControlName="name" placeholder="e.g., Resort License Workflow" [maxlength]="limits.NAME_MAX" />
|
||||
@if (form.controls.name.hasError('required')) {
|
||||
<mat-error>Name is required</mat-error>
|
||||
}
|
||||
@if (form.controls.name.hasError('minlength')) {
|
||||
<mat-error>Minimum {{ limits.NAME_MIN }} characters required</mat-error>
|
||||
}
|
||||
@if (form.controls.name.hasError('maxlength')) {
|
||||
<mat-error>Maximum {{ limits.NAME_MAX }} characters allowed</mat-error>
|
||||
}
|
||||
@if (form.controls.name.hasError('dangerousContent')) {
|
||||
<mat-error>Invalid characters detected</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Request Type</mat-label>
|
||||
<mat-select formControlName="requestType">
|
||||
<mat-label>Workflow Type</mat-label>
|
||||
<mat-select formControlName="workflowType">
|
||||
<mat-option value="NEW_LICENSE">New License</mat-option>
|
||||
<mat-option value="RENEWAL">Renewal</mat-option>
|
||||
<mat-option value="AMENDMENT">Amendment</mat-option>
|
||||
@@ -75,7 +93,14 @@ import { DepartmentResponseDto } from '../../../api/models';
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea matInput formControlName="description" rows="2"></textarea>
|
||||
<textarea matInput formControlName="description" rows="2" [maxlength]="limits.DESCRIPTION_MAX"></textarea>
|
||||
@if (form.controls.description.hasError('maxlength')) {
|
||||
<mat-error>Maximum {{ limits.DESCRIPTION_MAX }} characters allowed</mat-error>
|
||||
}
|
||||
@if (form.controls.description.hasError('dangerousContent')) {
|
||||
<mat-error>Invalid characters detected</mat-error>
|
||||
}
|
||||
<mat-hint align="end">{{ form.controls.description.value?.length || 0 }}/{{ limits.DESCRIPTION_MAX }}</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,9 +108,9 @@ import { DepartmentResponseDto } from '../../../api/models';
|
||||
<div class="form-section">
|
||||
<div class="section-header">
|
||||
<h3>Approval Stages</h3>
|
||||
<button mat-button type="button" color="primary" (click)="addStage()">
|
||||
<button mat-button type="button" color="primary" (click)="addStage()" [disabled]="stagesArray.length >= maxStages">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Stage
|
||||
Add Stage {{ stagesArray.length >= maxStages ? '(max reached)' : '' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +126,7 @@ import { DepartmentResponseDto } from '../../../api/models';
|
||||
<div class="stage-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Stage Name</mat-label>
|
||||
<input matInput formControlName="name" />
|
||||
<input matInput formControlName="name" [maxlength]="limits.NAME_MAX" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Department</mat-label>
|
||||
@@ -228,6 +253,10 @@ export class WorkflowFormComponent implements OnInit {
|
||||
private readonly workflowService = inject(WorkflowService);
|
||||
private readonly departmentService = inject(DepartmentService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
private readonly authService = inject(AuthService);
|
||||
|
||||
/** Debounce handler to prevent double-click submissions */
|
||||
private readonly submitDebounce = createSubmitDebounce(500);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly submitting = signal(false);
|
||||
@@ -235,10 +264,27 @@ export class WorkflowFormComponent implements OnInit {
|
||||
readonly departments = signal<DepartmentResponseDto[]>([]);
|
||||
private workflowId: string | null = null;
|
||||
|
||||
/** Input limits exposed for template binding */
|
||||
readonly limits = INPUT_LIMITS;
|
||||
|
||||
/** Maximum number of approval stages allowed */
|
||||
readonly maxStages = 20;
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
name: ['', [Validators.required]],
|
||||
description: [''],
|
||||
requestType: ['NEW_LICENSE', [Validators.required]],
|
||||
name: ['', [
|
||||
Validators.required,
|
||||
Validators.minLength(INPUT_LIMITS.NAME_MIN),
|
||||
Validators.maxLength(INPUT_LIMITS.NAME_MAX),
|
||||
noScriptValidator(),
|
||||
noNullBytesValidator(),
|
||||
notOnlyWhitespaceValidator(),
|
||||
]],
|
||||
description: ['', [
|
||||
Validators.maxLength(INPUT_LIMITS.DESCRIPTION_MAX),
|
||||
noScriptValidator(),
|
||||
noNullBytesValidator(),
|
||||
]],
|
||||
workflowType: ['NEW_LICENSE', [Validators.required]],
|
||||
stages: this.fb.array([this.createStageGroup()]),
|
||||
});
|
||||
|
||||
@@ -272,7 +318,7 @@ export class WorkflowFormComponent implements OnInit {
|
||||
this.form.patchValue({
|
||||
name: workflow.name,
|
||||
description: workflow.description || '',
|
||||
requestType: workflow.requestType,
|
||||
workflowType: workflow.workflowType,
|
||||
});
|
||||
|
||||
this.stagesArray.clear();
|
||||
@@ -300,7 +346,14 @@ export class WorkflowFormComponent implements OnInit {
|
||||
private createStageGroup() {
|
||||
return this.fb.group({
|
||||
id: [''],
|
||||
name: ['', Validators.required],
|
||||
name: ['', [
|
||||
Validators.required,
|
||||
Validators.minLength(INPUT_LIMITS.NAME_MIN),
|
||||
Validators.maxLength(INPUT_LIMITS.NAME_MAX),
|
||||
noScriptValidator(),
|
||||
noNullBytesValidator(),
|
||||
notOnlyWhitespaceValidator(),
|
||||
]],
|
||||
departmentId: ['', Validators.required],
|
||||
order: [1],
|
||||
isRequired: [true],
|
||||
@@ -308,6 +361,12 @@ export class WorkflowFormComponent implements OnInit {
|
||||
}
|
||||
|
||||
addStage(): void {
|
||||
// Prevent adding more than max stages
|
||||
if (this.stagesArray.length >= this.maxStages) {
|
||||
this.notification.warning(`Maximum ${this.maxStages} stages allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
const order = this.stagesArray.length + 1;
|
||||
const group = this.createStageGroup();
|
||||
group.patchValue({ order });
|
||||
@@ -328,18 +387,41 @@ export class WorkflowFormComponent implements OnInit {
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
// Debounce to prevent double-click submissions
|
||||
this.submitDebounce(() => this.performSubmit());
|
||||
}
|
||||
|
||||
private performSubmit(): void {
|
||||
// Prevent submission if already in progress
|
||||
if (this.submitting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
// Also mark stage controls as touched
|
||||
this.stagesArray.controls.forEach(control => {
|
||||
control.markAllAsTouched();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting.set(true);
|
||||
const values = this.form.getRawValue();
|
||||
const currentUser = this.authService.currentUser();
|
||||
|
||||
// Get departmentId from current user or first stage
|
||||
const departmentId = currentUser?.departmentId ||
|
||||
(values.stages[0]?.departmentId) || '';
|
||||
|
||||
const dto = {
|
||||
name: values.name!,
|
||||
description: values.description || undefined,
|
||||
requestType: values.requestType!,
|
||||
name: normalizeWhitespace(values.name),
|
||||
description: normalizeWhitespace(values.description) || undefined,
|
||||
workflowType: values.workflowType!,
|
||||
departmentId: departmentId,
|
||||
stages: values.stages.map((s, i) => ({
|
||||
id: s.id || `stage-${i + 1}`,
|
||||
name: s.name || `Stage ${i + 1}`,
|
||||
name: normalizeWhitespace(s.name) || `Stage ${i + 1}`,
|
||||
departmentId: s.departmentId || '',
|
||||
isRequired: s.isRequired ?? true,
|
||||
order: i + 1,
|
||||
@@ -357,8 +439,9 @@ export class WorkflowFormComponent implements OnInit {
|
||||
);
|
||||
this.router.navigate(['/workflows', result.id]);
|
||||
},
|
||||
error: () => {
|
||||
error: (err) => {
|
||||
this.submitting.set(false);
|
||||
this.notification.error(err?.error?.message || 'Failed to save workflow. Please try again.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user