RED-6713: show previous signed in domains

This commit is contained in:
Dan Percic 2023-05-21 18:53:40 +03:00
parent 721d3e3b1a
commit 223fbe688c
18 changed files with 322 additions and 127 deletions

View File

@ -1,74 +1,74 @@
@use 'common-mixins';
.top-bar {
height: var(--iqser-top-bar-height);
width: 100vw;
height: var(--iqser-top-bar-height);
width: 100vw;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 24px;
border-bottom: 1px solid var(--iqser-separator);
box-sizing: border-box;
background-color: var(--iqser-background);
> *:not(:last-child) {
margin-right: 50px;
}
.menu {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 24px;
border-bottom: 1px solid var(--iqser-separator);
box-sizing: border-box;
background-color: var(--iqser-background);
&.right {
justify-content: flex-end;
}
}
.menu-placeholder {
display: flex;
flex: 2;
}
.buttons {
display: flex;
margin-right: 8px;
> *:not(:last-child) {
margin-right: 50px;
margin-right: 14px;
}
}
}
.logo {
@include common-mixins.clear-a;
display: flex;
align-items: center;
}
.logo {
@include common-mixins.clear-a;
display: flex;
align-items: center;
}
.app-name {
font-family: var(--iqser-app-name-font-family);
font-size: var(--iqser-app-name-font-size);
color: var(--iqser-app-name-color);
font-weight: 800;
white-space: nowrap;
}
.menu {
display: flex;
align-items: center;
&.right {
justify-content: flex-end;
}
}
.menu-placeholder {
display: flex;
flex: 2;
}
.buttons {
display: flex;
margin-right: 8px;
> *:not(:last-child) {
margin-right: 14px;
}
}
.app-name {
font-family: var(--iqser-app-name-font-family);
font-size: var(--iqser-app-name-font-size);
color: var(--iqser-app-name-color);
font-weight: 800;
white-space: nowrap;
}
.dev-mode {
background-color: var(--iqser-primary);
color: var(--iqser-white);
font-size: 22px;
line-height: 16px;
text-align: center;
position: fixed;
top: 0;
z-index: 100;
right: 0;
height: var(--iqser-top-bar-height);
word-break: break-all;
display: flex;
justify-content: center;
align-items: center;
font-family: monospace;
width: 24px;
font-weight: bold;
background-color: var(--iqser-primary);
color: var(--iqser-white);
font-size: 22px;
line-height: 16px;
text-align: center;
position: fixed;
top: 0;
z-index: 100;
right: 0;
height: var(--iqser-top-bar-height);
word-break: break-all;
display: flex;
justify-content: center;
align-items: center;
font-family: monospace;
width: 24px;
font-weight: bold;
}

View File

@ -85,10 +85,10 @@ export class IqserPermissionsGuard implements CanActivate, CanMatch, CanActivate
}
if (Array.isArray(_redirectTo)) {
return this._router.navigate([this._tenantsService.currentTenant, ..._redirectTo]);
return this._router.navigate([this._tenantsService.activeTenantId, ..._redirectTo]);
}
return this._router.navigate([`${this._tenantsService.currentTenant}${_redirectTo}`]);
return this._router.navigate([`${this._tenantsService.activeTenantId}${_redirectTo}`]);
}
#checkRedirect(permissions: IqserPermissionsData, route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) {

View File

