newportsite 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/newportsite-1.1.3.tgz +0 -0
- package/ng-package.json +7 -0
- package/obfuscate.js +70 -0
- package/package.json +15 -0
- package/src/lib/app.component.ts +47 -0
- package/src/lib/app.routing.ts +38 -0
- package/src/lib/auth/alert.component.html +5 -0
- package/src/lib/auth/alert.component.ts +24 -0
- package/src/lib/auth/auth.component.html +1 -0
- package/src/lib/auth/auth.component.ts +10 -0
- package/src/lib/auth/auth.routes.ts +16 -0
- package/src/lib/auth/index.ts +4 -0
- package/src/lib/auth/login.component.html +87 -0
- package/src/lib/auth/login.component.ts +158 -0
- package/src/lib/auth/models/index.ts +1 -0
- package/src/lib/auth/models/user.ts +25 -0
- package/src/lib/auth/register.component.html +157 -0
- package/src/lib/auth/register.component.ts +219 -0
- package/src/lib/auth/services/alert.service.ts +47 -0
- package/src/lib/auth/services/auth.service.ts +28 -0
- package/src/lib/auth/services/index.ts +3 -0
- package/src/lib/auth/services/user.service.spec.ts +112 -0
- package/src/lib/auth/services/user.service.ts +47 -0
- package/src/lib/common/card.component.html +72 -0
- package/src/lib/common/card.component.ts +102 -0
- package/src/lib/common/commands.component.html +8 -0
- package/src/lib/common/commands.component.ts +42 -0
- package/src/lib/common/context.component.html +9 -0
- package/src/lib/common/context.component.ts +38 -0
- package/src/lib/common/grid.component.html +20 -0
- package/src/lib/common/grid.component.ts +747 -0
- package/src/lib/common/index.ts +9 -0
- package/src/lib/common/loader.component.html +5 -0
- package/src/lib/common/loader.component.ts +27 -0
- package/src/lib/common/lookup.component.html +29 -0
- package/src/lib/common/lookup.component.ts +115 -0
- package/src/lib/common/messagebox.component.html +39 -0
- package/src/lib/common/messagebox.component.ts +74 -0
- package/src/lib/common/theme-toggle.component.ts +139 -0
- package/src/lib/config.ts +62 -0
- package/src/lib/containers/default-layout/default-layout.component.html +191 -0
- package/src/lib/containers/default-layout/default-layout.component.ts +158 -0
- package/src/lib/containers/default-layout/index.ts +1 -0
- package/src/lib/containers/index.ts +1 -0
- package/src/lib/directives/component.draggable.ts +80 -0
- package/src/lib/directives/index.ts +2 -0
- package/src/lib/directives/input.directive.spec.ts +158 -0
- package/src/lib/directives/input.directive.ts +210 -0
- package/src/lib/home/dashboard/dashboard.component.html +38 -0
- package/src/lib/home/dashboard/dashboard.component.ts +50 -0
- package/src/lib/home/dashboard/index.ts +1 -0
- package/src/lib/home/index.component.html +1 -0
- package/src/lib/home/index.component.ts +10 -0
- package/src/lib/home/index.routes.ts +29 -0
- package/src/lib/home/index.ts +1 -0
- package/src/lib/home/info/index.ts +1 -0
- package/src/lib/home/info/info.component.css +476 -0
- package/src/lib/home/info/info.component.html +174 -0
- package/src/lib/home/info/info.component.ts +287 -0
- package/src/lib/home/model/article.component.html +10 -0
- package/src/lib/home/model/article.component.ts +50 -0
- package/src/lib/home/model/barchart.component.html +8 -0
- package/src/lib/home/model/barchart.component.ts +59 -0
- package/src/lib/home/model/index.ts +7 -0
- package/src/lib/home/model/itemdetail.component.html +25 -0
- package/src/lib/home/model/itemdetail.component.ts +93 -0
- package/src/lib/home/model/itemtab.component.html +25 -0
- package/src/lib/home/model/itemtab.component.ts +105 -0
- package/src/lib/home/model/model.component.html +121 -0
- package/src/lib/home/model/model.component.ts +510 -0
- package/src/lib/home/model/modeltoolbar.component.html +111 -0
- package/src/lib/home/model/modeltoolbar.component.ts +157 -0
- package/src/lib/home/model/navigation.component.html +86 -0
- package/src/lib/home/model/navigation.component.ts +247 -0
- package/src/lib/home/model/services/index.ts +1 -0
- package/src/lib/home/model/services/model.service.spec.ts +423 -0
- package/src/lib/home/model/services/model.service.ts +319 -0
- package/src/lib/home/modelsearch/index.ts +1 -0
- package/src/lib/home/modelsearch/modelsearch.component.html +124 -0
- package/src/lib/home/modelsearch/modelsearch.component.ts +453 -0
- package/src/lib/interfaces/data.interface.ts +131 -0
- package/src/lib/interfaces/index.ts +2 -0
- package/src/lib/interfaces/item.interface.ts +438 -0
- package/src/lib/players/lookup/lookup.directive.ts +6 -0
- package/src/lib/players/lookup/lookup.item.component.ts +37 -0
- package/src/lib/players/lookup/lookup.item.ts +9 -0
- package/src/lib/players/lookup/lookup.player.component.ts +59 -0
- package/src/lib/players/lookup/lookup.selector.component.ts +41 -0
- package/src/lib/players/model/model.directive.ts +6 -0
- package/src/lib/players/model/model.item.component.spec.ts +311 -0
- package/src/lib/players/model/model.item.component.ts +3457 -0
- package/src/lib/players/model/model.item.ts +9 -0
- package/src/lib/players/model/model.player.component.ts +109 -0
- package/src/lib/players/model/model.selector.component.ts +59 -0
- package/src/lib/scheduler/scheduler.component.html +13 -0
- package/src/lib/scheduler/scheduler.component.scss +6 -0
- package/src/lib/scheduler/scheduler.component.ts +296 -0
- package/src/lib/scheduler/scheduler.routes.ts +15 -0
- package/src/lib/scheduler/schedulerdialog.component.html +72 -0
- package/src/lib/scheduler/schedulerdialog.component.ts +208 -0
- package/src/lib/scheduler/services/scheduler.service.ts +133 -0
- package/src/lib/services/auth-state.service.ts +129 -0
- package/src/lib/services/auth.interceptor.spec.ts +144 -0
- package/src/lib/services/auth.interceptor.ts +44 -0
- package/src/lib/services/cache.service.spec.ts +143 -0
- package/src/lib/services/cache.service.ts +71 -0
- package/src/lib/services/global-error-handler.spec.ts +39 -0
- package/src/lib/services/global-error-handler.ts +28 -0
- package/src/lib/services/global.service.spec.ts +801 -0
- package/src/lib/services/global.service.ts +724 -0
- package/src/lib/services/message.service.ts +556 -0
- package/src/lib/services/theme.service.ts +96 -0
- package/src/lib/template/authtemplate.component.html +6 -0
- package/src/lib/template/authtemplate.component.ts +13 -0
- package/src/lib/template/basetemplate.component.html +7 -0
- package/src/lib/template/basetemplate.component.ts +13 -0
- package/src/lib/template/index.ts +3 -0
- package/src/lib/template/modeltemplate.component.html +7 -0
- package/src/lib/template/modeltemplate.component.ts +21 -0
- package/src/lib/utils/piva.spec.ts +56 -0
- package/src/lib/utils/piva.ts +29 -0
- package/src/lib/validators/email.validator.spec.ts +57 -0
- package/src/lib/validators/email.validator.ts +17 -0
- package/src/lib/validators/equalPasswords.validator.spec.ts +54 -0
- package/src/lib/validators/equalPasswords.validator.ts +17 -0
- package/src/lib/validators/index.ts +2 -0
- package/src/lib/version.ts +1 -0
- package/src/public-api.ts +64 -0
- package/src/typings.d.ts +2 -0
- package/tsconfig.lib.json +18 -0
- package/tsconfig.lib.prod.json +9 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
HostListener,
|
|
4
|
+
AfterViewInit,
|
|
5
|
+
DestroyRef,
|
|
6
|
+
inject,
|
|
7
|
+
input,
|
|
8
|
+
output,
|
|
9
|
+
viewChild,
|
|
10
|
+
computed,
|
|
11
|
+
effect,
|
|
12
|
+
ChangeDetectionStrategy,
|
|
13
|
+
} from '@angular/core';
|
|
14
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
15
|
+
|
|
16
|
+
import { GlobalService } from '../services/global.service';
|
|
17
|
+
|
|
18
|
+
import { AppMessageService } from '../services/message.service';
|
|
19
|
+
|
|
20
|
+
import { BsModalRef, ModalDirective } from 'ngx-bootstrap/modal';
|
|
21
|
+
|
|
22
|
+
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
|
23
|
+
|
|
24
|
+
import { ManagmentService } from '../home/model/services/index';
|
|
25
|
+
import { ManagmentInterface, TransportInterface } from '../interfaces';
|
|
26
|
+
import { NgxModalDraggableDirective } from '../directives/component.draggable';
|
|
27
|
+
import { NgStyle } from '@angular/common';
|
|
28
|
+
import { InputDirective } from '../directives/input.directive';
|
|
29
|
+
|
|
30
|
+
interface AssignedType {
|
|
31
|
+
id: any;
|
|
32
|
+
name: any;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Component({
|
|
36
|
+
selector: 'schedulerdialog',
|
|
37
|
+
templateUrl: 'schedulerdialog.component.html',
|
|
38
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
39
|
+
imports: [
|
|
40
|
+
ModalDirective,
|
|
41
|
+
NgxModalDraggableDirective,
|
|
42
|
+
NgStyle,
|
|
43
|
+
ReactiveFormsModule,
|
|
44
|
+
InputDirective,
|
|
45
|
+
],
|
|
46
|
+
})
|
|
47
|
+
export class SchedulerDialogComponent implements AfterViewInit {
|
|
48
|
+
gsv = inject(GlobalService);
|
|
49
|
+
msg = inject(AppMessageService);
|
|
50
|
+
fbl = inject(FormBuilder);
|
|
51
|
+
msv = inject(ManagmentService);
|
|
52
|
+
private destroyRef = inject(DestroyRef);
|
|
53
|
+
|
|
54
|
+
readonly modal = viewChild.required<ModalDirective>('modal');
|
|
55
|
+
|
|
56
|
+
show = input<boolean>(false);
|
|
57
|
+
title = input<string>('');
|
|
58
|
+
text = input<string>('');
|
|
59
|
+
showconfirm = input<boolean>(false);
|
|
60
|
+
width = input<number>(0);
|
|
61
|
+
height = input<number>(0);
|
|
62
|
+
top = input<number>(0);
|
|
63
|
+
left = input<number>(0);
|
|
64
|
+
|
|
65
|
+
schedulerDialogResult = output<any>();
|
|
66
|
+
|
|
67
|
+
public remove: boolean = false;
|
|
68
|
+
|
|
69
|
+
public assigneds: AssignedType[] = [];
|
|
70
|
+
|
|
71
|
+
public schedulerInfo: SchedulerInfo = new SchedulerInfo();
|
|
72
|
+
|
|
73
|
+
public profileform = this.fbl.group({
|
|
74
|
+
assigned: [this.schedulerInfo.assigned, Validators.required],
|
|
75
|
+
description: [this.schedulerInfo.description, Validators.required],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
public selected = -1;
|
|
79
|
+
|
|
80
|
+
public bsModalRef!: BsModalRef;
|
|
81
|
+
|
|
82
|
+
public managment!: ManagmentInterface;
|
|
83
|
+
|
|
84
|
+
readonly contextStyle = computed(() => ({
|
|
85
|
+
position: 'absolute',
|
|
86
|
+
top: this.top() + 'px',
|
|
87
|
+
left: this.left() + 'px',
|
|
88
|
+
width: this.width() + 'px',
|
|
89
|
+
height: this.height() + 'px',
|
|
90
|
+
pointerEvents: 'auto',
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
/** Loads the assignee lookup table and wires the show-effect to open the modal. */
|
|
94
|
+
public constructor() {
|
|
95
|
+
this.managment = this.gsv.getManagmentInterface();
|
|
96
|
+
this.gsv.setLoaderState(true);
|
|
97
|
+
this.managment.appId = this.gsv.getYear() < 2024 ? 'TAB' : 'TBB';
|
|
98
|
+
this.managment.year = this.gsv.getYear();
|
|
99
|
+
this.managment.entId = this.gsv.getYear() < 2024 ? 'TIA' : 'MTBB_TIA';
|
|
100
|
+
this.managment.keyId = '1';
|
|
101
|
+
this.managment.functionId = 'read';
|
|
102
|
+
this.managment.keys = 'prg=0';
|
|
103
|
+
this.msv
|
|
104
|
+
.getData(this.managment)
|
|
105
|
+
.pipe(takeUntilDestroyed())
|
|
106
|
+
.subscribe({
|
|
107
|
+
next: (data: TransportInterface) => {
|
|
108
|
+
const modules = JSON.parse(data.dataFile as string).entities.filter(
|
|
109
|
+
(a: any) => a.name === this.managment.entId.toLocaleLowerCase()
|
|
110
|
+
)[0].modules;
|
|
111
|
+
modules.forEach((item: any) => {
|
|
112
|
+
this.assigneds.push({
|
|
113
|
+
id: item.members.filter((e: any) => e.name === 'tipo')[0].value,
|
|
114
|
+
name: item.members.filter((e: any) => e.name === 'descrizione')[0]
|
|
115
|
+
.value,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
this.gsv.setLoaderState(false);
|
|
119
|
+
},
|
|
120
|
+
error: () => {
|
|
121
|
+
this.gsv.setLoaderState(false);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
effect(() => {
|
|
126
|
+
if (this.show()) {
|
|
127
|
+
if ((this.gsv.getDialog()!.tag as any)?.extendedProps !== undefined) {
|
|
128
|
+
this.remove = true;
|
|
129
|
+
this.profileform.patchValue({
|
|
130
|
+
assigned: (this.gsv.getDialog()!.tag as any)?.extendedProps?.assigned,
|
|
131
|
+
description: (this.gsv.getDialog()!.tag as any)?.title?.split('-')[1],
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
this.openModal();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Lifecycle: resets the reactive form on first render. */
|
|
140
|
+
public ngAfterViewInit(): void {
|
|
141
|
+
this.profileform.reset();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@HostListener('document:keydown.escape', ['$event']) onKeydownHandler(
|
|
145
|
+
event: KeyboardEvent
|
|
146
|
+
) {
|
|
147
|
+
event.stopImmediatePropagation();
|
|
148
|
+
this.remove = false;
|
|
149
|
+
this.closeDetail(false);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Shows the Bootstrap modal for the selected calendar event. */
|
|
153
|
+
public openModal() {
|
|
154
|
+
this.modal().show();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Returns the localised label for the close button ('Remove' in delete mode, 'Close' otherwise). */
|
|
158
|
+
get closeButtonText() {
|
|
159
|
+
return this.remove
|
|
160
|
+
? this.msg?.get('app.remove')
|
|
161
|
+
: this.msg?.get('app.close');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Closes the modal and triggers deletion of the current event. */
|
|
165
|
+
public closeAndRemove() {
|
|
166
|
+
this.closeDetail(false, this.remove);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Hides the modal, serialises the form to a SchedulerInfo object, and emits
|
|
171
|
+
* schedulerDialogResult with the confirm status, event tag, staff assignment, and delete flag.
|
|
172
|
+
*/
|
|
173
|
+
public closeDetail(status: boolean, remove: boolean = false) {
|
|
174
|
+
if (this.modal().isShown !== undefined) {
|
|
175
|
+
this.remove = remove;
|
|
176
|
+
const sch = this.profileform.value as SchedulerInfo;
|
|
177
|
+
this.modal().hide();
|
|
178
|
+
if (sch.assigned === null) {
|
|
179
|
+
this.schedulerDialogResult.emit({
|
|
180
|
+
value: status,
|
|
181
|
+
tag: this.gsv.getDialog()!.tag,
|
|
182
|
+
props: [''],
|
|
183
|
+
delete: this.remove,
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
this.schedulerDialogResult.emit({
|
|
187
|
+
value: status,
|
|
188
|
+
tag: this.gsv.getDialog()!.tag,
|
|
189
|
+
props: [
|
|
190
|
+
this.assigneds[parseFloat(sch?.assigned?.toString() ?? '-1') - 1]
|
|
191
|
+
.name +
|
|
192
|
+
'-' +
|
|
193
|
+
sch?.description?.toUpperCase(),
|
|
194
|
+
sch?.assigned,
|
|
195
|
+
],
|
|
196
|
+
delete: this.remove,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
this.profileform.reset();
|
|
200
|
+
this.remove = false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
class SchedulerInfo {
|
|
206
|
+
public description: string = '';
|
|
207
|
+
public assigned: number = -1;
|
|
208
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Injectable, inject } from '@angular/core';
|
|
2
|
+
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|
3
|
+
import { Observable, throwError } from 'rxjs';
|
|
4
|
+
import { GlobalService } from '../../services/global.service';
|
|
5
|
+
|
|
6
|
+
import { SchedulerInterface, TransportInterface } from '../../interfaces/index';
|
|
7
|
+
|
|
8
|
+
import { catchError, retry } from 'rxjs/operators';
|
|
9
|
+
|
|
10
|
+
@Injectable()
|
|
11
|
+
export class SchedulerService {
|
|
12
|
+
private http = inject(HttpClient);
|
|
13
|
+
private globalService = inject(GlobalService);
|
|
14
|
+
|
|
15
|
+
private schedulersUrl: string =
|
|
16
|
+
this.globalService.restService() + 'scheduler';
|
|
17
|
+
|
|
18
|
+
/** Sends DELETE DeleteSchedulerItem/{year}/{id} to remove a calendar event. */
|
|
19
|
+
public deleteItem(scheduler: SchedulerInterface): Observable<boolean> {
|
|
20
|
+
return this.http
|
|
21
|
+
.delete<boolean>(
|
|
22
|
+
this.schedulersUrl +
|
|
23
|
+
'/' +
|
|
24
|
+
'DeleteSchedulerItem' +
|
|
25
|
+
'/' +
|
|
26
|
+
scheduler.year +
|
|
27
|
+
'/' +
|
|
28
|
+
scheduler.id,
|
|
29
|
+
this.globalService.jwt()
|
|
30
|
+
)
|
|
31
|
+
.pipe(retry(3), catchError(this.handleError));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fetches a single scheduler record by year and id. */
|
|
35
|
+
public getSchedulerData(
|
|
36
|
+
scheduler: SchedulerInterface
|
|
37
|
+
): Observable<TransportInterface> {
|
|
38
|
+
return this.http
|
|
39
|
+
.get<TransportInterface>(
|
|
40
|
+
this.schedulersUrl +
|
|
41
|
+
'/' +
|
|
42
|
+
'GetSchedulerData' +
|
|
43
|
+
'/' +
|
|
44
|
+
scheduler.year +
|
|
45
|
+
'/' +
|
|
46
|
+
scheduler.id,
|
|
47
|
+
this.globalService.jwt()
|
|
48
|
+
)
|
|
49
|
+
.pipe(retry(3), catchError(this.handleError));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Fetches all scheduler records for the given year. */
|
|
53
|
+
public getSchedulerDataAll(
|
|
54
|
+
scheduler: SchedulerInterface
|
|
55
|
+
): Observable<TransportInterface> {
|
|
56
|
+
return this.http
|
|
57
|
+
.get<TransportInterface>(
|
|
58
|
+
this.schedulersUrl + '/' + 'GetSchedulerDataAll' + '/' + scheduler.year,
|
|
59
|
+
this.globalService.jwt()
|
|
60
|
+
)
|
|
61
|
+
.pipe(retry(3), catchError(this.handleError));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Fetches scheduler records within the given date range; throws if start/end are missing. */
|
|
65
|
+
public getSchedulerDataRange(
|
|
66
|
+
scheduler: SchedulerInterface
|
|
67
|
+
): Observable<TransportInterface> {
|
|
68
|
+
if (!scheduler.start || !scheduler.end) {
|
|
69
|
+
return throwError(() => new Error('Start and end dates are required'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return this.http
|
|
73
|
+
.get<TransportInterface>(
|
|
74
|
+
this.schedulersUrl +
|
|
75
|
+
'/' +
|
|
76
|
+
'GetSchedulerDataRange' +
|
|
77
|
+
'/' +
|
|
78
|
+
scheduler.year +
|
|
79
|
+
'/' +
|
|
80
|
+
scheduler.start.toDateString() +
|
|
81
|
+
'/' +
|
|
82
|
+
scheduler.end.toDateString(),
|
|
83
|
+
this.globalService.jwt()
|
|
84
|
+
)
|
|
85
|
+
.pipe(retry(3), catchError(this.handleError));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Sends POST to create a new scheduler event. */
|
|
89
|
+
public postData(
|
|
90
|
+
scheduler: SchedulerInterface
|
|
91
|
+
): Observable<TransportInterface> {
|
|
92
|
+
return this.http
|
|
93
|
+
.post<TransportInterface>(
|
|
94
|
+
this.schedulersUrl,
|
|
95
|
+
JSON.stringify(scheduler),
|
|
96
|
+
this.globalService.jwt()
|
|
97
|
+
)
|
|
98
|
+
.pipe(retry(3), catchError(this.handleError));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Sends PUT to update an existing scheduler event. */
|
|
102
|
+
public putData(
|
|
103
|
+
scheduler: SchedulerInterface
|
|
104
|
+
): Observable<TransportInterface> {
|
|
105
|
+
return this.http
|
|
106
|
+
.put<TransportInterface>(
|
|
107
|
+
this.schedulersUrl,
|
|
108
|
+
JSON.stringify(scheduler),
|
|
109
|
+
this.globalService.jwt()
|
|
110
|
+
)
|
|
111
|
+
.pipe(retry(3), catchError(this.handleError));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Maps client-side network errors and HTTP status codes to a typed Error observable. */
|
|
115
|
+
private handleError(error: HttpErrorResponse): Observable<never> {
|
|
116
|
+
let errorMessage = 'An error occurred';
|
|
117
|
+
|
|
118
|
+
if (error.error instanceof ErrorEvent) {
|
|
119
|
+
// Client-side or network error
|
|
120
|
+
errorMessage = `Client Error: ${error.error.message}`;
|
|
121
|
+
console.error('Client-side error:', error.error.message);
|
|
122
|
+
} else {
|
|
123
|
+
// Backend error
|
|
124
|
+
errorMessage = `Server Error (${error.status}): ${error.message}`;
|
|
125
|
+
console.error(
|
|
126
|
+
`Backend returned code ${error.status}, body:`,
|
|
127
|
+
error.error
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return throwError(() => new Error(errorMessage));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
|
|
2
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
3
|
+
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
|
4
|
+
import { BehaviorSubject, Observable, throwError } from 'rxjs';
|
|
5
|
+
import { catchError, filter, switchMap, take, tap } from 'rxjs/operators';
|
|
6
|
+
import { User } from '../auth/models/index';
|
|
7
|
+
import { NEWPORT_CONFIG } from '../config';
|
|
8
|
+
|
|
9
|
+
@Injectable({ providedIn: 'root' })
|
|
10
|
+
export class AuthStateService {
|
|
11
|
+
private http = inject(HttpClient);
|
|
12
|
+
private config = inject(NEWPORT_CONFIG);
|
|
13
|
+
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
14
|
+
|
|
15
|
+
private isRefreshing = false;
|
|
16
|
+
private refreshTokenSubject = new BehaviorSubject<string | null>(null);
|
|
17
|
+
|
|
18
|
+
/** Returns true if a non-expired JWT token exists in the current session. */
|
|
19
|
+
public isLogged(): boolean {
|
|
20
|
+
const user = this.getCurrentUser();
|
|
21
|
+
if (!user?.token) return false;
|
|
22
|
+
return !this.isTokenExpired(user.token);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Reads and deserialises the current user from sessionStorage; returns null on SSR or parse error. */
|
|
26
|
+
public getCurrentUser(): User | null {
|
|
27
|
+
if (!this.isBrowser) return null;
|
|
28
|
+
try {
|
|
29
|
+
const userStr = sessionStorage.getItem('currentUser');
|
|
30
|
+
return userStr ? JSON.parse(userStr) : null;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error('Error reading currentUser from sessionStorage:', error);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Persists the given user to sessionStorage, or removes the entry when null. */
|
|
38
|
+
public setCurrentUser(currentUser: User | null): void {
|
|
39
|
+
if (!this.isBrowser) return;
|
|
40
|
+
try {
|
|
41
|
+
if (currentUser) {
|
|
42
|
+
sessionStorage.setItem('currentUser', JSON.stringify(currentUser));
|
|
43
|
+
} else {
|
|
44
|
+
sessionStorage.removeItem('currentUser');
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('Error writing currentUser to sessionStorage:', error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Returns the display name of the currently logged-in user, or an empty string. */
|
|
52
|
+
public loggedUser(): string {
|
|
53
|
+
return this.getCurrentUser()?.name ?? '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public jwt(): { headers: HttpHeaders };
|
|
57
|
+
public jwt(fileType: string): { headers: HttpHeaders; reportProgress: boolean; responseType: 'blob' };
|
|
58
|
+
/** Builds the HTTP options object with the Authorization header; adds blob/progress options when fileType is provided. */
|
|
59
|
+
public jwt(fileType?: string): { headers: HttpHeaders; reportProgress?: boolean; responseType?: 'blob' } {
|
|
60
|
+
const headers = this.getHeaders(this.getCurrentUser());
|
|
61
|
+
if (!fileType) {
|
|
62
|
+
return { headers };
|
|
63
|
+
}
|
|
64
|
+
return { headers, reportProgress: true, responseType: 'blob' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Constructs an HttpHeaders object with JSON content-type and, if a user is provided, a Bearer token. */
|
|
68
|
+
public getHeaders(user: User | null): HttpHeaders {
|
|
69
|
+
if (user !== null && user !== undefined) {
|
|
70
|
+
return new HttpHeaders()
|
|
71
|
+
.set('Content-Type', 'application/json')
|
|
72
|
+
.set('Authorization', 'Bearer ' + user.token);
|
|
73
|
+
}
|
|
74
|
+
return new HttpHeaders().set('Content-Type', 'application/json');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Performs the token refresh call against POST {apiUrl}users/refresh.
|
|
79
|
+
* Concurrent callers wait on refreshTokenSubject instead of issuing
|
|
80
|
+
* duplicate refresh requests.
|
|
81
|
+
*/
|
|
82
|
+
public refreshAccessToken(): Observable<string> {
|
|
83
|
+
if (this.isRefreshing) {
|
|
84
|
+
return this.refreshTokenSubject.pipe(
|
|
85
|
+
filter((token): token is string => token !== null),
|
|
86
|
+
take(1)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const user = this.getCurrentUser();
|
|
91
|
+
if (!user?.refreshToken) {
|
|
92
|
+
return throwError(() => new Error('No refresh token available'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.isRefreshing = true;
|
|
96
|
+
this.refreshTokenSubject.next(null);
|
|
97
|
+
|
|
98
|
+
return this.http
|
|
99
|
+
.post<User>(
|
|
100
|
+
`${this.config.apiUrl}users/refresh`,
|
|
101
|
+
{ refreshToken: user.refreshToken },
|
|
102
|
+
{ headers: new HttpHeaders({ 'Content-Type': 'application/json' }) }
|
|
103
|
+
)
|
|
104
|
+
.pipe(
|
|
105
|
+
tap((refreshed) => {
|
|
106
|
+
const updated: User = { ...user, token: refreshed.token, refreshToken: refreshed.refreshToken };
|
|
107
|
+
this.setCurrentUser(updated);
|
|
108
|
+
this.isRefreshing = false;
|
|
109
|
+
this.refreshTokenSubject.next(refreshed.token);
|
|
110
|
+
}),
|
|
111
|
+
switchMap((refreshed) => [refreshed.token]),
|
|
112
|
+
catchError((err) => {
|
|
113
|
+
this.isRefreshing = false;
|
|
114
|
+
this.refreshTokenSubject.next(null);
|
|
115
|
+
return throwError(() => err);
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Returns true if the JWT payload's exp claim is in the past. */
|
|
121
|
+
private isTokenExpired(token: string): boolean {
|
|
122
|
+
try {
|
|
123
|
+
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
124
|
+
return payload.exp ? payload.exp * 1000 < Date.now() : false;
|
|
125
|
+
} catch {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { TestBed } from '@angular/core/testing';
|
|
2
|
+
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
3
|
+
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
|
4
|
+
import { Router } from '@angular/router';
|
|
5
|
+
import { Location } from '@angular/common';
|
|
6
|
+
import { authInterceptor } from './auth.interceptor';
|
|
7
|
+
import { GlobalService } from './global.service';
|
|
8
|
+
import { User } from '../auth/models/index';
|
|
9
|
+
import { NEWPORT_CONFIG } from '../config';
|
|
10
|
+
|
|
11
|
+
const TEST_CONFIG = { apiUrl: 'http://test/', logicFactory: () => {}, projectsInfoLoader: () => Promise.resolve({}) };
|
|
12
|
+
|
|
13
|
+
describe('authInterceptor', () => {
|
|
14
|
+
let http: HttpClient;
|
|
15
|
+
let httpMock: HttpTestingController;
|
|
16
|
+
let gsv: GlobalService;
|
|
17
|
+
|
|
18
|
+
const routerStub = { navigateByUrl: jest.fn().mockResolvedValue(true) };
|
|
19
|
+
const locationStub = { normalize: (url: string) => url };
|
|
20
|
+
|
|
21
|
+
function makeJwt(exp: number): string {
|
|
22
|
+
return `header.${btoa(JSON.stringify({ exp }))}.sig`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function loggedInUser(): User {
|
|
26
|
+
return {
|
|
27
|
+
name: 'Mario',
|
|
28
|
+
token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
|
|
29
|
+
refreshToken: 'valid-refresh-token',
|
|
30
|
+
} as unknown as User;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
localStorage.clear();
|
|
35
|
+
jest.clearAllMocks();
|
|
36
|
+
TestBed.configureTestingModule({
|
|
37
|
+
providers: [
|
|
38
|
+
GlobalService,
|
|
39
|
+
{ provide: Router, useValue: routerStub },
|
|
40
|
+
{ provide: Location, useValue: locationStub },
|
|
41
|
+
{ provide: NEWPORT_CONFIG, useValue: TEST_CONFIG },
|
|
42
|
+
provideHttpClient(withInterceptors([authInterceptor])),
|
|
43
|
+
provideHttpClientTesting(),
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
http = TestBed.inject(HttpClient);
|
|
47
|
+
httpMock = TestBed.inject(HttpTestingController);
|
|
48
|
+
gsv = TestBed.inject(GlobalService);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => httpMock.verify());
|
|
52
|
+
|
|
53
|
+
it('adds Authorization header when user has a valid token', () => {
|
|
54
|
+
gsv.setCurrentUser(loggedInUser());
|
|
55
|
+
|
|
56
|
+
http.get('/api/test').subscribe();
|
|
57
|
+
const req = httpMock.expectOne('/api/test');
|
|
58
|
+
|
|
59
|
+
expect(req.request.headers.get('Authorization')).toMatch(/^Bearer /);
|
|
60
|
+
req.flush({});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not override an existing Authorization header', () => {
|
|
64
|
+
gsv.setCurrentUser(loggedInUser());
|
|
65
|
+
|
|
66
|
+
http.get('/api/test', { headers: { Authorization: 'Bearer custom-token' } }).subscribe();
|
|
67
|
+
const req = httpMock.expectOne('/api/test');
|
|
68
|
+
|
|
69
|
+
expect(req.request.headers.get('Authorization')).toBe('Bearer custom-token');
|
|
70
|
+
req.flush({});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not add Authorization header when no user is logged in', () => {
|
|
74
|
+
http.get('/api/test').subscribe();
|
|
75
|
+
const req = httpMock.expectOne('/api/test');
|
|
76
|
+
|
|
77
|
+
expect(req.request.headers.has('Authorization')).toBe(false);
|
|
78
|
+
req.flush({});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('calls logOff when the server returns 401 and refresh also fails', () => {
|
|
82
|
+
const logOffSpy = jest.spyOn(gsv, 'logOff').mockImplementation(() => {});
|
|
83
|
+
gsv.setCurrentUser(loggedInUser());
|
|
84
|
+
|
|
85
|
+
http.get('/api/test').subscribe({ error: () => {} });
|
|
86
|
+
httpMock.expectOne('/api/test').flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
|
|
87
|
+
httpMock.expectOne('http://test/users/refresh').flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
|
|
88
|
+
|
|
89
|
+
expect(logOffSpy).toHaveBeenCalledTimes(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('calls logOff when 401 occurs and no refresh token is stored', () => {
|
|
93
|
+
const logOffSpy = jest.spyOn(gsv, 'logOff').mockImplementation(() => {});
|
|
94
|
+
|
|
95
|
+
http.get('/api/test').subscribe({ error: () => {} });
|
|
96
|
+
httpMock.expectOne('/api/test').flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
|
|
97
|
+
|
|
98
|
+
expect(logOffSpy).toHaveBeenCalledTimes(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not call logOff for 500 errors', () => {
|
|
102
|
+
const logOffSpy = jest.spyOn(gsv, 'logOff').mockImplementation(() => {});
|
|
103
|
+
|
|
104
|
+
http.get('/api/test').subscribe({ error: () => {} });
|
|
105
|
+
const req = httpMock.expectOne('/api/test');
|
|
106
|
+
req.flush('Server error', { status: 500, statusText: 'Internal Server Error' });
|
|
107
|
+
|
|
108
|
+
expect(logOffSpy).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('retries the original request with the new token after a successful refresh', done => {
|
|
112
|
+
const user = loggedInUser();
|
|
113
|
+
gsv.setCurrentUser(user);
|
|
114
|
+
|
|
115
|
+
http.get('/api/test').subscribe({
|
|
116
|
+
next: (data) => {
|
|
117
|
+
expect(data).toEqual({ ok: true });
|
|
118
|
+
done();
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
httpMock.expectOne('/api/test').flush('', { status: 401, statusText: 'Unauthorized' });
|
|
123
|
+
httpMock.expectOne('http://test/users/refresh').flush({
|
|
124
|
+
token: makeJwt(Math.floor(Date.now() / 1000) + 7200),
|
|
125
|
+
refreshToken: 'new-refresh-token',
|
|
126
|
+
});
|
|
127
|
+
httpMock.expectOne('/api/test').flush({ ok: true });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('re-throws an error after a 401 when refresh also fails', done => {
|
|
131
|
+
gsv.setCurrentUser(loggedInUser());
|
|
132
|
+
jest.spyOn(gsv, 'logOff').mockImplementation(() => {});
|
|
133
|
+
|
|
134
|
+
http.get('/api/test').subscribe({
|
|
135
|
+
error: err => {
|
|
136
|
+
expect(err).toBeTruthy();
|
|
137
|
+
done();
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
httpMock.expectOne('/api/test').flush('', { status: 401, statusText: 'Unauthorized' });
|
|
142
|
+
httpMock.expectOne('http://test/users/refresh').flush('', { status: 401, statusText: 'Unauthorized' });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
|
2
|
+
import { inject } from '@angular/core';
|
|
3
|
+
import { catchError, switchMap, throwError } from 'rxjs';
|
|
4
|
+
import { GlobalService } from './global.service';
|
|
5
|
+
import { AuthStateService } from './auth-state.service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Attaches the JWT Bearer token to every outgoing request.
|
|
9
|
+
* On 401, attempts a single token refresh via POST users/refresh and retries
|
|
10
|
+
* the original request. If the refresh also fails the user is logged out.
|
|
11
|
+
* Concurrent 401s are queued by AuthStateService and resolved together.
|
|
12
|
+
*/
|
|
13
|
+
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|
14
|
+
const gsv = inject(GlobalService);
|
|
15
|
+
const authState = inject(AuthStateService);
|
|
16
|
+
|
|
17
|
+
const isRefreshCall = req.url.includes('users/refresh');
|
|
18
|
+
|
|
19
|
+
let authReq = req;
|
|
20
|
+
if (!req.headers.has('Authorization') && !isRefreshCall) {
|
|
21
|
+
const user = gsv.getCurrentUser();
|
|
22
|
+
if (user?.token) {
|
|
23
|
+
authReq = req.clone({ setHeaders: { Authorization: `Bearer ${user.token}` } });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return next(authReq).pipe(
|
|
28
|
+
catchError((error: HttpErrorResponse) => {
|
|
29
|
+
if (error.status === 401 && !isRefreshCall) {
|
|
30
|
+
return authState.refreshAccessToken().pipe(
|
|
31
|
+
switchMap((newToken) => {
|
|
32
|
+
const retryReq = req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } });
|
|
33
|
+
return next(retryReq);
|
|
34
|
+
}),
|
|
35
|
+
catchError((refreshError) => {
|
|
36
|
+
gsv.logOff();
|
|
37
|
+
return throwError(() => refreshError);
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return throwError(() => error);
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
};
|