@betterstart/cli 0.1.69 → 0.1.70

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 (77) hide show
  1. package/dist/chunk-E4HZYXQ2.js +36 -0
  2. package/dist/chunk-E4HZYXQ2.js.map +1 -0
  3. package/dist/cli.js +580 -4444
  4. package/dist/cli.js.map +1 -1
  5. package/dist/reader-2T45D7JZ.js +7 -0
  6. package/package.json +1 -1
  7. package/templates/init/api/auth-route.ts +3 -0
  8. package/templates/init/api/upload-route.ts +74 -0
  9. package/templates/init/cms-globals.css +200 -0
  10. package/templates/init/components/data-table/data-table-pagination.tsx +90 -0
  11. package/templates/init/components/data-table/data-table-toolbar.tsx +93 -0
  12. package/templates/init/components/data-table/data-table.tsx +188 -0
  13. package/templates/init/components/layout/cms-header.tsx +32 -0
  14. package/templates/init/components/layout/cms-nav-link.tsx +25 -0
  15. package/templates/init/components/layout/cms-providers.tsx +33 -0
  16. package/templates/init/components/layout/cms-search.tsx +25 -0
  17. package/templates/init/components/layout/cms-sidebar.tsx +192 -0
  18. package/templates/init/components/layout/cms-sign-out.tsx +30 -0
  19. package/templates/init/components/shared/delete-dialog.tsx +75 -0
  20. package/templates/init/components/shared/page-header.tsx +23 -0
  21. package/templates/init/components/shared/status-badge.tsx +43 -0
  22. package/templates/init/data/navigation.ts +39 -0
  23. package/templates/init/db/client.ts +8 -0
  24. package/templates/init/db/schema.ts +88 -0
  25. package/{dist/chunk-6JCWMKSY.js → templates/init/drizzle.config.ts} +2 -11
  26. package/templates/init/hooks/use-cms-theme.tsx +78 -0
  27. package/templates/init/hooks/use-editor-image-upload.ts +82 -0
  28. package/templates/init/hooks/use-local-storage.ts +46 -0
  29. package/templates/init/hooks/use-mobile.ts +19 -0
  30. package/templates/init/hooks/use-upload.ts +177 -0
  31. package/templates/init/hooks/use-users.ts +13 -0
  32. package/templates/init/lib/actions/form-settings.ts +126 -0
  33. package/templates/init/lib/actions/profile.ts +62 -0
  34. package/templates/init/lib/actions/upload.ts +153 -0
  35. package/templates/init/lib/actions/users.ts +145 -0
  36. package/templates/init/lib/auth/auth-client.ts +12 -0
  37. package/templates/init/lib/auth/auth.ts +43 -0
  38. package/templates/init/lib/auth/middleware.ts +44 -0
  39. package/templates/init/lib/markdown/cached.ts +7 -0
  40. package/templates/init/lib/markdown/format.ts +55 -0
  41. package/templates/init/lib/markdown/render.ts +182 -0
  42. package/templates/init/lib/r2.ts +55 -0
  43. package/templates/init/pages/account-layout.tsx +63 -0
  44. package/templates/init/pages/authenticated-layout.tsx +26 -0
  45. package/templates/init/pages/cms-layout.tsx +16 -0
  46. package/templates/init/pages/dashboard-page.tsx +91 -0
  47. package/templates/init/pages/login-form.tsx +117 -0
  48. package/templates/init/pages/login-page.tsx +17 -0
  49. package/templates/init/pages/profile/profile-form.tsx +361 -0
  50. package/templates/init/pages/profile/profile-page.tsx +34 -0
  51. package/templates/init/pages/users/columns.tsx +241 -0
  52. package/templates/init/pages/users/create-user-dialog.tsx +116 -0
  53. package/templates/init/pages/users/edit-role-dialog.tsx +92 -0
  54. package/templates/init/pages/users/users-page-content.tsx +29 -0
  55. package/templates/init/pages/users/users-page.tsx +19 -0
  56. package/templates/init/pages/users/users-table.tsx +219 -0
  57. package/templates/init/types/auth.ts +78 -0
  58. package/templates/init/types/index.ts +79 -0
  59. package/templates/init/types/table-meta.ts +16 -0
  60. package/templates/init/utils/cn.ts +6 -0
  61. package/templates/init/utils/mailchimp.ts +39 -0
  62. package/templates/init/utils/seo.ts +90 -0
  63. package/templates/init/utils/validation.ts +105 -0
  64. package/templates/init/utils/webhook.ts +28 -0
  65. package/templates/ui/alert-dialog.tsx +46 -28
  66. package/templates/ui/avatar.tsx +37 -20
  67. package/templates/ui/button.tsx +3 -3
  68. package/templates/ui/card.tsx +30 -18
  69. package/templates/ui/dialog.tsx +46 -22
  70. package/templates/ui/dropdown-menu.tsx +1 -1
  71. package/templates/ui/input.tsx +1 -1
  72. package/templates/ui/select.tsx +42 -34
  73. package/templates/ui/sidebar.tsx +13 -13
  74. package/templates/ui/table.tsx +2 -2
  75. package/dist/chunk-6JCWMKSY.js.map +0 -1
  76. package/dist/drizzle-config-EDKOEZ6G.js +0 -7
  77. /package/dist/{drizzle-config-EDKOEZ6G.js.map → reader-2T45D7JZ.js.map} +0 -0
