@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.
- package/dist/chunk-E4HZYXQ2.js +36 -0
- package/dist/chunk-E4HZYXQ2.js.map +1 -0
- package/dist/cli.js +580 -4444
- package/dist/cli.js.map +1 -1
- package/dist/reader-2T45D7JZ.js +7 -0
- package/package.json +1 -1
- package/templates/init/api/auth-route.ts +3 -0
- package/templates/init/api/upload-route.ts +74 -0
- package/templates/init/cms-globals.css +200 -0
- package/templates/init/components/data-table/data-table-pagination.tsx +90 -0
- package/templates/init/components/data-table/data-table-toolbar.tsx +93 -0
- package/templates/init/components/data-table/data-table.tsx +188 -0
- package/templates/init/components/layout/cms-header.tsx +32 -0
- package/templates/init/components/layout/cms-nav-link.tsx +25 -0
- package/templates/init/components/layout/cms-providers.tsx +33 -0
- package/templates/init/components/layout/cms-search.tsx +25 -0
- package/templates/init/components/layout/cms-sidebar.tsx +192 -0
- package/templates/init/components/layout/cms-sign-out.tsx +30 -0
- package/templates/init/components/shared/delete-dialog.tsx +75 -0
- package/templates/init/components/shared/page-header.tsx +23 -0
- package/templates/init/components/shared/status-badge.tsx +43 -0
- package/templates/init/data/navigation.ts +39 -0
- package/templates/init/db/client.ts +8 -0
- package/templates/init/db/schema.ts +88 -0
- package/{dist/chunk-6JCWMKSY.js → templates/init/drizzle.config.ts} +2 -11
- package/templates/init/hooks/use-cms-theme.tsx +78 -0
- package/templates/init/hooks/use-editor-image-upload.ts +82 -0
- package/templates/init/hooks/use-local-storage.ts +46 -0
- package/templates/init/hooks/use-mobile.ts +19 -0
- package/templates/init/hooks/use-upload.ts +177 -0
- package/templates/init/hooks/use-users.ts +13 -0
- package/templates/init/lib/actions/form-settings.ts +126 -0
- package/templates/init/lib/actions/profile.ts +62 -0
- package/templates/init/lib/actions/upload.ts +153 -0
- package/templates/init/lib/actions/users.ts +145 -0
- package/templates/init/lib/auth/auth-client.ts +12 -0
- package/templates/init/lib/auth/auth.ts +43 -0
- package/templates/init/lib/auth/middleware.ts +44 -0
- package/templates/init/lib/markdown/cached.ts +7 -0
- package/templates/init/lib/markdown/format.ts +55 -0
- package/templates/init/lib/markdown/render.ts +182 -0
- package/templates/init/lib/r2.ts +55 -0
- package/templates/init/pages/account-layout.tsx +63 -0
- package/templates/init/pages/authenticated-layout.tsx +26 -0
- package/templates/init/pages/cms-layout.tsx +16 -0
- package/templates/init/pages/dashboard-page.tsx +91 -0
- package/templates/init/pages/login-form.tsx +117 -0
- package/templates/init/pages/login-page.tsx +17 -0
- package/templates/init/pages/profile/profile-form.tsx +361 -0
- package/templates/init/pages/profile/profile-page.tsx +34 -0
- package/templates/init/pages/users/columns.tsx +241 -0
- package/templates/init/pages/users/create-user-dialog.tsx +116 -0
- package/templates/init/pages/users/edit-role-dialog.tsx +92 -0
- package/templates/init/pages/users/users-page-content.tsx +29 -0
- package/templates/init/pages/users/users-page.tsx +19 -0
- package/templates/init/pages/users/users-table.tsx +219 -0
- package/templates/init/types/auth.ts +78 -0
- package/templates/init/types/index.ts +79 -0
- package/templates/init/types/table-meta.ts +16 -0
- package/templates/init/utils/cn.ts +6 -0
- package/templates/init/utils/mailchimp.ts +39 -0
- package/templates/init/utils/seo.ts +90 -0
- package/templates/init/utils/validation.ts +105 -0
- package/templates/init/utils/webhook.ts +28 -0
- package/templates/ui/alert-dialog.tsx +46 -28
- package/templates/ui/avatar.tsx +37 -20
- package/templates/ui/button.tsx +3 -3
- package/templates/ui/card.tsx +30 -18
- package/templates/ui/dialog.tsx +46 -22
- package/templates/ui/dropdown-menu.tsx +1 -1
- package/templates/ui/input.tsx +1 -1
- package/templates/ui/select.tsx +42 -34
- package/templates/ui/sidebar.tsx +13 -13
- package/templates/ui/table.tsx +2 -2
- package/dist/chunk-6JCWMKSY.js.map +0 -1
- package/dist/drizzle-config-EDKOEZ6G.js +0 -7
- /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
|
+
}
|