@lastbrain/module-auth 0.1.2 → 0.1.4

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 (48) hide show
  1. package/README.md +533 -0
  2. package/dist/api/admin/users.d.ts +9 -0
  3. package/dist/api/admin/users.d.ts.map +1 -0
  4. package/dist/api/admin/users.js +38 -0
  5. package/dist/api/auth/me.d.ts +17 -0
  6. package/dist/api/auth/me.d.ts.map +1 -0
  7. package/dist/api/auth/me.js +32 -0
  8. package/dist/api/auth/profile.d.ts +32 -0
  9. package/dist/api/auth/profile.d.ts.map +1 -0
  10. package/dist/api/auth/profile.js +104 -0
  11. package/dist/api/public/signin.js +3 -3
  12. package/dist/api/storage.d.ts +13 -0
  13. package/dist/api/storage.d.ts.map +1 -0
  14. package/dist/api/storage.js +47 -0
  15. package/dist/auth.build.config.d.ts.map +1 -1
  16. package/dist/auth.build.config.js +42 -2
  17. package/dist/web/admin/users.d.ts.map +1 -1
  18. package/dist/web/admin/users.js +94 -2
  19. package/dist/web/auth/dashboard.d.ts +1 -1
  20. package/dist/web/auth/dashboard.d.ts.map +1 -1
  21. package/dist/web/auth/dashboard.js +42 -2
  22. package/dist/web/auth/profile.d.ts.map +1 -1
  23. package/dist/web/auth/profile.js +191 -2
  24. package/dist/web/auth/reglage.d.ts.map +1 -1
  25. package/dist/web/auth/reglage.js +98 -2
  26. package/dist/web/public/SignInPage.d.ts.map +1 -1
  27. package/dist/web/public/SignInPage.js +1 -1
  28. package/dist/web/public/SignUpPage.js +1 -1
  29. package/package.json +8 -7
  30. package/src/api/admin/users.ts +51 -0
  31. package/src/api/auth/me.ts +39 -0
  32. package/src/api/auth/profile.ts +142 -0
  33. package/src/api/public/signin.ts +3 -3
  34. package/src/api/storage.ts +66 -0
  35. package/src/auth.build.config.ts +42 -2
  36. package/src/web/admin/users.tsx +290 -1
  37. package/src/web/auth/dashboard.tsx +207 -1
  38. package/src/web/auth/profile.tsx +420 -1
  39. package/src/web/auth/reglage.tsx +284 -1
  40. package/src/web/public/SignInPage.tsx +1 -2
  41. package/src/web/public/SignUpPage.tsx +2 -2
  42. package/supabase/.temp/cli-latest +1 -0
  43. package/supabase/migrations/20251112000000_user_init.sql +1 -1
  44. package/supabase/migrations/20251112000001_auto_profile_and_admin_view.sql +206 -0
  45. package/supabase/migrations/20251112000002_sync_avatars.sql +54 -0
  46. package/supabase/migrations-down/20251112000000_user_init.down.sql +2 -0
  47. package/supabase/migrations-down/20251112000001_auto_profile_and_admin_view.down.sql +23 -0
  48. package/supabase/migrations-down/20251112000002_sync_avatars.down.sql +9 -0
