mesauth-angular 1.1.9 → 1.2.0
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/README.md +6 -0
- package/dist/README.md +6 -0
- package/dist/fesm2022/mesauth-angular.mjs +30 -67
- package/dist/fesm2022/mesauth-angular.mjs.map +1 -1
- package/dist/index.d.ts +6 -3
- package/mesauth-angular-1.0.0.tgz +0 -0
- package/mesauth-angular-1.0.1.tgz +0 -0
- package/mesauth-angular-1.1.0.tgz +0 -0
- package/ng-package.json +8 -0
- package/package.json +1 -6
- package/src/index.ts +10 -0
- package/src/ma-user.component.ts +39 -0
- package/src/mes-auth.interceptor.ts +59 -0
- package/src/mes-auth.module.ts +9 -0
- package/src/mes-auth.service.ts +406 -0
- package/src/notification-badge.component.ts +125 -0
- package/src/notification-panel.component.ts +672 -0
- package/src/theme.service.ts +70 -0
- package/src/toast-container.component.ts +221 -0
- package/src/toast.service.ts +47 -0
- package/src/user-profile.component.ts +449 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { inject } from '@angular/core';
|
|
2
|
+
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
|
3
|
+
import { throwError } from 'rxjs';
|
|
4
|
+
import { catchError } from 'rxjs/operators';
|
|
5
|
+
import { Router } from '@angular/router';
|
|
6
|
+
import { MesAuthService } from './mes-auth.service';
|
|
7
|
+
|
|
8
|
+
// Track if we're currently redirecting to prevent loopback
|
|
9
|
+
let isRedirecting = false;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Functional HTTP interceptor for handling 401/403 auth errors.
|
|
13
|
+
* Redirects to login page on 401, and to 403 page on 403.
|
|
14
|
+
* Includes loopback prevention to avoid infinite redirects.
|
|
15
|
+
*/
|
|
16
|
+
export const mesAuthInterceptor: HttpInterceptorFn = (req, next) => {
|
|
17
|
+
const authService = inject(MesAuthService);
|
|
18
|
+
const router = inject(Router);
|
|
19
|
+
|
|
20
|
+
return next(req).pipe(
|
|
21
|
+
catchError((error: HttpErrorResponse) => {
|
|
22
|
+
const status = error.status;
|
|
23
|
+
|
|
24
|
+
// Check if we should handle this error and prevent loopback
|
|
25
|
+
if ((status === 401 || status === 403) && !isRedirecting) {
|
|
26
|
+
const config = authService.getConfig();
|
|
27
|
+
const baseUrl = config?.userBaseUrl || '';
|
|
28
|
+
|
|
29
|
+
const currentUrl = router.url + (window.location.hash || '');
|
|
30
|
+
const returnUrl = encodeURIComponent(currentUrl);
|
|
31
|
+
|
|
32
|
+
// Avoid loops if already on auth/unauth pages
|
|
33
|
+
const isLoginPage = currentUrl.includes('/login');
|
|
34
|
+
const is403Page = currentUrl.includes('/403');
|
|
35
|
+
const isAuthPage = currentUrl.includes('/auth');
|
|
36
|
+
// Skip redirect for the initial /auth/me check (app startup when not logged in)
|
|
37
|
+
const isMeAuthPage = req.url.includes('/auth/me');
|
|
38
|
+
|
|
39
|
+
if (status === 401 && !isLoginPage && !isAuthPage && !isMeAuthPage) {
|
|
40
|
+
// Session expired or not authenticated - redirect to login
|
|
41
|
+
// No isAuthenticated check: when session expires, BehaviorSubject still holds
|
|
42
|
+
// stale user data, so checking isAuthenticated would block the redirect.
|
|
43
|
+
isRedirecting = true;
|
|
44
|
+
setTimeout(() => { isRedirecting = false; }, 5000);
|
|
45
|
+
window.location.href = `${baseUrl}/login?returnUrl=${returnUrl}`;
|
|
46
|
+
} else if (status === 403 && !is403Page) {
|
|
47
|
+
isRedirecting = true;
|
|
48
|
+
setTimeout(() => { isRedirecting = false; }, 5000);
|
|
49
|
+
let redirectUrl = `${baseUrl}/403?returnUrl=${returnUrl}`;
|
|
50
|
+
if (error.error && error.error.required) {
|
|
51
|
+
redirectUrl += `&required=${encodeURIComponent(error.error.required)}`;
|
|
52
|
+
}
|
|
53
|
+
window.location.href = redirectUrl;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return throwError(() => error);
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
};
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { inject, Injectable, InjectionToken, EnvironmentProviders, makeEnvironmentProviders, provideAppInitializer } from '@angular/core';
|
|
2
|
+
import { HttpClient } from '@angular/common/http';
|
|
3
|
+
import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
|
|
4
|
+
import { BehaviorSubject, Subject, Observable, of, EMPTY } from 'rxjs';
|
|
5
|
+
import { tap, catchError } from 'rxjs/operators';
|
|
6
|
+
import { Router } from '@angular/router';
|
|
7
|
+
|
|
8
|
+
export interface MesAuthConfig {
|
|
9
|
+
apiBaseUrl: string;
|
|
10
|
+
withCredentials?: boolean;
|
|
11
|
+
userBaseUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Injection token for MesAuth configuration */
|
|
15
|
+
export const MES_AUTH_CONFIG = new InjectionToken<MesAuthConfig>('MES_AUTH_CONFIG');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Provides MesAuth with configuration.
|
|
19
|
+
* This is the recommended way to set up mesauth-angular in standalone apps.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* // app.config.ts
|
|
24
|
+
* export const appConfig: ApplicationConfig = {
|
|
25
|
+
* providers: [
|
|
26
|
+
* provideHttpClient(withInterceptors([mesAuthInterceptor])),
|
|
27
|
+
* provideMesAuth({
|
|
28
|
+
* apiBaseUrl: 'https://auth.example.com',
|
|
29
|
+
* userBaseUrl: 'https://app.example.com'
|
|
30
|
+
* })
|
|
31
|
+
* ]
|
|
32
|
+
* };
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function provideMesAuth(config: MesAuthConfig): EnvironmentProviders {
|
|
36
|
+
return makeEnvironmentProviders([
|
|
37
|
+
{ provide: MES_AUTH_CONFIG, useValue: config },
|
|
38
|
+
MesAuthService,
|
|
39
|
+
provideAppInitializer(() => {
|
|
40
|
+
const mesAuthService = inject(MesAuthService);
|
|
41
|
+
const httpClient = inject(HttpClient);
|
|
42
|
+
const router = inject(Router);
|
|
43
|
+
mesAuthService.init(config, httpClient, router);
|
|
44
|
+
})
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface IUser {
|
|
49
|
+
userId?: string;
|
|
50
|
+
userName?: string;
|
|
51
|
+
fullName?: string;
|
|
52
|
+
gender?: string;
|
|
53
|
+
email?: string;
|
|
54
|
+
phoneNumber?: string;
|
|
55
|
+
department?: string;
|
|
56
|
+
position?: string;
|
|
57
|
+
tokenVersion?: string;
|
|
58
|
+
permEndpoint?: string;
|
|
59
|
+
perms?: Set<string>;
|
|
60
|
+
employeeCode?: string;
|
|
61
|
+
avatarPath?: string;
|
|
62
|
+
loginMethod?: number;
|
|
63
|
+
hrFullNameVn?: string;
|
|
64
|
+
hrFullNameEn?: string;
|
|
65
|
+
hrPosition?: string;
|
|
66
|
+
hrJobTitle?: string;
|
|
67
|
+
hrGender?: string;
|
|
68
|
+
hrMobile?: string;
|
|
69
|
+
hrEmail?: string;
|
|
70
|
+
hrJoinDate?: string;
|
|
71
|
+
hrBirthDate?: string;
|
|
72
|
+
hrWorkStatus?: string;
|
|
73
|
+
hrDoiTuong?: string;
|
|
74
|
+
hrTeamCode?: string;
|
|
75
|
+
hrLineCode?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export enum NotificationType {
|
|
79
|
+
Info = 'Info',
|
|
80
|
+
Warning = 'Warning',
|
|
81
|
+
Error = 'Error',
|
|
82
|
+
Success = 'Success'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface NotificationDto {
|
|
86
|
+
id: string;
|
|
87
|
+
title: string;
|
|
88
|
+
message: string;
|
|
89
|
+
messageHtml?: string;
|
|
90
|
+
url?: string;
|
|
91
|
+
type: NotificationType;
|
|
92
|
+
isRead: boolean;
|
|
93
|
+
createdAt: string;
|
|
94
|
+
sourceAppName: string;
|
|
95
|
+
sourceAppIconUrl?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface FrontEndRoute {
|
|
99
|
+
id: number;
|
|
100
|
+
roleId: string;
|
|
101
|
+
roleName: string;
|
|
102
|
+
routePath: string;
|
|
103
|
+
routeName: string;
|
|
104
|
+
description?: string;
|
|
105
|
+
icon?: string;
|
|
106
|
+
cssClass?: string;
|
|
107
|
+
parentId?: number | null;
|
|
108
|
+
sortOrder: number;
|
|
109
|
+
isLabel: boolean;
|
|
110
|
+
isActive: boolean;
|
|
111
|
+
createdAt: string;
|
|
112
|
+
updatedAt?: string;
|
|
113
|
+
children: FrontEndRoute[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface UserFrontEndRoutesGrouped {
|
|
117
|
+
appId: string;
|
|
118
|
+
appName: string;
|
|
119
|
+
feUrl?: string;
|
|
120
|
+
routes: FrontEndRoute[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface FrontEndRouteMaster {
|
|
124
|
+
id: number;
|
|
125
|
+
appId: string;
|
|
126
|
+
routePath: string;
|
|
127
|
+
routeName: string;
|
|
128
|
+
description?: string;
|
|
129
|
+
icon?: string;
|
|
130
|
+
cssClass?: string;
|
|
131
|
+
parentId?: number | null;
|
|
132
|
+
sortOrder: number;
|
|
133
|
+
isLabel: boolean;
|
|
134
|
+
isActive: boolean;
|
|
135
|
+
createdAt: string;
|
|
136
|
+
updatedAt?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface CreateFrontEndRouteDto {
|
|
140
|
+
routePath: string;
|
|
141
|
+
routeName: string;
|
|
142
|
+
description?: string;
|
|
143
|
+
icon?: string;
|
|
144
|
+
cssClass?: string;
|
|
145
|
+
parentId?: number | null;
|
|
146
|
+
sortOrder?: number;
|
|
147
|
+
isLabel?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface PagedList<T> {
|
|
151
|
+
items: T[];
|
|
152
|
+
totalCount: number;
|
|
153
|
+
page: number;
|
|
154
|
+
pageSize: number;
|
|
155
|
+
totalPages: number;
|
|
156
|
+
hasNext: boolean;
|
|
157
|
+
hasPrevious: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface RealTimeNotificationDto {
|
|
161
|
+
id: string;
|
|
162
|
+
title: string;
|
|
163
|
+
message: string;
|
|
164
|
+
messageHtml?: string;
|
|
165
|
+
url?: string;
|
|
166
|
+
type: NotificationType;
|
|
167
|
+
createdAt: string;
|
|
168
|
+
sourceAppName: string;
|
|
169
|
+
sourceAppIconUrl?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@Injectable()
|
|
173
|
+
export class MesAuthService {
|
|
174
|
+
private hubConnection: HubConnection | null = null;
|
|
175
|
+
private _currentUser = new BehaviorSubject<IUser | null>(null);
|
|
176
|
+
public currentUser$: Observable<IUser | null> = this._currentUser.asObservable();
|
|
177
|
+
private _notifications = new Subject<any>();
|
|
178
|
+
public notifications$: Observable<any> = this._notifications.asObservable();
|
|
179
|
+
|
|
180
|
+
private apiBase = '';
|
|
181
|
+
private config: MesAuthConfig | null = null;
|
|
182
|
+
private http!: HttpClient;
|
|
183
|
+
private router?: Router;
|
|
184
|
+
|
|
185
|
+
constructor() {
|
|
186
|
+
// Empty constructor - all dependencies passed to init()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
init(config: MesAuthConfig, httpClient: HttpClient, router?: Router) {
|
|
190
|
+
this.config = config;
|
|
191
|
+
this.http = httpClient;
|
|
192
|
+
this.router = router;
|
|
193
|
+
this.apiBase = config.apiBaseUrl.replace(/\/$/, '');
|
|
194
|
+
|
|
195
|
+
// Fetch user once on init. Route changes do NOT re-fetch the user.
|
|
196
|
+
// Auth state is maintained via cookies; 401 errors are handled by HTTP interceptors.
|
|
197
|
+
// SignalR handles real-time notification delivery without polling.
|
|
198
|
+
this.fetchCurrentUser().subscribe();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
getConfig(): MesAuthConfig | null {
|
|
202
|
+
return this.config;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private fetchCurrentUser(): Observable<any> {
|
|
206
|
+
if (!this.apiBase) return EMPTY;
|
|
207
|
+
const url = `${this.apiBase}/auth/me`;
|
|
208
|
+
return this.http.get(url, { withCredentials: this.config?.withCredentials ?? true }).pipe(
|
|
209
|
+
tap((u) => {
|
|
210
|
+
this._currentUser.next(u);
|
|
211
|
+
if (u && this.config) {
|
|
212
|
+
this.startConnection(this.config);
|
|
213
|
+
}
|
|
214
|
+
}),
|
|
215
|
+
catchError((err) => {
|
|
216
|
+
// Silently handle auth errors (401/403) - user is not logged in
|
|
217
|
+
if (err.status === 401 || err.status === 403) {
|
|
218
|
+
this._currentUser.next(null);
|
|
219
|
+
}
|
|
220
|
+
return of(null);
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
public getUnreadCount(): Observable<any> {
|
|
226
|
+
return this.http.get(`${this.apiBase}/notif/me/unread-count`, { withCredentials: this.config?.withCredentials ?? true });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
public getNotifications(page: number = 1, pageSize: number = 20, includeRead: boolean = false, type?: string): Observable<any> {
|
|
230
|
+
let url = `${this.apiBase}/notif/me?page=${page}&pageSize=${pageSize}&includeRead=${includeRead}`;
|
|
231
|
+
if (type) {
|
|
232
|
+
url += `&type=${type}`;
|
|
233
|
+
}
|
|
234
|
+
return this.http.get(url, { withCredentials: this.config?.withCredentials ?? true });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
public markAsRead(notificationId: string): Observable<any> {
|
|
238
|
+
return this.http.patch(`${this.apiBase}/notif/${notificationId}/read`, {}, { withCredentials: this.config?.withCredentials ?? true });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
public markAllAsRead(): Observable<any> {
|
|
242
|
+
return this.http.patch(`${this.apiBase}/notif/me/read-all`, {}, { withCredentials: this.config?.withCredentials ?? true });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
public deleteNotification(notificationId: string): Observable<any> {
|
|
246
|
+
return this.http.delete(`${this.apiBase}/notif/${notificationId}`, { withCredentials: this.config?.withCredentials ?? true });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get frontend routes assigned to the current user
|
|
251
|
+
* Returns routes grouped by application
|
|
252
|
+
*/
|
|
253
|
+
public getFrontEndRoutes(): Observable<UserFrontEndRoutesGrouped[]> {
|
|
254
|
+
if (!this.apiBase) throw new Error('MesAuth not initialized');
|
|
255
|
+
return this.http.get<UserFrontEndRoutesGrouped[]>(`${this.apiBase}/fe-routes/me`, { withCredentials: this.config?.withCredentials ?? true });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get master routes for a specific application
|
|
260
|
+
* @param appId - The application ID
|
|
261
|
+
*/
|
|
262
|
+
public getRouteMasters(appId: string): Observable<FrontEndRouteMaster[]> {
|
|
263
|
+
if (!this.apiBase) throw new Error('MesAuth not initialized');
|
|
264
|
+
return this.http.get<FrontEndRouteMaster[]>(`${this.apiBase}/fe-routes/masters/${appId}`, { withCredentials: this.config?.withCredentials ?? true });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Register/sync frontend routes for an application
|
|
269
|
+
* This is typically called on app startup to sync routes from the frontend app
|
|
270
|
+
* @param appId - The application ID (passed via X-App-Id header)
|
|
271
|
+
* @param routes - Array of route definitions
|
|
272
|
+
*/
|
|
273
|
+
public registerFrontEndRoutes(appId: string, routes: CreateFrontEndRouteDto[]): Observable<any> {
|
|
274
|
+
if (!this.apiBase) throw new Error('MesAuth not initialized');
|
|
275
|
+
const headers = { 'X-App-Id': appId };
|
|
276
|
+
return this.http.post(`${this.apiBase}/fe-routes/register`, routes, {
|
|
277
|
+
headers,
|
|
278
|
+
withCredentials: this.config?.withCredentials ?? true
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Create a new route master
|
|
284
|
+
* @param appId - The application ID
|
|
285
|
+
* @param route - Route details
|
|
286
|
+
*/
|
|
287
|
+
public createRouteMaster(appId: string, route: CreateFrontEndRouteDto): Observable<FrontEndRouteMaster> {
|
|
288
|
+
if (!this.apiBase) throw new Error('MesAuth not initialized');
|
|
289
|
+
return this.http.post<FrontEndRouteMaster>(`${this.apiBase}/fe-routes/masters`, {
|
|
290
|
+
appId,
|
|
291
|
+
...route
|
|
292
|
+
}, { withCredentials: this.config?.withCredentials ?? true });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Update an existing route master
|
|
297
|
+
* @param routeId - The route master ID
|
|
298
|
+
* @param route - Updated route details
|
|
299
|
+
*/
|
|
300
|
+
public updateRouteMaster(routeId: number, route: Partial<CreateFrontEndRouteDto> & { isActive?: boolean }): Observable<FrontEndRouteMaster> {
|
|
301
|
+
if (!this.apiBase) throw new Error('MesAuth not initialized');
|
|
302
|
+
return this.http.put<FrontEndRouteMaster>(`${this.apiBase}/fe-routes/masters/${routeId}`, route, {
|
|
303
|
+
withCredentials: this.config?.withCredentials ?? true
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Delete a route master
|
|
309
|
+
* @param routeId - The route master ID
|
|
310
|
+
*/
|
|
311
|
+
public deleteRouteMaster(routeId: number): Observable<any> {
|
|
312
|
+
if (!this.apiBase) throw new Error('MesAuth not initialized');
|
|
313
|
+
return this.http.delete(`${this.apiBase}/fe-routes/masters/${routeId}`, {
|
|
314
|
+
withCredentials: this.config?.withCredentials ?? true
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Assign a route to a role
|
|
320
|
+
* @param routeMasterId - The route master ID
|
|
321
|
+
* @param roleId - The role ID (GUID)
|
|
322
|
+
*/
|
|
323
|
+
public assignRouteToRole(routeMasterId: number, roleId: string): Observable<any> {
|
|
324
|
+
if (!this.apiBase) throw new Error('MesAuth not initialized');
|
|
325
|
+
return this.http.post(`${this.apiBase}/fe-routes/mappings`, {
|
|
326
|
+
routeMasterId,
|
|
327
|
+
roleId
|
|
328
|
+
}, { withCredentials: this.config?.withCredentials ?? true });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Remove a route assignment from a role
|
|
333
|
+
* @param mappingId - The mapping ID
|
|
334
|
+
*/
|
|
335
|
+
public removeRouteFromRole(mappingId: number): Observable<any> {
|
|
336
|
+
if (!this.apiBase) throw new Error('MesAuth not initialized');
|
|
337
|
+
return this.http.delete(`${this.apiBase}/fe-routes/mappings/${mappingId}`, {
|
|
338
|
+
withCredentials: this.config?.withCredentials ?? true
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Get route-to-role mappings for a specific role
|
|
344
|
+
* @param roleId - The role ID (GUID)
|
|
345
|
+
*/
|
|
346
|
+
public getRouteMappingsByRole(roleId: string): Observable<any[]> {
|
|
347
|
+
if (!this.apiBase) throw new Error('MesAuth not initialized');
|
|
348
|
+
return this.http.get<any[]>(`${this.apiBase}/fe-routes/mappings?roleId=${roleId}`, {
|
|
349
|
+
withCredentials: this.config?.withCredentials ?? true
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private startConnection(config: MesAuthConfig) {
|
|
354
|
+
if (this.hubConnection) return;
|
|
355
|
+
const signalrUrl = config.apiBaseUrl.replace(/\/$/, '') + '/hub/notification';
|
|
356
|
+
const builder = new HubConnectionBuilder()
|
|
357
|
+
.withUrl(signalrUrl, { withCredentials: config.withCredentials ?? true })
|
|
358
|
+
.withAutomaticReconnect()
|
|
359
|
+
.configureLogging(LogLevel.Warning);
|
|
360
|
+
|
|
361
|
+
this.hubConnection = builder.build();
|
|
362
|
+
|
|
363
|
+
this.hubConnection.on('ReceiveNotification', (n: any) => {
|
|
364
|
+
this._notifications.next(n);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
this.hubConnection.start().then(() => {}).catch((err) => {});
|
|
368
|
+
|
|
369
|
+
this.hubConnection.onclose(() => {});
|
|
370
|
+
this.hubConnection.onreconnecting(() => {});
|
|
371
|
+
this.hubConnection.onreconnected(() => {});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
public stop() {
|
|
375
|
+
if (!this.hubConnection) return;
|
|
376
|
+
this.hubConnection.stop().catch(() => {});
|
|
377
|
+
this.hubConnection = null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
public logout(): Observable<any> {
|
|
381
|
+
const url = `${this.apiBase}/auth/logout`;
|
|
382
|
+
return this.http.post(url, {}, { withCredentials: this.config?.withCredentials ?? true }).pipe(
|
|
383
|
+
tap(() => {
|
|
384
|
+
this._currentUser.next(null);
|
|
385
|
+
this.stop();
|
|
386
|
+
})
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
public get currentUser(): IUser | null {
|
|
391
|
+
return this._currentUser.value;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
public get isAuthenticated(): boolean {
|
|
395
|
+
return this._currentUser.value !== null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Refreshes the current user from the server.
|
|
400
|
+
* Returns an Observable that completes when the user data is loaded.
|
|
401
|
+
* Callers can subscribe to wait for completion before proceeding (e.g., navigating after login).
|
|
402
|
+
*/
|
|
403
|
+
public refreshUser(): Observable<any> {
|
|
404
|
+
return this.fetchCurrentUser();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Component, OnInit, OnDestroy, Output, EventEmitter, HostBinding } from '@angular/core';
|
|
2
|
+
import { NgIf } from '@angular/common';
|
|
3
|
+
import { MesAuthService } from './mes-auth.service';
|
|
4
|
+
import { ThemeService, Theme } from './theme.service';
|
|
5
|
+
import { Subject } from 'rxjs';
|
|
6
|
+
import { takeUntil } from 'rxjs/operators';
|
|
7
|
+
|
|
8
|
+
@Component({
|
|
9
|
+
selector: 'ma-notification-badge',
|
|
10
|
+
standalone: true,
|
|
11
|
+
imports: [NgIf],
|
|
12
|
+
template: `
|
|
13
|
+
<button class="notification-btn" (click)="onNotificationClick()" title="Notifications">
|
|
14
|
+
<span class="icon">🔔</span>
|
|
15
|
+
<span class="badge" *ngIf="unreadCount > 0">{{ unreadCount }}</span>
|
|
16
|
+
</button>
|
|
17
|
+
`,
|
|
18
|
+
styles: [`
|
|
19
|
+
:host {
|
|
20
|
+
--error-color: #f44336;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
:host(.theme-dark) {
|
|
24
|
+
--error-color: #ef5350;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.notification-btn {
|
|
28
|
+
position: relative;
|
|
29
|
+
background: none;
|
|
30
|
+
border: none;
|
|
31
|
+
font-size: 24px;
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
padding: 8px;
|
|
34
|
+
transition: opacity 0.2s;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.notification-btn:hover {
|
|
38
|
+
opacity: 0.7;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.icon {
|
|
42
|
+
display: inline-block;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.badge {
|
|
46
|
+
position: absolute;
|
|
47
|
+
top: 0;
|
|
48
|
+
right: 0;
|
|
49
|
+
background-color: var(--error-color);
|
|
50
|
+
color: white;
|
|
51
|
+
border-radius: 50%;
|
|
52
|
+
width: 20px;
|
|
53
|
+
height: 20px;
|
|
54
|
+
display: flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
justify-content: center;
|
|
57
|
+
font-size: 12px;
|
|
58
|
+
font-weight: bold;
|
|
59
|
+
}
|
|
60
|
+
`]
|
|
61
|
+
})
|
|
62
|
+
export class NotificationBadgeComponent implements OnInit, OnDestroy {
|
|
63
|
+
@Output() notificationClick = new EventEmitter<void>();
|
|
64
|
+
@HostBinding('class') get themeClass(): string {
|
|
65
|
+
return `theme-${this.currentTheme}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
unreadCount = 0;
|
|
69
|
+
currentTheme: Theme = 'light';
|
|
70
|
+
private hasUser = false;
|
|
71
|
+
private destroy$ = new Subject<void>();
|
|
72
|
+
|
|
73
|
+
constructor(private authService: MesAuthService, private themeService: ThemeService) {}
|
|
74
|
+
|
|
75
|
+
ngOnInit() {
|
|
76
|
+
this.themeService.currentTheme$
|
|
77
|
+
.pipe(takeUntil(this.destroy$))
|
|
78
|
+
.subscribe(theme => {
|
|
79
|
+
this.currentTheme = theme;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this.authService.currentUser$
|
|
83
|
+
.pipe(takeUntil(this.destroy$))
|
|
84
|
+
.subscribe(user => {
|
|
85
|
+
this.hasUser = !!user;
|
|
86
|
+
if (!this.hasUser) {
|
|
87
|
+
this.unreadCount = 0;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.loadUnreadCount();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Listen for new notifications
|
|
94
|
+
this.authService.notifications$
|
|
95
|
+
.pipe(takeUntil(this.destroy$))
|
|
96
|
+
.subscribe(() => {
|
|
97
|
+
if (this.hasUser) {
|
|
98
|
+
this.loadUnreadCount();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
ngOnDestroy() {
|
|
104
|
+
this.destroy$.next();
|
|
105
|
+
this.destroy$.complete();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private loadUnreadCount() {
|
|
109
|
+
if (!this.hasUser) {
|
|
110
|
+
this.unreadCount = 0;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.authService.getUnreadCount().subscribe({
|
|
115
|
+
next: (response: any) => {
|
|
116
|
+
this.unreadCount = response.unreadCount || 0;
|
|
117
|
+
},
|
|
118
|
+
error: (err) => console.error('Error loading unread count:', err)
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
onNotificationClick() {
|
|
123
|
+
this.notificationClick.emit();
|
|
124
|
+
}
|
|
125
|
+
}
|