From 9745643456c779df2766dff08cbb8a97beeeba23 Mon Sep 17 00:00:00 2001 From: AllsVeam <101079338+AllsVeam@users.noreply.github.com> Date: Fri, 22 Aug 2025 21:49:18 -0600 Subject: [PATCH] WEB-233: Improve the OIDC integration with 2FA (#2602) --- docker-compose.yml | 6 + src/app/app.module.ts | 30 +- .../authentication/authentication.service.ts | 26 +- .../core/shell/sidenav/sidenav.component.ts | 24 +- .../core/shell/toolbar/toolbar.component.ts | 27 +- .../login-form/login-form.component.html | 115 ++-- .../login-form/login-form.component.scss | 5 + .../login/login-form/login-form.component.ts | 33 +- .../add-role/add-role.component.ts | 7 +- .../edit-role/edit-role.component.ts | 7 +- .../view-role/view-role.component.ts | 7 +- src/app/users/users-routing.module.ts | 149 ++++-- src/app/users/users.module.ts | 13 +- src/app/web-app.component.ts | 49 +- src/app/zitadel/auth.guard.ts | 10 + src/app/zitadel/auth.service.ts | 414 +++++++++++++++ .../zitadel/callback/callback.component.ts | 26 + .../change-password-dialog.component.html | 42 ++ .../change-password-dialog.component.scss | 8 + .../change-password-dialog.component.spec.ts | 24 + .../change-password-dialog.component.ts | 111 ++++ src/app/zitadel/shared/shared.module.ts | 34 ++ src/app/zitadel/token.interceptor.ts | 64 +++ .../create-user/create-user.component.html | 205 ++++++++ .../create-user/create-user.component.scss | 13 + .../create-user/create-user.component.spec.ts | 24 + .../create-user/create-user.component.ts | 489 ++++++++++++++++++ .../users/edit-user/edit-user.component.html | 124 +++++ .../users/edit-user/edit-user.component.scss | 3 + .../edit-user/edit-user.component.spec.ts | 24 + .../users/edit-user/edit-user.component.ts | 334 ++++++++++++ src/app/zitadel/users/userZitadel.resolver.ts | 30 ++ src/app/zitadel/users/users.component.html | 47 ++ src/app/zitadel/users/users.component.scss | 11 + src/app/zitadel/users/users.component.ts | 126 +++++ .../users/usersZitadel-template.resolver.ts | 26 + .../zitadel/users/usersZitadel.resolver.ts | 29 ++ src/app/zitadel/users/usersZitadel.service.ts | 129 +++++ .../users/view-user/view-user.component.html | 96 ++++ .../users/view-user/view-user.component.scss | 14 + .../view-user/view-user.component.spec.ts | 24 + .../users/view-user/view-user.component.ts | 113 ++++ src/assets/env.js | 13 +- src/assets/env.template.js | 7 + src/assets/translations/en-US.json | 117 +++++ src/assets/translations/es-MX.json | 117 +++++ src/environments/.env.ts | 4 +- src/environments/environment.prod.ts | 10 +- src/environments/environment.ts | 10 +- src/index.html | 10 + 50 files changed, 3248 insertions(+), 132 deletions(-) create mode 100644 src/app/zitadel/auth.guard.ts create mode 100644 src/app/zitadel/auth.service.ts create mode 100644 src/app/zitadel/callback/callback.component.ts create mode 100644 src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.html create mode 100644 src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.scss create mode 100644 src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.spec.ts create mode 100644 src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.ts create mode 100644 src/app/zitadel/shared/shared.module.ts create mode 100644 src/app/zitadel/token.interceptor.ts create mode 100644 src/app/zitadel/users/create-user/create-user.component.html create mode 100644 src/app/zitadel/users/create-user/create-user.component.scss create mode 100644 src/app/zitadel/users/create-user/create-user.component.spec.ts create mode 100644 src/app/zitadel/users/create-user/create-user.component.ts create mode 100644 src/app/zitadel/users/edit-user/edit-user.component.html create mode 100644 src/app/zitadel/users/edit-user/edit-user.component.scss create mode 100644 src/app/zitadel/users/edit-user/edit-user.component.spec.ts create mode 100644 src/app/zitadel/users/edit-user/edit-user.component.ts create mode 100644 src/app/zitadel/users/userZitadel.resolver.ts create mode 100644 src/app/zitadel/users/users.component.html create mode 100644 src/app/zitadel/users/users.component.scss create mode 100644 src/app/zitadel/users/users.component.ts create mode 100644 src/app/zitadel/users/usersZitadel-template.resolver.ts create mode 100644 src/app/zitadel/users/usersZitadel.resolver.ts create mode 100644 src/app/zitadel/users/usersZitadel.service.ts create mode 100644 src/app/zitadel/users/view-user/view-user.component.html create mode 100644 src/app/zitadel/users/view-user/view-user.component.scss create mode 100644 src/app/zitadel/users/view-user/view-user.component.spec.ts create mode 100644 src/app/zitadel/users/view-user/view-user.component.ts diff --git a/docker-compose.yml b/docker-compose.yml index f09dd1ae5..6cc501e8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 76375ac28..d86830444 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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 {} diff --git a/src/app/core/authentication/authentication.service.ts b/src/app/core/authentication/authentication.service.ts index 199eef2bc..1d2b1910a 100644 --- a/src/app/core/authentication/authentication.service.ts +++ b/src/app/core/authentication/authentication.service.ts @@ -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. diff --git a/src/app/core/shell/sidenav/sidenav.component.ts b/src/app/core/shell/sidenav/sidenav.component.ts index 4bf792029..ef75f77ea 100644 --- a/src/app/core/shell/sidenav/sidenav.component.ts +++ b/src/app/core/shell/sidenav/sidenav.component.ts @@ -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; /* 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(); + } } /** diff --git a/src/app/core/shell/toolbar/toolbar.component.ts b/src/app/core/shell/toolbar/toolbar.component.ts index 2dfb3eeab..ed24e8ee3 100644 --- a/src/app/core/shell/toolbar/toolbar.component.ts +++ b/src/app/core/shell/toolbar/toolbar.component.ts @@ -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(); + } } /** diff --git a/src/app/login/login-form/login-form.component.html b/src/app/login/login-form/login-form.component.html index 890b39ac2..cdd58ed29 100644 --- a/src/app/login/login-form/login-form.component.html +++ b/src/app/login/login-form/login-form.component.html @@ -1,55 +1,72 @@ -
- +
+ + + + + + {{ + 'labels.inputs.Remember me' | translate + }} + + + + - + +
- {{ - 'labels.inputs.Remember me' | translate - }} - - - - - - - +
+
+ +
+
+

Welcome! You are logged in.

+ + +
{{ protectedData | json }}
+
+
diff --git a/src/app/login/login-form/login-form.component.scss b/src/app/login/login-form/login-form.component.scss index a6195571c..c0ae7a27a 100644 --- a/src/app/login/login-form/login-form.component.scss +++ b/src/app/login/login-form/login-form.component.scss @@ -18,3 +18,8 @@ ::ng-deep .mat-mdc-form-field-infix { width: 144px !important; } + +.container-margin { + margin-top: 8rem; + margin-bottom: 8rem; +} diff --git a/src/app/login/login-form/login-form.component.ts b/src/app/login/login-form/login-form.component.ts index 08813b0ef..c1737127a 100644 --- a/src/app/login/login-form/login-form.component.ts +++ b/src/app/login/login-form/login-form.component.ts @@ -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. * diff --git a/src/app/system/roles-and-permissions/add-role/add-role.component.ts b/src/app/system/roles-and-permissions/add-role/add-role.component.ts index 0cf7fed0b..21d46a075 100644 --- a/src/app/system/roles-and-permissions/add-role/add-role.component.ts +++ b/src/app/system/roles-and-permissions/add-role/add-role.component.ts @@ -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 }); }); } diff --git a/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts b/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts index 539a75240..172c6defa 100644 --- a/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts +++ b/src/app/system/roles-and-permissions/edit-role/edit-role.component.ts @@ -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 }); }); } diff --git a/src/app/system/roles-and-permissions/view-role/view-role.component.ts b/src/app/system/roles-and-permissions/view-role/view-role.component.ts index 40b9ff976..50c701767 100644 --- a/src/app/system/roles-and-permissions/view-role/view-role.component.ts +++ b/src/app/system/roles-and-permissions/view-role/view-role.component.ts @@ -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 { diff --git a/src/app/users/users-routing.module.ts b/src/app/users/users-routing.module.ts index cd3a1fbe7..c8633ec95 100644 --- a/src/app/users/users-routing.module.ts +++ b/src/app/users/users-routing.module.ts @@ -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 {} diff --git a/src/app/users/users.module.ts b/src/app/users/users.module.ts index 04621849d..14732c240 100644 --- a/src/app/users/users.module.ts +++ b/src/app/users/users.module.ts @@ -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 {} diff --git a/src/app/web-app.component.ts b/src/app/web-app.component.ts index 8670e6f4c..82ae827c5 100644 --- a/src/app/web-app.component.ts +++ b/src/app/web-app.component.ts @@ -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); }); } } diff --git a/src/app/zitadel/auth.guard.ts b/src/app/zitadel/auth.guard.ts new file mode 100644 index 000000000..e2c707e07 --- /dev/null +++ b/src/app/zitadel/auth.guard.ts @@ -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; +}; diff --git a/src/app/zitadel/auth.service.ts b/src/app/zitadel/auth.service.ts new file mode 100644 index 000000000..49f910787 --- /dev/null +++ b/src/app/zitadel/auth.service.ts @@ -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 { + 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 { + 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); + } +} diff --git a/src/app/zitadel/callback/callback.component.ts b/src/app/zitadel/callback/callback.component.ts new file mode 100644 index 000000000..ae3e8554d --- /dev/null +++ b/src/app/zitadel/callback/callback.component.ts @@ -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: `

Processing callback...

` +}) +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'); + } + }); + } +} diff --git a/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.html b/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.html new file mode 100644 index 000000000..dffbc9d69 --- /dev/null +++ b/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.html @@ -0,0 +1,42 @@ +