@@ -1,3 +1,422 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import {
5
+ Card,
6
+ CardBody,
7
+ CardHeader,
8
+ Input,
9
+ Textarea,
10
+ Button,
11
+ Spinner,
12
+ Divider,
13
+ addToast,
14
+ AvatarUploader,
15
+ } from "@lastbrain/ui";
16
+ import { Save, User } from "lucide-react";
17
+ import { uploadFile, deleteFilesWithPrefix } from "../../api/storage.js";
18
+ import { supabaseBrowserClient } from "@lastbrain/core";
19
+
20
+ interface ProfileData {
21
+ first_name?: string;
22
+ last_name?: string;
23
+ avatar_url?: string;
24
+ bio?: string;
25
+ phone?: string;
26
+ company?: string;
27
+ website?: string;
28
+ location?: string;
29
+ language?: string;
30
+ timezone?: string;
31
+ }
32
+
33
+ interface UserMetadata {
34
+ avatar?: string | null;
35
+ avatar_sizes?: {
36
+ small?: string | null;
37
+ medium?: string | null;
38
+ large?: string | null;
39
+ } | null;
40
+ }
41
+
1
42
  export function ProfilePage() {
2
- return <div className="pt-12">Welcome to your Profile!</div>;
43
+ const [profile, setProfile] = useState<ProfileData>({});
44
+ const [isLoading, setIsLoading] = useState(true);
45
+ const [isSaving, setIsSaving] = useState(false);
46
+ const [_error, setError] = useState<string | null>(null);
47
+ const [currentUser, setCurrentUser] = useState<any>(null);
48
+
49
+ useEffect(() => {
50
+ fetchProfile();
51
+ fetchCurrentUser();
52
+ }, []);
53
+
54
+ const fetchCurrentUser = async () => {
55
+ try {
56
+ const {
57
+ data: { user },
58
+ } = await supabaseBrowserClient.auth.getUser();
59
+ setCurrentUser(user);
60
+ } catch (err) {
61
+ console.error("Error fetching current user:", err);
62
+ }
63
+ };
64
+
65
+ const fetchProfile = async () => {
66
+ try {
67
+ setIsLoading(true);
68
+ const response = await fetch("/api/auth/profile");
69
+
70
+ if (!response.ok) {
71
+ throw new Error("Failed to fetch profile");
72
+ }
73
+
74
+ const result = await response.json();
75
+ if (result.data) {
76
+ setProfile(result.data);
77
+ }
78
+ } catch (err) {
79
+ setError(err instanceof Error ? err.message : "An error occurred");
80
+ addToast({
81
+ title: "Error",
82
+ description: "Failed to load profile",
83
+ color: "danger",
84
+ });
85
+ } finally {
86
+ setIsLoading(false);
87
+ }
88
+ };
89
+
90
+ const handleSubmit = async (e: React.FormEvent) => {
91
+ e.preventDefault();
92
+ setIsSaving(true);
93
+
94
+ try {
95
+ const response = await fetch("/api/auth/profile", {
96
+ method: "PUT",
97
+ headers: {
98
+ "Content-Type": "application/json",
99
+ },
100
+ body: JSON.stringify(profile),
101
+ });
102
+
103
+ if (!response.ok) {
104
+ throw new Error("Failed to update profile");
105
+ }
106
+
107
+ addToast({
108
+ title: "Success",
109
+ description: "Profile updated successfully",
110
+ color: "success",
111
+ });
112
+ } catch (err) {
113
+ console.error("Error updating profile:", err);
114
+ setError(err instanceof Error ? err.message : "An error occurred");
115
+ addToast({
116
+ title: "Error",
117
+ description: "Failed to update profile",
118
+ color: "danger",
119
+ });
120
+ } finally {
121
+ setIsSaving(false);
122
+ }
123
+ };
124
+
125
+ const handleChange = (field: keyof ProfileData, value: string) => {
126
+ setProfile((prev) => ({ ...prev, [field]: value }));
127
+ };
128
+
129
+ const handleAvatarUpload = async (files: {
130
+ small: Blob;
131
+ medium: Blob;
132
+ large: Blob;
133
+ }) => {
134
+ if (!currentUser) throw new Error("User not authenticated");
135
+
136
+ const version = Date.now();
137
+ const urls = {
138
+ small: "",
139
+ medium: "",
140
+ large: "",
141
+ };
142
+
143
+ // Upload all three sizes
144
+ urls.small = await uploadFile(
145
+ "avatar",
146
+ `${currentUser.id}_32_${version}.webp`,
147
+ files.small,
148
+ "image/webp"
149
+ );
150
+ urls.medium = await uploadFile(
151
+ "avatar",
152
+ `${currentUser.id}_64_${version}.webp`,
153
+ files.medium,
154
+ "image/webp"
155
+ );
156
+ urls.large = await uploadFile(
157
+ "avatar",
158
+ `${currentUser.id}_128_${version}.webp`,
159
+ files.large,
160
+ "image/webp"
161
+ );
162
+
163
+ // Update user metadata
164
+ await supabaseBrowserClient.auth.updateUser({
165
+ data: {
166
+ avatar: `avatar/${currentUser.id}_128_${version}.webp`,
167
+ avatar_sizes: {
168
+ small: `avatar/${currentUser.id}_32_${version}.webp`,
169
+ medium: `avatar/${currentUser.id}_64_${version}.webp`,
170
+ large: `avatar/${currentUser.id}_128_${version}.webp`,
171
+ },
172
+ },
173
+ });
174
+
175
+ // Update profile avatar_url in database
176
+ try {
177
+ const response = await fetch("/api/auth/profile", {
178
+ method: "PATCH",
179
+ headers: {
180
+ "Content-Type": "application/json",
181
+ },
182
+ body: JSON.stringify({
183
+ avatar_url: `/avatar/${currentUser.id}_128_${version}.webp`,
184
+ }),
185
+ });
186
+
187
+ if (!response.ok) {
188
+ console.error("Failed to update avatar_url in profile");
189
+ }
190
+ } catch (error) {
191
+ console.error("Error updating profile avatar_url:", error);
192
+ }
193
+
194
+ // Update profile avatar_url locally
195
+ setProfile((prev) => ({
196
+ ...prev,
197
+ avatar_url: `/avatar/${currentUser.id}_128_${version}.webp`,
198
+ }));
199
+
200
+ return urls;
201
+ };
202
+
203
+ const handleAvatarDelete = async () => {
204
+ if (!currentUser) throw new Error("User not authenticated");
205
+
206
+ // Delete old files
207
+ await deleteFilesWithPrefix("avatar", currentUser.id);
208
+
209
+ // Update user metadata
210
+ await supabaseBrowserClient.auth.updateUser({
211
+ data: {
212
+ avatar: null,
213
+ avatar_sizes: {
214
+ small: null,
215
+ medium: null,
216
+ large: null,
217
+ },
218
+ },
219
+ });
220
+
221
+ // Update profile avatar_url in database
222
+ try {
223
+ const response = await fetch("/api/auth/profile", {
224
+ method: "PATCH",
225
+ headers: {
226
+ "Content-Type": "application/json",
227
+ },
228
+ body: JSON.stringify({
229
+ avatar_url: null,
230
+ }),
231
+ });
232
+
233
+ if (!response.ok) {
234
+ console.error("Failed to update avatar_url in profile");
235
+ }
236
+ } catch (error) {
237
+ console.error("Error updating profile avatar_url:", error);
238
+ }
239
+
240
+ // Update profile locally
241
+ setProfile((prev) => ({ ...prev, avatar_url: "" }));
242
+ };
243
+
244
+ if (isLoading) {
245
+ return (
246
+ <div className="flex justify-center items-center min-h-[400px]">
247
+ <Spinner size="lg" label="Loading profile..." />
248
+ </div>
249
+ );
250
+ }
251
+
252
+ return (
253
+ <div className="pt-12 pb-12 max-w-4xl mx-auto px-4">
254
+ <div className="flex items-center gap-2 mb-8">
255
+ <User className="w-8 h-8" />
256
+ <h1 className="text-3xl font-bold">Edit Profile</h1>
257
+ </div>
258
+
259
+ <form onSubmit={handleSubmit}>
260
+ <div className="space-y-6">
261
+ {/* Avatar Section */}
262
+ <Card>
263
+ <CardHeader>
264
+ <h3 className="text-lg font-semibold">Photo de profil</h3>
265
+ </CardHeader>
266
+ <Divider />
267
+ <CardBody>
268
+ <div className="flex justify-center">
269
+ <AvatarUploader
270
+ userId={currentUser?.id}
271
+ bucket="avatar"
272
+ shape="circle"
273
+ onUpload={handleAvatarUpload}
274
+ onDelete={handleAvatarDelete}
275
+ initialAvatarPath={
276
+ (currentUser?.user_metadata as UserMetadata)?.avatar ||
277
+ profile.avatar_url ||
278
+ null
279
+ }
280
+ initialAvatarSizes={(() => {
281
+ const sizes = (currentUser?.user_metadata as UserMetadata)
282
+ ?.avatar_sizes;
283
+ if (!sizes) return null;
284
+ return {
285
+ small: sizes.small ?? null,
286
+ medium: sizes.medium ?? null,
287
+ large: sizes.large ?? null,
288
+ };
289
+ })()}
290
+ onUploaded={(urls) => {
291
+ setProfile((prev) => ({ ...prev, avatar_url: urls.large }));
292
+ }}
293
+ onDeleted={() => {
294
+ setProfile((prev) => ({ ...prev, avatar_url: "" }));
295
+ }}
296
+ />
297
+ </div>
298
+ </CardBody>
299
+ </Card>
300
+
301
+ {/* Personal Information */}
302
+ <Card>
303
+ <CardHeader>
304
+ <h3 className="text-lg font-semibold">Personal Information</h3>
305
+ </CardHeader>
306
+ <Divider />
307
+ <CardBody>
308
+ <div className="grid gap-4 md:grid-cols-2">
309
+ <Input
310
+ label="First Name"
311
+ placeholder="Enter your first name"
312
+ value={profile.first_name || ""}
313
+ onChange={(e) => handleChange("first_name", e.target.value)}
314
+ />
315
+ <Input
316
+ label="Last Name"
317
+ placeholder="Enter your last name"
318
+ value={profile.last_name || ""}
319
+ onChange={(e) => handleChange("last_name", e.target.value)}
320
+ />
321
+ <Input
322
+ label="Phone"
323
+ placeholder="Enter your phone number"
324
+ type="tel"
325
+ value={profile.phone || ""}
326
+ onChange={(e) => handleChange("phone", e.target.value)}
327
+ className="md:col-span-2"
328
+ />
329
+ <Textarea
330
+ label="Bio"
331
+ placeholder="Tell us about yourself"
332
+ value={profile.bio || ""}
333
+ onChange={(e) => handleChange("bio", e.target.value)}
334
+ minRows={3}
335
+ className="md:col-span-2"
336
+ />
337
+ </div>
338
+ </CardBody>
339
+ </Card>
340
+
341
+ {/* Professional Information */}
342
+ <Card>
343
+ <CardHeader>
344
+ <h3 className="text-lg font-semibold">
345
+ Professional Information
346
+ </h3>
347
+ </CardHeader>
348
+ <Divider />
349
+ <CardBody>
350
+ <div className="grid gap-4 md:grid-cols-2">
351
+ <Input
352
+ label="Company"
353
+ placeholder="Enter your company name"
354
+ value={profile.company || ""}
355
+ onChange={(e) => handleChange("company", e.target.value)}
356
+ />
357
+ <Input
358
+ label="Website"
359
+ placeholder="https://example.com"
360
+ type="url"
361
+ value={profile.website || ""}
362
+ onChange={(e) => handleChange("website", e.target.value)}
363
+ />
364
+ <Input
365
+ label="Location"
366
+ placeholder="City, Country"
367
+ value={profile.location || ""}
368
+ onChange={(e) => handleChange("location", e.target.value)}
369
+ className="md:col-span-2"
370
+ />
371
+ </div>
372
+ </CardBody>
373
+ </Card>
374
+
375
+ {/* Preferences */}
376
+ <Card>
377
+ <CardHeader>
378
+ <h3 className="text-lg font-semibold">Preferences</h3>
379
+ </CardHeader>
380
+ <Divider />
381
+ <CardBody>
382
+ <div className="grid gap-4 md:grid-cols-2">
383
+ <Input
384
+ label="Language"
385
+ placeholder="en, fr, es..."
386
+ value={profile.language || ""}
387
+ onChange={(e) => handleChange("language", e.target.value)}
388
+ />
389
+ <Input
390
+ label="Timezone"
391
+ placeholder="Europe/Paris, America/New_York..."
392
+ value={profile.timezone || ""}
393
+ onChange={(e) => handleChange("timezone", e.target.value)}
394
+ />
395
+ </div>
396
+ </CardBody>
397
+ </Card>
398
+
399
+ {/* Actions */}
400
+ <div className="flex justify-end gap-3">
401
+ <Button
402
+ type="button"
403
+ variant="flat"
404
+ onPress={() => fetchProfile()}
405
+ isDisabled={isSaving}
406
+ >
407
+ Cancel
408
+ </Button>
409
+ <Button
410
+ type="submit"
411
+ color="primary"
412
+ isLoading={isSaving}
413
+ startContent={!isSaving && <Save className="w-4 h-4" />}
414
+ >
415
+ {isSaving ? "Saving..." : "Save Changes"}
416
+ </Button>
417
+ </div>
418
+ </div>
419
+ </form>
420
+ </div>
421
+ );
3
422
  }
