@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,78 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+
5
+ type Theme = 'light' | 'dark' | 'system'
6
+
7
+ interface ThemeContext {
8
+ theme: Theme
9
+ setTheme: (theme: Theme) => void
10
+ resolvedTheme: 'light' | 'dark'
11
+ }
12
+
13
+ const CmsThemeContext = React.createContext<ThemeContext | undefined>(undefined)
14
+
15
+ const STORAGE_KEY = 'cms-theme'
16
+
17
+ function getSystemTheme(): 'light' | 'dark' {
18
+ if (typeof window === 'undefined') return 'light'
19
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
20
+ }
21
+
22
+ export function CmsThemeProvider({ children }: { children: React.ReactNode }) {
23
+ const [theme, setThemeState] = React.useState<Theme>('system')
24
+ const [resolved, setResolved] = React.useState<'light' | 'dark'>('light')
25
+
26
+ // Read from localStorage on mount
27
+ React.useEffect(() => {
28
+ const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
29
+ if (stored && ['light', 'dark', 'system'].includes(stored)) {
30
+ setThemeState(stored)
31
+ }
32
+ }, [])
33
+
34
+ // Resolve theme and apply .dark class to .cms-root
35
+ React.useEffect(() => {
36
+ const actual = theme === 'system' ? getSystemTheme() : theme
37
+ setResolved(actual)
38
+
39
+ const root = document.querySelector('.cms-root')
40
+ if (root) {
41
+ root.classList.toggle('dark', actual === 'dark')
42
+ }
43
+ }, [theme])
44
+
45
+ // Listen for system theme changes
46
+ React.useEffect(() => {
47
+ if (theme !== 'system') return
48
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
49
+ const handler = () => {
50
+ const actual = getSystemTheme()
51
+ setResolved(actual)
52
+ const root = document.querySelector('.cms-root')
53
+ if (root) {
54
+ root.classList.toggle('dark', actual === 'dark')
55
+ }
56
+ }
57
+ mq.addEventListener('change', handler)
58
+ return () => mq.removeEventListener('change', handler)
59
+ }, [theme])
60
+
61
+ const setTheme = React.useCallback((t: Theme) => {
62
+ setThemeState(t)
63
+ localStorage.setItem(STORAGE_KEY, t)
64
+ }, [])
65
+
66
+ const value = React.useMemo(
67
+ () => ({ theme, setTheme, resolvedTheme: resolved }),
68
+ [theme, setTheme, resolved],
69
+ )
70
+
71
+ return <CmsThemeContext.Provider value={value}>{children}</CmsThemeContext.Provider>
72
+ }
73
+
74
+ export function useTheme(): ThemeContext {
75
+ const ctx = React.useContext(CmsThemeContext)
76
+ if (!ctx) throw new Error('useTheme must be used within CmsThemeProvider')
77
+ return ctx
78
+ }
@@ -0,0 +1,82 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useUpload } from './use-upload'
5
+
6
+ export interface EditorImageUploadResult {
7
+ url: string
8
+ filename: string
9
+ }
10
+
11
+ export interface UseEditorImageUploadOptions {
12
+ onImagesUploaded: (images: EditorImageUploadResult[]) => void
13
+ }
14
+
15
+ export function useEditorImageUpload({ onImagesUploaded }: UseEditorImageUploadOptions) {
16
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
17
+ const onImagesUploadedRef = React.useRef(onImagesUploaded)
18
+ onImagesUploadedRef.current = onImagesUploaded
19
+
20
+ const { upload, progress, mutation } = useUpload({
21
+ accept: 'image/*',
22
+ maxSizeInMB: 10,
23
+ prefix: 'images',
24
+ onSuccess: (result) => {
25
+ if (result.success && result.files) {
26
+ const images: EditorImageUploadResult[] = result.files.map((f) => ({
27
+ url: f.url,
28
+ filename: f.filename,
29
+ }))
30
+ onImagesUploadedRef.current(images)
31
+ }
32
+ },
33
+ })
34
+
35
+ const isUploading = mutation.isPending
36
+
37
+ const uploadImages = React.useCallback(
38
+ (files: File[]) => {
39
+ const imageFiles = files.filter((f) => f.type.startsWith('image/'))
40
+ if (imageFiles.length === 0) return
41
+ upload(imageFiles, 'images')
42
+ },
43
+ [upload],
44
+ )
45
+
46
+ const handleDrop = React.useCallback(
47
+ (e: React.DragEvent) => {
48
+ e.preventDefault()
49
+ e.stopPropagation()
50
+ const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'))
51
+ if (files.length > 0) {
52
+ uploadImages(files)
53
+ }
54
+ },
55
+ [uploadImages],
56
+ )
57
+
58
+ const openFilePicker = React.useCallback(() => {
59
+ fileInputRef.current?.click()
60
+ }, [])
61
+
62
+ const handleFileInputChange = React.useCallback(
63
+ (e: React.ChangeEvent<HTMLInputElement>) => {
64
+ const files = Array.from(e.target.files || [])
65
+ if (files.length > 0) {
66
+ uploadImages(files)
67
+ }
68
+ e.target.value = ''
69
+ },
70
+ [uploadImages],
71
+ )
72
+
73
+ return {
74
+ uploadImages,
75
+ isUploading,
76
+ progress,
77
+ handleDrop,
78
+ openFilePicker,
79
+ fileInputRef,
80
+ handleFileInputChange,
81
+ }
82
+ }
@@ -0,0 +1,46 @@
1
+ import * as React from 'react'
2
+
3
+ const CMS_STORAGE_PREFIX = 'cms-'
4
+
5
+ /** Type-safe wrapper for localStorage operations with cms- prefix. */
6
+ export function useLocalStorage<T>(key: string) {
7
+ const prefixedKey = CMS_STORAGE_PREFIX + key
8
+
9
+ const setItem = React.useCallback(
10
+ (value: T) => {
11
+ try {
12
+ localStorage.setItem(prefixedKey, JSON.stringify(value))
13
+ } catch {
14
+ // Silent failure for localStorage access errors
15
+ }
16
+ },
17
+ [prefixedKey],
18
+ )
19
+
20
+ const getItem = React.useCallback((): T | null => {
21
+ try {
22
+ const stored = localStorage.getItem(prefixedKey)
23
+ return stored ? JSON.parse(stored) : null
24
+ } catch {
25
+ return null
26
+ }
27
+ }, [prefixedKey])
28
+
29
+ const removeItem = React.useCallback(() => {
30
+ try {
31
+ localStorage.removeItem(prefixedKey)
32
+ } catch {
33
+ // Silent failure for localStorage access errors
34
+ }
35
+ }, [prefixedKey])
36
+
37
+ const hasItem = React.useCallback((): boolean => {
38
+ try {
39
+ return localStorage.getItem(prefixedKey) !== null
40
+ } catch {
41
+ return false
42
+ }
43
+ }, [prefixedKey])
44
+
45
+ return { setItem, getItem, removeItem, hasItem }
46
+ }
@@ -0,0 +1,19 @@
1
+ import * as React from 'react'
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
7
+
8
+ React.useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12
+ }
13
+ mql.addEventListener('change', onChange)
14
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15
+ return () => mql.removeEventListener('change', onChange)
16
+ }, [])
17
+
18
+ return !!isMobile
19
+ }
@@ -0,0 +1,177 @@
1
+ 'use client'
2
+
3
+ import type { UploadFileResult, UploadProgress } from '@cms/types'
4
+ import { type FileValidationConfig, validateFiles } from '@cms/utils/validation'
5
+ import { type UseMutationResult, useMutation } from '@tanstack/react-query'
6
+ import * as React from 'react'
7
+
8
+ export interface UseUploadOptions {
9
+ /** File validation configuration */
10
+ validationConfig?: FileValidationConfig
11
+ /** Accept string (e.g., "image/*") */
12
+ accept?: string
13
+ /** Maximum file size in MB */
14
+ maxSizeInMB?: number
15
+ /** Callback fired when upload progress changes */
16
+ onProgress?: (progress: UploadProgress[]) => void
17
+ /** Callback fired when upload succeeds */
18
+ onSuccess?: (result: UploadFileResult) => void
19
+ /** Callback fired when upload fails */
20
+ onError?: (error: Error) => void
21
+ /** Optional prefix for file paths in R2 */
22
+ prefix?: string
23
+ }
24
+
25
+ export interface UploadMutationVariables {
26
+ files: File[]
27
+ prefix?: string
28
+ }
29
+
30
+ export interface UseUploadReturn {
31
+ mutation: UseMutationResult<UploadFileResult, Error, UploadMutationVariables, unknown>
32
+ progress: UploadProgress[]
33
+ upload: (files: File[], prefix?: string) => void
34
+ validate: (files: File[]) => { valid: boolean; errors: string[] }
35
+ }
36
+
37
+ function parseAcceptTypes(accept: string): string[] {
38
+ return accept
39
+ .split(',')
40
+ .map((t) => t.trim())
41
+ .filter(Boolean)
42
+ }
43
+
44
+ export function useUpload(options: UseUploadOptions = {}): UseUploadReturn {
45
+ const {
46
+ validationConfig: providedConfig,
47
+ accept,
48
+ maxSizeInMB,
49
+ onProgress,
50
+ onSuccess,
51
+ onError,
52
+ prefix: defaultPrefix,
53
+ } = options
54
+
55
+ const validationConfig = React.useMemo<FileValidationConfig>(() => {
56
+ const config: FileValidationConfig = { ...providedConfig }
57
+
58
+ if (accept) {
59
+ const parsedTypes = parseAcceptTypes(accept)
60
+ if (config.allowedTypes && config.allowedTypes.length > 0) {
61
+ config.allowedTypes = [...config.allowedTypes, ...parsedTypes].filter(
62
+ (v, i, a) => a.indexOf(v) === i,
63
+ )
64
+ } else {
65
+ config.allowedTypes = parsedTypes
66
+ }
67
+ }
68
+
69
+ if (maxSizeInMB !== undefined) {
70
+ config.maxSizeInBytes = maxSizeInMB * 1024 * 1024
71
+ }
72
+
73
+ return config
74
+ }, [providedConfig, accept, maxSizeInMB])
75
+
76
+ const [progress, setProgress] = React.useState<UploadProgress[]>([])
77
+ const [_isPending, startTransition] = React.useTransition()
78
+
79
+ const mutation = useMutation<UploadFileResult, Error, UploadMutationVariables>({
80
+ mutationFn: async ({ files, prefix = defaultPrefix }) => {
81
+ const initialProgress: UploadProgress[] = files.map((file) => ({
82
+ filename: file.name,
83
+ progress: 0,
84
+ loaded: 0,
85
+ total: file.size,
86
+ }))
87
+ setProgress(initialProgress)
88
+ onProgress?.(initialProgress)
89
+
90
+ const formData = new FormData()
91
+ if (prefix) {
92
+ formData.append('prefix', prefix)
93
+ }
94
+
95
+ files.forEach((file, index) => {
96
+ formData.append(`file${index}`, file)
97
+ })
98
+
99
+ // Simulate progress since server doesn't support streaming
100
+ const progressInterval = setInterval(() => {
101
+ setProgress((prev) =>
102
+ prev.map((p) => {
103
+ const newProgress = Math.min(p.progress + 10, 90)
104
+ return {
105
+ ...p,
106
+ progress: newProgress,
107
+ loaded: Math.floor((p.total * newProgress) / 100),
108
+ }
109
+ }),
110
+ )
111
+ }, 200)
112
+
113
+ try {
114
+ if (validationConfig?.maxSizeInBytes) {
115
+ formData.append('maxSizeInBytes', validationConfig.maxSizeInBytes.toString())
116
+ }
117
+ if (validationConfig?.allowedTypes?.length) {
118
+ formData.append('allowedTypes', validationConfig.allowedTypes.join(','))
119
+ }
120
+
121
+ const response = await fetch('/api/cms/upload', {
122
+ method: 'POST',
123
+ body: formData,
124
+ })
125
+
126
+ const result = (await response.json()) as UploadFileResult
127
+
128
+ if (result.success) {
129
+ const completeProgress: UploadProgress[] = files.map((file) => ({
130
+ filename: file.name,
131
+ progress: 100,
132
+ loaded: file.size,
133
+ total: file.size,
134
+ }))
135
+ setProgress(completeProgress)
136
+ onProgress?.(completeProgress)
137
+ }
138
+
139
+ return result
140
+ } finally {
141
+ clearInterval(progressInterval)
142
+ }
143
+ },
144
+ onSuccess: (result) => {
145
+ onSuccess?.(result)
146
+ startTransition(() => {
147
+ setTimeout(() => {
148
+ setProgress([])
149
+ }, 2000)
150
+ })
151
+ },
152
+ onError: (error) => {
153
+ onError?.(error)
154
+ setProgress([])
155
+ },
156
+ })
157
+
158
+ const upload = React.useCallback(
159
+ (files: File[], prefix?: string) => {
160
+ mutation.mutate({ files, prefix })
161
+ },
162
+ [mutation],
163
+ )
164
+
165
+ const validate = React.useCallback(
166
+ (files: File[]) => {
167
+ const result = validateFiles(files, validationConfig)
168
+ return {
169
+ valid: result.valid,
170
+ errors: result.errors.map((e) => `${e.filename}: ${e.error}`),
171
+ }
172
+ },
173
+ [validationConfig],
174
+ )
175
+
176
+ return { mutation, progress, upload, validate }
177
+ }
@@ -0,0 +1,13 @@
1
+ 'use client'
2
+
3
+ import { getUsers } from '@cms/actions/users'
4
+ import type { UsersResponse } from '@cms/types/auth'
5
+ import { useQuery } from '@tanstack/react-query'
6
+
7
+ export function useUsers() {
8
+ return useQuery<UsersResponse>({
9
+ queryKey: ['users'],
10
+ queryFn: () => getUsers(),
11
+ staleTime: 0,
12
+ })
13
+ }
@@ -0,0 +1,126 @@
1
+ 'use server'
2
+
3
+ import db from '@cms/db'
4
+ import { formSettings } from '@cms/db/schema'
5
+ import { eq } from 'drizzle-orm'
6
+
7
+ export interface FormSettingsData {
8
+ id: number
9
+ formName: string
10
+ webhookUrl: string | null
11
+ webhookEnabled: boolean
12
+ notificationEmails: string | null
13
+ createdAt: string
14
+ updatedAt: string
15
+ }
16
+
17
+ export interface UpsertFormSettingsInput {
18
+ webhookUrl?: string | null
19
+ webhookEnabled?: boolean
20
+ notificationEmails?: string | null
21
+ }
22
+
23
+ export interface FormSettingsResult {
24
+ success: boolean
25
+ error?: string
26
+ settings?: FormSettingsData
27
+ }
28
+
29
+ export async function getFormSettings(formName: string): Promise<FormSettingsData | null> {
30
+ try {
31
+ const [settings] = await db
32
+ .select()
33
+ .from(formSettings)
34
+ .where(eq(formSettings.formName, formName))
35
+ .limit(1)
36
+ return (settings as FormSettingsData) ?? null
37
+ } catch (error) {
38
+ console.error(`Error fetching form settings for ${formName}:`, error)
39
+ return null
40
+ }
41
+ }
42
+
43
+ export async function upsertFormSettings(
44
+ formName: string,
45
+ data: UpsertFormSettingsInput,
46
+ ): Promise<FormSettingsResult> {
47
+ try {
48
+ const existing = await getFormSettings(formName)
49
+
50
+ if (existing) {
51
+ const [updated] = await db
52
+ .update(formSettings)
53
+ .set({
54
+ ...data,
55
+ updatedAt: new Date().toISOString(),
56
+ })
57
+ .where(eq(formSettings.formName, formName))
58
+ .returning()
59
+ return { success: true, settings: updated as FormSettingsData }
60
+ }
61
+
62
+ const [created] = await db
63
+ .insert(formSettings)
64
+ .values({
65
+ formName,
66
+ webhookUrl: data.webhookUrl ?? null,
67
+ webhookEnabled: data.webhookEnabled ?? false,
68
+ notificationEmails: data.notificationEmails ?? null,
69
+ })
70
+ .returning()
71
+ return { success: true, settings: created as FormSettingsData }
72
+ } catch (error) {
73
+ console.error(`Error upserting form settings for ${formName}:`, error)
74
+ return {
75
+ success: false,
76
+ error: error instanceof Error ? error.message : 'Failed to save form settings',
77
+ }
78
+ }
79
+ }
80
+
81
+ export async function getAllFormSettings(): Promise<FormSettingsData[]> {
82
+ try {
83
+ const settings = await db.select().from(formSettings)
84
+ return settings as FormSettingsData[]
85
+ } catch (error) {
86
+ console.error('Error fetching all form settings:', error)
87
+ return []
88
+ }
89
+ }
90
+
91
+ export async function testFormWebhook(
92
+ formName: string,
93
+ ): Promise<{ success: boolean; error?: string }> {
94
+ try {
95
+ const settings = await getFormSettings(formName)
96
+ if (!settings?.webhookUrl) {
97
+ return { success: false, error: 'No webhook URL configured' }
98
+ }
99
+
100
+ const formData = new URLSearchParams()
101
+ formData.append('form_name', formName)
102
+ formData.append('test', 'true')
103
+ formData.append('message', 'This is a test webhook from BetterStart')
104
+ formData.append('timestamp', new Date().toISOString())
105
+
106
+ const response = await fetch(settings.webhookUrl, {
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
109
+ body: formData.toString(),
110
+ })
111
+
112
+ if (!response.ok) {
113
+ return {
114
+ success: false,
115
+ error: `Webhook returned status ${response.status}`,
116
+ }
117
+ }
118
+
119
+ return { success: true }
120
+ } catch (error) {
121
+ return {
122
+ success: false,
123
+ error: error instanceof Error ? error.message : 'Failed to send test webhook',
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,62 @@
1
+ 'use server'
2
+
3
+ import { auth } from '@cms/auth'
4
+ import { getSession } from '@cms/auth/middleware'
5
+ import db from '@cms/db'
6
+ import { user } from '@cms/db/schema'
7
+ import { eq } from 'drizzle-orm'
8
+
9
+ export interface UpdateEmailResult {
10
+ success: boolean
11
+ error?: string
12
+ }
13
+
14
+ /**
15
+ * Update the current user's email address.
16
+ * Requires the current password for verification.
17
+ */
18
+ export async function updateEmail(
19
+ newEmail: string,
20
+ currentPassword: string
21
+ ): Promise<UpdateEmailResult> {
22
+ try {
23
+ const session = await getSession()
24
+ if (!session?.user) {
25
+ return { success: false, error: 'Not authenticated' }
26
+ }
27
+
28
+ // Verify current password
29
+ const verification = await auth.api.signInEmail({
30
+ body: { email: session.user.email, password: currentPassword },
31
+ asResponse: false,
32
+ })
33
+
34
+ if (!verification?.user) {
35
+ return { success: false, error: 'Incorrect password' }
36
+ }
37
+
38
+ // Check if new email is already taken
39
+ const existing = await db
40
+ .select({ id: user.id })
41
+ .from(user)
42
+ .where(eq(user.email, newEmail))
43
+ .limit(1)
44
+
45
+ if (existing.length > 0) {
46
+ return { success: false, error: 'Email already in use' }
47
+ }
48
+
49
+ // Update the email
50
+ await db
51
+ .update(user)
52
+ .set({ email: newEmail, updatedAt: new Date() })
53
+ .where(eq(user.id, session.user.id))
54
+
55
+ return { success: true }
56
+ } catch (error) {
57
+ return {
58
+ success: false,
59
+ error: error instanceof Error ? error.message : 'Failed to update email',
60
+ }
61
+ }
62
+ }