{{ 'labels.heading.Change Password' | translate }}

+ +
+
+ + + {{ 'labels.inputs.Password Current' | translate }} + + + {{ 'inputs.currentPasswordRequired' | translate }} + + + + + + {{ 'labels.inputs.Password New' | translate }} + + + {{ 'labels.commons.Password validation' | translate: { minchar: minPasswordLength } }} + + + + + + {{ 'labels.inputs.Repeat Password' | translate }} + + + {{ 'inputs.passwordsDoNotMatch' | translate }} + + + + + + + + +
+
diff --git a/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.scss b/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.scss new file mode 100644 index 000000000..e6103db4d --- /dev/null +++ b/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.scss @@ -0,0 +1,8 @@ +.error { + color: red; +} + +mat-dialog-content { + min-width: 240px; + width: 240px; +} diff --git a/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.spec.ts b/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.spec.ts new file mode 100644 index 000000000..1144ba12d --- /dev/null +++ b/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ChangePasswordDialogComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ChangePasswordDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.ts b/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.ts new file mode 100644 index 000000000..cbdcd361f --- /dev/null +++ b/src/app/zitadel/shared/change-password-dialog/change-password-dialog.component.ts @@ -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, + @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'); + } + }); + } +} diff --git a/src/app/zitadel/shared/shared.module.ts b/src/app/zitadel/shared/shared.module.ts new file mode 100644 index 000000000..6b2dafb06 --- /dev/null +++ b/src/app/zitadel/shared/shared.module.ts @@ -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 {} diff --git a/src/app/zitadel/token.interceptor.ts b/src/app/zitadel/token.interceptor.ts new file mode 100644 index 000000000..23c6a4748 --- /dev/null +++ b/src/app/zitadel/token.interceptor.ts @@ -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, next: HttpHandler): Observable> { + 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, next: HttpHandler): Promise> { + 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>; + } else { + throw new Error('No new access token obtained after refresh'); + } + } catch (e) { + console.error('Error in handle401Error, forcing logout'); + throw e; + } + } +} diff --git a/src/app/zitadel/users/create-user/create-user.component.html b/src/app/zitadel/users/create-user/create-user.component.html new file mode 100644 index 000000000..6b142e35d --- /dev/null +++ b/src/app/zitadel/users/create-user/create-user.component.html @@ -0,0 +1,205 @@ +
+ +
+ +
+ + + {{ 'labels.inputs.Username' | translate }} + + + {{ 'labels.inputs.Username' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + + {{ 'labels.inputs.Email' | translate }} + + + {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.invalid' | translate }} + + + {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + + {{ 'labels.inputs.First Name' | translate }} + + + + + + {{ 'labels.inputs.Last Name' | translate }} + + + + + + {{ 'labels.inputs.Language' | translate }} + + {{ 'languages.es-MX' | translate }} + {{ 'languages.en-US' | translate }} + + + + + + {{ 'labels.inputs.Gender' | translate }} + + {{ 'labels.catalogs.male' | translate }} + {{ 'labels.catalogs.female' | translate }} + {{ 'labels.catalogs.other' | translate }} + + + + +
+ + {{ 'labels.inputs.Code' | translate }} + + + {{ 'countries.' + country.key | translate }} ({{ country.code }}) + + + + + + {{ 'labels.inputs.Phone Number' | translate }} + + +
+ + + {{ 'labels.inputs.Office' | translate }} + + + {{ office.name }} + + + + {{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Staff' | translate }} + + + {{ staff.displayName }} + + + + + + + {{ 'labels.inputs.Password' | translate }} + + + {{ 'labels.inputs.Password' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + {{ 'labels.commons.Password should be at least' | translate }} + {{ 'labels.commons.12 characters long' | translate }} + + + {{ 'labels.commons.Password should not be more than' | translate }} + {{ 'labels.commons.50 characters long' | translate }} + + + {{ 'labels.commons.Password should include a' | translate }} + {{ 'labels.commons.numeral' | translate }} + {{ 'labels.commons.and at' | translate }} + {{ 'labels.commons.least one uppercase' | translate }}, + {{ 'labels.commons.lowercase and special character' | translate }} + + + + + + {{ 'labels.inputs.Repeat Password' | translate }} + + + {{ 'labels.inputs.Password' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + +

+ {{ 'labels.inputs.Passwords' | translate }} + {{ 'labels.commons.do not match' | translate }} +

+ + + + {{ 'labels.inputs.Roles' | translate }} + + + {{ role.name }} + + + + {{ 'labels.commons.At least one role' | translate }} + {{ 'labels.commons.must be selected' | translate }} + + +
+
+ + + + + +
+
+
+ + +

{{ 'labels.heading.Create User' | translate }}

+

+ {{ 'labels.text.Filling Details' | translate }}: + {{ + 'labels.inputs.Users' | translate + }} +

+
+ + + + +
+
diff --git a/src/app/zitadel/users/create-user/create-user.component.scss b/src/app/zitadel/users/create-user/create-user.component.scss new file mode 100644 index 000000000..81f0b20e2 --- /dev/null +++ b/src/app/zitadel/users/create-user/create-user.component.scss @@ -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; +} diff --git a/src/app/zitadel/users/create-user/create-user.component.spec.ts b/src/app/zitadel/users/create-user/create-user.component.spec.ts new file mode 100644 index 000000000..c2f14c7ec --- /dev/null +++ b/src/app/zitadel/users/create-user/create-user.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [CreateUserComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CreateUserComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/zitadel/users/create-user/create-user.component.ts b/src/app/zitadel/users/create-user/create-user.component.ts new file mode 100644 index 000000000..22e042d83 --- /dev/null +++ b/src/app/zitadel/users/create-user/create-user.component.ts @@ -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; + /* Template for popover on create user form */ + @ViewChild('templateUserFormRef') templateUserFormRef: TemplateRef; + + /** + * 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. + * @param target HTMLElement | ElementRef. + * @param position String. + * @param backdrop Boolean. + */ + showPopover( + template: TemplateRef, + target: HTMLElement | ElementRef, + 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']); + } + }); + } +} diff --git a/src/app/zitadel/users/edit-user/edit-user.component.html b/src/app/zitadel/users/edit-user/edit-user.component.html new file mode 100644 index 000000000..97880ac4f --- /dev/null +++ b/src/app/zitadel/users/edit-user/edit-user.component.html @@ -0,0 +1,124 @@ +
+ +
+ +
+ + {{ 'labels.inputs.Username' | translate }} + + + {{ 'labels.inputs.Username' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Email' | translate }} + + + {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.invalid' | translate }} + + + {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.First Name' | translate }} + + + {{ 'labels.inputs.First Name' | translate }} + {{ 'labels.commons.cannot begin with a special character or number' | translate }} + + + {{ 'labels.inputs.First Name' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Last Name' | translate }} + + + {{ 'labels.inputs.Last Name' | translate }} + {{ 'labels.commons.cannot begin with a special character or number' | translate }} + + + {{ 'labels.inputs.Last Name' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + + + + {{ 'labels.inputs.Code' | translate }} + + + {{ 'countries.' + country.key | translate }} ({{ country.code }}) + + + + + + {{ 'labels.inputs.Phone Number' | translate }} + + + + + {{ 'labels.inputs.Gender' | translate }} + + {{ 'labels.catalogs.male' | translate }} + {{ 'labels.catalogs.female' | translate }} + {{ 'labels.catalogs.other' | translate }} + + + + + {{ 'labels.inputs.Language' | translate }} + + {{ 'languages.es-MX' | translate }} + {{ 'languages.en-US' | translate }} + + + + + {{ 'labels.inputs.Office' | translate }} + + + {{ office.name }} + + + + + + {{ 'labels.inputs.Staff' | translate }} + + + {{ staff.displayName }} + + + + + + {{ 'labels.inputs.Roles' | translate }} + + + {{ rol.name }} + + + + + + + + +
+
+
+
+
diff --git a/src/app/zitadel/users/edit-user/edit-user.component.scss b/src/app/zitadel/users/edit-user/edit-user.component.scss new file mode 100644 index 000000000..d042d1108 --- /dev/null +++ b/src/app/zitadel/users/edit-user/edit-user.component.scss @@ -0,0 +1,3 @@ +.password-never-expires-wrapper { + padding: 17.5px 0; +} diff --git a/src/app/zitadel/users/edit-user/edit-user.component.spec.ts b/src/app/zitadel/users/edit-user/edit-user.component.spec.ts new file mode 100644 index 000000000..a8d5bb071 --- /dev/null +++ b/src/app/zitadel/users/edit-user/edit-user.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [EditUserComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditUserComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/zitadel/users/edit-user/edit-user.component.ts b/src/app/zitadel/users/edit-user/edit-user.component.ts new file mode 100644 index 000000000..827f38a2d --- /dev/null +++ b/src/app/zitadel/users/edit-user/edit-user.component.ts @@ -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']); + }); + } +} diff --git a/src/app/zitadel/users/userZitadel.resolver.ts b/src/app/zitadel/users/userZitadel.resolver.ts new file mode 100644 index 000000000..3e0303c62 --- /dev/null +++ b/src/app/zitadel/users/userZitadel.resolver.ts @@ -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} + */ + + resolve(route: ActivatedRouteSnapshot): Observable { + const userId = route.paramMap.get('id'); + return this.usersServiceZitadel.getUser(userId); + } +} diff --git a/src/app/zitadel/users/users.component.html b/src/app/zitadel/users/users.component.html new file mode 100644 index 000000000..69fbc6d10 --- /dev/null +++ b/src/app/zitadel/users/users.component.html @@ -0,0 +1,47 @@ +
+
+ +
+
+ +
+
+ + {{ 'labels.inputs.Filter' | translate }} + + +
+
+ +
+

Usuarios

+ + + + + + + + + + + + + + + + + + + + + + + +
{{ 'labels.inputs.First Name' | translate }}{{ user.firstname }}{{ 'labels.inputs.Last Name' | translate }}{{ user.lastname }}{{ 'labels.inputs.Email' | translate }}{{ user.email }}{{ 'labels.inputs.Office' | translate }}{{ user.officeName }}
+ + +
diff --git a/src/app/zitadel/users/users.component.scss b/src/app/zitadel/users/users.component.scss new file mode 100644 index 000000000..8dfce208d --- /dev/null +++ b/src/app/zitadel/users/users.component.scss @@ -0,0 +1,11 @@ +table { + width: 100%; + + .select-row:hover { + cursor: pointer; + } +} + +.container-margin { + margin-top: 1rem; +} diff --git a/src/app/zitadel/users/users.component.ts b/src/app/zitadel/users/users.component.ts new file mode 100644 index 000000000..3c400b9c3 --- /dev/null +++ b/src/app/zitadel/users/users.component.ts @@ -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(); + dataSourceZitadel = new MatTableDataSource(); + + /** 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; + @ViewChild('templateButtonCreateUser') templateButtonCreateUser!: TemplateRef; + @ViewChild('usersTable') usersTable!: ElementRef; + @ViewChild('templateUsersTable') templateUsersTable!: TemplateRef; + + 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, target: HTMLElement | ElementRef, 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']); + } +} diff --git a/src/app/zitadel/users/usersZitadel-template.resolver.ts b/src/app/zitadel/users/usersZitadel-template.resolver.ts new file mode 100644 index 000000000..74e4f8446 --- /dev/null +++ b/src/app/zitadel/users/usersZitadel-template.resolver.ts @@ -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} + */ + resolve(): Observable { + return this.usersServiceZitadel.getUsersTemplate(); + } +} diff --git a/src/app/zitadel/users/usersZitadel.resolver.ts b/src/app/zitadel/users/usersZitadel.resolver.ts new file mode 100644 index 000000000..f19e9b11e --- /dev/null +++ b/src/app/zitadel/users/usersZitadel.resolver.ts @@ -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} + */ + + resolve(): Observable { + return this.usersServiceZitadel.getUsers(); + } +} diff --git a/src/app/zitadel/users/usersZitadel.service.ts b/src/app/zitadel/users/usersZitadel.service.ts new file mode 100644 index 000000000..20455021c --- /dev/null +++ b/src/app/zitadel/users/usersZitadel.service.ts @@ -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} + */ + createUser(user: any): Observable { + return this.http.post(this.api + 'authentication/user', user); + } + + getDatosExtraUsuario(userId: string): Observable { + return this.http.get(`${this.api}authentication/user/db/${userId}`); + } + + assignRolesToUser(userId: string, roleKeys: string[]): Observable { + const payload = { + userId: userId, + roleKeys: roleKeys.map(String) + }; + return this.http.post(this.api + 'authentication/user/role', payload); + } + + createUserBd(user: any): Observable { + return this.http.post(this.api + 'authentication/user/db', user); + } + + /** + * @returns {Observable} Users template data + */ + getUsersTemplate(): Observable { + return this.http.get('/users/template'); + } + + /** + * @returns {Observable} Users data + */ + getUsers(): Observable { + 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} User. + */ + getUser(userId: string): Observable { + 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 { + return this.http.put(this.api + 'authentication/user', user); + } + + editRoles(roles: any): Observable { + return this.http.put(this.api + 'authentication/user/role', roles); + } + + editOffice(office: any): Observable { + return this.http.put(this.api + 'authentication/user/office', office); + } + + getRoles() { + return this.http.get(this.api + 'authentication/role'); + } +} diff --git a/src/app/zitadel/users/view-user/view-user.component.html b/src/app/zitadel/users/view-user/view-user.component.html new file mode 100644 index 000000000..74f7a6261 --- /dev/null +++ b/src/app/zitadel/users/view-user/view-user.component.html @@ -0,0 +1,96 @@ +
+ + +
+ +
+
+ + + +
+
+ +
+ + +
+
+ {{ 'labels.inputs.Login Name' | translate }} +
+ +
+ {{ userData.username }} +
+ +
+ {{ 'labels.inputs.First Name' | translate }} +
+ +
+ {{ userData.firstname }} +
+ +
+ {{ 'labels.inputs.Last Name' | translate }} +
+ +
+ {{ userData.lastname }} +
+ +
+ {{ 'labels.inputs.Email' | translate }} +
+ +
+ {{ userData.email }} +
+ +
+ {{ 'labels.inputs.Office' | translate }} +
+ +
+ {{ userData.officeName }} +
+ +
+ {{ 'labels.inputs.Roles' | translate }} +
+ +
+ + {{ role.name }} + +
+
+ +
+ +
+
+
+
diff --git a/src/app/zitadel/users/view-user/view-user.component.scss b/src/app/zitadel/users/view-user/view-user.component.scss new file mode 100644 index 000000000..b154f3f2b --- /dev/null +++ b/src/app/zitadel/users/view-user/view-user.component.scss @@ -0,0 +1,14 @@ +.container { + max-width: 37rem; + + .content { + div { + margin: 1rem 0; + word-wrap: break-word; + + span { + display: block; + } + } + } +} diff --git a/src/app/zitadel/users/view-user/view-user.component.spec.ts b/src/app/zitadel/users/view-user/view-user.component.spec.ts new file mode 100644 index 000000000..1d235f02b --- /dev/null +++ b/src/app/zitadel/users/view-user/view-user.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ViewUserZitadelComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ViewUserZitadelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/zitadel/users/view-user/view-user.component.ts b/src/app/zitadel/users/view-user/view-user.component.ts new file mode 100644 index 000000000..35bacb2ee --- /dev/null +++ b/src/app/zitadel/users/view-user/view-user.component.ts @@ -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); + } +} diff --git a/src/assets/env.js b/src/assets/env.js index 334e1c240..ec0cdef38 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -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); diff --git a/src/assets/env.template.js b/src/assets/env.template.js index 54ae9470b..00a70a91d 100644 --- a/src/assets/env.template.js +++ b/src/assets/env.template.js @@ -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); diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index 1220bae01..f728fe65d 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -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" } } diff --git a/src/assets/translations/es-MX.json b/src/assets/translations/es-MX.json index 4c865cea9..51b339dc9 100644 --- a/src/assets/translations/es-MX.json +++ b/src/assets/translations/es-MX.json @@ -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" } } diff --git a/src/environments/.env.ts b/src/environments/.env.ts index eb41e01ac..56730c557 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -2,8 +2,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '250703', - 'hash': 'gc4224678' + 'version': '250822', + 'hash': 'db35d040' }, 'allow_switching_backend_instance': true }; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 5de973f24..705028994 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -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 diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 52131ede8..811707f0a 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -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 diff --git a/src/index.html b/src/index.html index 8c53ed097..a5f2ce7eb 100644 --- a/src/index.html +++ b/src/index.html @@ -18,6 +18,16 @@ +