betterstart-cli 0.0.108 → 0.0.109

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.
@@ -1,30 +1,53 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
+ import type { FieldErrors } from 'react-hook-form'
4
5
  import { authClient } from '@admin/auth/client'
5
6
  import { Button } from '@admin/components/ui/button'
6
7
  import { Card, CardContent } from '@admin/components/ui/card'
7
8
  import { Field, FieldGroup, FieldLabel } from '@admin/components/ui/field'
9
+ import { Form } from '@admin/components/ui/form'
8
10
  import { Input } from '@admin/components/ui/input'
9
11
  import { Spinner } from '@admin/components/ui/spinner'
10
12
  import { cn } from '@admin/utils/shared/cn'
13
+ import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'
11
14
  import Image from 'next/image'
12
15
  import Link from 'next/link'
13
16
  import { useRouter } from 'next/navigation'
14
17
  import { createSerializer, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
18
+ import { useForm } from 'react-hook-form'
19
+ import { toast } from 'sonner'
20
+ import { z } from 'zod/v3'
15
21
 
16
22
  const serializeLoginUrl = createSerializer({
17
23
  reset: parseAsStringLiteral(['success'])
18
24
  })
19
25
 
26
+ const resetPasswordSchema = z
27
+ .object({
28
+ newPassword: z.string().min(8, 'Password must be at least 8 characters'),
29
+ confirmPassword: z.string().min(1, 'Confirm the new password')
30
+ })
31
+ .refine((values) => values.newPassword === values.confirmPassword, {
32
+ message: 'Passwords do not match',
33
+ path: ['confirmPassword']
34
+ })
35
+
36
+ type ResetPasswordFormValues = z.infer<typeof resetPasswordSchema>
37
+
20
38
  export function ResetPasswordForm({ className, ...props }: React.ComponentProps<'div'>) {
21
39
  const router = useRouter()
22
40
  const [token] = useQueryState('token', parseAsString)
23
-
24
- const [newPassword, setNewPassword] = React.useState('')
25
- const [confirmPassword, setConfirmPassword] = React.useState('')
26
- const [error, setError] = React.useState<string | null>(null)
27
41
  const [isPending, startTransition] = React.useTransition()
42
+ const form = useForm<ResetPasswordFormValues>({
43
+ resolver: standardSchemaResolver(resetPasswordSchema),
44
+ defaultValues: {
45
+ newPassword: '',
46
+ confirmPassword: ''
47
+ },
48
+ mode: 'onChange',
49
+ reValidateMode: 'onChange'
50
+ })
28
51
 
29
52
  if (!token) {
30
53
  return (
@@ -62,38 +85,30 @@ export function ResetPasswordForm({ className, ...props }: React.ComponentProps<
62
85
  )
63
86
  }
64
87
 
65
- const handleSubmit = (e: React.FormEvent) => {
66
- e.preventDefault()
67
- setError(null)
68
-
69
- if (newPassword !== confirmPassword) {
70
- setError('Passwords do not match')
71
- return
72
- }
73
-
74
- if (newPassword.length < 8) {
75
- setError('Password must be at least 8 characters')
76
- return
77
- }
78
-
88
+ const handleSubmit = (values: ResetPasswordFormValues) => {
79
89
  startTransition(async () => {
80
90
  try {
81
91
  const { error } = await authClient.resetPassword({
82
- newPassword,
92
+ newPassword: values.newPassword,
83
93
  token
84
94
  })
85
95
  if (error) {
86
- setError(error.message || 'Failed to reset password')
96
+ toast.error(error.message || 'Failed to reset password')
87
97
  return
88
98
  }
89
99
  await authClient.getSession({ query: { disableCookieCache: true } })
90
100
  router.push(serializeLoginUrl('/admin/login', { reset: 'success' }))
91
101
  } catch {
92
- setError('Failed to reset your password. Try again.')
102
+ toast.error('Failed to reset your password. Try again.')
93
103
  }
94
104
  })
95
105
  }
96
106
 
107
+ const handleInvalid = (errors: FieldErrors<ResetPasswordFormValues>) => {
108
+ const message = errors.newPassword?.message ?? errors.confirmPassword?.message
109
+ toast.error(typeof message === 'string' ? message : 'Please check your new password.')
110
+ }
111
+
97
112
  return (
98
113
  <div className={cn('flex flex-col gap-6', className)} {...props}>
99
114
  <Card className="overflow-hidden p-0">
@@ -107,53 +122,57 @@ export function ResetPasswordForm({ className, ...props }: React.ComponentProps<
107
122
  className="object-cover"
108
123
  />
109
124
  </div>
110
- <form onSubmit={handleSubmit} className="p-6 md:p-20">
111
- <FieldGroup className="gap-10 pb-12">
112
- <div className="flex flex-col items-start">
113
- <h1 className="text-xl font-medium">Reset password</h1>
114
- <p className="text-balance text-muted-foreground">Enter your new password below</p>
115
- </div>
116
- {error ? (
117
- <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
118
- {error}
125
+ <Form {...form}>
126
+ <form
127
+ autoComplete="off"
128
+ noValidate
129
+ onSubmit={form.handleSubmit(handleSubmit, handleInvalid)}
130
+ className="p-6 md:p-20"
131
+ >
132
+ <FieldGroup className="gap-10 pb-12">
133
+ <div className="flex flex-col items-start">
134
+ <h1 className="text-xl font-medium">Reset password</h1>
135
+ <p className="text-balance text-muted-foreground">
136
+ Enter your new password below
137
+ </p>
138
+ </div>
139
+ <div className="flex flex-col gap-4">
140
+ <Field>
141
+ <FieldLabel htmlFor="newPassword">New Password</FieldLabel>
142
+ <Input
143
+ id="newPassword"
144
+ type="password"
145
+ autoComplete="off"
146
+ placeholder="Enter new password"
147
+ aria-invalid={Boolean(form.formState.errors.newPassword)}
148
+ {...form.register('newPassword')}
149
+ required
150
+ disabled={isPending}
151
+ />
152
+ </Field>
153
+ <Field>
154
+ <FieldLabel htmlFor="confirmPassword">Confirm Password</FieldLabel>
155
+ <Input
156
+ id="confirmPassword"
157
+ type="password"
158
+ autoComplete="off"
159
+ placeholder="Confirm new password"
160
+ aria-invalid={Boolean(form.formState.errors.confirmPassword)}
161
+ {...form.register('confirmPassword')}
162
+ required
163
+ disabled={isPending}
164
+ />
165
+ </Field>
166
+ <Field className="pt-4">
167
+ <Button type="submit" disabled={isPending} aria-busy={isPending}>
168
+ {isPending && <Spinner />}
169
+ {isPending ? 'Resetting…' : 'Reset Password'}
170
+ </Button>
171
+ </Field>
119
172
  </div>
120
- ) : null}
121
- <div className="flex flex-col gap-4">
122
- <Field>
123
- <FieldLabel htmlFor="newPassword">New Password</FieldLabel>
124
- <Input
125
- id="newPassword"
126
- type="password"
127
- autoComplete="new-password"
128
- placeholder="Enter new password"
129
- value={newPassword}
130
- onChange={(e) => setNewPassword(e.target.value)}
131
- required
132
- disabled={isPending}
133
- />
134
- </Field>
135
- <Field>
136
- <FieldLabel htmlFor="confirmPassword">Confirm Password</FieldLabel>
137
- <Input
138
- id="confirmPassword"
139
- type="password"
140
- autoComplete="new-password"
141
- placeholder="Confirm new password"
142
- value={confirmPassword}
143
- onChange={(e) => setConfirmPassword(e.target.value)}
144
- required
145
- disabled={isPending}
146
- />
147
- </Field>
148
- <Field className="pt-4">
149
- <Button type="submit" disabled={isPending} aria-busy={isPending}>
150
- {isPending && <Spinner />}
151
- {isPending ? 'Resetting…' : 'Reset Password'}
152
- </Button>
153
- </Field>
154
- </div>
155
- </FieldGroup>
156
- </form>
173
+ </FieldGroup>
174
+ </form>
175
+ </Form>
157
176
  </CardContent>
158
177
  </Card>
159
178
  </div>
@@ -86,6 +86,7 @@ export function FormNotificationsDrawer({
86
86
  <DrawerContent className="h-full sm:max-w-xl">
87
87
  <Form {...form}>
88
88
  <form
89
+ autoComplete="off"
89
90
  onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
90
91
  className="flex min-h-0 flex-1 flex-col gap-4"
91
92
  >
@@ -67,8 +67,6 @@ export function WebhookEndpointDialog({ webhook, open, onOpenChange }: WebhookEn
67
67
  : [],
68
68
  [subscriptions, webhook]
69
69
  )
70
- const [selectedEvents, setSelectedEvents] = React.useState<string[] | null>(null)
71
- const events = selectedEvents ?? subscribedEvents
72
70
  const [secret, setSecret] = React.useState<string | null>(null)
73
71
 
74
72
  const formSources = webhookEventSources.filter((source) => source.kind === 'form')
@@ -83,6 +81,19 @@ export function WebhookEndpointDialog({ webhook, open, onOpenChange }: WebhookEn
83
81
  events: subscribedEvents
84
82
  }
85
83
  })
84
+ const events = form.watch('events')
85
+
86
+ React.useEffect(() => {
87
+ if (!open) return
88
+
89
+ form.reset({
90
+ name: webhook?.name ?? '',
91
+ url: webhook?.url ?? '',
92
+ enabled: webhook?.enabled ?? true,
93
+ events: subscribedEvents
94
+ })
95
+ setSecret(null)
96
+ }, [form, open, subscribedEvents, webhook])
86
97
 
87
98
  const saveMutation = useMutation({
88
99
  mutationFn: async (values: EndpointFormValues) => {
@@ -92,8 +103,8 @@ export function WebhookEndpointDialog({ webhook, open, onOpenChange }: WebhookEn
92
103
  enabled: values.enabled
93
104
  }
94
105
  const result = webhook
95
- ? await updateWebhook(webhook.id, { ...endpointValues, events })
96
- : await createWebhook({ ...endpointValues, events })
106
+ ? await updateWebhook(webhook.id, { ...endpointValues, events: values.events })
107
+ : await createWebhook({ ...endpointValues, events: values.events })
97
108
  if (!result.success) {
98
109
  throw new Error(result.error || 'Failed to save this webhook')
99
110
  }
@@ -172,16 +183,23 @@ export function WebhookEndpointDialog({ webhook, open, onOpenChange }: WebhookEn
172
183
  const isPending = saveMutation.isPending
173
184
 
174
185
  function toggleEvent(event: string, checked: boolean) {
175
- const nextEvents = checked ? [...events, event] : events.filter((entry) => entry !== event)
176
- setSelectedEvents(nextEvents)
177
- form.setValue('events', nextEvents)
186
+ const currentEvents = form.getValues('events')
187
+ const nextEvents = checked
188
+ ? currentEvents.includes(event)
189
+ ? currentEvents
190
+ : [...currentEvents, event]
191
+ : currentEvents.filter((entry) => entry !== event)
192
+ form.setValue('events', nextEvents, { shouldDirty: true, shouldValidate: true })
178
193
  }
179
194
 
180
195
  return (
181
196
  <Dialog open={open} onOpenChange={onOpenChange}>
182
197
  <DialogContent className="sm:max-w-135">
183
198
  <Form {...form}>
184
- <form onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))}>
199
+ <form
200
+ autoComplete="off"
201
+ onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))}
202
+ >
185
203
  <DialogHeader>
186
204
  <DialogTitle>{isEdit ? `Edit webhook — ${webhook.name}` : 'Add webhook'}</DialogTitle>
187
205
  <DialogDescription>
@@ -82,7 +82,7 @@ export function ChangePasswordDialog({
82
82
  const form = useForm<ChangePasswordFormValues>({
83
83
  resolver: standardSchemaResolver(isCurrentUser ? ownPasswordSchema : userPasswordSchema),
84
84
  defaultValues,
85
- mode: 'onBlur',
85
+ mode: 'onChange',
86
86
  reValidateMode: 'onChange'
87
87
  })
88
88
 
@@ -144,7 +144,10 @@ export function ChangePasswordDialog({
144
144
  </DialogTrigger>
145
145
  <DialogContent className="sm:max-w-135">
146
146
  <Form {...form}>
147
- <form onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
147
+ <form
148
+ autoComplete="off"
149
+ onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
150
+ >
148
151
  <DialogHeader>
149
152
  <DialogTitle>Change Password</DialogTitle>
150
153
  <DialogDescription>
@@ -166,7 +169,7 @@ export function ChangePasswordDialog({
166
169
  <FormControl>
167
170
  <Input
168
171
  type="password"
169
- autoComplete="current-password"
172
+ autoComplete="off"
170
173
  disabled={isPending}
171
174
  autoFocus
172
175
  {...field}
@@ -186,7 +189,7 @@ export function ChangePasswordDialog({
186
189
  <FormControl>
187
190
  <Input
188
191
  type="password"
189
- autoComplete="new-password"
192
+ autoComplete="off"
190
193
  disabled={isPending}
191
194
  autoFocus={!isCurrentUser}
192
195
  {...field}
@@ -204,12 +207,7 @@ export function ChangePasswordDialog({
204
207
  <FormItem>
205
208
  <FormLabel>Confirm Password</FormLabel>
206
209
  <FormControl>
207
- <Input
208
- type="password"
209
- autoComplete="new-password"
210
- disabled={isPending}
211
- {...field}
212
- />
210
+ <Input type="password" autoComplete="off" disabled={isPending} {...field} />
213
211
  </FormControl>
214
212
  <FormMessage />
215
213
  </FormItem>
@@ -67,7 +67,7 @@ export function CreateUserDialog() {
67
67
  const form = useForm<CreateUserFormValues>({
68
68
  resolver: standardSchemaResolver(createUserSchema),
69
69
  defaultValues,
70
- mode: 'onBlur',
70
+ mode: 'onChange',
71
71
  reValidateMode: 'onChange'
72
72
  })
73
73
 
@@ -111,7 +111,10 @@ export function CreateUserDialog() {
111
111
  <DialogTrigger render={<Button />}>Create User</DialogTrigger>
112
112
  <DialogContent className="sm:max-w-135">
113
113
  <Form {...form}>
114
- <form onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
114
+ <form
115
+ autoComplete="off"
116
+ onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
117
+ >
115
118
  <DialogHeader>
116
119
  <DialogTitle>Create User</DialogTitle>
117
120
  <DialogDescription>Add a new user to the system</DialogDescription>
@@ -129,7 +132,7 @@ export function CreateUserDialog() {
129
132
  <Input
130
133
  type="email"
131
134
  placeholder="user@example.com"
132
- autoComplete="email"
135
+ autoComplete="off"
133
136
  disabled={isPending}
134
137
  {...field}
135
138
  />
@@ -148,7 +151,7 @@ export function CreateUserDialog() {
148
151
  <Input
149
152
  type="text"
150
153
  placeholder="Full name"
151
- autoComplete="name"
154
+ autoComplete="off"
152
155
  disabled={isPending}
153
156
  {...field}
154
157
  />
@@ -167,7 +170,7 @@ export function CreateUserDialog() {
167
170
  <Input
168
171
  type="password"
169
172
  placeholder="Min 8 characters"
170
- autoComplete="new-password"
173
+ autoComplete="off"
171
174
  disabled={isPending}
172
175
  {...field}
173
176
  />
@@ -118,7 +118,7 @@ export function DeleteUserDialog({ userId, userName, isCurrentUser }: DeleteUser
118
118
  </AlertDialogTrigger>
119
119
  <AlertDialogContent>
120
120
  <Form {...form}>
121
- <form onSubmit={form.handleSubmit(() => mutation.mutate())}>
121
+ <form autoComplete="off" onSubmit={form.handleSubmit(() => mutation.mutate())}>
122
122
  <AlertDialogHeader>
123
123
  <AlertDialogTitle>Delete account?</AlertDialogTitle>
124
124
  <AlertDialogDescription>
@@ -12,6 +12,7 @@ import {
12
12
  DialogTitle,
13
13
  DialogTrigger
14
14
  } from '@admin/components/ui/dialog'
15
+ import { Form, FormControl, FormField, FormItem, FormMessage } from '@admin/components/ui/form'
15
16
  import {
16
17
  Select,
17
18
  SelectContent,
@@ -22,6 +23,7 @@ import {
22
23
  import { Spinner } from '@admin/components/ui/spinner'
23
24
  import { UserRole } from '@admin/types/auth'
24
25
  import { useQueryClient } from '@tanstack/react-query'
26
+ import { useForm } from 'react-hook-form'
25
27
  import { toast } from 'sonner'
26
28
 
27
29
  interface EditRoleDialogProps {
@@ -31,19 +33,32 @@ interface EditRoleDialogProps {
31
33
  children: React.ReactNode
32
34
  }
33
35
 
36
+ interface EditRoleFormValues {
37
+ role: UserRole
38
+ }
39
+
34
40
  export function EditRoleDialog({ userId, currentRole, userName, children }: EditRoleDialogProps) {
35
41
  const [open, setOpen] = React.useState(false)
36
- const [role, setRole] = React.useState(currentRole)
37
42
  const [isPending, startTransition] = React.useTransition()
38
43
  const queryClient = useQueryClient()
44
+ const form = useForm<EditRoleFormValues>({
45
+ defaultValues: {
46
+ role: currentRole as UserRole
47
+ }
48
+ })
49
+
50
+ React.useEffect(() => {
51
+ form.reset({ role: currentRole as UserRole })
52
+ }, [currentRole, form])
39
53
 
40
- const handleSave = () => {
54
+ const handleSave = (values: EditRoleFormValues) => {
41
55
  startTransition(async () => {
42
56
  try {
43
- const result = await updateUserRole(userId, role as UserRole)
57
+ const result = await updateUserRole(userId, values.role)
44
58
  if (result.success) {
45
59
  toast.success(`Role updated for ${userName}`)
46
60
  queryClient.refetchQueries({ queryKey: ['users'] })
61
+ form.reset(values)
47
62
  setOpen(false)
48
63
  } else {
49
64
  toast.error(result.error || 'Failed to update role')
@@ -54,41 +69,69 @@ export function EditRoleDialog({ userId, currentRole, userName, children }: Edit
54
69
  })
55
70
  }
56
71
 
72
+ function handleOpenChange(nextOpen: boolean) {
73
+ setOpen(nextOpen)
74
+
75
+ if (!nextOpen) {
76
+ form.reset({ role: currentRole as UserRole })
77
+ }
78
+ }
79
+
57
80
  return (
58
- <Dialog open={open} onOpenChange={setOpen}>
81
+ <Dialog open={open} onOpenChange={handleOpenChange}>
59
82
  <DialogTrigger render={children as React.ReactElement} />
60
83
  <DialogContent className="sm:max-w-87.5">
61
- <DialogHeader>
62
- <DialogTitle>Edit Role</DialogTitle>
63
- <DialogDescription>Change the role for {userName}</DialogDescription>
64
- </DialogHeader>
84
+ <Form {...form}>
85
+ <form autoComplete="off" onSubmit={form.handleSubmit(handleSave)}>
86
+ <DialogHeader>
87
+ <DialogTitle>Edit Role</DialogTitle>
88
+ <DialogDescription>Change the role for {userName}</DialogDescription>
89
+ </DialogHeader>
65
90
 
66
- <div className="p-2">
67
- <Select value={role} onValueChange={setRole}>
68
- <SelectTrigger className="w-full">
69
- <SelectValue />
70
- </SelectTrigger>
71
- <SelectContent>
72
- <SelectItem value={UserRole.ADMIN}>Admin</SelectItem>
73
- <SelectItem value={UserRole.EDITOR}>Editor</SelectItem>
74
- <SelectItem value={UserRole.MEMBER}>Member</SelectItem>
75
- </SelectContent>
76
- </Select>
77
- </div>
91
+ <div className="p-2">
92
+ <FormField
93
+ control={form.control}
94
+ name="role"
95
+ render={({ field }) => (
96
+ <FormItem>
97
+ <Select value={field.value} onValueChange={field.onChange} disabled={isPending}>
98
+ <FormControl>
99
+ <SelectTrigger className="w-full">
100
+ <SelectValue />
101
+ </SelectTrigger>
102
+ </FormControl>
103
+ <SelectContent>
104
+ <SelectItem value={UserRole.ADMIN}>Admin</SelectItem>
105
+ <SelectItem value={UserRole.EDITOR}>Editor</SelectItem>
106
+ <SelectItem value={UserRole.MEMBER}>Member</SelectItem>
107
+ </SelectContent>
108
+ </Select>
109
+ <FormMessage />
110
+ </FormItem>
111
+ )}
112
+ />
113
+ </div>
78
114
 
79
- <DialogFooter>
80
- <Button variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
81
- Cancel
82
- </Button>
83
- <Button
84
- onClick={handleSave}
85
- disabled={isPending || role === currentRole}
86
- aria-busy={isPending}
87
- >
88
- {isPending && <Spinner />}
89
- {isPending ? 'Saving Role…' : 'Save Role'}
90
- </Button>
91
- </DialogFooter>
115
+ <DialogFooter>
116
+ <Button
117
+ type="button"
118
+ variant="outline"
119
+ onClick={() => setOpen(false)}
120
+ disabled={isPending}
121
+ >
122
+ Cancel
123
+ </Button>
124
+ <Button
125
+ type="submit"
126
+ disabled={isPending || !form.formState.isDirty}
127
+ aria-busy={isPending}
128
+ >
129
+ {isPending && <Spinner />}
130
+ {isPending ? 'Saving Role…' : 'Save Role'}
131
+ </Button>
132
+ </DialogFooter>
133
+ </form>
134
+ </Form>
92
135
  </DialogContent>
93
136
  </Dialog>
94
137
  )
@@ -59,6 +59,12 @@ function InputGroupAddon({
59
59
  }
60
60
  e.currentTarget.parentElement?.querySelector('input')?.focus()
61
61
  }}
62
+ onKeyDown={(e) => {
63
+ if (e.key === 'Enter' || e.key === ' ') {
64
+ e.preventDefault()
65
+ e.currentTarget.parentElement?.querySelector('input')?.focus()
66
+ }
67
+ }}
62
68
  {...props}
63
69
  />
64
70
  )