@honuware/ui 0.1.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,503 @@
1
+ import * as i0 from '@angular/core';
2
+ import { Inject, Injectable, InjectionToken, inject, Component } from '@angular/core';
3
+ import { BehaviorSubject, of, throwError, firstValueFrom } from 'rxjs';
4
+ import { mergeMap, tap, map, catchError } from 'rxjs/operators';
5
+ import * as i5 from '@honuware/ui/access';
6
+ import { HONUWARE_AUTH_ACCESS, ErrorTypes } from '@honuware/ui/access';
7
+ import * as i3 from '@angular/router';
8
+ import { Router, RouterLink } from '@angular/router';
9
+ import * as i1 from '@angular/forms';
10
+ import { Validators, ReactiveFormsModule } from '@angular/forms';
11
+ import * as i6 from '@angular/material/form-field';
12
+ import { MatFormFieldModule } from '@angular/material/form-field';
13
+ import * as i7 from '@angular/material/input';
14
+ import { MatInputModule } from '@angular/material/input';
15
+ import * as i8 from '@angular/material/button';
16
+ import { MatButtonModule } from '@angular/material/button';
17
+ import { MatIcon } from '@angular/material/icon';
18
+ import * as i9 from '@angular/material/checkbox';
19
+ import { MatCheckboxModule } from '@angular/material/checkbox';
20
+ import * as i4 from '@honuware/ui/foundation';
21
+ import { sanitizeReturnUrl } from '@honuware/ui/foundation';
22
+
23
+ const DEFAULT_NON_AUTH_DATA = {
24
+ isAuth: false,
25
+ };
26
+ function hasPermission(authData, permission) {
27
+ return authData.isAuth && authData.permissions.includes(permission);
28
+ }
29
+ function hasManageProducts(authData) {
30
+ return (authData.isAuth && authData.isAdmin) || hasPermission(authData, 'manage_products');
31
+ }
32
+ function hasStaffAccess(authData) {
33
+ return (authData.isAuth && authData.isAdmin)
34
+ || hasPermission(authData, 'instructor')
35
+ || hasPermission(authData, 'admin_portal')
36
+ || hasPermission(authData, 'provider');
37
+ }
38
+
39
+ class AuthService {
40
+ authAccess;
41
+ _authDataSubject = new BehaviorSubject(DEFAULT_NON_AUTH_DATA);
42
+ get authData$() {
43
+ return this._authDataSubject.asObservable();
44
+ }
45
+ get authData() {
46
+ return this._authDataSubject.value;
47
+ }
48
+ constructor(authAccess) {
49
+ this.authAccess = authAccess;
50
+ }
51
+ updateAuthData(userInfo) {
52
+ const isAdmin = (userInfo.roles || []).includes('admin');
53
+ const updated = {
54
+ isAuth: true,
55
+ personId: userInfo.person_id,
56
+ firstName: userInfo.first_name,
57
+ lastName: userInfo.last_name,
58
+ email: userInfo.email,
59
+ createdAt: userInfo.created_at,
60
+ roles: userInfo.roles,
61
+ permissions: userInfo.permissions,
62
+ isAdmin: isAdmin,
63
+ mustChangePassword: userInfo.must_change_password ?? false,
64
+ };
65
+ this._authDataSubject.next(updated);
66
+ }
67
+ setUserInfo(firstName, lastName, email) {
68
+ const body = {
69
+ first_name: firstName,
70
+ last_name: lastName,
71
+ email: email
72
+ };
73
+ return this.authAccess.setUserInfo(body).pipe(mergeMap(() => this.authAccess.getUserInfo()), tap((ui) => this.updateAuthData(ui)), map(() => void 0));
74
+ }
75
+ doSetUserInfo(firstName, lastName, email) {
76
+ this.setUserInfo(firstName, lastName, email).subscribe();
77
+ }
78
+ updateUserPassword(oldPassword, newPassword) {
79
+ const body = {
80
+ old_password: oldPassword,
81
+ new_password: newPassword,
82
+ };
83
+ return this.authAccess.updateUserPassword(body);
84
+ }
85
+ doUpdateUserPassword(oldPassword, newPassword) {
86
+ this.updateUserPassword(oldPassword, newPassword).subscribe();
87
+ }
88
+ tryTokenLogin() {
89
+ // Path A: session token valid -> get user info
90
+ return this.authAccess.me().pipe(mergeMap(() => this.authAccess.getUserInfo().pipe(tap((ui) => this.updateAuthData(ui)), map(() => true))), catchError((err) => {
91
+ // If 401, attempt device remember token flow
92
+ if (err?.status === 401) {
93
+ return this.authAccess.remember().pipe(mergeMap(() => this.authAccess.me()), mergeMap(() => this.authAccess.getUserInfo().pipe(tap((ui) => this.updateAuthData(ui)), map(() => true))), catchError(() => of(false)));
94
+ }
95
+ return of(false);
96
+ }));
97
+ }
98
+ login(email, password, remember) {
99
+ const body = { email, password, remember };
100
+ return this.authAccess.login(body).pipe(mergeMap(() => this.authAccess.getUserInfo().pipe(tap((ui) => this.updateAuthData(ui)), map(() => void 0))));
101
+ }
102
+ logout() {
103
+ return this.authAccess.logout().pipe(tap(() => this._authDataSubject.next(DEFAULT_NON_AUTH_DATA)));
104
+ }
105
+ register(firstName, lastName, email, password) {
106
+ return this.authAccess.register(firstName, lastName, email, password);
107
+ }
108
+ // Phase 3.3 of the security review: the SPA's /verify route lands
109
+ // here. The verification email's link points at the SPA, which calls
110
+ // this and then immediately scrubs the URL via history.replaceState.
111
+ verify(email, secret) {
112
+ return this.authAccess.verify(email, secret);
113
+ }
114
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: AuthService, deps: [{ token: HONUWARE_AUTH_ACCESS }], target: i0.ɵɵFactoryTarget.Injectable });
115
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: AuthService, providedIn: 'root' });
116
+ }
117
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: AuthService, decorators: [{
118
+ type: Injectable,
119
+ args: [{
120
+ providedIn: 'root',
121
+ }]
122
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
123
+ type: Inject,
124
+ args: [HONUWARE_AUTH_ACCESS]
125
+ }] }] });
126
+
127
+ const DEFAULT_AUTH_ROUTES = {
128
+ loginPath: '/login',
129
+ registerPath: '/register',
130
+ postLogoutPath: '/',
131
+ mustChangePasswordPath: '/my/update-user-password',
132
+ returnUrlAllowlist: ['/my', '/admin', '/manage', '/staff', '/calendar', '/shop'],
133
+ };
134
+ // Root-provided with the knottyyoga defaults, so nothing needs wiring today.
135
+ // When the auth layer moves into @honuware/ui/auth the library token drops the
136
+ // app default and each consumer provides its own AuthRoutes.
137
+ const AUTH_ROUTES = new InjectionToken('AUTH_ROUTES', {
138
+ providedIn: 'root',
139
+ factory: () => DEFAULT_AUTH_ROUTES,
140
+ });
141
+
142
+ const AdminGuard = () => {
143
+ const router = inject(Router);
144
+ const authService = inject(AuthService);
145
+ const routes = inject(AUTH_ROUTES);
146
+ if (!authService.authData.isAuth || !authService.authData.isAdmin) {
147
+ return router.parseUrl(routes.postLogoutPath);
148
+ }
149
+ return true;
150
+ };
151
+ const AuthGuard = (route, state) => {
152
+ const router = inject(Router);
153
+ const authService = inject(AuthService);
154
+ const routes = inject(AUTH_ROUTES);
155
+ const url = state.url;
156
+ // Allow access to login and register without auth
157
+ if (url.startsWith(routes.loginPath) || url.startsWith(routes.registerPath)) {
158
+ return true;
159
+ }
160
+ if (!authService.authData.isAuth) {
161
+ const returnUrl = encodeURIComponent(url);
162
+ return router.parseUrl(`${routes.loginPath}?returnUrl=${returnUrl}`);
163
+ }
164
+ // Force password change if required (allow access to the change password page itself)
165
+ if (authService.authData.mustChangePassword &&
166
+ !url.startsWith(routes.mustChangePasswordPath)) {
167
+ return router.parseUrl(routes.mustChangePasswordPath);
168
+ }
169
+ return true;
170
+ };
171
+ const ManageProductsGuard = () => {
172
+ const router = inject(Router);
173
+ const authService = inject(AuthService);
174
+ const routes = inject(AUTH_ROUTES);
175
+ if (!hasManageProducts(authService.authData)) {
176
+ return router.parseUrl(routes.postLogoutPath);
177
+ }
178
+ return true;
179
+ };
180
+ const StaffGuard = () => {
181
+ const router = inject(Router);
182
+ const authService = inject(AuthService);
183
+ const routes = inject(AUTH_ROUTES);
184
+ if (!hasStaffAccess(authService.authData)) {
185
+ return router.parseUrl(routes.postLogoutPath);
186
+ }
187
+ return true;
188
+ };
189
+ const NoAuthGuard = () => {
190
+ const router = inject(Router);
191
+ const authService = inject(AuthService);
192
+ const routes = inject(AUTH_ROUTES);
193
+ if (authService.authData.isAuth) {
194
+ return router.parseUrl(routes.postLogoutPath);
195
+ }
196
+ return true;
197
+ };
198
+
199
+ class ErrorInterceptor {
200
+ errorService;
201
+ router;
202
+ authRoutes;
203
+ constructor(errorService, router, authRoutes) {
204
+ this.errorService = errorService;
205
+ this.router = router;
206
+ this.authRoutes = authRoutes;
207
+ }
208
+ intercept(request, next) {
209
+ return next.handle(request).pipe(catchError((error) => {
210
+ const problemDetails = this.errorService.parseError(error);
211
+ // Handle session expired globally - redirect to login
212
+ if (problemDetails.type === ErrorTypes.SESSION_EXPIRED ||
213
+ (problemDetails.type === ErrorTypes.NOT_AUTHENTICATED &&
214
+ !this.isAuthEndpoint(request.url))) {
215
+ this.router.navigate([this.authRoutes.loginPath]);
216
+ }
217
+ // Create extended error with parsed problem details
218
+ const parsedError = Object.assign(error, {
219
+ problemDetails,
220
+ });
221
+ return throwError(() => parsedError);
222
+ }));
223
+ }
224
+ isAuthEndpoint(url) {
225
+ const authEndpoints = ['/api/login', '/api/register', '/api/me', '/api/remember'];
226
+ return authEndpoints.some((endpoint) => url.includes(endpoint));
227
+ }
228
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ErrorInterceptor, deps: [{ token: i5.ErrorService }, { token: i3.Router }, { token: AUTH_ROUTES }], target: i0.ɵɵFactoryTarget.Injectable });
229
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ErrorInterceptor });
230
+ }
231
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: ErrorInterceptor, decorators: [{
232
+ type: Injectable
233
+ }], ctorParameters: () => [{ type: i5.ErrorService }, { type: i3.Router }, { type: undefined, decorators: [{
234
+ type: Inject,
235
+ args: [AUTH_ROUTES]
236
+ }] }] });
237
+
238
+ class LoginComponent {
239
+ fb;
240
+ authService;
241
+ router;
242
+ route;
243
+ toastService;
244
+ errorService;
245
+ authRoutes;
246
+ form;
247
+ hidePassword = true;
248
+ returnUrl;
249
+ constructor(fb, authService, router, route, toastService, errorService, authRoutes) {
250
+ this.fb = fb;
251
+ this.authService = authService;
252
+ this.router = router;
253
+ this.route = route;
254
+ this.toastService = toastService;
255
+ this.errorService = errorService;
256
+ this.authRoutes = authRoutes;
257
+ this.form = this.fb.group({
258
+ email: ['', [Validators.required]],
259
+ password: ['', [Validators.required]],
260
+ remember: [false],
261
+ });
262
+ // Phase 10.2: never trust the raw query parameter — see
263
+ // sanitizeReturnUrl above for the threat model.
264
+ this.returnUrl = sanitizeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'), this.authRoutes.returnUrlAllowlist);
265
+ }
266
+ togglePasswordVisibility() {
267
+ this.hidePassword = !this.hidePassword;
268
+ }
269
+ onSignIn() {
270
+ if (this.form.invalid) {
271
+ this.form.markAllAsTouched();
272
+ return;
273
+ }
274
+ const email = this.form.get('email')?.value ?? '';
275
+ const password = this.form.get('password')?.value ?? '';
276
+ const remember = !!this.form.get('remember')?.value;
277
+ this.authService.login(email, password, remember).subscribe({
278
+ next: () => {
279
+ if (this.authService.authData.isAuth && this.authService.authData.mustChangePassword) {
280
+ this.router.navigateByUrl(this.authRoutes.mustChangePasswordPath);
281
+ }
282
+ else {
283
+ this.router.navigateByUrl(this.returnUrl);
284
+ }
285
+ },
286
+ error: (err) => {
287
+ const problem = this.errorService.parseError(err);
288
+ this.toastService.error(this.errorService.getUserFriendlyMessage(problem));
289
+ },
290
+ });
291
+ }
292
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: LoginComponent, deps: [{ token: i1.FormBuilder }, { token: AuthService }, { token: i3.Router }, { token: i3.ActivatedRoute }, { token: i4.ToastService }, { token: i5.ErrorService }, { token: AUTH_ROUTES }], target: i0.ɵɵFactoryTarget.Component });
293
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.18", type: LoginComponent, isStandalone: true, selector: "hw-login", ngImport: i0, template: "<div\n class=\"max-w-[420px] p-6 flex flex-col gap-8 mx-auto my-8 border border-gray-300 rounded-lg\"\n>\n <h1 class=\"text-center text-2xl\">Log In</h1>\n\n <form [formGroup]=\"form\" (submit)=\"onSignIn()\" class=\"flex flex-col gap-4\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Email</mat-label>\n <input id=\"email\" matInput type=\"text\" formControlName=\"email\" />\n </mat-form-field>\n\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input id=\"password\"\n matInput\n [type]=\"hidePassword ? 'password' : 'text'\"\n formControlName=\"password\" />\n <button type=\"button\"\n matSuffix\n (click)=\"togglePasswordVisibility()\"\n class=\"mr-2\">\n <mat-icon>\n {{\n hidePassword ? \"visibility_off\" : \"visibility\"\n }}\n </mat-icon>\n </button>\n </mat-form-field>\n\n <mat-checkbox id=\"remember\" formControlName=\"remember\">Remember Me</mat-checkbox>\n\n <button mat-raised-button id=\"signIn\" color=\"primary\" type=\"submit\">Sign in</button>\n </form>\n\n <a\n id=\"create-account-link\"\n [routerLink]=\"'/register'\"\n color=\"primary\"\n class=\"text-center\"\n >\n Create an account\n </a>\n</div>\n", styles: [""], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.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.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i6.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i6.MatLabel, selector: "mat-label" }, { kind: "directive", type: i6.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i7.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i8.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i9.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }] });
294
+ }
295
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: LoginComponent, decorators: [{
296
+ type: Component,
297
+ args: [{ selector: 'hw-login', standalone: true, imports: [
298
+ ReactiveFormsModule,
299
+ MatFormFieldModule,
300
+ MatInputModule,
301
+ MatButtonModule,
302
+ MatCheckboxModule,
303
+ MatIcon,
304
+ RouterLink
305
+ ], template: "<div\n class=\"max-w-[420px] p-6 flex flex-col gap-8 mx-auto my-8 border border-gray-300 rounded-lg\"\n>\n <h1 class=\"text-center text-2xl\">Log In</h1>\n\n <form [formGroup]=\"form\" (submit)=\"onSignIn()\" class=\"flex flex-col gap-4\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Email</mat-label>\n <input id=\"email\" matInput type=\"text\" formControlName=\"email\" />\n </mat-form-field>\n\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input id=\"password\"\n matInput\n [type]=\"hidePassword ? 'password' : 'text'\"\n formControlName=\"password\" />\n <button type=\"button\"\n matSuffix\n (click)=\"togglePasswordVisibility()\"\n class=\"mr-2\">\n <mat-icon>\n {{\n hidePassword ? \"visibility_off\" : \"visibility\"\n }}\n </mat-icon>\n </button>\n </mat-form-field>\n\n <mat-checkbox id=\"remember\" formControlName=\"remember\">Remember Me</mat-checkbox>\n\n <button mat-raised-button id=\"signIn\" color=\"primary\" type=\"submit\">Sign in</button>\n </form>\n\n <a\n id=\"create-account-link\"\n [routerLink]=\"'/register'\"\n color=\"primary\"\n class=\"text-center\"\n >\n Create an account\n </a>\n</div>\n" }]
306
+ }], ctorParameters: () => [{ type: i1.FormBuilder }, { type: AuthService }, { type: i3.Router }, { type: i3.ActivatedRoute }, { type: i4.ToastService }, { type: i5.ErrorService }, { type: undefined, decorators: [{
307
+ type: Inject,
308
+ args: [AUTH_ROUTES]
309
+ }] }] });
310
+
311
+ class RegisterComponent {
312
+ fb;
313
+ authService;
314
+ router;
315
+ toastService;
316
+ errorService;
317
+ authRoutes;
318
+ form;
319
+ hidePassword = true;
320
+ constructor(fb, authService, router, toastService, errorService, authRoutes) {
321
+ this.fb = fb;
322
+ this.authService = authService;
323
+ this.router = router;
324
+ this.toastService = toastService;
325
+ this.errorService = errorService;
326
+ this.authRoutes = authRoutes;
327
+ this.form = this.fb.group({
328
+ email: ['', [Validators.required, Validators.email]],
329
+ first_name: ['', [Validators.required]],
330
+ last_name: ['', [Validators.required]],
331
+ password: ['', [Validators.required]],
332
+ password2: ['', [Validators.required]],
333
+ }, { validators: this.passwordsMatchValidator });
334
+ }
335
+ togglePasswordVisibility() {
336
+ this.hidePassword = !this.hidePassword;
337
+ }
338
+ // Custom validator that flags mismatch only when both fields are dirty and values differ
339
+ passwordsMatchValidator(group) {
340
+ const password = group.get('password');
341
+ const password2 = group.get('password2');
342
+ if (!password || !password2)
343
+ return null;
344
+ const bothDirty = password.dirty && password2.dirty;
345
+ const mismatch = password.value !== password2.value;
346
+ if (bothDirty && mismatch) {
347
+ password2.setErrors({ mismatch: true });
348
+ return { mismatch: true };
349
+ }
350
+ else {
351
+ // Clear mismatch error when aligned or not both dirty
352
+ if (password2.hasError('mismatch')) {
353
+ const otherErrors = { ...password2.errors };
354
+ delete otherErrors['mismatch'];
355
+ password2.setErrors(Object.keys(otherErrors).length ? otherErrors : null);
356
+ }
357
+ return null;
358
+ }
359
+ }
360
+ onRegister() {
361
+ if (this.form.invalid) {
362
+ this.form.markAllAsTouched();
363
+ return;
364
+ }
365
+ const email = this.form.get('email')?.value ?? '';
366
+ const firstName = this.form.get('first_name')?.value ?? '';
367
+ const lastName = this.form.get('last_name')?.value ?? '';
368
+ const password = this.form.get('password')?.value ?? '';
369
+ this.authService.register(firstName, lastName, email, password).subscribe({
370
+ next: () => this.router.navigate([this.authRoutes.loginPath]),
371
+ error: (err) => {
372
+ const problem = this.errorService.parseError(err);
373
+ this.toastService.error(this.errorService.getUserFriendlyMessage(problem));
374
+ }
375
+ });
376
+ }
377
+ showPasswordMismatch() {
378
+ const password = this.form.get('password');
379
+ const password2 = this.form.get('password2');
380
+ if (!password || !password2)
381
+ return false;
382
+ return !!password.dirty && !!password2.dirty && password.value !== password2.value;
383
+ }
384
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: RegisterComponent, deps: [{ token: i1.FormBuilder }, { token: AuthService }, { token: i3.Router }, { token: i4.ToastService }, { token: i5.ErrorService }, { token: AUTH_ROUTES }], target: i0.ɵɵFactoryTarget.Component });
385
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.18", type: RegisterComponent, isStandalone: true, selector: "hw-register", ngImport: i0, template: "<div\n class=\"max-w-[420px] p-6 flex flex-col gap-8 mx-auto my-8 border border-gray-300 rounded-lg\"\n >\n <h1 class=\"text-center text-2xl\">Create Account</h1>\n\n <form [formGroup]=\"form\" (ngSubmit)=\"onRegister()\" class=\"flex flex-col gap-4\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Email</mat-label>\n <input id=\"email\" matInput type=\"email\" formControlName=\"email\" />\n </mat-form-field>\n\n <mat-form-field appearance=\"outline\">\n <mat-label>First name</mat-label>\n <input id=\"first_name\" matInput type=\"text\" formControlName=\"first_name\" />\n </mat-form-field>\n\n <mat-form-field appearance=\"outline\">\n <mat-label>Last name</mat-label>\n <input id=\"last_name\" matInput type=\"text\" formControlName=\"last_name\" />\n </mat-form-field>\n\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input id=\"password\" matInput [type]=\"hidePassword ? 'password' : 'text'\" formControlName=\"password\" />\n <button type=\"button\"\n matSuffix\n (click)=\"togglePasswordVisibility()\"\n class=\"mr-2\">\n <mat-icon>\n {{\n hidePassword ? \"visibility_off\" : \"visibility\"\n }}\n </mat-icon>\n </button>\n </mat-form-field>\n\n <mat-form-field appearance=\"outline\">\n <mat-label>Reenter password</mat-label>\n <input id=\"password2\" matInput [type]=\"hidePassword ? 'password' : 'text'\" formControlName=\"password2\" />\n <button type=\"button\"\n matSuffix\n (click)=\"togglePasswordVisibility()\"\n class=\"mr-2\">\n <mat-icon>\n {{\n hidePassword ? \"visibility_off\" : \"visibility\"\n }}\n </mat-icon>\n </button>\n @if (showPasswordMismatch()) {\n <mat-error>\n Passwords do not match.\n </mat-error>\n }\n </mat-form-field>\n\n <button id=\"Register\" mat-raised-button color=\"primary\" type=\"submit\">\n Register\n </button>\n </form>\n</div>\n", styles: [".register-container{max-width:480px;margin:2rem auto;padding:1.5rem;display:flex;flex-direction:column;gap:1rem}form{display:flex;flex-direction:column;gap:1rem}.full-width{width:100%}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.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.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i6.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i6.MatLabel, selector: "mat-label" }, { kind: "directive", type: i6.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "directive", type: i6.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i7.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i8.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }] });
386
+ }
387
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: RegisterComponent, decorators: [{
388
+ type: Component,
389
+ args: [{ selector: 'hw-register', standalone: true, imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIcon], template: "<div\n class=\"max-w-[420px] p-6 flex flex-col gap-8 mx-auto my-8 border border-gray-300 rounded-lg\"\n >\n <h1 class=\"text-center text-2xl\">Create Account</h1>\n\n <form [formGroup]=\"form\" (ngSubmit)=\"onRegister()\" class=\"flex flex-col gap-4\">\n <mat-form-field appearance=\"outline\">\n <mat-label>Email</mat-label>\n <input id=\"email\" matInput type=\"email\" formControlName=\"email\" />\n </mat-form-field>\n\n <mat-form-field appearance=\"outline\">\n <mat-label>First name</mat-label>\n <input id=\"first_name\" matInput type=\"text\" formControlName=\"first_name\" />\n </mat-form-field>\n\n <mat-form-field appearance=\"outline\">\n <mat-label>Last name</mat-label>\n <input id=\"last_name\" matInput type=\"text\" formControlName=\"last_name\" />\n </mat-form-field>\n\n <mat-form-field appearance=\"outline\">\n <mat-label>Password</mat-label>\n <input id=\"password\" matInput [type]=\"hidePassword ? 'password' : 'text'\" formControlName=\"password\" />\n <button type=\"button\"\n matSuffix\n (click)=\"togglePasswordVisibility()\"\n class=\"mr-2\">\n <mat-icon>\n {{\n hidePassword ? \"visibility_off\" : \"visibility\"\n }}\n </mat-icon>\n </button>\n </mat-form-field>\n\n <mat-form-field appearance=\"outline\">\n <mat-label>Reenter password</mat-label>\n <input id=\"password2\" matInput [type]=\"hidePassword ? 'password' : 'text'\" formControlName=\"password2\" />\n <button type=\"button\"\n matSuffix\n (click)=\"togglePasswordVisibility()\"\n class=\"mr-2\">\n <mat-icon>\n {{\n hidePassword ? \"visibility_off\" : \"visibility\"\n }}\n </mat-icon>\n </button>\n @if (showPasswordMismatch()) {\n <mat-error>\n Passwords do not match.\n </mat-error>\n }\n </mat-form-field>\n\n <button id=\"Register\" mat-raised-button color=\"primary\" type=\"submit\">\n Register\n </button>\n </form>\n</div>\n", styles: [".register-container{max-width:480px;margin:2rem auto;padding:1.5rem;display:flex;flex-direction:column;gap:1rem}form{display:flex;flex-direction:column;gap:1rem}.full-width{width:100%}\n"] }]
390
+ }], ctorParameters: () => [{ type: i1.FormBuilder }, { type: AuthService }, { type: i3.Router }, { type: i4.ToastService }, { type: i5.ErrorService }, { type: undefined, decorators: [{
391
+ type: Inject,
392
+ args: [AUTH_ROUTES]
393
+ }] }] });
394
+
395
+ /**
396
+ * Phase 3.3 of the security review.
397
+ *
398
+ * The verification email links at this SPA route — `/verify?email=…&secret=…` —
399
+ * rather than at the API. The component:
400
+ *
401
+ * 1. Reads `email` and `secret` from the query string.
402
+ * 2. Immediately calls `AuthService.verify(email, secret)` (POST /api/verify).
403
+ * 3. Right after firing the request, scrubs the URL via
404
+ * `history.replaceState({}, '', '/verify-success')` so the secret
405
+ * stops appearing in the URL bar / browser history. We do this
406
+ * synchronously, NOT inside the response callback, so the URL is
407
+ * sanitized whether the request succeeds, fails, or is delayed.
408
+ * 4. On success or failure, routes to `/login` with a toast. The two
409
+ * paths use different toasts but the toast text never echoes the
410
+ * server's error detail — failed verifications all collapse to one
411
+ * generic message to avoid distinguishing "wrong secret" from
412
+ * "expired" from "already verified".
413
+ */
414
+ class VerifyComponent {
415
+ route;
416
+ router;
417
+ authService;
418
+ toastService;
419
+ authRoutes;
420
+ constructor(route, router, authService, toastService, authRoutes) {
421
+ this.route = route;
422
+ this.router = router;
423
+ this.authService = authService;
424
+ this.toastService = toastService;
425
+ this.authRoutes = authRoutes;
426
+ }
427
+ ngOnInit() {
428
+ const email = this.route.snapshot.queryParamMap.get('email') ?? '';
429
+ const secret = this.route.snapshot.queryParamMap.get('secret') ?? '';
430
+ if (!email || !secret) {
431
+ this.scrubUrl();
432
+ this.toastService.error('Verification link is missing required parameters.');
433
+ this.router.navigate([this.authRoutes.loginPath]);
434
+ return;
435
+ }
436
+ // Fire the verification request first, THEN scrub the URL. The
437
+ // secret is in memory either way; the goal of the scrub is to keep
438
+ // it out of the browser's URL bar and history. Doing it before
439
+ // we're sure about the response means even a slow / failed request
440
+ // doesn't leave the secret visible.
441
+ const verify$ = this.authService.verify(email, secret);
442
+ this.scrubUrl();
443
+ verify$.subscribe({
444
+ next: () => {
445
+ this.toastService.success('Email verified — please log in.');
446
+ this.router.navigate([this.authRoutes.loginPath]);
447
+ },
448
+ error: () => {
449
+ // Phase 3.3: don't echo the server error detail. All failure
450
+ // modes (wrong secret, expired, already-used) collapse to a
451
+ // single generic message.
452
+ this.toastService.error('Verification failed or expired. Please try again.');
453
+ this.router.navigate([this.authRoutes.loginPath]);
454
+ },
455
+ });
456
+ }
457
+ scrubUrl() {
458
+ // We can't replace the path via Router.navigate without triggering
459
+ // a route change here — `history.replaceState` mutates only the URL
460
+ // bar / history entry. The `/verify-success` path doesn't need to
461
+ // be a real route; we navigate to /login on the next tick anyway.
462
+ window.history.replaceState({}, '', '/verify-success');
463
+ }
464
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: VerifyComponent, deps: [{ token: i3.ActivatedRoute }, { token: i3.Router }, { token: AuthService }, { token: i4.ToastService }, { token: AUTH_ROUTES }], target: i0.ɵɵFactoryTarget.Component });
465
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.18", type: VerifyComponent, isStandalone: true, selector: "hw-verify", ngImport: i0, template: "<div class=\"verify-container\">\n <p>Verifying your email...</p>\n</div>\n", styles: [".verify-container{display:flex;align-items:center;justify-content:center;min-height:50vh;padding:2rem;text-align:center}\n"] });
466
+ }
467
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.18", ngImport: i0, type: VerifyComponent, decorators: [{
468
+ type: Component,
469
+ args: [{ selector: 'hw-verify', standalone: true, imports: [], template: "<div class=\"verify-container\">\n <p>Verifying your email...</p>\n</div>\n", styles: [".verify-container{display:flex;align-items:center;justify-content:center;min-height:50vh;padding:2rem;text-align:center}\n"] }]
470
+ }], ctorParameters: () => [{ type: i3.ActivatedRoute }, { type: i3.Router }, { type: AuthService }, { type: i4.ToastService }, { type: undefined, decorators: [{
471
+ type: Inject,
472
+ args: [AUTH_ROUTES]
473
+ }] }] });
474
+
475
+ // Phase 10.1 of the security review: try silent re-auth at bootstrap so
476
+ // the SPA hydrates `authData$` BEFORE the first route activates. Without
477
+ // this an authenticated user lands on a public-looking shell, sees the
478
+ // guarded route bounce them to /login, and only then gets logged in by
479
+ // the device-token flow.
480
+ //
481
+ // Returns a thunk (factory) so Angular invokes it AFTER DI sets up the
482
+ // AuthService graph. We resolve regardless of outcome — a network blip
483
+ // or invalid cookie should not block app bootstrap. A consumer wires this
484
+ // into an APP_INITIALIZER with `deps: [AuthService]`.
485
+ function tryTokenLoginInitializer(authService) {
486
+ return () => firstValueFrom(authService.tryTokenLogin())
487
+ .then(() => void 0)
488
+ .catch(() => void 0);
489
+ }
490
+
491
+ /*
492
+ * @honuware/ui/auth — the auth layer: AuthService + AuthData/permission
493
+ * helpers, the five route guards, the ErrorInterceptor, the login/register/
494
+ * verify pages (selectors `hw-`), the AUTH_ROUTES config token, and the
495
+ * tryTokenLogin bootstrap initializer. Depends on foundation + access.
496
+ */
497
+
498
+ /**
499
+ * Generated bundle index. Do not edit.
500
+ */
501
+
502
+ export { AUTH_ROUTES, AdminGuard, AuthGuard, AuthService, DEFAULT_AUTH_ROUTES, DEFAULT_NON_AUTH_DATA, ErrorInterceptor, LoginComponent, ManageProductsGuard, NoAuthGuard, RegisterComponent, StaffGuard, VerifyComponent, hasManageProducts, hasPermission, hasStaffAccess, tryTokenLoginInitializer };
503
+ //# sourceMappingURL=honuware-ui-auth.mjs.map