changes related to session expire warning, session time out issue, exchange rate search label and modal resize-able
This commit is contained in:
@@ -4,7 +4,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { merge, fromEvent, Subscription } from 'rxjs';
|
import { merge, fromEvent, Subscription } from 'rxjs';
|
||||||
import { throttleTime } from 'rxjs/operators';
|
import { throttleTime } from 'rxjs/operators';
|
||||||
import { environment } from '../../../../environments/environment';
|
|
||||||
import { AuthService } from '../../../features/authentication/data-access/auth.service';
|
import { AuthService } from '../../../features/authentication/data-access/auth.service';
|
||||||
import { TokenStorageService } from './token-storage.service';
|
import { TokenStorageService } from './token-storage.service';
|
||||||
|
|
||||||
@@ -19,9 +18,6 @@ export class SessionTimeoutService implements OnDestroy {
|
|||||||
readonly showWarning = signal(false);
|
readonly showWarning = signal(false);
|
||||||
readonly remainingSeconds = signal(0);
|
readonly remainingSeconds = signal(0);
|
||||||
|
|
||||||
private readonly configWarningAfterMs = environment.sessionTimeout?.warningAfterMs ?? 25 * 60 * 1000;
|
|
||||||
private readonly configLogoutAfterMs = environment.sessionTimeout?.logoutAfterMs ?? 30 * 60 * 1000;
|
|
||||||
|
|
||||||
private activitySubscription: Subscription | null = null;
|
private activitySubscription: Subscription | null = null;
|
||||||
private warningTimer: ReturnType<typeof setTimeout> | null = null;
|
private warningTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private logoutTimer: ReturnType<typeof setTimeout> | null = null;
|
private logoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -109,8 +105,15 @@ export class SessionTimeoutService implements OnDestroy {
|
|||||||
this.showWarning.set(false);
|
this.showWarning.set(false);
|
||||||
this.remainingSeconds.set(0);
|
this.remainingSeconds.set(0);
|
||||||
|
|
||||||
// Calculate effective delays based on token expiry if available
|
// Delays are derived solely from the access token expiry issued by the backend.
|
||||||
const { warningDelay, logoutDelay } = this.calculateEffectiveDelays();
|
const delays = this.calculateEffectiveDelays();
|
||||||
|
|
||||||
|
if (!delays) {
|
||||||
|
// No token expiry info available - nothing to schedule client-side.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { warningDelay, logoutDelay } = delays;
|
||||||
|
|
||||||
const safeWarningDelay = Math.max(Math.min(warningDelay, logoutDelay), 0);
|
const safeWarningDelay = Math.max(Math.min(warningDelay, logoutDelay), 0);
|
||||||
const safeLogoutDelay = Math.max(logoutDelay, 0);
|
const safeLogoutDelay = Math.max(logoutDelay, 0);
|
||||||
@@ -130,19 +133,14 @@ export class SessionTimeoutService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate effective warning/logout delays based on the sooner of:
|
* Derive warning/logout delays purely from the access token's expiry
|
||||||
* - Configured timeout values
|
* timestamp issued by the backend - no client-side hard-coded durations.
|
||||||
* - Actual token expiry from backend
|
|
||||||
*/
|
*/
|
||||||
private calculateEffectiveDelays(): { warningDelay: number; logoutDelay: number } {
|
private calculateEffectiveDelays(): { warningDelay: number; logoutDelay: number } | null {
|
||||||
const accessTokenExpiresOn = this.tokenStorage.getAccessTokenExpiresOn();
|
const accessTokenExpiresOn = this.tokenStorage.getAccessTokenExpiresOn();
|
||||||
|
|
||||||
if (!accessTokenExpiresOn) {
|
if (!accessTokenExpiresOn) {
|
||||||
// No token expiry info - use configured values
|
return null;
|
||||||
return {
|
|
||||||
warningDelay: this.configWarningAfterMs,
|
|
||||||
logoutDelay: this.configLogoutAfterMs
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenExpiryMs = Date.parse(accessTokenExpiresOn);
|
const tokenExpiryMs = Date.parse(accessTokenExpiresOn);
|
||||||
@@ -155,14 +153,10 @@ export class SessionTimeoutService implements OnDestroy {
|
|||||||
|
|
||||||
const tokenTimeRemaining = tokenExpiryMs - now;
|
const tokenTimeRemaining = tokenExpiryMs - now;
|
||||||
|
|
||||||
// Use the sooner of configured timeout or token expiry
|
// Warn at 80% of the remaining token lifetime.
|
||||||
// Warning at 80% of token lifetime or configured warning, whichever is sooner
|
|
||||||
const tokenBasedWarning = Math.floor(tokenTimeRemaining * 0.8);
|
|
||||||
const tokenBasedLogout = tokenTimeRemaining;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
warningDelay: Math.min(this.configWarningAfterMs, tokenBasedWarning),
|
warningDelay: Math.floor(tokenTimeRemaining * 0.8),
|
||||||
logoutDelay: Math.min(this.configLogoutAfterMs, tokenBasedLogout)
|
logoutDelay: tokenTimeRemaining
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -2,9 +2,9 @@
|
|||||||
[totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
|
[totalRecords]="tableStore.filteredRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
|
||||||
[pageSize]="tableStore.queryState.pageSize()"
|
[pageSize]="tableStore.queryState.pageSize()"
|
||||||
[initialSortColumn]="tableStore.queryState.sortColumn()" [initialSortDirection]="tableStore.queryState.sortDirection()"
|
[initialSortColumn]="tableStore.queryState.sortColumn()" [initialSortDirection]="tableStore.queryState.sortDirection()"
|
||||||
tableTitle="Exchange Rates (ROE)" buttonTitle="Add"
|
tableTitle="Exchange Rates" buttonTitle="Add"
|
||||||
[showSearch]="true" [showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()"
|
[showSearch]="true" [showAddButton]="true" [showFilterButton]="true" [filterActive]="showFilters()"
|
||||||
searchPlaceholder="Search pair..." [searchDebounceTime]="300" toolTip="Add New Rate" (addClicked)="onAddRate()"
|
searchPlaceholder="Search rates..." [searchDebounceTime]="300" toolTip="Add New Rate" (addClicked)="onAddRate()"
|
||||||
(searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)"
|
(searchChanged)="tableStore.onSearch($event)" (pageChanged)="tableStore.onPageChange($event)"
|
||||||
(sortChanged)="tableStore.onSortChange($event)" (actionClicked)="onActionClick($event)"
|
(sortChanged)="tableStore.onSortChange($event)" (actionClicked)="onActionClick($event)"
|
||||||
(filterClicked)="onToggleFilters()">
|
(filterClicked)="onToggleFilters()">
|
||||||
|
|||||||
@@ -1,62 +1,3 @@
|
|||||||
<<<<<<< HEAD
|
|
||||||
<app-data-table
|
|
||||||
[columns]="columns()"
|
|
||||||
[rows]="tableStore.rows()"
|
|
||||||
[actions]="actions()"
|
|
||||||
[totalRecords]="tableStore.totalRecords()"
|
|
||||||
[pageIndex]="tableStore.queryState.pageIndex()"
|
|
||||||
[pageSize]="tableStore.queryState.pageSize()"
|
|
||||||
tableTitle="States"
|
|
||||||
buttonTitle="Add"
|
|
||||||
[showSearch]="true"
|
|
||||||
[showAddButton]="true"
|
|
||||||
[showFilterButton]="true"
|
|
||||||
[filterActive]="showFilters()"
|
|
||||||
searchPlaceholder="Search states..."
|
|
||||||
[searchDebounceTime]="300"
|
|
||||||
toolTip="Add State"
|
|
||||||
(addClicked)="onAddState()"
|
|
||||||
(filterClicked)="onToggleFilters()"
|
|
||||||
(searchChanged)="tableStore.onSearch($event)"
|
|
||||||
(pageChanged)="tableStore.onPageChange($event)"
|
|
||||||
(sortChanged)="tableStore.onSortChange($event)"
|
|
||||||
(actionClicked)="onActionClick($event)"
|
|
||||||
>
|
|
||||||
<ng-template appDataTableToolbar>
|
|
||||||
<form [formGroup]="countryFilterForm" (ngSubmit)="onApplyFilter($event)" autocomplete="off" class="flex flex-nowrap items-end gap-3">
|
|
||||||
<div class="w-[220px] shrink-0">
|
|
||||||
<app-autocomplete
|
|
||||||
formControlName="countryId"
|
|
||||||
inputId="state-country-filter"
|
|
||||||
variant="floating"
|
|
||||||
size="sm"
|
|
||||||
label="Country"
|
|
||||||
placeholder="Search"
|
|
||||||
[searchFn]="searchCountries"
|
|
||||||
[displayWith]="displayCountry"
|
|
||||||
[valueWith]="countryValue"
|
|
||||||
[selectedItem]="selectedCountryLookup()"
|
|
||||||
[minSearchLength]="1"
|
|
||||||
[debounceTime]="300"
|
|
||||||
[limit]="50"
|
|
||||||
[clearable]="true"
|
|
||||||
[hideValidation]="true"
|
|
||||||
wrapperClass="!mb-0 w-full"
|
|
||||||
(itemSelected)="onFilterCountrySelected($event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</ng-template>
|
|
||||||
<ng-template appDataTableFilterActions>
|
|
||||||
<button type="button" class="ti-btn ti-btn-primary-full !rounded-full !m-0 flex min-h-8 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap !px-3 !py-1.5 !text-[0.75rem]" (click)="onApplyFilter($event)">
|
|
||||||
<span>Apply</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="ti-btn ti-btn-light !rounded-full !m-0 flex min-h-8 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap !px-3 !py-1.5 !text-[0.75rem]" (click)="onResetFilter()">
|
|
||||||
<span>Reset</span>
|
|
||||||
</button>
|
|
||||||
</ng-template>
|
|
||||||
</app-data-table>
|
|
||||||
=======
|
|
||||||
<app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()"
|
<app-data-table [columns]="columns()" [rows]="tableStore.rows()" [actions]="actions()"
|
||||||
[totalRecords]="tableStore.totalRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
|
[totalRecords]="tableStore.totalRecords()" [pageIndex]="tableStore.queryState.pageIndex()"
|
||||||
[pageSize]="tableStore.queryState.pageSize()"
|
[pageSize]="tableStore.queryState.pageSize()"
|
||||||
@@ -88,7 +29,6 @@
|
|||||||
|
|
||||||
<app-confirm-dialog title="Delete State" text="Do you really want to delete this state?" confirmButtonText="Delete"
|
<app-confirm-dialog title="Delete State" text="Do you really want to delete this state?" confirmButtonText="Delete"
|
||||||
cancelButtonText="Cancel" (confirmed)="onDeleteConfirmed()" (cancelled)="onDeleteCancelled()" />
|
cancelButtonText="Cancel" (confirmed)="onDeleteConfirmed()" (cancelled)="onDeleteCancelled()" />
|
||||||
>>>>>>> dev
|
|
||||||
|
|
||||||
<app-state-form-modal [open]="tableStore.showModal()" [mode]="tableStore.modalMode()"
|
<app-state-form-modal [open]="tableStore.showModal()" [mode]="tableStore.modalMode()"
|
||||||
[stateId]="tableStore.selectedItem()?.id ?? null" (saved)="tableStore.refresh(); tableStore.closeModal()"
|
[stateId]="tableStore.selectedItem()?.id ?? null" (saved)="tableStore.refresh(); tableStore.closeModal()"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Subject } from 'rxjs';
|
|||||||
import { debounceTime, distinctUntilChanged, finalize, switchMap } from 'rxjs/operators';
|
import { debounceTime, distinctUntilChanged, finalize, switchMap } from 'rxjs/operators';
|
||||||
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
import { NotificationService } from '../../../../../core/services/common/notification.service';
|
||||||
|
|
||||||
import { CountryLookupDto, CountryService } from '../../../countries/public-api';
|
import { CountryService } from '../../../countries/public-api';
|
||||||
import { StateDto, UpdateStateRequest } from '../../models/state.model';
|
import { StateDto } from '../../models/state.model';
|
||||||
import { StateService } from '../../data-access/state.service';
|
import { StateService } from '../../data-access/state.service';
|
||||||
import { DataTable, DataTableToolbarDirective } from '../../../../../shared/components/data-table/data-table';
|
import { DataTable, DataTableToolbarDirective } from '../../../../../shared/components/data-table/data-table';
|
||||||
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
|
||||||
@@ -133,9 +133,6 @@ export class StateList implements OnInit {
|
|||||||
this.showFilters.update(value => !value);
|
this.showFilters.update(value => !value);
|
||||||
}
|
}
|
||||||
|
|
||||||
onFilterCountrySelected(country: CountryLookupDto | null): void {
|
|
||||||
this.selectedCountryLookup.set(country);
|
|
||||||
this.countryFilterForm.controls.countryId.setValue(country ? country.id : '');
|
|
||||||
onCountrySearchChanged(term: string): void {
|
onCountrySearchChanged(term: string): void {
|
||||||
this.countrySearch$.next(term);
|
this.countrySearch$.next(term);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -434,7 +434,7 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host-context(.dark) .data-table-modern .ti-pagination li .page-link {
|
:host-context(.dark) .data-table-modern .ti-pagination li .page-link,
|
||||||
:host-context(.dark) .data-table-modern ::ng-deep .p-paginator .p-paginator-page,
|
:host-context(.dark) .data-table-modern ::ng-deep .p-paginator .p-paginator-page,
|
||||||
:host-context(.dark) .data-table-modern ::ng-deep .p-paginator .p-paginator-prev,
|
:host-context(.dark) .data-table-modern ::ng-deep .p-paginator .p-paginator-prev,
|
||||||
:host-context(.dark) .data-table-modern ::ng-deep .p-paginator .p-paginator-next {
|
:host-context(.dark) .data-table-modern ::ng-deep .p-paginator .p-paginator-next {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .form-control:focus {
|
::ng-deep .form-control:focus {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary) !important;
|
||||||
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--color-primary) 20%, transparent);
|
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -308,8 +308,9 @@
|
|||||||
.shared-form-input::placeholder { color: transparent; }
|
.shared-form-input::placeholder { color: transparent; }
|
||||||
|
|
||||||
.shared-form-control.is-focused .shared-form-input,
|
.shared-form-control.is-focused .shared-form-input,
|
||||||
.shared-form-control.has-value .shared-form-input {
|
.shared-form-control.has-value .shared-form-input,
|
||||||
border-color: var(--color-primary);
|
.shared-form-input:focus {
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shared-floating-label {
|
.shared-floating-label {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
(mousedown)="$event.stopPropagation()">
|
(mousedown)="$event.stopPropagation()">
|
||||||
|
|
||||||
<div class="modern-modal">
|
<div class="modern-modal" [style.width]="modalInitialWidth()">
|
||||||
|
|
||||||
@if (showHeader()) {
|
@if (showHeader()) {
|
||||||
<div class="modern-modal-header">
|
<div class="modern-modal-header">
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
width: 100%;
|
max-width: 100%;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -113,10 +113,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
width: 100%;
|
min-width: 320px;
|
||||||
|
min-height: 220px;
|
||||||
|
max-width: 96vw;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
resize: both;
|
||||||
|
|
||||||
|
&::-webkit-resizer {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
|
|
||||||
@@ -920,6 +928,8 @@
|
|||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
|
||||||
max-height: 95vh;
|
max-height: 95vh;
|
||||||
|
width: 100% !important;
|
||||||
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header-content {
|
.modal-header-content {
|
||||||
|
|||||||
@@ -40,16 +40,16 @@ export class Modal implements OnDestroy {
|
|||||||
readonly closed = output<void>();
|
readonly closed = output<void>();
|
||||||
readonly submitted = output<void>();
|
readonly submitted = output<void>();
|
||||||
|
|
||||||
readonly modalSizeClass = computed(() => {
|
readonly modalInitialWidth = computed(() => {
|
||||||
const sizes: Record<ModalSize, string> = {
|
const widths: Record<ModalSize, string> = {
|
||||||
sm: 'max-w-md',
|
sm: '28rem',
|
||||||
md: 'max-w-2xl',
|
md: '42rem',
|
||||||
lg: 'max-w-4xl',
|
lg: '56rem',
|
||||||
xl: 'max-w-6xl',
|
xl: '72rem',
|
||||||
full: 'max-w-[96vw]'
|
full: '96vw'
|
||||||
};
|
};
|
||||||
|
|
||||||
return sizes[this.size()];
|
return widths[this.size()];
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly modalBoxClass = computed(() => {
|
readonly modalBoxClass = computed(() => {
|
||||||
@@ -60,8 +60,7 @@ export class Modal implements OnDestroy {
|
|||||||
'ease-out',
|
'ease-out',
|
||||||
'relative',
|
'relative',
|
||||||
'z-[1]',
|
'z-[1]',
|
||||||
'pointer-events-auto',
|
'pointer-events-auto'
|
||||||
this.modalSizeClass()
|
|
||||||
].join(' ');
|
].join(' ');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
export interface AppSessionTimeoutConfig {
|
|
||||||
warningAfterMs: number;
|
|
||||||
logoutAfterMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppEnvironment {
|
export interface AppEnvironment {
|
||||||
production: boolean;
|
production: boolean;
|
||||||
api: {
|
api: {
|
||||||
identity: string;
|
identity: string;
|
||||||
masterAdmin: string;
|
masterAdmin: string;
|
||||||
};
|
};
|
||||||
sessionTimeout: AppSessionTimeoutConfig;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,4 @@ export const environment: AppEnvironment = {
|
|||||||
identity: '/api',
|
identity: '/api',
|
||||||
masterAdmin: '/api',
|
masterAdmin: '/api',
|
||||||
},
|
},
|
||||||
sessionTimeout: {
|
|
||||||
warningAfterMs: 25 * 60 * 1000,
|
|
||||||
logoutAfterMs: 30 * 60 * 1000,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ export const environment: AppEnvironment = {
|
|||||||
// identity: 'https://identity.yourdomain.com/api',
|
// identity: 'https://identity.yourdomain.com/api',
|
||||||
// masterAdmin: 'https://master-admin.yourdomain.com/api',
|
// masterAdmin: 'https://master-admin.yourdomain.com/api',
|
||||||
},
|
},
|
||||||
sessionTimeout: {
|
|
||||||
warningAfterMs: 25 * 60 * 1000,
|
|
||||||
logoutAfterMs: 30 * 60 * 1000,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -2,9 +2,7 @@
|
|||||||
@forward "../public/assets/css/style.css";
|
@forward "../public/assets/css/style.css";
|
||||||
@forward "../node_modules/ngx-toastr/toastr.css";
|
@forward "../node_modules/ngx-toastr/toastr.css";
|
||||||
|
|
||||||
@import "flatpickr/dist/flatpickr.css";
|
@use "flatpickr/dist/flatpickr.css";
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&display=swap');
|
|
||||||
|
|
||||||
/* Etihad Altis Text (local font) */
|
/* Etihad Altis Text (local font) */
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|||||||
Reference in New Issue
Block a user