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 {
|
||||
WebhookResponseDto,
|
||||
CreateWebhookDto,
|
||||
@@ -8,8 +8,160 @@ import {
|
||||
WebhookTestResultDto,
|
||||
WebhookLogEntryDto,
|
||||
PaginatedWebhookLogsResponse,
|
||||
WebhookEvent,
|
||||
} from '../../../api/models';
|
||||
|
||||
// Valid webhook events from the model
|
||||
const VALID_WEBHOOK_EVENTS: WebhookEvent[] = [
|
||||
'APPROVAL_REQUIRED',
|
||||
'DOCUMENT_UPDATED',
|
||||
'REQUEST_APPROVED',
|
||||
'REQUEST_REJECTED',
|
||||
'CHANGES_REQUESTED',
|
||||
'LICENSE_MINTED',
|
||||
'LICENSE_REVOKED',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates URL format
|
||||
*/
|
||||
function validateUrl(url: string | undefined | null, fieldName = 'URL'): string {
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error(`${fieldName} cannot be empty`);
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
try {
|
||||
const parsedUrl = new URL(trimmed);
|
||||
// Only allow http and https protocols
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
throw new Error(`${fieldName} must use HTTP or HTTPS protocol`);
|
||||
}
|
||||
return trimmed;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('protocol')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`${fieldName} is not a valid URL`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates webhook events array
|
||||
*/
|
||||
function validateEvents(events: WebhookEvent[] | string[] | undefined | null): WebhookEvent[] {
|
||||
if (!events) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(events)) {
|
||||
throw new Error('Events must be an array');
|
||||
}
|
||||
|
||||
const validated = events.filter(
|
||||
(event): event is WebhookEvent =>
|
||||
typeof event === 'string' && VALID_WEBHOOK_EVENTS.includes(event as WebhookEvent)
|
||||
);
|
||||
return validated as WebhookEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures array response is valid
|
||||
*/
|
||||
function ensureValidArray<T>(response: T[] | null | undefined): T[] {
|
||||
return Array.isArray(response) ? response : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures paginated response is valid
|
||||
*/
|
||||
function ensureValidPaginatedResponse(
|
||||
response: PaginatedWebhookLogsResponse | null | undefined,
|
||||
page: number,
|
||||
limit: number
|
||||
): PaginatedWebhookLogsResponse {
|
||||
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 create webhook DTO
|
||||
*/
|
||||
function validateCreateWebhookDto(dto: CreateWebhookDto | null | undefined): CreateWebhookDto {
|
||||
if (!dto) {
|
||||
throw new Error('Webhook data is required');
|
||||
}
|
||||
|
||||
const url = validateUrl(dto.url, 'Webhook URL');
|
||||
const events = validateEvents(dto.events);
|
||||
|
||||
if (events.length === 0) {
|
||||
throw new Error('At least one event must be specified');
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
events,
|
||||
description: dto.description?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates update webhook DTO
|
||||
*/
|
||||
function validateUpdateWebhookDto(dto: UpdateWebhookDto | null | undefined): UpdateWebhookDto {
|
||||
if (!dto) {
|
||||
throw new Error('Update data is required');
|
||||
}
|
||||
|
||||
const sanitized: UpdateWebhookDto = {};
|
||||
|
||||
if (dto.url !== undefined) {
|
||||
sanitized.url = validateUrl(dto.url, 'Webhook URL');
|
||||
}
|
||||
|
||||
if (dto.events !== undefined) {
|
||||
sanitized.events = validateEvents(dto.events);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -17,34 +169,161 @@ export class WebhookService {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
getWebhooks(): Observable<WebhookResponseDto[]> {
|
||||
return this.api.get<WebhookResponseDto[]>('/webhooks');
|
||||
return this.api.get<WebhookResponseDto[]>('/webhooks').pipe(
|
||||
map((response) => ensureValidArray(response)),
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch webhooks';
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getWebhook(id: string): Observable<WebhookResponseDto> {
|
||||
return this.api.get<WebhookResponseDto>(`/webhooks/${id}`);
|
||||
try {
|
||||
const validId = validateId(id, 'Webhook ID');
|
||||
|
||||
return this.api.get<WebhookResponseDto>(`/webhooks/${validId}`).pipe(
|
||||
map((response) => {
|
||||
if (!response) {
|
||||
throw new Error('Webhook not found');
|
||||
}
|
||||
return {
|
||||
...response,
|
||||
events: Array.isArray(response.events) ? response.events : [],
|
||||
};
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to fetch webhook: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
createWebhook(dto: CreateWebhookDto): Observable<WebhookResponseDto> {
|
||||
return this.api.post<WebhookResponseDto>('/webhooks', dto);
|
||||
try {
|
||||
const sanitizedDto = validateCreateWebhookDto(dto);
|
||||
|
||||
return this.api.post<WebhookResponseDto>('/webhooks', sanitizedDto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create webhook';
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
updateWebhook(id: string, dto: UpdateWebhookDto): Observable<WebhookResponseDto> {
|
||||
return this.api.patch<WebhookResponseDto>(`/webhooks/${id}`, dto);
|
||||
try {
|
||||
const validId = validateId(id, 'Webhook ID');
|
||||
const sanitizedDto = validateUpdateWebhookDto(dto);
|
||||
|
||||
return this.api.patch<WebhookResponseDto>(`/webhooks/${validId}`, sanitizedDto).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to update webhook: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
deleteWebhook(id: string): Observable<void> {
|
||||
return this.api.delete<void>(`/webhooks/${id}`);
|
||||
try {
|
||||
const validId = validateId(id, 'Webhook ID');
|
||||
|
||||
return this.api.delete<void>(`/webhooks/${validId}`).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to delete webhook: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
testWebhook(id: string): Observable<WebhookTestResultDto> {
|
||||
return this.api.post<WebhookTestResultDto>(`/webhooks/${id}/test`, {});
|
||||
try {
|
||||
const validId = validateId(id, 'Webhook ID');
|
||||
|
||||
return this.api.post<WebhookTestResultDto>(`/webhooks/${validId}/test`, {}).pipe(
|
||||
map((response) => {
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
statusCode: 0,
|
||||
statusMessage: 'No response received',
|
||||
responseTime: 0,
|
||||
error: 'No response from server',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: typeof response.success === 'boolean' ? response.success : false,
|
||||
statusCode: typeof response.statusCode === 'number' ? response.statusCode : 0,
|
||||
statusMessage:
|
||||
typeof response.statusMessage === 'string' ? response.statusMessage : 'Unknown',
|
||||
responseTime: typeof response.responseTime === 'number' ? response.responseTime : 0,
|
||||
error: response.error,
|
||||
};
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : `Failed to test webhook: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
getWebhookLogs(id: string, page = 1, limit = 20): Observable<PaginatedWebhookLogsResponse> {
|
||||
return this.api.get<PaginatedWebhookLogsResponse>(`/webhooks/${id}/logs`, { page, limit });
|
||||
try {
|
||||
const validId = validateId(id, 'Webhook ID');
|
||||
const validated = validatePagination(page, limit);
|
||||
|
||||
return this.api
|
||||
.get<PaginatedWebhookLogsResponse>(`/webhooks/${validId}/logs`, {
|
||||
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 logs for webhook: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
toggleActive(id: string, isActive: boolean): Observable<WebhookResponseDto> {
|
||||
return this.api.patch<WebhookResponseDto>(`/webhooks/${id}`, { isActive });
|
||||
try {
|
||||
const validId = validateId(id, 'Webhook ID');
|
||||
|
||||
if (typeof isActive !== 'boolean') {
|
||||
return throwError(() => new Error('isActive must be a boolean value'));
|
||||
}
|
||||
|
||||
return this.api.patch<WebhookResponseDto>(`/webhooks/${validId}`, { isActive }).pipe(
|
||||
catchError((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to toggle active status for webhook: ${id}`;
|
||||
return throwError(() => new Error(message));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ import { PageHeaderComponent } from '../../../shared/components/page-header/page
|
||||
import { WebhookService } from '../services/webhook.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { WebhookEvent } from '../../../api/models';
|
||||
import {
|
||||
INPUT_LIMITS,
|
||||
noScriptValidator,
|
||||
noNullBytesValidator,
|
||||
strictUrlValidator,
|
||||
normalizeWhitespace,
|
||||
} from '../../../shared/utils/form-validators';
|
||||
import { createSubmitDebounce } from '../../../shared/utils/form-utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-webhook-form',
|
||||
@@ -58,12 +66,22 @@ import { WebhookEvent } from '../../../api/models';
|
||||
matInput
|
||||
formControlName="url"
|
||||
placeholder="https://your-server.com/webhook"
|
||||
[maxlength]="limits.URL_MAX"
|
||||
/>
|
||||
@if (form.controls.url.hasError('required')) {
|
||||
<mat-error>URL is required</mat-error>
|
||||
}
|
||||
@if (form.controls.url.hasError('pattern')) {
|
||||
<mat-error>Enter a valid HTTPS URL</mat-error>
|
||||
@if (form.controls.url.hasError('httpsRequired')) {
|
||||
<mat-error>HTTPS is required for webhook URLs</mat-error>
|
||||
}
|
||||
@if (form.controls.url.hasError('invalidUrl')) {
|
||||
<mat-error>Enter a valid URL</mat-error>
|
||||
}
|
||||
@if (form.controls.url.hasError('dangerousUrl')) {
|
||||
<mat-error>URL contains unsafe content</mat-error>
|
||||
}
|
||||
@if (form.controls.url.hasError('urlTooLong')) {
|
||||
<mat-error>URL is too long (max {{ limits.URL_MAX }} characters)</mat-error>
|
||||
}
|
||||
<mat-hint>Must be a publicly accessible HTTPS endpoint</mat-hint>
|
||||
</mat-form-field>
|
||||
@@ -88,7 +106,15 @@ import { WebhookEvent } from '../../../api/models';
|
||||
formControlName="description"
|
||||
rows="2"
|
||||
placeholder="What is this webhook used for?"
|
||||
[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 class="form-actions">
|
||||
@@ -148,11 +174,17 @@ export class WebhookFormComponent implements OnInit {
|
||||
private readonly webhookService = inject(WebhookService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
/** Debounce handler to prevent double-click submissions */
|
||||
private readonly submitDebounce = createSubmitDebounce(500);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly submitting = signal(false);
|
||||
readonly isEditMode = signal(false);
|
||||
private webhookId: string | null = null;
|
||||
|
||||
/** Input limits exposed for template binding */
|
||||
readonly limits = INPUT_LIMITS;
|
||||
|
||||
readonly eventOptions: { value: WebhookEvent; label: string }[] = [
|
||||
{ value: 'APPROVAL_REQUIRED', label: 'Approval Required' },
|
||||
{ value: 'DOCUMENT_UPDATED', label: 'Document Updated' },
|
||||
@@ -164,9 +196,16 @@ export class WebhookFormComponent implements OnInit {
|
||||
];
|
||||
|
||||
readonly form = this.fb.nonNullable.group({
|
||||
url: ['', [Validators.required, Validators.pattern(/^https:\/\/.+/)]],
|
||||
url: ['', [
|
||||
Validators.required,
|
||||
strictUrlValidator(true), // Require HTTPS for webhooks
|
||||
]],
|
||||
events: [[] as WebhookEvent[], [Validators.required]],
|
||||
description: [''],
|
||||
description: ['', [
|
||||
Validators.maxLength(INPUT_LIMITS.DESCRIPTION_MAX),
|
||||
noScriptValidator(),
|
||||
noNullBytesValidator(),
|
||||
]],
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -198,10 +237,30 @@ export class WebhookFormComponent 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();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting.set(true);
|
||||
const values = this.form.getRawValue();
|
||||
const rawValues = this.form.getRawValue();
|
||||
|
||||
// Normalize and sanitize values
|
||||
const values = {
|
||||
url: rawValues.url.trim(),
|
||||
events: rawValues.events,
|
||||
description: normalizeWhitespace(rawValues.description),
|
||||
};
|
||||
|
||||
const action$ = this.isEditMode()
|
||||
? this.webhookService.updateWebhook(this.webhookId!, values)
|
||||
@@ -214,8 +273,9 @@ export class WebhookFormComponent implements OnInit {
|
||||
);
|
||||
this.router.navigate(['/webhooks']);
|
||||
},
|
||||
error: () => {
|
||||
error: (err) => {
|
||||
this.submitting.set(false);
|
||||
this.notification.error(err?.error?.message || 'Failed to save webhook. 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';
|
||||
@@ -161,12 +162,14 @@ import { WebhookResponseDto } from '../../../api/models';
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class WebhookListComponent implements OnInit {
|
||||
export class WebhookListComponent implements OnInit, OnDestroy {
|
||||
private readonly webhookService = inject(WebhookService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly hasError = signal(false);
|
||||
readonly webhooks = signal<WebhookResponseDto[]>([]);
|
||||
|
||||
readonly displayedColumns = ['url', 'events', 'status', 'actions'];
|
||||
@@ -177,34 +180,49 @@ export class WebhookListComponent implements OnInit {
|
||||
|
||||
loadWebhooks(): void {
|
||||
this.loading.set(true);
|
||||
this.webhookService.getWebhooks().subscribe({
|
||||
next: (data) => {
|
||||
this.webhooks.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
this.hasError.set(false);
|
||||
|
||||
this.webhookService.getWebhooks()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
this.webhooks.set(data ?? []);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.hasError.set(true);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
formatEvent(event: string): string {
|
||||
return event.replace(/_/g, ' ').toLowerCase();
|
||||
return event?.replace(/_/g, ' ').toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
testWebhook(webhook: WebhookResponseDto): void {
|
||||
this.webhookService.testWebhook(webhook.id).subscribe({
|
||||
next: (result) => {
|
||||
if (result.success) {
|
||||
this.notification.success(`Webhook test successful (${result.statusCode})`);
|
||||
} else {
|
||||
this.notification.error(`Webhook test failed: ${result.error || result.statusMessage}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!webhook?.id) return;
|
||||
|
||||
this.webhookService.testWebhook(webhook.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
if (result?.success) {
|
||||
this.notification.success(`Webhook test successful (${result.statusCode})`);
|
||||
} else {
|
||||
this.notification.error(`Webhook test failed: ${result?.error || result?.statusMessage || 'Unknown error'}`);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.notification.error('Operation failed. Please try again.');
|
||||
console.error('Error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteWebhook(webhook: WebhookResponseDto): void {
|
||||
if (!webhook?.id) return;
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Webhook',
|
||||
@@ -214,15 +232,32 @@ export class WebhookListComponent implements OnInit {
|
||||
},
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.webhookService.deleteWebhook(webhook.id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Webhook deleted');
|
||||
this.loadWebhooks();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.webhookService.deleteWebhook(webhook.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Webhook deleted');
|
||||
this.loadWebhooks();
|
||||
},
|
||||
error: (err) => {
|
||||
this.notification.error('Operation failed. Please try again.');
|
||||
console.error('Error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Cleanup handled by DestroyRef/takeUntilDestroyed
|
||||
}
|
||||
|
||||
/** Retry loading data - clears error state */
|
||||
retryLoad(): void {
|
||||
this.loadWebhooks();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
@@ -128,12 +129,14 @@ import { WebhookLogEntryDto } from '../../../api/models';
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class WebhookLogsComponent implements OnInit {
|
||||
export class WebhookLogsComponent implements OnInit, OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly webhookService = inject(WebhookService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly hasError = signal(false);
|
||||
readonly logs = signal<WebhookLogEntryDto[]>([]);
|
||||
readonly totalItems = signal(0);
|
||||
readonly pageSize = signal(20);
|
||||
@@ -156,15 +159,19 @@ export class WebhookLogsComponent implements OnInit {
|
||||
if (!this.webhookId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.hasError.set(false);
|
||||
|
||||
this.webhookService
|
||||
.getWebhookLogs(this.webhookId, this.pageIndex() + 1, this.pageSize())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.logs.set(response.data);
|
||||
this.totalItems.set(response.total);
|
||||
this.logs.set(response?.data ?? []);
|
||||
this.totalItems.set(response?.total ?? 0);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.hasError.set(true);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
@@ -177,10 +184,19 @@ export class WebhookLogsComponent implements OnInit {
|
||||
}
|
||||
|
||||
formatEvent(event: string): string {
|
||||
return event.replace(/_/g, ' ').toLowerCase();
|
||||
return event?.replace(/_/g, ' ').toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
isSuccess(statusCode: number): boolean {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Cleanup handled by DestroyRef/takeUntilDestroyed
|
||||
}
|
||||
|
||||
/** Retry loading data - clears error state */
|
||||
retryLoad(): void {
|
||||
this.loadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user