@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.
- package/README.md +78 -0
- package/fesm2022/honuware-ui-access.mjs +557 -0
- package/fesm2022/honuware-ui-access.mjs.map +1 -0
- package/fesm2022/honuware-ui-auth.mjs +503 -0
- package/fesm2022/honuware-ui-auth.mjs.map +1 -0
- package/fesm2022/honuware-ui-controls.mjs +624 -0
- package/fesm2022/honuware-ui-controls.mjs.map +1 -0
- package/fesm2022/honuware-ui-crud.mjs +1101 -0
- package/fesm2022/honuware-ui-crud.mjs.map +1 -0
- package/fesm2022/honuware-ui-foundation.mjs +745 -0
- package/fesm2022/honuware-ui-foundation.mjs.map +1 -0
- package/fesm2022/honuware-ui-photos.mjs +238 -0
- package/fesm2022/honuware-ui-photos.mjs.map +1 -0
- package/fesm2022/honuware-ui-square.mjs +128 -0
- package/fesm2022/honuware-ui-square.mjs.map +1 -0
- package/fesm2022/honuware-ui-testing.mjs +325 -0
- package/fesm2022/honuware-ui-testing.mjs.map +1 -0
- package/fesm2022/honuware-ui.mjs +17 -0
- package/fesm2022/honuware-ui.mjs.map +1 -0
- package/package.json +80 -0
- package/types/honuware-ui-access.d.ts +382 -0
- package/types/honuware-ui-auth.d.ts +164 -0
- package/types/honuware-ui-controls.d.ts +136 -0
- package/types/honuware-ui-crud.d.ts +287 -0
- package/types/honuware-ui-foundation.d.ts +89 -0
- package/types/honuware-ui-photos.d.ts +46 -0
- package/types/honuware-ui-square.d.ts +65 -0
- package/types/honuware-ui-testing.d.ts +126 -0
- package/types/honuware-ui.d.ts +3 -0
|
@@ -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
|