@betterstart/cli 0.1.80 → 0.1.82

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@betterstart/cli",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "description": "Scaffold a full-featured CMS into any Next.js 16 application",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -3,17 +3,18 @@ import { Command, Search } from 'lucide-react'
3
3
 
4
4
  export const CmsSearch = () => {
5
5
  return (
6
- <div className="flex items-center gap-2 relative w-full max-w-[240px]">
6
+ <div className="flex items-center gap-2 relative w-full max-w-60">
7
7
  <Button
8
8
  variant="outline"
9
- className="w-full text-left items-center pr-2! pl-3 py-0 rounded-lg bg-white"
9
+ size="sm"
10
+ className="w-full text-left items-center pr-2! pl-3.5 py-0 rounded-full bg-white"
10
11
  >
11
- <Search className="shrink-0 size-3.5 -ml-0.5 text-muted-foreground/70" />
12
- <span className="w-full font-normal text-sm text-muted-foreground/70 [text-box-trim:trim-both]">
12
+ <Search className="shrink-0 size-3.5 -ml-0.5 text-muted-foreground" />
13
+ <span className="w-full font-normal text-sm text-muted-foreground [text-box-trim:trim-both]">
13
14
  Search
14
15
  </span>
15
16
  <div className="flex items-center gap-1 py-0.5 border rounded-full corner-squircle px-2 border-border bg-background">
16
- <Command className="size-3! text-muted-foreground" />
17
+ <Command className="size-3 text-muted-foreground" />
17
18
  <span className="font-mono text-xs font-medium">K</span>
18
19
  </div>
19
20
  </Button>
@@ -56,9 +56,6 @@ export async function CmsSidebar(props: React.ComponentProps<typeof Sidebar>) {
56
56
  const user = session?.user ?? null
57
57
  const groups = groupNavItems(cmsNavigation)
58
58
 
59
- console.log({ settings });
60
-
61
-
62
59
  return (
63
60
  <Sidebar collapsible="icon" {...props}>
64
61
  <SidebarHeader className="border-b border-border h-14 items-center flex w-full">
@@ -79,7 +76,7 @@ export async function CmsSidebar(props: React.ComponentProps<typeof Sidebar>) {
79
76
  </div>
80
77
  </SidebarHeader>
81
78
  <SidebarContent className='pb-2'>
82
- <SidebarGroup className="pb-1">
79
+ <SidebarGroup className="pb-0">
83
80
  <CmsSearch />
84
81
  </SidebarGroup>
85
82
 
@@ -141,12 +138,11 @@ export async function CmsSidebar(props: React.ComponentProps<typeof Sidebar>) {
141
138
  </div>
142
139
 
143
140
  <div className="group-data-[collapsible=icon]:hidden">
144
- <span className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'rounded-full group-hover:bg-white size-6!')}>
145
- <Ellipsis strokeWidth={2} />
141
+ <span className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'rounded-full group-hover:bg-white size-7')}>
142
+ <Ellipsis strokeWidth={2} className='size-4' />
146
143
  </span>
147
144
  </div>
148
145
  </Button>
149
-
150
146
  </DropdownMenuTrigger>
151
147
  <DropdownMenuContent align="start" className="w-68 p-0">
152
148
  <DropdownMenuGroup className='py-2 border-b border-border px-2'>
@@ -24,16 +24,16 @@ export const cmsNavigation: CmsNavigationItem[] = [
24
24
  href: '/cms/media',
25
25
  icon: ImagePlay,
26
26
  },
27
- {
28
- label: 'Categories',
29
- href: '/cms/categories',
30
- icon: Tag,
31
- group: 'Blog',
32
- },
33
27
  {
34
28
  label: 'Posts',
35
29
  href: '/cms/posts',
36
30
  icon: FileText,
37
31
  group: 'Blog',
38
32
  },
33
+ {
34
+ label: 'Categories',
35
+ href: '/cms/categories',
36
+ icon: Tag,
37
+ group: 'Blog',
38
+ },
39
39
  ]
@@ -6,6 +6,10 @@ import db from '@cms/db'
6
6
  import { user } from '@cms/db/schema'
7
7
  import { eq } from 'drizzle-orm'
8
8
 
9
+ export async function isEmailConfigured(): Promise<boolean> {
10
+ return !!process.env.BETTERSTART_RESEND_API_KEY
11
+ }
12
+
9
13
  export interface UpdateEmailResult {
10
14
  success: boolean
11
15
  error?: string
@@ -3,6 +3,7 @@ import * as schema from '@cms/db/schema'
3
3
  import { betterAuth } from 'better-auth'
4
4
  import { drizzleAdapter } from 'better-auth/adapters/drizzle'
5
5
  import { toNextJsHandler } from 'better-auth/next-js'
6
+ import { Resend } from 'resend'
6
7
 
7
8
  export { toNextJsHandler }
8
9
 
@@ -22,6 +23,22 @@ export const auth = betterAuth({
22
23
  emailAndPassword: {
23
24
  enabled: true,
24
25
  minPasswordLength: 8,
26
+ revokeSessionsOnPasswordReset: true,
27
+ sendResetPassword: async ({ user, url }: { user: { email: string; name: string }; url: string }) => {
28
+ const apiKey = process.env.BETTERSTART_RESEND_API_KEY
29
+ if (!apiKey) {
30
+ throw new Error(
31
+ 'Email is not configured. Set BETTERSTART_RESEND_API_KEY to enable password reset emails.'
32
+ )
33
+ }
34
+ const resend = new Resend(apiKey)
35
+ await resend.emails.send({
36
+ from: process.env.BETTERSTART_EMAIL_FROM || 'noreply@yourdomain.com',
37
+ to: user.email,
38
+ subject: 'Reset your password',
39
+ html: `<p>Hi ${user.name},</p><p>Click the link below to reset your password:</p><p><a href="${url}">Reset Password</a></p><p>This link will expire in 1 hour. If you didn't request this, you can safely ignore this email.</p>`,
40
+ })
41
+ },
25
42
  },
26
43
  session: {
27
44
  expiresIn: 60 * 60 * 24 * 7, // 7 days
@@ -0,0 +1,130 @@
1
+ 'use client'
2
+
3
+ import { isEmailConfigured } from '@cms/actions/profile'
4
+ import { authClient } from '@cms/auth/client'
5
+ import { Button } from '@cms/components/ui/button'
6
+ import { Card, CardContent } from '@cms/components/ui/card'
7
+ import { Field, FieldGroup, FieldLabel } from '@cms/components/ui/field'
8
+ import { Input } from '@cms/components/ui/input'
9
+ import { cn } from '@cms/utils/cn'
10
+ import { LoaderCircle } from 'lucide-react'
11
+ import Link from 'next/link'
12
+ import * as React from 'react'
13
+
14
+ export function ForgotPasswordForm({
15
+ className,
16
+ ...props
17
+ }: React.ComponentProps<'div'>) {
18
+ const [email, setEmail] = React.useState('')
19
+ const [error, setError] = React.useState<string | null>(null)
20
+ const [success, setSuccess] = React.useState(false)
21
+ const [isPending, startTransition] = React.useTransition()
22
+
23
+ const handleSubmit = (e: React.FormEvent) => {
24
+ e.preventDefault()
25
+ setError(null)
26
+
27
+ startTransition(async () => {
28
+ try {
29
+ const emailReady = await isEmailConfigured()
30
+ if (!emailReady) {
31
+ setError(
32
+ 'Email is not configured. Set BETTERSTART_RESEND_API_KEY to enable password reset emails.'
33
+ )
34
+ return
35
+ }
36
+ const { error } = await authClient.requestPasswordReset({
37
+ email,
38
+ redirectTo: '/cms/reset-password',
39
+ })
40
+ if (error) {
41
+ setError(error.message || 'Failed to send reset link')
42
+ return
43
+ }
44
+ setSuccess(true)
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
+ <div className="p-6 md:p-20">
63
+ {success ? (
64
+ <FieldGroup className="pb-12 gap-10">
65
+ <div className="flex flex-col items-start">
66
+ <h1 className="text-xl font-medium">Check your email</h1>
67
+ <p className="text-balance text-muted-foreground">
68
+ If an account exists for {email}, we sent a password reset
69
+ link. Check your inbox and spam folder.
70
+ </p>
71
+ </div>
72
+ <Link
73
+ href="/cms/login"
74
+ className="text-sm underline-offset-4 hover:underline"
75
+ >
76
+ Back to login
77
+ </Link>
78
+ </FieldGroup>
79
+ ) : (
80
+ <form onSubmit={handleSubmit}>
81
+ <FieldGroup className="pb-12 gap-10">
82
+ <div className="flex flex-col items-start">
83
+ <h1 className="text-xl font-medium">Forgot password</h1>
84
+ <p className="text-balance text-muted-foreground">
85
+ Enter your email and we&apos;ll send you a reset link
86
+ </p>
87
+ </div>
88
+ {error ? (
89
+ <div className="bg-destructive/10 text-destructive text-sm p-3 rounded-md">
90
+ {error}
91
+ </div>
92
+ ) : null}
93
+ <div className="flex flex-col gap-4">
94
+ <Field>
95
+ <FieldLabel htmlFor="email">Email</FieldLabel>
96
+ <Input
97
+ id="email"
98
+ type="email"
99
+ autoComplete="email"
100
+ placeholder="m@example.com"
101
+ value={email}
102
+ onChange={(e) => setEmail(e.target.value)}
103
+ required
104
+ disabled={isPending}
105
+ />
106
+ </Field>
107
+ <Field className="pt-4">
108
+ <Button type="submit" disabled={isPending}>
109
+ {isPending && (
110
+ <LoaderCircle className="animate-spin" />
111
+ )}
112
+ {isPending ? 'Sending...' : 'Send Reset Link'}
113
+ </Button>
114
+ </Field>
115
+ <Link
116
+ href="/cms/login"
117
+ className="text-sm text-center underline-offset-4 hover:underline"
118
+ >
119
+ Back to login
120
+ </Link>
121
+ </div>
122
+ </FieldGroup>
123
+ </form>
124
+ )}
125
+ </div>
126
+ </CardContent>
127
+ </Card>
128
+ </div>
129
+ )
130
+ }
@@ -0,0 +1,20 @@
1
+ import type { Metadata } from 'next'
2
+ import { Suspense } from 'react'
3
+ import { ForgotPasswordForm } from './forgot-password-form'
4
+
5
+ export const metadata: Metadata = {
6
+ title: 'Forgot Password',
7
+ robots: { index: false, follow: false },
8
+ }
9
+
10
+ export default function ForgotPasswordPage() {
11
+ return (
12
+ <div className="flex min-h-svh flex-col items-center justify-center bg-background p-6 md:p-10">
13
+ <div className="w-full max-w-sm md:max-w-4xl">
14
+ <Suspense>
15
+ <ForgotPasswordForm />
16
+ </Suspense>
17
+ </div>
18
+ </div>
19
+ )
20
+ }
@@ -11,7 +11,8 @@ import {
11
11
  import { Input } from '@cms/components/ui/input'
12
12
  import { cn } from '@cms/utils/cn'
13
13
  import { LoaderCircle } from 'lucide-react'
14
- import { useRouter } from 'next/navigation'
14
+ import Link from 'next/link'
15
+ import { useRouter, useSearchParams } from 'next/navigation'
15
16
  import * as React from 'react'
16
17
 
17
18
  export function LoginForm({
@@ -19,6 +20,8 @@ export function LoginForm({
19
20
  ...props
20
21
  }: React.ComponentProps<'div'>) {
21
22
  const router = useRouter()
23
+ const searchParams = useSearchParams()
24
+ const resetSuccess = searchParams.get('reset') === 'success'
22
25
  const [email, setEmail] = React.useState('')
23
26
  const [password, setPassword] = React.useState('')
24
27
  const [error, setError] = React.useState<string | null>(null)
@@ -67,6 +70,12 @@ export function LoginForm({
67
70
  Login to your account
68
71
  </p>
69
72
  </div>
73
+ {resetSuccess ? (
74
+ <div className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 text-sm p-3 rounded-md">
75
+ Password reset successfully. Please log in with your new
76
+ password.
77
+ </div>
78
+ ) : null}
70
79
  {error ? (
71
80
  <div className="bg-destructive/10 text-destructive text-sm p-3 rounded-md">
72
81
  {error}
@@ -89,6 +98,13 @@ export function LoginForm({
89
98
  <Field>
90
99
  <div className="flex items-center">
91
100
  <FieldLabel htmlFor="password">Password</FieldLabel>
101
+ <Link
102
+ href="/cms/forgot-password"
103
+ className="ml-auto text-sm underline-offset-4 hover:underline"
104
+ tabIndex={-1}
105
+ >
106
+ Forgot password?
107
+ </Link>
92
108
  </div>
93
109
  <Input
94
110
  id="password"
@@ -1,4 +1,7 @@
1
1
  import type { Metadata } from 'next'
2
+ import { redirect } from 'next/navigation'
3
+ import { Suspense } from 'react'
4
+ import { getSession } from '@cms/auth/middleware'
2
5
  import { LoginForm } from './login-form'
3
6
 
4
7
  export const metadata: Metadata = {
@@ -6,11 +9,18 @@ export const metadata: Metadata = {
6
9
  robots: { index: false, follow: false },
7
10
  }
8
11
 
9
- export default function LoginPage() {
12
+ export default async function LoginPage() {
13
+ const session = await getSession()
14
+ if (session?.user) {
15
+ redirect('/cms')
16
+ }
17
+
10
18
  return (
11
19
  <div className="flex min-h-svh flex-col items-center justify-center bg-background p-6 md:p-10">
12
20
  <div className="w-full max-w-sm md:max-w-4xl">
13
- <LoginForm />
21
+ <Suspense>
22
+ <LoginForm />
23
+ </Suspense>
14
24
  </div>
15
25
  </div>
16
26
  )
@@ -5,10 +5,11 @@ import { useForm } from 'react-hook-form'
5
5
  import { toast } from 'sonner'
6
6
  import { z } from 'zod/v3'
7
7
  import { authClient } from '@cms/auth/client'
8
- import { updateEmail } from '@cms/actions/profile'
8
+ import { isEmailConfigured, updateEmail } from '@cms/actions/profile'
9
9
  import { Avatar, AvatarFallback, AvatarImage } from '@cms/components/ui/avatar'
10
10
  import { Button } from '@cms/components/ui/button'
11
11
  import { Input } from '@cms/components/ui/input'
12
+ import { LoaderCircle } from 'lucide-react'
12
13
  import {
13
14
  Form,
14
15
  FormControl,
@@ -41,19 +42,6 @@ const profileSchema = z.object({
41
42
 
42
43
  type ProfileValues = z.infer<typeof profileSchema>
43
44
 
44
- const passwordSchema = z
45
- .object({
46
- currentPassword: z.string().min(1, 'Current password is required'),
47
- newPassword: z.string().min(8, 'Password must be at least 8 characters'),
48
- confirmPassword: z.string().min(1, 'Please confirm your new password'),
49
- })
50
- .refine((data) => data.newPassword === data.confirmPassword, {
51
- message: 'Passwords do not match',
52
- path: ['confirmPassword'],
53
- })
54
-
55
- type PasswordValues = z.infer<typeof passwordSchema>
56
-
57
45
  interface ProfileFormProps {
58
46
  user: {
59
47
  name: string
@@ -65,7 +53,7 @@ interface ProfileFormProps {
65
53
  export function ProfileForm({ user }: ProfileFormProps) {
66
54
  const router = useRouter()
67
55
  const [profilePending, setProfilePending] = useState(false)
68
- const [passwordPending, setPasswordPending] = useState(false)
56
+ const [resetPending, setResetPending] = useState(false)
69
57
 
70
58
  const profileForm = useForm<ProfileValues>({
71
59
  resolver: zodResolver(profileSchema),
@@ -77,15 +65,6 @@ export function ProfileForm({ user }: ProfileFormProps) {
77
65
  },
78
66
  })
79
67
 
80
- const passwordForm = useForm<PasswordValues>({
81
- resolver: zodResolver(passwordSchema),
82
- defaultValues: {
83
- currentPassword: '',
84
- newPassword: '',
85
- confirmPassword: '',
86
- },
87
- })
88
-
89
68
  const emailDirty = profileForm.watch('email') !== user.email
90
69
  const imageValue = profileForm.watch('image')
91
70
 
@@ -140,27 +119,29 @@ export function ProfileForm({ user }: ProfileFormProps) {
140
119
  }
141
120
  }
142
121
 
143
- async function onPasswordSubmit(values: PasswordValues) {
144
- setPasswordPending(true)
145
-
122
+ async function handleResetPassword() {
123
+ setResetPending(true)
146
124
  try {
147
- const { error } = await authClient.changePassword({
148
- currentPassword: values.currentPassword,
149
- newPassword: values.newPassword,
125
+ const emailReady = await isEmailConfigured()
126
+ if (!emailReady) {
127
+ toast.error(
128
+ 'Email is not configured. Set BETTERSTART_RESEND_API_KEY to enable password reset emails.'
129
+ )
130
+ return
131
+ }
132
+ const { error } = await authClient.requestPasswordReset({
133
+ email: user.email,
134
+ redirectTo: '/cms/reset-password',
150
135
  })
151
-
152
136
  if (error) {
153
- toast.error(error.message || 'Failed to change password')
154
- setPasswordPending(false)
137
+ toast.error(error.message || 'Failed to send reset link')
155
138
  return
156
139
  }
157
-
158
- toast.success('Password changed successfully')
159
- passwordForm.reset()
140
+ toast.success('Check your email for a password reset link')
160
141
  } catch {
161
- toast.error('An error occurred while changing your password')
142
+ toast.error('An error occurred while sending the reset link')
162
143
  } finally {
163
- setPasswordPending(false)
144
+ setResetPending(false)
164
145
  }
165
146
  }
166
147
 
@@ -186,6 +167,27 @@ export function ProfileForm({ user }: ProfileFormProps) {
186
167
  </div>
187
168
  </CardHeader>
188
169
  <CardContent className="space-y-6">
170
+ <FormField
171
+ control={profileForm.control}
172
+ name="image"
173
+ render={({ field: formField }) => (
174
+ <FormItem>
175
+ <FormLabel>Profile Picture</FormLabel>
176
+ <FormControl>
177
+ <ImageUploadField
178
+ value={formField.value}
179
+ onChange={formField.onChange}
180
+ onBlur={formField.onBlur}
181
+ disabled={profilePending}
182
+ maxSizeInMB={5}
183
+ label=""
184
+ />
185
+ </FormControl>
186
+ <FormMessage />
187
+ </FormItem>
188
+ )}
189
+ />
190
+
189
191
  <FormField
190
192
  control={profileForm.control}
191
193
  name="name"
@@ -245,30 +247,10 @@ export function ProfileForm({ user }: ProfileFormProps) {
245
247
  )}
246
248
  />
247
249
  )}
248
-
249
- <FormField
250
- control={profileForm.control}
251
- name="image"
252
- render={({ field: formField }) => (
253
- <FormItem>
254
- <FormLabel>Profile Picture</FormLabel>
255
- <FormControl>
256
- <ImageUploadField
257
- value={formField.value}
258
- onChange={formField.onChange}
259
- onBlur={formField.onBlur}
260
- disabled={profilePending}
261
- maxSizeInMB={5}
262
- label=""
263
- />
264
- </FormControl>
265
- <FormMessage />
266
- </FormItem>
267
- )}
268
- />
269
250
  </CardContent>
270
251
  <CardFooter>
271
252
  <Button type="submit" disabled={profilePending || !profileForm.formState.isDirty} size="sm" className="ml-auto min-w-25">
253
+ {profilePending && <LoaderCircle className="animate-spin" />}
272
254
  {profilePending ? 'Saving...' : 'Save'}
273
255
  </Button>
274
256
  </CardFooter>
@@ -276,84 +258,32 @@ export function ProfileForm({ user }: ProfileFormProps) {
276
258
  </form>
277
259
  </Form>
278
260
 
279
- <Form {...passwordForm}>
280
- <form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)}>
281
- <Card className="material-sm!">
282
- <CardHeader>
283
- <CardTitle>Change Password</CardTitle>
284
- <CardDescription>
285
- Update your password to keep your account secure
286
- </CardDescription>
287
- </CardHeader>
288
- <CardContent className="space-y-6">
289
- <FormField
290
- control={passwordForm.control}
291
- name="currentPassword"
292
- render={({ field: formField }) => (
293
- <FormItem>
294
- <FormLabel>Current Password</FormLabel>
295
- <FormControl>
296
- <Input
297
- type="password"
298
- placeholder="Enter current password"
299
- autoComplete="current-password"
300
- disabled={passwordPending}
301
- {...formField}
302
- />
303
- </FormControl>
304
- <FormMessage />
305
- </FormItem>
306
- )}
307
- />
308
-
309
- <FormField
310
- control={passwordForm.control}
311
- name="newPassword"
312
- render={({ field: formField }) => (
313
- <FormItem>
314
- <FormLabel>New Password</FormLabel>
315
- <FormControl>
316
- <Input
317
- type="password"
318
- placeholder="Enter new password"
319
- autoComplete="new-password"
320
- disabled={passwordPending}
321
- {...formField}
322
- />
323
- </FormControl>
324
- <FormMessage />
325
- </FormItem>
326
- )}
327
- />
328
-
329
- <FormField
330
- control={passwordForm.control}
331
- name="confirmPassword"
332
- render={({ field: formField }) => (
333
- <FormItem>
334
- <FormLabel>Confirm New Password</FormLabel>
335
- <FormControl>
336
- <Input
337
- type="password"
338
- placeholder="Confirm new password"
339
- autoComplete="new-password"
340
- disabled={passwordPending}
341
- {...formField}
342
- />
343
- </FormControl>
344
- <FormMessage />
345
- </FormItem>
346
- )}
347
- />
348
- </CardContent>
349
- <CardFooter>
350
- <Button type="submit" disabled={passwordPending || !passwordForm.formState.isDirty} size="sm" className="ml-auto min-w-25">
351
- {passwordPending ? 'Changing...' : 'Change Password'}
352
- </Button>
353
- </CardFooter>
354
- </Card>
355
- </form>
356
- </Form>
261
+ <Card className="material-sm!">
262
+ <CardHeader>
263
+ <CardTitle>Reset Password</CardTitle>
264
+ <CardDescription>
265
+ Send a password reset link to your email address
266
+ </CardDescription>
267
+ </CardHeader>
268
+ <CardContent>
269
+ <p className="text-sm text-muted-foreground">
270
+ A reset link will be sent to{' '}
271
+ <span className="font-medium text-foreground">{user.email}</span>
272
+ </p>
273
+ </CardContent>
274
+ <CardFooter>
275
+ <Button
276
+ type="button"
277
+ size="sm"
278
+ className="ml-auto min-w-25"
279
+ disabled={resetPending}
280
+ onClick={handleResetPassword}
281
+ >
282
+ {resetPending && <LoaderCircle className="animate-spin" />}
283
+ {resetPending ? 'Sending...' : 'Reset Password'}
284
+ </Button>
285
+ </CardFooter>
286
+ </Card>
357
287
  </div>
358
288
  )
359
289
  }
@@ -20,7 +20,7 @@ export default async function ProfilePage() {
20
20
  return (
21
21
  <>
22
22
  {showPageHeader && <PageHeader title="Profile" />}
23
- <main className="container mx-auto max-w-2xl p-6 pb-20">
23
+ <main className="container mx-auto max-w-5xl pt-10 pb-20 w-full">
24
24
  <ProfileForm
25
25
  user={{
26
26
  name: session.user.name,