@@ -1,3 +1,286 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import {
5
+ Card,
6
+ CardBody,
7
+ CardHeader,
8
+ Switch,
9
+ Button,
10
+ Spinner,
11
+ Divider,
12
+ Select,
13
+ SelectItem,
14
+ addToast,
15
+ } from "@lastbrain/ui";
16
+ import { Settings, Save } from "lucide-react";
17
+
18
+ interface UserPreferences {
19
+ email_notifications?: boolean;
20
+ push_notifications?: boolean;
21
+ marketing_emails?: boolean;
22
+ theme?: string;
23
+ language?: string;
24
+ timezone?: string;
25
+ }
26
+
27
+ interface ProfileData {
28
+ language?: string;
29
+ timezone?: string;
30
+ preferences?: UserPreferences;
31
+ }
32
+
1
33
  export function ReglagePage() {
2
- return <div className="pt-12">Welcome to your Reglage!</div>;
34
+ const [preferences, setPreferences] = useState<UserPreferences>({
35
+ email_notifications: true,
36
+ push_notifications: false,
37
+ marketing_emails: false,
38
+ theme: "system",
39
+ language: "en",
40
+ timezone: "UTC",
41
+ });
42
+ const [isLoading, setIsLoading] = useState(true);
43
+ const [isSaving, setIsSaving] = useState(false);
44
+
45
+ useEffect(() => {
46
+ fetchSettings();
47
+ }, []);
48
+
49
+ const fetchSettings = async () => {
50
+ try {
51
+ setIsLoading(true);
52
+ const response = await fetch("/api/auth/profile");
53
+
54
+ if (!response.ok) {
55
+ throw new Error("Failed to fetch settings");
56
+ }
57
+
58
+ const result = await response.json();
59
+ if (result.data) {
60
+ const profile: ProfileData = result.data;
61
+ setPreferences((prev) => ({
62
+ ...prev,
63
+ language: profile.language || prev.language,
64
+ timezone: profile.timezone || prev.timezone,
65
+ ...(profile.preferences || {}),
66
+ }));
67
+ }
68
+ } catch (err) {
69
+ console.error("Error loading settings:", err);
70
+ addToast({
71
+ title: "Error",
72
+ description: "Failed to load settings",
73
+ color: "danger",
74
+ });
75
+ } finally {
76
+ setIsLoading(false);
77
+ }
78
+ };
79
+
80
+ const handleSave = async () => {
81
+ setIsSaving(true);
82
+
83
+ try {
84
+ const response = await fetch("/api/auth/profile", {
85
+ method: "PUT",
86
+ headers: {
87
+ "Content-Type": "application/json",
88
+ },
89
+ body: JSON.stringify({
90
+ language: preferences.language,
91
+ timezone: preferences.timezone,
92
+ preferences: {
93
+ email_notifications: preferences.email_notifications,
94
+ push_notifications: preferences.push_notifications,
95
+ marketing_emails: preferences.marketing_emails,
96
+ theme: preferences.theme,
97
+ },
98
+ }),
99
+ });
100
+
101
+ if (!response.ok) {
102
+ throw new Error("Failed to update settings");
103
+ }
104
+
105
+ addToast({
106
+ title: "Success",
107
+ description: "Settings updated successfully",
108
+ color: "success",
109
+ });
110
+ } catch (err) {
111
+ console.error("Error updating settings:", err);
112
+ addToast({
113
+ title: "Error",
114
+ description: "Failed to update settings",
115
+ color: "danger",
116
+ });
117
+ } finally {
118
+ setIsSaving(false);
119
+ }
120
+ };
121
+
122
+ const handleToggle = (key: keyof UserPreferences, value: boolean) => {
123
+ setPreferences((prev) => ({ ...prev, [key]: value }));
124
+ };
125
+
126
+ const handleSelect = (key: keyof UserPreferences, value: string) => {
127
+ setPreferences((prev) => ({ ...prev, [key]: value }));
128
+ };
129
+
130
+ if (isLoading) {
131
+ return (
132
+ <div className="flex justify-center items-center min-h-[400px]">
133
+ <Spinner size="lg" label="Loading settings..." />
134
+ </div>
135
+ );
136
+ }
137
+
138
+ return (
139
+ <div className="pt-12 pb-12 max-w-4xl mx-auto px-4">
140
+ <div className="flex items-center gap-2 mb-8">
141
+ <Settings className="w-8 h-8" />
142
+ <h1 className="text-3xl font-bold">Account Settings</h1>
143
+ </div>
144
+
145
+ <div className="space-y-6">
146
+ {/* Notifications */}
147
+ <Card>
148
+ <CardHeader>
149
+ <h3 className="text-lg font-semibold">Notifications</h3>
150
+ </CardHeader>
151
+ <Divider />
152
+ <CardBody>
153
+ <div className="space-y-4">
154
+ <div className="flex justify-between items-center">
155
+ <div>
156
+ <p className="font-medium">Email Notifications</p>
157
+ <p className="text-small text-default-500">
158
+ Receive email notifications for important updates
159
+ </p>
160
+ </div>
161
+ <Switch
162
+ isSelected={preferences.email_notifications}
163
+ onValueChange={(value) =>
164
+ handleToggle("email_notifications", value)
165
+ }
166
+ />
167
+ </div>
168
+ <Divider />
169
+ <div className="flex justify-between items-center">
170
+ <div>
171
+ <p className="font-medium">Push Notifications</p>
172
+ <p className="text-small text-default-500">
173
+ Receive push notifications in your browser
174
+ </p>
175
+ </div>
176
+ <Switch
177
+ isSelected={preferences.push_notifications}
178
+ onValueChange={(value) =>
179
+ handleToggle("push_notifications", value)
180
+ }
181
+ />
182
+ </div>
183
+ <Divider />
184
+ <div className="flex justify-between items-center">
185
+ <div>
186
+ <p className="font-medium">Marketing Emails</p>
187
+ <p className="text-small text-default-500">
188
+ Receive emails about new features and updates
189
+ </p>
190
+ </div>
191
+ <Switch
192
+ isSelected={preferences.marketing_emails}
193
+ onValueChange={(value) =>
194
+ handleToggle("marketing_emails", value)
195
+ }
196
+ />
197
+ </div>
198
+ </div>
199
+ </CardBody>
200
+ </Card>
201
+
202
+ {/* Appearance */}
203
+ <Card>
204
+ <CardHeader>
205
+ <h3 className="text-lg font-semibold">Appearance</h3>
206
+ </CardHeader>
207
+ <Divider />
208
+ <CardBody>
209
+ <Select
210
+ label="Theme"
211
+ placeholder="Select a theme"
212
+ selectedKeys={preferences.theme ? [preferences.theme] : []}
213
+ onChange={(e) => handleSelect("theme", e.target.value)}
214
+ >
215
+ <SelectItem key="light">Light</SelectItem>
216
+ <SelectItem key="dark">Dark</SelectItem>
217
+ <SelectItem key="system">System</SelectItem>
218
+ </Select>
219
+ </CardBody>
220
+ </Card>
221
+
222
+ {/* Language & Region */}
223
+ <Card>
224
+ <CardHeader>
225
+ <h3 className="text-lg font-semibold">Language & Region</h3>
226
+ </CardHeader>
227
+ <Divider />
228
+ <CardBody>
229
+ <div className="grid gap-4 md:grid-cols-2">
230
+ <Select
231
+ label="Language"
232
+ placeholder="Select a language"
233
+ selectedKeys={
234
+ preferences.language ? [preferences.language] : []
235
+ }
236
+ onChange={(e) => handleSelect("language", e.target.value)}
237
+ >
238
+ <SelectItem key="en">English</SelectItem>
239
+ <SelectItem key="fr">Français</SelectItem>
240
+ <SelectItem key="es">Español</SelectItem>
241
+ <SelectItem key="de">Deutsch</SelectItem>
242
+ </Select>
243
+ <Select
244
+ label="Timezone"
245
+ placeholder="Select a timezone"
246
+ selectedKeys={
247
+ preferences.timezone ? [preferences.timezone] : []
248
+ }
249
+ onChange={(e) => handleSelect("timezone", e.target.value)}
250
+ >
251
+ <SelectItem key="UTC">UTC</SelectItem>
252
+ <SelectItem key="Europe/Paris">Europe/Paris</SelectItem>
253
+ <SelectItem key="America/New_York">America/New_York</SelectItem>
254
+ <SelectItem key="America/Los_Angeles">
255
+ America/Los_Angeles
256
+ </SelectItem>
257
+ <SelectItem key="Asia/Tokyo">Asia/Tokyo</SelectItem>
258
+ </Select>
259
+ </div>
260
+ </CardBody>
261
+ </Card>
262
+
263
+ {/* Actions */}
264
+ <div className="flex justify-end gap-3">
265
+ <Button
266
+ type="button"
267
+ variant="flat"
268
+ onPress={() => fetchSettings()}
269
+ isDisabled={isSaving}
270
+ >
271
+ Reset
272
+ </Button>
273
+ <Button
274
+ type="button"
275
+ color="primary"
276
+ isLoading={isSaving}
277
+ onPress={handleSave}
278
+ startContent={!isSaving && <Save className="w-4 h-4" />}
279
+ >
280
+ {isSaving ? "Saving..." : "Save Settings"}
281
+ </Button>
282
+ </div>
283
+ </div>
284
+ </div>
285
+ );
3
286
  }