hazo_auth 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.
Files changed (162) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/components.json +22 -0
  4. package/hazo_auth_config.example.ini +414 -0
  5. package/hazo_notify_config.example.ini +159 -0
  6. package/instrumentation.ts +32 -0
  7. package/migrations/001_add_token_type_to_refresh_tokens.sql +14 -0
  8. package/migrations/002_add_name_to_hazo_users.sql +7 -0
  9. package/next.config.mjs +55 -0
  10. package/package.json +114 -0
  11. package/postcss.config.mjs +8 -0
  12. package/public/file.svg +1 -0
  13. package/public/globe.svg +1 -0
  14. package/public/next.svg +1 -0
  15. package/public/vercel.svg +1 -0
  16. package/public/window.svg +1 -0
  17. package/scripts/apply_migration.ts +118 -0
  18. package/src/app/api/auth/change_password/route.ts +109 -0
  19. package/src/app/api/auth/forgot_password/route.ts +107 -0
  20. package/src/app/api/auth/library_photos/route.ts +70 -0
  21. package/src/app/api/auth/login/route.ts +155 -0
  22. package/src/app/api/auth/logout/route.ts +62 -0
  23. package/src/app/api/auth/me/route.ts +47 -0
  24. package/src/app/api/auth/profile_picture/[filename]/route.ts +67 -0
  25. package/src/app/api/auth/register/route.ts +106 -0
  26. package/src/app/api/auth/remove_profile_picture/route.ts +86 -0
  27. package/src/app/api/auth/resend_verification/route.ts +107 -0
  28. package/src/app/api/auth/reset_password/route.ts +107 -0
  29. package/src/app/api/auth/update_user/route.ts +126 -0
  30. package/src/app/api/auth/upload_profile_picture/route.ts +268 -0
  31. package/src/app/api/auth/validate_reset_token/route.ts +80 -0
  32. package/src/app/api/auth/verify_email/route.ts +85 -0
  33. package/src/app/api/migrations/apply/route.ts +91 -0
  34. package/src/app/favicon.ico +0 -0
  35. package/src/app/fonts/GeistMonoVF.woff +0 -0
  36. package/src/app/fonts/GeistVF.woff +0 -0
  37. package/src/app/forgot_password/forgot_password_page_client.tsx +60 -0
  38. package/src/app/forgot_password/page.tsx +24 -0
  39. package/src/app/globals.css +89 -0
  40. package/src/app/hazo_connect/api/sqlite/data/route.ts +197 -0
  41. package/src/app/hazo_connect/api/sqlite/schema/route.ts +35 -0
  42. package/src/app/hazo_connect/api/sqlite/tables/route.ts +26 -0
  43. package/src/app/hazo_connect/sqlite_admin/page.tsx +51 -0
  44. package/src/app/hazo_connect/sqlite_admin/sqlite-admin-client.tsx +947 -0
  45. package/src/app/layout.tsx +43 -0
  46. package/src/app/login/login_page_client.tsx +71 -0
  47. package/src/app/login/page.tsx +26 -0
  48. package/src/app/my_settings/my_settings_page_client.tsx +120 -0
  49. package/src/app/my_settings/page.tsx +40 -0
  50. package/src/app/page.tsx +170 -0
  51. package/src/app/register/page.tsx +26 -0
  52. package/src/app/register/register_page_client.tsx +72 -0
  53. package/src/app/reset_password/page.tsx +29 -0
  54. package/src/app/reset_password/reset_password_page_client.tsx +81 -0
  55. package/src/app/verify_email/page.tsx +24 -0
  56. package/src/app/verify_email/verify_email_page_client.tsx +60 -0
  57. package/src/components/layouts/email_verification/config/email_verification_field_config.ts +86 -0
  58. package/src/components/layouts/email_verification/hooks/use_email_verification.ts +291 -0
  59. package/src/components/layouts/email_verification/index.tsx +297 -0
  60. package/src/components/layouts/forgot_password/config/forgot_password_field_config.ts +58 -0
  61. package/src/components/layouts/forgot_password/hooks/use_forgot_password_form.ts +179 -0
  62. package/src/components/layouts/forgot_password/index.tsx +168 -0
  63. package/src/components/layouts/login/config/login_field_config.ts +67 -0
  64. package/src/components/layouts/login/hooks/use_login_form.ts +281 -0
  65. package/src/components/layouts/login/index.tsx +224 -0
  66. package/src/components/layouts/my_settings/components/editable_field.tsx +177 -0
  67. package/src/components/layouts/my_settings/components/password_change_dialog.tsx +301 -0
  68. package/src/components/layouts/my_settings/components/profile_picture_dialog.tsx +385 -0
  69. package/src/components/layouts/my_settings/components/profile_picture_display.tsx +66 -0
  70. package/src/components/layouts/my_settings/components/profile_picture_gravatar_tab.tsx +143 -0
  71. package/src/components/layouts/my_settings/components/profile_picture_library_tab.tsx +282 -0
  72. package/src/components/layouts/my_settings/components/profile_picture_upload_tab.tsx +341 -0
  73. package/src/components/layouts/my_settings/config/my_settings_field_config.ts +61 -0
  74. package/src/components/layouts/my_settings/hooks/use_my_settings.ts +458 -0
  75. package/src/components/layouts/my_settings/index.tsx +351 -0
  76. package/src/components/layouts/register/config/register_field_config.ts +101 -0
  77. package/src/components/layouts/register/hooks/use_register_form.ts +272 -0
  78. package/src/components/layouts/register/index.tsx +208 -0
  79. package/src/components/layouts/reset_password/config/reset_password_field_config.ts +86 -0
  80. package/src/components/layouts/reset_password/hooks/use_reset_password_form.ts +276 -0
  81. package/src/components/layouts/reset_password/index.tsx +294 -0
  82. package/src/components/layouts/shared/components/already_logged_in_guard.tsx +95 -0
  83. package/src/components/layouts/shared/components/field_error_message.tsx +29 -0
  84. package/src/components/layouts/shared/components/form_action_buttons.tsx +64 -0
  85. package/src/components/layouts/shared/components/form_field_wrapper.tsx +44 -0
  86. package/src/components/layouts/shared/components/form_header.tsx +36 -0
  87. package/src/components/layouts/shared/components/logout_button.tsx +76 -0
  88. package/src/components/layouts/shared/components/password_field.tsx +72 -0
  89. package/src/components/layouts/shared/components/sidebar_layout_wrapper.tsx +264 -0
  90. package/src/components/layouts/shared/components/two_column_auth_layout.tsx +44 -0
  91. package/src/components/layouts/shared/components/unauthorized_guard.tsx +78 -0
  92. package/src/components/layouts/shared/components/visual_panel.tsx +41 -0
  93. package/src/components/layouts/shared/config/layout_customization.ts +95 -0
  94. package/src/components/layouts/shared/data/layout_data_client.ts +19 -0
  95. package/src/components/layouts/shared/hooks/use_auth_status.ts +103 -0
  96. package/src/components/layouts/shared/utils/ip_address.ts +37 -0
  97. package/src/components/layouts/shared/utils/validation.ts +66 -0
  98. package/src/components/ui/avatar.tsx +50 -0
  99. package/src/components/ui/button.tsx +57 -0
  100. package/src/components/ui/dialog.tsx +122 -0
  101. package/src/components/ui/hazo_ui_tooltip.tsx +67 -0
  102. package/src/components/ui/input.tsx +22 -0
  103. package/src/components/ui/label.tsx +26 -0
  104. package/src/components/ui/separator.tsx +31 -0
  105. package/src/components/ui/sheet.tsx +139 -0
  106. package/src/components/ui/sidebar.tsx +773 -0
  107. package/src/components/ui/skeleton.tsx +15 -0
  108. package/src/components/ui/sonner.tsx +31 -0
  109. package/src/components/ui/switch.tsx +29 -0
  110. package/src/components/ui/tabs.tsx +55 -0
  111. package/src/components/ui/tooltip.tsx +32 -0
  112. package/src/components/ui/vertical-tabs.tsx +59 -0
  113. package/src/hooks/use-mobile.tsx +19 -0
  114. package/src/lib/already_logged_in_config.server.ts +46 -0
  115. package/src/lib/app_logger.ts +24 -0
  116. package/src/lib/auth/auth_utils.server.ts +196 -0
  117. package/src/lib/auth/server_auth.ts +88 -0
  118. package/src/lib/config/config_loader.server.ts +149 -0
  119. package/src/lib/email_verification_config.server.ts +32 -0
  120. package/src/lib/file_types_config.server.ts +25 -0
  121. package/src/lib/forgot_password_config.server.ts +32 -0
  122. package/src/lib/hazo_connect_instance.server.ts +77 -0
  123. package/src/lib/hazo_connect_setup.server.ts +181 -0
  124. package/src/lib/hazo_connect_setup.ts +54 -0
  125. package/src/lib/login_config.server.ts +46 -0
  126. package/src/lib/messages_config.server.ts +45 -0
  127. package/src/lib/migrations/apply_migration.ts +105 -0
  128. package/src/lib/my_settings_config.server.ts +135 -0
  129. package/src/lib/password_requirements_config.server.ts +39 -0
  130. package/src/lib/profile_picture_config.server.ts +56 -0
  131. package/src/lib/register_config.server.ts +57 -0
  132. package/src/lib/reset_password_config.server.ts +75 -0
  133. package/src/lib/services/email_service.ts +581 -0
  134. package/src/lib/services/email_verification_service.ts +264 -0
  135. package/src/lib/services/login_service.ts +118 -0
  136. package/src/lib/services/password_change_service.ts +154 -0
  137. package/src/lib/services/password_reset_service.ts +405 -0
  138. package/src/lib/services/profile_picture_remove_service.ts +120 -0
  139. package/src/lib/services/profile_picture_service.ts +215 -0
  140. package/src/lib/services/profile_picture_source_mapper.ts +62 -0
  141. package/src/lib/services/registration_service.ts +163 -0
  142. package/src/lib/services/token_service.ts +240 -0
  143. package/src/lib/services/user_update_service.ts +128 -0
  144. package/src/lib/ui_sizes_config.server.ts +37 -0
  145. package/src/lib/user_fields_config.server.ts +31 -0
  146. package/src/lib/utils/api_route_helpers.ts +60 -0
  147. package/src/lib/utils.ts +11 -0
  148. package/src/middleware.ts +91 -0
  149. package/src/server/config/config_loader.ts +496 -0
  150. package/src/server/index.ts +38 -0
  151. package/src/server/logging/logger_service.ts +56 -0
  152. package/src/server/routes/root_router.ts +16 -0
  153. package/src/server/server.ts +28 -0
  154. package/src/server/types/app_types.ts +74 -0
  155. package/src/server/types/express.d.ts +15 -0
  156. package/src/stories/email_verification_layout.stories.tsx +137 -0
  157. package/src/stories/forgot_password_layout.stories.tsx +85 -0
  158. package/src/stories/login_layout.stories.tsx +85 -0
  159. package/src/stories/project_overview.stories.tsx +33 -0
  160. package/src/stories/register_layout.stories.tsx +107 -0
  161. package/tailwind.config.ts +77 -0
  162. package/tsconfig.json +27 -0
