blob: 5e59a9badade60f75229957ed8fa6be6679dc3cd [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Component, inject, signal, DestroyRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { AuthService } from '../../core/services/auth.service';
import { ConfigService } from '../../core/services/config.service';
/**
* Component providing the user login interface.
*
* Handles authentication credentials, tenant selection, and API endpoint configuration.
* Adheres to accessibility standards and supports multiple languages.
*/
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, MatTooltipModule],
template: `
<div class="login-page">
<div class="login-card" role="main">
<div class="lang-selector">
<select
#langSelect
(change)="switchLanguage(langSelect.value)"
[attr.aria-label]="'app.language.select' | translate"
>
<option value="en" [selected]="translate.getCurrentLang() === 'en'">
{{ 'app.language.en' | translate }}
</option>
<option value="hi" [selected]="translate.getCurrentLang() === 'hi'">
{{ 'app.language.hi' | translate }}
</option>
<option value="ko" [selected]="translate.getCurrentLang() === 'ko'">
{{ 'app.language.ko' | translate }}
</option>
</select>
</div>
<div class="login-header">
<img src="favicon.png" alt="Fineract Logo" class="login-logo" />
<h1>{{ 'app.title' | translate }}</h1>
<p class="subtitle">{{ 'login.welcome' | translate }}</p>
</div>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" class="login-form">
<div class="form-field">
<label for="serverUrl" [matTooltip]="'login.tooltips.serverUrl' | translate">
{{ 'login.serverUrl' | translate }} ℹ️
</label>
<select id="serverUrl" formControlName="serverUrl">
<option value="https://demo.mifos.io/fineract-provider/api/v1">
Mifos Sandbox (https://demo.mifos.io/fineract-provider/api/v1)
</option>
<option value="/fineract-provider/api/v1">Local Server (Proxied)</option>
<option value="custom">Custom URL...</option>
</select>
</div>
@if (loginForm.get('serverUrl')?.value === 'custom') {
<div class="form-field">
<label for="customUrl">{{ 'login.customUrl' | translate }}</label>
<input
id="customUrl"
type="text"
formControlName="customUrl"
placeholder="https://..."
/>
</div>
}
<div class="form-field">
<label for="tenantId" [matTooltip]="'login.tooltips.tenantId' | translate">
{{ 'login.tenantId' | translate }} ℹ️
</label>
<input
id="tenantId"
type="text"
formControlName="tenantId"
[attr.aria-invalid]="loginForm.get('tenantId')?.invalid"
/>
</div>
<div class="form-field">
<label for="username">{{ 'login.username' | translate }}</label>
<input
id="username"
type="text"
formControlName="username"
autocomplete="username"
[attr.aria-invalid]="loginForm.get('username')?.invalid"
/>
</div>
<div class="form-field">
<label for="password">{{ 'login.password' | translate }}</label>
<input
id="password"
type="password"
formControlName="password"
autocomplete="current-password"
[attr.aria-invalid]="loginForm.get('password')?.invalid"
/>
</div>
@if (error()) {
<div class="error-message" role="alert">
{{ error() }}
</div>
}
<button type="submit" class="submit-btn" [disabled]="loginForm.invalid || isLoading()">
@if (isLoading()) {
<span class="spinner"></span>
{{ 'login.loggingIn' | translate }}
} @else {
{{ 'login.submit' | translate }}
}
</button>
</form>
<div class="login-footer">
<p>&copy; 2026 Apache Fineract</p>
</div>
</div>
</div>
`,
styles: [
`
.login-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
padding: 1rem;
}
.login-card {
position: relative;
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 440px;
}
.lang-selector {
position: absolute;
top: 1rem;
right: 1rem;
}
.lang-selector select {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
border-radius: 4px;
}
.login-header {
text-align: center;
margin-bottom: 1.5rem;
}
.login-logo {
height: 48px;
margin-bottom: 0.5rem;
}
h1 {
font-size: 1.25rem;
color: #333;
margin: 0;
}
.subtitle {
color: #666;
font-size: 0.85rem;
margin-top: 0.25rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
label {
font-weight: 600;
font-size: 0.8rem;
color: #444;
cursor: help;
}
input,
select {
padding: 0.6rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.9rem;
transition: border-color 0.2s;
}
input:focus,
select:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.error-message {
background-color: #ffebee;
color: #c62828;
padding: 0.6rem;
border-radius: 6px;
font-size: 0.8rem;
border-left: 4px solid #c62828;
}
.submit-btn {
margin-top: 0.5rem;
padding: 0.75rem;
background-color: #3498db;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
}
.submit-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.login-footer {
margin-top: 1.5rem;
text-align: center;
color: #999;
font-size: 0.7rem;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`,
],
})
export class LoginComponent {
private readonly fb = inject(FormBuilder);
private readonly authService = inject(AuthService);
private readonly configService = inject(ConfigService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
protected readonly translate = inject(TranslateService);
/** Signal indicating if a login request is in progress */
protected readonly isLoading = signal(false);
/** Signal containing the current login error message if any */
protected readonly error = signal<string | null>(null);
/** Reactive form group for login credentials and server settings */
protected readonly loginForm = this.fb.group({
serverUrl: [this.configService.apiUrl, Validators.required],
customUrl: [''],
tenantId: [this.authService.currentTenantId(), Validators.required],
username: ['', Validators.required],
password: ['', Validators.required],
});
/**
* Switches the application language at runtime.
* @param lang - The target language code (e.g., 'en', 'hi', 'ko')
*/
switchLanguage(lang: string) {
this.translate.use(lang);
}
/**
* Handles the login form submission.
* Updates configuration if needed and attempts authentication via AuthService.
*/
onSubmit(): void {
if (this.loginForm.valid) {
this.isLoading.set(true);
this.error.set(null);
const { username, password, tenantId, serverUrl, customUrl } = this.loginForm.value;
const finalUrl = serverUrl === 'custom' ? customUrl : serverUrl;
// CRITICAL: Check previous URL before updating it
const previousUrl = this.configService.apiUrl;
if (finalUrl) {
this.configService.setApiUrl(finalUrl);
}
this.authService
.login(username!, password!, tenantId!)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
// Force reload only if the target API has actually changed
if (finalUrl && finalUrl !== previousUrl) {
console.log(
'API Target changed from',
previousUrl,
'to',
finalUrl,
'- Reloading app.',
);
window.location.href = '/';
} else {
this.router.navigate(['/']);
}
},
error: (err) => {
this.isLoading.set(false);
this.error.set(
err.error?.defaultUserMessage || 'Login failed. Check credentials/server.',
);
},
});
}
}
}