Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion src/app/core/error-handler/error-handler.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,24 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';

export interface ErrorMessage {
title: string;
message: string;
action?: string;
}

export interface FineractErrorDetail {
userMessageGlobalisationCode?: string;
defaultUserMessage?: string;
}
export interface FineractErrorResponse {
userMessageGlobalisationCode?: string;
defaultUserMessage?: string;
errors?: FineractErrorDetail[];
}

/**
* Centralized error handler service for consistent error messaging
* across the application. Converts HTTP errors into user-friendly
Expand All @@ -49,7 +60,9 @@ export class ErrorHandlerService {
// eslint-disable-next-line @angular-eslint/prefer-inject
private snackBar: MatSnackBar,
// eslint-disable-next-line @angular-eslint/prefer-inject
private router: Router
private router: Router,
// eslint-disable-next-line @angular-eslint/prefer-inject
private translate: TranslateService
) {}

/**
Expand Down Expand Up @@ -189,4 +202,58 @@ export class ErrorHandlerService {
panelClass: ['info-snackbar']
});
}

/**
* Translates a Fineract error response into a single, translated message.
* Falls back to defaultUserMessage when translation keys are missing.
*/
translateFineractError(errorResponse: FineractErrorResponse | null | undefined): string {
if (!errorResponse || typeof errorResponse !== 'object') {
return '';
}

const messages: string[] = [];

if (errorResponse.userMessageGlobalisationCode) {
const mainMsg = this.getMessageForCode(
errorResponse.userMessageGlobalisationCode,
errorResponse.defaultUserMessage
);
if (mainMsg) {
messages.push(mainMsg);
}
} else if (errorResponse.defaultUserMessage) {
messages.push(errorResponse.defaultUserMessage);
}
if (Array.isArray(errorResponse.errors)) {
errorResponse.errors.forEach((error: FineractErrorDetail) => {
if (!error || typeof error !== 'object') {
return;
}
if (error.userMessageGlobalisationCode) {
const nestedMsg = this.getMessageForCode(error.userMessageGlobalisationCode, error.defaultUserMessage);
if (nestedMsg) {
messages.push(nestedMsg);
}
} else if (error.defaultUserMessage) {
messages.push(error.defaultUserMessage);
}
});
}
const uniqueMessages = Array.from(new Set(messages.filter((m) => !!m && typeof m === 'string')));
return uniqueMessages.join(' ');
}

private getMessageForCode(code: string, defaultMessage?: string): string {
if (!code) {
return defaultMessage || '';
}

const translated = this.translate.instant(code);
if (translated && translated !== code) {
return translated;
}

return defaultMessage || '';
}
}
63 changes: 42 additions & 21 deletions src/app/core/http/error-handler.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { environment } from '../../../environments/environment';
/** Custom Services */
import { Logger } from '../logger/logger.service';
import { AlertService } from '../alert/alert.service';
import { TranslateService } from '@ngx-translate/core'; // Added import for TranslateService
import { TranslateService } from '@ngx-translate/core';
import { ErrorHandlerService } from '../error-handler/error-handler.service';

