@noatgnu/cupcake-core 1.0.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.
@@ -0,0 +1,2684 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, inject, Injectable, signal, computed, Component, effect, NgModule } from '@angular/core';
3
+ import * as i1 from '@angular/common/http';
4
+ import { HttpClient, HttpParams, provideHttpClient, withInterceptors, HttpClientModule } from '@angular/common/http';
5
+ import { BehaviorSubject, map, tap, throwError, catchError, take, switchMap, filter, debounceTime, distinctUntilChanged } from 'rxjs';
6
+ import { map as map$1 } from 'rxjs/operators';
7
+ import { Router, ActivatedRoute, RouterModule } from '@angular/router';
8
+ import * as i1$1 from '@angular/forms';
9
+ import { FormBuilder, Validators, ReactiveFormsModule, FormsModule, NonNullableFormBuilder } from '@angular/forms';
10
+ import * as i2 from '@angular/common';
11
+ import { CommonModule } from '@angular/common';
12
+ import * as i2$1 from '@ng-bootstrap/ng-bootstrap';
13
+ import { NgbAlert, NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap';
14
+ import * as i2$2 from 'ngx-color/sketch';
15
+ import { ColorSketchModule } from 'ngx-color/sketch';
16
+
17
+ var ResourceType;
18
+ (function (ResourceType) {
19
+ ResourceType["METADATA_TABLE"] = "metadata_table";
20
+ ResourceType["METADATA_TABLE_TEMPLATE"] = "metadata_table_template";
21
+ ResourceType["METADATA_COLUMN_TEMPLATE"] = "metadata_column_template";
22
+ ResourceType["FILE"] = "file";
23
+ ResourceType["DATASET"] = "dataset";
24
+ ResourceType["SCHEMA"] = "schema";
25
+ })(ResourceType || (ResourceType = {}));
26
+ const ResourceTypeLabels = {
27
+ [ResourceType.METADATA_TABLE]: 'Metadata Table',
28
+ [ResourceType.METADATA_TABLE_TEMPLATE]: 'Metadata Table Template',
29
+ [ResourceType.METADATA_COLUMN_TEMPLATE]: 'Metadata Column Template',
30
+ [ResourceType.FILE]: 'File',
31
+ [ResourceType.DATASET]: 'Dataset',
32
+ [ResourceType.SCHEMA]: 'Schema'
33
+ };
34
+ var ResourceVisibility;
35
+ (function (ResourceVisibility) {
36
+ ResourceVisibility["PRIVATE"] = "private";
37
+ ResourceVisibility["GROUP"] = "group";
38
+ ResourceVisibility["PUBLIC"] = "public";
39
+ })(ResourceVisibility || (ResourceVisibility = {}));
40
+ const ResourceVisibilityLabels = {
41
+ [ResourceVisibility.PRIVATE]: 'Private',
42
+ [ResourceVisibility.GROUP]: 'Lab Group',
43
+ [ResourceVisibility.PUBLIC]: 'Public'
44
+ };
45
+ var ResourceRole;
46
+ (function (ResourceRole) {
47
+ ResourceRole["OWNER"] = "owner";
48
+ ResourceRole["ADMIN"] = "admin";
49
+ ResourceRole["EDITOR"] = "editor";
50
+ ResourceRole["VIEWER"] = "viewer";
51
+ })(ResourceRole || (ResourceRole = {}));
52
+ const ResourceRoleLabels = {
53
+ [ResourceRole.OWNER]: 'Owner',
54
+ [ResourceRole.ADMIN]: 'Administrator',
55
+ [ResourceRole.EDITOR]: 'Editor',
56
+ [ResourceRole.VIEWER]: 'Viewer'
57
+ };
58
+ var InvitationStatus;
59
+ (function (InvitationStatus) {
60
+ InvitationStatus["PENDING"] = "pending";
61
+ InvitationStatus["ACCEPTED"] = "accepted";
62
+ InvitationStatus["REJECTED"] = "rejected";
63
+ InvitationStatus["EXPIRED"] = "expired";
64
+ InvitationStatus["CANCELLED"] = "cancelled";
65
+ })(InvitationStatus || (InvitationStatus = {}));
66
+ const InvitationStatusLabels = {
67
+ [InvitationStatus.PENDING]: 'Pending',
68
+ [InvitationStatus.ACCEPTED]: 'Accepted',
69
+ [InvitationStatus.REJECTED]: 'Rejected',
70
+ [InvitationStatus.EXPIRED]: 'Expired',
71
+ [InvitationStatus.CANCELLED]: 'Cancelled'
72
+ };
73
+
74
+ /**
75
+ * CUPCAKE Core (CCC) - Models barrel export
76
+ * User management, lab groups, and core functionality interfaces
77
+ */
78
+ // Base types and enums
79
+
80
+ const CUPCAKE_CORE_CONFIG = new InjectionToken('CUPCAKE_CORE_CONFIG');
81
+ class AuthService {
82
+ http = inject(HttpClient);
83
+ config = inject(CUPCAKE_CORE_CONFIG);
84
+ apiUrl = this.config.apiUrl;
85
+ currentUserSubject = new BehaviorSubject(null);
86
+ currentUser$ = this.currentUserSubject.asObservable();
87
+ isAuthenticatedSubject = new BehaviorSubject(this.hasValidTokenOnInit());
88
+ isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
89
+ constructor() {
90
+ this.initializeAuthState();
91
+ if (typeof window !== 'undefined') {
92
+ window.addEventListener('tokenRefreshed', () => {
93
+ this.updateAuthStateAfterRefresh();
94
+ });
95
+ window.addEventListener('authCleared', () => {
96
+ this.clearAuthData();
97
+ });
98
+ }
99
+ }
100
+ hasValidTokenOnInit() {
101
+ if (typeof window === 'undefined')
102
+ return false;
103
+ const token = localStorage.getItem('ccvAccessToken');
104
+ if (!token)
105
+ return false;
106
+ try {
107
+ const payload = JSON.parse(atob(token.split('.')[1]));
108
+ const now = Math.floor(Date.now() / 1000);
109
+ return payload.exp > now;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
115
+ initializeAuthState() {
116
+ const token = this.getAccessToken();
117
+ const refreshToken = this.getRefreshToken();
118
+ if (token && !this.isTokenExpired(token)) {
119
+ this.isAuthenticatedSubject.next(true);
120
+ // Try to get basic user info from token first (for immediate display)
121
+ const user = this.getUserFromToken();
122
+ if (user) {
123
+ this.currentUserSubject.next(user);
124
+ }
125
+ // Then fetch complete user profile from backend to ensure we have latest data
126
+ this.fetchUserProfile().subscribe({
127
+ next: (fullUser) => {
128
+ // Profile fetched successfully
129
+ },
130
+ error: (error) => {
131
+ // Don't clear auth state on profile fetch failure - user might still be authenticated
132
+ }
133
+ });
134
+ }
135
+ else if (refreshToken && token && this.isTokenExpired(token)) {
136
+ // Token is expired but we have refresh token
137
+ // Don't try to refresh here - let the interceptor handle it when needed
138
+ // Just clear the auth state for now but keep the tokens
139
+ this.isAuthenticatedSubject.next(false);
140
+ this.currentUserSubject.next(null);
141
+ }
142
+ else if (refreshToken && !token) {
143
+ // We have refresh token but no access token
144
+ // Keep the refresh token, clear auth state
145
+ this.isAuthenticatedSubject.next(false);
146
+ this.currentUserSubject.next(null);
147
+ }
148
+ else {
149
+ // No tokens at all, just set auth state to false without clearing
150
+ // (tokens might be missing for other reasons, let auth guard handle redirect)
151
+ this.isAuthenticatedSubject.next(false);
152
+ this.currentUserSubject.next(null);
153
+ }
154
+ }
155
+ isTokenExpired(token) {
156
+ try {
157
+ const payload = JSON.parse(atob(token.split('.')[1]));
158
+ const now = Math.floor(Date.now() / 1000);
159
+ return payload.exp < now;
160
+ }
161
+ catch {
162
+ return true;
163
+ }
164
+ }
165
+ getUserFromToken() {
166
+ const token = this.getAccessToken();
167
+ if (!token)
168
+ return null;
169
+ try {
170
+ const payload = JSON.parse(atob(token.split('.')[1]));
171
+ return {
172
+ id: payload.user_id,
173
+ username: payload.username || '',
174
+ email: payload.email || '',
175
+ firstName: payload.first_name || '',
176
+ lastName: payload.last_name || '',
177
+ isStaff: payload.is_staff || false,
178
+ isSuperuser: payload.is_superuser || false,
179
+ isActive: payload.is_active || true,
180
+ dateJoined: payload.date_joined || '',
181
+ lastLogin: payload.last_login || null,
182
+ hasOrcid: payload.orcid_id ? true : false,
183
+ orcidId: payload.orcid_id
184
+ };
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+ initiateORCIDLogin() {
191
+ return this.http.get(`${this.apiUrl}/auth/orcid/login/`)
192
+ .pipe(map(response => ({
193
+ authorizationUrl: response.authorization_url,
194
+ state: response.state
195
+ })));
196
+ }
197
+ handleORCIDCallback(code, state) {
198
+ const params = new URLSearchParams({ code, state });
199
+ return this.http.get(`${this.apiUrl}/auth/orcid/callback/?${params}`)
200
+ .pipe(tap(response => this.setAuthData(response)));
201
+ }
202
+ exchangeORCIDToken(accessToken, orcidId) {
203
+ return this.http.post(`${this.apiUrl}/auth/orcid/token/`, {
204
+ access_token: accessToken,
205
+ orcid_id: orcidId
206
+ }).pipe(tap(response => this.setAuthData(response)));
207
+ }
208
+ login(username, password) {
209
+ return this.http.post(`${this.apiUrl}/auth/login/`, {
210
+ username,
211
+ password
212
+ }).pipe(tap(response => this.setAuthData(response)));
213
+ }
214
+ logout() {
215
+ const refreshToken = this.getRefreshToken();
216
+ const payload = refreshToken ? { refresh: refreshToken } : {};
217
+ return this.http.post(`${this.apiUrl}/auth/logout/`, payload)
218
+ .pipe(tap(() => this.clearAuthData()));
219
+ }
220
+ checkAuthStatus() {
221
+ return this.http.get(`${this.apiUrl}/auth/status/`)
222
+ .pipe(tap(status => {
223
+ if (status.authenticated && status.user) {
224
+ this.currentUserSubject.next(status.user);
225
+ this.isAuthenticatedSubject.next(true);
226
+ }
227
+ else {
228
+ this.clearAuthData();
229
+ }
230
+ }));
231
+ }
232
+ fetchUserProfile() {
233
+ return this.http.get(`${this.apiUrl}/auth/profile/`)
234
+ .pipe(tap(response => {
235
+ this.currentUserSubject.next(response.user);
236
+ this.isAuthenticatedSubject.next(true);
237
+ }), map(response => response.user));
238
+ }
239
+ getCurrentUser() {
240
+ return this.currentUserSubject.value;
241
+ }
242
+ isAuthenticated() {
243
+ return this.isAuthenticatedSubject.value;
244
+ }
245
+ getAccessToken() {
246
+ return localStorage.getItem('ccvAccessToken');
247
+ }
248
+ getRefreshToken() {
249
+ return localStorage.getItem('ccvRefreshToken');
250
+ }
251
+ setAuthData(response) {
252
+ const accessToken = response.accessToken || response.access_token || response.access;
253
+ const refreshToken = response.refreshToken || response.refresh_token || response.refresh;
254
+ localStorage.setItem('ccvAccessToken', accessToken);
255
+ localStorage.setItem('ccvRefreshToken', refreshToken);
256
+ this.currentUserSubject.next(response.user);
257
+ this.isAuthenticatedSubject.next(true);
258
+ }
259
+ tryRefreshToken() {
260
+ const refreshToken = this.getRefreshToken();
261
+ if (!refreshToken) {
262
+ this.clearAuthData();
263
+ return throwError(() => new Error('No refresh token available'));
264
+ }
265
+ return this.http.post(`${this.apiUrl}/auth/token/refresh/`, {
266
+ refresh: refreshToken
267
+ }).pipe(tap((response) => {
268
+ localStorage.setItem('ccvAccessToken', response.access);
269
+ this.isAuthenticatedSubject.next(true);
270
+ // Try to get basic user info from new token first (for immediate display)
271
+ const user = this.getUserFromToken();
272
+ if (user) {
273
+ this.currentUserSubject.next(user);
274
+ }
275
+ // Fetch complete user profile from backend after successful token refresh
276
+ this.fetchUserProfile().subscribe({
277
+ next: (fullUser) => {
278
+ // Profile fetched successfully
279
+ },
280
+ error: (error) => {
281
+ // Failed to fetch user profile after token refresh
282
+ }
283
+ });
284
+ }), catchError((error) => {
285
+ // Refresh failed, clear all auth data
286
+ this.clearAuthData();
287
+ return throwError(() => error);
288
+ }));
289
+ }
290
+ refreshToken() {
291
+ const refreshToken = this.getRefreshToken();
292
+ if (!refreshToken) {
293
+ throw new Error('No refresh token available');
294
+ }
295
+ return this.http.post(`${this.apiUrl}/auth/token/refresh/`, {
296
+ refresh: refreshToken
297
+ }).pipe(tap(response => {
298
+ localStorage.setItem('ccvAccessToken', response.access);
299
+ this.isAuthenticatedSubject.next(true);
300
+ // Fetch complete user profile from backend after successful token refresh
301
+ this.fetchUserProfile().subscribe({
302
+ next: (fullUser) => {
303
+ // Profile fetched successfully
304
+ },
305
+ error: (error) => {
306
+ // Failed to fetch user profile after token refresh
307
+ }
308
+ });
309
+ }));
310
+ }
311
+ updateAuthStateAfterRefresh() {
312
+ const token = this.getAccessToken();
313
+ if (token && !this.isTokenExpired(token)) {
314
+ this.isAuthenticatedSubject.next(true);
315
+ // Try to get basic user info from new token first (for immediate display)
316
+ const user = this.getUserFromToken();
317
+ if (user) {
318
+ this.currentUserSubject.next(user);
319
+ }
320
+ // Fetch complete user profile from backend after token refresh
321
+ this.fetchUserProfile().subscribe({
322
+ next: (fullUser) => {
323
+ // Profile fetched successfully
324
+ },
325
+ error: (error) => {
326
+ // Don't clear auth state on profile fetch failure - token refresh was successful
327
+ }
328
+ });
329
+ }
330
+ }
331
+ clearAuthData() {
332
+ localStorage.removeItem('ccvAccessToken');
333
+ localStorage.removeItem('ccvRefreshToken');
334
+ this.currentUserSubject.next(null);
335
+ this.isAuthenticatedSubject.next(false);
336
+ }
337
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: AuthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
338
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: AuthService, providedIn: 'root' });
339
+ }
340
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: AuthService, decorators: [{
341
+ type: Injectable,
342
+ args: [{
343
+ providedIn: 'root'
344
+ }]
345
+ }], ctorParameters: () => [] });
346
+
347
+ class ResourceService {
348
+ convertLegacyVisibility(isPublic, isDefault = false) {
349
+ if (isDefault || isPublic) {
350
+ return ResourceVisibility.PUBLIC;
351
+ }
352
+ return ResourceVisibility.PRIVATE;
353
+ }
354
+ convertToLegacyVisibility(visibility) {
355
+ return visibility === ResourceVisibility.PUBLIC;
356
+ }
357
+ getVisibilityLabel(visibility) {
358
+ switch (visibility) {
359
+ case ResourceVisibility.PRIVATE:
360
+ return 'Private';
361
+ case ResourceVisibility.GROUP:
362
+ return 'Lab Group';
363
+ case ResourceVisibility.PUBLIC:
364
+ return 'Public';
365
+ default:
366
+ return 'Unknown';
367
+ }
368
+ }
369
+ getRoleLabel(role) {
370
+ switch (role) {
371
+ case ResourceRole.OWNER:
372
+ return 'Owner';
373
+ case ResourceRole.ADMIN:
374
+ return 'Administrator';
375
+ case ResourceRole.EDITOR:
376
+ return 'Editor';
377
+ case ResourceRole.VIEWER:
378
+ return 'Viewer';
379
+ default:
380
+ return 'Unknown';
381
+ }
382
+ }
383
+ canPerformAction(resource, action) {
384
+ switch (action) {
385
+ case 'view':
386
+ return resource.canView ?? true;
387
+ case 'edit':
388
+ return resource.canEdit ?? false;
389
+ case 'delete':
390
+ return resource.canDelete ?? false;
391
+ case 'share':
392
+ // Share permission is based on ownership or admin role
393
+ // This would typically require checking permissions via API
394
+ return resource.canEdit ?? false;
395
+ default:
396
+ return false;
397
+ }
398
+ }
399
+ getVisibilityOptions() {
400
+ return [
401
+ {
402
+ value: ResourceVisibility.PRIVATE,
403
+ label: 'Private',
404
+ description: 'Only you can access this resource'
405
+ },
406
+ {
407
+ value: ResourceVisibility.GROUP,
408
+ label: 'Lab Group',
409
+ description: 'Members of your lab group can access this resource'
410
+ },
411
+ {
412
+ value: ResourceVisibility.PUBLIC,
413
+ label: 'Public',
414
+ description: 'All users can access this resource'
415
+ }
416
+ ];
417
+ }
418
+ getRoleOptions() {
419
+ return [
420
+ {
421
+ value: ResourceRole.VIEWER,
422
+ label: 'Viewer',
423
+ description: 'Can view the resource'
424
+ },
425
+ {
426
+ value: ResourceRole.EDITOR,
427
+ label: 'Editor',
428
+ description: 'Can view and edit the resource'
429
+ },
430
+ {
431
+ value: ResourceRole.ADMIN,
432
+ label: 'Administrator',
433
+ description: 'Can view, edit, and manage permissions'
434
+ },
435
+ {
436
+ value: ResourceRole.OWNER,
437
+ label: 'Owner',
438
+ description: 'Full control over the resource'
439
+ }
440
+ ];
441
+ }
442
+ transformLegacyResource(legacyData) {
443
+ const transformed = { ...legacyData };
444
+ if (legacyData.creator !== undefined) {
445
+ transformed.owner = legacyData.creator;
446
+ delete transformed.creator;
447
+ }
448
+ if (legacyData.creator_username !== undefined) {
449
+ transformed.ownerUsername = legacyData.creator_username;
450
+ delete transformed.creator_username;
451
+ }
452
+ if (legacyData.is_public !== undefined) {
453
+ transformed.visibility = this.convertLegacyVisibility(legacyData.is_public, legacyData.is_default);
454
+ delete transformed.is_public;
455
+ }
456
+ if (transformed.visibility === undefined) {
457
+ transformed.visibility = ResourceVisibility.PRIVATE;
458
+ }
459
+ if (transformed.isActive === undefined) {
460
+ transformed.isActive = true;
461
+ }
462
+ if (transformed.isLocked === undefined) {
463
+ transformed.isLocked = false;
464
+ }
465
+ return transformed;
466
+ }
467
+ prepareForAPI(resourceData) {
468
+ const prepared = { ...resourceData };
469
+ if (prepared.owner !== undefined) {
470
+ prepared.creator = prepared.owner;
471
+ delete prepared.owner;
472
+ }
473
+ if (prepared.ownerUsername !== undefined) {
474
+ prepared.creator_username = prepared.ownerUsername;
475
+ delete prepared.ownerUsername;
476
+ }
477
+ if (prepared.visibility !== undefined) {
478
+ prepared.is_public = this.convertToLegacyVisibility(prepared.visibility);
479
+ delete prepared.visibility;
480
+ }
481
+ return prepared;
482
+ }
483
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: ResourceService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
484
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: ResourceService, providedIn: 'root' });
485
+ }
486
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: ResourceService, decorators: [{
487
+ type: Injectable,
488
+ args: [{
489
+ providedIn: 'root'
490
+ }]
491
+ }] });
492
+
493
+ class ApiService {
494
+ http;
495
+ config = inject(CUPCAKE_CORE_CONFIG);
496
+ apiUrl = this.config.apiUrl;
497
+ resourceService = inject(ResourceService);
498
+ constructor(http) {
499
+ this.http = http;
500
+ }
501
+ // ===== SYSTEMATIC CASE TRANSFORMATION METHODS =====
502
+ /**
503
+ * Transform camelCase object to snake_case for API requests
504
+ */
505
+ transformToSnakeCase(obj) {
506
+ if (obj === null || typeof obj !== 'object') {
507
+ return obj;
508
+ }
509
+ if (Array.isArray(obj)) {
510
+ return obj.map(item => this.transformToSnakeCase(item));
511
+ }
512
+ const transformed = {};
513
+ Object.entries(obj).forEach(([key, value]) => {
514
+ // Convert camelCase to snake_case
515
+ const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
516
+ transformed[snakeKey] = this.transformToSnakeCase(value);
517
+ });
518
+ return transformed;
519
+ }
520
+ /**
521
+ * Transform snake_case object to camelCase for TypeScript interfaces
522
+ */
523
+ transformToCamelCase(obj) {
524
+ if (obj === null || typeof obj !== 'object') {
525
+ return obj;
526
+ }
527
+ if (Array.isArray(obj)) {
528
+ return obj.map(item => this.transformToCamelCase(item));
529
+ }
530
+ const transformed = {};
531
+ Object.entries(obj).forEach(([key, value]) => {
532
+ // Convert snake_case to camelCase
533
+ const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
534
+ transformed[camelKey] = this.transformToCamelCase(value);
535
+ });
536
+ return transformed;
537
+ }
538
+ /**
539
+ * Make HTTP GET request with automatic snake_case to camelCase transformation
540
+ */
541
+ get(url, options) {
542
+ return this.http.get(url, options).pipe(map$1(response => this.transformToCamelCase(response)));
543
+ }
544
+ /**
545
+ * Make HTTP POST request with automatic camelCase to snake_case transformation
546
+ */
547
+ post(url, body, options) {
548
+ const transformedBody = this.transformToSnakeCase(body);
549
+ return this.http.post(url, transformedBody, options).pipe(map$1(response => this.transformToCamelCase(response)));
550
+ }
551
+ /**
552
+ * Make HTTP PUT request with automatic camelCase to snake_case transformation
553
+ */
554
+ put(url, body, options) {
555
+ const transformedBody = this.transformToSnakeCase(body);
556
+ return this.http.put(url, transformedBody, options).pipe(map$1(response => this.transformToCamelCase(response)));
557
+ }
558
+ /**
559
+ * Make HTTP PATCH request with automatic camelCase to snake_case transformation
560
+ */
561
+ patch(url, body, options) {
562
+ const transformedBody = this.transformToSnakeCase(body);
563
+ return this.http.patch(url, transformedBody, options).pipe(map$1(response => this.transformToCamelCase(response)));
564
+ }
565
+ /**
566
+ * Make HTTP DELETE request with automatic snake_case to camelCase transformation
567
+ */
568
+ delete(url, options) {
569
+ return this.http.delete(url, options).pipe(map$1(response => this.transformToCamelCase(response)));
570
+ }
571
+ // USER PROFILE
572
+ getUserProfile() {
573
+ return this.http.get(`${this.apiUrl}/auth/profile/`);
574
+ }
575
+ // ===================================================================
576
+ // SITE CONFIGURATION METHODS
577
+ // ===================================================================
578
+ getSiteConfig() {
579
+ return this.http.get(`${this.apiUrl}/site_config/`);
580
+ }
581
+ updateSiteConfig(id, config) {
582
+ return this.http.patch(`${this.apiUrl}/site_config/${id}/`, config);
583
+ }
584
+ // ===================================================================
585
+ // USER MANAGEMENT METHODS
586
+ // ===================================================================
587
+ // Admin-only user management
588
+ getUsers(params) {
589
+ let httpParams = new HttpParams();
590
+ if (params?.isStaff !== undefined)
591
+ httpParams = httpParams.set('is_staff', params.isStaff.toString());
592
+ if (params?.isActive !== undefined)
593
+ httpParams = httpParams.set('is_active', params.isActive.toString());
594
+ if (params?.search)
595
+ httpParams = httpParams.set('search', params.search);
596
+ if (params?.page)
597
+ httpParams = httpParams.set('page', params.page.toString());
598
+ if (params?.pageSize)
599
+ httpParams = httpParams.set('page_size', params.pageSize.toString());
600
+ return this.http.get(`${this.apiUrl}/users/`, { params: httpParams });
601
+ }
602
+ getUser(id) {
603
+ return this.http.get(`${this.apiUrl}/users/${id}/`);
604
+ }
605
+ createUser(userData) {
606
+ return this.http.post(`${this.apiUrl}/users/admin_create/`, userData);
607
+ }
608
+ updateUser(id, userData) {
609
+ return this.http.patch(`${this.apiUrl}/users/${id}/`, userData);
610
+ }
611
+ deleteUser(id) {
612
+ return this.http.delete(`${this.apiUrl}/users/${id}/`);
613
+ }
614
+ // Public user registration
615
+ registerUser(userData) {
616
+ return this.http.post(`${this.apiUrl}/users/register/`, userData);
617
+ }
618
+ // Authentication configuration
619
+ getAuthConfig() {
620
+ return this.http.get(`${this.apiUrl}/users/auth_config/`);
621
+ }
622
+ getRegistrationStatus() {
623
+ return this.http.get(`${this.apiUrl}/users/registration_status/`);
624
+ }
625
+ // ===================================================================
626
+ // PASSWORD MANAGEMENT METHODS
627
+ // ===================================================================
628
+ // User password change (authenticated user)
629
+ changePassword(passwordData) {
630
+ return this.http.post(`${this.apiUrl}/users/change_password/`, passwordData);
631
+ }
632
+ // User profile update
633
+ updateProfile(profileData) {
634
+ return this.http.post(`${this.apiUrl}/users/update_profile/`, profileData);
635
+ }
636
+ // Email change with verification
637
+ requestEmailChange(emailData) {
638
+ return this.http.post(`${this.apiUrl}/users/request_email_change/`, emailData);
639
+ }
640
+ confirmEmailChange(confirmData) {
641
+ return this.http.post(`${this.apiUrl}/users/confirm_email_change/`, confirmData);
642
+ }
643
+ // Admin password reset
644
+ resetUserPassword(userId, passwordData) {
645
+ const apiData = {
646
+ user_id: passwordData.userId,
647
+ new_password: passwordData.newPassword,
648
+ confirm_password: passwordData.confirmPassword,
649
+ force_password_change: passwordData.forcePasswordChange,
650
+ reason: passwordData.reason
651
+ };
652
+ return this.http.post(`${this.apiUrl}/users/${userId}/reset_password/`, apiData);
653
+ }
654
+ // Password reset request (forgot password)
655
+ requestPasswordReset(resetData) {
656
+ return this.http.post(`${this.apiUrl}/users/request_password_reset/`, resetData);
657
+ }
658
+ // Confirm password reset with token
659
+ confirmPasswordReset(confirmData) {
660
+ return this.http.post(`${this.apiUrl}/users/confirm_password_reset/`, confirmData);
661
+ }
662
+ // ===================================================================
663
+ // ACCOUNT LINKING METHODS
664
+ // ===================================================================
665
+ // Link ORCID to current user account
666
+ linkOrcid(orcidData) {
667
+ return this.http.post(`${this.apiUrl}/users/link_orcid/`, orcidData);
668
+ }
669
+ // Unlink ORCID from current user account
670
+ unlinkOrcid() {
671
+ return this.http.delete(`${this.apiUrl}/users/unlink_orcid/`);
672
+ }
673
+ // Detect duplicate accounts
674
+ detectDuplicateAccounts(searchData) {
675
+ return this.http.post(`${this.apiUrl}/users/detect_duplicates/`, searchData);
676
+ }
677
+ // Request account merge
678
+ requestAccountMerge(mergeData) {
679
+ return this.http.post(`${this.apiUrl}/users/request_merge/`, mergeData);
680
+ }
681
+ // ANNOTATION MANAGEMENT
682
+ getAnnotationFolders(params) {
683
+ let httpParams = new HttpParams();
684
+ if (params) {
685
+ Object.keys(params).forEach(key => {
686
+ const value = params[key];
687
+ if (value !== undefined && value !== null) {
688
+ httpParams = httpParams.set(key, value.toString());
689
+ }
690
+ });
691
+ }
692
+ return this.http.get(`${this.apiUrl}/annotation-folders/`, { params: httpParams }).pipe(map$1(response => ({
693
+ ...response,
694
+ results: response.results.map((folder) => this.resourceService.transformLegacyResource(folder))
695
+ })));
696
+ }
697
+ getAnnotationFolder(id) {
698
+ return this.http.get(`${this.apiUrl}/annotation-folders/${id}/`).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
699
+ }
700
+ createAnnotationFolder(folderData) {
701
+ const preparedData = this.resourceService.prepareForAPI(folderData);
702
+ return this.http.post(`${this.apiUrl}/annotation-folders/`, preparedData).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
703
+ }
704
+ updateAnnotationFolder(id, folderData) {
705
+ const preparedData = this.resourceService.prepareForAPI(folderData);
706
+ return this.http.patch(`${this.apiUrl}/annotation-folders/${id}/`, preparedData).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
707
+ }
708
+ deleteAnnotationFolder(id) {
709
+ return this.http.delete(`${this.apiUrl}/annotation-folders/${id}/`);
710
+ }
711
+ getAnnotations(params) {
712
+ let httpParams = new HttpParams();
713
+ if (params) {
714
+ Object.keys(params).forEach(key => {
715
+ const value = params[key];
716
+ if (value !== undefined && value !== null) {
717
+ httpParams = httpParams.set(key, value.toString());
718
+ }
719
+ });
720
+ }
721
+ return this.http.get(`${this.apiUrl}/annotations/`, { params: httpParams }).pipe(map$1(response => ({
722
+ ...response,
723
+ results: response.results.map((annotation) => this.resourceService.transformLegacyResource(annotation))
724
+ })));
725
+ }
726
+ getAnnotation(id) {
727
+ return this.http.get(`${this.apiUrl}/annotations/${id}/`).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
728
+ }
729
+ createAnnotation(annotationData) {
730
+ const formData = new FormData();
731
+ Object.keys(annotationData).forEach(key => {
732
+ const value = annotationData[key];
733
+ if (value !== undefined && value !== null) {
734
+ if (key === 'file' && value instanceof File) {
735
+ formData.append(key, value);
736
+ }
737
+ else {
738
+ formData.append(key, value.toString());
739
+ }
740
+ }
741
+ });
742
+ return this.http.post(`${this.apiUrl}/annotations/`, formData).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
743
+ }
744
+ updateAnnotation(id, annotationData) {
745
+ const preparedData = this.resourceService.prepareForAPI(annotationData);
746
+ return this.http.patch(`${this.apiUrl}/annotations/${id}/`, preparedData).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
747
+ }
748
+ deleteAnnotation(id) {
749
+ return this.http.delete(`${this.apiUrl}/annotations/${id}/`);
750
+ }
751
+ // RESOURCE PERMISSIONS
752
+ getResourcePermissions(params) {
753
+ let httpParams = new HttpParams();
754
+ if (params) {
755
+ Object.keys(params).forEach(key => {
756
+ const value = params[key];
757
+ if (value !== undefined && value !== null) {
758
+ httpParams = httpParams.set(key, value.toString());
759
+ }
760
+ });
761
+ }
762
+ return this.http.get(`${this.apiUrl}/resource-permissions/`, { params: httpParams }).pipe(map$1(response => ({
763
+ ...response,
764
+ results: response.results.map((permission) => this.resourceService.transformLegacyResource(permission))
765
+ })));
766
+ }
767
+ getResourcePermission(id) {
768
+ return this.http.get(`${this.apiUrl}/resource-permissions/${id}/`).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
769
+ }
770
+ createResourcePermission(permissionData) {
771
+ const preparedData = this.resourceService.prepareForAPI(permissionData);
772
+ return this.http.post(`${this.apiUrl}/resource-permissions/`, preparedData).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
773
+ }
774
+ updateResourcePermission(id, permissionData) {
775
+ const preparedData = this.resourceService.prepareForAPI(permissionData);
776
+ return this.http.patch(`${this.apiUrl}/resource-permissions/${id}/`, preparedData).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
777
+ }
778
+ deleteResourcePermission(id) {
779
+ return this.http.delete(`${this.apiUrl}/resource-permissions/${id}/`);
780
+ }
781
+ createBulkPermissions(bulkData) {
782
+ const preparedData = this.resourceService.prepareForAPI(bulkData);
783
+ return this.http.post(`${this.apiUrl}/resource-permissions/bulk_create/`, preparedData).pipe(map$1(response => ({
784
+ ...response,
785
+ created: response.created.map((permission) => this.resourceService.transformLegacyResource(permission))
786
+ })));
787
+ }
788
+ getResourcePermissionsByResource(resourceContentType, resourceObjectId) {
789
+ const httpParams = new HttpParams()
790
+ .set('resourceContentType', resourceContentType.toString())
791
+ .set('resourceObjectId', resourceObjectId.toString());
792
+ return this.http.get(`${this.apiUrl}/resource-permissions/by_resource/`, { params: httpParams }).pipe(map$1(permissions => permissions.map(permission => this.resourceService.transformLegacyResource(permission))));
793
+ }
794
+ // REMOTE HOST MANAGEMENT
795
+ getRemoteHosts(params) {
796
+ let httpParams = new HttpParams();
797
+ if (params) {
798
+ Object.keys(params).forEach(key => {
799
+ const value = params[key];
800
+ if (value !== undefined && value !== null) {
801
+ httpParams = httpParams.set(key, value.toString());
802
+ }
803
+ });
804
+ }
805
+ return this.http.get(`${this.apiUrl}/remote-hosts/`, { params: httpParams }).pipe(map$1(response => ({
806
+ ...response,
807
+ results: response.results.map((host) => this.resourceService.transformLegacyResource(host))
808
+ })));
809
+ }
810
+ getRemoteHost(id) {
811
+ return this.http.get(`${this.apiUrl}/remote-hosts/${id}/`).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
812
+ }
813
+ createRemoteHost(hostData) {
814
+ const preparedData = this.resourceService.prepareForAPI(hostData);
815
+ return this.http.post(`${this.apiUrl}/remote-hosts/`, preparedData).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
816
+ }
817
+ updateRemoteHost(id, hostData) {
818
+ const preparedData = this.resourceService.prepareForAPI(hostData);
819
+ return this.http.patch(`${this.apiUrl}/remote-hosts/${id}/`, preparedData).pipe(map$1(response => this.resourceService.transformLegacyResource(response)));
820
+ }
821
+ deleteRemoteHost(id) {
822
+ return this.http.delete(`${this.apiUrl}/remote-hosts/${id}/`);
823
+ }
824
+ testRemoteHostConnection(id) {
825
+ return this.http.post(`${this.apiUrl}/remote-hosts/${id}/test_connection/`, {});
826
+ }
827
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: ApiService, deps: [{ token: i1.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable });
828
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: ApiService, providedIn: 'root' });
829
+ }
830
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: ApiService, decorators: [{
831
+ type: Injectable,
832
+ args: [{
833
+ providedIn: 'root'
834
+ }]
835
+ }], ctorParameters: () => [{ type: i1.HttpClient }] });
836
+
837
+ /**
838
+ * Base API service with systematic case transformation
839
+ * All other API services should extend this to get automatic snake_case <-> camelCase conversion
840
+ */
841
+ class BaseApiService {
842
+ http = inject(HttpClient);
843
+ config = inject(CUPCAKE_CORE_CONFIG);
844
+ apiUrl = this.config.apiUrl;
845
+ // ===== SYSTEMATIC CASE TRANSFORMATION METHODS =====
846
+ /**
847
+ * Transform camelCase object to snake_case for API requests
848
+ */
849
+ transformToSnakeCase(obj) {
850
+ if (obj === null || typeof obj !== 'object') {
851
+ return obj;
852
+ }
853
+ if (Array.isArray(obj)) {
854
+ return obj.map(item => this.transformToSnakeCase(item));
855
+ }
856
+ const transformed = {};
857
+ Object.entries(obj).forEach(([key, value]) => {
858
+ // Convert camelCase to snake_case
859
+ const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
860
+ transformed[snakeKey] = this.transformToSnakeCase(value);
861
+ });
862
+ return transformed;
863
+ }
864
+ /**
865
+ * Transform snake_case object to camelCase for TypeScript interfaces
866
+ */
867
+ transformToCamelCase(obj) {
868
+ if (obj === null || typeof obj !== 'object') {
869
+ return obj;
870
+ }
871
+ if (Array.isArray(obj)) {
872
+ return obj.map(item => this.transformToCamelCase(item));
873
+ }
874
+ const transformed = {};
875
+ Object.entries(obj).forEach(([key, value]) => {
876
+ // Convert snake_case to camelCase
877
+ const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
878
+ transformed[camelKey] = this.transformToCamelCase(value);
879
+ });
880
+ return transformed;
881
+ }
882
+ // ===== HTTP METHODS WITH AUTOMATIC TRANSFORMATION =====
883
+ /**
884
+ * Make HTTP GET request with automatic snake_case to camelCase transformation
885
+ */
886
+ get(url, options) {
887
+ return this.http.get(url, options).pipe(map$1(response => this.transformToCamelCase(response)));
888
+ }
889
+ /**
890
+ * Make HTTP POST request with automatic camelCase to snake_case transformation
891
+ */
892
+ post(url, body, options) {
893
+ const transformedBody = this.transformToSnakeCase(body);
894
+ return this.http.post(url, transformedBody, options).pipe(map$1(response => this.transformToCamelCase(response)));
895
+ }
896
+ /**
897
+ * Make HTTP PUT request with automatic camelCase to snake_case transformation
898
+ */
899
+ put(url, body, options) {
900
+ const transformedBody = this.transformToSnakeCase(body);
901
+ return this.http.put(url, transformedBody, options).pipe(map$1(response => this.transformToCamelCase(response)));
902
+ }
903
+ /**
904
+ * Make HTTP PATCH request with automatic camelCase to snake_case transformation
905
+ */
906
+ patch(url, body, options) {
907
+ const transformedBody = this.transformToSnakeCase(body);
908
+ return this.http.patch(url, transformedBody, options).pipe(map$1(response => this.transformToCamelCase(response)));
909
+ }
910
+ /**
911
+ * Make HTTP DELETE request with automatic snake_case to camelCase transformation
912
+ */
913
+ delete(url, options) {
914
+ return this.http.delete(url, options).pipe(map$1(response => this.transformToCamelCase(response)));
915
+ }
916
+ /**
917
+ * Build HttpParams from query parameters object with automatic case transformation
918
+ */
919
+ buildHttpParams(params) {
920
+ let httpParams = new HttpParams();
921
+ if (params) {
922
+ // Transform to snake_case before creating params
923
+ const transformedParams = this.transformToSnakeCase(params);
924
+ Object.entries(transformedParams).forEach(([key, value]) => {
925
+ if (value !== undefined && value !== null) {
926
+ httpParams = httpParams.set(key, value.toString());
927
+ }
928
+ });
929
+ }
930
+ return httpParams;
931
+ }
932
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: BaseApiService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
933
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: BaseApiService, providedIn: 'root' });
934
+ }
935
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: BaseApiService, decorators: [{
936
+ type: Injectable,
937
+ args: [{
938
+ providedIn: 'root'
939
+ }]
940
+ }] });
941
+
942
+ class SiteConfigService extends BaseApiService {
943
+ defaultConfig = {
944
+ siteName: 'CUPCAKE Vanilla',
945
+ showPoweredBy: true,
946
+ primaryColor: '#1976d2',
947
+ allowUserRegistration: false,
948
+ enableOrcidLogin: false,
949
+ installedApps: {},
950
+ createdAt: new Date().toISOString(),
951
+ updatedAt: new Date().toISOString()
952
+ };
953
+ configSubject = new BehaviorSubject(this.defaultConfig);
954
+ config$ = this.configSubject.asObservable();
955
+ constructor() {
956
+ super();
957
+ this.loadConfig();
958
+ }
959
+ loadConfig() {
960
+ const savedConfig = localStorage.getItem('site_config');
961
+ if (savedConfig) {
962
+ try {
963
+ const config = JSON.parse(savedConfig);
964
+ this.configSubject.next({ ...this.defaultConfig, ...config });
965
+ }
966
+ catch (error) {
967
+ // Invalid config, use defaults
968
+ }
969
+ }
970
+ this.fetchConfigFromBackend().subscribe({
971
+ next: (config) => {
972
+ this.configSubject.next({ ...this.defaultConfig, ...config });
973
+ localStorage.setItem('site_config', JSON.stringify(config));
974
+ },
975
+ error: () => {
976
+ // Continue with current config
977
+ }
978
+ });
979
+ }
980
+ fetchConfigFromBackend() {
981
+ return this.get(`${this.apiUrl}/site-config/public/`);
982
+ }
983
+ getCurrentConfig() {
984
+ return this.get(`${this.apiUrl}/site-config/current/`);
985
+ }
986
+ updateConfig(config) {
987
+ return this.put(`${this.apiUrl}/site-config/update_config/`, config);
988
+ }
989
+ getSiteName() {
990
+ return this.configSubject.value.siteName;
991
+ }
992
+ shouldShowPoweredBy() {
993
+ return this.configSubject.value.showPoweredBy !== false;
994
+ }
995
+ getLogoUrl() {
996
+ const config = this.configSubject.value;
997
+ return config.logoImage || config.logoUrl || null;
998
+ }
999
+ getPrimaryColor() {
1000
+ return this.configSubject.value.primaryColor || '#1976d2';
1001
+ }
1002
+ isRegistrationEnabled() {
1003
+ return this.configSubject.value.allowUserRegistration === true;
1004
+ }
1005
+ isOrcidLoginEnabled() {
1006
+ return this.configSubject.value.enableOrcidLogin === true;
1007
+ }
1008
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: SiteConfigService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1009
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: SiteConfigService, providedIn: 'root' });
1010
+ }
1011
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: SiteConfigService, decorators: [{
1012
+ type: Injectable,
1013
+ args: [{
1014
+ providedIn: 'root'
1015
+ }]
1016
+ }], ctorParameters: () => [] });
1017
+
1018
+ class ToastService {
1019
+ toastsSignal = signal([], ...(ngDevMode ? [{ debugName: "toastsSignal" }] : []));
1020
+ // Public readonly signal for components to subscribe to
1021
+ toasts = this.toastsSignal.asReadonly();
1022
+ show(message, type = 'info', duration = 5000) {
1023
+ const id = this.generateId();
1024
+ const toast = {
1025
+ id,
1026
+ message,
1027
+ type,
1028
+ duration,
1029
+ dismissible: true
1030
+ };
1031
+ this.toastsSignal.update(toasts => [...toasts, toast]);
1032
+ // Auto-remove after duration
1033
+ if (duration > 0) {
1034
+ setTimeout(() => {
1035
+ this.remove(id);
1036
+ }, duration);
1037
+ }
1038
+ }
1039
+ success(message, duration = 5000) {
1040
+ this.show(message, 'success', duration);
1041
+ }
1042
+ error(message, duration = 8000) {
1043
+ this.show(message, 'error', duration);
1044
+ }
1045
+ warning(message, duration = 6000) {
1046
+ this.show(message, 'warning', duration);
1047
+ }
1048
+ info(message, duration = 5000) {
1049
+ this.show(message, 'info', duration);
1050
+ }
1051
+ remove(id) {
1052
+ this.toastsSignal.update(toasts => toasts.filter(toast => toast.id !== id));
1053
+ }
1054
+ clear() {
1055
+ this.toastsSignal.set([]);
1056
+ }
1057
+ generateId() {
1058
+ return Math.random().toString(36).substring(2) + Date.now().toString(36);
1059
+ }
1060
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: ToastService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1061
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: ToastService, providedIn: 'root' });
1062
+ }
1063
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: ToastService, decorators: [{
1064
+ type: Injectable,
1065
+ args: [{
1066
+ providedIn: 'root'
1067
+ }]
1068
+ }] });
1069
+
1070
+ class UserManagementService {
1071
+ apiService = inject(ApiService);
1072
+ authService = inject(AuthService);
1073
+ usersSubject = new BehaviorSubject([]);
1074
+ users$ = this.usersSubject.asObservable();
1075
+ totalUsersSubject = new BehaviorSubject(0);
1076
+ totalUsers$ = this.totalUsersSubject.asObservable();
1077
+ constructor() { }
1078
+ getUserProfile() {
1079
+ return this.apiService.getUserProfile();
1080
+ }
1081
+ updateProfile(profileData) {
1082
+ return this.apiService.updateProfile(profileData);
1083
+ }
1084
+ changePassword(passwordData) {
1085
+ return this.apiService.changePassword(passwordData);
1086
+ }
1087
+ requestEmailChange(emailData) {
1088
+ return this.apiService.requestEmailChange(emailData);
1089
+ }
1090
+ getUsers(params) {
1091
+ return this.apiService.getUsers(params);
1092
+ }
1093
+ getUser(id) {
1094
+ return this.apiService.getUser(id);
1095
+ }
1096
+ createUser(userData) {
1097
+ return this.apiService.createUser(userData);
1098
+ }
1099
+ updateUser(id, userData) {
1100
+ return this.apiService.updateUser(id, userData);
1101
+ }
1102
+ deleteUser(id) {
1103
+ return this.apiService.deleteUser(id);
1104
+ }
1105
+ resetUserPassword(userId, passwordData) {
1106
+ return this.apiService.resetUserPassword(userId, passwordData);
1107
+ }
1108
+ getUserDisplayName(user) {
1109
+ if (!user)
1110
+ return '';
1111
+ if (user.firstName || user.lastName) {
1112
+ return `${user.firstName} ${user.lastName}`.trim();
1113
+ }
1114
+ return user.username || user.email || 'User';
1115
+ }
1116
+ formatDate(dateString) {
1117
+ return dateString ? new Date(dateString).toLocaleDateString() : 'Never';
1118
+ }
1119
+ isCurrentUserAdmin() {
1120
+ const user = this.authService.getCurrentUser();
1121
+ return user?.isStaff || false;
1122
+ }
1123
+ isCurrentUserSuperuser() {
1124
+ const user = this.authService.getCurrentUser();
1125
+ return user?.isSuperuser || false;
1126
+ }
1127
+ updateUsersState(users, total) {
1128
+ this.usersSubject.next(users);
1129
+ this.totalUsersSubject.next(total);
1130
+ }
1131
+ getCurrentUsers() {
1132
+ return this.usersSubject.getValue();
1133
+ }
1134
+ getCurrentTotalUsers() {
1135
+ return this.totalUsersSubject.getValue();
1136
+ }
1137
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: UserManagementService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1138
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: UserManagementService, providedIn: 'root' });
1139
+ }
1140
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: UserManagementService, decorators: [{
1141
+ type: Injectable,
1142
+ args: [{
1143
+ providedIn: 'root'
1144
+ }]
1145
+ }], ctorParameters: () => [] });
1146
+
1147
+ class LabGroupService extends BaseApiService {
1148
+ // LAB GROUPS
1149
+ getLabGroups(params) {
1150
+ const httpParams = this.buildHttpParams(params);
1151
+ return this.get(`${this.apiUrl}/lab-groups/`, { params: httpParams });
1152
+ }
1153
+ getMyLabGroups(params) {
1154
+ const httpParams = this.buildHttpParams(params);
1155
+ return this.get(`${this.apiUrl}/lab-groups/my_groups/`, { params: httpParams });
1156
+ }
1157
+ createLabGroup(labGroup) {
1158
+ return this.post(`${this.apiUrl}/lab-groups/`, labGroup);
1159
+ }
1160
+ updateLabGroup(id, labGroup) {
1161
+ return this.patch(`${this.apiUrl}/lab-groups/${id}/`, labGroup);
1162
+ }
1163
+ deleteLabGroup(id) {
1164
+ return this.delete(`${this.apiUrl}/lab-groups/${id}/`);
1165
+ }
1166
+ getLabGroupMembers(id) {
1167
+ return this.get(`${this.apiUrl}/lab-groups/${id}/members/`);
1168
+ }
1169
+ inviteUserToLabGroup(id, invitation) {
1170
+ return this.post(`${this.apiUrl}/lab-groups/${id}/invite_user/`, invitation);
1171
+ }
1172
+ leaveLabGroup(id) {
1173
+ return this.post(`${this.apiUrl}/lab-groups/${id}/leave/`, {});
1174
+ }
1175
+ removeMemberFromLabGroup(id, userId) {
1176
+ return this.post(`${this.apiUrl}/lab-groups/${id}/remove_member/`, { userId });
1177
+ }
1178
+ // LAB GROUP INVITATIONS
1179
+ getLabGroupInvitations(params) {
1180
+ const httpParams = this.buildHttpParams(params);
1181
+ return this.get(`${this.apiUrl}/lab-group-invitations/`, { params: httpParams });
1182
+ }
1183
+ getMyPendingInvitations() {
1184
+ return this.get(`${this.apiUrl}/lab-group-invitations/my_pending_invitations/`);
1185
+ }
1186
+ acceptLabGroupInvitation(id) {
1187
+ return this.post(`${this.apiUrl}/lab-group-invitations/${id}/accept_invitation/`, {});
1188
+ }
1189
+ rejectLabGroupInvitation(id) {
1190
+ return this.post(`${this.apiUrl}/lab-group-invitations/${id}/reject_invitation/`, {});
1191
+ }
1192
+ cancelLabGroupInvitation(id) {
1193
+ return this.post(`${this.apiUrl}/lab-group-invitations/${id}/cancel_invitation/`, {});
1194
+ }
1195
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: LabGroupService, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
1196
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: LabGroupService, providedIn: 'root' });
1197
+ }
1198
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: LabGroupService, decorators: [{
1199
+ type: Injectable,
1200
+ args: [{
1201
+ providedIn: 'root'
1202
+ }]
1203
+ }] });
1204
+
1205
+ const authGuard = (route, state) => {
1206
+ const authService = inject(AuthService);
1207
+ const router = inject(Router);
1208
+ return authService.isAuthenticated$.pipe(take(1), map(isAuthenticated => {
1209
+ if (isAuthenticated) {
1210
+ return true;
1211
+ }
1212
+ router.navigate(['/login'], {
1213
+ queryParams: { returnUrl: state.url }
1214
+ });
1215
+ return false;
1216
+ }));
1217
+ };
1218
+
1219
+ const adminGuard = (route, state) => {
1220
+ const authService = inject(AuthService);
1221
+ const router = inject(Router);
1222
+ const user = authService.getCurrentUser();
1223
+ if (!user) {
1224
+ router.navigate(['/login']);
1225
+ return false;
1226
+ }
1227
+ if (!user.isStaff) {
1228
+ router.navigate(['/metadata']);
1229
+ return false;
1230
+ }
1231
+ return true;
1232
+ };
1233
+
1234
+ // Global flag to prevent multiple simultaneous token refresh attempts
1235
+ let isRefreshing = false;
1236
+ // Subject to coordinate requests waiting for token refresh
1237
+ const refreshTokenSubject = new BehaviorSubject(null);
1238
+ const authInterceptor = (req, next) => {
1239
+ const router = inject(Router);
1240
+ const http = inject(HttpClient);
1241
+ const config = inject(CUPCAKE_CORE_CONFIG);
1242
+ if (req.url.includes('/auth/login/') ||
1243
+ req.url.includes('/auth/token/') ||
1244
+ req.url.includes('/auth/orcid/') ||
1245
+ req.url.includes('/auth/register/') ||
1246
+ req.url.includes('/site-config/public/')) {
1247
+ return next(req);
1248
+ }
1249
+ const token = localStorage.getItem('ccvAccessToken');
1250
+ let authReq = req;
1251
+ if (token) {
1252
+ authReq = addTokenToRequest(req, token);
1253
+ }
1254
+ return next(authReq).pipe(catchError((error) => {
1255
+ if (error.status === 401) {
1256
+ return handle401Error(authReq, next, http, router, config);
1257
+ }
1258
+ return throwError(() => error);
1259
+ }));
1260
+ };
1261
+ function addTokenToRequest(request, token) {
1262
+ return request.clone({
1263
+ setHeaders: {
1264
+ Authorization: `Bearer ${token}`
1265
+ }
1266
+ });
1267
+ }
1268
+ function handle401Error(request, next, http, router, config) {
1269
+ if (!isRefreshing) {
1270
+ isRefreshing = true;
1271
+ refreshTokenSubject.next(null);
1272
+ const refreshToken = localStorage.getItem('ccvRefreshToken');
1273
+ if (refreshToken) {
1274
+ return http.post(`${config.apiUrl}/auth/token/refresh/`, {
1275
+ refresh: refreshToken
1276
+ }).pipe(switchMap((tokenResponse) => {
1277
+ isRefreshing = false;
1278
+ // Update stored tokens
1279
+ localStorage.setItem('ccvAccessToken', tokenResponse.access);
1280
+ refreshTokenSubject.next(tokenResponse.access);
1281
+ // Notify AuthService that token was refreshed
1282
+ if (typeof window !== 'undefined') {
1283
+ window.dispatchEvent(new CustomEvent('tokenRefreshed'));
1284
+ }
1285
+ // Retry the original request with new token
1286
+ return next(addTokenToRequest(request, tokenResponse.access));
1287
+ }), catchError((refreshError) => {
1288
+ // Refresh failed, clear tokens and redirect to login
1289
+ isRefreshing = false;
1290
+ localStorage.removeItem('ccvAccessToken');
1291
+ localStorage.removeItem('ccvRefreshToken');
1292
+ refreshTokenSubject.next(null);
1293
+ // Notify AuthService that auth was cleared
1294
+ if (typeof window !== 'undefined') {
1295
+ window.dispatchEvent(new CustomEvent('authCleared'));
1296
+ }
1297
+ router.navigate(['/login'], {
1298
+ queryParams: { returnUrl: router.url }
1299
+ });
1300
+ return throwError(() => refreshError);
1301
+ }));
1302
+ }
1303
+ else {
1304
+ // No refresh token, clear storage and redirect
1305
+ isRefreshing = false;
1306
+ localStorage.removeItem('ccvAccessToken');
1307
+ localStorage.removeItem('ccvRefreshToken');
1308
+ // Notify AuthService that auth was cleared
1309
+ if (typeof window !== 'undefined') {
1310
+ window.dispatchEvent(new CustomEvent('authCleared'));
1311
+ }
1312
+ router.navigate(['/login'], {
1313
+ queryParams: { returnUrl: router.url }
1314
+ });
1315
+ return throwError(() => new Error('No refresh token available'));
1316
+ }
1317
+ }
1318
+ else {
1319
+ // If refresh is in progress, wait for it to complete
1320
+ return refreshTokenSubject.pipe(filter((token) => token !== null), take(1), switchMap((token) => {
1321
+ return next(addTokenToRequest(request, token));
1322
+ }));
1323
+ }
1324
+ }
1325
+
1326
+ class LoginComponent {
1327
+ authService = inject(AuthService);
1328
+ fb = inject(FormBuilder);
1329
+ router = inject(Router);
1330
+ route = inject(ActivatedRoute);
1331
+ siteConfigService = inject(SiteConfigService);
1332
+ apiService = inject(ApiService);
1333
+ loginForm;
1334
+ loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
1335
+ error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : []));
1336
+ success = signal(null, ...(ngDevMode ? [{ debugName: "success" }] : []));
1337
+ // Observable for site configuration
1338
+ siteConfig$ = this.siteConfigService.config$;
1339
+ // Auth configuration signals
1340
+ authConfig = signal(null, ...(ngDevMode ? [{ debugName: "authConfig" }] : []));
1341
+ registrationStatus = signal(null, ...(ngDevMode ? [{ debugName: "registrationStatus" }] : []));
1342
+ constructor() {
1343
+ this.loginForm = this.fb.group({
1344
+ username: ['', [Validators.required]],
1345
+ password: ['', [Validators.required]]
1346
+ });
1347
+ }
1348
+ returnUrl = '/';
1349
+ ngOnInit() {
1350
+ // Load auth configuration
1351
+ this.loadAuthConfig();
1352
+ // Get return URL from query params
1353
+ this.route.queryParams.subscribe(params => {
1354
+ this.returnUrl = params['returnUrl'] || '/';
1355
+ // Check for ORCID callback parameters
1356
+ if (params['code'] && params['state']) {
1357
+ this.handleORCIDCallback(params['code'], params['state']);
1358
+ }
1359
+ else if (params['error']) {
1360
+ this.error.set(`ORCID authentication failed: ${params['error']}`);
1361
+ }
1362
+ // Check for registration success message
1363
+ if (params['registered'] === 'true') {
1364
+ this.success.set('Registration successful! You can now log in with your credentials.');
1365
+ if (params['username']) {
1366
+ this.loginForm.patchValue({ username: params['username'] });
1367
+ }
1368
+ }
1369
+ });
1370
+ // Subscribe to authentication state changes to handle token refresh scenarios
1371
+ this.authService.isAuthenticated$.subscribe(isAuthenticated => {
1372
+ if (isAuthenticated) {
1373
+ this.router.navigate([this.returnUrl]);
1374
+ }
1375
+ });
1376
+ }
1377
+ /**
1378
+ * Load authentication configuration to determine available login options
1379
+ */
1380
+ loadAuthConfig() {
1381
+ this.apiService.getAuthConfig().subscribe({
1382
+ next: (config) => {
1383
+ this.authConfig.set(config);
1384
+ },
1385
+ error: (error) => {
1386
+ console.warn('Could not load auth config:', error);
1387
+ // Use defaults if config loading fails
1388
+ this.authConfig.set({
1389
+ registrationEnabled: false,
1390
+ orcidLoginEnabled: false,
1391
+ regularLoginEnabled: true
1392
+ });
1393
+ }
1394
+ });
1395
+ this.apiService.getRegistrationStatus().subscribe({
1396
+ next: (status) => {
1397
+ this.registrationStatus.set(status);
1398
+ },
1399
+ error: (error) => {
1400
+ console.warn('Could not load registration status:', error);
1401
+ }
1402
+ });
1403
+ }
1404
+ /**
1405
+ * Handle traditional username/password login
1406
+ */
1407
+ onSubmit() {
1408
+ if (this.loginForm.valid) {
1409
+ this.loading.set(true);
1410
+ this.error.set(null);
1411
+ const { username, password } = this.loginForm.value;
1412
+ this.authService.login(username, password).subscribe({
1413
+ next: (response) => {
1414
+ this.success.set('Login successful!');
1415
+ setTimeout(() => {
1416
+ this.router.navigate([this.returnUrl]);
1417
+ }, 1000);
1418
+ },
1419
+ error: (error) => {
1420
+ this.loading.set(false);
1421
+ this.error.set(error.error?.detail || 'Login failed. Please check your credentials.');
1422
+ }
1423
+ });
1424
+ }
1425
+ }
1426
+ /**
1427
+ * Initiate ORCID OAuth login
1428
+ */
1429
+ loginWithORCID() {
1430
+ this.loading.set(true);
1431
+ this.error.set(null);
1432
+ this.authService.initiateORCIDLogin().subscribe({
1433
+ next: (response) => {
1434
+ sessionStorage.setItem('orcid_state', response.state);
1435
+ window.location.href = response.authorizationUrl;
1436
+ },
1437
+ error: (error) => {
1438
+ this.loading.set(false);
1439
+ this.error.set(error.error?.error || 'Failed to initiate ORCID login.');
1440
+ }
1441
+ });
1442
+ }
1443
+ /**
1444
+ * Handle ORCID OAuth callback
1445
+ */
1446
+ handleORCIDCallback(code, state) {
1447
+ this.loading.set(true);
1448
+ this.error.set(null);
1449
+ const storedState = sessionStorage.getItem('orcid_state');
1450
+ if (storedState !== state) {
1451
+ this.error.set('Invalid state parameter. Possible security issue.');
1452
+ this.loading.set(false);
1453
+ return;
1454
+ }
1455
+ sessionStorage.removeItem('orcid_state');
1456
+ this.authService.handleORCIDCallback(code, state).subscribe({
1457
+ next: (response) => {
1458
+ this.success.set(`Welcome, ${response.user.firstName || response.user.username}!`);
1459
+ setTimeout(() => {
1460
+ this.router.navigate([this.returnUrl]);
1461
+ }, 1000);
1462
+ },
1463
+ error: (error) => {
1464
+ this.loading.set(false);
1465
+ this.error.set(error.error?.error || 'ORCID authentication failed.');
1466
+ }
1467
+ });
1468
+ }
1469
+ /**
1470
+ * Clear error message
1471
+ */
1472
+ clearError() {
1473
+ this.error.set(null);
1474
+ }
1475
+ /**
1476
+ * Clear success message
1477
+ */
1478
+ clearSuccess() {
1479
+ this.success.set(null);
1480
+ }
1481
+ /**
1482
+ * Computed signals for UI display logic
1483
+ */
1484
+ shouldShowOrcidLogin = computed(() => this.authConfig()?.orcidLoginEnabled === true, ...(ngDevMode ? [{ debugName: "shouldShowOrcidLogin" }] : []));
1485
+ shouldShowRegistration = computed(() => this.registrationStatus()?.registrationEnabled === true, ...(ngDevMode ? [{ debugName: "shouldShowRegistration" }] : []));
1486
+ shouldShowRegularLogin = computed(() => this.authConfig()?.regularLoginEnabled !== false, ...(ngDevMode ? [{ debugName: "shouldShowRegularLogin" }] : []));
1487
+ registrationMessage = computed(() => this.registrationStatus()?.message || 'Registration is currently enabled', ...(ngDevMode ? [{ debugName: "registrationMessage" }] : []));
1488
+ /**
1489
+ * Navigate to registration page
1490
+ */
1491
+ goToRegister() {
1492
+ this.router.navigate(['/register'], {
1493
+ queryParams: { returnUrl: this.returnUrl }
1494
+ });
1495
+ }
1496
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: LoginComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1497
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.7", type: LoginComponent, isStandalone: true, selector: "app-login", ngImport: i0, template: "<div class=\"container\">\r\n <div class=\"login-wrapper\">\r\n <div class=\"card\">\r\n <div class=\"card-body\">\r\n @if (siteConfig$ | async; as config) {\r\n <h3 class=\"card-title text-center mb-4\">\r\n <i class=\"bi bi-flask me-2\"></i>{{ config.siteName }}\r\n </h3>\r\n }\r\n\r\n <!-- Success Alert -->\r\n @if (success()) {\r\n <ngb-alert type=\"success\" (closed)=\"clearSuccess()\" [dismissible]=\"true\">\r\n {{ success() }}\r\n </ngb-alert>\r\n }\r\n\r\n <!-- Error Alert -->\r\n @if (error()) {\r\n <ngb-alert type=\"danger\" (closed)=\"clearError()\" [dismissible]=\"true\">\r\n {{ error() }}\r\n </ngb-alert>\r\n }\r\n\r\n <!-- ORCID Login Section -->\r\n @if (shouldShowOrcidLogin()) {\r\n <div class=\"mb-4\">\r\n <button \r\n type=\"button\" \r\n class=\"btn btn-primary w-100 mb-3\"\r\n [disabled]=\"loading()\"\r\n (click)=\"loginWithORCID()\">\r\n @if (loading()) {\r\n <span class=\"spinner-border spinner-border-sm me-2\" aria-hidden=\"true\"></span>\r\n }\r\n <i class=\"bi bi-person-badge me-2\"></i>\r\n Login with ORCID\r\n </button>\r\n <p class=\"text-muted text-center small\">\r\n Sign in with your ORCID account for seamless access\r\n </p>\r\n </div>\r\n }\r\n\r\n <!-- Divider -->\r\n @if (shouldShowOrcidLogin() && shouldShowRegularLogin()) {\r\n <div class=\"text-center mb-4\">\r\n <span class=\"text-muted\">or</span>\r\n </div>\r\n }\r\n\r\n <!-- Traditional Login Form -->\r\n @if (shouldShowRegularLogin()) {\r\n <form [formGroup]=\"loginForm\" (ngSubmit)=\"onSubmit()\">\r\n <div class=\"mb-3\">\r\n <label for=\"username\" class=\"form-label\">Username</label>\r\n <div class=\"input-group\">\r\n <span class=\"input-group-text\">\r\n <i class=\"bi bi-person\"></i>\r\n </span>\r\n <input \r\n type=\"text\" \r\n class=\"form-control\" \r\n id=\"username\"\r\n formControlName=\"username\"\r\n [class.is-invalid]=\"loginForm.get('username')?.invalid && loginForm.get('username')?.touched\"\r\n placeholder=\"Enter your username\">\r\n </div>\r\n @if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) {\r\n <div class=\"invalid-feedback d-block\">\r\n Username is required\r\n </div>\r\n }\r\n </div>\r\n\r\n <div class=\"mb-3\">\r\n <label for=\"password\" class=\"form-label\">Password</label>\r\n <div class=\"input-group\">\r\n <span class=\"input-group-text\">\r\n <i class=\"bi bi-lock\"></i>\r\n </span>\r\n <input \r\n type=\"password\" \r\n class=\"form-control\" \r\n id=\"password\"\r\n formControlName=\"password\"\r\n [class.is-invalid]=\"loginForm.get('password')?.invalid && loginForm.get('password')?.touched\"\r\n placeholder=\"Enter your password\">\r\n </div>\r\n @if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) {\r\n <div class=\"invalid-feedback d-block\">\r\n Password is required\r\n </div>\r\n }\r\n </div>\r\n\r\n <button \r\n type=\"submit\" \r\n class=\"btn btn-outline-primary w-100\"\r\n [disabled]=\"loginForm.invalid || loading()\">\r\n @if (loading()) {\r\n <span class=\"spinner-border spinner-border-sm me-2\" aria-hidden=\"true\"></span>\r\n }\r\n <i class=\"bi bi-box-arrow-in-right me-2\"></i>\r\n Sign In\r\n </button>\r\n </form>\r\n }\r\n\r\n <!-- Registration Information -->\r\n @if (shouldShowRegistration()) {\r\n <div class=\"mt-4 text-center\">\r\n <div class=\"alert alert-info py-2\" role=\"alert\">\r\n <i class=\"bi bi-person-plus me-1\"></i>\r\n <strong>New Users:</strong> {{ registrationMessage() }}\r\n <div class=\"mt-2\">\r\n <button type=\"button\" class=\"btn btn-outline-primary btn-sm\" (click)=\"goToRegister()\">\r\n <i class=\"bi bi-person-plus me-1\"></i>Create Account\r\n </button>\r\n </div>\r\n <div class=\"mt-1\">\r\n <small class=\"text-muted\">\r\n @if (shouldShowOrcidLogin()) {\r\n <span>You can also sign in with ORCID to create an account</span>\r\n }\r\n </small>\r\n </div>\r\n </div>\r\n </div>\r\n }\r\n\r\n <!-- Additional Information -->\r\n @if (siteConfig$ | async; as config) {\r\n <div class=\"mt-4 text-center\">\r\n <p class=\"text-muted small\">\r\n {{ config.siteName }} - Scientific Metadata Management\r\n </p>\r\n @if (shouldShowOrcidLogin() && !shouldShowRegistration()) {\r\n <p class=\"text-muted small\">\r\n <i class=\"bi bi-info-circle me-1\"></i>\r\n New users can sign in directly with ORCID\r\n </p>\r\n }\r\n </div>\r\n }\r\n </div>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".container{min-height:100vh;display:flex;align-items:center;justify-content:center;background:none;overflow:auto}.login-wrapper{width:100%;max-width:450px}.card{border:none;border-radius:12px;box-shadow:0 8px 24px var(--cupcake-shadow);background:var(--cupcake-card-bg);backdrop-filter:blur(10px);width:100%}.card-body{padding:2rem}.card-title{color:var(--cupcake-text);font-weight:600}.btn-primary{background:linear-gradient(135deg,var(--cupcake-primary) 0%,var(--cupcake-primary-dark) 100%);border:none;border-radius:8px;padding:12px;font-weight:500;transition:all .3s ease}.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(var(--cupcake-primary-rgb),.3)}.btn-outline-primary{border-radius:8px;padding:12px;font-weight:500;transition:all .3s ease}.btn-outline-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(var(--cupcake-primary-rgb),.2)}.input-group-text{background:var(--cupcake-bg-tertiary);border-color:var(--cupcake-border);color:var(--cupcake-text-muted)}.form-control{border-radius:0 8px 8px 0;transition:all .3s ease}.form-control:focus{box-shadow:0 0 0 .2rem rgba(var(--cupcake-primary-rgb),.15);border-color:var(--cupcake-primary)}.input-group-text:first-child{border-radius:8px 0 0 8px}.spinner-border-sm{width:1rem;height:1rem}ngb-alert{border-radius:8px;border:none}ngb-alert.alert-success{background:var(--bs-success-bg-subtle, rgba(25, 135, 84, .1));color:var(--bs-success-text-emphasis, #0f5132);border:1px solid var(--bs-success-border-subtle, rgba(25, 135, 84, .2))}ngb-alert.alert-danger{background:var(--bs-danger-bg-subtle, rgba(220, 53, 69, .1));color:var(--bs-danger-text-emphasis, #842029);border:1px solid var(--bs-danger-border-subtle, rgba(220, 53, 69, .2))}:root[data-bs-theme=dark] ngb-alert.alert-success,.dark-mode ngb-alert.alert-success{background:#19875426;color:#75b798;border:1px solid rgba(25,135,84,.3)}:root[data-bs-theme=dark] ngb-alert.alert-danger,.dark-mode ngb-alert.alert-danger{background:#dc354526;color:#ea868f;border:1px solid rgba(220,53,69,.3)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: NgbAlert, selector: "ngb-alert", inputs: ["animation", "dismissible", "type"], outputs: ["closed"], exportAs: ["ngbAlert"] }, { kind: "pipe", type: i2.AsyncPipe, name: "async" }] });
1498
+ }
1499
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: LoginComponent, decorators: [{
1500
+ type: Component,
1501
+ args: [{ selector: 'app-login', standalone: true, imports: [CommonModule, ReactiveFormsModule, NgbAlert], template: "<div class=\"container\">\r\n <div class=\"login-wrapper\">\r\n <div class=\"card\">\r\n <div class=\"card-body\">\r\n @if (siteConfig$ | async; as config) {\r\n <h3 class=\"card-title text-center mb-4\">\r\n <i class=\"bi bi-flask me-2\"></i>{{ config.siteName }}\r\n </h3>\r\n }\r\n\r\n <!-- Success Alert -->\r\n @if (success()) {\r\n <ngb-alert type=\"success\" (closed)=\"clearSuccess()\" [dismissible]=\"true\">\r\n {{ success() }}\r\n </ngb-alert>\r\n }\r\n\r\n <!-- Error Alert -->\r\n @if (error()) {\r\n <ngb-alert type=\"danger\" (closed)=\"clearError()\" [dismissible]=\"true\">\r\n {{ error() }}\r\n </ngb-alert>\r\n }\r\n\r\n <!-- ORCID Login Section -->\r\n @if (shouldShowOrcidLogin()) {\r\n <div class=\"mb-4\">\r\n <button \r\n type=\"button\" \r\n class=\"btn btn-primary w-100 mb-3\"\r\n [disabled]=\"loading()\"\r\n (click)=\"loginWithORCID()\">\r\n @if (loading()) {\r\n <span class=\"spinner-border spinner-border-sm me-2\" aria-hidden=\"true\"></span>\r\n }\r\n <i class=\"bi bi-person-badge me-2\"></i>\r\n Login with ORCID\r\n </button>\r\n <p class=\"text-muted text-center small\">\r\n Sign in with your ORCID account for seamless access\r\n </p>\r\n </div>\r\n }\r\n\r\n <!-- Divider -->\r\n @if (shouldShowOrcidLogin() && shouldShowRegularLogin()) {\r\n <div class=\"text-center mb-4\">\r\n <span class=\"text-muted\">or</span>\r\n </div>\r\n }\r\n\r\n <!-- Traditional Login Form -->\r\n @if (shouldShowRegularLogin()) {\r\n <form [formGroup]=\"loginForm\" (ngSubmit)=\"onSubmit()\">\r\n <div class=\"mb-3\">\r\n <label for=\"username\" class=\"form-label\">Username</label>\r\n <div class=\"input-group\">\r\n <span class=\"input-group-text\">\r\n <i class=\"bi bi-person\"></i>\r\n </span>\r\n <input \r\n type=\"text\" \r\n class=\"form-control\" \r\n id=\"username\"\r\n formControlName=\"username\"\r\n [class.is-invalid]=\"loginForm.get('username')?.invalid && loginForm.get('username')?.touched\"\r\n placeholder=\"Enter your username\">\r\n </div>\r\n @if (loginForm.get('username')?.invalid && loginForm.get('username')?.touched) {\r\n <div class=\"invalid-feedback d-block\">\r\n Username is required\r\n </div>\r\n }\r\n </div>\r\n\r\n <div class=\"mb-3\">\r\n <label for=\"password\" class=\"form-label\">Password</label>\r\n <div class=\"input-group\">\r\n <span class=\"input-group-text\">\r\n <i class=\"bi bi-lock\"></i>\r\n </span>\r\n <input \r\n type=\"password\" \r\n class=\"form-control\" \r\n id=\"password\"\r\n formControlName=\"password\"\r\n [class.is-invalid]=\"loginForm.get('password')?.invalid && loginForm.get('password')?.touched\"\r\n placeholder=\"Enter your password\">\r\n </div>\r\n @if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) {\r\n <div class=\"invalid-feedback d-block\">\r\n Password is required\r\n </div>\r\n }\r\n </div>\r\n\r\n <button \r\n type=\"submit\" \r\n class=\"btn btn-outline-primary w-100\"\r\n [disabled]=\"loginForm.invalid || loading()\">\r\n @if (loading()) {\r\n <span class=\"spinner-border spinner-border-sm me-2\" aria-hidden=\"true\"></span>\r\n }\r\n <i class=\"bi bi-box-arrow-in-right me-2\"></i>\r\n Sign In\r\n </button>\r\n </form>\r\n }\r\n\r\n <!-- Registration Information -->\r\n @if (shouldShowRegistration()) {\r\n <div class=\"mt-4 text-center\">\r\n <div class=\"alert alert-info py-2\" role=\"alert\">\r\n <i class=\"bi bi-person-plus me-1\"></i>\r\n <strong>New Users:</strong> {{ registrationMessage() }}\r\n <div class=\"mt-2\">\r\n <button type=\"button\" class=\"btn btn-outline-primary btn-sm\" (click)=\"goToRegister()\">\r\n <i class=\"bi bi-person-plus me-1\"></i>Create Account\r\n </button>\r\n </div>\r\n <div class=\"mt-1\">\r\n <small class=\"text-muted\">\r\n @if (shouldShowOrcidLogin()) {\r\n <span>You can also sign in with ORCID to create an account</span>\r\n }\r\n </small>\r\n </div>\r\n </div>\r\n </div>\r\n }\r\n\r\n <!-- Additional Information -->\r\n @if (siteConfig$ | async; as config) {\r\n <div class=\"mt-4 text-center\">\r\n <p class=\"text-muted small\">\r\n {{ config.siteName }} - Scientific Metadata Management\r\n </p>\r\n @if (shouldShowOrcidLogin() && !shouldShowRegistration()) {\r\n <p class=\"text-muted small\">\r\n <i class=\"bi bi-info-circle me-1\"></i>\r\n New users can sign in directly with ORCID\r\n </p>\r\n }\r\n </div>\r\n }\r\n </div>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".container{min-height:100vh;display:flex;align-items:center;justify-content:center;background:none;overflow:auto}.login-wrapper{width:100%;max-width:450px}.card{border:none;border-radius:12px;box-shadow:0 8px 24px var(--cupcake-shadow);background:var(--cupcake-card-bg);backdrop-filter:blur(10px);width:100%}.card-body{padding:2rem}.card-title{color:var(--cupcake-text);font-weight:600}.btn-primary{background:linear-gradient(135deg,var(--cupcake-primary) 0%,var(--cupcake-primary-dark) 100%);border:none;border-radius:8px;padding:12px;font-weight:500;transition:all .3s ease}.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(var(--cupcake-primary-rgb),.3)}.btn-outline-primary{border-radius:8px;padding:12px;font-weight:500;transition:all .3s ease}.btn-outline-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(var(--cupcake-primary-rgb),.2)}.input-group-text{background:var(--cupcake-bg-tertiary);border-color:var(--cupcake-border);color:var(--cupcake-text-muted)}.form-control{border-radius:0 8px 8px 0;transition:all .3s ease}.form-control:focus{box-shadow:0 0 0 .2rem rgba(var(--cupcake-primary-rgb),.15);border-color:var(--cupcake-primary)}.input-group-text:first-child{border-radius:8px 0 0 8px}.spinner-border-sm{width:1rem;height:1rem}ngb-alert{border-radius:8px;border:none}ngb-alert.alert-success{background:var(--bs-success-bg-subtle, rgba(25, 135, 84, .1));color:var(--bs-success-text-emphasis, #0f5132);border:1px solid var(--bs-success-border-subtle, rgba(25, 135, 84, .2))}ngb-alert.alert-danger{background:var(--bs-danger-bg-subtle, rgba(220, 53, 69, .1));color:var(--bs-danger-text-emphasis, #842029);border:1px solid var(--bs-danger-border-subtle, rgba(220, 53, 69, .2))}:root[data-bs-theme=dark] ngb-alert.alert-success,.dark-mode ngb-alert.alert-success{background:#19875426;color:#75b798;border:1px solid rgba(25,135,84,.3)}:root[data-bs-theme=dark] ngb-alert.alert-danger,.dark-mode ngb-alert.alert-danger{background:#dc354526;color:#ea868f;border:1px solid rgba(220,53,69,.3)}\n"] }]
1502
+ }], ctorParameters: () => [] });
1503
+
1504
+ class RegisterComponent {
1505
+ apiService = inject(ApiService);
1506
+ fb = inject(FormBuilder);
1507
+ router = inject(Router);
1508
+ route = inject(ActivatedRoute);
1509
+ siteConfigService = inject(SiteConfigService);
1510
+ registrationForm;
1511
+ loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
1512
+ error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : []));
1513
+ success = signal(null, ...(ngDevMode ? [{ debugName: "success" }] : []));
1514
+ // Observable for site configuration
1515
+ siteConfig$ = this.siteConfigService.config$;
1516
+ // Registration status signals
1517
+ registrationStatus = signal(null, ...(ngDevMode ? [{ debugName: "registrationStatus" }] : []));
1518
+ registrationEnabled = signal(false, ...(ngDevMode ? [{ debugName: "registrationEnabled" }] : []));
1519
+ returnUrl = '/login';
1520
+ constructor() {
1521
+ this.registrationForm = this.fb.group({
1522
+ username: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(150)]],
1523
+ email: ['', [Validators.required, Validators.email, Validators.maxLength(254)]],
1524
+ firstName: ['', [Validators.required, Validators.maxLength(30)]],
1525
+ lastName: ['', [Validators.required, Validators.maxLength(30)]],
1526
+ password: ['', [Validators.required, Validators.minLength(8)]],
1527
+ confirmPassword: ['', [Validators.required]]
1528
+ }, { validators: this.passwordMatchValidator });
1529
+ }
1530
+ ngOnInit() {
1531
+ // Check registration status
1532
+ this.checkRegistrationStatus();
1533
+ // Get return URL from query params
1534
+ this.route.queryParams.subscribe(params => {
1535
+ this.returnUrl = params['returnUrl'] || '/login';
1536
+ });
1537
+ }
1538
+ /**
1539
+ * Check if registration is enabled
1540
+ */
1541
+ checkRegistrationStatus() {
1542
+ this.apiService.getRegistrationStatus().subscribe({
1543
+ next: (status) => {
1544
+ this.registrationStatus.set(status);
1545
+ this.registrationEnabled.set(status.registrationEnabled);
1546
+ if (!this.registrationEnabled()) {
1547
+ this.error.set(status.message || 'Registration is currently disabled');
1548
+ }
1549
+ },
1550
+ error: (error) => {
1551
+ this.error.set('Unable to check registration status. Please try again later.');
1552
+ }
1553
+ });
1554
+ }
1555
+ /**
1556
+ * Custom validator to check if passwords match
1557
+ */
1558
+ passwordMatchValidator(form) {
1559
+ const password = form.get('password');
1560
+ const confirmPassword = form.get('confirmPassword');
1561
+ if (password && confirmPassword && password.value !== confirmPassword.value) {
1562
+ return { passwordMismatch: true };
1563
+ }
1564
+ return null;
1565
+ }
1566
+ /**
1567
+ * Handle form submission
1568
+ */
1569
+ onSubmit() {
1570
+ if (this.registrationForm.valid && this.registrationEnabled()) {
1571
+ this.loading.set(true);
1572
+ this.error.set(null);
1573
+ const registrationData = {
1574
+ username: this.registrationForm.get('username')?.value,
1575
+ email: this.registrationForm.get('email')?.value,
1576
+ firstName: this.registrationForm.get('firstName')?.value,
1577
+ lastName: this.registrationForm.get('lastName')?.value,
1578
+ password: this.registrationForm.get('password')?.value,
1579
+ passwordConfirm: this.registrationForm.get('confirmPassword')?.value
1580
+ };
1581
+ this.apiService.registerUser(registrationData).subscribe({
1582
+ next: (response) => {
1583
+ this.loading.set(false);
1584
+ this.success.set(response.message || 'Registration successful! You can now log in with your credentials.');
1585
+ // Redirect to login after successful registration
1586
+ setTimeout(() => {
1587
+ this.router.navigate(['/login'], {
1588
+ queryParams: {
1589
+ returnUrl: this.returnUrl,
1590
+ registered: 'true',
1591
+ username: registrationData.username
1592
+ }
1593
+ });
1594
+ }, 2000);
1595
+ },
1596
+ error: (error) => {
1597
+ this.loading.set(false);
1598
+ // Handle specific validation errors
1599
+ if (error.error && typeof error.error === 'object') {
1600
+ const errors = [];
1601
+ for (const [field, messages] of Object.entries(error.error)) {
1602
+ if (Array.isArray(messages)) {
1603
+ errors.push(`${field}: ${messages.join(', ')}`);
1604
+ }
1605
+ else {
1606
+ errors.push(`${field}: ${messages}`);
1607
+ }
1608
+ }
1609
+ this.error.set(errors.join('\n'));
1610
+ }
1611
+ else {
1612
+ this.error.set(error.error?.message || error.message || 'Registration failed. Please try again.');
1613
+ }
1614
+ }
1615
+ });
1616
+ }
1617
+ }
1618
+ /**
1619
+ * Navigate back to login
1620
+ */
1621
+ goToLogin() {
1622
+ this.router.navigate(['/login'], {
1623
+ queryParams: { returnUrl: this.returnUrl }
1624
+ });
1625
+ }
1626
+ /**
1627
+ * Clear error message
1628
+ */
1629
+ clearError() {
1630
+ this.error.set(null);
1631
+ }
1632
+ /**
1633
+ * Clear success message
1634
+ */
1635
+ clearSuccess() {
1636
+ this.success.set(null);
1637
+ }
1638
+ /**
1639
+ * Get field error message
1640
+ */
1641
+ getFieldErrorMessage(fieldName) {
1642
+ const field = this.registrationForm.get(fieldName);
1643
+ if (field && field.invalid && field.touched) {
1644
+ if (field.errors?.['required']) {
1645
+ return `${this.getFieldDisplayName(fieldName)} is required`;
1646
+ }
1647
+ if (field.errors?.['email']) {
1648
+ return 'Please enter a valid email address';
1649
+ }
1650
+ if (field.errors?.['minlength']) {
1651
+ const requiredLength = field.errors?.['minlength'].requiredLength;
1652
+ return `${this.getFieldDisplayName(fieldName)} must be at least ${requiredLength} characters`;
1653
+ }
1654
+ if (field.errors?.['maxlength']) {
1655
+ const requiredLength = field.errors?.['maxlength'].requiredLength;
1656
+ return `${this.getFieldDisplayName(fieldName)} must be no more than ${requiredLength} characters`;
1657
+ }
1658
+ }
1659
+ if (fieldName === 'confirmPassword' && this.registrationForm.errors?.['passwordMismatch'] && field?.touched) {
1660
+ return 'Passwords do not match';
1661
+ }
1662
+ return null;
1663
+ }
1664
+ /**
1665
+ * Get user-friendly field display name
1666
+ */
1667
+ getFieldDisplayName(fieldName) {
1668
+ const displayNames = {
1669
+ username: 'Username',
1670
+ email: 'Email',
1671
+ firstName: 'First name',
1672
+ lastName: 'Last name',
1673
+ password: 'Password',
1674
+ confirmPassword: 'Confirm password'
1675
+ };
1676
+ return displayNames[fieldName] || fieldName;
1677
+ }
1678
+ /**
1679
+ * Check if a field has errors and should display error styling
1680
+ */
1681
+ hasFieldError(fieldName) {
1682
+ const field = this.registrationForm.get(fieldName);
1683
+ if (fieldName === 'confirmPassword') {
1684
+ return (field?.invalid && field?.touched) ||
1685
+ (this.registrationForm.errors?.['passwordMismatch'] && field?.touched) || false;
1686
+ }
1687
+ return field?.invalid && field?.touched || false;
1688
+ }
1689
+ /**
1690
+ * Computed signals for UI display logic
1691
+ */
1692
+ isRegistrationDisabled = computed(() => !this.registrationEnabled(), ...(ngDevMode ? [{ debugName: "isRegistrationDisabled" }] : []));
1693
+ canSubmitForm = computed(() => {
1694
+ return this.registrationForm.valid && this.registrationEnabled() && !this.loading();
1695
+ }, ...(ngDevMode ? [{ debugName: "canSubmitForm" }] : []));
1696
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: RegisterComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1697
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.7", type: RegisterComponent, isStandalone: true, selector: "app-register", ngImport: i0, template: "<div class=\"container\">\n <div class=\"register-wrapper\">\n <div class=\"card\">\n <div class=\"card-body\">\n @if (siteConfig$ | async; as config) {\n <h3 class=\"card-title text-center mb-4\">\n <i class=\"bi bi-person-plus me-2\"></i>Create Account - {{ config.siteName }}\n </h3>\n }\n\n <!-- Success Alert -->\n @if (success()) {\n <ngb-alert type=\"success\" (closed)=\"clearSuccess()\" [dismissible]=\"true\">\n <i class=\"bi bi-check-circle me-2\"></i>{{ success() }}\n </ngb-alert>\n }\n\n <!-- Error Alert -->\n @if (error()) {\n <ngb-alert type=\"danger\" (closed)=\"clearError()\" [dismissible]=\"true\">\n <i class=\"bi bi-exclamation-triangle me-2\"></i>\n <span [innerHTML]=\"error()?.replace('\\n', '<br>') || ''\"></span>\n </ngb-alert>\n }\n\n <!-- Registration Disabled Message -->\n @if (!registrationEnabled() && !loading()) {\n <div class=\"alert alert-warning text-center\">\n <i class=\"bi bi-info-circle me-2\"></i>\n <strong>Registration Unavailable</strong>\n <div class=\"mt-2\">\n {{ registrationStatus()?.message || 'Registration is currently disabled' }}\n </div>\n <div class=\"mt-3\">\n <button type=\"button\" class=\"btn btn-outline-primary\" (click)=\"goToLogin()\">\n <i class=\"bi bi-arrow-left me-1\"></i>Back to Login\n </button>\n </div>\n </div>\n }\n\n <!-- Registration Form -->\n @if (registrationEnabled()) {\n <form [formGroup]=\"registrationForm\" (ngSubmit)=\"onSubmit()\">\n <!-- Username -->\n <div class=\"mb-3\">\n <label for=\"username\" class=\"form-label\">Username <span class=\"text-danger\">*</span></label>\n <div class=\"input-group\">\n <span class=\"input-group-text\">\n <i class=\"bi bi-person\"></i>\n </span>\n <input \n type=\"text\" \n class=\"form-control\" \n id=\"username\"\n formControlName=\"username\"\n [class.is-invalid]=\"hasFieldError('username')\"\n placeholder=\"Choose a username\">\n </div>\n @if (getFieldErrorMessage('username')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('username') }}\n </div>\n }\n <div class=\"form-text\">\n Username must be 3-150 characters. Only letters, numbers, and @/./+/-/_ allowed.\n </div>\n </div>\n\n <!-- Email -->\n <div class=\"mb-3\">\n <label for=\"email\" class=\"form-label\">Email Address <span class=\"text-danger\">*</span></label>\n <div class=\"input-group\">\n <span class=\"input-group-text\">\n <i class=\"bi bi-envelope\"></i>\n </span>\n <input \n type=\"email\" \n class=\"form-control\" \n id=\"email\"\n formControlName=\"email\"\n [class.is-invalid]=\"hasFieldError('email')\"\n placeholder=\"Enter your email address\">\n </div>\n @if (getFieldErrorMessage('email')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('email') }}\n </div>\n }\n </div>\n\n <!-- First Name and Last Name -->\n <div class=\"row\">\n <div class=\"col-md-6 mb-3\">\n <label for=\"first_name\" class=\"form-label\">First Name <span class=\"text-danger\">*</span></label>\n <input \n type=\"text\" \n class=\"form-control\" \n id=\"first_name\"\n formControlName=\"firstName\"\n [class.is-invalid]=\"hasFieldError('firstName')\"\n placeholder=\"First name\">\n @if (getFieldErrorMessage('firstName')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('firstName') }}\n </div>\n }\n </div>\n <div class=\"col-md-6 mb-3\">\n <label for=\"last_name\" class=\"form-label\">Last Name <span class=\"text-danger\">*</span></label>\n <input \n type=\"text\" \n class=\"form-control\" \n id=\"last_name\"\n formControlName=\"lastName\"\n [class.is-invalid]=\"hasFieldError('lastName')\"\n placeholder=\"Last name\">\n @if (getFieldErrorMessage('lastName')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('lastName') }}\n </div>\n }\n </div>\n </div>\n\n <!-- Password -->\n <div class=\"mb-3\">\n <label for=\"password\" class=\"form-label\">Password <span class=\"text-danger\">*</span></label>\n <div class=\"input-group\">\n <span class=\"input-group-text\">\n <i class=\"bi bi-lock\"></i>\n </span>\n <input \n type=\"password\" \n class=\"form-control\" \n id=\"password\"\n formControlName=\"password\"\n [class.is-invalid]=\"hasFieldError('password')\"\n placeholder=\"Create a password\">\n </div>\n @if (getFieldErrorMessage('password')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('password') }}\n </div>\n }\n <div class=\"form-text\">\n Password must be at least 8 characters long.\n </div>\n </div>\n\n <!-- Confirm Password -->\n <div class=\"mb-4\">\n <label for=\"confirm_password\" class=\"form-label\">Confirm Password <span class=\"text-danger\">*</span></label>\n <div class=\"input-group\">\n <span class=\"input-group-text\">\n <i class=\"bi bi-lock-fill\"></i>\n </span>\n <input \n type=\"password\" \n class=\"form-control\" \n id=\"confirm_password\"\n formControlName=\"confirmPassword\"\n [class.is-invalid]=\"hasFieldError('confirmPassword')\"\n placeholder=\"Confirm your password\">\n </div>\n @if (getFieldErrorMessage('confirmPassword')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('confirmPassword') }}\n </div>\n }\n </div>\n\n <!-- Submit Button -->\n <div class=\"d-grid gap-2 mb-3\">\n <button \n type=\"submit\" \n class=\"btn btn-primary\"\n [disabled]=\"registrationForm.invalid || loading() || !registrationEnabled()\">\n @if (loading()) {\n <span class=\"spinner-border spinner-border-sm me-2\" aria-hidden=\"true\"></span>\n }\n @if (!loading()) {\n <i class=\"bi bi-person-plus me-2\"></i>\n }\n Create Account\n </button>\n </div>\n\n <!-- Back to Login -->\n <div class=\"text-center\">\n <p class=\"text-muted small mb-2\">Already have an account?</p>\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"goToLogin()\">\n <i class=\"bi bi-arrow-left me-1\"></i>Back to Login\n </button>\n </div>\n </form>\n }\n\n <!-- Additional Information -->\n @if (siteConfig$ | async; as config) {\n <div class=\"mt-4 text-center\">\n <p class=\"text-muted small\">\n {{ config.siteName }} - Scientific Metadata Management\n </p>\n </div>\n }\n </div>\n </div>\n </div>\n</div>\n", styles: [".container{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem 1rem}.register-wrapper{width:100%;max-width:500px}.card{border:none;border-radius:1rem;box-shadow:0 .5rem 1rem #00000026}.card-body{padding:3rem}.card-title{font-size:1.5rem;font-weight:600;color:var(--bs-primary)}.form-control{border-radius:.5rem;border:1px solid var(--bs-border-color);padding:.75rem 1rem}.form-control:focus{border-color:var(--bs-primary);box-shadow:0 0 0 .2rem rgba(var(--bs-primary-rgb),.25)}.input-group-text{border-radius:.5rem 0 0 .5rem;background:var(--bs-light);border:1px solid var(--bs-border-color)}.input-group-text i{color:var(--bs-secondary)}.input-group .form-control{border-radius:0 .5rem .5rem 0}.btn{border-radius:.5rem;padding:.75rem 1.5rem;font-weight:500;transition:all .2s ease-in-out}.btn-primary{background:linear-gradient(135deg,var(--bs-primary) 0%,color-mix(in srgb,var(--bs-primary) 80%,black) 100%);border:none}.btn-primary:hover{transform:translateY(-1px);box-shadow:0 .25rem .5rem rgba(var(--bs-primary-rgb),.3)}.btn-primary:disabled{transform:none;box-shadow:none}.alert{border-radius:.75rem;border:none}.alert.alert-success{background:rgba(var(--bs-success-rgb),.1);color:var(--bs-success);border-left:4px solid var(--bs-success)}.alert.alert-danger{background:rgba(var(--bs-danger-rgb),.1);color:var(--bs-danger);border-left:4px solid var(--bs-danger)}.alert.alert-warning{background:rgba(var(--bs-warning-rgb),.1);color:var(--bs-warning-emphasis);border-left:4px solid var(--bs-warning)}.invalid-feedback{font-size:.875rem;color:var(--bs-danger)}.form-text{font-size:.8rem;color:var(--bs-secondary)}@media (max-width: 576px){.card-body{padding:2rem 1.5rem}.card-title{font-size:1.25rem}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: NgbAlert, selector: "ngb-alert", inputs: ["animation", "dismissible", "type"], outputs: ["closed"], exportAs: ["ngbAlert"] }, { kind: "pipe", type: i2.AsyncPipe, name: "async" }] });
1698
+ }
1699
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: RegisterComponent, decorators: [{
1700
+ type: Component,
1701
+ args: [{ selector: 'app-register', standalone: true, imports: [CommonModule, ReactiveFormsModule, NgbAlert], template: "<div class=\"container\">\n <div class=\"register-wrapper\">\n <div class=\"card\">\n <div class=\"card-body\">\n @if (siteConfig$ | async; as config) {\n <h3 class=\"card-title text-center mb-4\">\n <i class=\"bi bi-person-plus me-2\"></i>Create Account - {{ config.siteName }}\n </h3>\n }\n\n <!-- Success Alert -->\n @if (success()) {\n <ngb-alert type=\"success\" (closed)=\"clearSuccess()\" [dismissible]=\"true\">\n <i class=\"bi bi-check-circle me-2\"></i>{{ success() }}\n </ngb-alert>\n }\n\n <!-- Error Alert -->\n @if (error()) {\n <ngb-alert type=\"danger\" (closed)=\"clearError()\" [dismissible]=\"true\">\n <i class=\"bi bi-exclamation-triangle me-2\"></i>\n <span [innerHTML]=\"error()?.replace('\\n', '<br>') || ''\"></span>\n </ngb-alert>\n }\n\n <!-- Registration Disabled Message -->\n @if (!registrationEnabled() && !loading()) {\n <div class=\"alert alert-warning text-center\">\n <i class=\"bi bi-info-circle me-2\"></i>\n <strong>Registration Unavailable</strong>\n <div class=\"mt-2\">\n {{ registrationStatus()?.message || 'Registration is currently disabled' }}\n </div>\n <div class=\"mt-3\">\n <button type=\"button\" class=\"btn btn-outline-primary\" (click)=\"goToLogin()\">\n <i class=\"bi bi-arrow-left me-1\"></i>Back to Login\n </button>\n </div>\n </div>\n }\n\n <!-- Registration Form -->\n @if (registrationEnabled()) {\n <form [formGroup]=\"registrationForm\" (ngSubmit)=\"onSubmit()\">\n <!-- Username -->\n <div class=\"mb-3\">\n <label for=\"username\" class=\"form-label\">Username <span class=\"text-danger\">*</span></label>\n <div class=\"input-group\">\n <span class=\"input-group-text\">\n <i class=\"bi bi-person\"></i>\n </span>\n <input \n type=\"text\" \n class=\"form-control\" \n id=\"username\"\n formControlName=\"username\"\n [class.is-invalid]=\"hasFieldError('username')\"\n placeholder=\"Choose a username\">\n </div>\n @if (getFieldErrorMessage('username')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('username') }}\n </div>\n }\n <div class=\"form-text\">\n Username must be 3-150 characters. Only letters, numbers, and @/./+/-/_ allowed.\n </div>\n </div>\n\n <!-- Email -->\n <div class=\"mb-3\">\n <label for=\"email\" class=\"form-label\">Email Address <span class=\"text-danger\">*</span></label>\n <div class=\"input-group\">\n <span class=\"input-group-text\">\n <i class=\"bi bi-envelope\"></i>\n </span>\n <input \n type=\"email\" \n class=\"form-control\" \n id=\"email\"\n formControlName=\"email\"\n [class.is-invalid]=\"hasFieldError('email')\"\n placeholder=\"Enter your email address\">\n </div>\n @if (getFieldErrorMessage('email')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('email') }}\n </div>\n }\n </div>\n\n <!-- First Name and Last Name -->\n <div class=\"row\">\n <div class=\"col-md-6 mb-3\">\n <label for=\"first_name\" class=\"form-label\">First Name <span class=\"text-danger\">*</span></label>\n <input \n type=\"text\" \n class=\"form-control\" \n id=\"first_name\"\n formControlName=\"firstName\"\n [class.is-invalid]=\"hasFieldError('firstName')\"\n placeholder=\"First name\">\n @if (getFieldErrorMessage('firstName')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('firstName') }}\n </div>\n }\n </div>\n <div class=\"col-md-6 mb-3\">\n <label for=\"last_name\" class=\"form-label\">Last Name <span class=\"text-danger\">*</span></label>\n <input \n type=\"text\" \n class=\"form-control\" \n id=\"last_name\"\n formControlName=\"lastName\"\n [class.is-invalid]=\"hasFieldError('lastName')\"\n placeholder=\"Last name\">\n @if (getFieldErrorMessage('lastName')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('lastName') }}\n </div>\n }\n </div>\n </div>\n\n <!-- Password -->\n <div class=\"mb-3\">\n <label for=\"password\" class=\"form-label\">Password <span class=\"text-danger\">*</span></label>\n <div class=\"input-group\">\n <span class=\"input-group-text\">\n <i class=\"bi bi-lock\"></i>\n </span>\n <input \n type=\"password\" \n class=\"form-control\" \n id=\"password\"\n formControlName=\"password\"\n [class.is-invalid]=\"hasFieldError('password')\"\n placeholder=\"Create a password\">\n </div>\n @if (getFieldErrorMessage('password')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('password') }}\n </div>\n }\n <div class=\"form-text\">\n Password must be at least 8 characters long.\n </div>\n </div>\n\n <!-- Confirm Password -->\n <div class=\"mb-4\">\n <label for=\"confirm_password\" class=\"form-label\">Confirm Password <span class=\"text-danger\">*</span></label>\n <div class=\"input-group\">\n <span class=\"input-group-text\">\n <i class=\"bi bi-lock-fill\"></i>\n </span>\n <input \n type=\"password\" \n class=\"form-control\" \n id=\"confirm_password\"\n formControlName=\"confirmPassword\"\n [class.is-invalid]=\"hasFieldError('confirmPassword')\"\n placeholder=\"Confirm your password\">\n </div>\n @if (getFieldErrorMessage('confirmPassword')) {\n <div class=\"invalid-feedback d-block\">\n {{ getFieldErrorMessage('confirmPassword') }}\n </div>\n }\n </div>\n\n <!-- Submit Button -->\n <div class=\"d-grid gap-2 mb-3\">\n <button \n type=\"submit\" \n class=\"btn btn-primary\"\n [disabled]=\"registrationForm.invalid || loading() || !registrationEnabled()\">\n @if (loading()) {\n <span class=\"spinner-border spinner-border-sm me-2\" aria-hidden=\"true\"></span>\n }\n @if (!loading()) {\n <i class=\"bi bi-person-plus me-2\"></i>\n }\n Create Account\n </button>\n </div>\n\n <!-- Back to Login -->\n <div class=\"text-center\">\n <p class=\"text-muted small mb-2\">Already have an account?</p>\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"goToLogin()\">\n <i class=\"bi bi-arrow-left me-1\"></i>Back to Login\n </button>\n </div>\n </form>\n }\n\n <!-- Additional Information -->\n @if (siteConfig$ | async; as config) {\n <div class=\"mt-4 text-center\">\n <p class=\"text-muted small\">\n {{ config.siteName }} - Scientific Metadata Management\n </p>\n </div>\n }\n </div>\n </div>\n </div>\n</div>\n", styles: [".container{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem 1rem}.register-wrapper{width:100%;max-width:500px}.card{border:none;border-radius:1rem;box-shadow:0 .5rem 1rem #00000026}.card-body{padding:3rem}.card-title{font-size:1.5rem;font-weight:600;color:var(--bs-primary)}.form-control{border-radius:.5rem;border:1px solid var(--bs-border-color);padding:.75rem 1rem}.form-control:focus{border-color:var(--bs-primary);box-shadow:0 0 0 .2rem rgba(var(--bs-primary-rgb),.25)}.input-group-text{border-radius:.5rem 0 0 .5rem;background:var(--bs-light);border:1px solid var(--bs-border-color)}.input-group-text i{color:var(--bs-secondary)}.input-group .form-control{border-radius:0 .5rem .5rem 0}.btn{border-radius:.5rem;padding:.75rem 1.5rem;font-weight:500;transition:all .2s ease-in-out}.btn-primary{background:linear-gradient(135deg,var(--bs-primary) 0%,color-mix(in srgb,var(--bs-primary) 80%,black) 100%);border:none}.btn-primary:hover{transform:translateY(-1px);box-shadow:0 .25rem .5rem rgba(var(--bs-primary-rgb),.3)}.btn-primary:disabled{transform:none;box-shadow:none}.alert{border-radius:.75rem;border:none}.alert.alert-success{background:rgba(var(--bs-success-rgb),.1);color:var(--bs-success);border-left:4px solid var(--bs-success)}.alert.alert-danger{background:rgba(var(--bs-danger-rgb),.1);color:var(--bs-danger);border-left:4px solid var(--bs-danger)}.alert.alert-warning{background:rgba(var(--bs-warning-rgb),.1);color:var(--bs-warning-emphasis);border-left:4px solid var(--bs-warning)}.invalid-feedback{font-size:.875rem;color:var(--bs-danger)}.form-text{font-size:.8rem;color:var(--bs-secondary)}@media (max-width: 576px){.card-body{padding:2rem 1.5rem}.card-title{font-size:1.25rem}}\n"] }]
1702
+ }], ctorParameters: () => [] });
1703
+
1704
+ class UserManagementComponent {
1705
+ fb = inject(FormBuilder);
1706
+ userManagementService = inject(UserManagementService);
1707
+ authService = inject(AuthService);
1708
+ modalService = inject(NgbModal);
1709
+ // Data
1710
+ users = signal([], ...(ngDevMode ? [{ debugName: "users" }] : []));
1711
+ totalUsers = signal(0, ...(ngDevMode ? [{ debugName: "totalUsers" }] : []));
1712
+ isLoading = signal(false, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
1713
+ // Search and filters
1714
+ searchForm;
1715
+ searchTerm = signal('', ...(ngDevMode ? [{ debugName: "searchTerm" }] : []));
1716
+ staffFilter = signal('', ...(ngDevMode ? [{ debugName: "staffFilter" }] : []));
1717
+ activeFilter = signal('', ...(ngDevMode ? [{ debugName: "activeFilter" }] : []));
1718
+ // Pagination
1719
+ currentPage = signal(1, ...(ngDevMode ? [{ debugName: "currentPage" }] : []));
1720
+ pageSize = signal(10, ...(ngDevMode ? [{ debugName: "pageSize" }] : []));
1721
+ // UI state
1722
+ selectedUser = signal(null, ...(ngDevMode ? [{ debugName: "selectedUser" }] : []));
1723
+ isCreatingUser = signal(false, ...(ngDevMode ? [{ debugName: "isCreatingUser" }] : []));
1724
+ isUpdatingUser = signal(false, ...(ngDevMode ? [{ debugName: "isUpdatingUser" }] : []));
1725
+ isDeletingUser = signal(false, ...(ngDevMode ? [{ debugName: "isDeletingUser" }] : []));
1726
+ isResettingPassword = signal(false, ...(ngDevMode ? [{ debugName: "isResettingPassword" }] : []));
1727
+ // Messages
1728
+ successMessage = signal('', ...(ngDevMode ? [{ debugName: "successMessage" }] : []));
1729
+ errorMessage = signal('', ...(ngDevMode ? [{ debugName: "errorMessage" }] : []));
1730
+ // Make Math available in template
1731
+ Math = Math;
1732
+ // Computed signals
1733
+ totalPages = computed(() => Math.ceil(this.totalUsers() / this.pageSize()), ...(ngDevMode ? [{ debugName: "totalPages" }] : []));
1734
+ pages = computed(() => {
1735
+ const total = this.totalPages();
1736
+ const current = this.currentPage();
1737
+ const pages = [];
1738
+ // Show up to 5 pages around current page
1739
+ const start = Math.max(1, current - 2);
1740
+ const end = Math.min(total, current + 2);
1741
+ for (let i = start; i <= end; i++) {
1742
+ pages.push(i);
1743
+ }
1744
+ return pages;
1745
+ }, ...(ngDevMode ? [{ debugName: "pages" }] : []));
1746
+ showingFrom = computed(() => ((this.currentPage() - 1) * this.pageSize()) + 1, ...(ngDevMode ? [{ debugName: "showingFrom" }] : []));
1747
+ showingTo = computed(() => Math.min(this.currentPage() * this.pageSize(), this.totalUsers()), ...(ngDevMode ? [{ debugName: "showingTo" }] : []));
1748
+ hasResults = computed(() => this.users().length > 0, ...(ngDevMode ? [{ debugName: "hasResults" }] : []));
1749
+ canGoToPreviousPage = computed(() => this.currentPage() > 1, ...(ngDevMode ? [{ debugName: "canGoToPreviousPage" }] : []));
1750
+ canGoToNextPage = computed(() => this.currentPage() < this.totalPages(), ...(ngDevMode ? [{ debugName: "canGoToNextPage" }] : []));
1751
+ // Additional computed signals for UI state
1752
+ isAnyActionInProgress = computed(() => this.isCreatingUser() ||
1753
+ this.isUpdatingUser() ||
1754
+ this.isDeletingUser() ||
1755
+ this.isResettingPassword(), ...(ngDevMode ? [{ debugName: "isAnyActionInProgress" }] : []));
1756
+ selectedUserDisplayName = computed(() => {
1757
+ const user = this.selectedUser();
1758
+ return user ? this.getUserDisplayName(user) : '';
1759
+ }, ...(ngDevMode ? [{ debugName: "selectedUserDisplayName" }] : []));
1760
+ // Computed signal for modal states
1761
+ hasSelectedUser = computed(() => this.selectedUser() !== null, ...(ngDevMode ? [{ debugName: "hasSelectedUser" }] : []));
1762
+ // Computed signals for form states
1763
+ canCreateUser = computed(() => !this.isCreatingUser(), ...(ngDevMode ? [{ debugName: "canCreateUser" }] : []));
1764
+ canUpdateUser = computed(() => !this.isUpdatingUser(), ...(ngDevMode ? [{ debugName: "canUpdateUser" }] : []));
1765
+ canResetPassword = computed(() => !this.isResettingPassword(), ...(ngDevMode ? [{ debugName: "canResetPassword" }] : []));
1766
+ constructor() {
1767
+ this.searchForm = this.fb.group({
1768
+ search: [''],
1769
+ isStaff: [''],
1770
+ isActive: ['']
1771
+ });
1772
+ }
1773
+ ngOnInit() {
1774
+ this.loadUsers();
1775
+ // Subscribe to search form changes with debounce
1776
+ this.searchForm.valueChanges.pipe(debounceTime(300)).subscribe((values) => {
1777
+ this.searchTerm.set(values.search || '');
1778
+ this.staffFilter.set(values.isStaff || '');
1779
+ this.activeFilter.set(values.isActive || '');
1780
+ this.currentPage.set(1);
1781
+ });
1782
+ }
1783
+ loadUsers() {
1784
+ this.isLoading.set(true);
1785
+ this.errorMessage.set('');
1786
+ const searchParams = {
1787
+ search: this.searchTerm() || undefined,
1788
+ isStaff: this.staffFilter() !== '' ? this.staffFilter() === 'true' : undefined,
1789
+ isActive: this.activeFilter() !== '' ? this.activeFilter() === 'true' : undefined,
1790
+ page: this.currentPage(),
1791
+ pageSize: this.pageSize()
1792
+ };
1793
+ this.userManagementService.getUsers(searchParams).subscribe({
1794
+ next: (response) => {
1795
+ this.users.set(response.results);
1796
+ this.totalUsers.set(response.count);
1797
+ this.isLoading.set(false);
1798
+ },
1799
+ error: (error) => {
1800
+ this.errorMessage.set('Failed to load users');
1801
+ this.isLoading.set(false);
1802
+ }
1803
+ });
1804
+ }
1805
+ openCreateUserModal(content) {
1806
+ this.selectedUser.set(null);
1807
+ this.modalService.open(content, { size: 'lg' });
1808
+ }
1809
+ openEditUserModal(content, user) {
1810
+ this.selectedUser.set(user);
1811
+ this.modalService.open(content, { size: 'lg' });
1812
+ }
1813
+ openPasswordResetModal(content, user) {
1814
+ this.selectedUser.set(user);
1815
+ this.modalService.open(content);
1816
+ }
1817
+ createUser(userData) {
1818
+ this.isCreatingUser.set(true);
1819
+ this.errorMessage.set('');
1820
+ this.userManagementService.createUser(userData).subscribe({
1821
+ next: (response) => {
1822
+ this.successMessage.set(`User ${userData.username} created successfully`);
1823
+ this.loadUsers();
1824
+ this.isCreatingUser.set(false);
1825
+ this.modalService.dismissAll();
1826
+ },
1827
+ error: (error) => {
1828
+ this.errorMessage.set(error.error?.message || 'Failed to create user');
1829
+ this.isCreatingUser.set(false);
1830
+ }
1831
+ });
1832
+ }
1833
+ updateUser(userId, userData) {
1834
+ this.isUpdatingUser.set(true);
1835
+ this.errorMessage.set('');
1836
+ this.userManagementService.updateUser(userId, userData).subscribe({
1837
+ next: (updatedUser) => {
1838
+ this.successMessage.set(`User ${updatedUser.username} updated successfully`);
1839
+ this.loadUsers();
1840
+ this.isUpdatingUser.set(false);
1841
+ this.modalService.dismissAll();
1842
+ },
1843
+ error: (error) => {
1844
+ this.errorMessage.set(error.error?.message || 'Failed to update user');
1845
+ this.isUpdatingUser.set(false);
1846
+ }
1847
+ });
1848
+ }
1849
+ deleteUser(user) {
1850
+ if (!user.id)
1851
+ return;
1852
+ if (confirm(`Are you sure you want to delete user "${user.username}"? This action cannot be undone.`)) {
1853
+ this.isDeletingUser.set(true);
1854
+ this.errorMessage.set('');
1855
+ this.userManagementService.deleteUser(user.id).subscribe({
1856
+ next: () => {
1857
+ this.successMessage.set(`User ${user.username} deleted successfully`);
1858
+ this.loadUsers();
1859
+ this.isDeletingUser.set(false);
1860
+ },
1861
+ error: (error) => {
1862
+ this.errorMessage.set(error.error?.message || 'Failed to delete user');
1863
+ this.isDeletingUser.set(false);
1864
+ }
1865
+ });
1866
+ }
1867
+ }
1868
+ resetUserPassword(userId, passwordData) {
1869
+ this.isResettingPassword.set(true);
1870
+ this.errorMessage.set('');
1871
+ this.userManagementService.resetUserPassword(userId, passwordData).subscribe({
1872
+ next: (response) => {
1873
+ this.successMessage.set(response.message || 'Password reset successfully');
1874
+ this.isResettingPassword.set(false);
1875
+ this.modalService.dismissAll();
1876
+ },
1877
+ error: (error) => {
1878
+ this.errorMessage.set(error.error?.message || 'Failed to reset password');
1879
+ this.isResettingPassword.set(false);
1880
+ }
1881
+ });
1882
+ }
1883
+ toggleUserStatus(user) {
1884
+ if (!user.id)
1885
+ return;
1886
+ const newStatus = !user.isActive;
1887
+ const action = newStatus ? 'activate' : 'deactivate';
1888
+ if (confirm(`Are you sure you want to ${action} user "${user.username}"?`)) {
1889
+ this.updateUser(user.id, { isActive: newStatus });
1890
+ }
1891
+ }
1892
+ toggleStaffStatus(user) {
1893
+ if (!user.id)
1894
+ return;
1895
+ const newStatus = !user.isStaff;
1896
+ const action = newStatus ? 'grant staff privileges to' : 'revoke staff privileges from';
1897
+ if (confirm(`Are you sure you want to ${action} user "${user.username}"?`)) {
1898
+ this.updateUser(user.id, { isStaff: newStatus });
1899
+ }
1900
+ }
1901
+ onPageChange(page) {
1902
+ this.currentPage.set(page);
1903
+ this.loadUsers();
1904
+ }
1905
+ clearMessages() {
1906
+ this.successMessage.set('');
1907
+ this.errorMessage.set('');
1908
+ }
1909
+ formatDate(dateString) {
1910
+ return this.userManagementService.formatDate(dateString);
1911
+ }
1912
+ getUserDisplayName(user) {
1913
+ return this.userManagementService.getUserDisplayName(user);
1914
+ }
1915
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: UserManagementComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1916
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.7", type: UserManagementComponent, isStandalone: true, selector: "app-user-management", ngImport: i0, template: "<div class=\"user-management-container\">\n <!-- Header Section -->\n <nav class=\"navbar navbar-expand-lg bg-body-tertiary shadow-sm\">\n <div class=\"container-fluid\">\n <div class=\"d-flex align-items-center justify-content-between w-100\">\n <div class=\"d-flex align-items-center\">\n <h4 class=\"mb-0\">\n <i class=\"bi bi-people-fill me-2 text-primary\"></i>User Management\n </h4>\n <span class=\"badge bg-primary ms-3\">{{ totalUsers() }} users</span>\n </div>\n\n <div class=\"d-flex gap-2\">\n <button\n type=\"button\"\n class=\"btn btn-primary btn-sm\"\n (click)=\"openCreateUserModal(createUserModal)\"\n [disabled]=\"isLoading()\">\n <i class=\"bi bi-person-plus me-1\"></i>Create User\n </button>\n </div>\n </div>\n </div>\n </nav>\n\n <div class=\"users-content p-4\">\n <!-- Search and Filters Section -->\n <div class=\"card mb-4\">\n <div class=\"card-header\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-search me-2\"></i>Search & Filters\n </h5>\n </div>\n <div class=\"card-body\">\n <form [formGroup]=\"searchForm\" class=\"row g-3\">\n <div class=\"col-md-4\">\n <label for=\"search\" class=\"form-label\">Search</label>\n <div class=\"input-group\">\n <span class=\"input-group-text\">\n <i class=\"bi bi-search\"></i>\n </span>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"search\"\n formControlName=\"search\"\n placeholder=\"Search by username, email, or name\">\n </div>\n </div>\n\n <div class=\"col-md-4\">\n <label for=\"isStaff\" class=\"form-label\">Staff Status</label>\n <select class=\"form-select\" id=\"isStaff\" formControlName=\"isStaff\">\n <option value=\"\">All Users</option>\n <option value=\"true\">Staff Only</option>\n <option value=\"false\">Regular Users</option>\n </select>\n </div>\n\n <div class=\"col-md-4\">\n <label for=\"isActive\" class=\"form-label\">Account Status</label>\n <select class=\"form-select\" id=\"isActive\" formControlName=\"isActive\">\n <option value=\"\">All Accounts</option>\n <option value=\"true\">Active Only</option>\n <option value=\"false\">Inactive Only</option>\n </select>\n </div>\n </form>\n </div>\n </div>\n\n <!-- Success/Error Messages -->\n @if (successMessage()) {\n <div class=\"alert alert-success alert-dismissible\">\n <i class=\"bi bi-check-circle me-2\"></i>{{ successMessage() }}\n <button type=\"button\" class=\"btn-close\" (click)=\"clearMessages()\"></button>\n </div>\n }\n\n @if (errorMessage()) {\n <div class=\"alert alert-danger alert-dismissible\">\n <i class=\"bi bi-exclamation-triangle me-2\"></i>{{ errorMessage() }}\n <button type=\"button\" class=\"btn-close\" (click)=\"clearMessages()\"></button>\n </div>\n }\n\n <!-- Users Table -->\n <div class=\"card\">\n <div class=\"card-header d-flex justify-content-between align-items-center\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-table me-2\"></i>Users\n <span class=\"badge bg-primary ms-2\">{{ users().length }}</span>\n </h5>\n @if (isLoading()) {\n <div class=\"spinner-border spinner-border-sm text-primary\">\n <span class=\"visually-hidden\">Loading...</span>\n </div>\n }\n </div>\n <div class=\"card-body p-0\">\n @if (hasResults()) {\n <div class=\"table-responsive\">\n <table class=\"table table-hover mb-0\">\n <thead class=\"table-light\">\n <tr>\n <th>User</th>\n <th>Contact</th>\n <th>Status</th>\n <th>Joined</th>\n <th>Last Login</th>\n <th class=\"text-end\">Actions</th>\n </tr>\n </thead>\n <tbody>\n @for (user of users(); track user.id) {\n <tr>\n <td>\n <div class=\"d-flex align-items-center\">\n <div>\n <div class=\"fw-semibold\">{{ getUserDisplayName(user) }}</div>\n <small class=\"text-muted\">@{{ user.username }}</small>\n </div>\n </div>\n </td>\n <td>\n <div>{{ user.email }}</div>\n </td>\n <td>\n <div class=\"d-flex gap-1 flex-wrap\">\n @if (user.isActive) {\n <span class=\"badge bg-success\">Active</span>\n } @else {\n <span class=\"badge bg-danger\">Inactive</span>\n }\n @if (user.isStaff) {\n <span class=\"badge bg-warning\">Staff</span>\n }\n </div>\n </td>\n <td>{{ formatDate(user.dateJoined) }}</td>\n <td>{{ formatDate(user.lastLogin) }}</td>\n <td class=\"text-end\">\n <div class=\"btn-group btn-group-sm\" role=\"group\">\n <button\n type=\"button\"\n class=\"btn btn-outline-primary\"\n (click)=\"openEditUserModal(editUserModal, user)\"\n title=\"Edit user\">\n <i class=\"bi bi-pencil\"></i>\n </button>\n <button\n type=\"button\"\n class=\"btn btn-outline-warning\"\n (click)=\"openPasswordResetModal(passwordResetModal, user)\"\n title=\"Reset password\">\n <i class=\"bi bi-shield-lock\"></i>\n </button>\n <button\n type=\"button\"\n class=\"btn\"\n [class]=\"user.isActive ? 'btn-outline-secondary' : 'btn-outline-success'\"\n (click)=\"toggleUserStatus(user)\"\n [title]=\"user.isActive ? 'Deactivate user' : 'Activate user'\">\n <i class=\"bi\" [class]=\"user.isActive ? 'bi-person-dash' : 'bi-person-check'\"></i>\n </button>\n <button\n type=\"button\"\n class=\"btn\"\n [class]=\"user.isStaff ? 'btn-outline-secondary' : 'btn-outline-info'\"\n (click)=\"toggleStaffStatus(user)\"\n [title]=\"user.isStaff ? 'Remove staff privileges' : 'Grant staff privileges'\">\n <i class=\"bi\" [class]=\"user.isStaff ? 'bi-person-x' : 'bi-person-badge'\"></i>\n </button>\n <button\n type=\"button\"\n class=\"btn btn-outline-danger\"\n (click)=\"deleteUser(user)\"\n title=\"Delete user\">\n <i class=\"bi bi-trash\"></i>\n </button>\n </div>\n </td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n\n <!-- Pagination -->\n @if (totalPages() > 1) {\n <div class=\"d-flex justify-content-between align-items-center p-3 border-top\">\n <div class=\"text-muted\">\n Showing {{ showingFrom() }} to {{ showingTo() }} of {{ totalUsers() }} users\n </div>\n <nav>\n <ul class=\"pagination pagination-sm mb-0\">\n <li class=\"page-item\" [class.disabled]=\"!canGoToPreviousPage()\">\n <button class=\"page-link\" (click)=\"onPageChange(currentPage() - 1)\" [disabled]=\"!canGoToPreviousPage()\">\n <i class=\"bi bi-chevron-left\"></i>\n </button>\n </li>\n @for (page of pages(); track page) {\n <li class=\"page-item\" [class.active]=\"page === currentPage()\">\n <button class=\"page-link\" (click)=\"onPageChange(page)\">{{ page }}</button>\n </li>\n }\n <li class=\"page-item\" [class.disabled]=\"!canGoToNextPage()\">\n <button class=\"page-link\" (click)=\"onPageChange(currentPage() + 1)\" [disabled]=\"!canGoToNextPage()\">\n <i class=\"bi bi-chevron-right\"></i>\n </button>\n </li>\n </ul>\n </nav>\n </div>\n }\n } @else {\n <div class=\"text-center p-4\">\n <i class=\"bi bi-people display-4 text-muted\"></i>\n <p class=\"text-muted mt-2\">No users found</p>\n </div>\n }\n </div>\n </div>\n </div>\n</div>\n\n<!-- Create User Modal -->\n<ng-template #createUserModal let-modal>\n <div class=\"modal-header\">\n <h4 class=\"modal-title\">\n <i class=\"bi bi-person-plus me-2\"></i>Create User\n </h4>\n <button type=\"button\" class=\"btn-close\" (click)=\"modal.dismiss()\"></button>\n </div>\n <div class=\"modal-body\">\n <form #createForm=\"ngForm\" (ngSubmit)=\"createUser(createForm.value)\">\n <div class=\"row g-3\">\n <div class=\"col-md-6\">\n <label for=\"createUsername\" class=\"form-label\">Username</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"createUsername\"\n name=\"username\"\n ngModel\n required\n #usernameField=\"ngModel\">\n @if (usernameField.invalid && usernameField.touched) {\n <div class=\"text-danger small\">Username is required</div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"createEmail\" class=\"form-label\">Email</label>\n <input\n type=\"email\"\n class=\"form-control\"\n id=\"createEmail\"\n name=\"email\"\n ngModel\n required\n email\n #emailField=\"ngModel\">\n @if (emailField.invalid && emailField.touched) {\n <div class=\"text-danger small\">Valid email is required</div>\n }\n </div>\n </div>\n <div class=\"row g-3 mt-1\">\n <div class=\"col-md-6\">\n <label for=\"createFirstName\" class=\"form-label\">First Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"createFirstName\"\n name=\"firstName\"\n ngModel\n required\n #firstNameField=\"ngModel\">\n @if (firstNameField.invalid && firstNameField.touched) {\n <div class=\"text-danger small\">First name is required</div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"createLastName\" class=\"form-label\">Last Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"createLastName\"\n name=\"lastName\"\n ngModel\n required\n #lastNameField=\"ngModel\">\n @if (lastNameField.invalid && lastNameField.touched) {\n <div class=\"text-danger small\">Last name is required</div>\n }\n </div>\n </div>\n <div class=\"row g-3 mt-1\">\n <div class=\"col-md-6\">\n <label for=\"createPassword\" class=\"form-label\">Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"createPassword\"\n name=\"password\"\n ngModel\n required\n minlength=\"8\"\n #passwordField=\"ngModel\">\n @if (passwordField.invalid && passwordField.touched) {\n <div class=\"text-danger small\">Password must be at least 8 characters</div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"createPasswordConfirm\" class=\"form-label\">Confirm Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"createPasswordConfirm\"\n name=\"password_confirm\"\n ngModel\n required\n #passwordConfirmField=\"ngModel\">\n @if (passwordConfirmField.invalid && passwordConfirmField.touched) {\n <div class=\"text-danger small\">Password confirmation is required</div>\n }\n </div>\n </div>\n <div class=\"row g-3 mt-1\">\n <div class=\"col-md-6\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n id=\"createIsStaff\"\n name=\"isStaff\"\n ngModel>\n <label class=\"form-check-label\" for=\"createIsStaff\">\n Staff privileges\n </label>\n </div>\n </div>\n <div class=\"col-md-6\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n id=\"createIsActive\"\n name=\"isActive\"\n ngModel\n checked>\n <label class=\"form-check-label\" for=\"createIsActive\">\n Active account\n </label>\n </div>\n </div>\n </div>\n </form>\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"modal.dismiss()\">\n <i class=\"bi bi-x-circle me-1\"></i>Cancel\n </button>\n <button\n type=\"button\"\n class=\"btn btn-primary\"\n (click)=\"createUser(createForm.value)\"\n [disabled]=\"createForm.invalid || !canCreateUser()\">\n @if (isCreatingUser()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-person-plus me-1\"></i>Create User\n </button>\n </div>\n</ng-template>\n\n<!-- Edit User Modal -->\n<ng-template #editUserModal let-modal>\n <div class=\"modal-header\">\n <h4 class=\"modal-title\">\n <i class=\"bi bi-pencil me-2\"></i>Edit User\n </h4>\n <button type=\"button\" class=\"btn-close\" (click)=\"modal.dismiss()\"></button>\n </div>\n <div class=\"modal-body\">\n @if (hasSelectedUser()) {\n <form #editForm=\"ngForm\" id=\"editUserForm\" (ngSubmit)=\"updateUser(selectedUser()!.id!, editForm.value)\">\n <div class=\"row g-3\">\n <div class=\"col-md-6\">\n <label for=\"editUsername\" class=\"form-label\">Username</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"editUsername\"\n name=\"username\"\n [ngModel]=\"selectedUser()!.username\"\n required\n #editUsernameField=\"ngModel\">\n @if (editUsernameField.invalid && editUsernameField.touched) {\n <div class=\"text-danger small\">Username is required</div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"editEmail\" class=\"form-label\">Email</label>\n <input\n type=\"email\"\n class=\"form-control\"\n id=\"editEmail\"\n name=\"email\"\n [ngModel]=\"selectedUser()!.email\"\n required\n email\n #editEmailField=\"ngModel\">\n @if (editEmailField.invalid && editEmailField.touched) {\n <div class=\"text-danger small\">Valid email is required</div>\n }\n </div>\n </div>\n <div class=\"row g-3 mt-1\">\n <div class=\"col-md-6\">\n <label for=\"editFirstName\" class=\"form-label\">First Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"editFirstName\"\n name=\"firstName\"\n [ngModel]=\"selectedUser()!.firstName\"\n required\n #editFirstNameField=\"ngModel\">\n @if (editFirstNameField.invalid && editFirstNameField.touched) {\n <div class=\"text-danger small\">First name is required</div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"editLastName\" class=\"form-label\">Last Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"editLastName\"\n name=\"lastName\"\n [ngModel]=\"selectedUser()!.lastName\"\n required\n #editLastNameField=\"ngModel\">\n @if (editLastNameField.invalid && editLastNameField.touched) {\n <div class=\"text-danger small\">Last name is required</div>\n }\n </div>\n </div>\n <div class=\"row g-3 mt-1\">\n <div class=\"col-md-6\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n id=\"editIsStaff\"\n name=\"isStaff\"\n [ngModel]=\"selectedUser()!.isStaff\">\n <label class=\"form-check-label\" for=\"editIsStaff\">\n Staff privileges\n </label>\n </div>\n </div>\n <div class=\"col-md-6\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n id=\"editIsActive\"\n name=\"isActive\"\n [ngModel]=\"selectedUser()!.isActive\">\n <label class=\"form-check-label\" for=\"editIsActive\">\n Active account\n </label>\n </div>\n </div>\n </div>\n </form>\n }\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"modal.dismiss()\">\n <i class=\"bi bi-x-circle me-1\"></i>Cancel\n </button>\n @if (hasSelectedUser()) {\n <button\n type=\"submit\"\n class=\"btn btn-primary\"\n form=\"editUserForm\"\n [disabled]=\"!canUpdateUser()\">\n @if (isUpdatingUser()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-check-circle me-1\"></i>Update User\n </button>\n }\n </div>\n</ng-template>\n\n<!-- Password Reset Modal -->\n<ng-template #passwordResetModal let-modal>\n <div class=\"modal-header\">\n <h4 class=\"modal-title\">\n <i class=\"bi bi-shield-lock me-2\"></i>Reset Password\n </h4>\n <button type=\"button\" class=\"btn-close\" (click)=\"modal.dismiss()\"></button>\n </div>\n <div class=\"modal-body\">\n @if (hasSelectedUser()) {\n <div class=\"alert alert-warning\">\n <i class=\"bi bi-exclamation-triangle me-2\"></i>\n You are about to reset the password for user <strong>{{ selectedUserDisplayName() }}</strong>.\n </div>\n \n <form #resetForm=\"ngForm\" id=\"resetPasswordForm\" (ngSubmit)=\"resetUserPassword(selectedUser()!.id!, resetForm.value)\">\n <div class=\"mb-3\">\n <label for=\"resetNewPassword\" class=\"form-label\">New Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"resetNewPassword\"\n name=\"new_password\"\n ngModel\n required\n minlength=\"8\"\n #resetPasswordField=\"ngModel\">\n @if (resetPasswordField.invalid && resetPasswordField.touched) {\n <div class=\"text-danger small\">Password must be at least 8 characters</div>\n }\n </div>\n <div class=\"mb-3\">\n <label for=\"resetConfirmPassword\" class=\"form-label\">Confirm Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"resetConfirmPassword\"\n name=\"confirm_password\"\n ngModel\n required\n #resetConfirmField=\"ngModel\">\n @if (resetConfirmField.invalid && resetConfirmField.touched) {\n <div class=\"text-danger small\">Password confirmation is required</div>\n }\n </div>\n <div class=\"mb-3\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n id=\"forcePasswordChange\"\n name=\"force_password_change\"\n ngModel>\n <label class=\"form-check-label\" for=\"forcePasswordChange\">\n Force user to change password on next login\n </label>\n </div>\n </div>\n <div class=\"mb-3\">\n <label for=\"resetReason\" class=\"form-label\">Reason (optional)</label>\n <textarea\n class=\"form-control\"\n id=\"resetReason\"\n name=\"reason\"\n ngModel\n rows=\"2\"\n placeholder=\"Optional reason for password reset\"></textarea>\n </div>\n </form>\n }\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"modal.dismiss()\">\n <i class=\"bi bi-x-circle me-1\"></i>Cancel\n </button>\n @if (hasSelectedUser()) {\n <button\n type=\"submit\"\n class=\"btn btn-warning\"\n form=\"resetPasswordForm\"\n [disabled]=\"!canResetPassword()\">\n @if (isResettingPassword()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-shield-lock me-1\"></i>Reset Password\n </button>\n }\n </div>\n</ng-template>", styles: [""], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1$1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1$1.MinLengthValidator, selector: "[minlength][formControlName],[minlength][formControl],[minlength][ngModel]", inputs: ["minlength"] }, { kind: "directive", type: i1$1.EmailValidator, selector: "[email][formControlName],[email][formControl],[email][ngModel]", inputs: ["email"] }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: NgbModule }] });
1917
+ }
1918
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: UserManagementComponent, decorators: [{
1919
+ type: Component,
1920
+ args: [{ selector: 'app-user-management', standalone: true, imports: [CommonModule, ReactiveFormsModule, FormsModule, NgbModule], template: "<div class=\"user-management-container\">\n <!-- Header Section -->\n <nav class=\"navbar navbar-expand-lg bg-body-tertiary shadow-sm\">\n <div class=\"container-fluid\">\n <div class=\"d-flex align-items-center justify-content-between w-100\">\n <div class=\"d-flex align-items-center\">\n <h4 class=\"mb-0\">\n <i class=\"bi bi-people-fill me-2 text-primary\"></i>User Management\n </h4>\n <span class=\"badge bg-primary ms-3\">{{ totalUsers() }} users</span>\n </div>\n\n <div class=\"d-flex gap-2\">\n <button\n type=\"button\"\n class=\"btn btn-primary btn-sm\"\n (click)=\"openCreateUserModal(createUserModal)\"\n [disabled]=\"isLoading()\">\n <i class=\"bi bi-person-plus me-1\"></i>Create User\n </button>\n </div>\n </div>\n </div>\n </nav>\n\n <div class=\"users-content p-4\">\n <!-- Search and Filters Section -->\n <div class=\"card mb-4\">\n <div class=\"card-header\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-search me-2\"></i>Search & Filters\n </h5>\n </div>\n <div class=\"card-body\">\n <form [formGroup]=\"searchForm\" class=\"row g-3\">\n <div class=\"col-md-4\">\n <label for=\"search\" class=\"form-label\">Search</label>\n <div class=\"input-group\">\n <span class=\"input-group-text\">\n <i class=\"bi bi-search\"></i>\n </span>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"search\"\n formControlName=\"search\"\n placeholder=\"Search by username, email, or name\">\n </div>\n </div>\n\n <div class=\"col-md-4\">\n <label for=\"isStaff\" class=\"form-label\">Staff Status</label>\n <select class=\"form-select\" id=\"isStaff\" formControlName=\"isStaff\">\n <option value=\"\">All Users</option>\n <option value=\"true\">Staff Only</option>\n <option value=\"false\">Regular Users</option>\n </select>\n </div>\n\n <div class=\"col-md-4\">\n <label for=\"isActive\" class=\"form-label\">Account Status</label>\n <select class=\"form-select\" id=\"isActive\" formControlName=\"isActive\">\n <option value=\"\">All Accounts</option>\n <option value=\"true\">Active Only</option>\n <option value=\"false\">Inactive Only</option>\n </select>\n </div>\n </form>\n </div>\n </div>\n\n <!-- Success/Error Messages -->\n @if (successMessage()) {\n <div class=\"alert alert-success alert-dismissible\">\n <i class=\"bi bi-check-circle me-2\"></i>{{ successMessage() }}\n <button type=\"button\" class=\"btn-close\" (click)=\"clearMessages()\"></button>\n </div>\n }\n\n @if (errorMessage()) {\n <div class=\"alert alert-danger alert-dismissible\">\n <i class=\"bi bi-exclamation-triangle me-2\"></i>{{ errorMessage() }}\n <button type=\"button\" class=\"btn-close\" (click)=\"clearMessages()\"></button>\n </div>\n }\n\n <!-- Users Table -->\n <div class=\"card\">\n <div class=\"card-header d-flex justify-content-between align-items-center\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-table me-2\"></i>Users\n <span class=\"badge bg-primary ms-2\">{{ users().length }}</span>\n </h5>\n @if (isLoading()) {\n <div class=\"spinner-border spinner-border-sm text-primary\">\n <span class=\"visually-hidden\">Loading...</span>\n </div>\n }\n </div>\n <div class=\"card-body p-0\">\n @if (hasResults()) {\n <div class=\"table-responsive\">\n <table class=\"table table-hover mb-0\">\n <thead class=\"table-light\">\n <tr>\n <th>User</th>\n <th>Contact</th>\n <th>Status</th>\n <th>Joined</th>\n <th>Last Login</th>\n <th class=\"text-end\">Actions</th>\n </tr>\n </thead>\n <tbody>\n @for (user of users(); track user.id) {\n <tr>\n <td>\n <div class=\"d-flex align-items-center\">\n <div>\n <div class=\"fw-semibold\">{{ getUserDisplayName(user) }}</div>\n <small class=\"text-muted\">@{{ user.username }}</small>\n </div>\n </div>\n </td>\n <td>\n <div>{{ user.email }}</div>\n </td>\n <td>\n <div class=\"d-flex gap-1 flex-wrap\">\n @if (user.isActive) {\n <span class=\"badge bg-success\">Active</span>\n } @else {\n <span class=\"badge bg-danger\">Inactive</span>\n }\n @if (user.isStaff) {\n <span class=\"badge bg-warning\">Staff</span>\n }\n </div>\n </td>\n <td>{{ formatDate(user.dateJoined) }}</td>\n <td>{{ formatDate(user.lastLogin) }}</td>\n <td class=\"text-end\">\n <div class=\"btn-group btn-group-sm\" role=\"group\">\n <button\n type=\"button\"\n class=\"btn btn-outline-primary\"\n (click)=\"openEditUserModal(editUserModal, user)\"\n title=\"Edit user\">\n <i class=\"bi bi-pencil\"></i>\n </button>\n <button\n type=\"button\"\n class=\"btn btn-outline-warning\"\n (click)=\"openPasswordResetModal(passwordResetModal, user)\"\n title=\"Reset password\">\n <i class=\"bi bi-shield-lock\"></i>\n </button>\n <button\n type=\"button\"\n class=\"btn\"\n [class]=\"user.isActive ? 'btn-outline-secondary' : 'btn-outline-success'\"\n (click)=\"toggleUserStatus(user)\"\n [title]=\"user.isActive ? 'Deactivate user' : 'Activate user'\">\n <i class=\"bi\" [class]=\"user.isActive ? 'bi-person-dash' : 'bi-person-check'\"></i>\n </button>\n <button\n type=\"button\"\n class=\"btn\"\n [class]=\"user.isStaff ? 'btn-outline-secondary' : 'btn-outline-info'\"\n (click)=\"toggleStaffStatus(user)\"\n [title]=\"user.isStaff ? 'Remove staff privileges' : 'Grant staff privileges'\">\n <i class=\"bi\" [class]=\"user.isStaff ? 'bi-person-x' : 'bi-person-badge'\"></i>\n </button>\n <button\n type=\"button\"\n class=\"btn btn-outline-danger\"\n (click)=\"deleteUser(user)\"\n title=\"Delete user\">\n <i class=\"bi bi-trash\"></i>\n </button>\n </div>\n </td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n\n <!-- Pagination -->\n @if (totalPages() > 1) {\n <div class=\"d-flex justify-content-between align-items-center p-3 border-top\">\n <div class=\"text-muted\">\n Showing {{ showingFrom() }} to {{ showingTo() }} of {{ totalUsers() }} users\n </div>\n <nav>\n <ul class=\"pagination pagination-sm mb-0\">\n <li class=\"page-item\" [class.disabled]=\"!canGoToPreviousPage()\">\n <button class=\"page-link\" (click)=\"onPageChange(currentPage() - 1)\" [disabled]=\"!canGoToPreviousPage()\">\n <i class=\"bi bi-chevron-left\"></i>\n </button>\n </li>\n @for (page of pages(); track page) {\n <li class=\"page-item\" [class.active]=\"page === currentPage()\">\n <button class=\"page-link\" (click)=\"onPageChange(page)\">{{ page }}</button>\n </li>\n }\n <li class=\"page-item\" [class.disabled]=\"!canGoToNextPage()\">\n <button class=\"page-link\" (click)=\"onPageChange(currentPage() + 1)\" [disabled]=\"!canGoToNextPage()\">\n <i class=\"bi bi-chevron-right\"></i>\n </button>\n </li>\n </ul>\n </nav>\n </div>\n }\n } @else {\n <div class=\"text-center p-4\">\n <i class=\"bi bi-people display-4 text-muted\"></i>\n <p class=\"text-muted mt-2\">No users found</p>\n </div>\n }\n </div>\n </div>\n </div>\n</div>\n\n<!-- Create User Modal -->\n<ng-template #createUserModal let-modal>\n <div class=\"modal-header\">\n <h4 class=\"modal-title\">\n <i class=\"bi bi-person-plus me-2\"></i>Create User\n </h4>\n <button type=\"button\" class=\"btn-close\" (click)=\"modal.dismiss()\"></button>\n </div>\n <div class=\"modal-body\">\n <form #createForm=\"ngForm\" (ngSubmit)=\"createUser(createForm.value)\">\n <div class=\"row g-3\">\n <div class=\"col-md-6\">\n <label for=\"createUsername\" class=\"form-label\">Username</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"createUsername\"\n name=\"username\"\n ngModel\n required\n #usernameField=\"ngModel\">\n @if (usernameField.invalid && usernameField.touched) {\n <div class=\"text-danger small\">Username is required</div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"createEmail\" class=\"form-label\">Email</label>\n <input\n type=\"email\"\n class=\"form-control\"\n id=\"createEmail\"\n name=\"email\"\n ngModel\n required\n email\n #emailField=\"ngModel\">\n @if (emailField.invalid && emailField.touched) {\n <div class=\"text-danger small\">Valid email is required</div>\n }\n </div>\n </div>\n <div class=\"row g-3 mt-1\">\n <div class=\"col-md-6\">\n <label for=\"createFirstName\" class=\"form-label\">First Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"createFirstName\"\n name=\"firstName\"\n ngModel\n required\n #firstNameField=\"ngModel\">\n @if (firstNameField.invalid && firstNameField.touched) {\n <div class=\"text-danger small\">First name is required</div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"createLastName\" class=\"form-label\">Last Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"createLastName\"\n name=\"lastName\"\n ngModel\n required\n #lastNameField=\"ngModel\">\n @if (lastNameField.invalid && lastNameField.touched) {\n <div class=\"text-danger small\">Last name is required</div>\n }\n </div>\n </div>\n <div class=\"row g-3 mt-1\">\n <div class=\"col-md-6\">\n <label for=\"createPassword\" class=\"form-label\">Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"createPassword\"\n name=\"password\"\n ngModel\n required\n minlength=\"8\"\n #passwordField=\"ngModel\">\n @if (passwordField.invalid && passwordField.touched) {\n <div class=\"text-danger small\">Password must be at least 8 characters</div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"createPasswordConfirm\" class=\"form-label\">Confirm Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"createPasswordConfirm\"\n name=\"password_confirm\"\n ngModel\n required\n #passwordConfirmField=\"ngModel\">\n @if (passwordConfirmField.invalid && passwordConfirmField.touched) {\n <div class=\"text-danger small\">Password confirmation is required</div>\n }\n </div>\n </div>\n <div class=\"row g-3 mt-1\">\n <div class=\"col-md-6\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n id=\"createIsStaff\"\n name=\"isStaff\"\n ngModel>\n <label class=\"form-check-label\" for=\"createIsStaff\">\n Staff privileges\n </label>\n </div>\n </div>\n <div class=\"col-md-6\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n id=\"createIsActive\"\n name=\"isActive\"\n ngModel\n checked>\n <label class=\"form-check-label\" for=\"createIsActive\">\n Active account\n </label>\n </div>\n </div>\n </div>\n </form>\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"modal.dismiss()\">\n <i class=\"bi bi-x-circle me-1\"></i>Cancel\n </button>\n <button\n type=\"button\"\n class=\"btn btn-primary\"\n (click)=\"createUser(createForm.value)\"\n [disabled]=\"createForm.invalid || !canCreateUser()\">\n @if (isCreatingUser()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-person-plus me-1\"></i>Create User\n </button>\n </div>\n</ng-template>\n\n<!-- Edit User Modal -->\n<ng-template #editUserModal let-modal>\n <div class=\"modal-header\">\n <h4 class=\"modal-title\">\n <i class=\"bi bi-pencil me-2\"></i>Edit User\n </h4>\n <button type=\"button\" class=\"btn-close\" (click)=\"modal.dismiss()\"></button>\n </div>\n <div class=\"modal-body\">\n @if (hasSelectedUser()) {\n <form #editForm=\"ngForm\" id=\"editUserForm\" (ngSubmit)=\"updateUser(selectedUser()!.id!, editForm.value)\">\n <div class=\"row g-3\">\n <div class=\"col-md-6\">\n <label for=\"editUsername\" class=\"form-label\">Username</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"editUsername\"\n name=\"username\"\n [ngModel]=\"selectedUser()!.username\"\n required\n #editUsernameField=\"ngModel\">\n @if (editUsernameField.invalid && editUsernameField.touched) {\n <div class=\"text-danger small\">Username is required</div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"editEmail\" class=\"form-label\">Email</label>\n <input\n type=\"email\"\n class=\"form-control\"\n id=\"editEmail\"\n name=\"email\"\n [ngModel]=\"selectedUser()!.email\"\n required\n email\n #editEmailField=\"ngModel\">\n @if (editEmailField.invalid && editEmailField.touched) {\n <div class=\"text-danger small\">Valid email is required</div>\n }\n </div>\n </div>\n <div class=\"row g-3 mt-1\">\n <div class=\"col-md-6\">\n <label for=\"editFirstName\" class=\"form-label\">First Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"editFirstName\"\n name=\"firstName\"\n [ngModel]=\"selectedUser()!.firstName\"\n required\n #editFirstNameField=\"ngModel\">\n @if (editFirstNameField.invalid && editFirstNameField.touched) {\n <div class=\"text-danger small\">First name is required</div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"editLastName\" class=\"form-label\">Last Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"editLastName\"\n name=\"lastName\"\n [ngModel]=\"selectedUser()!.lastName\"\n required\n #editLastNameField=\"ngModel\">\n @if (editLastNameField.invalid && editLastNameField.touched) {\n <div class=\"text-danger small\">Last name is required</div>\n }\n </div>\n </div>\n <div class=\"row g-3 mt-1\">\n <div class=\"col-md-6\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n id=\"editIsStaff\"\n name=\"isStaff\"\n [ngModel]=\"selectedUser()!.isStaff\">\n <label class=\"form-check-label\" for=\"editIsStaff\">\n Staff privileges\n </label>\n </div>\n </div>\n <div class=\"col-md-6\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n id=\"editIsActive\"\n name=\"isActive\"\n [ngModel]=\"selectedUser()!.isActive\">\n <label class=\"form-check-label\" for=\"editIsActive\">\n Active account\n </label>\n </div>\n </div>\n </div>\n </form>\n }\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"modal.dismiss()\">\n <i class=\"bi bi-x-circle me-1\"></i>Cancel\n </button>\n @if (hasSelectedUser()) {\n <button\n type=\"submit\"\n class=\"btn btn-primary\"\n form=\"editUserForm\"\n [disabled]=\"!canUpdateUser()\">\n @if (isUpdatingUser()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-check-circle me-1\"></i>Update User\n </button>\n }\n </div>\n</ng-template>\n\n<!-- Password Reset Modal -->\n<ng-template #passwordResetModal let-modal>\n <div class=\"modal-header\">\n <h4 class=\"modal-title\">\n <i class=\"bi bi-shield-lock me-2\"></i>Reset Password\n </h4>\n <button type=\"button\" class=\"btn-close\" (click)=\"modal.dismiss()\"></button>\n </div>\n <div class=\"modal-body\">\n @if (hasSelectedUser()) {\n <div class=\"alert alert-warning\">\n <i class=\"bi bi-exclamation-triangle me-2\"></i>\n You are about to reset the password for user <strong>{{ selectedUserDisplayName() }}</strong>.\n </div>\n \n <form #resetForm=\"ngForm\" id=\"resetPasswordForm\" (ngSubmit)=\"resetUserPassword(selectedUser()!.id!, resetForm.value)\">\n <div class=\"mb-3\">\n <label for=\"resetNewPassword\" class=\"form-label\">New Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"resetNewPassword\"\n name=\"new_password\"\n ngModel\n required\n minlength=\"8\"\n #resetPasswordField=\"ngModel\">\n @if (resetPasswordField.invalid && resetPasswordField.touched) {\n <div class=\"text-danger small\">Password must be at least 8 characters</div>\n }\n </div>\n <div class=\"mb-3\">\n <label for=\"resetConfirmPassword\" class=\"form-label\">Confirm Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"resetConfirmPassword\"\n name=\"confirm_password\"\n ngModel\n required\n #resetConfirmField=\"ngModel\">\n @if (resetConfirmField.invalid && resetConfirmField.touched) {\n <div class=\"text-danger small\">Password confirmation is required</div>\n }\n </div>\n <div class=\"mb-3\">\n <div class=\"form-check\">\n <input\n class=\"form-check-input\"\n type=\"checkbox\"\n id=\"forcePasswordChange\"\n name=\"force_password_change\"\n ngModel>\n <label class=\"form-check-label\" for=\"forcePasswordChange\">\n Force user to change password on next login\n </label>\n </div>\n </div>\n <div class=\"mb-3\">\n <label for=\"resetReason\" class=\"form-label\">Reason (optional)</label>\n <textarea\n class=\"form-control\"\n id=\"resetReason\"\n name=\"reason\"\n ngModel\n rows=\"2\"\n placeholder=\"Optional reason for password reset\"></textarea>\n </div>\n </form>\n }\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"modal.dismiss()\">\n <i class=\"bi bi-x-circle me-1\"></i>Cancel\n </button>\n @if (hasSelectedUser()) {\n <button\n type=\"submit\"\n class=\"btn btn-warning\"\n form=\"resetPasswordForm\"\n [disabled]=\"!canResetPassword()\">\n @if (isResettingPassword()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-shield-lock me-1\"></i>Reset Password\n </button>\n }\n </div>\n</ng-template>" }]
1921
+ }], ctorParameters: () => [] });
1922
+
1923
+ class LabGroupsComponent {
1924
+ // Inject services using modern inject() syntax
1925
+ fb = inject(NonNullableFormBuilder);
1926
+ labGroupService = inject(LabGroupService);
1927
+ modalService = inject(NgbModal);
1928
+ toastService = inject(ToastService);
1929
+ // Forms
1930
+ searchForm;
1931
+ createGroupForm;
1932
+ inviteForm;
1933
+ // Signals for reactive state management
1934
+ searchParams = signal({
1935
+ search: '',
1936
+ limit: 10,
1937
+ offset: 0
1938
+ }, ...(ngDevMode ? [{ debugName: "searchParams" }] : []));
1939
+ // State signals
1940
+ isLoading = signal(false, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
1941
+ isCreatingGroup = signal(false, ...(ngDevMode ? [{ debugName: "isCreatingGroup" }] : []));
1942
+ isInviting = signal(false, ...(ngDevMode ? [{ debugName: "isInviting" }] : []));
1943
+ currentPage = signal(1, ...(ngDevMode ? [{ debugName: "currentPage" }] : []));
1944
+ pageSize = signal(10, ...(ngDevMode ? [{ debugName: "pageSize" }] : []));
1945
+ totalItems = signal(0, ...(ngDevMode ? [{ debugName: "totalItems" }] : []));
1946
+ // Data signals
1947
+ labGroupsData = signal({ count: 0, results: [] }, ...(ngDevMode ? [{ debugName: "labGroupsData" }] : []));
1948
+ selectedGroup = signal(null, ...(ngDevMode ? [{ debugName: "selectedGroup" }] : []));
1949
+ groupMembers = signal([], ...(ngDevMode ? [{ debugName: "groupMembers" }] : []));
1950
+ pendingInvitations = signal([], ...(ngDevMode ? [{ debugName: "pendingInvitations" }] : []));
1951
+ // UI state
1952
+ showCreateForm = signal(false, ...(ngDevMode ? [{ debugName: "showCreateForm" }] : []));
1953
+ showInviteForm = signal(false, ...(ngDevMode ? [{ debugName: "showInviteForm" }] : []));
1954
+ selectedGroupForMembers = signal(null, ...(ngDevMode ? [{ debugName: "selectedGroupForMembers" }] : []));
1955
+ // Computed values
1956
+ hasLabGroups = computed(() => this.labGroupsData().results.length > 0, ...(ngDevMode ? [{ debugName: "hasLabGroups" }] : []));
1957
+ showPagination = computed(() => this.labGroupsData().count > this.pageSize(), ...(ngDevMode ? [{ debugName: "showPagination" }] : []));
1958
+ totalPages = computed(() => Math.ceil(this.labGroupsData().count / this.pageSize()), ...(ngDevMode ? [{ debugName: "totalPages" }] : []));
1959
+ hasSearchValue = computed(() => (this.searchForm?.get('search')?.value || '').trim().length > 0, ...(ngDevMode ? [{ debugName: "hasSearchValue" }] : []));
1960
+ hasGroupMembers = computed(() => this.groupMembers().length > 0, ...(ngDevMode ? [{ debugName: "hasGroupMembers" }] : []));
1961
+ hasPendingInvitations = computed(() => this.pendingInvitations().length > 0, ...(ngDevMode ? [{ debugName: "hasPendingInvitations" }] : []));
1962
+ canInviteToCurrentGroup = computed(() => this.selectedGroupForMembers()?.canInvite || false, ...(ngDevMode ? [{ debugName: "canInviteToCurrentGroup" }] : []));
1963
+ canManageCurrentGroup = computed(() => this.selectedGroupForMembers()?.canManage || false, ...(ngDevMode ? [{ debugName: "canManageCurrentGroup" }] : []));
1964
+ currentGroupName = computed(() => this.selectedGroupForMembers()?.name || '', ...(ngDevMode ? [{ debugName: "currentGroupName" }] : []));
1965
+ groupMembersCount = computed(() => this.groupMembers().length, ...(ngDevMode ? [{ debugName: "groupMembersCount" }] : []));
1966
+ pendingInvitationsCount = computed(() => this.pendingInvitations().length, ...(ngDevMode ? [{ debugName: "pendingInvitationsCount" }] : []));
1967
+ constructor() {
1968
+ this.searchForm = this.fb.group({
1969
+ search: ['']
1970
+ });
1971
+ this.createGroupForm = this.fb.group({
1972
+ name: ['', [Validators.required, Validators.minLength(2)]],
1973
+ description: [''],
1974
+ allowMemberInvites: [true]
1975
+ });
1976
+ this.inviteForm = this.fb.group({
1977
+ invitedEmail: ['', [Validators.required, Validators.email]],
1978
+ message: ['']
1979
+ });
1980
+ // Effect to automatically reload lab groups when search params change
1981
+ effect(() => {
1982
+ const params = this.searchParams();
1983
+ this.loadLabGroupsWithParams(params);
1984
+ }, { allowSignalWrites: true });
1985
+ }
1986
+ ngOnInit() {
1987
+ this.setupSearch();
1988
+ this.loadInitialData();
1989
+ }
1990
+ loadInitialData() {
1991
+ this.searchParams.set({
1992
+ search: '',
1993
+ limit: this.pageSize(),
1994
+ offset: 0
1995
+ });
1996
+ }
1997
+ setupSearch() {
1998
+ this.searchForm.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe(formValue => {
1999
+ this.currentPage.set(1);
2000
+ this.searchParams.set({
2001
+ search: formValue.search?.trim() || '',
2002
+ limit: this.pageSize(),
2003
+ offset: 0
2004
+ });
2005
+ });
2006
+ }
2007
+ loadLabGroupsWithParams(params) {
2008
+ this.isLoading.set(true);
2009
+ this.labGroupService.getLabGroups({
2010
+ search: params.search || undefined,
2011
+ limit: params.limit,
2012
+ offset: params.offset
2013
+ }).subscribe({
2014
+ next: (response) => {
2015
+ this.isLoading.set(false);
2016
+ this.totalItems.set(response.count);
2017
+ this.labGroupsData.set(response);
2018
+ },
2019
+ error: (error) => {
2020
+ this.isLoading.set(false);
2021
+ this.labGroupsData.set({ count: 0, results: [] });
2022
+ console.error('Error loading lab groups:', error);
2023
+ }
2024
+ });
2025
+ }
2026
+ onPageChange(page) {
2027
+ this.currentPage.set(page);
2028
+ this.searchParams.update(params => ({
2029
+ ...params,
2030
+ offset: (page - 1) * this.pageSize()
2031
+ }));
2032
+ }
2033
+ toggleCreateForm() {
2034
+ this.showCreateForm.update(show => !show);
2035
+ if (!this.showCreateForm()) {
2036
+ this.createGroupForm.reset({
2037
+ name: '',
2038
+ description: '',
2039
+ allowMemberInvites: true
2040
+ });
2041
+ }
2042
+ }
2043
+ createLabGroup() {
2044
+ if (this.createGroupForm.valid) {
2045
+ this.isCreatingGroup.set(true);
2046
+ const formValue = this.createGroupForm.value;
2047
+ this.labGroupService.createLabGroup(formValue).subscribe({
2048
+ next: (newGroup) => {
2049
+ this.isCreatingGroup.set(false);
2050
+ this.toastService.success(`Lab group "${newGroup.name}" created successfully!`);
2051
+ this.toggleCreateForm();
2052
+ this.refreshLabGroups();
2053
+ },
2054
+ error: (error) => {
2055
+ this.isCreatingGroup.set(false);
2056
+ const errorMsg = error?.error?.detail || error?.message || 'Failed to create lab group. Please try again.';
2057
+ this.toastService.error(errorMsg);
2058
+ console.error('Error creating lab group:', error);
2059
+ }
2060
+ });
2061
+ }
2062
+ }
2063
+ viewGroupMembers(group) {
2064
+ this.selectedGroupForMembers.set(group);
2065
+ this.loadGroupMembers(group.id);
2066
+ this.loadPendingInvitations(group.id);
2067
+ }
2068
+ loadGroupMembers(groupId) {
2069
+ this.labGroupService.getLabGroupMembers(groupId).subscribe({
2070
+ next: (members) => {
2071
+ this.groupMembers.set(members);
2072
+ },
2073
+ error: (error) => {
2074
+ this.groupMembers.set([]);
2075
+ console.error('Error loading group members:', error);
2076
+ }
2077
+ });
2078
+ }
2079
+ loadPendingInvitations(groupId) {
2080
+ this.labGroupService.getLabGroupInvitations({
2081
+ labGroup: groupId,
2082
+ status: 'pending'
2083
+ }).subscribe({
2084
+ next: (response) => {
2085
+ this.pendingInvitations.set(response.results);
2086
+ },
2087
+ error: (error) => {
2088
+ this.pendingInvitations.set([]);
2089
+ console.error('Error loading pending invitations:', error);
2090
+ }
2091
+ });
2092
+ }
2093
+ toggleInviteForm() {
2094
+ this.showInviteForm.update(show => !show);
2095
+ if (!this.showInviteForm()) {
2096
+ this.inviteForm.reset();
2097
+ }
2098
+ }
2099
+ inviteMember() {
2100
+ if (this.inviteForm.valid && this.selectedGroupForMembers()) {
2101
+ this.isInviting.set(true);
2102
+ const groupId = this.selectedGroupForMembers().id;
2103
+ const inviteData = {
2104
+ labGroup: groupId,
2105
+ invitedEmail: this.inviteForm.value.invitedEmail,
2106
+ message: this.inviteForm.value.message || undefined
2107
+ };
2108
+ this.labGroupService.inviteUserToLabGroup(groupId, inviteData).subscribe({
2109
+ next: (invitation) => {
2110
+ this.isInviting.set(false);
2111
+ this.toastService.success(`Invitation sent to ${invitation.invitedEmail}!`);
2112
+ this.toggleInviteForm();
2113
+ this.loadPendingInvitations(groupId);
2114
+ },
2115
+ error: (error) => {
2116
+ this.isInviting.set(false);
2117
+ const errorMsg = error?.error?.detail || error?.message || 'Failed to send invitation. Please try again.';
2118
+ this.toastService.error(errorMsg);
2119
+ console.error('Error sending invitation:', error);
2120
+ }
2121
+ });
2122
+ }
2123
+ }
2124
+ removeMember(userId) {
2125
+ const group = this.selectedGroupForMembers();
2126
+ if (!group)
2127
+ return;
2128
+ const confirmMessage = `Are you sure you want to remove this member from "${group.name}"?`;
2129
+ if (confirm(confirmMessage)) {
2130
+ this.labGroupService.removeMemberFromLabGroup(group.id, userId).subscribe({
2131
+ next: (response) => {
2132
+ this.loadGroupMembers(group.id);
2133
+ this.toastService.success('Member removed successfully!');
2134
+ },
2135
+ error: (error) => {
2136
+ const errorMsg = error?.error?.detail || error?.message || 'Failed to remove member. Please try again.';
2137
+ this.toastService.error(errorMsg);
2138
+ console.error('Error removing member:', error);
2139
+ }
2140
+ });
2141
+ }
2142
+ }
2143
+ cancelInvitation(invitationId) {
2144
+ const confirmMessage = 'Are you sure you want to cancel this invitation?';
2145
+ if (confirm(confirmMessage)) {
2146
+ this.labGroupService.cancelLabGroupInvitation(invitationId).subscribe({
2147
+ next: (response) => {
2148
+ const group = this.selectedGroupForMembers();
2149
+ if (group) {
2150
+ this.loadPendingInvitations(group.id);
2151
+ }
2152
+ this.toastService.success('Invitation cancelled successfully!');
2153
+ },
2154
+ error: (error) => {
2155
+ const errorMsg = error?.error?.detail || error?.message || 'Failed to cancel invitation. Please try again.';
2156
+ this.toastService.error(errorMsg);
2157
+ console.error('Error cancelling invitation:', error);
2158
+ }
2159
+ });
2160
+ }
2161
+ }
2162
+ leaveGroup(group) {
2163
+ const confirmMessage = `Are you sure you want to leave "${group.name}"?`;
2164
+ if (confirm(confirmMessage)) {
2165
+ this.labGroupService.leaveLabGroup(group.id).subscribe({
2166
+ next: (response) => {
2167
+ this.refreshLabGroups();
2168
+ this.selectedGroupForMembers.set(null);
2169
+ this.toastService.success(`Successfully left "${group.name}"!`);
2170
+ },
2171
+ error: (error) => {
2172
+ const errorMsg = error?.error?.detail || error?.message || 'Failed to leave group. Please try again.';
2173
+ this.toastService.error(errorMsg);
2174
+ console.error('Error leaving group:', error);
2175
+ }
2176
+ });
2177
+ }
2178
+ }
2179
+ deleteGroup(group) {
2180
+ const confirmMessage = `Are you sure you want to delete the lab group "${group.name}"?\n\nThis action cannot be undone.`;
2181
+ if (confirm(confirmMessage)) {
2182
+ this.labGroupService.deleteLabGroup(group.id).subscribe({
2183
+ next: () => {
2184
+ this.refreshLabGroups();
2185
+ this.selectedGroupForMembers.set(null);
2186
+ this.toastService.success(`Lab group "${group.name}" deleted successfully!`);
2187
+ },
2188
+ error: (error) => {
2189
+ const errorMsg = error?.error?.detail || error?.message || 'Failed to delete lab group. Please try again.';
2190
+ this.toastService.error(errorMsg);
2191
+ console.error('Error deleting group:', error);
2192
+ }
2193
+ });
2194
+ }
2195
+ }
2196
+ refreshLabGroups() {
2197
+ this.searchParams.update(params => ({ ...params }));
2198
+ }
2199
+ closeGroupDetails() {
2200
+ this.selectedGroupForMembers.set(null);
2201
+ this.groupMembers.set([]);
2202
+ this.pendingInvitations.set([]);
2203
+ this.showInviteForm.set(false);
2204
+ }
2205
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: LabGroupsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2206
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.7", type: LabGroupsComponent, isStandalone: true, selector: "app-lab-groups", ngImport: i0, template: "<div class=\"lab-groups-container\">\n <!-- Header Section -->\n <nav class=\"navbar navbar-expand-lg bg-body-tertiary shadow-sm\">\n <div class=\"container-fluid\">\n <div class=\"d-flex align-items-center justify-content-between w-100\">\n <div class=\"d-flex align-items-center\">\n <h4 class=\"navbar-brand m-0 fw-bold\">\n <i class=\"bi bi-people me-2\"></i>Lab Groups\n </h4>\n </div>\n <div class=\"d-flex gap-2\">\n <button type=\"button\" class=\"btn btn-primary btn-sm\" (click)=\"toggleCreateForm()\">\n <i class=\"bi bi-plus me-1\"></i>New Lab Group\n </button>\n </div>\n </div>\n </div>\n </nav>\n\n <div class=\"groups-content p-4\">\n <!-- Create Group Form -->\n @if (showCreateForm()) {\n <div class=\"card mb-4\">\n <div class=\"card-header\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-plus-circle me-2\"></i>Create New Lab Group\n </h5>\n </div>\n <div class=\"card-body\">\n <form [formGroup]=\"createGroupForm\" (ngSubmit)=\"createLabGroup()\" class=\"row g-3\">\n <div class=\"col-md-6\">\n <div class=\"form-floating\">\n <input \n type=\"text\" \n class=\"form-control\" \n id=\"groupName\" \n formControlName=\"name\"\n [class.is-invalid]=\"createGroupForm.get('name')?.invalid && createGroupForm.get('name')?.touched\"\n placeholder=\"Group name\">\n <label for=\"groupName\">Group Name *</label>\n </div>\n @if (createGroupForm.get('name')?.invalid && createGroupForm.get('name')?.touched) {\n <div class=\"invalid-feedback\">\n @if (createGroupForm.get('name')?.errors?.['required']) {\n Group name is required\n }\n @if (createGroupForm.get('name')?.errors?.['minlength']) {\n Group name must be at least 2 characters\n }\n </div>\n }\n </div>\n <div class=\"col-md-6\">\n <div class=\"form-check form-switch mt-3\">\n <input \n class=\"form-check-input\" \n type=\"checkbox\" \n id=\"allowInvites\" \n formControlName=\"allowMemberInvites\">\n <label class=\"form-check-label\" for=\"allowInvites\">\n <i class=\"bi bi-envelope me-1\"></i>Allow members to invite others\n </label>\n </div>\n </div>\n <div class=\"col-12\">\n <div class=\"form-floating\">\n <textarea \n class=\"form-control\" \n id=\"groupDescription\" \n formControlName=\"description\"\n style=\"height: 100px\"\n placeholder=\"Description\"></textarea>\n <label for=\"groupDescription\">Description (optional)</label>\n </div>\n </div>\n <div class=\"col-12\">\n <div class=\"d-flex gap-2\">\n <button \n type=\"submit\" \n class=\"btn btn-primary\"\n [disabled]=\"createGroupForm.invalid || isCreatingGroup()\">\n @if (isCreatingGroup()) {\n <span class=\"spinner-border spinner-border-sm me-2\" role=\"status\"></span>\n }\n Create Group\n </button>\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"toggleCreateForm()\">\n Cancel\n </button>\n </div>\n </div>\n </form>\n </div>\n </div>\n }\n\n <!-- Search & Filters Section -->\n <div class=\"card mb-4\">\n <div class=\"card-header\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-search me-2\"></i>Search & Filters\n </h5>\n </div>\n <div class=\"card-body\">\n <form [formGroup]=\"searchForm\" class=\"row g-3\">\n <div class=\"col-md-12\">\n <div class=\"form-floating\">\n <input \n type=\"search\" \n class=\"form-control\" \n id=\"groupSearch\" \n formControlName=\"search\"\n placeholder=\"Search groups...\">\n <label for=\"groupSearch\">\n <i class=\"bi bi-search me-1\"></i>Search Groups\n </label>\n </div>\n </div>\n </form>\n </div>\n </div>\n\n <!-- Groups List -->\n <div class=\"card\">\n <div class=\"card-header d-flex justify-content-between align-items-center\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-people me-2\"></i>Lab Groups\n <span class=\"badge bg-primary ms-2\">{{ labGroupsData().count }}</span>\n </h5>\n @if (isLoading()) {\n <div class=\"spinner-border spinner-border-sm text-primary\" role=\"status\">\n <span class=\"visually-hidden\">Loading...</span>\n </div>\n }\n </div>\n <div class=\"card-body p-0\">\n @if (hasLabGroups()) {\n <div class=\"table-responsive\">\n <table class=\"table table-hover mb-0\">\n <thead class=\"table-light\">\n <tr>\n <th scope=\"col\"><i class=\"bi bi-tag me-1\"></i>Group Name</th>\n <th scope=\"col\"><i class=\"bi bi-person me-1\"></i>Creator</th>\n <th scope=\"col\"><i class=\"bi bi-people me-1\"></i>Members</th>\n <th scope=\"col\"><i class=\"bi bi-check-circle me-1\"></i>Status</th>\n <th scope=\"col\"><i class=\"bi bi-calendar me-1\"></i>Created</th>\n <th scope=\"col\" style=\"width: 120px;\"><i class=\"bi bi-gear me-1\"></i>Actions</th>\n </tr>\n </thead>\n <tbody>\n @for (group of labGroupsData().results; track group.id) {\n <tr>\n <td>\n <strong>{{ group.name }}</strong>\n @if (group.description) {\n <br><small class=\"text-muted\">{{ group.description }}</small>\n }\n </td>\n <td>{{ group.creatorName || 'Unknown' }}</td>\n <td>\n <span class=\"badge bg-info\">{{ group.memberCount }}</span>\n </td>\n <td>\n @if (group.isActive) {\n <span class=\"badge bg-success\">Active</span>\n } @else {\n <span class=\"badge bg-secondary\">Inactive</span>\n }\n </td>\n <td>\n <small>{{ group.createdAt | date:'short' }}</small>\n </td>\n <td>\n <div class=\"btn-group btn-group-sm\" role=\"group\">\n <button \n type=\"button\"\n class=\"btn btn-outline-info\" \n (click)=\"viewGroupMembers(group)\"\n title=\"View members\">\n <i class=\"bi bi-people\"></i>\n </button>\n @if (group.isMember && !group.isCreator) {\n <button \n type=\"button\"\n class=\"btn btn-outline-warning\" \n (click)=\"leaveGroup(group)\"\n title=\"Leave group\">\n <i class=\"bi bi-box-arrow-right\"></i>\n </button>\n }\n @if (group.canManage) {\n <button \n type=\"button\"\n class=\"btn btn-outline-danger\" \n (click)=\"deleteGroup(group)\"\n title=\"Delete group\">\n <i class=\"bi bi-trash\"></i>\n </button>\n }\n </div>\n </td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n\n <!-- Pagination -->\n @if (showPagination()) {\n <div class=\"d-flex justify-content-center mt-3 p-3\">\n <ngb-pagination\n [collectionSize]=\"labGroupsData().count\"\n [page]=\"currentPage()\"\n [pageSize]=\"pageSize()\"\n [maxSize]=\"5\"\n [boundaryLinks]=\"true\"\n [rotate]=\"true\"\n (pageChange)=\"onPageChange($event)\"\n class=\"pagination-sm\">\n </ngb-pagination>\n </div>\n }\n } @else {\n <div class=\"text-center py-5\">\n <div class=\"text-muted\">\n <i class=\"bi bi-people display-1 mb-3\"></i>\n <h5>No Lab Groups Found</h5>\n <p>\n @if (hasSearchValue()) {\n Try adjusting your search criteria or create a new lab group.\n } @else {\n Get started by creating your first lab group.\n }\n </p>\n <button type=\"button\" class=\"btn btn-primary\" (click)=\"toggleCreateForm()\">\n <i class=\"bi bi-plus me-1\"></i>Create First Lab Group\n </button>\n </div>\n </div>\n }\n </div>\n </div>\n </div>\n</div>\n\n<!-- Group Members Modal -->\n@if (selectedGroupForMembers()) {\n <div class=\"modal-backdrop fade show\"></div>\n <div class=\"modal fade show d-block\" tabindex=\"-1\">\n <div class=\"modal-dialog modal-lg\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h5 class=\"modal-title\">\n <i class=\"bi bi-people me-2\"></i>{{ currentGroupName() }} - Members\n </h5>\n <button type=\"button\" class=\"btn-close\" (click)=\"closeGroupDetails()\"></button>\n </div>\n <div class=\"modal-body\">\n <!-- Invite Form -->\n @if (canInviteToCurrentGroup()) {\n <div class=\"card mb-4\">\n <div class=\"card-header d-flex justify-content-between align-items-center\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-envelope me-2\"></i>Invite Member\n </h6>\n <button \n type=\"button\"\n class=\"btn btn-sm btn-outline-primary\" \n (click)=\"toggleInviteForm()\">\n @if (showInviteForm()) {\n Cancel\n } @else {\n <i class=\"bi bi-plus me-1\"></i>Invite\n }\n </button>\n </div>\n @if (showInviteForm()) {\n <div class=\"card-body\">\n <form [formGroup]=\"inviteForm\" (ngSubmit)=\"inviteMember()\" class=\"row g-3\">\n <div class=\"col-md-8\">\n <div class=\"form-floating\">\n <input \n type=\"email\" \n class=\"form-control\" \n id=\"inviteEmail\" \n formControlName=\"invitedEmail\"\n [class.is-invalid]=\"inviteForm.get('invitedEmail')?.invalid && inviteForm.get('invitedEmail')?.touched\"\n placeholder=\"Email address\">\n <label for=\"inviteEmail\">Email Address *</label>\n </div>\n @if (inviteForm.get('invitedEmail')?.invalid && inviteForm.get('invitedEmail')?.touched) {\n <div class=\"invalid-feedback\">\n @if (inviteForm.get('invitedEmail')?.errors?.['required']) {\n Email is required\n }\n @if (inviteForm.get('invitedEmail')?.errors?.['email']) {\n Please enter a valid email address\n }\n </div>\n }\n </div>\n <div class=\"col-md-4\">\n <button \n type=\"submit\" \n class=\"btn btn-primary w-100 h-100\"\n [disabled]=\"inviteForm.invalid || isInviting()\">\n @if (isInviting()) {\n <span class=\"spinner-border spinner-border-sm me-2\" role=\"status\"></span>\n }\n Send Invite\n </button>\n </div>\n <div class=\"col-12\">\n <div class=\"form-floating\">\n <textarea \n class=\"form-control\" \n id=\"inviteMessage\" \n formControlName=\"message\"\n style=\"height: 80px\"\n placeholder=\"Message\"></textarea>\n <label for=\"inviteMessage\">Message (optional)</label>\n </div>\n </div>\n </form>\n </div>\n }\n </div>\n }\n\n <!-- Current Members -->\n <div class=\"card mb-3\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-people me-2\"></i>Current Members ({{ groupMembersCount() }})\n </h6>\n </div>\n <div class=\"card-body p-0\">\n @if (hasGroupMembers()) {\n <div class=\"list-group list-group-flush\">\n @for (member of groupMembers(); track member.id) {\n <div class=\"list-group-item d-flex justify-content-between align-items-center\">\n <div>\n <strong>{{ member.firstName }} {{ member.lastName }}</strong>\n <br>\n <small class=\"text-muted\">{{ member.email }}</small>\n @if (selectedGroupForMembers() && member.id === selectedGroupForMembers()!.creator) {\n <span class=\"badge bg-warning ms-2\">Creator</span>\n }\n </div>\n @if (canManageCurrentGroup() && selectedGroupForMembers() && member.id !== selectedGroupForMembers()!.creator) {\n <button \n type=\"button\"\n class=\"btn btn-outline-danger btn-sm\" \n (click)=\"removeMember(member.id)\"\n title=\"Remove member\">\n <i class=\"bi bi-person-dash\"></i>\n </button>\n }\n </div>\n }\n </div>\n } @else {\n <div class=\"text-center py-3 text-muted\">\n <i class=\"bi bi-people fs-4 mb-2\"></i>\n <div>No members found</div>\n </div>\n }\n </div>\n </div>\n\n <!-- Pending Invitations -->\n @if (hasPendingInvitations()) {\n <div class=\"card\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-clock me-2\"></i>Pending Invitations ({{ pendingInvitationsCount() }})\n </h6>\n </div>\n <div class=\"card-body p-0\">\n <div class=\"list-group list-group-flush\">\n @for (invitation of pendingInvitations(); track invitation.id) {\n <div class=\"list-group-item d-flex justify-content-between align-items-center\">\n <div>\n <strong>{{ invitation.invitedEmail }}</strong>\n <br>\n <small class=\"text-muted\">\n Invited by {{ invitation.inviterName }} on {{ invitation.createdAt | date:'short' }}\n </small>\n @if (invitation.message) {\n <br>\n <small class=\"text-info\">{{ invitation.message }}</small>\n }\n </div>\n @if (canManageCurrentGroup()) {\n <button \n type=\"button\"\n class=\"btn btn-outline-danger btn-sm\" \n (click)=\"cancelInvitation(invitation.id)\"\n title=\"Cancel invitation\">\n <i class=\"bi bi-x-circle\"></i>\n </button>\n }\n </div>\n }\n </div>\n </div>\n </div>\n }\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"closeGroupDetails()\">\n Close\n </button>\n </div>\n </div>\n </div>\n </div>\n}", styles: [".lab-groups-container .groups-content{max-width:1200px;margin:0 auto}@media (max-width: 768px){.lab-groups-container .groups-content{padding:1rem!important}.lab-groups-container .btn-group{flex-direction:column;width:100%}.lab-groups-container .btn-group .btn{border-radius:.25rem!important;margin-bottom:.25rem}.lab-groups-container .modal-dialog{margin:.5rem}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: NgbModule }, { kind: "component", type: i2$1.NgbPagination, selector: "ngb-pagination", inputs: ["disabled", "boundaryLinks", "directionLinks", "ellipses", "rotate", "collectionSize", "maxSize", "page", "pageSize", "size"], outputs: ["pageChange"] }, { kind: "pipe", type: i2.DatePipe, name: "date" }] });
2207
+ }
2208
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: LabGroupsComponent, decorators: [{
2209
+ type: Component,
2210
+ args: [{ selector: 'app-lab-groups', standalone: true, imports: [CommonModule, ReactiveFormsModule, NgbModule], template: "<div class=\"lab-groups-container\">\n <!-- Header Section -->\n <nav class=\"navbar navbar-expand-lg bg-body-tertiary shadow-sm\">\n <div class=\"container-fluid\">\n <div class=\"d-flex align-items-center justify-content-between w-100\">\n <div class=\"d-flex align-items-center\">\n <h4 class=\"navbar-brand m-0 fw-bold\">\n <i class=\"bi bi-people me-2\"></i>Lab Groups\n </h4>\n </div>\n <div class=\"d-flex gap-2\">\n <button type=\"button\" class=\"btn btn-primary btn-sm\" (click)=\"toggleCreateForm()\">\n <i class=\"bi bi-plus me-1\"></i>New Lab Group\n </button>\n </div>\n </div>\n </div>\n </nav>\n\n <div class=\"groups-content p-4\">\n <!-- Create Group Form -->\n @if (showCreateForm()) {\n <div class=\"card mb-4\">\n <div class=\"card-header\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-plus-circle me-2\"></i>Create New Lab Group\n </h5>\n </div>\n <div class=\"card-body\">\n <form [formGroup]=\"createGroupForm\" (ngSubmit)=\"createLabGroup()\" class=\"row g-3\">\n <div class=\"col-md-6\">\n <div class=\"form-floating\">\n <input \n type=\"text\" \n class=\"form-control\" \n id=\"groupName\" \n formControlName=\"name\"\n [class.is-invalid]=\"createGroupForm.get('name')?.invalid && createGroupForm.get('name')?.touched\"\n placeholder=\"Group name\">\n <label for=\"groupName\">Group Name *</label>\n </div>\n @if (createGroupForm.get('name')?.invalid && createGroupForm.get('name')?.touched) {\n <div class=\"invalid-feedback\">\n @if (createGroupForm.get('name')?.errors?.['required']) {\n Group name is required\n }\n @if (createGroupForm.get('name')?.errors?.['minlength']) {\n Group name must be at least 2 characters\n }\n </div>\n }\n </div>\n <div class=\"col-md-6\">\n <div class=\"form-check form-switch mt-3\">\n <input \n class=\"form-check-input\" \n type=\"checkbox\" \n id=\"allowInvites\" \n formControlName=\"allowMemberInvites\">\n <label class=\"form-check-label\" for=\"allowInvites\">\n <i class=\"bi bi-envelope me-1\"></i>Allow members to invite others\n </label>\n </div>\n </div>\n <div class=\"col-12\">\n <div class=\"form-floating\">\n <textarea \n class=\"form-control\" \n id=\"groupDescription\" \n formControlName=\"description\"\n style=\"height: 100px\"\n placeholder=\"Description\"></textarea>\n <label for=\"groupDescription\">Description (optional)</label>\n </div>\n </div>\n <div class=\"col-12\">\n <div class=\"d-flex gap-2\">\n <button \n type=\"submit\" \n class=\"btn btn-primary\"\n [disabled]=\"createGroupForm.invalid || isCreatingGroup()\">\n @if (isCreatingGroup()) {\n <span class=\"spinner-border spinner-border-sm me-2\" role=\"status\"></span>\n }\n Create Group\n </button>\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"toggleCreateForm()\">\n Cancel\n </button>\n </div>\n </div>\n </form>\n </div>\n </div>\n }\n\n <!-- Search & Filters Section -->\n <div class=\"card mb-4\">\n <div class=\"card-header\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-search me-2\"></i>Search & Filters\n </h5>\n </div>\n <div class=\"card-body\">\n <form [formGroup]=\"searchForm\" class=\"row g-3\">\n <div class=\"col-md-12\">\n <div class=\"form-floating\">\n <input \n type=\"search\" \n class=\"form-control\" \n id=\"groupSearch\" \n formControlName=\"search\"\n placeholder=\"Search groups...\">\n <label for=\"groupSearch\">\n <i class=\"bi bi-search me-1\"></i>Search Groups\n </label>\n </div>\n </div>\n </form>\n </div>\n </div>\n\n <!-- Groups List -->\n <div class=\"card\">\n <div class=\"card-header d-flex justify-content-between align-items-center\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-people me-2\"></i>Lab Groups\n <span class=\"badge bg-primary ms-2\">{{ labGroupsData().count }}</span>\n </h5>\n @if (isLoading()) {\n <div class=\"spinner-border spinner-border-sm text-primary\" role=\"status\">\n <span class=\"visually-hidden\">Loading...</span>\n </div>\n }\n </div>\n <div class=\"card-body p-0\">\n @if (hasLabGroups()) {\n <div class=\"table-responsive\">\n <table class=\"table table-hover mb-0\">\n <thead class=\"table-light\">\n <tr>\n <th scope=\"col\"><i class=\"bi bi-tag me-1\"></i>Group Name</th>\n <th scope=\"col\"><i class=\"bi bi-person me-1\"></i>Creator</th>\n <th scope=\"col\"><i class=\"bi bi-people me-1\"></i>Members</th>\n <th scope=\"col\"><i class=\"bi bi-check-circle me-1\"></i>Status</th>\n <th scope=\"col\"><i class=\"bi bi-calendar me-1\"></i>Created</th>\n <th scope=\"col\" style=\"width: 120px;\"><i class=\"bi bi-gear me-1\"></i>Actions</th>\n </tr>\n </thead>\n <tbody>\n @for (group of labGroupsData().results; track group.id) {\n <tr>\n <td>\n <strong>{{ group.name }}</strong>\n @if (group.description) {\n <br><small class=\"text-muted\">{{ group.description }}</small>\n }\n </td>\n <td>{{ group.creatorName || 'Unknown' }}</td>\n <td>\n <span class=\"badge bg-info\">{{ group.memberCount }}</span>\n </td>\n <td>\n @if (group.isActive) {\n <span class=\"badge bg-success\">Active</span>\n } @else {\n <span class=\"badge bg-secondary\">Inactive</span>\n }\n </td>\n <td>\n <small>{{ group.createdAt | date:'short' }}</small>\n </td>\n <td>\n <div class=\"btn-group btn-group-sm\" role=\"group\">\n <button \n type=\"button\"\n class=\"btn btn-outline-info\" \n (click)=\"viewGroupMembers(group)\"\n title=\"View members\">\n <i class=\"bi bi-people\"></i>\n </button>\n @if (group.isMember && !group.isCreator) {\n <button \n type=\"button\"\n class=\"btn btn-outline-warning\" \n (click)=\"leaveGroup(group)\"\n title=\"Leave group\">\n <i class=\"bi bi-box-arrow-right\"></i>\n </button>\n }\n @if (group.canManage) {\n <button \n type=\"button\"\n class=\"btn btn-outline-danger\" \n (click)=\"deleteGroup(group)\"\n title=\"Delete group\">\n <i class=\"bi bi-trash\"></i>\n </button>\n }\n </div>\n </td>\n </tr>\n }\n </tbody>\n </table>\n </div>\n\n <!-- Pagination -->\n @if (showPagination()) {\n <div class=\"d-flex justify-content-center mt-3 p-3\">\n <ngb-pagination\n [collectionSize]=\"labGroupsData().count\"\n [page]=\"currentPage()\"\n [pageSize]=\"pageSize()\"\n [maxSize]=\"5\"\n [boundaryLinks]=\"true\"\n [rotate]=\"true\"\n (pageChange)=\"onPageChange($event)\"\n class=\"pagination-sm\">\n </ngb-pagination>\n </div>\n }\n } @else {\n <div class=\"text-center py-5\">\n <div class=\"text-muted\">\n <i class=\"bi bi-people display-1 mb-3\"></i>\n <h5>No Lab Groups Found</h5>\n <p>\n @if (hasSearchValue()) {\n Try adjusting your search criteria or create a new lab group.\n } @else {\n Get started by creating your first lab group.\n }\n </p>\n <button type=\"button\" class=\"btn btn-primary\" (click)=\"toggleCreateForm()\">\n <i class=\"bi bi-plus me-1\"></i>Create First Lab Group\n </button>\n </div>\n </div>\n }\n </div>\n </div>\n </div>\n</div>\n\n<!-- Group Members Modal -->\n@if (selectedGroupForMembers()) {\n <div class=\"modal-backdrop fade show\"></div>\n <div class=\"modal fade show d-block\" tabindex=\"-1\">\n <div class=\"modal-dialog modal-lg\">\n <div class=\"modal-content\">\n <div class=\"modal-header\">\n <h5 class=\"modal-title\">\n <i class=\"bi bi-people me-2\"></i>{{ currentGroupName() }} - Members\n </h5>\n <button type=\"button\" class=\"btn-close\" (click)=\"closeGroupDetails()\"></button>\n </div>\n <div class=\"modal-body\">\n <!-- Invite Form -->\n @if (canInviteToCurrentGroup()) {\n <div class=\"card mb-4\">\n <div class=\"card-header d-flex justify-content-between align-items-center\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-envelope me-2\"></i>Invite Member\n </h6>\n <button \n type=\"button\"\n class=\"btn btn-sm btn-outline-primary\" \n (click)=\"toggleInviteForm()\">\n @if (showInviteForm()) {\n Cancel\n } @else {\n <i class=\"bi bi-plus me-1\"></i>Invite\n }\n </button>\n </div>\n @if (showInviteForm()) {\n <div class=\"card-body\">\n <form [formGroup]=\"inviteForm\" (ngSubmit)=\"inviteMember()\" class=\"row g-3\">\n <div class=\"col-md-8\">\n <div class=\"form-floating\">\n <input \n type=\"email\" \n class=\"form-control\" \n id=\"inviteEmail\" \n formControlName=\"invitedEmail\"\n [class.is-invalid]=\"inviteForm.get('invitedEmail')?.invalid && inviteForm.get('invitedEmail')?.touched\"\n placeholder=\"Email address\">\n <label for=\"inviteEmail\">Email Address *</label>\n </div>\n @if (inviteForm.get('invitedEmail')?.invalid && inviteForm.get('invitedEmail')?.touched) {\n <div class=\"invalid-feedback\">\n @if (inviteForm.get('invitedEmail')?.errors?.['required']) {\n Email is required\n }\n @if (inviteForm.get('invitedEmail')?.errors?.['email']) {\n Please enter a valid email address\n }\n </div>\n }\n </div>\n <div class=\"col-md-4\">\n <button \n type=\"submit\" \n class=\"btn btn-primary w-100 h-100\"\n [disabled]=\"inviteForm.invalid || isInviting()\">\n @if (isInviting()) {\n <span class=\"spinner-border spinner-border-sm me-2\" role=\"status\"></span>\n }\n Send Invite\n </button>\n </div>\n <div class=\"col-12\">\n <div class=\"form-floating\">\n <textarea \n class=\"form-control\" \n id=\"inviteMessage\" \n formControlName=\"message\"\n style=\"height: 80px\"\n placeholder=\"Message\"></textarea>\n <label for=\"inviteMessage\">Message (optional)</label>\n </div>\n </div>\n </form>\n </div>\n }\n </div>\n }\n\n <!-- Current Members -->\n <div class=\"card mb-3\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-people me-2\"></i>Current Members ({{ groupMembersCount() }})\n </h6>\n </div>\n <div class=\"card-body p-0\">\n @if (hasGroupMembers()) {\n <div class=\"list-group list-group-flush\">\n @for (member of groupMembers(); track member.id) {\n <div class=\"list-group-item d-flex justify-content-between align-items-center\">\n <div>\n <strong>{{ member.firstName }} {{ member.lastName }}</strong>\n <br>\n <small class=\"text-muted\">{{ member.email }}</small>\n @if (selectedGroupForMembers() && member.id === selectedGroupForMembers()!.creator) {\n <span class=\"badge bg-warning ms-2\">Creator</span>\n }\n </div>\n @if (canManageCurrentGroup() && selectedGroupForMembers() && member.id !== selectedGroupForMembers()!.creator) {\n <button \n type=\"button\"\n class=\"btn btn-outline-danger btn-sm\" \n (click)=\"removeMember(member.id)\"\n title=\"Remove member\">\n <i class=\"bi bi-person-dash\"></i>\n </button>\n }\n </div>\n }\n </div>\n } @else {\n <div class=\"text-center py-3 text-muted\">\n <i class=\"bi bi-people fs-4 mb-2\"></i>\n <div>No members found</div>\n </div>\n }\n </div>\n </div>\n\n <!-- Pending Invitations -->\n @if (hasPendingInvitations()) {\n <div class=\"card\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-clock me-2\"></i>Pending Invitations ({{ pendingInvitationsCount() }})\n </h6>\n </div>\n <div class=\"card-body p-0\">\n <div class=\"list-group list-group-flush\">\n @for (invitation of pendingInvitations(); track invitation.id) {\n <div class=\"list-group-item d-flex justify-content-between align-items-center\">\n <div>\n <strong>{{ invitation.invitedEmail }}</strong>\n <br>\n <small class=\"text-muted\">\n Invited by {{ invitation.inviterName }} on {{ invitation.createdAt | date:'short' }}\n </small>\n @if (invitation.message) {\n <br>\n <small class=\"text-info\">{{ invitation.message }}</small>\n }\n </div>\n @if (canManageCurrentGroup()) {\n <button \n type=\"button\"\n class=\"btn btn-outline-danger btn-sm\" \n (click)=\"cancelInvitation(invitation.id)\"\n title=\"Cancel invitation\">\n <i class=\"bi bi-x-circle\"></i>\n </button>\n }\n </div>\n }\n </div>\n </div>\n </div>\n }\n </div>\n <div class=\"modal-footer\">\n <button type=\"button\" class=\"btn btn-outline-secondary\" (click)=\"closeGroupDetails()\">\n Close\n </button>\n </div>\n </div>\n </div>\n </div>\n}", styles: [".lab-groups-container .groups-content{max-width:1200px;margin:0 auto}@media (max-width: 768px){.lab-groups-container .groups-content{padding:1rem!important}.lab-groups-container .btn-group{flex-direction:column;width:100%}.lab-groups-container .btn-group .btn{border-radius:.25rem!important;margin-bottom:.25rem}.lab-groups-container .modal-dialog{margin:.5rem}}\n"] }]
2211
+ }], ctorParameters: () => [] });
2212
+
2213
+ class UserProfileComponent {
2214
+ fb = inject(FormBuilder);
2215
+ userManagementService = inject(UserManagementService);
2216
+ authService = inject(AuthService);
2217
+ // User data
2218
+ currentUser = signal(null, ...(ngDevMode ? [{ debugName: "currentUser" }] : []));
2219
+ isLoading = signal(false, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
2220
+ // Forms
2221
+ profileForm;
2222
+ passwordForm;
2223
+ emailChangeForm;
2224
+ // UI state
2225
+ activeTab = signal('profile', ...(ngDevMode ? [{ debugName: "activeTab" }] : []));
2226
+ isUpdatingProfile = signal(false, ...(ngDevMode ? [{ debugName: "isUpdatingProfile" }] : []));
2227
+ isChangingPassword = signal(false, ...(ngDevMode ? [{ debugName: "isChangingPassword" }] : []));
2228
+ isChangingEmail = signal(false, ...(ngDevMode ? [{ debugName: "isChangingEmail" }] : []));
2229
+ // Success/error messages
2230
+ profileMessage = signal('', ...(ngDevMode ? [{ debugName: "profileMessage" }] : []));
2231
+ passwordMessage = signal('', ...(ngDevMode ? [{ debugName: "passwordMessage" }] : []));
2232
+ emailMessage = signal('', ...(ngDevMode ? [{ debugName: "emailMessage" }] : []));
2233
+ errorMessage = signal('', ...(ngDevMode ? [{ debugName: "errorMessage" }] : []));
2234
+ // Computed signals for derived values
2235
+ fullName = computed(() => {
2236
+ const user = this.currentUser();
2237
+ return user ? `${user.firstName} ${user.lastName}`.trim() : '';
2238
+ }, ...(ngDevMode ? [{ debugName: "fullName" }] : []));
2239
+ isStaff = computed(() => this.currentUser()?.isStaff || false, ...(ngDevMode ? [{ debugName: "isStaff" }] : []));
2240
+ joinDate = computed(() => {
2241
+ const user = this.currentUser();
2242
+ return user?.dateJoined ? new Date(user.dateJoined).toLocaleDateString() : '';
2243
+ }, ...(ngDevMode ? [{ debugName: "joinDate" }] : []));
2244
+ lastLogin = computed(() => {
2245
+ const user = this.currentUser();
2246
+ return user?.lastLogin ? new Date(user.lastLogin).toLocaleDateString() : 'Never';
2247
+ }, ...(ngDevMode ? [{ debugName: "lastLogin" }] : []));
2248
+ constructor() {
2249
+ this.profileForm = this.fb.group({
2250
+ firstName: ['', [Validators.required, Validators.maxLength(30)]],
2251
+ lastName: ['', [Validators.required, Validators.maxLength(30)]],
2252
+ currentPassword: ['', [Validators.required]]
2253
+ });
2254
+ this.passwordForm = this.fb.group({
2255
+ currentPassword: ['', [Validators.required]],
2256
+ newPassword: ['', [Validators.required, Validators.minLength(8)]],
2257
+ confirmPassword: ['', [Validators.required]]
2258
+ }, { validators: this.passwordMatchValidator });
2259
+ this.emailChangeForm = this.fb.group({
2260
+ newEmail: ['', [Validators.required, Validators.email]],
2261
+ currentPassword: ['', [Validators.required]]
2262
+ });
2263
+ }
2264
+ ngOnInit() {
2265
+ this.loadUserProfile();
2266
+ }
2267
+ loadUserProfile() {
2268
+ this.isLoading.set(true);
2269
+ this.userManagementService.getUserProfile().subscribe({
2270
+ next: (response) => {
2271
+ this.currentUser.set(response.user);
2272
+ this.profileForm.patchValue({
2273
+ firstName: response.user.firstName,
2274
+ lastName: response.user.lastName
2275
+ });
2276
+ this.isLoading.set(false);
2277
+ },
2278
+ error: (error) => {
2279
+ this.errorMessage.set('Failed to load user profile');
2280
+ this.isLoading.set(false);
2281
+ }
2282
+ });
2283
+ }
2284
+ updateProfile() {
2285
+ if (this.profileForm.valid) {
2286
+ this.isUpdatingProfile.set(true);
2287
+ this.profileMessage.set('');
2288
+ this.errorMessage.set('');
2289
+ const profileData = {
2290
+ firstName: this.profileForm.get('firstName')?.value,
2291
+ lastName: this.profileForm.get('lastName')?.value,
2292
+ currentPassword: this.profileForm.get('currentPassword')?.value
2293
+ };
2294
+ this.userManagementService.updateProfile(profileData).subscribe({
2295
+ next: (response) => {
2296
+ this.profileMessage.set('Profile updated successfully');
2297
+ this.currentUser.set(response.user);
2298
+ this.profileForm.get('currentPassword')?.setValue('');
2299
+ this.isUpdatingProfile.set(false);
2300
+ },
2301
+ error: (error) => {
2302
+ this.errorMessage.set(error.error?.message || 'Failed to update profile');
2303
+ this.isUpdatingProfile.set(false);
2304
+ }
2305
+ });
2306
+ }
2307
+ }
2308
+ changePassword() {
2309
+ if (this.passwordForm.valid) {
2310
+ this.isChangingPassword.set(true);
2311
+ this.passwordMessage.set('');
2312
+ this.errorMessage.set('');
2313
+ const passwordData = {
2314
+ currentPassword: this.passwordForm.get('currentPassword')?.value,
2315
+ newPassword: this.passwordForm.get('newPassword')?.value,
2316
+ confirmPassword: this.passwordForm.get('confirmPassword')?.value
2317
+ };
2318
+ this.userManagementService.changePassword(passwordData).subscribe({
2319
+ next: (response) => {
2320
+ this.passwordMessage.set('Password changed successfully');
2321
+ this.passwordForm.reset();
2322
+ this.isChangingPassword.set(false);
2323
+ },
2324
+ error: (error) => {
2325
+ this.errorMessage.set(error.error?.message || 'Failed to change password');
2326
+ this.isChangingPassword.set(false);
2327
+ }
2328
+ });
2329
+ }
2330
+ }
2331
+ requestEmailChange() {
2332
+ if (this.emailChangeForm.valid) {
2333
+ this.isChangingEmail.set(true);
2334
+ this.emailMessage.set('');
2335
+ this.errorMessage.set('');
2336
+ const emailData = {
2337
+ newEmail: this.emailChangeForm.get('newEmail')?.value,
2338
+ currentPassword: this.emailChangeForm.get('currentPassword')?.value
2339
+ };
2340
+ this.userManagementService.requestEmailChange(emailData).subscribe({
2341
+ next: (response) => {
2342
+ this.emailMessage.set(`Verification email sent to ${response.new_email}. Please check your inbox and follow the instructions.`);
2343
+ this.emailChangeForm.reset();
2344
+ this.isChangingEmail.set(false);
2345
+ },
2346
+ error: (error) => {
2347
+ this.errorMessage.set(error.error?.message || 'Failed to request email change');
2348
+ this.isChangingEmail.set(false);
2349
+ }
2350
+ });
2351
+ }
2352
+ }
2353
+ setActiveTab(tab) {
2354
+ this.activeTab.set(tab);
2355
+ // Clear messages when switching tabs
2356
+ this.profileMessage.set('');
2357
+ this.passwordMessage.set('');
2358
+ this.emailMessage.set('');
2359
+ this.errorMessage.set('');
2360
+ }
2361
+ passwordMatchValidator(form) {
2362
+ const newPassword = form.get('newPassword');
2363
+ const confirmPassword = form.get('confirmPassword');
2364
+ if (newPassword && confirmPassword && newPassword.value !== confirmPassword.value) {
2365
+ return { passwordMismatch: true };
2366
+ }
2367
+ return null;
2368
+ }
2369
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: UserProfileComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2370
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.7", type: UserProfileComponent, isStandalone: true, selector: "app-user-profile", ngImport: i0, template: "<div class=\"user-profile-container\">\n <!-- Header Section -->\n <nav class=\"navbar navbar-expand-lg bg-body-tertiary shadow-sm\">\n <div class=\"container-fluid\">\n <div class=\"d-flex align-items-center justify-content-between w-100\">\n <div class=\"d-flex align-items-center\">\n <h4 class=\"mb-0\">\n <i class=\"bi bi-person-circle me-2 text-primary\"></i>User Profile\n </h4>\n @if (isStaff()) {\n <span class=\"badge bg-warning ms-3\">Staff</span>\n }\n </div>\n </div>\n </div>\n </nav>\n\n <div class=\"profile-content p-4\">\n @if (isLoading()) {\n <div class=\"text-center\">\n <div class=\"spinner-border text-primary me-2\"></div>\n <span>Loading profile...</span>\n </div>\n } @else {\n <!-- User Info Card -->\n <div class=\"card mb-4\">\n <div class=\"card-header\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-info-circle me-2\"></i>Account Information\n </h5>\n </div>\n <div class=\"card-body\">\n <div class=\"row\">\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Full Name:</small>\n <div class=\"fw-semibold\">{{ fullName() }}</div>\n </div>\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Username:</small>\n <div class=\"fw-semibold\">{{ currentUser()?.username }}</div>\n </div>\n </div>\n <div class=\"row mt-3\">\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Email:</small>\n <div class=\"fw-semibold\">{{ currentUser()?.email }}</div>\n </div>\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Account Status:</small>\n <div>\n @if (currentUser()?.isActive) {\n <span class=\"badge bg-success\">Active</span>\n } @else {\n <span class=\"badge bg-danger\">Inactive</span>\n }\n </div>\n </div>\n </div>\n <div class=\"row mt-3\">\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Member Since:</small>\n <div class=\"fw-semibold\">{{ joinDate() }}</div>\n </div>\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Last Login:</small>\n <div class=\"fw-semibold\">{{ lastLogin() }}</div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Navigation Tabs -->\n <ul ngbNav #profileNav=\"ngbNav\" [activeId]=\"activeTab()\" (activeIdChange)=\"setActiveTab($event)\" class=\"nav-tabs\">\n <li [ngbNavItem]=\"'profile'\">\n <button ngbNavLink (click)=\"setActiveTab('profile')\">\n <i class=\"bi bi-person me-1\"></i>Profile Settings\n </button>\n <ng-template ngbNavContent>\n <div class=\"mt-4\">\n <!-- Profile Update Form -->\n <div class=\"card\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-pencil-square me-2\"></i>Update Profile Information\n </h6>\n </div>\n <div class=\"card-body\">\n <form [formGroup]=\"profileForm\" (ngSubmit)=\"updateProfile()\">\n <div class=\"row g-3\">\n <div class=\"col-md-6\">\n <label for=\"firstName\" class=\"form-label\">First Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"firstName\"\n formControlName=\"firstName\"\n [class.is-invalid]=\"profileForm.get('firstName')?.invalid && profileForm.get('firstName')?.touched\">\n @if (profileForm.get('firstName')?.invalid && profileForm.get('firstName')?.touched) {\n <div class=\"invalid-feedback\">\n First name is required (max 30 characters)\n </div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"lastName\" class=\"form-label\">Last Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"lastName\"\n formControlName=\"lastName\"\n [class.is-invalid]=\"profileForm.get('lastName')?.invalid && profileForm.get('lastName')?.touched\">\n @if (profileForm.get('lastName')?.invalid && profileForm.get('lastName')?.touched) {\n <div class=\"invalid-feedback\">\n Last name is required (max 30 characters)\n </div>\n }\n </div>\n </div>\n <div class=\"mt-3\">\n <label for=\"currentPassword\" class=\"form-label\">Current Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"currentPassword\"\n formControlName=\"currentPassword\"\n placeholder=\"Enter your current password to confirm changes\"\n [class.is-invalid]=\"profileForm.get('currentPassword')?.invalid && profileForm.get('currentPassword')?.touched\">\n @if (profileForm.get('currentPassword')?.invalid && profileForm.get('currentPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Current password is required to update profile\n </div>\n }\n </div>\n\n @if (profileMessage()) {\n <div class=\"alert alert-success mt-3\">\n <i class=\"bi bi-check-circle me-2\"></i>{{ profileMessage() }}\n </div>\n }\n\n <div class=\"mt-3\">\n <button\n type=\"submit\"\n class=\"btn btn-primary\"\n [disabled]=\"profileForm.invalid || isUpdatingProfile()\">\n @if (isUpdatingProfile()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-check-circle me-1\"></i>\n Update Profile\n </button>\n </div>\n </form>\n </div>\n </div>\n </div>\n </ng-template>\n </li>\n\n <li [ngbNavItem]=\"'password'\">\n <button ngbNavLink (click)=\"setActiveTab('password')\">\n <i class=\"bi bi-shield-lock me-1\"></i>Change Password\n </button>\n <ng-template ngbNavContent>\n <div class=\"mt-4\">\n <!-- Password Change Form -->\n <div class=\"card\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-shield-lock me-2\"></i>Change Password\n </h6>\n </div>\n <div class=\"card-body\">\n <form [formGroup]=\"passwordForm\" (ngSubmit)=\"changePassword()\">\n <div class=\"mb-3\">\n <label for=\"currentPasswordChange\" class=\"form-label\">Current Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"currentPasswordChange\"\n formControlName=\"currentPassword\"\n [class.is-invalid]=\"passwordForm.get('currentPassword')?.invalid && passwordForm.get('currentPassword')?.touched\">\n @if (passwordForm.get('currentPassword')?.invalid && passwordForm.get('currentPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Current password is required\n </div>\n }\n </div>\n <div class=\"mb-3\">\n <label for=\"newPassword\" class=\"form-label\">New Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"newPassword\"\n formControlName=\"newPassword\"\n [class.is-invalid]=\"passwordForm.get('newPassword')?.invalid && passwordForm.get('newPassword')?.touched\">\n @if (passwordForm.get('newPassword')?.invalid && passwordForm.get('newPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Password must be at least 8 characters long\n </div>\n }\n </div>\n <div class=\"mb-3\">\n <label for=\"confirmPassword\" class=\"form-label\">Confirm New Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"confirmPassword\"\n formControlName=\"confirmPassword\"\n [class.is-invalid]=\"(passwordForm.get('confirmPassword')?.invalid || passwordForm.hasError('passwordMismatch')) && passwordForm.get('confirmPassword')?.touched\">\n @if (passwordForm.get('confirmPassword')?.invalid && passwordForm.get('confirmPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Please confirm your new password\n </div>\n }\n @if (passwordForm.hasError('passwordMismatch') && passwordForm.get('confirmPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Passwords do not match\n </div>\n }\n </div>\n\n @if (passwordMessage()) {\n <div class=\"alert alert-success\">\n <i class=\"bi bi-check-circle me-2\"></i>{{ passwordMessage() }}\n </div>\n }\n\n <button\n type=\"submit\"\n class=\"btn btn-primary\"\n [disabled]=\"passwordForm.invalid || isChangingPassword()\">\n @if (isChangingPassword()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-shield-check me-1\"></i>\n Change Password\n </button>\n </form>\n </div>\n </div>\n </div>\n </ng-template>\n </li>\n\n <li [ngbNavItem]=\"'email'\">\n <button ngbNavLink (click)=\"setActiveTab('email')\">\n <i class=\"bi bi-envelope me-1\"></i>Change Email\n </button>\n <ng-template ngbNavContent>\n <div class=\"mt-4\">\n <!-- Email Change Form -->\n <div class=\"card\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-envelope me-2\"></i>Change Email Address\n </h6>\n </div>\n <div class=\"card-body\">\n <div class=\"alert alert-info\">\n <i class=\"bi bi-info-circle me-2\"></i>\n Changing your email will require verification. You'll receive a confirmation email at your new address.\n </div>\n \n <form [formGroup]=\"emailChangeForm\" (ngSubmit)=\"requestEmailChange()\">\n <div class=\"mb-3\">\n <label for=\"currentEmail\" class=\"form-label\">Current Email</label>\n <input\n type=\"email\"\n class=\"form-control\"\n id=\"currentEmail\"\n [value]=\"currentUser()?.email\"\n readonly>\n </div>\n <div class=\"mb-3\">\n <label for=\"newEmail\" class=\"form-label\">New Email</label>\n <input\n type=\"email\"\n class=\"form-control\"\n id=\"newEmail\"\n formControlName=\"newEmail\"\n [class.is-invalid]=\"emailChangeForm.get('newEmail')?.invalid && emailChangeForm.get('newEmail')?.touched\">\n @if (emailChangeForm.get('newEmail')?.invalid && emailChangeForm.get('newEmail')?.touched) {\n <div class=\"invalid-feedback\">\n Please enter a valid email address\n </div>\n }\n </div>\n <div class=\"mb-3\">\n <label for=\"passwordEmailChange\" class=\"form-label\">Current Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"passwordEmailChange\"\n formControlName=\"currentPassword\"\n [class.is-invalid]=\"emailChangeForm.get('currentPassword')?.invalid && emailChangeForm.get('currentPassword')?.touched\">\n @if (emailChangeForm.get('currentPassword')?.invalid && emailChangeForm.get('currentPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Current password is required\n </div>\n }\n </div>\n\n @if (emailMessage()) {\n <div class=\"alert alert-success\">\n <i class=\"bi bi-check-circle me-2\"></i>{{ emailMessage() }}\n </div>\n }\n\n <button\n type=\"submit\"\n class=\"btn btn-primary\"\n [disabled]=\"emailChangeForm.invalid || isChangingEmail()\">\n @if (isChangingEmail()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-envelope-check me-1\"></i>\n Request Email Change\n </button>\n </form>\n </div>\n </div>\n </div>\n </ng-template>\n </li>\n </ul>\n\n <div [ngbNavOutlet]=\"profileNav\" class=\"mt-3\"></div>\n\n <!-- Error Messages -->\n @if (errorMessage()) {\n <div class=\"alert alert-danger mt-3\">\n <i class=\"bi bi-exclamation-triangle me-2\"></i>{{ errorMessage() }}\n </div>\n }\n }\n </div>\n</div>", styles: [""], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: NgbModule }, { kind: "directive", type: i2$1.NgbNavContent, selector: "ng-template[ngbNavContent]" }, { kind: "directive", type: i2$1.NgbNav, selector: "[ngbNav]", inputs: ["activeId", "animation", "destroyOnHide", "orientation", "roles", "keyboard"], outputs: ["activeIdChange", "shown", "hidden", "navChange"], exportAs: ["ngbNav"] }, { kind: "directive", type: i2$1.NgbNavItem, selector: "[ngbNavItem]", inputs: ["destroyOnHide", "disabled", "domId", "ngbNavItem"], outputs: ["shown", "hidden"], exportAs: ["ngbNavItem"] }, { kind: "directive", type: i2$1.NgbNavItemRole, selector: "[ngbNavItem]:not(ng-container)" }, { kind: "directive", type: i2$1.NgbNavLinkButton, selector: "button[ngbNavLink]" }, { kind: "directive", type: i2$1.NgbNavLinkBase, selector: "[ngbNavLink]" }, { kind: "component", type: i2$1.NgbNavOutlet, selector: "[ngbNavOutlet]", inputs: ["paneRole", "ngbNavOutlet"] }] });
2371
+ }
2372
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: UserProfileComponent, decorators: [{
2373
+ type: Component,
2374
+ args: [{ selector: 'app-user-profile', standalone: true, imports: [CommonModule, ReactiveFormsModule, NgbModule], template: "<div class=\"user-profile-container\">\n <!-- Header Section -->\n <nav class=\"navbar navbar-expand-lg bg-body-tertiary shadow-sm\">\n <div class=\"container-fluid\">\n <div class=\"d-flex align-items-center justify-content-between w-100\">\n <div class=\"d-flex align-items-center\">\n <h4 class=\"mb-0\">\n <i class=\"bi bi-person-circle me-2 text-primary\"></i>User Profile\n </h4>\n @if (isStaff()) {\n <span class=\"badge bg-warning ms-3\">Staff</span>\n }\n </div>\n </div>\n </div>\n </nav>\n\n <div class=\"profile-content p-4\">\n @if (isLoading()) {\n <div class=\"text-center\">\n <div class=\"spinner-border text-primary me-2\"></div>\n <span>Loading profile...</span>\n </div>\n } @else {\n <!-- User Info Card -->\n <div class=\"card mb-4\">\n <div class=\"card-header\">\n <h5 class=\"mb-0\">\n <i class=\"bi bi-info-circle me-2\"></i>Account Information\n </h5>\n </div>\n <div class=\"card-body\">\n <div class=\"row\">\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Full Name:</small>\n <div class=\"fw-semibold\">{{ fullName() }}</div>\n </div>\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Username:</small>\n <div class=\"fw-semibold\">{{ currentUser()?.username }}</div>\n </div>\n </div>\n <div class=\"row mt-3\">\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Email:</small>\n <div class=\"fw-semibold\">{{ currentUser()?.email }}</div>\n </div>\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Account Status:</small>\n <div>\n @if (currentUser()?.isActive) {\n <span class=\"badge bg-success\">Active</span>\n } @else {\n <span class=\"badge bg-danger\">Inactive</span>\n }\n </div>\n </div>\n </div>\n <div class=\"row mt-3\">\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Member Since:</small>\n <div class=\"fw-semibold\">{{ joinDate() }}</div>\n </div>\n <div class=\"col-md-6\">\n <small class=\"text-muted\">Last Login:</small>\n <div class=\"fw-semibold\">{{ lastLogin() }}</div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Navigation Tabs -->\n <ul ngbNav #profileNav=\"ngbNav\" [activeId]=\"activeTab()\" (activeIdChange)=\"setActiveTab($event)\" class=\"nav-tabs\">\n <li [ngbNavItem]=\"'profile'\">\n <button ngbNavLink (click)=\"setActiveTab('profile')\">\n <i class=\"bi bi-person me-1\"></i>Profile Settings\n </button>\n <ng-template ngbNavContent>\n <div class=\"mt-4\">\n <!-- Profile Update Form -->\n <div class=\"card\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-pencil-square me-2\"></i>Update Profile Information\n </h6>\n </div>\n <div class=\"card-body\">\n <form [formGroup]=\"profileForm\" (ngSubmit)=\"updateProfile()\">\n <div class=\"row g-3\">\n <div class=\"col-md-6\">\n <label for=\"firstName\" class=\"form-label\">First Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"firstName\"\n formControlName=\"firstName\"\n [class.is-invalid]=\"profileForm.get('firstName')?.invalid && profileForm.get('firstName')?.touched\">\n @if (profileForm.get('firstName')?.invalid && profileForm.get('firstName')?.touched) {\n <div class=\"invalid-feedback\">\n First name is required (max 30 characters)\n </div>\n }\n </div>\n <div class=\"col-md-6\">\n <label for=\"lastName\" class=\"form-label\">Last Name</label>\n <input\n type=\"text\"\n class=\"form-control\"\n id=\"lastName\"\n formControlName=\"lastName\"\n [class.is-invalid]=\"profileForm.get('lastName')?.invalid && profileForm.get('lastName')?.touched\">\n @if (profileForm.get('lastName')?.invalid && profileForm.get('lastName')?.touched) {\n <div class=\"invalid-feedback\">\n Last name is required (max 30 characters)\n </div>\n }\n </div>\n </div>\n <div class=\"mt-3\">\n <label for=\"currentPassword\" class=\"form-label\">Current Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"currentPassword\"\n formControlName=\"currentPassword\"\n placeholder=\"Enter your current password to confirm changes\"\n [class.is-invalid]=\"profileForm.get('currentPassword')?.invalid && profileForm.get('currentPassword')?.touched\">\n @if (profileForm.get('currentPassword')?.invalid && profileForm.get('currentPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Current password is required to update profile\n </div>\n }\n </div>\n\n @if (profileMessage()) {\n <div class=\"alert alert-success mt-3\">\n <i class=\"bi bi-check-circle me-2\"></i>{{ profileMessage() }}\n </div>\n }\n\n <div class=\"mt-3\">\n <button\n type=\"submit\"\n class=\"btn btn-primary\"\n [disabled]=\"profileForm.invalid || isUpdatingProfile()\">\n @if (isUpdatingProfile()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-check-circle me-1\"></i>\n Update Profile\n </button>\n </div>\n </form>\n </div>\n </div>\n </div>\n </ng-template>\n </li>\n\n <li [ngbNavItem]=\"'password'\">\n <button ngbNavLink (click)=\"setActiveTab('password')\">\n <i class=\"bi bi-shield-lock me-1\"></i>Change Password\n </button>\n <ng-template ngbNavContent>\n <div class=\"mt-4\">\n <!-- Password Change Form -->\n <div class=\"card\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-shield-lock me-2\"></i>Change Password\n </h6>\n </div>\n <div class=\"card-body\">\n <form [formGroup]=\"passwordForm\" (ngSubmit)=\"changePassword()\">\n <div class=\"mb-3\">\n <label for=\"currentPasswordChange\" class=\"form-label\">Current Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"currentPasswordChange\"\n formControlName=\"currentPassword\"\n [class.is-invalid]=\"passwordForm.get('currentPassword')?.invalid && passwordForm.get('currentPassword')?.touched\">\n @if (passwordForm.get('currentPassword')?.invalid && passwordForm.get('currentPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Current password is required\n </div>\n }\n </div>\n <div class=\"mb-3\">\n <label for=\"newPassword\" class=\"form-label\">New Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"newPassword\"\n formControlName=\"newPassword\"\n [class.is-invalid]=\"passwordForm.get('newPassword')?.invalid && passwordForm.get('newPassword')?.touched\">\n @if (passwordForm.get('newPassword')?.invalid && passwordForm.get('newPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Password must be at least 8 characters long\n </div>\n }\n </div>\n <div class=\"mb-3\">\n <label for=\"confirmPassword\" class=\"form-label\">Confirm New Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"confirmPassword\"\n formControlName=\"confirmPassword\"\n [class.is-invalid]=\"(passwordForm.get('confirmPassword')?.invalid || passwordForm.hasError('passwordMismatch')) && passwordForm.get('confirmPassword')?.touched\">\n @if (passwordForm.get('confirmPassword')?.invalid && passwordForm.get('confirmPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Please confirm your new password\n </div>\n }\n @if (passwordForm.hasError('passwordMismatch') && passwordForm.get('confirmPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Passwords do not match\n </div>\n }\n </div>\n\n @if (passwordMessage()) {\n <div class=\"alert alert-success\">\n <i class=\"bi bi-check-circle me-2\"></i>{{ passwordMessage() }}\n </div>\n }\n\n <button\n type=\"submit\"\n class=\"btn btn-primary\"\n [disabled]=\"passwordForm.invalid || isChangingPassword()\">\n @if (isChangingPassword()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-shield-check me-1\"></i>\n Change Password\n </button>\n </form>\n </div>\n </div>\n </div>\n </ng-template>\n </li>\n\n <li [ngbNavItem]=\"'email'\">\n <button ngbNavLink (click)=\"setActiveTab('email')\">\n <i class=\"bi bi-envelope me-1\"></i>Change Email\n </button>\n <ng-template ngbNavContent>\n <div class=\"mt-4\">\n <!-- Email Change Form -->\n <div class=\"card\">\n <div class=\"card-header\">\n <h6 class=\"mb-0\">\n <i class=\"bi bi-envelope me-2\"></i>Change Email Address\n </h6>\n </div>\n <div class=\"card-body\">\n <div class=\"alert alert-info\">\n <i class=\"bi bi-info-circle me-2\"></i>\n Changing your email will require verification. You'll receive a confirmation email at your new address.\n </div>\n \n <form [formGroup]=\"emailChangeForm\" (ngSubmit)=\"requestEmailChange()\">\n <div class=\"mb-3\">\n <label for=\"currentEmail\" class=\"form-label\">Current Email</label>\n <input\n type=\"email\"\n class=\"form-control\"\n id=\"currentEmail\"\n [value]=\"currentUser()?.email\"\n readonly>\n </div>\n <div class=\"mb-3\">\n <label for=\"newEmail\" class=\"form-label\">New Email</label>\n <input\n type=\"email\"\n class=\"form-control\"\n id=\"newEmail\"\n formControlName=\"newEmail\"\n [class.is-invalid]=\"emailChangeForm.get('newEmail')?.invalid && emailChangeForm.get('newEmail')?.touched\">\n @if (emailChangeForm.get('newEmail')?.invalid && emailChangeForm.get('newEmail')?.touched) {\n <div class=\"invalid-feedback\">\n Please enter a valid email address\n </div>\n }\n </div>\n <div class=\"mb-3\">\n <label for=\"passwordEmailChange\" class=\"form-label\">Current Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n id=\"passwordEmailChange\"\n formControlName=\"currentPassword\"\n [class.is-invalid]=\"emailChangeForm.get('currentPassword')?.invalid && emailChangeForm.get('currentPassword')?.touched\">\n @if (emailChangeForm.get('currentPassword')?.invalid && emailChangeForm.get('currentPassword')?.touched) {\n <div class=\"invalid-feedback\">\n Current password is required\n </div>\n }\n </div>\n\n @if (emailMessage()) {\n <div class=\"alert alert-success\">\n <i class=\"bi bi-check-circle me-2\"></i>{{ emailMessage() }}\n </div>\n }\n\n <button\n type=\"submit\"\n class=\"btn btn-primary\"\n [disabled]=\"emailChangeForm.invalid || isChangingEmail()\">\n @if (isChangingEmail()) {\n <span class=\"spinner-border spinner-border-sm me-2\"></span>\n }\n <i class=\"bi bi-envelope-check me-1\"></i>\n Request Email Change\n </button>\n </form>\n </div>\n </div>\n </div>\n </ng-template>\n </li>\n </ul>\n\n <div [ngbNavOutlet]=\"profileNav\" class=\"mt-3\"></div>\n\n <!-- Error Messages -->\n @if (errorMessage()) {\n <div class=\"alert alert-danger mt-3\">\n <i class=\"bi bi-exclamation-triangle me-2\"></i>{{ errorMessage() }}\n </div>\n }\n }\n </div>\n</div>" }]
2375
+ }], ctorParameters: () => [] });
2376
+
2377
+ class SiteConfigComponent {
2378
+ fb = inject(FormBuilder);
2379
+ siteConfigService = inject(SiteConfigService);
2380
+ configForm;
2381
+ // Signals for reactive state management
2382
+ loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
2383
+ error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : []));
2384
+ success = signal(null, ...(ngDevMode ? [{ debugName: "success" }] : []));
2385
+ selectedLogoFile = signal(null, ...(ngDevMode ? [{ debugName: "selectedLogoFile" }] : []));
2386
+ currentConfig = signal(null, ...(ngDevMode ? [{ debugName: "currentConfig" }] : []));
2387
+ // Computed signal for preview configuration
2388
+ previewConfig = computed(() => {
2389
+ if (!this.currentConfig())
2390
+ return null;
2391
+ return { ...this.currentConfig(), ...this.configForm.value };
2392
+ }, ...(ngDevMode ? [{ debugName: "previewConfig" }] : []));
2393
+ // Preset colors for the color picker
2394
+ presetColors = [
2395
+ '#1976d2', '#2196f3', '#03a9f4', '#00bcd4', '#009688', '#4caf50',
2396
+ '#8bc34a', '#cddc39', '#ffeb3b', '#ffc107', '#ff9800', '#ff5722',
2397
+ '#f44336', '#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#607d8b'
2398
+ ];
2399
+ constructor() {
2400
+ this.configForm = this.fb.group({
2401
+ siteName: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(255)]],
2402
+ logoUrl: [''],
2403
+ primaryColor: ['#1976d2', [Validators.pattern(/^#[0-9A-Fa-f]{6}$/)]],
2404
+ showPoweredBy: [true],
2405
+ allowUserRegistration: [false],
2406
+ enableOrcidLogin: [false]
2407
+ });
2408
+ }
2409
+ ngOnInit() {
2410
+ // Load current configuration for admin (authenticated endpoint)
2411
+ this.siteConfigService.getCurrentConfig().subscribe({
2412
+ next: (config) => {
2413
+ this.currentConfig.set(config);
2414
+ this.configForm.patchValue(config);
2415
+ },
2416
+ error: (error) => {
2417
+ this.error.set('Failed to load current configuration.');
2418
+ }
2419
+ });
2420
+ }
2421
+ /**
2422
+ * Update site configuration
2423
+ */
2424
+ onSubmit() {
2425
+ if (this.configForm.valid) {
2426
+ this.loading.set(true);
2427
+ this.error.set(null);
2428
+ this.success.set(null);
2429
+ const config = this.configForm.value;
2430
+ this.siteConfigService.updateConfig(config).subscribe({
2431
+ next: (updatedConfig) => {
2432
+ this.loading.set(false);
2433
+ this.success.set('Site configuration updated successfully!');
2434
+ this.currentConfig.set(updatedConfig);
2435
+ localStorage.setItem('site_config', JSON.stringify(updatedConfig));
2436
+ setTimeout(() => {
2437
+ this.success.set(null);
2438
+ }, 3000);
2439
+ },
2440
+ error: (error) => {
2441
+ this.loading.set(false);
2442
+ this.error.set(error.error?.detail || 'Failed to update site configuration.');
2443
+ setTimeout(() => {
2444
+ this.error.set(null);
2445
+ }, 5000);
2446
+ }
2447
+ });
2448
+ }
2449
+ }
2450
+ /**
2451
+ * Reset form to current configuration
2452
+ */
2453
+ resetForm() {
2454
+ if (this.currentConfig()) {
2455
+ this.configForm.patchValue(this.currentConfig());
2456
+ }
2457
+ this.error.set(null);
2458
+ this.success.set(null);
2459
+ }
2460
+ /**
2461
+ * Handle color change from ngx-color picker
2462
+ */
2463
+ onColorChange(event) {
2464
+ const color = event.color?.hex || event.hex || event;
2465
+ if (color && typeof color === 'string') {
2466
+ this.configForm.get('primaryColor')?.setValue(color);
2467
+ this.configForm.get('primaryColor')?.markAsTouched();
2468
+ }
2469
+ }
2470
+ /**
2471
+ * Get a darker version of the given color for gradients
2472
+ */
2473
+ getDarkerColor(hex) {
2474
+ if (!hex || !hex.startsWith('#')) {
2475
+ return '#1565c0'; // Default darker color
2476
+ }
2477
+ // Remove # and convert to RGB
2478
+ const r = parseInt(hex.slice(1, 3), 16);
2479
+ const g = parseInt(hex.slice(3, 5), 16);
2480
+ const b = parseInt(hex.slice(5, 7), 16);
2481
+ // Darken by 20%
2482
+ const factor = 0.8;
2483
+ const darkR = Math.floor(r * factor);
2484
+ const darkG = Math.floor(g * factor);
2485
+ const darkB = Math.floor(b * factor);
2486
+ // Convert back to hex
2487
+ const toHex = (n) => n.toString(16).padStart(2, '0');
2488
+ return `#${toHex(darkR)}${toHex(darkG)}${toHex(darkB)}`;
2489
+ }
2490
+ /**
2491
+ * Get the current primary color value for styling
2492
+ */
2493
+ getCurrentPrimaryColor() {
2494
+ return this.configForm.get('primaryColor')?.value || '#1976d2';
2495
+ }
2496
+ /**
2497
+ * Clear error message
2498
+ */
2499
+ clearError() {
2500
+ this.error.set(null);
2501
+ }
2502
+ /**
2503
+ * Clear success message
2504
+ */
2505
+ clearSuccess() {
2506
+ this.success.set(null);
2507
+ }
2508
+ /**
2509
+ * Handle logo file selection
2510
+ */
2511
+ onLogoFileSelected(event) {
2512
+ const target = event.target;
2513
+ if (target.files && target.files.length > 0) {
2514
+ const file = target.files[0];
2515
+ // Validate file type
2516
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
2517
+ if (!allowedTypes.includes(file.type)) {
2518
+ this.error.set('Please select a valid image file (JPEG, PNG, GIF, or SVG).');
2519
+ this.selectedLogoFile.set(null);
2520
+ target.value = '';
2521
+ return;
2522
+ }
2523
+ // Validate file size (max 5MB)
2524
+ const maxSize = 5 * 1024 * 1024; // 5MB
2525
+ if (file.size > maxSize) {
2526
+ this.error.set('Logo file size must be less than 5MB.');
2527
+ this.selectedLogoFile.set(null);
2528
+ target.value = '';
2529
+ return;
2530
+ }
2531
+ this.selectedLogoFile.set(file);
2532
+ this.error.set(null);
2533
+ }
2534
+ }
2535
+ /**
2536
+ * Remove selected logo file
2537
+ */
2538
+ clearLogoFile() {
2539
+ this.selectedLogoFile.set(null);
2540
+ const fileInput = document.querySelector('input[type="file"]');
2541
+ if (fileInput) {
2542
+ fileInput.value = '';
2543
+ }
2544
+ }
2545
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: SiteConfigComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2546
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.7", type: SiteConfigComponent, isStandalone: true, selector: "app-site-config", ngImport: i0, template: "<div class=\"site-config-container\">\r\n <!-- Header Section -->\r\n <nav class=\"navbar navbar-expand-lg bg-body-tertiary shadow-sm\">\r\n <div class=\"container-fluid\">\r\n <div class=\"d-flex align-items-center justify-content-between w-100\">\r\n <div class=\"d-flex align-items-center\">\r\n <h4 class=\"navbar-brand m-0 fw-bold\">\r\n <i class=\"bi bi-gear me-2\"></i>Site Configuration\r\n </h4>\r\n </div>\r\n <div class=\"d-flex gap-2\">\r\n <button \r\n type=\"submit\" \r\n form=\"configForm\"\r\n class=\"btn btn-primary btn-sm\"\r\n [disabled]=\"configForm.invalid || loading()\">\r\n @if (loading()) {\r\n <span class=\"spinner-border spinner-border-sm me-1\" aria-hidden=\"true\"></span>\r\n } @else {\r\n <i class=\"bi bi-check-lg me-1\"></i>\r\n }\r\n Save Configuration\r\n </button>\r\n \r\n <button \r\n type=\"button\" \r\n class=\"btn btn-outline-secondary btn-sm\"\r\n (click)=\"resetForm()\"\r\n [disabled]=\"loading()\">\r\n <i class=\"bi bi-arrow-clockwise me-1\"></i>\r\n Reset\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n </nav>\r\n\r\n <div class=\"config-content p-4\">\r\n <div class=\"row\">\r\n <div class=\"col-lg-8\">\r\n <!-- Site Branding Card -->\r\n <div class=\"card mb-4\">\r\n <div class=\"card-header\">\r\n <h5 class=\"mb-0\">\r\n <i class=\"bi bi-brush me-2\"></i>Site Branding\r\n </h5>\r\n </div>\r\n <div class=\"card-body\">\r\n <!-- Success Alert -->\r\n @if (success()) {\r\n <ngb-alert type=\"success\" (closed)=\"clearSuccess()\" [dismissible]=\"true\">\r\n <i class=\"bi bi-check-circle me-2\"></i>{{ success() }}\r\n </ngb-alert>\r\n }\r\n\r\n <!-- Error Alert -->\r\n @if (error()) {\r\n <ngb-alert type=\"danger\" (closed)=\"clearError()\" [dismissible]=\"true\">\r\n <i class=\"bi bi-exclamation-triangle me-2\"></i>{{ error() }}\r\n </ngb-alert>\r\n }\r\n\r\n <form id=\"configForm\" [formGroup]=\"configForm\" (ngSubmit)=\"onSubmit()\">\r\n <!-- Site Name -->\r\n <div class=\"mb-3\">\r\n <label for=\"site_name\" class=\"form-label\">\r\n <i class=\"bi bi-building me-1\"></i>\r\n Site Name <span class=\"text-danger\">*</span>\r\n </label>\r\n <input \r\n type=\"text\" \r\n class=\"form-control\" \r\n id=\"site_name\"\r\n formControlName=\"siteName\"\r\n [class.is-invalid]=\"configForm.get('siteName')?.invalid && configForm.get('siteName')?.touched\"\r\n placeholder=\"Enter your site name\">\r\n @if (configForm.get('siteName')?.invalid && configForm.get('siteName')?.touched) {\r\n <div class=\"invalid-feedback\">\r\n @if (configForm.get('siteName')?.errors?.['required']) {\r\n <span>Site name is required</span>\r\n }\r\n @if (configForm.get('siteName')?.errors?.['minlength']) {\r\n <span>Site name must be at least 1 character</span>\r\n }\r\n @if (configForm.get('siteName')?.errors?.['maxlength']) {\r\n <span>Site name must be less than 255 characters</span>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-text\">\r\n This will appear in the navigation bar and login page\r\n </div>\r\n </div>\r\n\r\n <!-- Logo URL -->\r\n <div class=\"mb-3\">\r\n <label for=\"logo_url\" class=\"form-label\">\r\n <i class=\"bi bi-image me-1\"></i>\r\n Logo URL\r\n </label>\r\n <input \r\n type=\"url\" \r\n class=\"form-control\" \r\n id=\"logo_url\"\r\n formControlName=\"logoUrl\"\r\n placeholder=\"https://example.com/logo.png\">\r\n <div class=\"form-text\">\r\n Optional: URL to your organization's logo\r\n </div>\r\n </div>\r\n\r\n <!-- Logo File Upload -->\r\n <div class=\"mb-3\">\r\n <label for=\"logo_file\" class=\"form-label\">\r\n <i class=\"bi bi-upload me-1\"></i>\r\n Upload Logo File\r\n </label>\r\n <input \r\n type=\"file\" \r\n class=\"form-control\" \r\n id=\"logo_file\"\r\n accept=\"image/*\"\r\n (change)=\"onLogoFileSelected($event)\">\r\n <div class=\"form-text\">\r\n Upload a logo file (overrides logo URL). Supported: JPEG, PNG, GIF, SVG. Max 5MB.\r\n </div>\r\n @if (selectedLogoFile()) {\r\n <div class=\"mt-2\">\r\n <div class=\"alert alert-info py-2\">\r\n <i class=\"bi bi-file-image me-1\"></i>\r\n Selected: {{ selectedLogoFile()?.name }}\r\n <button type=\"button\" class=\"btn btn-sm btn-outline-danger ms-2\" (click)=\"clearLogoFile()\">\r\n <i class=\"bi bi-x\"></i>\r\n </button>\r\n </div>\r\n </div>\r\n }\r\n </div>\r\n\r\n <!-- Primary Color -->\r\n <div class=\"mb-3\">\r\n <label for=\"primary_color\" class=\"form-label\">\r\n <i class=\"bi bi-palette me-1\"></i>\r\n Primary Color\r\n </label>\r\n <div class=\"color-picker-wrapper\">\r\n <color-sketch\r\n [color]=\"configForm.get('primaryColor')?.value\"\r\n (onChange)=\"onColorChange($event)\"\r\n [presetColors]=\"presetColors\">\r\n </color-sketch>\r\n </div>\r\n @if (configForm.get('primaryColor')?.invalid && configForm.get('primaryColor')?.touched) {\r\n <div class=\"invalid-feedback\">\r\n @if (configForm.get('primaryColor')?.errors?.['pattern']) {\r\n <span>Please enter a valid hex color (e.g., #1976d2)</span>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-text\">\r\n Main theme color for navigation and buttons\r\n </div>\r\n </div>\r\n\r\n <!-- Show Powered By -->\r\n <div class=\"mb-4\">\r\n <div class=\"form-check\">\r\n <input \r\n class=\"form-check-input\" \r\n type=\"checkbox\" \r\n id=\"show_powered_by\"\r\n formControlName=\"showPoweredBy\">\r\n <label class=\"form-check-label\" for=\"show_powered_by\">\r\n <i class=\"bi bi-lightning-charge me-1\"></i>\r\n Show \"Powered by CUPCAKE Vanilla\"\r\n </label>\r\n </div>\r\n <div class=\"form-text\">\r\n Display a small footer credit (appears on hover in bottom-right corner)\r\n </div>\r\n </div>\r\n\r\n <!-- Authentication Settings Section -->\r\n <div class=\"mb-4\">\r\n <h6 class=\"text-muted border-bottom pb-2\">\r\n <i class=\"bi bi-shield-lock me-2\"></i>Authentication Settings\r\n </h6>\r\n \r\n <!-- Allow User Registration -->\r\n <div class=\"mb-3\">\r\n <div class=\"form-check\">\r\n <input \r\n class=\"form-check-input\" \r\n type=\"checkbox\" \r\n id=\"allow_user_registration\"\r\n formControlName=\"allowUserRegistration\">\r\n <label class=\"form-check-label\" for=\"allow_user_registration\">\r\n <i class=\"bi bi-person-plus me-1\"></i>\r\n Allow Public User Registration\r\n </label>\r\n </div>\r\n <div class=\"form-text\">\r\n When enabled, users can register new accounts without admin approval. \r\n The registration API endpoint will be accessible to the public.\r\n </div>\r\n </div>\r\n\r\n <!-- Enable ORCID Login -->\r\n <div class=\"mb-3\">\r\n <div class=\"form-check\">\r\n <input \r\n class=\"form-check-input\" \r\n type=\"checkbox\" \r\n id=\"enable_orcid_login\"\r\n formControlName=\"enableOrcidLogin\">\r\n <label class=\"form-check-label\" for=\"enable_orcid_login\">\r\n <i class=\"bi bi-person-badge me-1\"></i>\r\n Enable ORCID OAuth Login\r\n </label>\r\n </div>\r\n <div class=\"form-text\">\r\n Allow users to log in using their ORCID account. \r\n Requires ORCID OAuth configuration in server settings.\r\n </div>\r\n </div>\r\n </div>\r\n\r\n </form>\r\n </div>\r\n </div>\r\n\r\n </div>\r\n\r\n <div class=\"col-lg-4\">\r\n <!-- Preview Panel -->\r\n <div class=\"card mb-4\">\r\n <div class=\"card-header\">\r\n <h5 class=\"mb-0\">\r\n <i class=\"bi bi-eye me-2\"></i>Preview\r\n </h5>\r\n </div>\r\n <div class=\"card-body\">\r\n <div class=\"preview-navbar mb-3\" \r\n [style.background]=\"'linear-gradient(135deg, ' + (configForm.get('primaryColor')?.value || '#1976d2') + ' 0%, ' + getDarkerColor(configForm.get('primaryColor')?.value || '#1976d2') + ' 100%)'\">\r\n <div class=\"preview-nav\">\r\n <span class=\"preview-brand\">\r\n @if (configForm.get('logoUrl')?.value) {\r\n <img [src]=\"configForm.get('logoUrl')?.value\" alt=\"Logo\" style=\"height: 20px; width: auto;\" class=\"me-2\">\r\n } @else {\r\n <i class=\"bi bi-flask me-2\"></i>\r\n }\r\n {{ configForm.get('siteName')?.value || 'Your Site Name' }}\r\n </span>\r\n </div>\r\n </div>\r\n \r\n <div class=\"preview-login-card\">\r\n <h6 class=\"text-center mb-3\">\r\n @if (configForm.get('logoUrl')?.value) {\r\n <img [src]=\"configForm.get('logoUrl')?.value\" alt=\"Logo\" style=\"height: 24px; width: auto;\" class=\"me-2\">\r\n } @else {\r\n <i class=\"bi bi-flask me-2\"></i>\r\n }\r\n {{ configForm.get('siteName')?.value || 'Your Site Name' }}\r\n </h6>\r\n @if (configForm.get('enableOrcidLogin')?.value) {\r\n <div class=\"preview-button mb-2\"\r\n [style.background]=\"'linear-gradient(135deg, ' + (configForm.get('primaryColor')?.value || '#1976d2') + ' 0%, ' + getDarkerColor(configForm.get('primaryColor')?.value || '#1976d2') + ' 100%)'\">\r\n <i class=\"bi bi-person-badge me-2\"></i>\r\n Login with ORCID\r\n </div>\r\n }\r\n <div class=\"preview-button mb-2\"\r\n [style.background]=\"'linear-gradient(135deg, ' + (configForm.get('primaryColor')?.value || '#1976d2') + ' 0%, ' + getDarkerColor(configForm.get('primaryColor')?.value || '#1976d2') + ' 100%)'\">\r\n <i class=\"bi bi-box-arrow-in-right me-2\"></i>\r\n Regular Login\r\n </div>\r\n @if (configForm.get('allowUserRegistration')?.value) {\r\n <div class=\"text-center mb-2\">\r\n <small class=\"text-muted\">\r\n <i class=\"bi bi-person-plus me-1\"></i>\r\n Don't have an account? <a href=\"#\" class=\"text-decoration-none\">Register here</a>\r\n </small>\r\n </div>\r\n }\r\n <div class=\"text-center\">\r\n <small class=\"text-muted\">\r\n {{ configForm.get('siteName')?.value || 'Your Site Name' }} - Scientific Metadata Management\r\n </small>\r\n </div>\r\n </div>\r\n\r\n <div class=\"mt-3\">\r\n <h6 class=\"text-muted\">Authentication Status:</h6>\r\n <ul class=\"list-unstyled small text-muted mb-0\">\r\n <li>\r\n <i class=\"bi bi-check-circle-fill text-success me-1\"></i>\r\n Regular login: Always enabled\r\n </li>\r\n <li>\r\n @if (configForm.get('enableOrcidLogin')?.value) {\r\n <i class=\"bi bi-check-circle-fill text-success me-1\"></i>\r\n ORCID login: Enabled\r\n } @else {\r\n <i class=\"bi bi-x-circle-fill text-danger me-1\"></i>\r\n ORCID login: Disabled\r\n }\r\n </li>\r\n <li>\r\n @if (configForm.get('allowUserRegistration')?.value) {\r\n <i class=\"bi bi-check-circle-fill text-success me-1\"></i>\r\n Public registration: Enabled\r\n } @else {\r\n <i class=\"bi bi-x-circle-fill text-danger me-1\"></i>\r\n Public registration: Disabled\r\n }\r\n </li>\r\n </ul>\r\n </div>\r\n\r\n @if (configForm.get('showPoweredBy')?.value) {\r\n <div class=\"mt-3\">\r\n <small class=\"text-muted\">\r\n <i class=\"bi bi-lightning-charge me-1\"></i>\r\n Footer: \"Powered by CUPCAKE Vanilla\" will appear on hover\r\n </small>\r\n </div>\r\n }\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n</div>", styles: [".color-picker-wrapper ::ng-deep .sketch-picker{background:var(--bs-body-bg)!important;border:1px solid var(--bs-border-color)!important;box-shadow:0 .5rem 1rem #00000026!important}.color-picker-wrapper ::ng-deep .sketch-picker .sketch-picker-controls .sketch-picker-color-wrap .sketch-picker-active{border:2px solid var(--bs-border-color)!important}.color-picker-wrapper ::ng-deep .sketch-picker .sketch-picker-fields{background:var(--bs-body-bg)!important}.color-picker-wrapper ::ng-deep .sketch-picker .sketch-picker-fields .sketch-picker-field input{background:var(--bs-body-bg)!important;border:1px solid var(--bs-border-color)!important;color:var(--bs-body-color)!important}.color-picker-wrapper ::ng-deep .sketch-picker .sketch-picker-fields .sketch-picker-field input:focus{border-color:var(--bs-primary)!important;box-shadow:0 0 0 .25rem rgba(var(--bs-primary-rgb),.25)!important}.color-picker-wrapper ::ng-deep .sketch-picker .sketch-picker-fields .sketch-picker-field label{color:var(--bs-body-color)!important}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: NgbAlert, selector: "ngb-alert", inputs: ["animation", "dismissible", "type"], outputs: ["closed"], exportAs: ["ngbAlert"] }, { kind: "ngmodule", type: ColorSketchModule }, { kind: "component", type: i2$2.SketchComponent, selector: "color-sketch", inputs: ["disableAlpha", "presetColors", "width"] }] });
2547
+ }
2548
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: SiteConfigComponent, decorators: [{
2549
+ type: Component,
2550
+ args: [{ selector: 'app-site-config', standalone: true, imports: [CommonModule, ReactiveFormsModule, NgbAlert, ColorSketchModule], template: "<div class=\"site-config-container\">\r\n <!-- Header Section -->\r\n <nav class=\"navbar navbar-expand-lg bg-body-tertiary shadow-sm\">\r\n <div class=\"container-fluid\">\r\n <div class=\"d-flex align-items-center justify-content-between w-100\">\r\n <div class=\"d-flex align-items-center\">\r\n <h4 class=\"navbar-brand m-0 fw-bold\">\r\n <i class=\"bi bi-gear me-2\"></i>Site Configuration\r\n </h4>\r\n </div>\r\n <div class=\"d-flex gap-2\">\r\n <button \r\n type=\"submit\" \r\n form=\"configForm\"\r\n class=\"btn btn-primary btn-sm\"\r\n [disabled]=\"configForm.invalid || loading()\">\r\n @if (loading()) {\r\n <span class=\"spinner-border spinner-border-sm me-1\" aria-hidden=\"true\"></span>\r\n } @else {\r\n <i class=\"bi bi-check-lg me-1\"></i>\r\n }\r\n Save Configuration\r\n </button>\r\n \r\n <button \r\n type=\"button\" \r\n class=\"btn btn-outline-secondary btn-sm\"\r\n (click)=\"resetForm()\"\r\n [disabled]=\"loading()\">\r\n <i class=\"bi bi-arrow-clockwise me-1\"></i>\r\n Reset\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n </nav>\r\n\r\n <div class=\"config-content p-4\">\r\n <div class=\"row\">\r\n <div class=\"col-lg-8\">\r\n <!-- Site Branding Card -->\r\n <div class=\"card mb-4\">\r\n <div class=\"card-header\">\r\n <h5 class=\"mb-0\">\r\n <i class=\"bi bi-brush me-2\"></i>Site Branding\r\n </h5>\r\n </div>\r\n <div class=\"card-body\">\r\n <!-- Success Alert -->\r\n @if (success()) {\r\n <ngb-alert type=\"success\" (closed)=\"clearSuccess()\" [dismissible]=\"true\">\r\n <i class=\"bi bi-check-circle me-2\"></i>{{ success() }}\r\n </ngb-alert>\r\n }\r\n\r\n <!-- Error Alert -->\r\n @if (error()) {\r\n <ngb-alert type=\"danger\" (closed)=\"clearError()\" [dismissible]=\"true\">\r\n <i class=\"bi bi-exclamation-triangle me-2\"></i>{{ error() }}\r\n </ngb-alert>\r\n }\r\n\r\n <form id=\"configForm\" [formGroup]=\"configForm\" (ngSubmit)=\"onSubmit()\">\r\n <!-- Site Name -->\r\n <div class=\"mb-3\">\r\n <label for=\"site_name\" class=\"form-label\">\r\n <i class=\"bi bi-building me-1\"></i>\r\n Site Name <span class=\"text-danger\">*</span>\r\n </label>\r\n <input \r\n type=\"text\" \r\n class=\"form-control\" \r\n id=\"site_name\"\r\n formControlName=\"siteName\"\r\n [class.is-invalid]=\"configForm.get('siteName')?.invalid && configForm.get('siteName')?.touched\"\r\n placeholder=\"Enter your site name\">\r\n @if (configForm.get('siteName')?.invalid && configForm.get('siteName')?.touched) {\r\n <div class=\"invalid-feedback\">\r\n @if (configForm.get('siteName')?.errors?.['required']) {\r\n <span>Site name is required</span>\r\n }\r\n @if (configForm.get('siteName')?.errors?.['minlength']) {\r\n <span>Site name must be at least 1 character</span>\r\n }\r\n @if (configForm.get('siteName')?.errors?.['maxlength']) {\r\n <span>Site name must be less than 255 characters</span>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-text\">\r\n This will appear in the navigation bar and login page\r\n </div>\r\n </div>\r\n\r\n <!-- Logo URL -->\r\n <div class=\"mb-3\">\r\n <label for=\"logo_url\" class=\"form-label\">\r\n <i class=\"bi bi-image me-1\"></i>\r\n Logo URL\r\n </label>\r\n <input \r\n type=\"url\" \r\n class=\"form-control\" \r\n id=\"logo_url\"\r\n formControlName=\"logoUrl\"\r\n placeholder=\"https://example.com/logo.png\">\r\n <div class=\"form-text\">\r\n Optional: URL to your organization's logo\r\n </div>\r\n </div>\r\n\r\n <!-- Logo File Upload -->\r\n <div class=\"mb-3\">\r\n <label for=\"logo_file\" class=\"form-label\">\r\n <i class=\"bi bi-upload me-1\"></i>\r\n Upload Logo File\r\n </label>\r\n <input \r\n type=\"file\" \r\n class=\"form-control\" \r\n id=\"logo_file\"\r\n accept=\"image/*\"\r\n (change)=\"onLogoFileSelected($event)\">\r\n <div class=\"form-text\">\r\n Upload a logo file (overrides logo URL). Supported: JPEG, PNG, GIF, SVG. Max 5MB.\r\n </div>\r\n @if (selectedLogoFile()) {\r\n <div class=\"mt-2\">\r\n <div class=\"alert alert-info py-2\">\r\n <i class=\"bi bi-file-image me-1\"></i>\r\n Selected: {{ selectedLogoFile()?.name }}\r\n <button type=\"button\" class=\"btn btn-sm btn-outline-danger ms-2\" (click)=\"clearLogoFile()\">\r\n <i class=\"bi bi-x\"></i>\r\n </button>\r\n </div>\r\n </div>\r\n }\r\n </div>\r\n\r\n <!-- Primary Color -->\r\n <div class=\"mb-3\">\r\n <label for=\"primary_color\" class=\"form-label\">\r\n <i class=\"bi bi-palette me-1\"></i>\r\n Primary Color\r\n </label>\r\n <div class=\"color-picker-wrapper\">\r\n <color-sketch\r\n [color]=\"configForm.get('primaryColor')?.value\"\r\n (onChange)=\"onColorChange($event)\"\r\n [presetColors]=\"presetColors\">\r\n </color-sketch>\r\n </div>\r\n @if (configForm.get('primaryColor')?.invalid && configForm.get('primaryColor')?.touched) {\r\n <div class=\"invalid-feedback\">\r\n @if (configForm.get('primaryColor')?.errors?.['pattern']) {\r\n <span>Please enter a valid hex color (e.g., #1976d2)</span>\r\n }\r\n </div>\r\n }\r\n <div class=\"form-text\">\r\n Main theme color for navigation and buttons\r\n </div>\r\n </div>\r\n\r\n <!-- Show Powered By -->\r\n <div class=\"mb-4\">\r\n <div class=\"form-check\">\r\n <input \r\n class=\"form-check-input\" \r\n type=\"checkbox\" \r\n id=\"show_powered_by\"\r\n formControlName=\"showPoweredBy\">\r\n <label class=\"form-check-label\" for=\"show_powered_by\">\r\n <i class=\"bi bi-lightning-charge me-1\"></i>\r\n Show \"Powered by CUPCAKE Vanilla\"\r\n </label>\r\n </div>\r\n <div class=\"form-text\">\r\n Display a small footer credit (appears on hover in bottom-right corner)\r\n </div>\r\n </div>\r\n\r\n <!-- Authentication Settings Section -->\r\n <div class=\"mb-4\">\r\n <h6 class=\"text-muted border-bottom pb-2\">\r\n <i class=\"bi bi-shield-lock me-2\"></i>Authentication Settings\r\n </h6>\r\n \r\n <!-- Allow User Registration -->\r\n <div class=\"mb-3\">\r\n <div class=\"form-check\">\r\n <input \r\n class=\"form-check-input\" \r\n type=\"checkbox\" \r\n id=\"allow_user_registration\"\r\n formControlName=\"allowUserRegistration\">\r\n <label class=\"form-check-label\" for=\"allow_user_registration\">\r\n <i class=\"bi bi-person-plus me-1\"></i>\r\n Allow Public User Registration\r\n </label>\r\n </div>\r\n <div class=\"form-text\">\r\n When enabled, users can register new accounts without admin approval. \r\n The registration API endpoint will be accessible to the public.\r\n </div>\r\n </div>\r\n\r\n <!-- Enable ORCID Login -->\r\n <div class=\"mb-3\">\r\n <div class=\"form-check\">\r\n <input \r\n class=\"form-check-input\" \r\n type=\"checkbox\" \r\n id=\"enable_orcid_login\"\r\n formControlName=\"enableOrcidLogin\">\r\n <label class=\"form-check-label\" for=\"enable_orcid_login\">\r\n <i class=\"bi bi-person-badge me-1\"></i>\r\n Enable ORCID OAuth Login\r\n </label>\r\n </div>\r\n <div class=\"form-text\">\r\n Allow users to log in using their ORCID account. \r\n Requires ORCID OAuth configuration in server settings.\r\n </div>\r\n </div>\r\n </div>\r\n\r\n </form>\r\n </div>\r\n </div>\r\n\r\n </div>\r\n\r\n <div class=\"col-lg-4\">\r\n <!-- Preview Panel -->\r\n <div class=\"card mb-4\">\r\n <div class=\"card-header\">\r\n <h5 class=\"mb-0\">\r\n <i class=\"bi bi-eye me-2\"></i>Preview\r\n </h5>\r\n </div>\r\n <div class=\"card-body\">\r\n <div class=\"preview-navbar mb-3\" \r\n [style.background]=\"'linear-gradient(135deg, ' + (configForm.get('primaryColor')?.value || '#1976d2') + ' 0%, ' + getDarkerColor(configForm.get('primaryColor')?.value || '#1976d2') + ' 100%)'\">\r\n <div class=\"preview-nav\">\r\n <span class=\"preview-brand\">\r\n @if (configForm.get('logoUrl')?.value) {\r\n <img [src]=\"configForm.get('logoUrl')?.value\" alt=\"Logo\" style=\"height: 20px; width: auto;\" class=\"me-2\">\r\n } @else {\r\n <i class=\"bi bi-flask me-2\"></i>\r\n }\r\n {{ configForm.get('siteName')?.value || 'Your Site Name' }}\r\n </span>\r\n </div>\r\n </div>\r\n \r\n <div class=\"preview-login-card\">\r\n <h6 class=\"text-center mb-3\">\r\n @if (configForm.get('logoUrl')?.value) {\r\n <img [src]=\"configForm.get('logoUrl')?.value\" alt=\"Logo\" style=\"height: 24px; width: auto;\" class=\"me-2\">\r\n } @else {\r\n <i class=\"bi bi-flask me-2\"></i>\r\n }\r\n {{ configForm.get('siteName')?.value || 'Your Site Name' }}\r\n </h6>\r\n @if (configForm.get('enableOrcidLogin')?.value) {\r\n <div class=\"preview-button mb-2\"\r\n [style.background]=\"'linear-gradient(135deg, ' + (configForm.get('primaryColor')?.value || '#1976d2') + ' 0%, ' + getDarkerColor(configForm.get('primaryColor')?.value || '#1976d2') + ' 100%)'\">\r\n <i class=\"bi bi-person-badge me-2\"></i>\r\n Login with ORCID\r\n </div>\r\n }\r\n <div class=\"preview-button mb-2\"\r\n [style.background]=\"'linear-gradient(135deg, ' + (configForm.get('primaryColor')?.value || '#1976d2') + ' 0%, ' + getDarkerColor(configForm.get('primaryColor')?.value || '#1976d2') + ' 100%)'\">\r\n <i class=\"bi bi-box-arrow-in-right me-2\"></i>\r\n Regular Login\r\n </div>\r\n @if (configForm.get('allowUserRegistration')?.value) {\r\n <div class=\"text-center mb-2\">\r\n <small class=\"text-muted\">\r\n <i class=\"bi bi-person-plus me-1\"></i>\r\n Don't have an account? <a href=\"#\" class=\"text-decoration-none\">Register here</a>\r\n </small>\r\n </div>\r\n }\r\n <div class=\"text-center\">\r\n <small class=\"text-muted\">\r\n {{ configForm.get('siteName')?.value || 'Your Site Name' }} - Scientific Metadata Management\r\n </small>\r\n </div>\r\n </div>\r\n\r\n <div class=\"mt-3\">\r\n <h6 class=\"text-muted\">Authentication Status:</h6>\r\n <ul class=\"list-unstyled small text-muted mb-0\">\r\n <li>\r\n <i class=\"bi bi-check-circle-fill text-success me-1\"></i>\r\n Regular login: Always enabled\r\n </li>\r\n <li>\r\n @if (configForm.get('enableOrcidLogin')?.value) {\r\n <i class=\"bi bi-check-circle-fill text-success me-1\"></i>\r\n ORCID login: Enabled\r\n } @else {\r\n <i class=\"bi bi-x-circle-fill text-danger me-1\"></i>\r\n ORCID login: Disabled\r\n }\r\n </li>\r\n <li>\r\n @if (configForm.get('allowUserRegistration')?.value) {\r\n <i class=\"bi bi-check-circle-fill text-success me-1\"></i>\r\n Public registration: Enabled\r\n } @else {\r\n <i class=\"bi bi-x-circle-fill text-danger me-1\"></i>\r\n Public registration: Disabled\r\n }\r\n </li>\r\n </ul>\r\n </div>\r\n\r\n @if (configForm.get('showPoweredBy')?.value) {\r\n <div class=\"mt-3\">\r\n <small class=\"text-muted\">\r\n <i class=\"bi bi-lightning-charge me-1\"></i>\r\n Footer: \"Powered by CUPCAKE Vanilla\" will appear on hover\r\n </small>\r\n </div>\r\n }\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n</div>", styles: [".color-picker-wrapper ::ng-deep .sketch-picker{background:var(--bs-body-bg)!important;border:1px solid var(--bs-border-color)!important;box-shadow:0 .5rem 1rem #00000026!important}.color-picker-wrapper ::ng-deep .sketch-picker .sketch-picker-controls .sketch-picker-color-wrap .sketch-picker-active{border:2px solid var(--bs-border-color)!important}.color-picker-wrapper ::ng-deep .sketch-picker .sketch-picker-fields{background:var(--bs-body-bg)!important}.color-picker-wrapper ::ng-deep .sketch-picker .sketch-picker-fields .sketch-picker-field input{background:var(--bs-body-bg)!important;border:1px solid var(--bs-border-color)!important;color:var(--bs-body-color)!important}.color-picker-wrapper ::ng-deep .sketch-picker .sketch-picker-fields .sketch-picker-field input:focus{border-color:var(--bs-primary)!important;box-shadow:0 0 0 .25rem rgba(var(--bs-primary-rgb),.25)!important}.color-picker-wrapper ::ng-deep .sketch-picker .sketch-picker-fields .sketch-picker-field label{color:var(--bs-body-color)!important}\n"] }]
2551
+ }], ctorParameters: () => [] });
2552
+
2553
+ class ToastContainerComponent {
2554
+ toastService = inject(ToastService);
2555
+ /**
2556
+ * Signal containing all active toast messages
2557
+ */
2558
+ toasts = this.toastService.toasts;
2559
+ /**
2560
+ * Computed signal that maps toast types to their CSS classes
2561
+ */
2562
+ toastClassMap = computed(() => ({
2563
+ success: 'bg-success text-white',
2564
+ error: 'bg-danger text-white',
2565
+ warning: 'bg-warning text-dark',
2566
+ info: 'bg-primary text-white'
2567
+ }), ...(ngDevMode ? [{ debugName: "toastClassMap" }] : []));
2568
+ /**
2569
+ * Computed signal that maps toast types to their Bootstrap icons
2570
+ */
2571
+ toastIconMap = computed(() => ({
2572
+ success: 'bi-check-circle-fill',
2573
+ error: 'bi-exclamation-triangle-fill',
2574
+ warning: 'bi-exclamation-circle-fill',
2575
+ info: 'bi-info-circle-fill'
2576
+ }), ...(ngDevMode ? [{ debugName: "toastIconMap" }] : []));
2577
+ /**
2578
+ * Removes a toast message from the service
2579
+ */
2580
+ remove(toast) {
2581
+ this.toastService.remove(toast.id);
2582
+ }
2583
+ /**
2584
+ * Gets the CSS class for a toast based on its type
2585
+ */
2586
+ getToastClass(type) {
2587
+ const classMap = this.toastClassMap();
2588
+ return classMap[type] || classMap.info;
2589
+ }
2590
+ /**
2591
+ * Gets the Bootstrap icon class for a toast based on its type
2592
+ */
2593
+ getToastIcon(type) {
2594
+ const iconMap = this.toastIconMap();
2595
+ return iconMap[type] || iconMap.info;
2596
+ }
2597
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: ToastContainerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2598
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.7", type: ToastContainerComponent, isStandalone: true, selector: "app-toast-container", ngImport: i0, template: "<div class=\"toast-container position-fixed top-0 end-0 p-3\" style=\"z-index: 1055;\">\n @for (toast of toasts(); track toast.id) {\n <ngb-toast \n [class]=\"getToastClass(toast.type)\"\n [autohide]=\"true\"\n [delay]=\"toast.duration || 5000\"\n (hidden)=\"remove(toast)\">\n <ng-template ngbToastHeader>\n <i [class]=\"'bi ' + getToastIcon(toast.type) + ' me-2'\" \n [attr.aria-hidden]=\"true\"></i>\n <strong class=\"me-auto\">\n {{ toast.type | titlecase }}\n </strong>\n </ng-template>\n {{ toast.message }}\n </ngb-toast>\n } @empty {\n <!-- No toasts to display -->\n }\n</div>\n", styles: [".toast-container{max-width:400px}.toast-container .toast{margin-bottom:.5rem;border:none;box-shadow:0 .5rem 1rem #00000026}.toast-container .toast .toast-header{border-bottom:1px solid rgba(255,255,255,.1)}.toast-container .toast .toast-header .btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast-container .toast .toast-body{font-size:.875rem}[data-bs-theme=dark] .toast-container .toast.bg-warning{color:var(--bs-dark)!important}[data-bs-theme=dark] .toast-container .toast.bg-warning .toast-header{border-bottom-color:#0000001a}[data-bs-theme=dark] .toast-container .toast.bg-warning .toast-header .btn-close{filter:none}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: NgbModule }, { kind: "component", type: i2$1.NgbToast, selector: "ngb-toast", inputs: ["animation", "delay", "autohide", "header"], outputs: ["shown", "hidden"], exportAs: ["ngbToast"] }, { kind: "directive", type: i2$1.NgbToastHeader, selector: "[ngbToastHeader]" }, { kind: "pipe", type: i2.TitleCasePipe, name: "titlecase" }] });
2599
+ }
2600
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: ToastContainerComponent, decorators: [{
2601
+ type: Component,
2602
+ args: [{ selector: 'app-toast-container', standalone: true, imports: [CommonModule, NgbModule], template: "<div class=\"toast-container position-fixed top-0 end-0 p-3\" style=\"z-index: 1055;\">\n @for (toast of toasts(); track toast.id) {\n <ngb-toast \n [class]=\"getToastClass(toast.type)\"\n [autohide]=\"true\"\n [delay]=\"toast.duration || 5000\"\n (hidden)=\"remove(toast)\">\n <ng-template ngbToastHeader>\n <i [class]=\"'bi ' + getToastIcon(toast.type) + ' me-2'\" \n [attr.aria-hidden]=\"true\"></i>\n <strong class=\"me-auto\">\n {{ toast.type | titlecase }}\n </strong>\n </ng-template>\n {{ toast.message }}\n </ngb-toast>\n } @empty {\n <!-- No toasts to display -->\n }\n</div>\n", styles: [".toast-container{max-width:400px}.toast-container .toast{margin-bottom:.5rem;border:none;box-shadow:0 .5rem 1rem #00000026}.toast-container .toast .toast-header{border-bottom:1px solid rgba(255,255,255,.1)}.toast-container .toast .toast-header .btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast-container .toast .toast-body{font-size:.875rem}[data-bs-theme=dark] .toast-container .toast.bg-warning{color:var(--bs-dark)!important}[data-bs-theme=dark] .toast-container .toast.bg-warning .toast-header{border-bottom-color:#0000001a}[data-bs-theme=dark] .toast-container .toast.bg-warning .toast-header .btn-close{filter:none}\n"] }]
2603
+ }] });
2604
+
2605
+ // Auth components
2606
+
2607
+ class CupcakeCoreModule {
2608
+ static forRoot(config) {
2609
+ return {
2610
+ ngModule: CupcakeCoreModule,
2611
+ providers: [
2612
+ { provide: CUPCAKE_CORE_CONFIG, useValue: config },
2613
+ provideHttpClient(withInterceptors([authInterceptor]))
2614
+ ]
2615
+ };
2616
+ }
2617
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: CupcakeCoreModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
2618
+ static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.1.7", ngImport: i0, type: CupcakeCoreModule, imports: [CommonModule,
2619
+ ReactiveFormsModule,
2620
+ HttpClientModule,
2621
+ RouterModule,
2622
+ NgbModule,
2623
+ // Standalone components
2624
+ LoginComponent,
2625
+ RegisterComponent,
2626
+ ToastContainerComponent], exports: [LoginComponent,
2627
+ RegisterComponent,
2628
+ ToastContainerComponent,
2629
+ // Re-export modules that consumers might need
2630
+ CommonModule,
2631
+ ReactiveFormsModule,
2632
+ NgbModule] });
2633
+ static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: CupcakeCoreModule, imports: [CommonModule,
2634
+ ReactiveFormsModule,
2635
+ HttpClientModule,
2636
+ RouterModule,
2637
+ NgbModule,
2638
+ // Standalone components
2639
+ LoginComponent,
2640
+ RegisterComponent,
2641
+ ToastContainerComponent,
2642
+ // Re-export modules that consumers might need
2643
+ CommonModule,
2644
+ ReactiveFormsModule,
2645
+ NgbModule] });
2646
+ }
2647
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.7", ngImport: i0, type: CupcakeCoreModule, decorators: [{
2648
+ type: NgModule,
2649
+ args: [{
2650
+ declarations: [],
2651
+ imports: [
2652
+ CommonModule,
2653
+ ReactiveFormsModule,
2654
+ HttpClientModule,
2655
+ RouterModule,
2656
+ NgbModule,
2657
+ // Standalone components
2658
+ LoginComponent,
2659
+ RegisterComponent,
2660
+ ToastContainerComponent
2661
+ ],
2662
+ exports: [
2663
+ LoginComponent,
2664
+ RegisterComponent,
2665
+ ToastContainerComponent,
2666
+ // Re-export modules that consumers might need
2667
+ CommonModule,
2668
+ ReactiveFormsModule,
2669
+ NgbModule
2670
+ ]
2671
+ }]
2672
+ }] });
2673
+
2674
+ /*
2675
+ * Public API Surface of cupcake-core
2676
+ */
2677
+ // Models
2678
+
2679
+ /**
2680
+ * Generated bundle index. Do not edit.
2681
+ */
2682
+
2683
+ export { ApiService, AuthService, BaseApiService, CUPCAKE_CORE_CONFIG, CupcakeCoreModule, InvitationStatus, InvitationStatusLabels, LabGroupService, LabGroupsComponent, LoginComponent, RegisterComponent, ResourceRole, ResourceRoleLabels, ResourceService, ResourceType, ResourceTypeLabels, ResourceVisibility, ResourceVisibilityLabels, SiteConfigComponent, SiteConfigService, ToastContainerComponent, ToastService, UserManagementComponent, UserManagementService, UserProfileComponent, adminGuard, authGuard, authInterceptor };
2684
+ //# sourceMappingURL=noatgnu-cupcake-core.mjs.map