@@ -0,0 +1,63 @@
1
+ import { Suspense } from 'react'
2
+ import { getSession } from '@cms/auth/middleware'
3
+ import { cms } from '@cms/data/cms'
4
+ import { CmsSidebar } from '@cms/components/layout/cms-sidebar'
5
+ import { CmsSignOut } from '@cms/components/layout/cms-sign-out'
6
+ import { SidebarInset, SidebarProvider } from '@cms/components/ui/sidebar'
7
+ import { UserRole } from '@cms/types/auth'
8
+ import Link from 'next/link'
9
+ import { redirect } from 'next/navigation'
10
+
11
+ export default function AccountLayout({ children }: { children: React.ReactNode }) {
12
+ return (
13
+ <Suspense
14
+ fallback={
15
+ <div className="flex items-center justify-center h-screen">
16
+ <div className="text-muted-foreground">Loading...</div>
17
+ </div>
18
+ }
19
+ >
20
+ <AccountShell>{children}</AccountShell>
21
+ </Suspense>
22
+ )
23
+ }
24
+
25
+ async function AccountShell({ children }: { children: React.ReactNode }) {
26
+ const session = await getSession()
27
+
28
+ if (!session?.user) {
29
+ redirect('/cms/login')
30
+ }
31
+
32
+ const role = session.user.role as string
33
+
34
+ // Admin/Editor get the full dashboard shell with sidebar
35
+ if (role === UserRole.ADMIN || role === UserRole.EDITOR) {
36
+ return (
37
+ <SidebarProvider>
38
+ <CmsSidebar />
39
+ <SidebarInset>
40
+ <main>{children}</main>
41
+ </SidebarInset>
42
+ </SidebarProvider>
43
+ )
44
+ }
45
+
46
+ // Member gets a minimal shell
47
+ return (
48
+ <div className="min-h-screen flex flex-col">
49
+ <header className="sticky top-0 z-50 flex h-14 items-center justify-between border-b bg-background px-6">
50
+ <Link
51
+ href="/cms"
52
+ className="text-sm font-medium hover:text-foreground/80 transition-colors"
53
+ >
54
+ {cms.name}
55
+ </Link>
56
+ <CmsSignOut />
57
+ </header>
58
+ <div className="flex-1">
59
+ {children}
60
+ </div>
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,26 @@
1
+ import { Suspense } from 'react'
2
+ import { requireRole } from '@cms/auth/middleware'
3
+ import { CmsSidebar } from '@cms/components/layout/cms-sidebar'
4
+ import { SidebarInset, SidebarProvider } from '@cms/components/ui/sidebar'
5
+ import { UserRole } from '@cms/types/auth'
6
+
7
+ export default function CmsAuthLayout({ children }: { children: React.ReactNode }) {
8
+ return (
9
+ <Suspense fallback={<div className="flex items-center justify-center h-screen"><div className="text-muted-foreground">Loading...</div></div>}>
10
+ <AuthenticatedShell>{children}</AuthenticatedShell>
11
+ </Suspense>
12
+ )
13
+ }
14
+
15
+ async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
16
+ await requireRole([UserRole.ADMIN, UserRole.EDITOR])
17
+
18
+ return (
19
+ <SidebarProvider>
20
+ <CmsSidebar />
21
+ <SidebarInset>
22
+ <main>{children}</main>
23
+ </SidebarInset>
24
+ </SidebarProvider>
25
+ )
26
+ }
@@ -0,0 +1,16 @@
1
+ import '@cms/cms-globals.css'
2
+ import { CmsProviders } from '@cms/components/layout/cms-providers'
3
+ import { GeistSans } from 'geist/font/sans'
4
+ import { GeistMono } from 'geist/font/mono'
5
+
6
+ export default function CmsLayout({ children }: { children: React.ReactNode }) {
7
+ return (
8
+ <CmsProviders>
9
+ <div
10
+ className={`cms-root min-h-screen antialiased ${GeistSans.variable} ${GeistMono.variable}`}
11
+ >
12
+ {children}
13
+ </div>
14
+ </CmsProviders>
15
+ )
16
+ }
@@ -0,0 +1,91 @@
1
+ import { PageHeader } from '@cms/components/shared/page-header'
2
+ import { Badge } from '@cms/components/ui/badge'
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cms/components/ui/card'
4
+ import { FileText, Settings, Users } from 'lucide-react'
5
+ import Link from 'next/link'
6
+
7
+ const quickLinks = [
8
+ {
9
+ title: 'Users',
10
+ description: 'Manage admin users and roles',
11
+ href: '/cms/users',
12
+ icon: Users,
13
+ },
14
+ {
15
+ title: 'Settings',
16
+ description: 'Configure CMS settings',
17
+ href: '/cms/settings',
18
+ icon: Settings,
19
+ },
20
+ {
21
+ title: 'Generate',
22
+ description: 'Add a new resource from a schema',
23
+ href: '#',
24
+ icon: FileText,
25
+ hint: 'npx betterstart generate <schema>',
26
+ },
27
+ ]
28
+
29
+ export default function DashboardPage() {
30
+ return (
31
+ <div className="flex flex-col">
32
+ <PageHeader title="Dashboard" />
33
+ <div className="p-6 space-y-6">
34
+ <div className="grid gap-4 md:grid-cols-3">
35
+ {quickLinks.map((link) => (
36
+ <Link key={link.title} href={link.href}>
37
+ <Card className="hover:bg-accent/50 transition-colors h-full">
38
+ <CardHeader className="pb-2">
39
+ <div className="flex items-center gap-2">
40
+ <link.icon className="size-4 text-muted-foreground" />
41
+ <CardTitle className="text-sm font-medium">{link.title}</CardTitle>
42
+ </div>
43
+ </CardHeader>
44
+ <CardContent>
45
+ <CardDescription>{link.description}</CardDescription>
46
+ {link.hint && (
47
+ <code className="mt-2 block rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
48
+ {link.hint}
49
+ </code>
50
+ )}
51
+ </CardContent>
52
+ </Card>
53
+ </Link>
54
+ ))}
55
+ </div>
56
+ <Card>
57
+ <CardHeader>
58
+ <CardTitle className="text-sm font-medium">Environment</CardTitle>
59
+ <CardDescription>Current configuration status</CardDescription>
60
+ </CardHeader>
61
+ <CardContent className="space-y-3">
62
+ <div className="flex items-center justify-between">
63
+ <span className="text-sm text-muted-foreground">Environment</span>
64
+ <Badge variant="outline">
65
+ {process.env.NODE_ENV === 'production' ? 'Production' : 'Development'}
66
+ </Badge>
67
+ </div>
68
+ <div className="flex items-center justify-between">
69
+ <span className="text-sm text-muted-foreground">Database</span>
70
+ <Badge variant="outline">
71
+ {process.env.BETTERSTART_DATABASE_URL ? 'Connected' : 'Not configured'}
72
+ </Badge>
73
+ </div>
74
+ <div className="flex items-center justify-between">
75
+ <span className="text-sm text-muted-foreground">Storage (R2)</span>
76
+ <Badge variant="outline">
77
+ {process.env.BETTERSTART_R2_BUCKET_NAME ? 'Configured' : 'Not configured'}
78
+ </Badge>
79
+ </div>
80
+ <div className="flex items-center justify-between">
81
+ <span className="text-sm text-muted-foreground">Email (Resend)</span>
82
+ <Badge variant="outline">
83
+ {process.env.BETTERSTART_RESEND_API_KEY ? 'Configured' : 'Not configured'}
84
+ </Badge>
85
+ </div>
86
+ </CardContent>
87
+ </Card>
88
+ </div>
89
+ </div>
90
+ )
91
+ }
@@ -0,0 +1,117 @@
1
+ 'use client'
2
+
3
+ import { authClient } from '@cms/auth/client'
4
+ import { Button } from '@cms/components/ui/button'
5
+ import { Card, CardContent } from '@cms/components/ui/card'
6
+ import {
7
+ Field,
8
+ FieldGroup,
9
+ FieldLabel,
10
+ } from '@cms/components/ui/field'
11
+ import { Input } from '@cms/components/ui/input'
12
+ import { cn } from '@cms/utils/cn'
13
+ import { LoaderCircle } from 'lucide-react'
14
+ import { useRouter } from 'next/navigation'
15
+ import * as React from 'react'
16
+
17
+ export function LoginForm({
18
+ className,
19
+ ...props
20
+ }: React.ComponentProps<'div'>) {
21
+ const router = useRouter()
22
+ const [email, setEmail] = React.useState('')
23
+ const [password, setPassword] = React.useState('')
24
+ const [error, setError] = React.useState<string | null>(null)
25
+ const [isPending, startTransition] = React.useTransition()
26
+
27
+ const handleSubmit = (e: React.FormEvent) => {
28
+ e.preventDefault()
29
+ setError(null)
30
+
31
+ startTransition(async () => {
32
+ try {
33
+ const result = await authClient.signIn.email({
34
+ email,
35
+ password,
36
+ })
37
+
38
+ if (result.error) {
39
+ setError(result.error.message || 'Invalid email or password')
40
+ return
41
+ }
42
+
43
+ router.push('/cms')
44
+ router.refresh()
45
+ } catch {
46
+ setError('An error occurred. Please try again.')
47
+ }
48
+ })
49
+ }
50
+
51
+ return (
52
+ <div className={cn('flex flex-col gap-6', className)} {...props}>
53
+ <Card className="overflow-hidden p-0">
54
+ <CardContent className="grid p-0 md:grid-cols-[5fr_7fr]">
55
+ <div className="relative hidden bg-muted md:block">
56
+ <img
57
+ src="https://assets.betterstart.dev/assets/placeholder.png"
58
+ alt="Image"
59
+ className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
60
+ />
61
+ </div>
62
+ <form onSubmit={handleSubmit} className="p-6 md:p-20">
63
+ <FieldGroup className="pb-12 gap-10">
64
+ <div className="flex flex-col items-start">
65
+ <h1 className="text-xl font-medium">Welcome back</h1>
66
+ <p className="text-balance text-muted-foreground">
67
+ Login to your account
68
+ </p>
69
+ </div>
70
+ {error ? (
71
+ <div className="bg-destructive/10 text-destructive text-sm p-3 rounded-md">
72
+ {error}
73
+ </div>
74
+ ) : null}
75
+ <div className="flex flex-col gap-4">
76
+ <Field>
77
+ <FieldLabel htmlFor="email">Email</FieldLabel>
78
+ <Input
79
+ id="email"
80
+ type="email"
81
+ autoComplete="email"
82
+ placeholder="m@example.com"
83
+ value={email}
84
+ onChange={(e) => setEmail(e.target.value)}
85
+ required
86
+ disabled={isPending}
87
+ />
88
+ </Field>
89
+ <Field>
90
+ <div className="flex items-center">
91
+ <FieldLabel htmlFor="password">Password</FieldLabel>
92
+ </div>
93
+ <Input
94
+ id="password"
95
+ type="password"
96
+ autoComplete="current-password"
97
+ placeholder="Enter your password"
98
+ value={password}
99
+ onChange={(e) => setPassword(e.target.value)}
100
+ required
101
+ disabled={isPending}
102
+ />
103
+ </Field>
104
+ <Field className='pt-4'>
105
+ <Button type="submit" disabled={isPending}>
106
+ {isPending && <LoaderCircle className="animate-spin" />}
107
+ {isPending ? 'Signing in...' : 'Login'}
108
+ </Button>
109
+ </Field>
110
+ </div>
111
+ </FieldGroup>
112
+ </form>
113
+ </CardContent>
114
+ </Card>
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1,17 @@
1
+ import type { Metadata } from 'next'
2
+ import { LoginForm } from './login-form'
3
+
4
+ export const metadata: Metadata = {
5
+ title: 'CMS Login',
6
+ robots: { index: false, follow: false },
7
+ }
8
+
9
+ export default function LoginPage() {
10
+ return (
11
+ <div className="flex min-h-svh flex-col items-center justify-center bg-background p-6 md:p-10">
12
+ <div className="w-full max-w-sm md:max-w-4xl">
13
+ <LoginForm />
14
+ </div>
15
+ </div>
16
+ )
17
+ }
@@ -0,0 +1,361 @@
1
+ 'use client'
2
+
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { useForm } from 'react-hook-form'
5
+ import { toast } from 'sonner'
6
+ import { z } from 'zod/v3'
7
+ import { authClient } from '@cms/auth/client'
8
+ import { updateEmail } from '@cms/actions/profile'
9
+ import { Avatar, AvatarFallback, AvatarImage } from '@cms/components/ui/avatar'
10
+ import { Button } from '@cms/components/ui/button'
11
+ import { Input } from '@cms/components/ui/input'
12
+ import {
13
+ Form,
14
+ FormControl,
15
+ FormField,
16
+ FormItem,
17
+ FormLabel,
18
+ FormMessage,
19
+ } from '@cms/components/ui/form'
20
+ import { ImageUploadField } from '@cms/components/ui/image-upload-field'
21
+ import { Separator } from '@cms/components/ui/separator'
22
+ import { useRouter } from 'next/navigation'
23
+ import { useState } from 'react'
24
+
25
+ const profileSchema = z.object({
26
+ name: z.string().min(1, 'Name is required'),
27
+ email: z.string().email('Invalid email address'),
28
+ image: z
29
+ .string()
30
+ .transform((val) => (val === '' ? undefined : val))
31
+ .optional(),
32
+ currentPasswordForEmail: z.string().optional(),
33
+ })
34
+
35
+ type ProfileValues = z.infer<typeof profileSchema>
36
+
37
+ const passwordSchema = z
38
+ .object({
39
+ currentPassword: z.string().min(1, 'Current password is required'),
40
+ newPassword: z.string().min(8, 'Password must be at least 8 characters'),
41
+ confirmPassword: z.string().min(1, 'Please confirm your new password'),
42
+ })
43
+ .refine((data) => data.newPassword === data.confirmPassword, {
44
+ message: 'Passwords do not match',
45
+ path: ['confirmPassword'],
46
+ })
47
+
48
+ type PasswordValues = z.infer<typeof passwordSchema>
49
+
50
+ interface ProfileFormProps {
51
+ user: {
52
+ name: string
53
+ email: string
54
+ image: string | null
55
+ }
56
+ }
57
+
58
+ export function ProfileForm({ user }: ProfileFormProps) {
59
+ const router = useRouter()
60
+ const [profilePending, setProfilePending] = useState(false)
61
+ const [passwordPending, setPasswordPending] = useState(false)
62
+
63
+ const profileForm = useForm<ProfileValues>({
64
+ resolver: zodResolver(profileSchema),
65
+ defaultValues: {
66
+ name: user.name,
67
+ email: user.email,
68
+ image: user.image ?? '',
69
+ currentPasswordForEmail: '',
70
+ },
71
+ })
72
+
73
+ const passwordForm = useForm<PasswordValues>({
74
+ resolver: zodResolver(passwordSchema),
75
+ defaultValues: {
76
+ currentPassword: '',
77
+ newPassword: '',
78
+ confirmPassword: '',
79
+ },
80
+ })
81
+
82
+ const emailDirty = profileForm.watch('email') !== user.email
83
+
84
+ async function onProfileSubmit(values: ProfileValues) {
85
+ setProfilePending(true)
86
+
87
+ try {
88
+ // Update name and image via Better Auth
89
+ const { error } = await authClient.updateUser({
90
+ name: values.name,
91
+ image: values.image ?? null,
92
+ })
93
+
94
+ if (error) {
95
+ toast.error(error.message || 'Failed to update profile')
96
+ setProfilePending(false)
97
+ return
98
+ }
99
+
100
+ // If email changed, update via server action
101
+ if (values.email !== user.email) {
102
+ if (!values.currentPasswordForEmail) {
103
+ profileForm.setError('currentPasswordForEmail', {
104
+ message: 'Password is required to change email',
105
+ })
106
+ setProfilePending(false)
107
+ return
108
+ }
109
+
110
+ const result = await updateEmail(
111
+ values.email,
112
+ values.currentPasswordForEmail
113
+ )
114
+
115
+ if (!result.success) {
116
+ toast.error(result.error || 'Failed to update email')
117
+ setProfilePending(false)
118
+ return
119
+ }
120
+ }
121
+
122
+ toast.success('Profile updated successfully')
123
+ profileForm.reset({
124
+ name: values.name,
125
+ email: values.email,
126
+ image: values.image ?? '',
127
+ currentPasswordForEmail: '',
128
+ })
129
+ router.refresh()
130
+ } catch {
131
+ toast.error('An error occurred while updating your profile')
132
+ } finally {
133
+ setProfilePending(false)
134
+ }
135
+ }
136
+
137
+ async function onPasswordSubmit(values: PasswordValues) {
138
+ setPasswordPending(true)
139
+
140
+ try {
141
+ const { error } = await authClient.changePassword({
142
+ currentPassword: values.currentPassword,
143
+ newPassword: values.newPassword,
144
+ })
145
+
146
+ if (error) {
147
+ toast.error(error.message || 'Failed to change password')
148
+ setPasswordPending(false)
149
+ return
150
+ }
151
+
152
+ toast.success('Password changed successfully')
153
+ passwordForm.reset()
154
+ } catch {
155
+ toast.error('An error occurred while changing your password')
156
+ } finally {
157
+ setPasswordPending(false)
158
+ }
159
+ }
160
+
161
+ const imageValue = profileForm.watch('image')
162
+
163
+ return (
164
+ <div className="space-y-6">
165
+ {/* Profile Section */}
166
+ <Form {...profileForm}>
167
+ <form
168
+ onSubmit={profileForm.handleSubmit(onProfileSubmit)}
169
+ className="space-y-6 p-6 rounded-2xl border bg-card"
170
+ >
171
+ <div className="flex items-center gap-4">
172
+ <Avatar className="size-16">
173
+ <AvatarImage src={imageValue || undefined} />
174
+ <AvatarFallback className="text-lg font-semibold">
175
+ {user.name?.charAt(0) ?? '?'}
176
+ </AvatarFallback>
177
+ </Avatar>
178
+ <div>
179
+ <h2 className="text-lg font-semibold">Profile</h2>
180
+ <p className="text-sm text-muted-foreground">
181
+ Update your name, email, and profile picture
182
+ </p>
183
+ </div>
184
+ </div>
185
+
186
+ <Separator />
187
+
188
+ <FormField
189
+ control={profileForm.control}
190
+ name="image"
191
+ render={({ field: formField }) => (
192
+ <FormItem>
193
+ <FormLabel>Profile Picture</FormLabel>
194
+ <FormControl>
195
+ <ImageUploadField
196
+ value={formField.value}
197
+ onChange={formField.onChange}
198
+ onBlur={formField.onBlur}
199
+ disabled={profilePending}
200
+ maxSizeInMB={5}
201
+ label=""
202
+ />
203
+ </FormControl>
204
+ <FormMessage />
205
+ </FormItem>
206
+ )}
207
+ />
208
+
209
+ <FormField
210
+ control={profileForm.control}
211
+ name="name"
212
+ render={({ field: formField }) => (
213
+ <FormItem>
214
+ <FormLabel>Name</FormLabel>
215
+ <FormControl>
216
+ <Input
217
+ type="text"
218
+ placeholder="Your name"
219
+ disabled={profilePending}
220
+ {...formField}
221
+ />
222
+ </FormControl>
223
+ <FormMessage />
224
+ </FormItem>
225
+ )}
226
+ />
227
+
228
+ <FormField
229
+ control={profileForm.control}
230
+ name="email"
231
+ render={({ field: formField }) => (
232
+ <FormItem>
233
+ <FormLabel>Email</FormLabel>
234
+ <FormControl>
235
+ <Input
236
+ type="email"
237
+ placeholder="you@example.com"
238
+ disabled={profilePending}
239
+ {...formField}
240
+ />
241
+ </FormControl>
242
+ <FormMessage />
243
+ </FormItem>
244
+ )}
245
+ />
246
+
247
+ {emailDirty && (
248
+ <FormField
249
+ control={profileForm.control}
250
+ name="currentPasswordForEmail"
251
+ render={({ field: formField }) => (
252
+ <FormItem>
253
+ <FormLabel>Current Password</FormLabel>
254
+ <FormControl>
255
+ <Input
256
+ type="password"
257
+ placeholder="Enter current password to change email"
258
+ autoComplete="current-password"
259
+ disabled={profilePending}
260
+ {...formField}
261
+ />
262
+ </FormControl>
263
+ <FormMessage />
264
+ </FormItem>
265
+ )}
266
+ />
267
+ )}
268
+
269
+ <div className="flex justify-end">
270
+ <Button type="submit" disabled={profilePending}>
271
+ {profilePending ? 'Saving...' : 'Save Profile'}
272
+ </Button>
273
+ </div>
274
+ </form>
275
+ </Form>
276
+
277
+ {/* Password Section */}
278
+ <Form {...passwordForm}>
279
+ <form
280
+ onSubmit={passwordForm.handleSubmit(onPasswordSubmit)}
281
+ className="space-y-6 p-6 rounded-2xl border bg-card"
282
+ >
283
+ <div>
284
+ <h2 className="text-lg font-semibold">Change Password</h2>
285
+ <p className="text-sm text-muted-foreground">
286
+ Update your password to keep your account secure
287
+ </p>
288
+ </div>
289
+
290
+ <Separator />
291
+
292
+ <FormField
293
+ control={passwordForm.control}
294
+ name="currentPassword"
295
+ render={({ field: formField }) => (
296
+ <FormItem>
297
+ <FormLabel>Current Password</FormLabel>
298
+ <FormControl>
299
+ <Input
300
+ type="password"
301
+ placeholder="Enter current password"
302
+ autoComplete="current-password"
303
+ disabled={passwordPending}
304
+ {...formField}
305
+ />
306
+ </FormControl>
307
+ <FormMessage />
308
+ </FormItem>
309
+ )}
310
+ />
311
+
312
+ <FormField
313
+ control={passwordForm.control}
314
+ name="newPassword"
315
+ render={({ field: formField }) => (
316
+ <FormItem>
317
+ <FormLabel>New Password</FormLabel>
318
+ <FormControl>
319
+ <Input
320
+ type="password"
321
+ placeholder="Enter new password"
322
+ autoComplete="new-password"
323
+ disabled={passwordPending}
324
+ {...formField}
325
+ />
326
+ </FormControl>
327
+ <FormMessage />
328
+ </FormItem>
329
+ )}
330
+ />
331
+
332
+ <FormField
333
+ control={passwordForm.control}
334
+ name="confirmPassword"
335
+ render={({ field: formField }) => (
336
+ <FormItem>
337
+ <FormLabel>Confirm New Password</FormLabel>
338
+ <FormControl>
339
+ <Input
340
+ type="password"
341
+ placeholder="Confirm new password"
342
+ autoComplete="new-password"
343
+ disabled={passwordPending}
344
+ {...formField}
345
+ />
346
+ </FormControl>
347
+ <FormMessage />
348
+ </FormItem>
349
+ )}
350
+ />
351
+
352
+ <div className="flex justify-end">
353
+ <Button type="submit" disabled={passwordPending}>
354
+ {passwordPending ? 'Changing...' : 'Change Password'}
355
+ </Button>
356
+ </div>
357
+ </form>
358
+ </Form>
359
+ </div>
360
+ )
361
+ }