hazo_auth 4.2.0 → 4.3.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.
Files changed (82) hide show
  1. package/bin/hazo_auth.mjs +35 -0
  2. package/cli-src/assets/images/forgot_password_default.jpg +0 -0
  3. package/cli-src/assets/images/login_default.jpg +0 -0
  4. package/cli-src/assets/images/register_default.jpg +0 -0
  5. package/cli-src/assets/images/reset_password_default.jpg +0 -0
  6. package/cli-src/assets/images/verify_email_default.jpg +0 -0
  7. package/cli-src/cli/generate.ts +276 -0
  8. package/cli-src/cli/index.ts +207 -0
  9. package/cli-src/cli/init.ts +254 -0
  10. package/cli-src/cli/init_users.ts +376 -0
  11. package/cli-src/cli/validate.ts +581 -0
  12. package/cli-src/lib/already_logged_in_config.server.ts +46 -0
  13. package/cli-src/lib/app_logger.ts +24 -0
  14. package/cli-src/lib/auth/auth_cache.ts +220 -0
  15. package/cli-src/lib/auth/auth_rate_limiter.ts +121 -0
  16. package/cli-src/lib/auth/auth_types.ts +110 -0
  17. package/cli-src/lib/auth/auth_utils.server.ts +196 -0
  18. package/cli-src/lib/auth/hazo_get_auth.server.ts +512 -0
  19. package/cli-src/lib/auth/index.ts +23 -0
  20. package/cli-src/lib/auth/nextauth_config.ts +227 -0
  21. package/cli-src/lib/auth/scope_cache.ts +233 -0
  22. package/cli-src/lib/auth/server_auth.ts +88 -0
  23. package/cli-src/lib/auth/session_token_validator.edge.ts +91 -0
  24. package/cli-src/lib/auth_utility_config.server.ts +136 -0
  25. package/cli-src/lib/config/config_loader.server.ts +164 -0
  26. package/cli-src/lib/config/default_config.ts +199 -0
  27. package/cli-src/lib/email_verification_config.server.ts +63 -0
  28. package/cli-src/lib/file_types_config.server.ts +25 -0
  29. package/cli-src/lib/forgot_password_config.server.ts +63 -0
  30. package/cli-src/lib/hazo_connect_instance.server.ts +101 -0
  31. package/cli-src/lib/hazo_connect_setup.server.ts +194 -0
  32. package/cli-src/lib/hazo_connect_setup.ts +54 -0
  33. package/cli-src/lib/index.ts +46 -0
  34. package/cli-src/lib/login_config.server.ts +106 -0
  35. package/cli-src/lib/messages_config.server.ts +45 -0
  36. package/cli-src/lib/migrations/apply_migration.ts +105 -0
  37. package/cli-src/lib/my_settings_config.server.ts +135 -0
  38. package/cli-src/lib/oauth_config.server.ts +87 -0
  39. package/cli-src/lib/password_requirements_config.server.ts +40 -0
  40. package/cli-src/lib/profile_pic_menu_config.server.ts +138 -0
  41. package/cli-src/lib/profile_picture_config.server.ts +56 -0
  42. package/cli-src/lib/register_config.server.ts +101 -0
  43. package/cli-src/lib/reset_password_config.server.ts +103 -0
  44. package/cli-src/lib/scope_hierarchy_config.server.ts +151 -0
  45. package/cli-src/lib/services/email_service.ts +587 -0
  46. package/cli-src/lib/services/email_verification_service.ts +270 -0
  47. package/cli-src/lib/services/index.ts +16 -0
  48. package/cli-src/lib/services/login_service.ts +150 -0
  49. package/cli-src/lib/services/oauth_service.ts +494 -0
  50. package/cli-src/lib/services/password_change_service.ts +154 -0
  51. package/cli-src/lib/services/password_reset_service.ts +418 -0
  52. package/cli-src/lib/services/profile_picture_remove_service.ts +120 -0
  53. package/cli-src/lib/services/profile_picture_service.ts +451 -0
  54. package/cli-src/lib/services/profile_picture_source_mapper.ts +62 -0
  55. package/cli-src/lib/services/registration_service.ts +185 -0
  56. package/cli-src/lib/services/scope_labels_service.ts +348 -0
  57. package/cli-src/lib/services/scope_service.ts +778 -0
  58. package/cli-src/lib/services/session_token_service.ts +177 -0
  59. package/cli-src/lib/services/token_service.ts +240 -0
  60. package/cli-src/lib/services/user_profiles_cache.ts +189 -0
  61. package/cli-src/lib/services/user_profiles_service.ts +264 -0
  62. package/cli-src/lib/services/user_scope_service.ts +554 -0
  63. package/cli-src/lib/services/user_update_service.ts +141 -0
  64. package/cli-src/lib/ui_shell_config.server.ts +73 -0
  65. package/cli-src/lib/ui_sizes_config.server.ts +37 -0
  66. package/cli-src/lib/user_fields_config.server.ts +31 -0
  67. package/cli-src/lib/user_management_config.server.ts +39 -0
  68. package/cli-src/lib/user_profiles_config.server.ts +55 -0
  69. package/cli-src/lib/utils/api_route_helpers.ts +60 -0
  70. package/cli-src/lib/utils/error_sanitizer.ts +75 -0
  71. package/cli-src/lib/utils/password_validator.ts +65 -0
  72. package/cli-src/lib/utils.ts +11 -0
  73. package/cli-src/server/logging/logger_service.ts +56 -0
  74. package/dist/cli/index.js +18 -0
  75. package/dist/cli/init_users.d.ts +17 -0
  76. package/dist/cli/init_users.d.ts.map +1 -0
  77. package/dist/cli/init_users.js +307 -0
  78. package/dist/components/layouts/shared/config/layout_customization.d.ts +2 -7
  79. package/dist/components/layouts/shared/config/layout_customization.d.ts.map +1 -1
  80. package/dist/lib/utils/password_validator.d.ts +7 -1
  81. package/dist/lib/utils/password_validator.d.ts.map +1 -1
  82. package/package.json +5 -2