/** Initialize Logger */
const log = new Logger('ErrorHandlerInterceptor');
Expand All @@ -32,6 +33,7 @@ const log = new Logger('ErrorHandlerInterceptor');
export class ErrorHandlerInterceptor implements HttpInterceptor {
private alertService = inject(AlertService);
private translate = inject(TranslateService);
private errorHandlerService = inject(ErrorHandlerService);

/**
* Intercepts a Http request and adds a default error handler.
Expand All @@ -45,33 +47,49 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
*/
private handleError(response: HttpErrorResponse, request: HttpRequest<any>): Observable<HttpEvent<any>> {
const status = response.status;
let errorMessage = response.error.developerMessage || response.message;
if (response.error.errors) {
if (response.error.errors[0]) {
errorMessage = response.error.errors[0].defaultUserMessage || response.error.errors[0].developerMessage;
}
}
const errorBody: any = response.error;
const translatedErrorMessage = this.errorHandlerService.translateFineractError(errorBody);
const developerMessage: string | undefined = errorBody?.developerMessage;
const errorMessage = translatedErrorMessage || errorBody?.defaultUserMessage || response.message;

const isClientImage404 = status === 404 && request.url.includes('/clients/') && request.url.includes('/images');

if (!environment.production && !isClientImage404) {
if (developerMessage) {
log.error(`Request Error (developerMessage): ${developerMessage}`);
}
log.error(`Request Error: ${errorMessage}`);
}

if (status === 401 || (environment.oauth.enabled && status === 400)) {
this.alertService.alert({ type: 'Authentication Error', message: 'Invalid User Details. Please try again!' });
} else if (status === 403 && errorMessage === 'The provided one time token is invalid') {
this.alertService.alert({ type: 'Invalid Token', message: 'Invalid Token. Please try again!' });
} else if (status === 400) {
// OAuth2 errors for invalid grants are returned as 400, so we need to check the URL.
if (status === 401 || (environment.oauth.enabled && status === 400 && request.url.includes('/oauth/token'))) {
this.alertService.alert({
type: 'Bad Request',
message: errorMessage || 'Invalid parameters were passed in the request!'
type: this.translate.instant('error.resource.authenticationError.type'),
message: this.translate.instant('error.resource.authenticationError.message')
});
} else if (status === 403) {
} else if (status === 400) {
this.alertService.alert({
type: 'Unauthorized Request',
message: errorMessage || 'You are not authorized for this request!'
type: this.translate.instant('error.resource.badRequest.type'),
message: errorMessage || this.translate.instant('error.resource.badRequest.message')
});
} else if (status === 403) {
// The token check must use a stable identifier, not the translated message.
const isInvalidToken =
errorBody?.userMessageGlobalisationCode === 'error.msg.invalid.onetime.token' ||
errorBody?.defaultUserMessage === 'The provided one time token is invalid' ||
errorMessage === 'The provided one time token is invalid';

if (isInvalidToken) {
this.alertService.alert({
type: this.translate.instant('error.resource.invalidToken.type'),
message: this.translate.instant('error.resource.invalidToken.message')
});
} else {
this.alertService.alert({
type: this.translate.instant('error.resource.unauthorizedRequest.type'),
message: errorMessage || this.translate.instant('error.resource.unauthorizedRequest.message')
});
}
} else if (status === 404) {
// Check if this is an image request that should be silently handled (client profile image)
if (isClientImage404) {
Expand All @@ -81,21 +99,24 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
} else {
this.alertService.alert({
type: this.translate.instant('error.resource.not.found'),
message: errorMessage || 'Resource does not exist!'
message: errorMessage || this.translate.instant('error.resource.notFound.message')
});
}
} else if (status === 500) {
this.alertService.alert({
type: 'Internal Server Error',
message: 'Internal Server Error. Please try again later.'
type: this.translate.instant('error.resource.internalServerError.type'),
message: this.translate.instant('error.resource.internalServerError.message')
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if (status === 501) {
this.alertService.alert({
type: this.translate.instant('error.resource.notImplemented.type'),
message: this.translate.instant('error.resource.notImplemented.message')
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
this.alertService.alert({ type: 'Unknown Error', message: 'Unknown Error. Please try again later.' });
this.alertService.alert({
type: this.translate.instant('error.resource.unknownError.type'),
message: errorMessage || this.translate.instant('error.resource.unknownError.message')
});
}

throw response;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,18 +81,15 @@
<div class="permission-toolbar">
<div class="permission-toolbar-left">
<h3>
@if (!isSearchActive) {
{{ 'labels.buttons.Permissions' | translate }}: <strong>{{ formatName(previousGrouping) }}</strong>
} @else {
{{ 'labels.buttons.Permissions' | translate }}:
<strong>{{ 'labels.text.Search Results' | translate }}</strong>
<span class="search-count">({{ filteredPermissions.length }})</span>
{{ 'labels.buttons.Permissions' | translate }}: <strong>{{ formatName(previousGrouping) }}</strong>
@if (isSearchActive) {
<span class="search-count">({{ groupingMatchCounts[previousGrouping] || 0 }})</span>
}
</h3>
</div>

<div class="permission-toolbar-right">
@if (!isDisabled) {
@if (!isDisabled && !isSearchActive) {
<button mat-raised-button color="primary" (click)="selectAll()">
{{ 'labels.buttons.Select All' | translate }}
</button>
Expand Down Expand Up @@ -121,27 +118,30 @@ <h3>
</mat-form-field>
</div>

@if (!isSearchActive) {
<div class="layout-row permissions-panel">
<div class="layout-column flex-30 grouping-list">
<mat-list>
@for (grouping of groupings; track grouping) {
<mat-list-item
[ngClass]="selectedItem === grouping ? 'active' : 'inactive'"
(click)="showPermissions(grouping)"
>
<span class="grouping-name">
{{ formatName(grouping) }}
</span>
</mat-list-item>
}
</mat-list>
</div>
<div class="layout-row permissions-panel">
<div class="layout-column flex-30 grouping-list">
<mat-list>
@for (grouping of groupings; track grouping) {
<mat-list-item
[ngClass]="selectedItem === grouping ? 'active' : 'inactive'"
(click)="showPermissions(grouping)"
>
<span class="grouping-name">
{{ formatName(grouping) }}
@if (isSearchActive) {
<span class="match-count-badge">{{ groupingMatchCounts[grouping] || 0 }}</span>
}
</span>
</mat-list-item>
}
</mat-list>
</div>

<mat-divider [vertical]="true"></mat-divider>
<mat-divider [vertical]="true"></mat-divider>

<div class="flex-70 layout-column list-permission">
<form [formGroup]="formGroup" (submit)="submit()">
<div class="flex-70 layout-column list-permission">
<form [formGroup]="formGroup" (submit)="submit()">
@if (!isSearchActive) {
@for (permission of permissions.permissions; track permission) {
<div class="display-permissions">
<div formArrayName="roster">
Expand All @@ -153,33 +153,28 @@ <h3>
</div>
</div>
}
</form>
</div>
</div>
} @else {
<div class="search-results-panel">
<form [formGroup]="formGroup">
@if (filteredPermissions.length === 0) {
<div class="no-results">
<mat-icon>search_off</mat-icon>
<p>{{ 'labels.text.No permissions found' | translate }}</p>
</div>
}
@for (permission of filteredPermissions; track permission.id) {
<div class="display-permissions search-result-item">
<div formArrayName="roster">
<div [formGroupName]="permission.id">
<mat-checkbox name="cp" id="search-{{ permission.code }}" formControlName="selected">
{{ permissionName(permission.code) }}
</mat-checkbox>
<span class="permission-grouping-badge">{{ formatName(permission.grouping) }}</span>
} @else {
@if (filteredGroupPermissions.length === 0) {
<div class="no-results">
<mat-icon>search_off</mat-icon>
<p>{{ 'labels.text.No permissions found' | translate }}</p>
</div>
}
@for (permission of filteredGroupPermissions; track permission) {
<div class="display-permissions">
<div formArrayName="roster">
<div [formGroupName]="permission.id">
<mat-checkbox name="cp" id="search-{{ permission.code }}" formControlName="selected">
{{ permissionName(permission.code) }}
</mat-checkbox>
</div>
</div>
</div>
</div>
}
}
</form>
</div>
}
</div>

@if (!isDisabled) {
<mat-card-actions class="submit-actions">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,28 @@ mat-list-item {
}

.grouping-name {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 8px;
}

.match-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 20px;
font-size: 0.75rem;
font-weight: 500;
padding: 0 6px;
margin-left: 8px;
border-radius: 10px;
background-color: $accent;
color: $white;
}

.list-permission {
padding-left: 24px;
padding-right: 16px;
Expand Down Expand Up @@ -138,33 +157,6 @@ mat-list-item {
}
}

.search-results-panel {
max-height: 480px;
overflow-y: auto;
padding: 0 16px;
}

.search-result-item {
display: flex;
align-items: center;
border-bottom: 1px solid $light-grey;

&:last-child {
border-bottom: none;
}
}

.permission-grouping-badge {
display: inline-block;
font-size: 0.75rem;
padding: 2px 8px;
margin-left: 8px;
border-radius: 16px;
background-color: $light-grey;
color: $dark-grey;
vertical-align: middle;
}

.no-results {
display: flex;
flex-direction: column;
Expand Down
Loading