@@ -0,0 +1,282 @@
1
+ // file_description: Library tab component for profile picture dialog with category tabs and image grid
2
+ // section: client_directive
3
+ "use client";
4
+
5
+ // section: imports
6
+ import { useState, useEffect } from "react";
7
+ import { Switch } from "@/components/ui/switch";
8
+ import { Label } from "@/components/ui/label";
9
+ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
10
+ import { VerticalTabs, VerticalTabsList, VerticalTabsTrigger, VerticalTabsContent } from "@/components/ui/vertical-tabs";
11
+ import { Loader2 } from "lucide-react";
12
+ import { HazoUITooltip } from "@/components/ui/hazo_ui_tooltip";
13
+
14
+ // section: types
15
+ export type ProfilePictureLibraryTabProps = {
16
+ useLibrary: boolean;
17
+ onUseLibraryChange: (use: boolean) => void;
18
+ onPhotoSelect: (photoUrl: string) => void;
19
+ disabled?: boolean;
20
+ libraryPhotoPath: string;
21
+ currentPhotoUrl?: string;
22
+ libraryTooltipMessage: string;
23
+ tooltipIconSizeSmall: number;
24
+ libraryPhotoGridColumns: number;
25
+ libraryPhotoPreviewSize: number;
26
+ };
27
+
28
+ // section: component
29
+ /**
30
+ * Library tab component for profile picture dialog
31
+ * Two columns: left = vertical category tabs, right = image grid + preview
32
+ * Lazy loads thumbnails when category is selected
33
+ * @param props - Component props including library state, photo selection handler, and configuration
34
+ * @returns Library tab component
35
+ */
36
+ export function ProfilePictureLibraryTab({
37
+ useLibrary,
38
+ onUseLibraryChange,
39
+ onPhotoSelect,
40
+ disabled = false,
41
+ libraryPhotoPath,
42
+ currentPhotoUrl,
43
+ libraryTooltipMessage,
44
+ tooltipIconSizeSmall,
45
+ libraryPhotoGridColumns,
46
+ libraryPhotoPreviewSize,
47
+ }: ProfilePictureLibraryTabProps) {
48
+ const [categories, setCategories] = useState<string[]>([]);
49
+ const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
50
+ const [photos, setPhotos] = useState<string[]>([]);
51
+ const [selectedPhoto, setSelectedPhoto] = useState<string | null>(currentPhotoUrl || null);
52
+ const [loadingCategories, setLoadingCategories] = useState(false);
53
+ const [loadingPhotos, setLoadingPhotos] = useState(false);
54
+
55
+ // Load categories on mount
56
+ useEffect(() => {
57
+ const loadCategories = async () => {
58
+ setLoadingCategories(true);
59
+ try {
60
+ const response = await fetch("/api/auth/library_photos");
61
+ const data = await response.json();
62
+ if (data.success && data.categories) {
63
+ setCategories(data.categories);
64
+ // Select first category if available
65
+ if (data.categories.length > 0) {
66
+ setSelectedCategory(data.categories[0]);
67
+ }
68
+ }
69
+ } catch (error) {
70
+ // Client-side error handling - show toast notification
71
+ import("sonner").then(({ toast }) => {
72
+ toast.error("Failed to load photo categories. Please try again.");
73
+ });
74
+ } finally {
75
+ setLoadingCategories(false);
76
+ }
77
+ };
78
+
79
+ void loadCategories();
80
+ }, []);
81
+
82
+ // Sync selectedPhoto with currentPhotoUrl when it changes
83
+ useEffect(() => {
84
+ if (currentPhotoUrl && currentPhotoUrl !== selectedPhoto) {
85
+ setSelectedPhoto(currentPhotoUrl);
86
+ }
87
+ }, [currentPhotoUrl]);
88
+
89
+ // Load photos when category is selected
90
+ useEffect(() => {
91
+ if (!selectedCategory) {
92
+ setPhotos([]);
93
+ if (!currentPhotoUrl) {
94
+ setSelectedPhoto(null);
95
+ }
96
+ return;
97
+ }
98
+
99
+ const loadPhotos = async () => {
100
+ setLoadingPhotos(true);
101
+ try {
102
+ const response = await fetch(`/api/auth/library_photos?category=${encodeURIComponent(selectedCategory)}`);
103
+ const data = await response.json();
104
+ if (data.success && data.photos) {
105
+ setPhotos(data.photos);
106
+
107
+ // If we have a current photo URL and it's in this category, select it
108
+ if (currentPhotoUrl && data.photos.includes(currentPhotoUrl)) {
109
+ setSelectedPhoto(currentPhotoUrl);
110
+ onPhotoSelect(currentPhotoUrl);
111
+ } else if (data.photos.length > 0) {
112
+ // Otherwise, select first photo if available and notify parent
113
+ const firstPhoto = data.photos[0];
114
+ setSelectedPhoto(firstPhoto);
115
+ onPhotoSelect(firstPhoto);
116
+ } else if (!currentPhotoUrl) {
117
+ // Clear selection if no photos and no current photo
118
+ setSelectedPhoto(null);
119
+ }
120
+ }
121
+ } catch (error) {
122
+ // Client-side error handling - show toast notification
123
+ import("sonner").then(({ toast }) => {
124
+ toast.error("Failed to load photos. Please try again.");
125
+ });
126
+ } finally {
127
+ setLoadingPhotos(false);
128
+ }
129
+ };
130
+
131
+ void loadPhotos();
132
+ }, [selectedCategory, onPhotoSelect, currentPhotoUrl]);
133
+
134
+ const handlePhotoClick = (photoUrl: string) => {
135
+ setSelectedPhoto(photoUrl);
136
+ onPhotoSelect(photoUrl);
137
+ };
138
+
139
+ const getInitials = (): string => {
140
+ return "L";
141
+ };
142
+
143
+ return (
144
+ <div className="cls_profile_picture_library_tab flex flex-col gap-4">
145
+ {/* Switch */}
146
+ <div className="cls_profile_picture_library_tab_switch flex items-center gap-3">
147
+ <Switch
148
+ id="use-library"
149
+ checked={useLibrary}
150
+ onCheckedChange={onUseLibraryChange}
151
+ disabled={disabled}
152
+ className="cls_profile_picture_library_tab_switch_input"
153
+ aria-label="Use library photo"
154
+ />
155
+ <Label
156
+ htmlFor="use-library"
157
+ className="cls_profile_picture_library_tab_switch_label text-sm font-medium text-slate-700 cursor-pointer"
158
+ >
159
+ Use library photo
160
+ <HazoUITooltip
161
+ message={libraryTooltipMessage}
162
+ iconSize={tooltipIconSizeSmall}
163
+ side="top"
164
+ />
165
+ </Label>
166
+ </div>
167
+
168
+ {/* Three columns: category tabs (25%), photo grid (50%), preview (25%) */}
169
+ <div className="cls_profile_picture_library_tab_content grid grid-cols-12 gap-4">
170
+ {/* Left column: Category tabs (25% - 3 columns) */}
171
+ <div className="cls_profile_picture_library_tab_categories_container flex flex-col gap-2 col-span-3">
172
+ <Label className="cls_profile_picture_library_tab_categories_label text-sm font-medium text-slate-700">
173
+ Categories
174
+ </Label>
175
+ {loadingCategories ? (
176
+ <div className="cls_profile_picture_library_tab_loading flex items-center justify-center p-8 border border-slate-200 rounded-lg bg-slate-50 min-h-[400px]">
177
+ <Loader2 className="h-6 w-6 text-slate-400 animate-spin" aria-hidden="true" />
178
+ </div>
179
+ ) : categories.length > 0 ? (
180
+ <VerticalTabs
181
+ value={selectedCategory || categories[0]}
182
+ onValueChange={setSelectedCategory}
183
+ className="cls_profile_picture_library_tab_vertical_tabs"
184
+ >
185
+ <VerticalTabsList className="cls_profile_picture_library_tab_vertical_tabs_list w-full">
186
+ {categories.map((category) => (
187
+ <VerticalTabsTrigger
188
+ key={category}
189
+ value={category}
190
+ className="cls_profile_picture_library_tab_vertical_tabs_trigger w-full justify-start"
191
+ >
192
+ {category}
193
+ </VerticalTabsTrigger>
194
+ ))}
195
+ </VerticalTabsList>
196
+ </VerticalTabs>
197
+ ) : (
198
+ <div className="cls_profile_picture_library_tab_no_categories flex items-center justify-center p-8 border border-slate-200 rounded-lg bg-slate-50 min-h-[400px]">
199
+ <p className="cls_profile_picture_library_tab_no_categories_text text-sm text-slate-600">
200
+ No categories available
201
+ </p>
202
+ </div>
203
+ )}
204
+ </div>
205
+
206
+ {/* Middle column: Photo grid (50% - 6 columns) */}
207
+ <div className="cls_profile_picture_library_tab_photos_container flex flex-col gap-2 col-span-6">
208
+ <Label className="cls_profile_picture_library_tab_photos_label text-sm font-medium text-slate-700">
209
+ Photos
210
+ </Label>
211
+ {loadingPhotos ? (
212
+ <div className="cls_profile_picture_library_tab_photos_loading flex items-center justify-center p-8 border border-slate-200 rounded-lg bg-slate-50 min-h-[400px]">
213
+ <Loader2 className="h-6 w-6 text-slate-400 animate-spin" aria-hidden="true" />
214
+ </div>
215
+ ) : photos.length > 0 ? (
216
+ <div className={`cls_profile_picture_library_tab_photos_grid grid grid-cols-${libraryPhotoGridColumns} gap-3 overflow-y-auto p-4 border border-slate-200 rounded-lg bg-slate-50 min-h-[400px] max-h-[400px]`}>
217
+ {photos.map((photoUrl) => (
218
+ <button
219
+ key={photoUrl}
220
+ type="button"
221
+ onClick={() => handlePhotoClick(photoUrl)}
222
+ className={`
223
+ cls_profile_picture_library_tab_photo_thumbnail
224
+ aspect-square rounded-lg overflow-hidden border-2 transition-colors
225
+ ${selectedPhoto === photoUrl ? "border-blue-500 ring-2 ring-blue-200" : "border-slate-200 hover:border-slate-300"}
226
+ `}
227
+ aria-label={`Select ${photoUrl}`}
228
+ >
229
+ <img
230
+ src={photoUrl}
231
+ alt="Library photo thumbnail"
232
+ className="cls_profile_picture_library_tab_photo_thumbnail_image w-full h-full object-cover"
233
+ loading="lazy"
234
+ />
235
+ </button>
236
+ ))}
237
+ </div>
238
+ ) : (
239
+ <div className="cls_profile_picture_library_tab_no_photos flex items-center justify-center p-8 border border-slate-200 rounded-lg bg-slate-50 min-h-[400px]">
240
+ <p className="cls_profile_picture_library_tab_no_photos_text text-sm text-slate-600">
241
+ No photos in this category
242
+ </p>
243
+ </div>
244
+ )}
245
+ </div>
246
+
247
+ {/* Right column: Preview (25% - 3 columns) */}
248
+ <div className="cls_profile_picture_library_tab_preview_container flex flex-col gap-2 col-span-3">
249
+ <Label className="cls_profile_picture_library_tab_preview_label text-sm font-medium text-slate-700">
250
+ Preview
251
+ </Label>
252
+ {selectedPhoto ? (
253
+ <div className="cls_profile_picture_library_tab_preview flex flex-col items-center gap-4 p-4 border border-slate-200 rounded-lg bg-slate-50 min-h-[400px] justify-center">
254
+ <div className="cls_profile_picture_library_tab_preview_image_wrapper w-full flex items-center justify-center">
255
+ <img
256
+ src={selectedPhoto}
257
+ alt="Selected library photo preview"
258
+ className="cls_profile_picture_library_tab_preview_image max-w-full max-h-[350px] rounded-lg object-contain"
259
+ />
260
+ </div>
261
+ <p className="cls_profile_picture_library_tab_preview_text text-sm text-slate-600 text-center">
262
+ Selected photo preview
263
+ </p>
264
+ </div>
265
+ ) : (
266
+ <div className="cls_profile_picture_library_tab_preview_empty flex flex-col items-center justify-center gap-2 p-8 border border-slate-200 rounded-lg bg-slate-50 min-h-[400px]">
267
+ <Avatar className="cls_profile_picture_library_tab_preview_empty_avatar h-32 w-32">
268
+ <AvatarFallback className="cls_profile_picture_library_tab_preview_empty_avatar_fallback bg-slate-200 text-slate-600 text-3xl">
269
+ {getInitials()}
270
+ </AvatarFallback>
271
+ </Avatar>
272
+ <p className="cls_profile_picture_library_tab_preview_empty_text text-sm text-slate-500 text-center">
273
+ Select a photo to see preview
274
+ </p>
275
+ </div>
276
+ )}
277
+ </div>
278
+ </div>
279
+ </div>
280
+ );
281
+ }
282
+
@@ -0,0 +1,341 @@
1
+ // file_description: Upload tab component for profile picture dialog with dropzone and preview
2
+ // section: client_directive
3
+ "use client";
4
+
5
+ // section: imports
6
+ import { useState, useCallback, useEffect } from "react";
7
+ import { Switch } from "@/components/ui/switch";
8
+ import { Label } from "@/components/ui/label";
9
+ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
10
+ import { Upload, X, Loader2, Info } from "lucide-react";
11
+ import { Button } from "@/components/ui/button";
12
+ import imageCompression from "browser-image-compression";
13
+
14
+ // section: types
15
+ export type ProfilePictureUploadTabProps = {
16
+ useUpload: boolean;
17
+ onUseUploadChange: (use: boolean) => void;
18
+ onFileSelect: (file: File) => Promise<void>;
19
+ maxSize: number; // in bytes
20
+ uploadEnabled: boolean;
21
+ disabled?: boolean;
22
+ currentPreview?: string;
23
+ photoUploadDisabledMessage?: string;
24
+ imageCompressionMaxDimension?: number;
25
+ uploadFileHardLimitBytes?: number;
26
+ allowedImageMimeTypes?: string[];
27
+ };
28
+
29
+ // section: component
30
+ /**
31
+ * Upload tab component for profile picture dialog
32
+ * Two columns: left = dropzone, right = preview
33
+ * Uses browser-image-compression for client-side compression
34
+ * @param props - Component props including upload state, file handler, and configuration
35
+ * @returns Upload tab component
36
+ */
37
+ export function ProfilePictureUploadTab({
38
+ useUpload,
39
+ onUseUploadChange,
40
+ onFileSelect,
41
+ maxSize,
42
+ uploadEnabled,
43
+ disabled = false,
44
+ currentPreview,
45
+ photoUploadDisabledMessage,
46
+ imageCompressionMaxDimension = 200,
47
+ uploadFileHardLimitBytes = 10485760, // 10MB default
48
+ allowedImageMimeTypes = ["image/jpeg", "image/jpg", "image/png"],
49
+ }: ProfilePictureUploadTabProps) {
50
+ const [dragActive, setDragActive] = useState(false);
51
+ const [preview, setPreview] = useState<string | null>(currentPreview || null);
52
+ const [isNewImage, setIsNewImage] = useState(false); // Track if preview is showing a newly uploaded image
53
+ const [uploading, setUploading] = useState(false);
54
+ const [compressing, setCompressing] = useState(false);
55
+ const [error, setError] = useState<string | null>(null);
56
+
57
+ // Update preview when currentPreview changes (e.g., when dialog opens)
58
+ useEffect(() => {
59
+ if (currentPreview) {
60
+ setPreview(currentPreview);
61
+ setIsNewImage(false); // Reset to current when dialog opens or currentPreview changes
62
+ } else {
63
+ // If no current preview, only clear if we're not showing a new image
64
+ if (!isNewImage) {
65
+ setPreview(null);
66
+ }
67
+ }
68
+ // eslint-disable-next-line react-hooks/exhaustive-deps
69
+ }, [currentPreview]); // Only depend on currentPreview to avoid loops, isNewImage check is intentional
70
+
71
+ const handleFile = useCallback(async (file: File) => {
72
+ // Validate file type
73
+ if (!allowedImageMimeTypes.includes(file.type)) {
74
+ setError(`Invalid file type. Only ${allowedImageMimeTypes.map(t => t.split("/")[1].toUpperCase()).join(", ")} files are allowed.`);
75
+ return;
76
+ }
77
+
78
+ // Hard limit: reject files larger than configured limit (too large to process efficiently)
79
+ if (file.size > uploadFileHardLimitBytes) {
80
+ setError(`File is too large. Maximum size is ${Math.round(maxSize / 1024)}KB.`);
81
+ return;
82
+ }
83
+
84
+ setError(null);
85
+ setCompressing(false);
86
+ setUploading(false);
87
+
88
+ // If file is larger than maxSize, compress it
89
+ if (file.size > maxSize) {
90
+ setCompressing(true);
91
+ try {
92
+ // Compress image
93
+ const options = {
94
+ maxSizeMB: maxSize / (1024 * 1024), // Convert bytes to MB
95
+ maxWidthOrHeight: imageCompressionMaxDimension,
96
+ useWebWorker: true,
97
+ fileType: file.type,
98
+ };
99
+
100
+ const compressedFile = await imageCompression(file, options);
101
+ setCompressing(false);
102
+
103
+ // Check if compressed file is still too large
104
+ if (compressedFile.size > maxSize) {
105
+ setError(`File is too large. Maximum size is ${Math.round(maxSize / 1024)}KB. After compression, file is ${Math.round(compressedFile.size / 1024)}KB.`);
106
+ return;
107
+ }
108
+
109
+ // Create preview
110
+ const reader = new FileReader();
111
+ reader.onloadend = () => {
112
+ setPreview(reader.result as string);
113
+ setIsNewImage(true); // Mark as new image
114
+ };
115
+ reader.readAsDataURL(compressedFile);
116
+
117
+ // Upload the compressed file
118
+ setUploading(true);
119
+ await onFileSelect(compressedFile);
120
+ setUploading(false);
121
+ } catch (error) {
122
+ setCompressing(false);
123
+ const errorMessage = error instanceof Error ? error.message : "Failed to compress image";
124
+ setError(errorMessage);
125
+ }
126
+ } else {
127
+ // File is already small enough, just upload it
128
+ setUploading(true);
129
+ try {
130
+ // Create preview
131
+ const reader = new FileReader();
132
+ reader.onloadend = () => {
133
+ setPreview(reader.result as string);
134
+ setIsNewImage(true); // Mark as new image
135
+ };
136
+ reader.readAsDataURL(file);
137
+
138
+ // Call onFileSelect with original file
139
+ await onFileSelect(file);
140
+ } catch (error) {
141
+ const errorMessage = error instanceof Error ? error.message : "Failed to process image";
142
+ setError(errorMessage);
143
+ } finally {
144
+ setUploading(false);
145
+ }
146
+ }
147
+ }, [maxSize, onFileSelect]);
148
+
149
+ const handleDrag = useCallback((e: React.DragEvent<HTMLDivElement>) => {
150
+ e.preventDefault();
151
+ e.stopPropagation();
152
+ if (e.type === "dragenter" || e.type === "dragover") {
153
+ setDragActive(true);
154
+ } else if (e.type === "dragleave") {
155
+ setDragActive(false);
156
+ }
157
+ }, []);
158
+
159
+ const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
160
+ e.preventDefault();
161
+ e.stopPropagation();
162
+ setDragActive(false);
163
+
164
+ if (!uploadEnabled || disabled) {
165
+ setError("Photo upload is not enabled");
166
+ return;
167
+ }
168
+
169
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
170
+ void handleFile(e.dataTransfer.files[0]);
171
+ }
172
+ }, [uploadEnabled, disabled, handleFile]);
173
+
174
+ const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
175
+ e.preventDefault();
176
+ if (!uploadEnabled || disabled) {
177
+ setError("Photo upload is not enabled");
178
+ return;
179
+ }
180
+
181
+ if (e.target.files && e.target.files[0]) {
182
+ void handleFile(e.target.files[0]);
183
+ }
184
+ }, [uploadEnabled, disabled, handleFile]);
185
+
186
+ const handleRemove = useCallback(() => {
187
+ setPreview(currentPreview || null); // Reset to current preview if available
188
+ setIsNewImage(false); // Reset to showing current image
189
+ setError(null);
190
+ }, [currentPreview]);
191
+
192
+ const getInitials = (): string => {
193
+ return "U";
194
+ };
195
+
196
+ return (
197
+ <div className="cls_profile_picture_upload_tab flex flex-col gap-4">
198
+ {/* Switch */}
199
+ <div className="cls_profile_picture_upload_tab_switch flex items-center gap-3">
200
+ <Switch
201
+ id="use-upload"
202
+ checked={useUpload}
203
+ onCheckedChange={onUseUploadChange}
204
+ disabled={disabled || !uploadEnabled}
205
+ className="cls_profile_picture_upload_tab_switch_input"
206
+ aria-label="Use uploaded photo"
207
+ />
208
+ <Label
209
+ htmlFor="use-upload"
210
+ className="cls_profile_picture_upload_tab_switch_label text-sm font-medium text-slate-700 cursor-pointer"
211
+ >
212
+ Use uploaded photo
213
+ </Label>
214
+ </div>
215
+
216
+ {!uploadEnabled && (
217
+ <div className="cls_profile_picture_upload_tab_disabled flex items-center gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
218
+ <Info className="h-4 w-4 text-yellow-600" aria-hidden="true" />
219
+ <p className="cls_profile_picture_upload_tab_disabled_text text-sm text-yellow-800">
220
+ {photoUploadDisabledMessage}
221
+ </p>
222
+ </div>
223
+ )}
224
+
225
+ {/* Two columns: dropzone and preview */}
226
+ <div className="cls_profile_picture_upload_tab_content grid grid-cols-1 md:grid-cols-2 gap-6">
227
+ {/* Left column: Dropzone */}
228
+ <div className="cls_profile_picture_upload_tab_dropzone_container flex flex-col gap-2">
229
+ <Label className="cls_profile_picture_upload_tab_dropzone_label text-sm font-medium text-slate-700">
230
+ Upload Photo
231
+ </Label>
232
+ <div
233
+ className={`
234
+ cls_profile_picture_upload_tab_dropzone
235
+ flex flex-col items-center justify-center
236
+ border-2 border-dashed rounded-lg p-8
237
+ transition-colors
238
+ ${dragActive ? "border-blue-500 bg-blue-50" : "border-slate-300 bg-slate-50"}
239
+ ${disabled || !uploadEnabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:border-slate-400"}
240
+ `}
241
+ onDragEnter={handleDrag}
242
+ onDragLeave={handleDrag}
243
+ onDragOver={handleDrag}
244
+ onDrop={handleDrop}
245
+ onClick={() => {
246
+ if (!disabled && uploadEnabled) {
247
+ document.getElementById("file-upload-input")?.click();
248
+ }
249
+ }}
250
+ >
251
+ <input
252
+ id="file-upload-input"
253
+ type="file"
254
+ accept={allowedImageMimeTypes.join(",")}
255
+ onChange={handleChange}
256
+ disabled={disabled || !uploadEnabled}
257
+ className="hidden"
258
+ aria-label="Upload profile picture"
259
+ />
260
+ <Upload className="h-8 w-8 text-slate-400 mb-2" aria-hidden="true" />
261
+ <p className="cls_profile_picture_upload_tab_dropzone_text text-sm text-slate-600 text-center">
262
+ Drag and drop an image here, or click to select
263
+ </p>
264
+ <p className="cls_profile_picture_upload_tab_dropzone_hint text-xs text-slate-500 text-center mt-1">
265
+ JPG or PNG, max {Math.round(maxSize / 1024)}KB
266
+ </p>
267
+ </div>
268
+ {error && (
269
+ <p className="cls_profile_picture_upload_tab_error text-sm text-red-600" role="alert">
270
+ {error}
271
+ </p>
272
+ )}
273
+ </div>
274
+
275
+ {/* Right column: Preview */}
276
+ <div className="cls_profile_picture_upload_tab_preview_container flex flex-col gap-2">
277
+ <Label className="cls_profile_picture_upload_tab_preview_label text-sm font-medium text-slate-700">
278
+ {isNewImage ? "Preview (new)" : "Preview (current)"}
279
+ </Label>
280
+ <div className="cls_profile_picture_upload_tab_preview_content flex flex-col items-center justify-center border border-slate-200 rounded-lg p-6 bg-slate-50 min-h-[200px]">
281
+ {compressing ? (
282
+ <div className="cls_profile_picture_upload_tab_compressing flex flex-col items-center gap-2">
283
+ <Loader2 className="h-8 w-8 text-slate-400 animate-spin" aria-hidden="true" />
284
+ <p className="cls_profile_picture_upload_tab_compressing_text text-sm text-slate-600">
285
+ Compressing image...
286
+ </p>
287
+ </div>
288
+ ) : uploading ? (
289
+ <div className="cls_profile_picture_upload_tab_uploading flex flex-col items-center gap-2">
290
+ <Loader2 className="h-8 w-8 text-slate-400 animate-spin" aria-hidden="true" />
291
+ <p className="cls_profile_picture_upload_tab_uploading_text text-sm text-slate-600">
292
+ Uploading...
293
+ </p>
294
+ </div>
295
+ ) : preview ? (
296
+ <div className="cls_profile_picture_upload_tab_preview_image_container flex flex-col items-center gap-4">
297
+ <div className="cls_profile_picture_upload_tab_preview_image_wrapper relative">
298
+ <Avatar className="cls_profile_picture_upload_tab_preview_avatar h-32 w-32">
299
+ <AvatarImage
300
+ src={preview}
301
+ alt="Uploaded profile picture preview"
302
+ className="cls_profile_picture_upload_tab_preview_avatar_image"
303
+ />
304
+ <AvatarFallback className="cls_profile_picture_upload_tab_preview_avatar_fallback bg-slate-200 text-slate-600 text-3xl">
305
+ {getInitials()}
306
+ </AvatarFallback>
307
+ </Avatar>
308
+ <Button
309
+ type="button"
310
+ onClick={handleRemove}
311
+ variant="ghost"
312
+ size="icon"
313
+ className="cls_profile_picture_upload_tab_preview_remove absolute -top-2 -right-2 rounded-full h-6 w-6 border border-slate-300 bg-white hover:bg-slate-50"
314
+ aria-label="Remove preview"
315
+ >
316
+ <X className="h-4 w-4" aria-hidden="true" />
317
+ </Button>
318
+ </div>
319
+ <p className="cls_profile_picture_upload_tab_preview_success_text text-sm text-slate-600 text-center">
320
+ Preview of your uploaded photo
321
+ </p>
322
+ </div>
323
+ ) : (
324
+ <div className="cls_profile_picture_upload_tab_preview_empty flex flex-col items-center gap-2">
325
+ <Avatar className="cls_profile_picture_upload_tab_preview_empty_avatar h-32 w-32">
326
+ <AvatarFallback className="cls_profile_picture_upload_tab_preview_empty_avatar_fallback bg-slate-200 text-slate-600 text-3xl">
327
+ {getInitials()}
328
+ </AvatarFallback>
329
+ </Avatar>
330
+ <p className="cls_profile_picture_upload_tab_preview_empty_text text-sm text-slate-500 text-center">
331
+ Upload an image to see preview
332
+ </p>
333
+ </div>
334
+ )}
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </div>
339
+ );
340
+ }
341
+
@@ -0,0 +1,61 @@
1
+ // file_description: my settings layout specific configuration helpers
2
+ // section: imports
3
+ import type { LayoutFieldMap } from "@/components/layouts/shared/config/layout_customization";
4
+ import {
5
+ resolveButtonPalette,
6
+ type ButtonPaletteDefaults,
7
+ type ButtonPaletteOverrides,
8
+ type PasswordRequirementOptions,
9
+ } from "@/components/layouts/shared/config/layout_customization";
10
+
11
+ // section: field_identifiers
12
+ export const MY_SETTINGS_FIELD_IDS = {
13
+ NAME: "name",
14
+ EMAIL: "email_address",
15
+ PASSWORD: "password",
16
+ } as const;
17
+
18
+ export type MySettingsFieldId = (typeof MY_SETTINGS_FIELD_IDS)[keyof typeof MY_SETTINGS_FIELD_IDS];
19
+
20
+ // section: label_defaults
21
+ export type MySettingsLabelDefaults = {
22
+ heading: string;
23
+ profileTab: string;
24
+ securityTab: string;
25
+ lastLoggedInLabel: string;
26
+ profilePictureLabel: string;
27
+ changePasswordButton: string;
28
+ };
29
+
30
+ const MY_SETTINGS_LABEL_DEFAULTS: MySettingsLabelDefaults = {
31
+ heading: "My Settings",
32
+ profileTab: "Profile",
33
+ securityTab: "Security",
34
+ lastLoggedInLabel: "Last logged in",
35
+ profilePictureLabel: "Profile Picture",
36
+ changePasswordButton: "Change Password",
37
+ };
38
+
39
+ export type MySettingsLabelOverrides = Partial<MySettingsLabelDefaults>;
40
+
41
+ export const resolveMySettingsLabels = (
42
+ overrides?: MySettingsLabelOverrides,
43
+ ): MySettingsLabelDefaults => {
44
+ return {
45
+ ...MY_SETTINGS_LABEL_DEFAULTS,
46
+ ...overrides,
47
+ };
48
+ };
49
+
50
+ // section: button_palette_defaults
51
+ const MY_SETTINGS_BUTTON_PALETTE_DEFAULTS: ButtonPaletteDefaults = {
52
+ submitBackground: "#0f172a",
53
+ submitText: "#ffffff",
54
+ cancelBorder: "#cbd5f5",
55
+ cancelText: "#0f172a",
56
+ };
57
+
58
+ export const resolveMySettingsButtonPalette = (
59
+ overrides?: ButtonPaletteOverrides,
60
+ ) => resolveButtonPalette(MY_SETTINGS_BUTTON_PALETTE_DEFAULTS, overrides);
61
+