@@ -0,0 +1,451 @@
1
+ // file_description: service for profile picture management including default photo logic, Gravatar, and library photos
2
+ // section: imports
3
+ import type { HazoConnectAdapter } from "hazo_connect";
4
+ import { createCrudService } from "hazo_connect/server";
5
+ import gravatarUrl from "gravatar-url";
6
+ import { get_profile_picture_config } from "../profile_picture_config.server";
7
+ import { get_ui_sizes_config } from "../ui_sizes_config.server";
8
+ import { get_file_types_config } from "../file_types_config.server";
9
+ import { create_app_logger } from "../app_logger";
10
+ import path from "path";
11
+ import fs from "fs";
12
+ import { map_ui_source_to_db, type ProfilePictureSourceUI } from "./profile_picture_source_mapper";
13
+
14
+ // section: types
15
+ export type ProfilePictureSource = ProfilePictureSourceUI;
16
+
17
+ export type DefaultProfilePictureResult = {
18
+ profile_picture_url: string;
19
+ profile_source: ProfilePictureSource;
20
+ };
21
+
22
+ export type LibraryPhotosResult = {
23
+ photos: string[];
24
+ total: number;
25
+ page: number;
26
+ page_size: number;
27
+ has_more: boolean;
28
+ source: "project" | "node_modules";
29
+ };
30
+
31
+ // section: cache
32
+ // Cache the resolved library path to avoid repeated filesystem checks
33
+ let cached_library_path: string | null = null;
34
+ let cached_library_source: "project" | "node_modules" | null = null;
35
+
36
+ // section: helpers
37
+ /**
38
+ * Resolves the library path, checking project's public folder first, then node_modules
39
+ * @returns Object with path and source, or null if not found
40
+ */
41
+ function resolve_library_path(): { path: string; source: "project" | "node_modules" } | null {
42
+ // Return cached value if available
43
+ if (cached_library_path && cached_library_source) {
44
+ if (fs.existsSync(cached_library_path)) {
45
+ return { path: cached_library_path, source: cached_library_source };
46
+ }
47
+ // Cache is stale, clear it
48
+ cached_library_path = null;
49
+ cached_library_source = null;
50
+ }
51
+
52
+ const config = get_profile_picture_config();
53
+ const library_subpath = config.library_photo_path.replace(/^\//, "");
54
+
55
+ // Try 1: Project's public folder
56
+ const project_library_path = path.resolve(process.cwd(), "public", library_subpath);
57
+ if (fs.existsSync(project_library_path)) {
58
+ // Check if it has any content (not just empty directory)
59
+ try {
60
+ const entries = fs.readdirSync(project_library_path);
61
+ if (entries.length > 0) {
62
+ cached_library_path = project_library_path;
63
+ cached_library_source = "project";
64
+ return { path: project_library_path, source: "project" };
65
+ }
66
+ } catch {
67
+ // Continue to fallback
68
+ }
69
+ }
70
+
71
+ // Try 2: node_modules/hazo_auth/public folder
72
+ const node_modules_library_path = path.resolve(
73
+ process.cwd(),
74
+ "node_modules",
75
+ "hazo_auth",
76
+ "public",
77
+ library_subpath
78
+ );
79
+ if (fs.existsSync(node_modules_library_path)) {
80
+ cached_library_path = node_modules_library_path;
81
+ cached_library_source = "node_modules";
82
+ return { path: node_modules_library_path, source: "node_modules" };
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Generates Gravatar URL from email address
90
+ * @param email - User's email address
91
+ * @param size - Image size in pixels (defaults to config value)
92
+ * @returns Gravatar URL
93
+ */
94
+ export function get_gravatar_url(email: string, size?: number): string {
95
+ const uiSizes = get_ui_sizes_config();
96
+ const gravatarSize = size || uiSizes.gravatar_size;
97
+ return gravatarUrl(email, {
98
+ size: gravatarSize,
99
+ default: "identicon",
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Gets library photo categories by reading subdirectories
105
+ * @returns Array of category names
106
+ */
107
+ export function get_library_categories(): string[] {
108
+ const resolved = resolve_library_path();
109
+
110
+ if (!resolved) {
111
+ return [];
112
+ }
113
+
114
+ try {
115
+ const entries = fs.readdirSync(resolved.path, { withFileTypes: true });
116
+ return entries
117
+ .filter((entry) => entry.isDirectory())
118
+ .map((entry) => entry.name)
119
+ .sort();
120
+ } catch (error) {
121
+ const logger = create_app_logger();
122
+ const error_message = error instanceof Error ? error.message : "Unknown error";
123
+ logger.warn("profile_picture_service_read_categories_failed", {
124
+ filename: "profile_picture_service.ts",
125
+ line_number: 0,
126
+ library_path: resolved.path,
127
+ source: resolved.source,
128
+ error: error_message,
129
+ });
130
+ return [];
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Gets photos in a specific library category with pagination support
136
+ * @param category - Category name
137
+ * @param page - Page number (1-indexed, default 1)
138
+ * @param page_size - Number of photos per page (default 20, max 100)
139
+ * @returns Object with photos array and pagination info
140
+ */
141
+ export function get_library_photos_paginated(
142
+ category: string,
143
+ page: number = 1,
144
+ page_size: number = 20
145
+ ): LibraryPhotosResult {
146
+ const resolved = resolve_library_path();
147
+ const config = get_profile_picture_config();
148
+
149
+ // Ensure page_size is within bounds
150
+ const effective_page_size = Math.min(Math.max(1, page_size), 100);
151
+ const effective_page = Math.max(1, page);
152
+
153
+ if (!resolved) {
154
+ return {
155
+ photos: [],
156
+ total: 0,
157
+ page: effective_page,
158
+ page_size: effective_page_size,
159
+ has_more: false,
160
+ source: "project",
161
+ };
162
+ }
163
+
164
+ const category_path = path.join(resolved.path, category);
165
+
166
+ if (!fs.existsSync(category_path)) {
167
+ return {
168
+ photos: [],
169
+ total: 0,
170
+ page: effective_page,
171
+ page_size: effective_page_size,
172
+ has_more: false,
173
+ source: resolved.source,
174
+ };
175
+ }
176
+
177
+ try {
178
+ const fileTypes = get_file_types_config();
179
+ const allowedExtensions = fileTypes.allowed_image_extensions.map(ext =>
180
+ ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`
181
+ );
182
+
183
+ const entries = fs.readdirSync(category_path, { withFileTypes: true });
184
+ const all_photos = entries
185
+ .filter((entry) => {
186
+ if (!entry.isFile()) return false;
187
+ const ext = path.extname(entry.name).toLowerCase();
188
+ return allowedExtensions.includes(ext);
189
+ })
190
+ .map((entry) => entry.name)
191
+ .sort();
192
+
193
+ const total = all_photos.length;
194
+ const start_index = (effective_page - 1) * effective_page_size;
195
+ const end_index = start_index + effective_page_size;
196
+ const page_photos = all_photos.slice(start_index, end_index);
197
+
198
+ // Generate URLs based on source
199
+ // For node_modules source, we need to serve via API route
200
+ const photo_urls = page_photos.map((filename) => {
201
+ if (resolved.source === "node_modules") {
202
+ // Serve via API route that reads from node_modules
203
+ return `/api/hazo_auth/library_photo/${category}/${filename}`;
204
+ } else {
205
+ // Serve directly from public folder
206
+ return `${config.library_photo_path}/${category}/${filename}`;
207
+ }
208
+ });
209
+
210
+ return {
211
+ photos: photo_urls,
212
+ total,
213
+ page: effective_page,
214
+ page_size: effective_page_size,
215
+ has_more: end_index < total,
216
+ source: resolved.source,
217
+ };
218
+ } catch (error) {
219
+ const logger = create_app_logger();
220
+ const error_message = error instanceof Error ? error.message : "Unknown error";
221
+ logger.warn("profile_picture_service_read_photos_failed", {
222
+ filename: "profile_picture_service.ts",
223
+ line_number: 0,
224
+ category,
225
+ category_path,
226
+ source: resolved.source,
227
+ error: error_message,
228
+ });
229
+ return {
230
+ photos: [],
231
+ total: 0,
232
+ page: effective_page,
233
+ page_size: effective_page_size,
234
+ has_more: false,
235
+ source: resolved.source,
236
+ };
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Gets photos in a specific library category (legacy non-paginated version)
242
+ * @param category - Category name
243
+ * @returns Array of photo URLs (relative to public directory or API route)
244
+ */
245
+ export function get_library_photos(category: string): string[] {
246
+ // Use paginated version with large page size for backwards compatibility
247
+ const result = get_library_photos_paginated(category, 1, 1000);
248
+ return result.photos;
249
+ }
250
+
251
+ /**
252
+ * Gets the physical file path for a library photo (used for serving from node_modules)
253
+ * @param category - Category name
254
+ * @param filename - Photo filename
255
+ * @returns Full file path or null if not found
256
+ */
257
+ export function get_library_photo_path(category: string, filename: string): string | null {
258
+ const resolved = resolve_library_path();
259
+
260
+ if (!resolved) {
261
+ return null;
262
+ }
263
+
264
+ const photo_path = path.join(resolved.path, category, filename);
265
+
266
+ if (fs.existsSync(photo_path)) {
267
+ return photo_path;
268
+ }
269
+
270
+ return null;
271
+ }
272
+
273
+ /**
274
+ * Gets the source of library photos (for diagnostic purposes)
275
+ * @returns Source type or null if no library found
276
+ */
277
+ export function get_library_source(): "project" | "node_modules" | null {
278
+ const resolved = resolve_library_path();
279
+ return resolved?.source ?? null;
280
+ }
281
+
282
+ /**
283
+ * Clears the library path cache (useful for testing or after copying files)
284
+ */
285
+ export function clear_library_cache(): void {
286
+ cached_library_path = null;
287
+ cached_library_source = null;
288
+ }
289
+
290
+ /**
291
+ * Gets default profile picture based on configuration priority
292
+ * @param user_email - User's email address
293
+ * @param user_name - User's name (optional)
294
+ * @returns Default profile picture URL and source, or null if no default available
295
+ */
296
+ /**
297
+ * Checks if a Gravatar exists for the given email by making a HEAD request
298
+ * @param email - User email address
299
+ * @returns Promise<boolean> - true if Gravatar exists (status 200), false otherwise (404 or error)
300
+ */
301
+ async function check_gravatar_exists(email: string): Promise<boolean> {
302
+ try {
303
+ const uiSizes = get_ui_sizes_config();
304
+ const gravatar_url = get_gravatar_url(email, uiSizes.gravatar_size);
305
+
306
+ // Make HEAD request to check if image exists without downloading it
307
+ const response = await fetch(gravatar_url, {
308
+ method: 'HEAD',
309
+ // Add timeout to prevent hanging
310
+ signal: AbortSignal.timeout(5000) // 5 second timeout
311
+ });
312
+
313
+ // Gravatar returns 200 if user has an image, 404 if using default/mystery-person
314
+ return response.ok;
315
+ } catch (error) {
316
+ // If fetch fails (network error, timeout, etc.), assume no Gravatar
317
+ return false;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Gets a random image from a random category in the library
323
+ * @returns string | null - Random library photo URL or null if no photos available
324
+ */
325
+ function get_random_library_photo(): string | null {
326
+ const categories = get_library_categories();
327
+
328
+ if (categories.length === 0) {
329
+ return null;
330
+ }
331
+
332
+ // Pick a random category
333
+ const random_category = categories[Math.floor(Math.random() * categories.length)];
334
+
335
+ // Get photos from that category
336
+ const photos = get_library_photos(random_category);
337
+
338
+ if (photos.length === 0) {
339
+ return null;
340
+ }
341
+
342
+ // Pick a random photo from that category
343
+ const random_photo = photos[Math.floor(Math.random() * photos.length)];
344
+
345
+ return random_photo;
346
+ }
347
+
348
+ export async function get_default_profile_picture(
349
+ user_email: string,
350
+ user_name?: string,
351
+ ): Promise<DefaultProfilePictureResult | null> {
352
+ const config = get_profile_picture_config();
353
+
354
+ if (!config.user_photo_default) {
355
+ return null;
356
+ }
357
+
358
+ const uiSizes = get_ui_sizes_config();
359
+
360
+ // Try priority 1
361
+ if (config.user_photo_default_priority1 === "gravatar") {
362
+ // Check if Gravatar actually exists for this email
363
+ const gravatar_exists = await check_gravatar_exists(user_email);
364
+
365
+ if (gravatar_exists) {
366
+ const gravatar_url = get_gravatar_url(user_email, uiSizes.gravatar_size);
367
+ return {
368
+ profile_picture_url: gravatar_url,
369
+ profile_source: "gravatar",
370
+ };
371
+ }
372
+ // If Gravatar doesn't exist, fall through to priority 2
373
+ } else if (config.user_photo_default_priority1 === "library") {
374
+ // Use random library photo instead of first photo
375
+ const random_photo = get_random_library_photo();
376
+ if (random_photo) {
377
+ return {
378
+ profile_picture_url: random_photo,
379
+ profile_source: "library",
380
+ };
381
+ }
382
+ }
383
+
384
+ // Try priority 2 if priority 1 didn't work (only if priority2 is different from priority1)
385
+ const priority1 = config.user_photo_default_priority1;
386
+ const priority2 = config.user_photo_default_priority2;
387
+
388
+ if (priority2 && priority2 !== priority1) {
389
+ if (priority2 === "gravatar") {
390
+ // Check if Gravatar actually exists for this email
391
+ const gravatar_exists = await check_gravatar_exists(user_email);
392
+
393
+ if (gravatar_exists) {
394
+ const gravatar_url = get_gravatar_url(user_email, uiSizes.gravatar_size);
395
+ return {
396
+ profile_picture_url: gravatar_url,
397
+ profile_source: "gravatar",
398
+ };
399
+ }
400
+ } else if (priority2 === "library") {
401
+ // Use random library photo instead of first photo
402
+ const random_photo = get_random_library_photo();
403
+ if (random_photo) {
404
+ return {
405
+ profile_picture_url: random_photo,
406
+ profile_source: "library",
407
+ };
408
+ }
409
+ }
410
+ }
411
+
412
+ // No default photo available
413
+ return null;
414
+ }
415
+
416
+ /**
417
+ * Updates user profile picture in database
418
+ * @param adapter - The hazo_connect adapter instance
419
+ * @param user_id - User ID
420
+ * @param profile_picture_url - Profile picture URL
421
+ * @param profile_source - Profile picture source type
422
+ * @returns Success status
423
+ */
424
+ export async function update_user_profile_picture(
425
+ adapter: HazoConnectAdapter,
426
+ user_id: string,
427
+ profile_picture_url: string,
428
+ profile_source: ProfilePictureSource,
429
+ ): Promise<{ success: boolean; error?: string }> {
430
+ try {
431
+ const users_service = createCrudService(adapter, "hazo_users");
432
+ const now = new Date().toISOString();
433
+
434
+ // Map UI source value to database enum value
435
+ const db_profile_source = map_ui_source_to_db(profile_source);
436
+
437
+ await users_service.updateById(user_id, {
438
+ profile_picture_url,
439
+ profile_source: db_profile_source,
440
+ changed_at: now,
441
+ });
442
+
443
+ return { success: true };
444
+ } catch (error) {
445
+ const error_message = error instanceof Error ? error.message : "Unknown error";
446
+ return {
447
+ success: false,
448
+ error: error_message,
449
+ };
450
+ }
451
+ }
@@ -0,0 +1,62 @@
1
+ // file_description: helper to map between UI profile picture source values and database enum values
2
+ // section: types
3
+ /**
4
+ * UI representation of profile picture source
5
+ * Used in components and API interfaces
6
+ */
7
+ export type ProfilePictureSourceUI = "upload" | "library" | "gravatar" | "custom";
8
+
9
+ /**
10
+ * Database enum values for profile_source
11
+ * Must match the CHECK constraint in the database
12
+ */
13
+ export type ProfilePictureSourceDB = "gravatar" | "custom" | "predefined";
14
+
15
+ // section: helpers
16
+ /**
17
+ * Maps UI profile picture source to database enum value
18
+ * @param uiSource - UI representation of source ("upload", "library", "gravatar", "custom")
19
+ * @returns Database enum value ("gravatar", "custom", "predefined")
20
+ */
21
+ export function map_ui_source_to_db(uiSource: ProfilePictureSourceUI): ProfilePictureSourceDB {
22
+ switch (uiSource) {
23
+ case "upload":
24
+ return "custom"; // User uploaded their own photo
25
+ case "library":
26
+ return "predefined"; // User selected from predefined library
27
+ case "gravatar":
28
+ return "gravatar"; // User's Gravatar
29
+ case "custom":
30
+ return "custom"; // Already in database format
31
+ default:
32
+ // Fallback to custom for unknown values
33
+ return "custom";
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Maps database enum value to UI representation
39
+ * @param dbSource - Database enum value ("gravatar", "custom", "predefined")
40
+ * @returns UI representation ("upload", "library", "gravatar", "custom")
41
+ */
42
+ export function map_db_source_to_ui(dbSource: ProfilePictureSourceDB | string | null | undefined): ProfilePictureSourceUI {
43
+ if (!dbSource) {
44
+ return "custom"; // Default fallback
45
+ }
46
+
47
+ switch (dbSource) {
48
+ case "gravatar":
49
+ return "gravatar";
50
+ case "custom":
51
+ return "upload"; // Map custom to upload in UI (user uploaded their own)
52
+ case "predefined":
53
+ return "library"; // Map predefined to library in UI (user selected from library)
54
+ default:
55
+ // For unknown values, try to return as-is if it matches UI format
56
+ if (dbSource === "upload" || dbSource === "library") {
57
+ return dbSource as ProfilePictureSourceUI;
58
+ }
59
+ return "custom"; // Fallback
60
+ }
61
+ }
62
+
@@ -0,0 +1,185 @@
1
+ // file_description: service for user registration operations using hazo_connect
2
+ // section: imports
3
+ import type { HazoConnectAdapter } from "hazo_connect";
4
+ import { createCrudService } from "hazo_connect/server";
5
+ import argon2 from "argon2";
6
+ import { randomUUID } from "crypto";
7
+ import { create_token } from "./token_service";
8
+ import { get_default_profile_picture } from "./profile_picture_service";
9
+ import { get_profile_picture_config } from "../profile_picture_config.server";
10
+ import { map_ui_source_to_db } from "./profile_picture_source_mapper";
11
+ import { create_app_logger } from "../app_logger";
12
+ import { send_template_email } from "./email_service";
13
+ import { sanitize_error_for_user } from "../utils/error_sanitizer";
14
+ import { get_filename, get_line_number } from "../utils/api_route_helpers";
15
+
16
+ // section: types
17
+ export type RegistrationData = {
18
+ email: string;
19
+ password: string;
20
+ name?: string;
21
+ url_on_logon?: string;
22
+ };
23
+
24
+ export type RegistrationResult = {
25
+ success: boolean;
26
+ user_id?: string;
27
+ error?: string;
28
+ };
29
+
30
+ // section: helpers
31
+ /**
32
+ * Registers a new user in the database using hazo_connect
33
+ * @param adapter - The hazo_connect adapter instance
34
+ * @param data - Registration data (email, password, optional name)
35
+ * @returns Registration result with success status and user_id or error
36
+ */
37
+ export async function register_user(
38
+ adapter: HazoConnectAdapter,
39
+ data: RegistrationData,
40
+ ): Promise<RegistrationResult> {
41
+ try {
42
+ const { email, password, name, url_on_logon } = data;
43
+
44
+ // Create CRUD service for hazo_users table
45
+ const users_service = createCrudService(adapter, "hazo_users");
46
+
47
+ // Check if user already exists
48
+ const existing_users = await users_service.findBy({
49
+ email_address: email,
50
+ });
51
+
52
+ if (Array.isArray(existing_users) && existing_users.length > 0) {
53
+ return {
54
+ success: false,
55
+ error: "Email address already registered",
56
+ };
57
+ }
58
+
59
+ // Hash password using argon2
60
+ const password_hash = await argon2.hash(password);
61
+
62
+ // Generate user ID
63
+ const user_id = randomUUID();
64
+ const now = new Date().toISOString();
65
+
66
+ // Insert user into database using CRUD service
67
+ const insert_data: Record<string, unknown> = {
68
+ id: user_id,
69
+ email_address: email,
70
+ password_hash: password_hash,
71
+ email_verified: false,
72
+ is_active: true,
73
+ login_attempts: 0,
74
+ auth_providers: "email", // Track that this user registered with email/password
75
+ created_at: now,
76
+ changed_at: now,
77
+ };
78
+
79
+ // Include name if provided
80
+ if (name) {
81
+ insert_data.name = name;
82
+ }
83
+
84
+ // Validate and include url_on_logon if provided
85
+ if (url_on_logon) {
86
+ // Ensure it's a relative path starting with / but not //
87
+ if (url_on_logon.startsWith("/") && !url_on_logon.startsWith("//")) {
88
+ insert_data.url_on_logon = url_on_logon;
89
+ }
90
+ }
91
+
92
+ // Set default profile picture if enabled
93
+ const profile_picture_config = get_profile_picture_config();
94
+ if (profile_picture_config.user_photo_default) {
95
+ const default_photo = await get_default_profile_picture(email, name);
96
+ if (default_photo) {
97
+ insert_data.profile_picture_url = default_photo.profile_picture_url;
98
+ // Map UI source value to database enum value
99
+ insert_data.profile_source = map_ui_source_to_db(default_photo.profile_source);
100
+ }
101
+ }
102
+
103
+ const inserted_users = await users_service.insert(insert_data);
104
+
105
+ // Verify insertion was successful
106
+ if (!Array.isArray(inserted_users) || inserted_users.length === 0) {
107
+ return {
108
+ success: false,
109
+ error: "Failed to create user account",
110
+ };
111
+ }
112
+
113
+ // Create email verification token for the new user
114
+ const token_result = await create_token({
115
+ adapter,
116
+ user_id,
117
+ token_type: "email_verification",
118
+ });
119
+
120
+ if (!token_result.success) {
121
+ // Log error but don't fail registration - token can be resent later
122
+ const logger = create_app_logger();
123
+ const error_message = token_result.error || "Unknown error";
124
+ logger.error("registration_service_token_creation_failed", {
125
+ filename: "registration_service.ts",
126
+ line_number: 0,
127
+ user_id,
128
+ error: error_message,
129
+ note: "This may be due to missing token_type column in hazo_refresh_tokens table. Please ensure migration 001_add_token_type_to_refresh_tokens.sql has been applied.",
130
+ });
131
+ } else {
132
+ const logger = create_app_logger();
133
+ logger.info("registration_service_token_created", {
134
+ filename: "registration_service.ts",
135
+ line_number: 0,
136
+ user_id,
137
+ });
138
+ }
139
+
140
+ // Send verification email if token was created successfully
141
+ if (token_result.success && token_result.raw_token) {
142
+ const email_result = await send_template_email("email_verification", email, {
143
+ token: token_result.raw_token,
144
+ user_email: email,
145
+ user_name: name,
146
+ });
147
+
148
+ if (!email_result.success) {
149
+ const logger = create_app_logger();
150
+ logger.error("registration_service_email_send_failed", {
151
+ filename: "registration_service.ts",
152
+ line_number: 0,
153
+ user_id,
154
+ email,
155
+ error: email_result.error,
156
+ note: "User registration succeeded but verification email failed to send",
157
+ });
158
+ }
159
+ }
160
+
161
+ return {
162
+ success: true,
163
+ user_id,
164
+ };
165
+ } catch (error) {
166
+ const logger = create_app_logger();
167
+ const user_friendly_error = sanitize_error_for_user(error, {
168
+ logToConsole: true,
169
+ logToLogger: true,
170
+ logger,
171
+ context: {
172
+ filename: "registration_service.ts",
173
+ line_number: get_line_number(),
174
+ email: data.email,
175
+ operation: "register_user",
176
+ },
177
+ });
178
+
179
+ return {
180
+ success: false,
181
+ error: user_friendly_error,
182
+ };
183
+ }
184
+ }
185
+