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:
@@ -1,6 +1,6 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { Observable, throwError, map, catchError } from 'rxjs';
|
||||
import { ApiService, validateId, validatePagination } from '../../../core/services/api.service';
|
||||
import {
|
||||
WorkflowResponseDto,
|
||||
CreateWorkflowDto,
|
||||
@@ -9,6 +9,118 @@ import {
|
||||
WorkflowValidationResultDto,
|
||||
} from '../../../api/models';
|
||||
|
||||
/**
|
||||
* Ensures response has valid data array for paginated workflows
|
||||
*/
|
||||
function ensureValidPaginatedResponse(
|
||||
response: PaginatedWorkflowsResponse | null | undefined,
|
||||
page: number,
|
||||
limit: number
|
||||
): PaginatedWorkflowsResponse {
|
||||
if (!response) {
|
||||
return {
|
||||
data: [],
|
||||
total: 0,
|
||||
page,
|
||||
limit,
|
||||
totalPages: 0,
|
||||
hasNextPage: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: Array.isArray(response.data) ? response.data : [],
|
||||
total: typeof response.total === 'number' && response.total >= 0 ? response.total : 0,
|
||||
page: typeof response.page === 'number' && response.page >= 1 ? response.page : page,
|
||||
limit: typeof response.limit === 'number' && response.limit >= 1 ? response.limit : limit,
|
||||
totalPages: typeof response.totalPages === 'number' && response.totalPages >= 0 ? response.totalPages : 0,
|
||||
hasNextPage: typeof response.hasNextPage === 'boolean' ? response.hasNextPage : false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates workflow data for creation
|
||||
*/
|
||||
function validateCreateWorkflowDto(dto: CreateWorkflowDto | null | undefined): CreateWorkflowDto {
|
||||
if (!dto) {
|
||||
throw new Error('Workflow data is required');
|
||||
}
|
||||
|
||||
if (!dto.name || typeof dto.name !== 'string' || dto.name.trim().length === 0) {
|
||||
throw new Error('Workflow name is required');
|
||||
}
|
||||
|
||||
if (dto.name.trim().length > 200) {
|
||||
throw new Error('Workflow name cannot exceed 200 characters');
|
||||
}
|
||||
|
||||
if (!dto.departmentId || typeof dto.departmentId !== 'string' || dto.departmentId.trim().length === 0) {
|
||||
throw new Error('Department ID is required');
|
||||
}
|
||||
|
||||
if (!dto.stages || !Array.isArray(dto.stages) || dto.stages.length === 0) {
|
||||
throw new Error('At least one workflow stage is required');
|
||||
}
|
||||
|
||||
// Validate each stage
|
||||
dto.stages.forEach((stage, index) => {
|
||||
if (!stage.name || typeof stage.name !== 'string' || stage.name.trim().length === 0) {
|
||||
throw new Error(`Stage ${index + 1}: Name is required`);
|
||||
}
|
||||
if (!stage.order || typeof stage.order !== 'number' || stage.order < 1) {
|
||||
throw new Error(`Stage ${index + 1}: Valid order is required`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...dto,
|
||||
name: dto.name.trim(),
|
||||
description: dto.description?.trim() || undefined,
|
||||
departmentId: dto.departmentId.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates workflow data for update
|
||||
*/
|
||||
function validateUpdateWorkflowDto(dto: UpdateWorkflowDto | null | undefined): UpdateWorkflowDto {
|
||||
if (!dto) {
|
||||
throw new Error('Update data is required');
|
||||
}
|
||||
|
||||
const sanitized: UpdateWorkflowDto = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
if (typeof dto.name !== 'string' || dto.name.trim().length === 0) {
|
||||
throw new Error('Workflow name cannot be empty');
|
||||
}
|
||||
if (dto.name.trim().length > 200) {
|
||||
throw new Error('Workflow name cannot exceed 200 characters');
|
||||
}
|
||||
sanitized.name = dto.name.trim();
|
||||
}
|
||||
|
||||
if (dto.description !== undefined) {
|
||||
sanitized.description = typeof dto.description === 'string' ? dto.description.trim() : undefined;
|
||||
}
|
||||
|
||||
if (dto.isActive !== undefined) {
|
||||
if (typeof dto.isActive !== 'boolean') {
|
||||
throw new Error('isActive must be a boolean');
|
||||
}
|
||||
sanitized.isActive = dto.isActive;
|
||||
}
|
||||
|
||||
if (dto.stages !== undefined) {
|
||||
if (!Array.isArray(dto.stages)) {
|
||||
throw new Error('Stages must be an array');
|
||||
}
|
||||
sanitized.stages = dto.stages;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -16,30 +128,134 @@ export class WorkflowService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
getWorkflows(page = 1, limit = 10): Observable<PaginatedWorkflowsResponse> {
|
||||
return this.api.get<PaginatedWorkflowsResponse>('/workflows', { page, limit });
|
||||
const validated = validatePagination(page, limit);
|
||||
|
||||
return this.api
|
||||
.get<PaginatedWorkflowsResponse>('/workflows', {
|
||||
page: validated.page,
|
||||
limit: validated.limit,
|
||||
})
|
||||
.pipe(
|
||||
map((response) => ensureValidPaginatedResponse(response, validated.page, validated.limit)),
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch workflows';
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getWorkflow(id: string): Observable<WorkflowResponseDto> {
|
||||
return this.api.get<WorkflowResponseDto>(`/workflows/${id}`);
|
||||
try {
|
||||
const validId = validateId(id, 'Workflow ID');
|
||||
|
||||
return this.api.get<WorkflowResponseDto>(`/workflows/${validId}`).pipe(
|
||||
map((response) => {
|
||||
if (!response) {
|
||||
throw new Error('Workflow not found');
|
||||
}
|
||||
// Ensure nested arrays are valid
|
||||
return {
|
||||
...response,
|
||||
stages: Array.isArray(response.stages) ? response.stages : [],
|
||||
};
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to fetch workflow: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
createWorkflow(dto: CreateWorkflowDto): Observable<WorkflowResponseDto> {
|
||||
return this.api.post<WorkflowResponseDto>('/workflows', dto);
|
||||
try {
|
||||
const sanitizedDto = validateCreateWorkflowDto(dto);
|
||||
|
||||
return this.api.post<WorkflowResponseDto>('/workflows', sanitizedDto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create workflow';
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
updateWorkflow(id: string, dto: UpdateWorkflowDto): Observable<WorkflowResponseDto> {
|
||||
return this.api.patch<WorkflowResponseDto>(`/workflows/${id}`, dto);
|
||||
try {
|
||||
const validId = validateId(id, 'Workflow ID');
|
||||
const sanitizedDto = validateUpdateWorkflowDto(dto);
|
||||
|
||||
return this.api.patch<WorkflowResponseDto>(`/workflows/${validId}`, sanitizedDto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to update workflow: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
deleteWorkflow(id: string): Observable<void> {
|
||||
return this.api.delete<void>(`/workflows/${id}`);
|
||||
try {
|
||||
const validId = validateId(id, 'Workflow ID');
|
||||
|
||||
return this.api.delete<void>(`/workflows/${validId}`).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to delete workflow: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
validateWorkflow(dto: CreateWorkflowDto): Observable<WorkflowValidationResultDto> {
|
||||
return this.api.post<WorkflowValidationResultDto>('/workflows/validate', dto);
|
||||
try {
|
||||
const sanitizedDto = validateCreateWorkflowDto(dto);
|
||||
|
||||
return this.api.post<WorkflowValidationResultDto>('/workflows/validate', sanitizedDto).pipe(
|
||||
map((response) => {
|
||||
if (!response) {
|
||||
return { isValid: false, errors: ['Validation failed: No response'] };
|
||||
}
|
||||
return {
|
||||
isValid: typeof response.isValid === 'boolean' ? response.isValid : false,
|
||||
errors: Array.isArray(response.errors) ? response.errors : [],
|
||||
};
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Failed to validate workflow';
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
toggleActive(id: string, isActive: boolean): Observable<WorkflowResponseDto> {
|
||||
return this.api.patch<WorkflowResponseDto>(`/workflows/${id}`, { isActive });
|
||||
try {
|
||||
const validId = validateId(id, 'Workflow ID');
|
||||
|
||||
if (typeof isActive !== 'boolean') {
|
||||
return throwError(() => new Error('isActive must be a boolean value'));
|
||||
}
|
||||
|
||||
return this.api.patch<WorkflowResponseDto>(`/workflows/${validId}`, { isActive }).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : `Failed to toggle active status for workflow: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,9 +439,9 @@
|
||||
<!-- Bottom Info Bar -->
|
||||
<footer class="builder-footer">
|
||||
<div class="footer-left">
|
||||
<mat-form-field appearance="outline" class="request-type-field">
|
||||
<mat-label>Request Type</mat-label>
|
||||
<mat-select [formControl]="workflowForm.controls.requestType">
|
||||
<mat-form-field appearance="outline" class="workflow-type-field">
|
||||
<mat-label>Workflow Type</mat-label>
|
||||
<mat-select [formControl]="workflowForm.controls.workflowType">
|
||||
<mat-option value="NEW_LICENSE">New License</mat-option>
|
||||
<mat-option value="RENEWAL">Renewal</mat-option>
|
||||
<mat-option value="AMENDMENT">Amendment</mat-option>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MatDividerModule } from '@angular/material/divider';
|
||||
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 { WorkflowResponseDto, WorkflowStage, DepartmentResponseDto } from '../../../api/models';
|
||||
|
||||
// Node position interface for canvas positioning
|
||||
@@ -81,6 +82,7 @@ export class WorkflowBuilderComponent implements OnInit {
|
||||
private readonly workflowService = inject(WorkflowService);
|
||||
private readonly departmentService = inject(DepartmentService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
private readonly authService = inject(AuthService);
|
||||
|
||||
// State signals
|
||||
readonly loading = signal(false);
|
||||
@@ -105,7 +107,7 @@ export class WorkflowBuilderComponent implements OnInit {
|
||||
readonly workflowForm = this.fb.nonNullable.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
requestType: ['NEW_LICENSE', Validators.required],
|
||||
workflowType: ['NEW_LICENSE', Validators.required],
|
||||
isActive: [true],
|
||||
});
|
||||
|
||||
@@ -162,7 +164,7 @@ export class WorkflowBuilderComponent implements OnInit {
|
||||
this.workflowForm.patchValue({
|
||||
name: workflow.name,
|
||||
description: workflow.description || '',
|
||||
requestType: workflow.requestType,
|
||||
workflowType: workflow.workflowType,
|
||||
isActive: workflow.isActive,
|
||||
});
|
||||
|
||||
@@ -505,10 +507,17 @@ export class WorkflowBuilderComponent implements OnInit {
|
||||
this.saving.set(true);
|
||||
|
||||
const workflowData = this.workflowForm.getRawValue();
|
||||
const currentUser = this.authService.currentUser();
|
||||
|
||||
// Get departmentId from current user or first stage with a department
|
||||
const departmentId = currentUser?.departmentId ||
|
||||
this.stages().find(s => s.departmentId)?.departmentId || '';
|
||||
|
||||
const dto = {
|
||||
name: workflowData.name,
|
||||
description: workflowData.description || undefined,
|
||||
requestType: workflowData.requestType,
|
||||
workflowType: workflowData.workflowType,
|
||||
departmentId: departmentId,
|
||||
stages: this.stages().map((s, index) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
|
||||
@@ -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.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, inject, signal, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
@@ -72,9 +73,9 @@ import { WorkflowResponseDto } from '../../../api/models';
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="requestType">
|
||||
<th mat-header-cell *matHeaderCellDef>Request Type</th>
|
||||
<td mat-cell *matCellDef="let row">{{ formatType(row.requestType) }}</td>
|
||||
<ng-container matColumnDef="workflowType">
|
||||
<th mat-header-cell *matHeaderCellDef>Workflow Type</th>
|
||||
<td mat-cell *matCellDef="let row">{{ formatType(row.workflowType) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="stages">
|
||||
@@ -156,16 +157,18 @@ import { WorkflowResponseDto } from '../../../api/models';
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class WorkflowListComponent implements OnInit {
|
||||
export class WorkflowListComponent implements OnInit, OnDestroy {
|
||||
private readonly workflowService = inject(WorkflowService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly hasError = signal(false);
|
||||
readonly workflows = signal<WorkflowResponseDto[]>([]);
|
||||
readonly totalItems = signal(0);
|
||||
readonly pageSize = signal(10);
|
||||
readonly pageIndex = signal(0);
|
||||
|
||||
readonly displayedColumns = ['name', 'requestType', 'stages', 'status', 'actions'];
|
||||
readonly displayedColumns = ['name', 'workflowType', 'stages', 'status', 'actions'];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadWorkflows();
|
||||
@@ -173,16 +176,30 @@ export class WorkflowListComponent implements OnInit {
|
||||
|
||||
loadWorkflows(): void {
|
||||
this.loading.set(true);
|
||||
this.workflowService.getWorkflows(this.pageIndex() + 1, this.pageSize()).subscribe({
|
||||
next: (response) => {
|
||||
this.workflows.set(response.data);
|
||||
this.totalItems.set(response.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
this.hasError.set(false);
|
||||
|
||||
this.workflowService.getWorkflows(this.pageIndex() + 1, this.pageSize())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.workflows.set(response?.data ?? []);
|
||||
this.totalItems.set(response?.total ?? 0);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.hasError.set(true);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Cleanup handled by DestroyRef/takeUntilDestroyed
|
||||
}
|
||||
|
||||
/** Retry loading data - clears error state */
|
||||
retryLoad(): void {
|
||||
this.loadWorkflows();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
|
||||
@@ -56,8 +56,8 @@ import { WorkflowResponseDto } from '../../../api/models';
|
||||
<app-status-badge [status]="wf.isActive ? 'ACTIVE' : 'INACTIVE'" />
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Request Type</span>
|
||||
<span class="value">{{ formatType(wf.requestType) }}</span>
|
||||
<span class="label">Workflow Type</span>
|
||||
<span class="value">{{ formatType(wf.workflowType) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Total Stages</span>
|
||||
@@ -279,6 +279,10 @@ export class WorkflowPreviewComponent implements OnInit {
|
||||
this.notification.success(wf.isActive ? 'Workflow deactivated' : 'Workflow activated');
|
||||
this.loadWorkflow();
|
||||
},
|
||||
error: (err) => {
|
||||
this.notification.error('Operation failed. Please try again.');
|
||||
console.error('Error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -302,6 +306,10 @@ export class WorkflowPreviewComponent implements OnInit {
|
||||
this.notification.success('Workflow deleted');
|
||||
this.router.navigate(['/workflows']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.notification.error('Operation failed. Please try again.');
|
||||
console.error('Error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user