@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'iqser-logo [icon]',
selector: 'iqser-logo',
template: ` <mat-icon [svgIcon]="icon"></mat-icon>`,
styles: [
`
@ -21,5 +21,5 @@ import { MatIconModule } from '@angular/material/icon';
imports: [MatIconModule],
})
export class LogoComponent {
@Input() icon!: string;
@Input({ required: true }) icon!: string;
}

View File

@ -0,0 +1,15 @@
:host {
--height: 20px;
div {
height: #{var(--height)};
@media screen and (max-width: 1600px) {
height: calc(#{var(--height)} * 0.9);
}
@media screen and (max-width: 1200px) {
height: calc(#{var(--height)} * 0.85);
}
}
}

View File

@ -0,0 +1,16 @@
import { Component, HostBinding, Input } from '@angular/core';
@Component({
selector: 'iqser-spacer [height]',
template: ' <div></div> ',
styleUrls: ['./spacer.component.scss'],
standalone: true,
})
export class SpacerComponent {
@Input({ required: true }) height!: number;
@HostBinding('style.--height')
get heightStyle() {
return this.height + 'px';
}
}

View File

@ -4,5 +4,5 @@ export * from './guards/if-not-logged-in.guard';
export * from './services';
export * from './tenant.pipe';
export * from './tenants.module';
export * from './tenant-resolve/tenant-resolve.component';
export * from './tenant-select/tenant-select.component';
export * from './services/keycloak-status.service';

View File

@ -30,7 +30,7 @@ export class KeycloakStatusService {
}
createLoginUrl() {
const currentTenant = this.#tenantsService.currentTenant;
const currentTenant = this.#tenantsService.activeTenantId;
if (currentTenant && window.location.href.indexOf('/' + currentTenant) > 0) {
return window.location.href;
}

View File

@ -8,9 +8,9 @@ export class TenantIdInterceptor implements HttpInterceptor {
protected readonly _tenantsService = inject(TenantsService);
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
if (this._tenantsService.currentTenant) {
if (this._tenantsService.activeTenantId) {
const updatedRequest = req.clone({
setHeaders: { 'X-TENANT-ID': this._tenantsService.currentTenant },
setHeaders: { 'X-TENANT-ID': this._tenantsService.activeTenantId },
});
return next.handle(updatedRequest);

View File

@ -2,7 +2,7 @@ import { computed, inject, Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { NGXLogger } from 'ngx-logger';
import { BASE_HREF } from '../../utils';
import { BASE_HREF, List } from '../../utils';
import { Router } from '@angular/router';
export interface IBaseTenant {
@ -11,6 +11,22 @@ export interface IBaseTenant {
readonly guid: string;
}
export interface IStoredTenantId {
readonly tenantId: string;
readonly email: string;
}
export type StoredTenantIds = List<IStoredTenantId>;
export interface IStoredTenant {
readonly tenant: IBaseTenant;
readonly email: string;
}
export type StoredTenants = IStoredTenant[];
const STORED_TENANTS_KEY = 'red-stored-tenants';
@Injectable({ providedIn: 'root' })
export class TenantsService {
readonly tenants = signal<IBaseTenant[] | undefined>(undefined);
@ -31,8 +47,11 @@ export class TenantsService {
key: localStorage.key.bind(localStorage),
};
readonly #activeTenantId = signal<string>('');
readonly activeTenant = computed(() => {
return this.tenants()?.find(t => t.tenantId === this.#activeTenantId());
});
get currentTenant() {
get activeTenantId() {
return this.#activeTenantId();
}
@ -82,11 +101,61 @@ export class TenantsService {
}
this.#mutateStorage(tenantId);
this.#setCurrentTenantId(tenantId);
this.#setActiveTenantId(tenantId);
return true;
}
#setCurrentTenantId(tenantId: string) {
storeTenant(email: string) {
if (!email) {
this.#logger.warn('[TENANTS] Email is null, skip storing');
return;
}
const storedTenants = this.getStoredTenants();
const activeTenant = this.activeTenant();
const existing = storedTenants.find(s => s.email === email && s.tenant.tenantId === activeTenant?.tenantId);
if (existing) {
this.#logger.info('[TENANTS] Stored tenant exists: ', storedTenants);
return;
}
if (!activeTenant) {
this.#logger.error('[TENANTS] Active tenant is null when storing tenants');
return;
}
storedTenants.push({ tenant: activeTenant, email: email });
const serializableTenants = storedTenants.map(s => ({
tenantId: s.tenant.tenantId,
email: s.email,
}));
this.#storageReference.setItem(STORED_TENANTS_KEY, JSON.stringify(serializableTenants));
this.#logger.info('[TENANTS] Stored tenants: ', storedTenants);
}
getStoredTenants(): StoredTenants {
const rawStoredTenants = this.#storageReference.getItem(STORED_TENANTS_KEY);
const stored = JSON.parse(rawStoredTenants ?? '[]') as StoredTenantIds;
const tenants = this.tenants();
if (!tenants) {
this.#logger.error('[TENANTS] Tenants data is null when retrieving stored tenants');
return [];
}
const trueStored: StoredTenants = [];
for (const s of stored) {
const tenant = tenants.find(t => t.tenantId === s.tenantId);
if (!tenant) {
this.#logger.warn(`[TENANTS] Stored tenant with id ${tenant} not found, skipping`);
continue;
}
trueStored.push({ tenant, email: s.email });
}
return trueStored;
}
#setActiveTenantId(tenantId: string) {
this.#logger.info('[TENANTS] Set current tenant id: ', tenantId);
this.#activeTenantId.set(tenantId);
}

View File

@ -1,25 +0,0 @@
<div class="tenant-section">
<form (submit)="updateTenantSelection()" [formGroup]="form">
<div class="heading-l" translate="tenant-resolve.header"></div>
<div class="iqser-input-group required w-400">
<mat-form-field *ngIf="_tenantsService.tenants() as tenants">
<mat-select
[placeholder]="'tenant-resolve.form.tenant-placeholder' | translate"
class="full-width"
formControlName="tenantId"
>
<mat-option *ngFor="let option of tenants" [value]="option.tenantId">
{{ option.displayName || option.tenantId }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<iqser-icon-button
[label]="'tenant-resolve.actions.save' | translate"
[submit]="true"
[type]="iconButtonTypes.primary"
></iqser-icon-button>
</form>
</div>

View File

@ -1,13 +0,0 @@
.tenant-section {
display: flex;
align-items: flex-start;
justify-content: center;
width: 100vw;
height: 100vh;
margin-top: 32px;
> form > * {
padding: 8px;
}
}

View File

@ -0,0 +1,55 @@
<div class="tenant-section">
<a class="logo">
<div class="actions">
<iqser-logo icon="red:logo"></iqser-logo>
<div class="app-name">{{ titleService.getTitle() }}</div>
</div>
</a>
<iqser-spacer [height]="100"></iqser-spacer>
<div *ngIf="storedTenants.length" class="pb-30 subheading" translate="tenant-resolve.header.sign-in-previous-domain"></div>
<div *ngIf="storedTenants.length" style="display: flex; flex-direction: column">
<div
*ngFor="let stored of storedTenants"
[routerLink]="[stored.tenant.tenantId]"
class="pointer mat-elevation-z2 card stored-tenant-card mt-10"
>
<iqser-logo class="card-icon" icon="red:logo" mat-card-image></iqser-logo>
<div class="card-content">
<span class="heading">{{ stored.tenant.displayName }}</span>
<span>{{ stored.email }}</span>
</div>
<mat-icon class="card-icon upside-down" svgIcon="iqser:expand"></mat-icon>
</div>
</div>
<div *ngIf="storedTenants.length === 0" class="heading pb-30" translate="tenant-resolve.header.first-time"></div>
<ng-container *ngIf="storedTenants.length">
<iqser-spacer [height]="100"></iqser-spacer>
<div class="pb-30 subheading" translate="tenant-resolve.header.join-another-domain"></div>
</ng-container>
<div class="mat-elevation-z16 card input-card">
<span class="heading url">{{ url }}</span>
<form (submit)="updateTenantSelection()" [formGroup]="form" class="w-full">
<div class="iqser-input-group required w-full">
<mat-form-field>
<input [placeholder]="'tenant-resolve.input-placeholder' | translate" formControlName="tenantId" matInput />
</mat-form-field>
</div>
<button class="card-icon upside-down" mat-icon-button type="submit">
<mat-icon svgIcon="iqser:expand"></mat-icon>
</button>
</form>
</div>
<iqser-spacer [height]="50"></iqser-spacer>
<div [innerHTML]="'tenant-resolve.contact-administrator' | translate"></div>
</div>

View File

@ -0,0 +1,65 @@
.tenant-section {
display: flex;
align-items: center;
flex-direction: column;
width: 100vw;
height: 100vh;
margin-top: 32px;
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
}
.stored-tenant-card {
width: 450px;
height: 90px;
}
.card {
display: flex;
align-items: center;
border-radius: 10px;
}
.card-icon {
margin-right: 20px;
margin-left: 20px;
}
.upside-down {
transform: rotate(180deg);
}
.card-content {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.pb-30 {
padding-bottom: 30px;
}
.input-card {
width: 600px;
height: 65px;
}
.url {
margin-left: 20px;
margin-right: 10px;
}
form {
display: flex;
align-items: center;
flex-grow: 1;
}
.subheading {
font-size: 17px;
}

View File

@ -4,26 +4,27 @@ import { FormBuilder, Validators } from '@angular/forms';
import { TenantsService } from '../services';
import { IconButtonTypes } from '../../buttons';
import { LoadingService } from '../../loading';
import { Title } from '@angular/platform-browser';
import { getConfig } from '../../services';
import { BASE_HREF } from '../../utils';
@Component({
templateUrl: './tenant-resolve.component.html',
styleUrls: ['./tenant-resolve.component.scss'],
templateUrl: './tenant-select.component.html',
styleUrls: ['./tenant-select.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TenantResolveComponent {
export class TenantSelectComponent {
readonly loadingService = inject(LoadingService);
readonly iconButtonTypes = IconButtonTypes;
readonly form;
readonly form = inject(FormBuilder).group({
// eslint-disable-next-line @typescript-eslint/unbound-method
tenantId: ['', Validators.required],
});
readonly url = getConfig().OAUTH_URL.replace('https://', '').replace('/auth', '') + inject(BASE_HREF) + '/';
protected readonly _tenantsService = inject(TenantsService);
protected readonly storedTenants = this._tenantsService.getStoredTenants();
protected readonly titleService = inject(Title);
readonly #router = inject(Router);
readonly #formBuilder = inject(FormBuilder);
constructor() {
this.form = this.#formBuilder.group({
// eslint-disable-next-line @typescript-eslint/unbound-method
tenantId: ['', Validators.required],
});
}
updateTenantSelection() {
const tenant = this.form.controls.tenantId.value;

View File

@ -14,6 +14,6 @@ export class TenantPipe implements PipeTransform {
return undefined;
}
const _value = Array.isArray(value) ? value.join('/') : value;
return '/' + this.#tenantsService.currentTenant + _value;
return '/' + this.#tenantsService.activeTenantId + _value;
}
}

View File

@ -3,14 +3,20 @@ import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { MatDialogModule } from '@angular/material/dialog';
import { CircleButtonComponent, IconButtonComponent } from '../buttons';
import { TenantResolveComponent } from './tenant-resolve/tenant-resolve.component';
import { TenantSelectComponent } from './tenant-select/tenant-select.component';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { ReactiveFormsModule } from '@angular/forms';
import { TenantIdInterceptor, TenantIdResponseInterceptor, TenantsService } from './services';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { MatCardModule } from '@angular/material/card';
import { LogoComponent } from '../shared/logo/logo.component';
import { SpacerComponent } from '../shared/spacer/spacer.component';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { RouterLink } from '@angular/router';
const components = [TenantResolveComponent];
const components = [TenantSelectComponent];
@NgModule({
declarations: [...components],
@ -23,6 +29,12 @@ const components = [TenantResolveComponent];
MatSelectModule,
IconButtonComponent,
ReactiveFormsModule,
MatCardModule,
LogoComponent,
SpacerComponent,
MatIconModule,
MatButtonModule,
RouterLink,
],
exports: [...components],
})

View File

@ -35,7 +35,7 @@ export class IqserAuthGuard extends KeycloakAuthGuard {
const user = await this._userService.loadCurrentUser();
if (user?.hasAnyRole && route.routeConfig?.path === 'auth-error') {
await this._router.navigate([`/${this._tenantsService.currentTenant}/main`]);
await this._router.navigate([`/${this._tenantsService.activeTenantId}/main`]);
return false;
}

View File

@ -14,7 +14,7 @@ export class IqserRoleGuard implements CanActivate {
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const currentUser = this._userService.currentUser;
if (!currentUser || !currentUser.hasAnyRole) {
await this._router.navigate([`/${this._tenantsService.currentTenant}/auth-error`]);
await this._router.navigate([`/${this._tenantsService.activeTenantId}/auth-error`]);
this._loadingService.stop();
return false;
}