WEB-233: Improve the OIDC integration with 2FA (#2602)

This commit is contained in:
AllsVeam
2025-08-22 21:49:18 -06:00
committed by GitHub
parent c00a3bd059
commit 9745643456
50 changed files with 3248 additions and 132 deletions
+6
View File
@@ -24,3 +24,9 @@ services:
- MIFOS_SUPPORTED_LANGUAGES=cs-CS,de-DE,en-US,es-MX,es-CL,fr-FR,it-IT,ko-KO,lt-LT,lv-LV,ne-NE,pt-PT,sw-SW
- MIFOS_SESSION_IDLE_TIMEOUT=300000
- MIFOS_OAUTH_SERVER_ENABLED=false
- MIFOS_OAUTH_SERVER_ENABLED=${FINERACT_PLUGIN_OIDC_BASE_URL}
- FINERACT_PLUGIN_OIDC_ENABLED=${FINERACT_PLUGIN_OIDC_ENABLED}
- FINERACT_PLUGIN_OIDC_BASE_URL=${FINERACT_PLUGIN_OIDC_BASE_URL}
- FINERACT_PLUGIN_OIDC_CLIENT_ID=${FINERACT_PLUGIN_OIDC_CLIENT_ID}
- FINERACT_PLUGIN_OIDC_API_URL=${FINERACT_PLUGIN_OIDC_API_URL}
- FINERACT_PLUGIN_OIDC_FRONTEND_URL=${FINERACT_PLUGIN_OIDC_FRONTEND_URL}
+28 -2
View File
@@ -2,7 +2,13 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { HttpBackend, HttpClient, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import {
HttpBackend,
HttpClient,
provideHttpClient,
withInterceptorsFromDi,
HTTP_INTERCEPTORS
} from '@angular/common/http';
/** Environment Configuration */
@@ -47,6 +53,11 @@ import {
} from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { AuthenticationInterceptor as TokenInterceptor } from './core/authentication/authentication.interceptor';
import { TokenInterceptor as ZitadelTokenInterceptor } from './zitadel/token.interceptor';
import { AuthService } from './zitadel/auth.service';
import { environment } from '../environments/environment';
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
handle(params: MissingTranslationHandlerParams): string {
// Remove the 'labels.catalogs.' prefix and return the fallback value
@@ -112,6 +123,21 @@ export function HttpLoaderFactory(http: HttpClient) {
],
providers: [
DatePipe,
provideHttpClient(withInterceptorsFromDi())]
AuthService,
{
provide: HTTP_INTERCEPTORS,
useClass: !(
environment.OIDC.oidcServerEnabled === false ||
environment.OIDC.oidcServerEnabled === 'false' ||
environment.OIDC.oidcServerEnabled === 0 ||
environment.OIDC.oidcServerEnabled === '0' ||
environment.OIDC.oidcServerEnabled === null ||
environment.OIDC.oidcServerEnabled === undefined
)
? TokenInterceptor
: ZitadelTokenInterceptor,
multi: true
}
]
})
export class AppModule {}
@@ -105,15 +105,12 @@ export class AuthenticationService {
httpParams = httpParams.set('grant_type', 'password');
let headers = new HttpHeaders();
headers = headers.set('Content-Type', 'application/x-www-form-urlencoded');
return this.http
.disableApiPrefix()
.post(`${environment.oauth.serverUrl}/token`, httpParams.toString(), { headers: headers })
.pipe(
map((tokenResponse: OAuth2Token) => {
this.getUserDetails(tokenResponse);
return of(true);
})
);
return this.http.post(`${environment.oauth.serverUrl}/token`, httpParams.toString(), { headers: headers }).pipe(
map((tokenResponse: OAuth2Token) => {
this.getUserDetails(tokenResponse);
return of(true);
})
);
} else {
return this.http
.post('/authentication', { username: loginContext.username, password: loginContext.password })
@@ -137,7 +134,6 @@ export class AuthenticationService {
let headers = new HttpHeaders();
headers = headers.set('Authorization', 'bearer ' + tokenResponse.access_token);
this.http
.disableApiPrefix()
.get(`${environment.serverUrl}/userdetails`, { headers: headers })
.subscribe((credentials: Credentials) => {
this.onLoginSuccess(credentials);
@@ -174,7 +170,6 @@ export class AuthenticationService {
let headers = new HttpHeaders();
headers = headers.set('Content-Type', 'application/x-www-form-urlencoded');
return this.http
.disableApiPrefix()
.post(`${environment.oauth.serverUrl}/token`, httpParams.toString(), { headers: headers })
.subscribe((tokenResponse: OAuth2Token) => {
this.storage.setItem(this.oAuthTokenDetailsStorageKey, JSON.stringify(tokenResponse));
@@ -241,7 +236,6 @@ export class AuthenticationService {
let headers = new HttpHeaders();
headers = headers.set('Content-Type', 'application/x-www-form-urlencoded');
return this.http
.disableApiPrefix()
.post(`${environment.oauth.serverUrl}/logout`, httpParams.toString(), { headers: headers })
.subscribe();
}
@@ -318,6 +312,14 @@ export class AuthenticationService {
}
}
public saveZitadelCredentials(credentials: Credentials): void {
this.setCredentials(credentials);
}
public saveZitadeloAuthTokenDetailsStorageKey(tokenResponse: OAuth2Token): void {
this.storage.setItem(this.oAuthTokenDetailsStorageKey, JSON.stringify(tokenResponse));
}
/**
* Following functions are for two factor authentication and require
* first level authorization headers to be setup for the requests.
@@ -24,6 +24,10 @@ import { MatIcon } from '@angular/material/icon';
import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Environment Configuration and Zitadel*/
import { environment } from '../../../../environments/environment';
import { AuthService } from 'app/zitadel/auth.service';
/**
* Sidenav component.
*/
@@ -59,6 +63,17 @@ export class SidenavComponent implements OnInit, AfterViewInit {
/** Collection of possible frequent activities */
frequentActivities: any[] = frequentActivities;
/** Zitadel oidcServerEnabled */
public environment = environment;
oidcServerEnabled = !(
(window as any)?.env?.oidcServerEnabled === false ||
(window as any)?.env?.oidcServerEnabled === 'false' ||
(window as any)?.env?.oidcServerEnabled === 0 ||
(window as any)?.env?.oidcServerEnabled === '0' ||
(window as any)?.env?.oidcServerEnabled === null ||
(window as any)?.env?.oidcServerEnabled === undefined
);
/* Refernce of logo */
@ViewChild('logo') logo: ElementRef<any>;
/* Template for popover on logo */
@@ -82,7 +97,8 @@ export class SidenavComponent implements OnInit, AfterViewInit {
private authenticationService: AuthenticationService,
private settingsService: SettingsService,
private configurationWizardService: ConfigurationWizardService,
private popoverService: PopoverService
private popoverService: PopoverService,
private authService: AuthService
) {
this.userActivity = JSON.parse(localStorage.getItem('mifosXLocation'));
}
@@ -100,7 +116,11 @@ export class SidenavComponent implements OnInit, AfterViewInit {
* Logs out the authenticated user and redirects to login page.
*/
logout() {
this.authenticationService.logout().subscribe(() => this.router.navigate(['/login'], { replaceUrl: true }));
if (this.oidcServerEnabled) {
this.authenticationService.logout().subscribe(() => this.router.navigate(['/login'], { replaceUrl: true }));
} else {
this.authService.logout();
}
}
/**
@@ -41,6 +41,10 @@ import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../.
import { ThemeToggleComponent } from '../../../shared/theme-toggle/theme-toggle.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Environment Configuration and Zitadel*/
import { environment } from '../../../../environments/environment';
import { AuthService } from 'app/zitadel/auth.service';
/**
* Toolbar component.
*/
@@ -83,6 +87,17 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** Zitadel oidcServerEnabled */
public environment = environment;
oidcServerEnabled = !(
(window as any)?.env?.oidcServerEnabled === false ||
(window as any)?.env?.oidcServerEnabled === 'false' ||
(window as any)?.env?.oidcServerEnabled === 0 ||
(window as any)?.env?.oidcServerEnabled === '0' ||
(window as any)?.env?.oidcServerEnabled === null ||
(window as any)?.env?.oidcServerEnabled === undefined
);
/** Instance of sidenav. */
@Input() sidenav: MatSidenav;
/** Sidenav collapse event. */
@@ -103,7 +118,8 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
private popoverService: PopoverService,
private configurationWizardService: ConfigurationWizardService,
private dialog: MatDialog,
private changeDetector: ChangeDetectorRef
private changeDetector: ChangeDetectorRef,
private authService: AuthService
) {}
/**
@@ -140,10 +156,11 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
* Logs out the authenticated user and redirects to login page.
*/
logout() {
this.authenticationService.logout().subscribe(() => {
this.notificationsTray.destroy();
this.router.navigate(['/login'], { replaceUrl: true });
});
if (this.oidcServerEnabled) {
this.authenticationService.logout().subscribe(() => this.router.navigate(['/login'], { replaceUrl: true }));
} else {
this.authService.logout();
}
}
/**
@@ -1,55 +1,72 @@
<form class="layout-column" [formGroup]="loginForm" (ngSubmit)="login()" id="login-form">
<mat-form-field class="login-input flex-align-center">
<span matPrefix class="m-r-10">
<fa-icon icon="user-circle" size="lg"></fa-icon>
</span>
<mat-label>{{ 'labels.inputs.Username' | translate }}</mat-label>
<input matInput type="text" autocomplete="off" formControlName="username" />
<mat-error *ngIf="loginForm.controls.username.hasError('required')">
{{ 'labels.inputs.Username' | translate }} <strong>{{ 'labels.commons.is required' | translate }}</strong>
</mat-error>
</mat-form-field>
<div *ngIf="oidcServerEnabled">
<form class="layout-column" [formGroup]="loginForm" (ngSubmit)="login()" id="login-form">
<mat-form-field class="login-input flex-align-center">
<span matPrefix class="m-r-10">
<fa-icon icon="user-circle" size="lg"></fa-icon>
</span>
<mat-label>{{ 'labels.inputs.Username' | translate }}</mat-label>
<input matInput type="text" autocomplete="off" formControlName="username" />
<mat-error *ngIf="loginForm.controls.username.hasError('required')">
{{ 'labels.inputs.Username' | translate }} <strong>{{ 'labels.commons.is required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field class="login-input flex-align-center">
<span matPrefix class="m-r-10">
<fa-icon icon="lock" size="lg"></fa-icon>
</span>
<mat-label>{{ 'labels.inputs.Password' | translate }}</mat-label>
<input matInput type="{{ passwordInputType }}" formControlName="password" />
<button
type="button"
matSuffix
*ngIf="loginForm.controls.password.value && !loading"
mat-icon-button
(mousedown)="togglePasswordVisibility()"
(mouseup)="togglePasswordVisibility()"
>
<fa-icon *ngIf="passwordInputType === 'password'" icon="eye"></fa-icon>
<fa-icon *ngIf="passwordInputType === 'text'" icon="eye-slash"></fa-icon>
</button>
<mat-error *ngIf="loginForm.controls.password.hasError('required')">
{{ 'labels.inputs.Password' | translate }} <strong>{{ 'labels.commons.pass is required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-checkbox formControlName="remember" class="m-t-10 flex-align-center">{{
'labels.inputs.Remember me' | translate
}}</mat-checkbox>
<mat-progress-bar *ngIf="loading" mode="indeterminate"></mat-progress-bar>
<button mat-raised-button color="primary" class="login-button flex-align-center" [disabled]="!loginForm.valid">
{{ 'labels.buttons.Login' | translate }}
<mat-spinner [diameter]="20" *ngIf="loading"></mat-spinner>
</button>
<mat-form-field class="login-input flex-align-center">
<span matPrefix class="m-r-10">
<fa-icon icon="lock" size="lg"></fa-icon>
</span>
<mat-label>{{ 'labels.inputs.Password' | translate }}</mat-label>
<input matInput type="{{ passwordInputType }}" formControlName="password" />
<button
type="button"
matSuffix
*ngIf="loginForm.controls.password.value && !loading"
mat-icon-button
(mousedown)="togglePasswordVisibility()"
(mouseup)="togglePasswordVisibility()"
mat-button
class="login-button flex-align-center"
(click)="forgotPassword()"
[disabled]="loading"
>
<fa-icon *ngIf="passwordInputType === 'password'" icon="eye"></fa-icon>
<fa-icon *ngIf="passwordInputType === 'text'" icon="eye-slash"></fa-icon>
{{ 'labels.links.Forgot Password?' | translate }}
</button>
<mat-error *ngIf="loginForm.controls.password.hasError('required')">
{{ 'labels.inputs.Password' | translate }} <strong>{{ 'labels.commons.pass is required' | translate }}</strong>
</mat-error>
</mat-form-field>
</form>
</div>
<mat-checkbox formControlName="remember" class="m-t-10 flex-align-center">{{
'labels.inputs.Remember me' | translate
}}</mat-checkbox>
<mat-progress-bar *ngIf="loading" mode="indeterminate"></mat-progress-bar>
<button mat-raised-button color="primary" class="login-button flex-align-center" [disabled]="!loginForm.valid">
{{ 'labels.buttons.Login' | translate }}
<mat-spinner [diameter]="20" *ngIf="loading"></mat-spinner>
</button>
<button
type="button"
mat-button
class="login-button flex-align-center"
(click)="forgotPassword()"
[disabled]="loading"
>
{{ 'labels.links.Forgot Password?' | translate }}
</button>
</form>
<div *ngIf="!oidcServerEnabled" class="container container-margin">
<div *ngIf="!isLoggedIn">
<button mat-raised-button color="primary" class="login-button-z" (click)="loginOIDC()">
{{ 'labels.buttons.Login' | translate }}
<mat-spinner [diameter]="20" *ngIf="loading"></mat-spinner>
</button>
</div>
<div *ngIf="isLoggedIn">
<p>Welcome! You are logged in.</p>
<button (click)="logout()">Logout</button>
<button (click)="loadProtectedData()">Load Protected Data</button>
<pre>{{ protectedData | json }}</pre>
</div>
</div>
@@ -18,3 +18,8 @@
::ng-deep .mat-mdc-form-field-infix {
width: 144px !important;
}
.container-margin {
margin-top: 8rem;
margin-bottom: 8rem;
}
@@ -15,6 +15,10 @@ import { MatProgressBar } from '@angular/material/progress-bar';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Ruta de seguridad Zitadel*/
import { AuthService } from '../../zitadel/auth.service';
import { environment } from '../../../environments/environment';
/**
* Login form component.
*/
@@ -40,13 +44,24 @@ export class LoginFormComponent implements OnInit {
/** True if loading. */
loading = false;
public environment = environment;
oidcServerEnabled = !(
(window as any)?.env?.oidcServerEnabled === false ||
(window as any)?.env?.oidcServerEnabled === 'false' ||
(window as any)?.env?.oidcServerEnabled === 0 ||
(window as any)?.env?.oidcServerEnabled === '0' ||
(window as any)?.env?.oidcServerEnabled === null ||
(window as any)?.env?.oidcServerEnabled === undefined
);
/**
* @param {FormBuilder} formBuilder Form Builder.
* @param {AuthenticationService} authenticationService Authentication Service.
*/
constructor(
private formBuilder: FormBuilder,
private authenticationService: AuthenticationService
private authenticationService: AuthenticationService,
private authService: AuthService
) {}
/**
@@ -78,6 +93,22 @@ export class LoginFormComponent implements OnInit {
.subscribe();
}
/**
* Authenticates the user if the credentials are valid to Zitadel.
*/
loginOIDC() {
this.authService.login();
}
getUsers() {
this.authService.getUsers();
}
logout() {
this.authService.logout();
}
/**
* Toggles the visibility of the password input field.
*
@@ -7,6 +7,9 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { SystemService } from '../../system.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Service Zitadel */
import { AuthService } from 'app/zitadel/auth.service';
/**
* Add Role Component.
*/
@@ -32,7 +35,8 @@ export class AddRoleComponent implements OnInit {
private formBuilder: UntypedFormBuilder,
private systemService: SystemService,
private route: ActivatedRoute,
private router: Router
private router: Router,
private authService: AuthService
) {}
/**
@@ -64,6 +68,7 @@ export class AddRoleComponent implements OnInit {
*/
submit() {
this.systemService.createRole(this.roleForm.value).subscribe((response: any) => {
this.authService.createRole(response.resourceId, this.roleForm.value.name, this.roleForm.value.description);
this.router.navigate(['../'], { relativeTo: this.route });
});
}
@@ -7,6 +7,9 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { SystemService } from '../../system.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Service Zitadel */
import { AuthService } from 'app/zitadel/auth.service';
/**
* Edit Role Description Component.
*/
@@ -35,7 +38,8 @@ export class EditRoleComponent implements OnInit {
private formBuilder: UntypedFormBuilder,
private systemService: SystemService,
private route: ActivatedRoute,
private router: Router
private router: Router,
private authService: AuthService
) {
this.route.data.subscribe((data: { role: any }) => {
this.roleData = data.role;
@@ -71,6 +75,7 @@ export class EditRoleComponent implements OnInit {
*/
submit() {
this.systemService.updateRole(this.roleForm.value, this.roleData.id).subscribe(() => {
this.authService.updateRole(this.roleData.id, this.roleForm.get('name')?.value, this.roleForm.value.description);
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
@@ -18,6 +18,9 @@ import { MatDivider } from '@angular/material/divider';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Service Zitadel */
import { AuthService } from 'app/zitadel/auth.service';
/**
* View Role and Permissions Component
*/
@@ -84,7 +87,8 @@ export class ViewRoleComponent implements OnInit {
private router: Router,
private formBuilder: UntypedFormBuilder,
private translateService: TranslateService,
public dialog: MatDialog
public dialog: MatDialog,
private authService: AuthService
) {
this.route.data.subscribe((data: { roledetails: any }) => {
this.rolePermissionService = data.roledetails;
@@ -281,6 +285,7 @@ export class ViewRoleComponent implements OnInit {
deleteRoleDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.systemService.deleteRole(this.roleId).subscribe(() => {
this.authService.deleteRole(this.roleId);
this.router.navigate(['/system/roles-and-permissions']);
});
} else {
+108 -41
View File
@@ -16,51 +16,115 @@ import { UsersResolver } from './users.resolver';
import { UsersTemplateResolver } from './users-template.resolver';
import { UserResolver } from './user.resolver';
/** Users Routes */
/** Custom Resolvers for Zitadel */
import { UsersZitadelResolver } from '../zitadel/users/usersZitadel.resolver';
import { UsersZitadelTemplateResolver } from '../zitadel/users/usersZitadel-template.resolver';
import { UserZitadelResolver } from '../zitadel/users/userZitadel.resolver';
/** Custom Components for Zitadel */
import { UsersComponent as UserZitadelComponent } from '../zitadel/users/users.component';
import { ViewUserComponent as ViewUserZitadelComponent } from '../zitadel/users/view-user/view-user.component';
import { CreateUserComponent as CreateUserZitadelComponent } from '../zitadel/users/create-user/create-user.component';
import { EditUserComponent as EditUserZitadelComponent } from '../zitadel/users/edit-user/edit-user.component';
/** Environment */
import { environment } from 'environments/environment';
/** Users Routes for Mifos */
const mifosRoutes: Routes = [
{
path: '',
component: UsersComponent,
resolve: { users: UsersResolver }
},
{
path: 'create',
component: CreateUserComponent,
data: { title: 'Create User', breadcrumb: 'Create User' },
resolve: { usersTemplate: UsersTemplateResolver }
},
{
path: ':id',
data: { title: 'View User', routeParamBreadcrumb: 'id' },
children: [
{
path: '',
component: ViewUserComponent,
resolve: { user: UserResolver }
},
{
path: 'edit',
component: EditUserComponent,
data: { title: 'Edit User', breadcrumb: 'Edit', routeResolveBreadcrumb: false },
resolve: {
user: UserResolver,
usersTemplate: UsersTemplateResolver
}
}
]
}
];
/**
* Route definition for Zitadel
*/
const zitadelRoutes: Routes = [
{
path: '',
component: UserZitadelComponent,
resolve: { users: UsersZitadelResolver }
},
{
path: 'create',
component: CreateUserZitadelComponent,
data: { title: 'Create User', breadcrumb: 'Create User' },
resolve: { usersTemplate: UsersTemplateResolver }
},
{
path: ':id',
data: { title: 'View User', routeParamBreadcrumb: 'id' },
children: [
{
path: '',
component: ViewUserZitadelComponent,
resolve: { user: UserZitadelResolver }
},
{
path: 'edit',
component: EditUserZitadelComponent,
data: { title: 'Edit User', breadcrumb: 'Edit', routeResolveBreadcrumb: false },
resolve: {
user: UserZitadelResolver,
usersTemplate: UsersZitadelTemplateResolver
}
}
]
}
];
/**
* Choose the correct route set according to the environment
*/
const isOidcEnabled = !(
(window as any)?.env?.oidcServerEnabled === false ||
(window as any)?.env?.oidcServerEnabled === 'false' ||
(window as any)?.env?.oidcServerEnabled === 0 ||
(window as any)?.env?.oidcServerEnabled === '0' ||
(window as any)?.env?.oidcServerEnabled === null ||
(window as any)?.env?.oidcServerEnabled === undefined
);
const selectedRoutes = isOidcEnabled ? mifosRoutes : zitadelRoutes;
/**
* Final routes with the shell
*/
const routes: Routes = [
Route.withShell([
{
path: 'appusers',
data: { title: 'Users', breadcrumb: 'Users' },
children: [
{
path: '',
component: UsersComponent,
resolve: {
users: UsersResolver
}
},
{
path: 'create',
component: CreateUserComponent,
data: { title: 'Create User', breadcrumb: 'Create User' },
resolve: {
usersTemplate: UsersTemplateResolver
}
},
{
path: ':id',
data: { title: 'View User', routeParamBreadcrumb: 'id' },
children: [
{
path: '',
component: ViewUserComponent,
resolve: {
user: UserResolver
}
},
{
path: 'edit',
component: EditUserComponent,
data: { title: 'Edit User', breadcrumb: 'Edit', routeResolveBreadcrumb: false },
resolve: {
user: UserResolver,
usersTemplate: UsersTemplateResolver
}
}
]
}
]
children: selectedRoutes
}
])
@@ -77,7 +141,10 @@ const routes: Routes = [
providers: [
UsersResolver,
UsersTemplateResolver,
UserResolver
UserResolver,
UsersZitadelResolver,
UserZitadelResolver,
UsersZitadelTemplateResolver
]
})
export class UsersRoutingModule {}
+12 -1
View File
@@ -11,6 +11,12 @@ import { CreateUserComponent } from './create-user/create-user.component';
import { ViewUserComponent } from './view-user/view-user.component';
import { EditUserComponent } from './edit-user/edit-user.component';
/** Custom Components of Zitadel */
import { ViewUserComponent as ViewUserZitadelComponent } from '../zitadel/users/view-user/view-user.component';
import { EditUserComponent as EditUserZitadelComponent } from '../zitadel/users/edit-user/edit-user.component';
import { CreateUserComponent as CreateUserZitadelComponent } from '../zitadel/users/create-user/create-user.component';
import { UsersComponent as UserZitadelComponent } from '../zitadel/users/users.component';
/**
* Users Module
*
@@ -23,7 +29,12 @@ import { EditUserComponent } from './edit-user/edit-user.component';
UsersComponent,
CreateUserComponent,
ViewUserComponent,
EditUserComponent
EditUserComponent,
// Users Zitadel
UserZitadelComponent,
ViewUserZitadelComponent,
EditUserZitadelComponent,
CreateUserZitadelComponent
]
})
export class UsersModule {}
+40 -9
View File
@@ -32,6 +32,8 @@ import { animate, style, transition, trigger } from '@angular/animations';
import { I18nService } from './core/i18n/i18n.service';
import { ThemingService } from './shared/theme-toggle/theming.service';
import { AuthService } from './zitadel/auth.service';
/** Initialize Logger */
const log = new Logger('MifosX');
@@ -90,6 +92,17 @@ export class WebAppComponent implements OnInit {
i18nService: I18nService;
oidcServerEnabled = !(
(window as any)?.env?.oidcServerEnabled === false ||
(window as any)?.env?.oidcServerEnabled === 'false' ||
(window as any)?.env?.oidcServerEnabled === 0 ||
(window as any)?.env?.oidcServerEnabled === '0' ||
(window as any)?.env?.oidcServerEnabled === null ||
(window as any)?.env?.oidcServerEnabled === undefined
);
isLoggedIn = false;
/**
* @param {Router} router Router for navigation.
* @param {ActivatedRoute} activatedRoute Activated Route.
@@ -117,7 +130,8 @@ export class WebAppComponent implements OnInit {
private themingService: ThemingService,
private dateUtils: Dates,
private idle: IdleTimeoutService,
private dialog: MatDialog
private dialog: MatDialog,
private authService: AuthService
) {}
@HostBinding('class') public cssClass: string;
@@ -133,7 +147,20 @@ export class WebAppComponent implements OnInit {
*
* 4) Alerts
*/
login() {
this.authService.login();
}
ngOnInit() {
let code = localStorage.getItem('auth_code');
if (code) {
const codeVerifier = localStorage.getItem('code_verifier');
this.authService.exchangeCodeForTokens(code, codeVerifier);
this.isLoggedIn = !this.isLoggedIn;
}
this.themingService.theme.subscribe((value: string) => {
this.cssClass = value;
});
@@ -222,14 +249,18 @@ export class WebAppComponent implements OnInit {
// Subscribe to session timeout If IdleTimeout is higher than 0 (zero)
if (environment.session.timeout.idleTimeout > 0) {
this.idle.$onSessionTimeout.subscribe(() => {
if (this.authenticationService.getUserLoggedIn()) {
this.alertService.alert({
type: 'Session timeout',
message: this.translateService.instant('labels.text.Session timed out')
});
this.dialog.open(SessionTimeoutDialogComponent);
this.logout();
}
this.alertService.alert({
type: 'Session timeout',
message: this.translateService.instant('labels.text.Session timed out')
});
this.dialog.open(SessionTimeoutDialogComponent);
setTimeout(() => {
if (this.oidcServerEnabled) {
this.logout();
} else {
this.authService.logout();
}
}, 1000);
});
}
}
+10
View File
@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router, UrlTree, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const authService = inject(AuthService);
const router = inject(Router);
return true;
};
+414
View File
@@ -0,0 +1,414 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
/** Custom Services */
import { AuthenticationService } from '../core/authentication/authentication.service';
import { Credentials } from '../core/authentication/credentials.model';
import { OAuth2Token } from '../core/authentication/o-auth2-token.model';
import { environment } from 'environments/environment';
@Injectable({ providedIn: 'root' })
export class AuthService {
private baseUrl = environment.OIDC.oidcBaseUrl;
private authUrl = `${this.baseUrl}oauth/v2/authorize`;
private tokenUrl = `${this.baseUrl}oauth/v2/token`;
private clientId = environment.OIDC.oidcClientId;
private api = environment.OIDC.oidcApiUrl;
private frontUrl = environment.OIDC.oidcFrontUrl;
private redirectUri = `${this.frontUrl}#/login`;
private refreshTimeoutId: any = null;
constructor(
private authenticationService: AuthenticationService,
private http: HttpClient,
private route: ActivatedRoute,
private router: Router
) {}
async login() {
const codeVerifier = this.generateRandomString();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
localStorage.setItem('code_verifier', codeVerifier);
const url =
`${this.authUrl}` +
`?client_id=${encodeURIComponent(this.clientId)}` +
`&redirect_uri=${encodeURIComponent(this.redirectUri)}` +
`&response_type=code` +
`&scope=openid profile email offline_access` +
`&code_challenge=${codeChallenge}` +
`&code_challenge_method=S256`;
window.location.href = url;
}
logout() {
//return;
const idToken = localStorage.getItem('id_token');
const postLogoutRedirectUri = this.frontUrl + '#/login';
if (this.refreshTimeoutId) {
clearTimeout(this.refreshTimeoutId);
this.refreshTimeoutId = null;
}
if (!idToken) {
//console.warn('No id_token found. Redirecting to login.');
window.location.href = postLogoutRedirectUri;
return;
}
sessionStorage.removeItem('mifosXCredentials');
sessionStorage.removeItem('mifosXZitadelTokenDetails');
localStorage.removeItem('access_token');
localStorage.removeItem('expires_in');
localStorage.removeItem('id_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('refresh_expires_in');
localStorage.removeItem('token_start_time');
localStorage.removeItem('code_verifier');
localStorage.removeItem('mifosXZitadel');
localStorage.removeItem('auth_code');
const logoutUrl = `${this.baseUrl}/oidc/v1/end_session?id_token_hint=${idToken}&post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirectUri)}`;
window.location.href = logoutUrl;
}
getAccessToken(): string | null {
const rawToken = sessionStorage.getItem('mifosXZitadelTokenDetails');
if (rawToken) {
const parsedToken: OAuth2Token = JSON.parse(rawToken);
return parsedToken.access_token;
}
return null;
}
generateRandomString(length = 128) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
let result = '';
for (let i = 0; i < length; i++) {
result += charset.charAt(Math.floor(Math.random() * charset.length));
}
return result;
}
async generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return base64;
}
exchangeCodeForTokens(code: string, codeVerifier: string | null) {
const payload = {
code: code,
code_verifier: codeVerifier || ''
};
fetch(this.api + 'authentication/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
.then((res) => {
if (!res.ok) {
throw new Error(`Error exchanging code: ${res.status} ${res.statusText}`);
}
return res.json();
})
.then(
(tokens: {
access_token: string;
id_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
}) => {
const token: OAuth2Token = {
access_token: tokens.access_token,
token_type: tokens.token_type,
refresh_token: tokens.refresh_token,
expires_in: tokens.expires_in,
scope: 'Bearer'
};
localStorage.setItem('id_token', tokens.id_token);
localStorage.setItem('mifosXZitadel', 'true');
sessionStorage.setItem('mifosXZitadelTokenDetails', JSON.stringify(token));
localStorage.setItem('refresh_token', tokens.refresh_token);
this.scheduleRefresh(tokens.expires_in);
localStorage.removeItem('auth_code');
localStorage.removeItem('code_verifier');
this.userdetails();
}
)
.catch((error) => {
//console.error('Error exchanging the code for tokens:', error);
localStorage.removeItem('auth_code');
localStorage.removeItem('code_verifier');
});
}
userdetails() {
const accessToken = this.getAccessToken();
if (!accessToken) {
//console.error('No access token found');
return;
}
fetch(this.api + 'authentication/userdetails', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ token: accessToken })
})
.then((res) => {
if (!res.ok) {
throw new Error('Error retrieving user data from backend');
}
return res.json();
})
.then((userInfo) => {
const user = userInfo.object;
const credentials: Credentials = user;
this.authenticationService.saveZitadelCredentials(credentials);
window.location.href = '/#/home';
})
.catch((error) => {
//console.error('Error consuming backend:', error);
});
}
// Delete
public deletUser(userId: string) {
fetch(`${this.api}authentication/user/${userId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.getAccessToken()}`
}
})
.then((res) => res.json())
.then((data) => {
//console.log(data);
if (data.status === 200) {
this.router.navigate(['/appusers']);
} else {
alert(data.msg);
}
})
.catch((error) => {
alert(error.msg);
});
}
// Activar
public activeUser(userId: string) {
fetch(`${this.api}authentication/user/act/${userId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.getAccessToken()}`
},
body: JSON.stringify({ userId })
})
.then((res) => res.json())
.then((data) => {
window.location.reload();
})
.catch((error) => {
alert(error.msg);
//console.error('Error activating user:', error);
});
}
// Desactive
public desactiveUser(userId: string) {
fetch(`${this.api}authentication/user/des/${userId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.getAccessToken()}`
}
})
.then((res) => res.json())
.then((data) => {
window.location.reload();
})
.catch((error) => {
alert(error.msg);
//console.error('Error deactivating user:', error);
});
}
public getUsers() {
let getUsers: any[] = [];
fetch(`${this.api}authentication/user`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.getAccessToken()}`
}
})
.then((res) => res.json())
.then((response) => {
const users = response.data?.result;
if (Array.isArray(users)) {
users.forEach((element: { human: any }) => {
const human = element.human;
if (human) {
getUsers.push(human);
}
});
//console.log(getUsers);
} else {
//console.error('The response does not contain valid users');
}
})
.catch((error) => console.error(`Error retrieving users: ${error}`));
}
/*** CRUD to Role */
public createRole(roleKey: string, displayName: string, group: string) {
fetch(`${this.api}authentication/role`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.getAccessToken()}`
},
body: JSON.stringify({ roleKey, displayName, group })
})
.then((res) => res.json())
.then((data) => {
// Registro exitoso
})
.catch((error) => {
alert(error.msg);
//console.error('Error creating Role:', error);
});
}
public updateRole(roleKey: string, displayName: string, group: string) {
fetch(`${this.api}authentication/role`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.getAccessToken()}`
},
body: JSON.stringify({ roleKey, displayName, group })
})
.then((res) => res.json())
.then((data) => {})
.catch((error) => {
alert(error.msg);
//console.error('Error updating Role:', error);
});
}
public deleteRole(roleKey: string) {
fetch(`${this.api}authentication/role/${roleKey}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.getAccessToken()}`
},
body: JSON.stringify({ roleKey })
})
.then((res) => res.json())
.then((data) => {})
.catch((error) => {
alert(error.msg);
//console.error(error.msg);
});
}
refreshToken(): Promise<void> {
return new Promise((resolve, reject) => {
const rt = localStorage.getItem('refresh_token');
if (!rt) {
//console.warn('No refresh_token exists in localStorage. You must log in again.');
//console.log("logout from refreshToken");
//this.logout();
return reject('Sin refresh_token');
}
const payload = new URLSearchParams();
payload.set('grant_type', 'refresh_token');
payload.set('refresh_token', rt);
payload.set('client_id', this.clientId);
//console.log('Iniciando refreshToken()');
fetch(this.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: payload.toString()
})
.then((res) => {
if (!res.ok) {
//console.error(`Error HTTP en refresh: ${res.status} ${res.statusText}`);
return res.text().then((text) => {
//console.error('Cuerpo de error:', text);
throw new Error(text);
});
}
return res.json();
})
.then((tokens) => {
//console.log('Respuesta del token refresh:', tokens);
if (!tokens || !tokens.access_token || !tokens.expires_in) {
throw new Error("The server's response does not contain the expected fields.");
}
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('id_token', tokens.id_token ?? '');
localStorage.setItem('refresh_token', tokens.refresh_token ?? '');
localStorage.setItem('expires_in', tokens.expires_in.toString());
localStorage.setItem('refresh_expires_in', tokens.refresh_expires_in?.toString() ?? '');
localStorage.setItem('token_start_time', Date.now().toString());
this.scheduleRefresh(tokens.expires_in);
resolve();
})
.catch((err) => {
//console.warn('refreshToken failed, forcing logout in 2 seconds');
//console.warn('→ Error:', err);
//console.warn('→ refresh_token usado:', rt);
setTimeout(() => {
//console.log('Forcing logout after refreshToken error');
//this.logout();
}, 300000);
reject(err);
});
});
}
private scheduleRefresh(expiresIn: number) {
const refreshInMs = (expiresIn - 3539) * 1000;
if (refreshInMs <= 0) {
//console.log('expiresIn too small or negative, refreshing immediately');
this.refreshToken();
return;
}
if (this.refreshTimeoutId) {
clearTimeout(this.refreshTimeoutId);
}
this.refreshTimeoutId = setTimeout(() => {
this.refreshToken();
}, refreshInMs);
}
}
@@ -0,0 +1,26 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { AuthService } from '../auth.service';
@Component({
selector: 'mifosx-callback',
template: `<p>Processing callback...</p>`
})
export class CallbackComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private authService: AuthService
) {}
ngOnInit(): void {
this.route.queryParams.subscribe((params) => {
const code = params['code'];
if (code) {
const codeVerifier = localStorage.getItem('code_verifier');
this.authService.exchangeCodeForTokens(code, codeVerifier);
} else {
console.warn('No code was received in the URL');
}
});
}
}
@@ -0,0 +1,42 @@
<h1 mat-dialog-title>{{ 'labels.heading.Change Password' | translate }}</h1>
<div mat-dialog-content>
<form [formGroup]="changePasswordForm" class="layout-column" (ngSubmit)="onSubmit()">
<!-- Contraseña actual -->
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labels.inputs.Password Current' | translate }}</mat-label>
<input matInput type="password" formControlName="currentPassword" required />
<mat-error *ngIf="changePasswordForm.get('currentPassword')?.hasError('required')">
{{ 'inputs.currentPasswordRequired' | translate }}
</mat-error>
</mat-form-field>
<!-- Nueva contraseña -->
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labels.inputs.Password New' | translate }}</mat-label>
<input matInput type="password" formControlName="password" required />
<mat-error *ngIf="changePasswordForm.get('password')?.errors">
{{ 'labels.commons.Password validation' | translate: { minchar: minPasswordLength } }}
</mat-error>
</mat-form-field>
<!-- Confirmar contraseña -->
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labels.inputs.Repeat Password' | translate }}</mat-label>
<input matInput type="password" formControlName="repeatPassword" required />
<mat-error *ngIf="changePasswordForm.get('repeatPassword')?.hasError('notequal')">
{{ 'inputs.passwordsDoNotMatch' | translate }}
</mat-error>
</mat-form-field>
<!-- Botones de acción dentro del formulario -->
<mat-dialog-actions align="center">
<button mat-raised-button color="warn" type="button" (click)="dialogRef.close()">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="!changePasswordForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-dialog-actions>
</form>
</div>
@@ -0,0 +1,8 @@
.error {
color: red;
}
mat-dialog-content {
min-width: 240px;
width: 240px;
}
@@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangePasswordDialogComponent } from './change-password-dialog.component';
describe('ChangePasswordDialogComponent', () => {
let component: ChangePasswordDialogComponent;
let fixture: ComponentFixture<ChangePasswordDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ChangePasswordDialogComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ChangePasswordDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,111 @@
import { Component, OnInit, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogActions } from '@angular/material/dialog';
import { UntypedFormBuilder, Validators, AbstractControl, ValidationErrors, FormGroup } from '@angular/forms';
import { confirmPasswordValidator } from 'app/login/reset-password/confirm-password.validator';
import { HttpClient } from '@angular/common/http';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { environment } from '../../../../environments/environment';
/**
* Change Password Dialog component.
*/
@Component({
selector: 'mifosx-change-password-dialog',
templateUrl: './change-password-dialog.component.html',
styleUrls: ['./change-password-dialog.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogActions
]
})
export class ChangePasswordDialogComponent implements OnInit {
minPasswordLength: number = environment.minPasswordLength || 12;
changePasswordForm: FormGroup;
constructor(
public dialogRef: MatDialogRef<ChangePasswordDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private formBuilder: UntypedFormBuilder,
private http: HttpClient
) {}
private api = environment.OIDC.oidcApiUrl;
ngOnInit(): void {
this.createChangePasswordForm();
this.setupPasswordMatchValidation();
}
createChangePasswordForm(): void {
this.changePasswordForm = this.formBuilder.group(
{
currentPassword: [
'',
Validators.required
],
password: [
'',
[
Validators.required,
Validators.minLength(this.minPasswordLength),
Validators.maxLength(50),
Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).+$/)]
],
repeatPassword: [
'',
Validators.required
]
},
{ validators: confirmPasswordValidator }
);
}
setupPasswordMatchValidation(): void {
this.changePasswordForm.get('password')?.valueChanges.subscribe(() => {
this.changePasswordForm.get('repeatPassword')?.updateValueAndValidity();
});
this.changePasswordForm.get('repeatPassword')?.setValidators([
Validators.required,
this.matchOtherControl('password')]);
}
matchOtherControl(controlNameToMatch: string) {
return (control: AbstractControl): ValidationErrors | null => {
const parent = control?.parent;
if (!parent) return null;
const matchingControl = parent.get(controlNameToMatch);
if (!matchingControl) return null;
return control.value === matchingControl.value ? null : { notequal: true };
};
}
onSubmit(): void {
if (this.changePasswordForm.invalid) return;
const requestBody = {
userId: this.data.id,
currentPassword: this.changePasswordForm.value.currentPassword,
newPassword: {
password: this.changePasswordForm.value.password,
changeRequired: false
}
};
console.log('JSON a enviar:', requestBody);
this.http.put(this.api + 'authentication/user/password', requestBody).subscribe({
next: (res) => {
//console.log('Contraseña actualizada', res);
this.dialogRef.close(true);
},
error: (err) => {
console.log(err);
alert(err + 'No es posible actualizar contraseña');
}
});
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
// Angular Material Modules
import { MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { ChangePasswordDialogComponent } from './change-password-dialog/change-password-dialog.component';
@NgModule({
imports: [
CommonModule,
ReactiveFormsModule,
TranslateModule,
MatDialogModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatTooltipModule,
ChangePasswordDialogComponent
],
exports: [
ChangePasswordDialogComponent
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class SharedModule {}
+64
View File
@@ -0,0 +1,64 @@
import { Injectable } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError, from } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { environment } from 'environments/environment';
@Injectable()
export class TokenInterceptor implements HttpInterceptor {
public environment = environment;
FINERACT_PLATFORM_TENANT_IDENTIFIER = environment.fineractPlatformTenantId;
constructor(private authService: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = this.authService.getAccessToken();
let headersConfig: { [key: string]: string } = {
'Fineract-Platform-TenantId': this.FINERACT_PLATFORM_TENANT_IDENTIFIER,
'Content-Type': req.headers.get('Content-Type') || 'application/json'
};
const publicEndpoints = [
'/auth/test',
'/health'
];
const isPublicEndpoint = publicEndpoints.some((url) => req.url.includes(url));
if (token && !isPublicEndpoint) {
headersConfig['Authorization'] = `Bearer ${token}`;
}
const authReq = req.clone({ setHeaders: headersConfig });
return next.handle(authReq).pipe(
catchError((err) => {
if (err instanceof HttpErrorResponse && err.status === 401 && !isPublicEndpoint) {
return from(this.handle401Error(authReq, next));
}
return throwError(() => err);
})
);
}
private async handle401Error(request: HttpRequest<any>, next: HttpHandler): Promise<HttpEvent<any>> {
try {
await this.authService.refreshToken();
const newToken = this.authService.getAccessToken();
if (newToken) {
const retriedReq = request.clone({
setHeaders: {
Authorization: `Bearer ${newToken}`,
'Fineract-Platform-TenantId': this.FINERACT_PLATFORM_TENANT_IDENTIFIER,
'Content-Type': request.headers.get('Content-Type') || 'application/json'
}
});
return next.handle(retriedReq).toPromise() as Promise<HttpEvent<any>>;
} else {
throw new Error('No new access token obtained after refresh');
}
} catch (e) {
console.error('Error in handle401Error, forcing logout');
throw e;
}
}
}
@@ -0,0 +1,205 @@
<div class="container">
<mat-card>
<form [formGroup]="userForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<!-- Username -->
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Username' | translate }}</mat-label>
<input matInput required formControlName="username" />
<mat-error *ngIf="userForm.controls.username.hasError('required')">
{{ 'labels.inputs.Username' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<!-- Email -->
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Email' | translate }}</mat-label>
<input matInput required formControlName="email" />
<mat-error *ngIf="userForm.controls.email.hasError('email')">
{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.invalid' | translate }}</strong>
</mat-error>
<mat-error *ngIf="userForm.controls.email.hasError('required')">
{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<!-- First Name -->
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<input matInput formControlName="firstName" required />
</mat-form-field>
<!-- Last Name -->
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<input matInput formControlName="lastName" required />
</mat-form-field>
<!-- Preferred Language -->
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Language' | translate }}</mat-label>
<mat-select formControlName="preferredLanguage" required>
<mat-option value="es">{{ 'languages.es-MX' | translate }}</mat-option>
<mat-option value="en">{{ 'languages.en-US' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<!-- Gender -->
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Gender' | translate }}</mat-label>
<mat-select formControlName="gender" required>
<mat-option value="GENDER_MALE">{{ 'labels.catalogs.male' | translate }}</mat-option>
<mat-option value="GENDER_FEMALE">{{ 'labels.catalogs.female' | translate }}</mat-option>
<mat-option value="GENDER_OTHER">{{ 'labels.catalogs.other' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<!-- Country code and phone -->
<div class="flex items-center gap-2">
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Code' | translate }}</mat-label>
<mat-select formControlName="countryCode" required>
<mat-option *ngFor="let country of countryCodes" [value]="country.code">
{{ 'countries.' + country.key | translate }} ({{ country.code }})
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Phone Number' | translate }}</mat-label>
<input matInput formControlName="phoneNumber" required maxlength="12" minlength="4" />
</mat-form-field>
</div>
<mat-form-field class="flex-20">
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
<mat-select required formControlName="officeId">
<mat-option *ngFor="let office of officesData" [value]="office.id">
{{ office.name }}
</mat-option>
</mat-select>
<mat-error *ngIf="userForm.controls.officeId.hasError('required')">
{{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field class="flex-20">
<mat-label>{{ 'labels.inputs.Staff' | translate }}</mat-label>
<mat-select formControlName="staffId">
<mat-option *ngFor="let staff of staffData" [value]="staff.id">
{{ staff.displayName }}
</mat-option>
</mat-select>
</mat-form-field>
<!-- Password -->
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Password' | translate }}</mat-label>
<input matInput type="password" formControlName="password" required />
<mat-error *ngIf="userForm.get('password')?.hasError('required')">
{{ 'labels.inputs.Password' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
<mat-error *ngIf="userForm.get('password')?.hasError('minlength')">
{{ 'labels.commons.Password should be at least' | translate }}
<strong>{{ 'labels.commons.12 characters long' | translate }}</strong>
</mat-error>
<mat-error *ngIf="userForm.get('password')?.hasError('maxlength')">
{{ 'labels.commons.Password should not be more than' | translate }}
<strong>{{ 'labels.commons.50 characters long' | translate }}</strong>
</mat-error>
<mat-error
*ngIf="
userForm.get('password')?.hasError('pattern') &&
!userForm.get('password')?.hasError('minlength') &&
!userForm.get('password')?.hasError('maxlength')
"
>
{{ 'labels.commons.Password should include a' | translate }}
<strong>{{ 'labels.commons.numeral' | translate }}</strong>
{{ 'labels.commons.and at' | translate }}
<strong>{{ 'labels.commons.least one uppercase' | translate }}</strong
>,
<strong>{{ 'labels.commons.lowercase and special character' | translate }}</strong>
</mat-error>
</mat-form-field>
<!-- Repeat password -->
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Repeat Password' | translate }}</mat-label>
<input matInput type="password" formControlName="repeatPassword" required />
<mat-error *ngIf="userForm.get('repeatPassword')?.hasError('required')">
{{ 'labels.inputs.Password' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<!-- Passwords do not match -->
<p
class="flex-98 reset-password-error"
*ngIf="
userForm.errors?.passwordsDoNotMatch &&
!userForm.get('password')?.hasError('required') &&
!userForm.get('repeatPassword')?.hasError('required')
"
>
{{ 'labels.inputs.Passwords' | translate }}
<strong>{{ 'labels.commons.do not match' | translate }}</strong>
</p>
<!-- Roles -->
<mat-form-field class="flex-20">
<mat-label>{{ 'labels.inputs.Roles' | translate }}</mat-label>
<mat-select formControlName="roles" required multiple>
<mat-option *ngFor="let role of rolesData" [value]="role.id">
{{ role.name }}
</mat-option>
</mat-select>
<mat-error *ngIf="userForm.controls.roles.hasError('required')">
{{ 'labels.commons.At least one role' | translate }}
<strong>{{ 'labels.commons.must be selected' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
</mat-card-content>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="userForm.invalid">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
</div>
<ng-template #templateUserFormRef let-popover="popover">
<h2>{{ 'labels.heading.Create User' | translate }}</h2>
<p class="mw400">
{{ 'labels.text.Filling Details' | translate }}:
<a href="https://mifosforge.jira.com/wiki/spaces/docs/pages/67141740/Users" target="_blank">{{
'labels.inputs.Users' | translate
}}</a>
</p>
<div class="layout-row align-end gap-2px responsive-column">
<button mat-raised-button color="warn" (click)="popover.close(); configurationWizardService.closeConfigWizard()">
{{ 'labels.buttons.Close' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close()">
{{ 'labels.buttons.Create User' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); previousStep()">
{{ 'labels.buttons.Back' | translate }}
</button>
<button mat-raised-button color="primary" (click)="popover.close(); nextStep()">
{{ 'labels.buttons.Next' | translate }}
</button>
</div>
</ng-template>
@@ -0,0 +1,13 @@
.password-never-expires-wrapper {
padding: 17.5px 0;
}
.send-password-to-email-wrapper {
padding: 17.5px 0;
}
.reset-password-error {
color: #f44336;
font-size: 80%;
margin: 0;
}
@@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CreateUserComponent } from './create-user.component';
describe('CreateUserComponent', () => {
let component: CreateUserComponent;
let fixture: ComponentFixture<CreateUserComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [CreateUserComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CreateUserComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,489 @@
/** Angular Imports */
import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import {
UntypedFormGroup,
UntypedFormBuilder,
UntypedFormControl,
Validators,
ReactiveFormsModule
} from '@angular/forms';
import { Router, ActivatedRoute, RouterModule } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
//import { UsersService } from '../users.service';
import { UsersServiceZitadel } from '../usersZitadel.service';
import { PopoverService } from '../../../configuration-wizard/popover/popover.service';
/** Custom Dialog Component */
import { PasswordsUtility } from 'app/core/utils/passwords-utility';
import { confirmPasswordValidator } from 'app/login/reset-password/confirm-password.validator';
import { ConfigurationWizardService } from 'app/configuration-wizard/configuration-wizard.service';
import { ContinueSetupDialogComponent } from 'app/configuration-wizard/continue-setup-dialog/continue-setup-dialog.component';
import { UsersService } from 'app/users/users.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Create user component.
*/
@Component({
selector: 'mifosx-create-user',
templateUrl: './create-user.component.html',
styleUrls: ['./create-user.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS
]
})
export class CreateUserComponent implements OnInit, AfterViewInit {
/** User form. */
userForm: UntypedFormGroup;
/** Offices data. */
officesData: any;
/** Roles data. */
rolesData: any;
/** Staff data. */
staffData: any;
countryCodes = [
{ code: '+93', key: 'AF' },
{ code: '+355', key: 'AL' },
{ code: '+213', key: 'DZ' },
{ code: '+376', key: 'AD' },
{ code: '+244', key: 'AO' },
{ code: '+54', key: 'AR' },
{ code: '+374', key: 'AM' },
{ code: '+61', key: 'AU' },
{ code: '+43', key: 'AT' },
{ code: '+994', key: 'AZ' },
{ code: '+973', key: 'BH' },
{ code: '+880', key: 'BD' },
{ code: '+375', key: 'BY' },
{ code: '+32', key: 'BE' },
{ code: '+591', key: 'BO' },
{ code: '+387', key: 'BA' },
{ code: '+55', key: 'BR' },
{ code: '+359', key: 'BG' },
{ code: '+226', key: 'BF' },
{ code: '+257', key: 'BI' },
{ code: '+855', key: 'KH' },
{ code: '+237', key: 'CM' },
{ code: '+1', key: 'CA' },
{ code: '+56', key: 'CL' },
{ code: '+86', key: 'CN' },
{ code: '+57', key: 'CO' },
{ code: '+506', key: 'CR' },
{ code: '+385', key: 'HR' },
{ code: '+53', key: 'CU' },
{ code: '+357', key: 'CY' },
{ code: '+420', key: 'CZ' },
{ code: '+45', key: 'DK' },
{ code: '+20', key: 'EG' },
{ code: '+503', key: 'SV' },
{ code: '+34', key: 'ES' },
{ code: '+372', key: 'EE' },
{ code: '+251', key: 'ET' },
{ code: '+358', key: 'FI' },
{ code: '+33', key: 'FR' },
{ code: '+995', key: 'GE' },
{ code: '+49', key: 'DE' },
{ code: '+233', key: 'GH' },
{ code: '+30', key: 'GR' },
{ code: '+502', key: 'GT' },
{ code: '+504', key: 'HN' },
{ code: '+36', key: 'HU' },
{ code: '+354', key: 'IS' },
{ code: '+91', key: 'IN' },
{ code: '+62', key: 'ID' },
{ code: '+964', key: 'IQ' },
{ code: '+98', key: 'IR' },
{ code: '+353', key: 'IE' },
{ code: '+972', key: 'IL' },
{ code: '+39', key: 'IT' },
{ code: '+81', key: 'JP' },
{ code: '+962', key: 'JO' },
{ code: '+7', key: 'KZ' },
{ code: '+254', key: 'KE' },
{ code: '+965', key: 'KW' },
{ code: '+996', key: 'KG' },
{ code: '+371', key: 'LV' },
{ code: '+961', key: 'LB' },
{ code: '+218', key: 'LY' },
{ code: '+370', key: 'LT' },
{ code: '+352', key: 'LU' },
{ code: '+389', key: 'MK' },
{ code: '+60', key: 'MY' },
{ code: '+52', key: 'MX' },
{ code: '+373', key: 'MD' },
{ code: '+377', key: 'MC' },
{ code: '+976', key: 'MN' },
{ code: '+212', key: 'MA' },
{ code: '+258', key: 'MZ' },
{ code: '+977', key: 'NP' },
{ code: '+31', key: 'NL' },
{ code: '+64', key: 'NZ' },
{ code: '+505', key: 'NI' },
{ code: '+234', key: 'NG' },
{ code: '+47', key: 'NO' },
{ code: '+92', key: 'PK' },
{ code: '+507', key: 'PA' },
{ code: '+595', key: 'PY' },
{ code: '+51', key: 'PE' },
{ code: '+63', key: 'PH' },
{ code: '+48', key: 'PL' },
{ code: '+351', key: 'PT' },
{ code: '+974', key: 'QA' },
{ code: '+40', key: 'RO' },
{ code: '+7', key: 'RU' },
{ code: '+966', key: 'SA' },
{ code: '+221', key: 'SN' },
{ code: '+381', key: 'RS' },
{ code: '+65', key: 'SG' },
{ code: '+421', key: 'SK' },
{ code: '+386', key: 'SI' },
{ code: '+27', key: 'ZA' },
{ code: '+82', key: 'KR' },
{ code: '+94', key: 'LK' },
{ code: '+46', key: 'SE' },
{ code: '+41', key: 'CH' },
{ code: '+886', key: 'TW' },
{ code: '+66', key: 'TH' },
{ code: '+90', key: 'TR' },
{ code: '+256', key: 'UG' },
{ code: '+380', key: 'UA' },
{ code: '+971', key: 'AE' },
{ code: '+44', key: 'GB' },
{ code: '+1', key: 'US' },
{ code: '+598', key: 'UY' },
{ code: '+998', key: 'UZ' },
{ code: '+58', key: 'VE' },
{ code: '+84', key: 'VN' },
{ code: '+967', key: 'YE' },
{ code: '+260', key: 'ZM' },
{ code: '+263', key: 'ZW' }
];
/* Reference of create user form */
@ViewChild('userFormRef') userFormRef: ElementRef<any>;
/* Template for popover on create user form */
@ViewChild('templateUserFormRef') templateUserFormRef: TemplateRef<any>;
/**
* Retrieves the offices and roles data from `resolve`.
* @param {FormBuilder} formBuilder Form Builder.
* @param {UsersServiceZitadel} UsersService Users Service.
* @param {ActivatedRoute} route Activated Route.
* @param {Router} router Router for navigation.
* @param {ConfigurationWizardService} configurationWizardService ConfigurationWizard Service.
* @param {PopoverService} popoverService PopoverService.
*/
constructor(
private formBuilder: UntypedFormBuilder,
private usersService: UsersServiceZitadel,
private usersService2: UsersService,
private route: ActivatedRoute,
private router: Router,
private popoverService: PopoverService,
private configurationWizardService: ConfigurationWizardService,
private dialog: MatDialog,
private passwordsUtility: PasswordsUtility
) {
this.route.data.subscribe((data: { usersTemplate: any }) => {
this.officesData = data.usersTemplate.allowedOffices;
this.rolesData = data.usersTemplate.availableRoles;
});
}
/**
* Creates the user form, sets the staff data and conditional controls of the user form.
*/
ngOnInit() {
this.createUserForm();
this.setStaffData();
this.setConditionalControls();
}
/**
* Creates the user form.
*/
createUserForm(): void {
this.userForm = this.formBuilder.group(
{
username: [
'',
Validators.required
],
email: [
'',
[
Validators.required,
Validators.email
]
],
firstName: [
'',
Validators.required
],
lastName: [
'',
Validators.required
],
preferredLanguage: [
'',
Validators.required
],
gender: [
'',
Validators.required
],
countryCode: [
'+1',
Validators.required
],
phoneNumber: [
'',
[
Validators.required,
Validators.pattern(/^[0-9]{7,15}$/)]
],
password: [
'',
[
Validators.required,
Validators.minLength(12),
Validators.maxLength(50),
Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).+$/)]
],
repeatPassword: [
'',
Validators.required
],
roles: [
'',
Validators.required
],
officeId: [
'',
Validators.required
],
staffId: ['']
},
{ validators: confirmPasswordValidator }
);
/*
this.userForm.get('repeatPassword')?.valueChanges.subscribe(() => {
this.userForm.updateValueAndValidity();
});
this.userForm.statusChanges.subscribe((status) => {
console.log('Form status:', status);
console.log(
'Invalid fields:',
Object.entries(this.userForm.controls)
.filter(
([
_,
control
]) => control.invalid
)
.map(([key]) => key)
);
});*/
}
/**
* Sets the staff data each time the user selects a new office
*/
setStaffData() {
this.userForm.get('officeId').valueChanges.subscribe((officeId: string) => {
this.staffData = [];
this.usersService2.getStaff(officeId).subscribe((staff: any) => {
this.staffData = staff;
});
});
}
/**
* Sets the conditional controls of the user form
*/
setConditionalControls() {
this.userForm.get('sendPasswordToEmail').valueChanges.subscribe((sendPasswordToEmail: boolean) => {
const passwordControl = this.userForm.get('password');
const repeatPasswordControl = this.userForm.get('repeatPassword');
if (sendPasswordToEmail) {
passwordControl.disable();
repeatPasswordControl.disable();
this.userForm.get('email')?.setValidators([
Validators.required,
Validators.email
]);
} else {
passwordControl.enable();
repeatPasswordControl.enable();
this.userForm.get('email')?.setValidators([Validators.email]);
}
this.userForm.get('email')?.updateValueAndValidity();
});
}
/**
* Submits the user form and creates user,
* if successful redirects to view created user.
*/
submit() {
const fullForm = this.userForm.value;
const fullPhone = `${fullForm.countryCode}${fullForm.phoneNumber}`;
const password = `${fullForm.repeatPassword}`;
const givenName = `${fullForm.firstName}`;
const familyName = `${fullForm.lastName}`;
const nickName = `${fullForm.username}`;
const user = {
...fullForm,
phone: fullPhone,
password: password,
givenName: givenName,
familyName: familyName,
nickName: nickName,
displayName: `${fullForm.firstName} ${fullForm.lastName}`
};
const dataOffi = {
officeId: fullForm.officeId,
staffId: fullForm.staffId
};
const selectedRoleIds = this.userForm.get('roles')?.value;
delete user.officeId;
delete user.staffId;
delete user.roles;
delete user.countryCode;
delete user.phoneNumber;
delete user.repeatPassword;
delete user.firstName;
delete user.lastName;
this.usersService.createUser(user).subscribe((response: any) => {
const userId = response.object?.userId;
if (userId) {
const bodyBD = {
id: userId,
officeId: dataOffi.officeId,
staffId: dataOffi.staffId,
username: user.nickName,
firstname: user.givenName,
lastname: user.familyName,
roleIds: selectedRoleIds || []
};
//console.log('Sending to CrearBD:', bodyBD);
this.usersService.createUserBd(bodyBD).subscribe(
(resBD: any) => {
//console.log('User created in DB:', resBD);
if (selectedRoleIds?.length > 0) {
this.usersService.assignRolesToUser(userId, selectedRoleIds).subscribe(
() => {
if (this.configurationWizardService.showUsersForm === true) {
this.configurationWizardService.showUsersForm = false;
this.openDialog();
} else {
this.router.navigate(['/appusers']);
}
},
(error) => {
console.error('Error assigning roles:', error);
}
);
} else {
console.warn('No selected roles found.');
}
},
(error) => {
console.error('Error creating in DB (CrearBD):', error);
}
);
} else {
console.error('Could not get userId');
}
});
}
/**
* Popover function
* @param template TemplateRef<any>.
* @param target HTMLElement | ElementRef<any>.
* @param position String.
* @param backdrop Boolean.
*/
showPopover(
template: TemplateRef<any>,
target: HTMLElement | ElementRef<any>,
position: string,
backdrop: boolean
): void {
setTimeout(() => this.popoverService.open(template, target, position, backdrop, {}), 200);
}
/**
* To show popover.
*/
ngAfterViewInit() {
if (this.configurationWizardService.showUsersForm === true) {
setTimeout(() => {
this.showPopover(this.templateUserFormRef, this.userFormRef.nativeElement, 'top', true);
});
}
}
/**
* Next Step (Maker Checker Tasks System Page) Configuration Wizard.
*/
nextStep() {
this.configurationWizardService.showUsersForm = false;
this.configurationWizardService.showMakerCheckerTable = true;
this.router.navigate(['/system']);
}
/**
* Previous Step (Users page) Configuration Wizard.
*/
previousStep() {
this.configurationWizardService.showUsersForm = false;
this.configurationWizardService.showUsersList = true;
this.router.navigate(['/appusers']);
}
/**
* Opens dialog if the user wants to create more users.
*/
openDialog() {
const continueSetupDialogRef = this.dialog.open(ContinueSetupDialogComponent, {
data: {
stepName: 'user'
}
});
continueSetupDialogRef.afterClosed().subscribe((response: { step: number }) => {
if (response.step === 1) {
this.configurationWizardService.showUsersForm = false;
this.router.navigate(['../'], { relativeTo: this.route });
} else if (response.step === 2) {
this.configurationWizardService.showUsersForm = true;
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.router.onSameUrlNavigation = 'reload';
this.router.navigate(['/organization/users/create']);
} else if (response.step === 3) {
this.configurationWizardService.showUsersForm = false;
this.configurationWizardService.showMakerCheckerTable = true;
this.router.navigate(['/system']);
}
});
}
}
@@ -0,0 +1,124 @@
<div class="container">
<mat-card>
<form *ngIf="editUserForm" [formGroup]="editUserForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Username' | translate }}</mat-label>
<input matInput required formControlName="username" readonline />
<mat-error *ngIf="editUserForm.controls.username.hasError('required')">
{{ 'labels.inputs.Username' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Email' | translate }}</mat-label>
<input matInput required formControlName="email" />
<mat-error *ngIf="editUserForm.controls.email.hasError('email')">
{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.invalid' | translate }}</strong>
</mat-error>
<mat-error *ngIf="editUserForm.controls.email.hasError('required')">
{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<input matInput required formControlName="firstname" />
<mat-error *ngIf="editUserForm.controls.firstname.hasError('pattern')">
{{ 'labels.inputs.First Name' | translate }}
<strong>{{ 'labels.commons.cannot begin with a special character or number' | translate }}</strong>
</mat-error>
<mat-error *ngIf="editUserForm.controls.firstname.hasError('required')">
{{ 'labels.inputs.First Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<input matInput required formControlName="lastname" />
<mat-error *ngIf="editUserForm.controls.lastname.hasError('pattern')">
{{ 'labels.inputs.Last Name' | translate }}
<strong>{{ 'labels.commons.cannot begin with a special character or number' | translate }}</strong>
</mat-error>
<mat-error *ngIf="editUserForm.controls.lastname.hasError('required')">
{{ 'labels.inputs.Last Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field class="flex-20">
<mat-label>{{ 'labels.inputs.Code' | translate }}</mat-label>
<mat-select formControlName="countryCode" required>
<mat-option *ngFor="let country of countryCodes" [value]="country.code">
{{ 'countries.' + country.key | translate }} ({{ country.code }})
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="flex-75">
<mat-label>{{ 'labels.inputs.Phone Number' | translate }}</mat-label>
<input matInput formControlName="phoneNumber" required maxlength="12" minlength="4" />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Gender' | translate }}</mat-label>
<mat-select formControlName="gender" required>
<mat-option value="GENDER_MALE">{{ 'labels.catalogs.male' | translate }}</mat-option>
<mat-option value="GENDER_FEMALE">{{ 'labels.catalogs.female' | translate }}</mat-option>
<mat-option value="GENDER_OTHER">{{ 'labels.catalogs.other' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Language' | translate }}</mat-label>
<mat-select formControlName="preferredLanguage" required>
<mat-option value="es">{{ 'languages.es-MX' | translate }}</mat-option>
<mat-option value="en">{{ 'languages.en-US' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
<mat-select required formControlName="officeId" (selectionChange)="officeChanged($event.value)">
<mat-option *ngFor="let office of officesData" [value]="office.id">
{{ office.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Staff' | translate }}</mat-label>
<mat-select formControlName="staffId">
<mat-option *ngFor="let staff of staffData" [value]="staff.id">
{{ staff.displayName }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Roles' | translate }}</mat-label>
<mat-select formControlName="roles" multiple>
<mat-option *ngFor="let rol of rolesData" [value]="rol.id">
{{ rol.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
</div>
</mat-card-content>
</form>
</mat-card>
</div>
@@ -0,0 +1,3 @@
.password-never-expires-wrapper {
padding: 17.5px 0;
}
@@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { EditUserComponent } from './edit-user.component';
describe('EditUserComponent', () => {
let component: EditUserComponent;
let fixture: ComponentFixture<EditUserComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [EditUserComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditUserComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,334 @@
/** Angular Imports */
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
/** Custom Services */
import { UsersServiceZitadel } from '../usersZitadel.service';
import { UsersService } from 'app/users/users.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Edit User Component.
*/
@Component({
selector: 'mifosx-edit-user',
templateUrl: './edit-user.component.html',
styleUrls: ['./edit-user.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
]
})
export class EditUserComponent implements OnInit {
/** User Data */
userData: any;
/** Offices Data */
officesData: any;
/** Staff Data */
staffData: any;
/** Roles Data */
rolesData: any;
/** Edit User form. */
editUserForm: UntypedFormGroup;
/**
* Retrieves the offices data from `resolve`.
* @param {FormBuilder} formBuilder Form Builder.
* @param {UsersServiceZitadel} UsersServiceZitadel Users Service.
* @param {ActivatedRoute} route Activated Route.
* @param {UsersService} UsersService Users Service.
* @param {ConfigurationWizardService} configurationWizardService ConfigurationWizard Service.
* @param {Router} router Router for navigation.
*/
constructor(
private formBuilder: UntypedFormBuilder,
private UsersServiceZitadel: UsersServiceZitadel,
private UsersService: UsersService,
private route: ActivatedRoute,
private router: Router
) {
this.route.data.subscribe((data: { user: any; usersTemplate: any }) => {
const fullUserData = data.user;
this.userData = fullUserData.object?.result?.[0] || {};
this.officesData = data.usersTemplate.allowedOffices;
this.rolesData = data.usersTemplate.availableRoles;
this.UsersServiceZitadel.getDatosExtraUsuario(this.userData.id).subscribe((resp: any) => {
const datos = resp.object;
console.table(datos);
this.userData = {
...this.userData,
userName: datos.username_zitadel,
officeId: datos.office_id,
staffId: datos.staff_id,
selectedRoles: datos.roles || []
};
this.createEditUserForm();
this.officeChanged(this.userData.officeId);
this.UsersServiceZitadel.getRoles().subscribe((response: any) => {
const rolesZitadel = response.object?.result || [];
this.rolesData = rolesZitadel.map((r: any) => ({
id: r.key,
name: r.displayName
}));
});
});
});
}
countryCodes = [
{ code: '+93', key: 'AF' },
{ code: '+355', key: 'AL' },
{ code: '+213', key: 'DZ' },
{ code: '+376', key: 'AD' },
{ code: '+244', key: 'AO' },
{ code: '+54', key: 'AR' },
{ code: '+374', key: 'AM' },
{ code: '+61', key: 'AU' },
{ code: '+43', key: 'AT' },
{ code: '+994', key: 'AZ' },
{ code: '+973', key: 'BH' },
{ code: '+880', key: 'BD' },
{ code: '+375', key: 'BY' },
{ code: '+32', key: 'BE' },
{ code: '+591', key: 'BO' },
{ code: '+387', key: 'BA' },
{ code: '+55', key: 'BR' },
{ code: '+359', key: 'BG' },
{ code: '+226', key: 'BF' },
{ code: '+257', key: 'BI' },
{ code: '+855', key: 'KH' },
{ code: '+237', key: 'CM' },
{ code: '+1', key: 'CA' },
{ code: '+56', key: 'CL' },
{ code: '+86', key: 'CN' },
{ code: '+57', key: 'CO' },
{ code: '+506', key: 'CR' },
{ code: '+385', key: 'HR' },
{ code: '+53', key: 'CU' },
{ code: '+357', key: 'CY' },
{ code: '+420', key: 'CZ' },
{ code: '+45', key: 'DK' },
{ code: '+20', key: 'EG' },
{ code: '+503', key: 'SV' },
{ code: '+34', key: 'ES' },
{ code: '+372', key: 'EE' },
{ code: '+251', key: 'ET' },
{ code: '+358', key: 'FI' },
{ code: '+33', key: 'FR' },
{ code: '+995', key: 'GE' },
{ code: '+49', key: 'DE' },
{ code: '+233', key: 'GH' },
{ code: '+30', key: 'GR' },
{ code: '+502', key: 'GT' },
{ code: '+504', key: 'HN' },
{ code: '+36', key: 'HU' },
{ code: '+354', key: 'IS' },
{ code: '+91', key: 'IN' },
{ code: '+62', key: 'ID' },
{ code: '+964', key: 'IQ' },
{ code: '+98', key: 'IR' },
{ code: '+353', key: 'IE' },
{ code: '+972', key: 'IL' },
{ code: '+39', key: 'IT' },
{ code: '+81', key: 'JP' },
{ code: '+962', key: 'JO' },
{ code: '+7', key: 'KZ' },
{ code: '+254', key: 'KE' },
{ code: '+965', key: 'KW' },
{ code: '+996', key: 'KG' },
{ code: '+371', key: 'LV' },
{ code: '+961', key: 'LB' },
{ code: '+218', key: 'LY' },
{ code: '+370', key: 'LT' },
{ code: '+352', key: 'LU' },
{ code: '+389', key: 'MK' },
{ code: '+60', key: 'MY' },
{ code: '+52', key: 'MX' },
{ code: '+373', key: 'MD' },
{ code: '+377', key: 'MC' },
{ code: '+976', key: 'MN' },
{ code: '+212', key: 'MA' },
{ code: '+258', key: 'MZ' },
{ code: '+977', key: 'NP' },
{ code: '+31', key: 'NL' },
{ code: '+64', key: 'NZ' },
{ code: '+505', key: 'NI' },
{ code: '+234', key: 'NG' },
{ code: '+47', key: 'NO' },
{ code: '+92', key: 'PK' },
{ code: '+507', key: 'PA' },
{ code: '+595', key: 'PY' },
{ code: '+51', key: 'PE' },
{ code: '+63', key: 'PH' },
{ code: '+48', key: 'PL' },
{ code: '+351', key: 'PT' },
{ code: '+974', key: 'QA' },
{ code: '+40', key: 'RO' },
{ code: '+7', key: 'RU' },
{ code: '+966', key: 'SA' },
{ code: '+221', key: 'SN' },
{ code: '+381', key: 'RS' },
{ code: '+65', key: 'SG' },
{ code: '+421', key: 'SK' },
{ code: '+386', key: 'SI' },
{ code: '+27', key: 'ZA' },
{ code: '+82', key: 'KR' },
{ code: '+94', key: 'LK' },
{ code: '+46', key: 'SE' },
{ code: '+41', key: 'CH' },
{ code: '+886', key: 'TW' },
{ code: '+66', key: 'TH' },
{ code: '+90', key: 'TR' },
{ code: '+256', key: 'UG' },
{ code: '+380', key: 'UA' },
{ code: '+971', key: 'AE' },
{ code: '+44', key: 'GB' },
{ code: '+1', key: 'US' },
{ code: '+598', key: 'UY' },
{ code: '+998', key: 'UZ' },
{ code: '+58', key: 'VE' },
{ code: '+84', key: 'VN' },
{ code: '+967', key: 'YE' },
{ code: '+260', key: 'ZM' },
{ code: '+263', key: 'ZW' }
];
// eslint-disable-next-line @angular-eslint/no-empty-lifecycle-method
ngOnInit() {}
createEditUserForm() {
const profile = this.userData?.human?.profile ?? {};
const email = this.userData?.human?.email?.email || '';
const phone = this.userData?.human?.phone?.phone || '';
const defaultCode = '+1';
let countryCode = defaultCode;
let phoneNumber = phone;
const gender = profile?.gender && profile.gender !== '' ? profile.gender : 'GENDER_MALE';
const preferredLanguage = profile.preferredLanguage || 'es';
for (const c of this.countryCodes) {
if (phone.startsWith(c.code)) {
countryCode = c.code;
phoneNumber = phone.replace(c.code, '');
break;
}
}
this.editUserForm = this.formBuilder.group({
username: [
this.userData.userName,
Validators.required
],
email: [
email,
[
Validators.required,
Validators.email
]
],
firstname: [
profile.firstName || '',
[
Validators.required,
Validators.pattern('(^[A-z]).*')]
],
lastname: [
profile.lastName || '',
[
Validators.required,
Validators.pattern('(^[A-z]).*')]
],
countryCode: [
countryCode,
Validators.required
],
phoneNumber: [
phoneNumber,
Validators.required
],
gender: [
gender,
Validators.required
],
preferredLanguage: [
preferredLanguage,
Validators.required
],
officeId: [
this.userData.officeId,
Validators.required
],
staffId: [
this.userData.staffId || null
],
roles: [
this.userData.selectedRoles.map((role: any) => role.id.toString()),
Validators.required
]
});
}
/**
* Fetches the staff for the selected office
* @param officeId the selected office id
*/
officeChanged(officeId: number | undefined) {
if (!officeId) {
console.warn('No officeId provided to load staff.');
return;
}
this.staffData = [];
this.UsersService.getStaff(officeId).subscribe((staff: any) => {
this.staffData = staff;
});
}
/**
* Submits the user form and edits the user,
* if successful redirects to the updated user.
*/
submit() {
const form = this.editUserForm.value;
const fullPhone = `${form.countryCode}${form.phoneNumber}`;
const userPayload: any = {
userId: this.userData.id,
email: {
email: form.email,
isVerified: true
},
phone: {
phone: fullPhone,
isVerified: true
},
profile: {
username: form.username,
givenName: form.firstname,
familyName: form.lastname,
displayName: `${form.firstname} ${form.lastname}`,
nickName: form.firstname,
preferredLanguage: form.preferredLanguage,
gender: form.gender
}
};
const rolesPayload = {
userId: this.userData.id,
roleKeys: this.editUserForm.value.roles
};
const officePayload = {
userId: this.userData.id,
officeId: form.officeId,
staffId: form.staffId
};
this.UsersServiceZitadel.editUser(userPayload).subscribe();
this.UsersServiceZitadel.editRoles(rolesPayload).subscribe();
this.UsersServiceZitadel.editOffice(officePayload).subscribe((response: any) => {
this.router.navigate(['/appusers']);
});
}
}
@@ -0,0 +1,30 @@
/** Angular Imports */
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
/** rxjs Imports */
import { Observable } from 'rxjs';
/** Custom Services */
import { UsersServiceZitadel } from './usersZitadel.service';
/**
* UsersZitadel data resolver.
*/
@Injectable()
export class UserZitadelResolver {
/**
* @param {UsersServiceZitadel} usersServiceZitadel Users service.
*/
constructor(private usersServiceZitadel: UsersServiceZitadel) {}
/**
* Returns the user data.
* @returns {Observable<any>}
*/
resolve(route: ActivatedRouteSnapshot): Observable<any> {
const userId = route.paramMap.get('id');
return this.usersServiceZitadel.getUser(userId);
}
}
@@ -0,0 +1,47 @@
<div class="container m-b-20 layout-row align-end gap-20px">
<div #buttonCreateUser class="in-block">
<button mat-raised-button color="primary" [routerLink]="['create']">
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Create User' | translate }}
</button>
</div>
</div>
<div class="container container-margin">
<div #filter class="layout-row gap-20px">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
<input matInput (keyup)="applyFilter($event.target.value)" />
</mat-form-field>
</div>
</div>
<div class="container mat-elevation-z8 container-margin">
<h3>Usuarios</h3>
<table mat-table [dataSource]="dataSource" matSort #sortUsers="matSort">
<ng-container matColumnDef="firstname">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.First Name' | translate }}</th>
<td mat-cell *matCellDef="let user">{{ user.firstname }}</td>
</ng-container>
<ng-container matColumnDef="lastname">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Last Name' | translate }}</th>
<td mat-cell *matCellDef="let user">{{ user.lastname }}</td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Email' | translate }}</th>
<td mat-cell *matCellDef="let user">{{ user.email }}</td>
</ng-container>
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Office' | translate }}</th>
<td mat-cell *matCellDef="let user">{{ user.officeName }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="select-row"></tr>
</table>
<mat-paginator #paginatorUsers [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
@@ -0,0 +1,11 @@
table {
width: 100%;
.select-row:hover {
cursor: pointer;
}
}
.container-margin {
margin-top: 1rem;
}
+126
View File
@@ -0,0 +1,126 @@
import { MatPaginatorModule } from '@angular/material/paginator';
/** Angular Imports */
import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
/** Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
/**
* Users component.
*/
@Component({
selector: 'mifosx-users',
templateUrl: './users.component.html',
styleUrls: ['./users.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FontAwesomeModule,
MatPaginatorModule,
MatSortModule,
MatTableModule
]
})
export class UsersComponent implements OnInit, AfterViewInit {
/** User data */
usersData: any[] = [];
usersZitadelData: any[] = [];
/** Columns */
displayedColumns: string[] = [
'firstname',
'lastname',
'email',
'officeName'
];
/** DataSources */
dataSource = new MatTableDataSource<any>();
dataSourceZitadel = new MatTableDataSource<any>();
/** References for pagination and sorting */
@ViewChild('paginatorUsers', { static: true }) paginatorUsers!: MatPaginator;
@ViewChild('sortUsers', { static: true }) sortUsers!: MatSort;
@ViewChild('paginatorZitadel', { static: true }) paginatorZitadel!: MatPaginator;
@ViewChild('sortZitadel', { static: true }) sortZitadel!: MatSort;
/* Buttons and popovers */
@ViewChild('buttonCreateUser') buttonCreateUser!: ElementRef<any>;
@ViewChild('templateButtonCreateUser') templateButtonCreateUser!: TemplateRef<any>;
@ViewChild('usersTable') usersTable!: ElementRef<any>;
@ViewChild('templateUsersTable') templateUsersTable!: TemplateRef<any>;
constructor(
private route: ActivatedRoute,
private router: Router,
public configurationWizardService: ConfigurationWizardService,
private popoverService: PopoverService
) {}
ngOnInit() {
// Get data from resolver
this.route.data.subscribe((data: { users: any; usersZitadel: any }) => {
this.usersData = data.users || [];
this.usersZitadelData = data.usersZitadel || [];
// Initialize tables
this.dataSource = new MatTableDataSource(this.usersData);
this.dataSourceZitadel = new MatTableDataSource(this.usersZitadelData);
// Assign paginator and sort AFTER having data
this.dataSource.paginator = this.paginatorUsers;
this.dataSource.sort = this.sortUsers;
this.dataSourceZitadel.paginator = this.paginatorZitadel;
this.dataSourceZitadel.sort = this.sortZitadel;
});
}
/** Filter tables */
applyFilter(filterValue: string) {
const filter = filterValue.trim().toLowerCase();
this.dataSource.filter = filter;
this.dataSourceZitadel.filter = filter;
}
/** Show popover */
showPopover(template: TemplateRef<any>, target: HTMLElement | ElementRef<any>, position: string, backdrop: boolean) {
setTimeout(() => this.popoverService.open(template, target, position, backdrop, {}), 200);
}
ngAfterViewInit() {
if (this.configurationWizardService.showUsers) {
setTimeout(() => {
this.showPopover(this.templateButtonCreateUser, this.buttonCreateUser.nativeElement, 'bottom', true);
});
}
if (this.configurationWizardService.showUsersList) {
setTimeout(() => {
this.showPopover(this.templateUsersTable, this.usersTable.nativeElement, 'top', true);
});
}
}
/** Navigate */
nextStep() {
this.configurationWizardService.showUsers = false;
this.configurationWizardService.showUsersList = false;
this.configurationWizardService.showUsersForm = true;
this.router.navigate(['/appusers/create']);
}
previousStep() {
this.configurationWizardService.showUsers = false;
this.configurationWizardService.showUsersList = false;
this.configurationWizardService.showRolesandPermissionList = true;
this.router.navigate(['/system/roles-and-permissions']);
}
}
@@ -0,0 +1,26 @@
/** Angular Imports */
import { Injectable } from '@angular/core';
/** rxjs Imports */
import { Observable } from 'rxjs';
/** Custom Services */
import { UsersServiceZitadel } from './usersZitadel.service';
/**
* Users template data resolver.
*/
@Injectable()
export class UsersZitadelTemplateResolver {
/**
* @param { UsersServiceZitadel } usersServiceZitadel Users service.
*/
constructor(private usersServiceZitadel: UsersServiceZitadel) {}
/**
* Returns the users template data.
* @returns {Observable<any>}
*/
resolve(): Observable<any> {
return this.usersServiceZitadel.getUsersTemplate();
}
}
@@ -0,0 +1,29 @@
/** Angular Imports */
import { Injectable } from '@angular/core';
/** rxjs Imports */
import { Observable } from 'rxjs';
/** Custom Services */
import { UsersServiceZitadel } from './usersZitadel.service';
/**
* UsersZitadel data resolver.
*/
@Injectable()
export class UsersZitadelResolver {
/**
* @param {UsersServiceZitadel} usersServiceZitadel Users service.
*/
constructor(private usersServiceZitadel: UsersServiceZitadel) {}
/**
* Returns the users data.
* @returns {Observable<any>}
*/
resolve(): Observable<any> {
return this.usersServiceZitadel.getUsers();
}
}
@@ -0,0 +1,129 @@
/** Angular Imports */
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
/** rxjs Imports */
import { from, Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { AuthService } from '../auth.service';
import { environment } from 'environments/environment';
/**
* UsersZitadel service.
*/
@Injectable({
providedIn: 'root'
})
export class UsersServiceZitadel {
private api = environment.OIDC.oidcApiUrl;
/**
* @param {HttpClient} http Http Client to send requests.
*/
constructor(
private http: HttpClient,
private autservice: AuthService
) {}
/**
* @param {any} user User to be created.
* @returns {Observable<any>}
*/
createUser(user: any): Observable<any> {
return this.http.post(this.api + 'authentication/user', user);
}
getDatosExtraUsuario(userId: string): Observable<any> {
return this.http.get(`${this.api}authentication/user/db/${userId}`);
}
assignRolesToUser(userId: string, roleKeys: string[]): Observable<any> {
const payload = {
userId: userId,
roleKeys: roleKeys.map(String)
};
return this.http.post(this.api + 'authentication/user/role', payload);
}
createUserBd(user: any): Observable<any> {
return this.http.post(this.api + 'authentication/user/db', user);
}
/**
* @returns {Observable<any>} Users template data
*/
getUsersTemplate(): Observable<any> {
return this.http.get('/users/template');
}
/**
* @returns {Observable<any>} Users data
*/
getUsers(): Observable<any[]> {
const token = this.autservice.getAccessToken();
return from(
fetch(`${this.api}authentication/user`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
).pipe(
switchMap((res) => res.json()),
map((response) => {
const out: any[] = [];
const users = response.object?.result;
if (Array.isArray(users)) {
users.forEach((user) => {
if (user.human) {
out.push({
id: user.id,
firstname: user.human.profile.firstName,
lastname: user.human.profile.lastName,
email: user.human.email.email,
officeName: 'Head Office'
});
}
});
}
return out;
})
);
}
/**
* @param {string} userId user ID of user.
* @returns {Observable<any>} User.
*/
getUser(userId: string): Observable<any> {
const url = `${this.api}authentication/user/${userId}`;
return from(
fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.autservice.getAccessToken()}`
}
})
).pipe(
switchMap((res) => res.json()),
map((response) => response)
);
}
editUser(user: any): Observable<any> {
return this.http.put(this.api + 'authentication/user', user);
}
editRoles(roles: any): Observable<any> {
return this.http.put(this.api + 'authentication/user/role', roles);
}
editOffice(office: any): Observable<any> {
return this.http.put(this.api + 'authentication/user/office', office);
}
getRoles() {
return this.http.get(this.api + 'authentication/role');
}
}
@@ -0,0 +1,96 @@
<div class="container m-b-20 layout-row layout-lt-md-column align-end gap-2percent">
<button mat-raised-button color="primary" [routerLink]="['edit']">
<fa-icon icon="edit" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Edit' | translate }}
</button>
<button mat-raised-button color="warn" (click)="delete()">
<fa-icon icon="trash" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Delete' | translate }}
</button>
<div
*ngIf="userData.isSelfServiceUser === 'USER_STATE_ACTIVE' || userData.isSelfServiceUser === 'USER_STATE_INACTIVE'"
>
<button mat-raised-button color="primary" (click)="changeUserPassword(userData.id)">
<fa-icon icon="cog" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Change Password' | translate }}
</button>
</div>
<div [ngSwitch]="userData.isSelfServiceUser">
<button *ngSwitchCase="'USER_STATE_ACTIVE'" mat-raised-button color="warn" (click)="desactiveUser(userData.id)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>
Desactivar
</button>
<button *ngSwitchCase="'USER_STATE_INACTIVE'" mat-raised-button color="primary" (click)="activeUser(userData.id)">
<fa-icon icon="check" class="m-r-10"></fa-icon>
Activar
</button>
<button *ngSwitchDefault mat-raised-button color="primary" disabled>
<fa-icon icon="ban" class="m-r-10"></fa-icon>
Initial
</button>
</div>
</div>
<div class="container">
<mat-card>
<mat-card-content>
<div class="layout-row-wrap">
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Login Name' | translate }}
</div>
<div class="flex-50">
{{ userData.username }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.First Name' | translate }}
</div>
<div class="flex-50">
{{ userData.firstname }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Last Name' | translate }}
</div>
<div class="flex-50">
{{ userData.lastname }}
</div>
<div class="flex-50 mat-body-strong" *ngIf="userData.email">
{{ 'labels.inputs.Email' | translate }}
</div>
<div class="flex-50" *ngIf="userData.email">
{{ userData.email }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Office' | translate }}
</div>
<div class="flex-50">
{{ userData.officeName }}
</div>
<div class="flex-50 mat-body-strong">
{{ 'labels.inputs.Roles' | translate }}
</div>
<div class="flex-50">
<span *ngFor="let role of userData.selectedRoles">
{{ role.name }}
</span>
</div>
</div>
<div class="layout-row layout-align-center gap-2percent column-on-mobile">
<button type="button" mat-raised-button color="primary" [routerLink]="['../../']">
{{ 'labels.buttons.Back' | translate }}
</button>
</div>
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,14 @@
.container {
max-width: 37rem;
.content {
div {
margin: 1rem 0;
word-wrap: break-word;
span {
display: block;
}
}
}
}
@@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ViewUserComponent as ViewUserZitadelComponent } from './view-user.component';
describe('ViewUserComponent', () => {
let component: ViewUserZitadelComponent;
let fixture: ComponentFixture<ViewUserZitadelComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ViewUserZitadelComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ViewUserZitadelComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,113 @@
/** Angular Imports */
import { Component } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
//import { UsersService } from '../users.service';
import { UsersServiceZitadel } from '../usersZitadel.service';
/** aUTH zITADEL */
import { AuthService } from 'app/zitadel/auth.service';
/** Custom Components */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ChangePasswordDialogComponent } from 'app/zitadel/shared/change-password-dialog/change-password-dialog.component';
import { strings } from '@angular-devkit/schematics';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
/**
* View user component.
*/
@Component({
selector: 'mifosx-view-user',
templateUrl: './view-user.component.html',
styleUrls: ['./view-user.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FontAwesomeModule
]
})
export class ViewUserComponent {
/** User Data. */
userData: any;
/**
* Retrieves the user data from `resolve`.
* @param {UsersServiceZitadel} usersServiceZitadel Users Service.
* @param {ActivatedRoute} route Activated Route.
* @param {Router} router Router for navigation.
* @param {MatDialog} dialog Dialog reference.
*/
constructor(
private usersService: UsersServiceZitadel,
private route: ActivatedRoute,
private router: Router,
private dialog: MatDialog,
private authService: AuthService
) {
this.route.data.subscribe((data: { user: any }) => {
const u = data.user.object?.result?.[0];
const user = {
id: u.id,
username: u.userName,
firstname: u.human?.profile?.firstName,
lastname: u.human?.profile?.lastName,
officeName: 'Head Office',
officeId: u.user_uuid ?? null,
availableRoles: u.availableRoles ?? 'Admin',
selectedRoles: u.selectedRoles ?? 'Admin',
isSelfServiceUser: u.state
};
this.userData = user;
});
}
/**
* Deletes the user and redirects to users.
*/
delete() {
const deleteUserDialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `user ${this.userData.id}` }
});
deleteUserDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.authService.deletUser(this.userData.id);
}
});
}
/**
* Change Password of the Users.
*/
changeUserPassword(userId: string) {
const changeUserPasswordDialogRef = this.dialog.open(ChangePasswordDialogComponent, {
width: '440px',
data: { id: userId }
});
changeUserPasswordDialogRef.afterClosed().subscribe((response: any) => {
if (response.password && response.repeatPassword) {
const password = response.password;
const repeatPassword = response.repeatPassword;
const firstname = this.userData.firstname;
const data = { password: password, repeatPassword: repeatPassword, firstname: firstname };
}
});
}
/**
* disable User
*/
desactiveUser(userId: string) {
this.authService.desactiveUser(userId);
}
activeUser(userId: string) {
this.authService.activeUser(userId);
}
}
+10 -3
View File
@@ -35,13 +35,20 @@
// Time in milliseconds for Session idle timeout, default 300000 seconds
window['env']['sessionIdleTimeout'] = '0';
// OAuth Server Enabled
// OAuth Server Enabled
window['env']['oauthServerEnabled'] = '';
// OAuth Server URL
// OAuth Server URL
window['env']['oauthServerUrl'] = '';
// OAuth Client Id
// OAuth Client Id
window['env']['oauthAppId'] = '';
// OIDC Plugin Environment variables
window['env']['oidcServerEnabled'] = $FINERACT_PLUGIN_OIDC_ENABLED;
window['env']['oidcBaseUrl'] = $FINERACT_PLUGIN_OIDC_BASE_URL;
window['env']['oidcClientId'] = $FINERACT_PLUGIN_OIDC_CLIENT_ID;
window['env']['oidcApiUrl'] = $FINERACT_PLUGIN_OIDC_API_URL;
window['env']['oidcFrontUrl'] = $FINERACT_PLUGIN_OIDC_FRONTEND_URL;
})(this);
+7
View File
@@ -54,4 +54,11 @@
window['env']['vNextApiProvider'] = '$VNEXT_API_PROVIDER';
window['env']['vNextApiVersion'] = '$VNEXT_API_VERSION';
window['env']['interbankTransfers'] = '$VNEXT_INTERBANK_TRANSFERS';
// OIDC Plugin Environment variables
window['env']['oidcServerEnabled'] = '$FINERACT_PLUGIN_OIDC_ENABLED';
window['env']['oidcBaseUrl'] = '$FINERACT_PLUGIN_OIDC_BASE_URL';
window['env']['oidcClientId'] = '$FINERACT_PLUGIN_OIDC_CLIENT_ID';
window['env']['oidcApiUrl'] = '$FINERACT_PLUGIN_OIDC_API_URL';
window['env']['oidcFrontUrl'] = '$FINERACT_PLUGIN_OIDC_FRONTEND_URL';
})(this);
+117
View File
@@ -3641,5 +3641,122 @@
"loan product will become inactive and unavailable to clients": "The date that the loan product will become inactive and unavailable to clients. If blank, the load product will never become inactive.",
"Refund transactions where interest refund will automatically be calculated": "Refund transactions where interest refund will automatically be calculated",
"Create Interest Refund": "Create Interest Refund"
},
"countries": {
"AF": "Afghanistan",
"AL": "Albania",
"DZ": "Algeria",
"AD": "Andorra",
"AO": "Angola",
"AR": "Argentina",
"AM": "Armenia",
"AU": "Australia",
"AT": "Austria",
"AZ": "Azerbaijan",
"BH": "Bahrain",
"BD": "Bangladesh",
"BY": "Belarus",
"BE": "Belgium",
"BO": "Bolivia",
"BA": "Bosnia and Herzegovina",
"BR": "Brazil",
"BG": "Bulgaria",
"BF": "Burkina Faso",
"BI": "Burundi",
"KH": "Cambodia",
"CM": "Cameroon",
"CA": "Canada",
"CL": "Chile",
"CN": "China",
"CO": "Colombia",
"CR": "Costa Rica",
"HR": "Croatia",
"CU": "Cuba",
"CY": "Cyprus",
"CZ": "Czechia",
"DK": "Denmark",
"EG": "Egypt",
"SV": "El Salvador",
"ES": "Spain",
"EE": "Estonia",
"ET": "Ethiopia",
"FI": "Finland",
"FR": "France",
"GE": "Georgia",
"DE": "Germany",
"GH": "Ghana",
"GR": "Greece",
"GT": "Guatemala",
"HN": "Honduras",
"HU": "Hungary",
"IS": "Iceland",
"IN": "India",
"ID": "Indonesia",
"IQ": "Iraq",
"IR": "Iran",
"IE": "Ireland",
"IL": "Israel",
"IT": "Italy",
"JP": "Japan",
"JO": "Jordan",
"KZ": "Kazakhstan",
"KE": "Kenya",
"KW": "Kuwait",
"KG": "Kyrgyzstan",
"LV": "Latvia",
"LB": "Lebanon",
"LY": "Libya",
"LT": "Lithuania",
"LU": "Luxembourg",
"MK": "North Macedonia",
"MY": "Malaysia",
"MX": "Mexico",
"MD": "Moldova",
"MC": "Monaco",
"MN": "Mongolia",
"MA": "Morocco",
"MZ": "Mozambique",
"NP": "Nepal",
"NL": "Netherlands",
"NZ": "New Zealand",
"NI": "Nicaragua",
"NG": "Nigeria",
"NO": "Norway",
"PK": "Pakistan",
"PA": "Panama",
"PY": "Paraguay",
"PE": "Peru",
"PH": "Philippines",
"PL": "Poland",
"PT": "Portugal",
"QA": "Qatar",
"RO": "Romania",
"RU": "Russia",
"SA": "Saudi Arabia",
"SN": "Senegal",
"RS": "Serbia",
"SG": "Singapore",
"SK": "Slovakia",
"SI": "Slovenia",
"ZA": "South Africa",
"KR": "South Korea",
"LK": "Sri Lanka",
"SE": "Sweden",
"CH": "Switzerland",
"TW": "Taiwan",
"TH": "Thailand",
"TR": "Turkey",
"UG": "Uganda",
"UA": "Ukraine",
"AE": "United Arab Emirates",
"GB": "United Kingdom",
"US": "United States",
"UY": "Uruguay",
"UZ": "Uzbekistan",
"VE": "Venezuela",
"VN": "Vietnam",
"YE": "Yemen",
"ZM": "Zambia",
"ZW": "Zimbabwe"
}
}
+117
View File
@@ -3537,5 +3537,122 @@
"loan product will become inactive and unavailable to clients": "La fecha en que el producto de Crédito quedará inactivo y no estará disponible para los clientes. Si está en blanco, el producto de carga nunca quedará inactivo.",
"Refund transactions where interest refund will automatically be calculated": "Transacciones de reembolso en las que el reembolso de intereses se calculará automáticamente",
"Create Interest Refund": "Crear reembolso de intereses"
},
"countries": {
"AF": "Afganistán",
"AL": "Albania",
"DZ": "Argelia",
"AD": "Andorra",
"AO": "Angola",
"AR": "Argentina",
"AM": "Armenia",
"AU": "Australia",
"AT": "Austria",
"AZ": "Azerbaiyán",
"BH": "Baréin",
"BD": "Bangladés",
"BY": "Bielorrusia",
"BE": "Bélgica",
"BO": "Bolivia",
"BA": "Bosnia y Herzegovina",
"BR": "Brasil",
"BG": "Bulgaria",
"BF": "Burkina Faso",
"BI": "Burundi",
"KH": "Camboya",
"CM": "Camerún",
"CA": "Canadá",
"CL": "Chile",
"CN": "China",
"CO": "Colombia",
"CR": "Costa Rica",
"HR": "Croacia",
"CU": "Cuba",
"CY": "Chipre",
"CZ": "Chequia",
"DK": "Dinamarca",
"EG": "Egipto",
"SV": "El Salvador",
"ES": "España",
"EE": "Estonia",
"ET": "Etiopía",
"FI": "Finlandia",
"FR": "Francia",
"GE": "Georgia",
"DE": "Alemania",
"GH": "Ghana",
"GR": "Grecia",
"GT": "Guatemala",
"HN": "Honduras",
"HU": "Hungría",
"IS": "Islandia",
"IN": "India",
"ID": "Indonesia",
"IQ": "Irak",
"IR": "Irán",
"IE": "Irlanda",
"IL": "Israel",
"IT": "Italia",
"JP": "Japón",
"JO": "Jordania",
"KZ": "Kazajistán",
"KE": "Kenia",
"KW": "Kuwait",
"KG": "Kirguistán",
"LV": "Letonia",
"LB": "Líbano",
"LY": "Libia",
"LT": "Lituania",
"LU": "Luxemburgo",
"MK": "Macedonia del Norte",
"MY": "Malasia",
"MX": "México",
"MD": "Moldavia",
"MC": "Mónaco",
"MN": "Mongolia",
"MA": "Marruecos",
"MZ": "Mozambique",
"NP": "Nepal",
"NL": "Países Bajos",
"NZ": "Nueva Zelanda",
"NI": "Nicaragua",
"NG": "Nigeria",
"NO": "Noruega",
"PK": "Pakistán",
"PA": "Panamá",
"PY": "Paraguay",
"PE": "Perú",
"PH": "Filipinas",
"PL": "Polonia",
"PT": "Portugal",
"QA": "Catar",
"RO": "Rumanía",
"RU": "Rusia",
"SA": "Arabia Saudita",
"SN": "Senegal",
"RS": "Serbia",
"SG": "Singapur",
"SK": "Eslovaquia",
"SI": "Eslovenia",
"ZA": "Sudáfrica",
"KR": "Corea del Sur",
"LK": "Sri Lanka",
"SE": "Suecia",
"CH": "Suiza",
"TW": "Taiwán",
"TH": "Tailandia",
"TR": "Turquía",
"UG": "Uganda",
"UA": "Ucrania",
"AE": "Emiratos Árabes Unidos",
"GB": "Reino Unido",
"US": "Estados Unidos",
"UY": "Uruguay",
"UZ": "Uzbekistán",
"VE": "Venezuela",
"VN": "Vietnam",
"YE": "Yemen",
"ZM": "Zambia",
"ZW": "Zimbabue"
}
}
+2 -2
View File
@@ -2,8 +2,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '250703',
'hash': 'gc4224678'
'version': '250822',
'hash': 'db35d040'
},
'allow_switching_backend_instance': true
};
+9 -1
View File
@@ -60,7 +60,15 @@ export const environment = {
vNextApiVersion: window['env']['vNextApiVersion'] || '/v1.0',
interbankTransfers: window['env']['interbankTransfers'] || false,
minPasswordLength: loadedEnv['minPasswordLength'] || 12
minPasswordLength: loadedEnv['minPasswordLength'] || 12,
OIDC: {
oidcServerEnabled: window['env']['oidcServerEnabled'] || false,
oidcBaseUrl: window['env']['oidcBaseUrl'] || '',
oidcClientId: window['env']['oidcClientId'] || '',
oidcApiUrl: window['env']['oidcApiUrl'] || '',
oidcFrontUrl: window['env']['oidcFrontUrl'] || ''
}
};
// Server URL
+9 -1
View File
@@ -64,7 +64,15 @@ export const environment = {
vNextApiVersion: window.env?.vNextApiVersion || '/v1.0',
interbankTransfers: window.env?.interbankTransfers || false,
minPasswordLength: loadedEnv.minPasswordLength || 12
minPasswordLength: loadedEnv.minPasswordLength || 12,
OIDC: {
oidcServerEnabled: window['env']['oidcServerEnabled'] || false,
oidcBaseUrl: window['env']['oidcBaseUrl'] || '',
oidcClientId: window['env']['oidcClientId'] || '',
oidcApiUrl: window['env']['oidcApiUrl'] || '',
oidcFrontUrl: window['env']['oidcFrontUrl'] || ''
}
};
// Server URL
+10
View File
@@ -18,6 +18,16 @@
<!-- Load environment variables -->
</head>
<body class="h-full">
<script>
function getQueryParam(param) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(param);
}
const code = getQueryParam('code');
if (code) {
localStorage.setItem('auth_code', code);
}
</script>
<script src="assets/env.js"></script>
<!--[if lt IE 10]>
<p>