@amirulabu/create-recurring-rabbit-app 0.0.0-alpha
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/bin/index.js +2 -0
- package/dist/index.js +592 -0
- package/package.json +43 -0
- package/templates/default/.editorconfig +21 -0
- package/templates/default/.env.example +15 -0
- package/templates/default/.eslintrc.json +35 -0
- package/templates/default/.prettierrc.json +7 -0
- package/templates/default/README.md +346 -0
- package/templates/default/app.config.ts +20 -0
- package/templates/default/docs/adding-features.md +439 -0
- package/templates/default/docs/adr/001-use-sqlite-for-development-database.md +22 -0
- package/templates/default/docs/adr/002-use-tanstack-start-over-nextjs.md +22 -0
- package/templates/default/docs/adr/003-use-better-auth-over-nextauth.md +22 -0
- package/templates/default/docs/adr/004-use-drizzle-over-prisma.md +22 -0
- package/templates/default/docs/adr/005-use-trpc-for-api-layer.md +22 -0
- package/templates/default/docs/adr/006-use-tailwind-css-v4-with-shadcn-ui.md +22 -0
- package/templates/default/docs/architecture.md +241 -0
- package/templates/default/docs/database.md +376 -0
- package/templates/default/docs/deployment.md +435 -0
- package/templates/default/docs/troubleshooting.md +668 -0
- package/templates/default/drizzle/migrations/0001_initial_schema.sql +39 -0
- package/templates/default/drizzle/migrations/meta/0001_snapshot.json +225 -0
- package/templates/default/drizzle/migrations/meta/_journal.json +12 -0
- package/templates/default/drizzle.config.ts +10 -0
- package/templates/default/lighthouserc.json +78 -0
- package/templates/default/src/app/__root.tsx +32 -0
- package/templates/default/src/app/api/auth/$.ts +15 -0
- package/templates/default/src/app/api/trpc.server.ts +12 -0
- package/templates/default/src/app/auth/forgot-password.tsx +107 -0
- package/templates/default/src/app/auth/login.tsx +34 -0
- package/templates/default/src/app/auth/register.tsx +34 -0
- package/templates/default/src/app/auth/reset-password.tsx +171 -0
- package/templates/default/src/app/auth/verify-email.tsx +111 -0
- package/templates/default/src/app/dashboard/index.tsx +122 -0
- package/templates/default/src/app/dashboard/settings.tsx +161 -0
- package/templates/default/src/app/globals.css +55 -0
- package/templates/default/src/app/index.tsx +83 -0
- package/templates/default/src/components/features/auth/login-form.tsx +172 -0
- package/templates/default/src/components/features/auth/register-form.tsx +202 -0
- package/templates/default/src/components/layout/dashboard-layout.tsx +27 -0
- package/templates/default/src/components/layout/header.tsx +29 -0
- package/templates/default/src/components/layout/sidebar.tsx +38 -0
- package/templates/default/src/components/ui/button.tsx +57 -0
- package/templates/default/src/components/ui/card.tsx +79 -0
- package/templates/default/src/components/ui/input.tsx +24 -0
- package/templates/default/src/lib/api.ts +42 -0
- package/templates/default/src/lib/auth.ts +292 -0
- package/templates/default/src/lib/email.ts +221 -0
- package/templates/default/src/lib/env.ts +119 -0
- package/templates/default/src/lib/hydration-timing.ts +289 -0
- package/templates/default/src/lib/monitoring.ts +336 -0
- package/templates/default/src/lib/utils.ts +6 -0
- package/templates/default/src/server/api/root.ts +10 -0
- package/templates/default/src/server/api/routers/dashboard.ts +37 -0
- package/templates/default/src/server/api/routers/user.ts +31 -0
- package/templates/default/src/server/api/trpc.ts +132 -0
- package/templates/default/src/server/auth/config.ts +241 -0
- package/templates/default/src/server/db/index.ts +153 -0
- package/templates/default/src/server/db/migrate.ts +125 -0
- package/templates/default/src/server/db/schema.ts +170 -0
- package/templates/default/src/server/db/seed.ts +130 -0
- package/templates/default/src/types/global.d.ts +25 -0
- package/templates/default/tailwind.config.js +46 -0
- package/templates/default/tsconfig.json +36 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createFileRoute, useRouter, useSearch } from '@tanstack/react-router'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { authClient } from '@/lib/auth'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { Input } from '@/components/ui/input'
|
|
6
|
+
import {
|
|
7
|
+
Card,
|
|
8
|
+
CardContent,
|
|
9
|
+
CardDescription,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle,
|
|
12
|
+
} from '@/components/ui/card'
|
|
13
|
+
|
|
14
|
+
export const Route = createFileRoute('/auth/reset-password')({
|
|
15
|
+
component: ResetPassword,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function ResetPassword() {
|
|
19
|
+
const router = useRouter()
|
|
20
|
+
const search = useSearch({ from: '/auth/reset-password' })
|
|
21
|
+
const [password, setPassword] = useState('')
|
|
22
|
+
const [confirmPassword, setConfirmPassword] = useState('')
|
|
23
|
+
const [loading, setLoading] = useState(false)
|
|
24
|
+
const [message, setMessage] = useState('')
|
|
25
|
+
const [error, setError] = useState('')
|
|
26
|
+
const [tokenValid, setTokenValid] = useState(false)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (search.token) {
|
|
30
|
+
setTokenValid(true)
|
|
31
|
+
} else {
|
|
32
|
+
setError('Invalid reset link. Please request a new password reset.')
|
|
33
|
+
}
|
|
34
|
+
}, [search.token])
|
|
35
|
+
|
|
36
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
37
|
+
e.preventDefault()
|
|
38
|
+
setLoading(true)
|
|
39
|
+
setMessage('')
|
|
40
|
+
setError('')
|
|
41
|
+
|
|
42
|
+
if (password !== confirmPassword) {
|
|
43
|
+
setError('Passwords do not match.')
|
|
44
|
+
setLoading(false)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (password.length < 8) {
|
|
49
|
+
setError('Password must be at least 8 characters.')
|
|
50
|
+
setLoading(false)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await authClient.resetPassword({
|
|
56
|
+
newPassword: password,
|
|
57
|
+
token: search.token as string,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
setMessage(
|
|
61
|
+
'Password reset successful! You can now sign in with your new password.'
|
|
62
|
+
)
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
router.navigate({ to: '/auth/login' })
|
|
65
|
+
}, 2000)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
setError('Failed to reset password. The link may have expired.')
|
|
68
|
+
console.error(err)
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
|
76
|
+
<Card className="w-full max-w-md">
|
|
77
|
+
<CardHeader>
|
|
78
|
+
<CardTitle>Reset Password</CardTitle>
|
|
79
|
+
<CardDescription>Enter your new password below</CardDescription>
|
|
80
|
+
</CardHeader>
|
|
81
|
+
<CardContent>
|
|
82
|
+
{!tokenValid ? (
|
|
83
|
+
<div className="space-y-4">
|
|
84
|
+
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
|
85
|
+
{error}
|
|
86
|
+
</div>
|
|
87
|
+
<Button
|
|
88
|
+
onClick={() => router.navigate({ to: '/auth/forgot-password' })}
|
|
89
|
+
className="w-full"
|
|
90
|
+
>
|
|
91
|
+
Request New Reset Link
|
|
92
|
+
</Button>
|
|
93
|
+
</div>
|
|
94
|
+
) : message ? (
|
|
95
|
+
<div className="space-y-4">
|
|
96
|
+
<div className="p-3 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md">
|
|
97
|
+
{message}
|
|
98
|
+
</div>
|
|
99
|
+
<p className="text-sm text-gray-500 text-center">
|
|
100
|
+
Redirecting to sign in...
|
|
101
|
+
</p>
|
|
102
|
+
</div>
|
|
103
|
+
) : (
|
|
104
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
105
|
+
<div className="space-y-2">
|
|
106
|
+
<label
|
|
107
|
+
htmlFor="password"
|
|
108
|
+
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
109
|
+
>
|
|
110
|
+
New Password
|
|
111
|
+
</label>
|
|
112
|
+
<Input
|
|
113
|
+
id="password"
|
|
114
|
+
type="password"
|
|
115
|
+
value={password}
|
|
116
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
117
|
+
placeholder="Enter new password"
|
|
118
|
+
required
|
|
119
|
+
disabled={loading}
|
|
120
|
+
minLength={8}
|
|
121
|
+
/>
|
|
122
|
+
<p className="text-xs text-gray-500">
|
|
123
|
+
Must be at least 8 characters
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="space-y-2">
|
|
128
|
+
<label
|
|
129
|
+
htmlFor="confirmPassword"
|
|
130
|
+
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
131
|
+
>
|
|
132
|
+
Confirm Password
|
|
133
|
+
</label>
|
|
134
|
+
<Input
|
|
135
|
+
id="confirmPassword"
|
|
136
|
+
type="password"
|
|
137
|
+
value={confirmPassword}
|
|
138
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
139
|
+
placeholder="Confirm new password"
|
|
140
|
+
required
|
|
141
|
+
disabled={loading}
|
|
142
|
+
minLength={8}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{error && (
|
|
147
|
+
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
|
148
|
+
{error}
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
<Button type="submit" disabled={loading} className="w-full">
|
|
153
|
+
{loading ? 'Resetting...' : 'Reset Password'}
|
|
154
|
+
</Button>
|
|
155
|
+
|
|
156
|
+
<div className="text-center">
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
onClick={() => router.navigate({ to: '/auth/login' })}
|
|
160
|
+
className="text-sm text-gray-600 hover:text-gray-900"
|
|
161
|
+
>
|
|
162
|
+
Back to Sign In
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
</form>
|
|
166
|
+
)}
|
|
167
|
+
</CardContent>
|
|
168
|
+
</Card>
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createFileRoute, useRouter, useSearch } from '@tanstack/react-router'
|
|
2
|
+
import { useEffect } from 'react'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
CardDescription,
|
|
8
|
+
CardHeader,
|
|
9
|
+
CardTitle,
|
|
10
|
+
} from '@/components/ui/card'
|
|
11
|
+
|
|
12
|
+
export const Route = createFileRoute('/auth/verify-email')({
|
|
13
|
+
component: VerifyEmail,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function VerifyEmail() {
|
|
17
|
+
const router = useRouter()
|
|
18
|
+
const search = useSearch({ from: '/auth/verify-email' })
|
|
19
|
+
const error = search.error as string | null
|
|
20
|
+
const success = search.success as string | null
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (success === 'true') {
|
|
24
|
+
const timeout = setTimeout(() => {
|
|
25
|
+
router.navigate({ to: '/auth/login' })
|
|
26
|
+
}, 3000)
|
|
27
|
+
return () => clearTimeout(timeout)
|
|
28
|
+
}
|
|
29
|
+
}, [success, router])
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
|
33
|
+
<Card className="w-full max-w-md">
|
|
34
|
+
<CardHeader>
|
|
35
|
+
<CardTitle>Email Verification</CardTitle>
|
|
36
|
+
<CardDescription>
|
|
37
|
+
{error ? 'Verification Failed' : 'Verification Complete'}
|
|
38
|
+
</CardDescription>
|
|
39
|
+
</CardHeader>
|
|
40
|
+
<CardContent className="space-y-4">
|
|
41
|
+
{success === 'true' ? (
|
|
42
|
+
<div className="text-center py-4">
|
|
43
|
+
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4">
|
|
44
|
+
<svg
|
|
45
|
+
className="w-8 h-8 text-green-600"
|
|
46
|
+
fill="none"
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
viewBox="0 0 24 24"
|
|
49
|
+
>
|
|
50
|
+
<path
|
|
51
|
+
strokeLinecap="round"
|
|
52
|
+
strokeLinejoin="round"
|
|
53
|
+
strokeWidth={2}
|
|
54
|
+
d="M5 13l4 4L19 7"
|
|
55
|
+
/>
|
|
56
|
+
</svg>
|
|
57
|
+
</div>
|
|
58
|
+
<p className="text-gray-700">
|
|
59
|
+
Your email has been verified! You can now sign in.
|
|
60
|
+
</p>
|
|
61
|
+
<p className="text-sm text-gray-500 mt-2">
|
|
62
|
+
Redirecting to sign in...
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
) : error ? (
|
|
66
|
+
<div className="text-center py-4">
|
|
67
|
+
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 mb-4">
|
|
68
|
+
<svg
|
|
69
|
+
className="w-8 h-8 text-red-600"
|
|
70
|
+
fill="none"
|
|
71
|
+
stroke="currentColor"
|
|
72
|
+
viewBox="0 0 24 24"
|
|
73
|
+
>
|
|
74
|
+
<path
|
|
75
|
+
strokeLinecap="round"
|
|
76
|
+
strokeLinejoin="round"
|
|
77
|
+
strokeWidth={2}
|
|
78
|
+
d="M6 18L18 6M6 6l12 12"
|
|
79
|
+
/>
|
|
80
|
+
</svg>
|
|
81
|
+
</div>
|
|
82
|
+
<p className="text-gray-700 mb-4">
|
|
83
|
+
{error === 'invalid_token'
|
|
84
|
+
? 'Invalid verification link. Please request a new verification email.'
|
|
85
|
+
: error === 'expired_token'
|
|
86
|
+
? 'Verification link has expired. Please request a new verification email.'
|
|
87
|
+
: 'Verification failed. Please try again.'}
|
|
88
|
+
</p>
|
|
89
|
+
<div className="space-x-2">
|
|
90
|
+
<Button onClick={() => router.navigate({ to: '/auth/login' })}>
|
|
91
|
+
Sign In
|
|
92
|
+
</Button>
|
|
93
|
+
<Button
|
|
94
|
+
variant="outline"
|
|
95
|
+
onClick={() => router.navigate({ to: '/auth/register' })}
|
|
96
|
+
>
|
|
97
|
+
Register
|
|
98
|
+
</Button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
) : (
|
|
102
|
+
<div className="text-center py-4">
|
|
103
|
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto mb-4" />
|
|
104
|
+
<p className="text-gray-700">Verifying your email...</p>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</CardContent>
|
|
108
|
+
</Card>
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createFileRoute, useRouter, redirect } from '@tanstack/react-router'
|
|
2
|
+
import { useQuery } from '@tanstack/react-query'
|
|
3
|
+
import { useTRPC } from '@/lib/api'
|
|
4
|
+
import { signOut } from '@/lib/auth'
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
6
|
+
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
|
7
|
+
|
|
8
|
+
export const Route = createFileRoute('/dashboard/')({
|
|
9
|
+
beforeLoad: async ({ location }) => {
|
|
10
|
+
if (typeof window === 'undefined') {
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch('/api/auth/get-session', {
|
|
15
|
+
headers: {
|
|
16
|
+
cookie: document.cookie,
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
const session = await response.json()
|
|
20
|
+
if (!session?.user) {
|
|
21
|
+
throw redirect({
|
|
22
|
+
to: '/auth/login',
|
|
23
|
+
search: { redirect: location.href },
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof Error && error.message.includes('redirect')) {
|
|
28
|
+
throw error
|
|
29
|
+
}
|
|
30
|
+
throw redirect({
|
|
31
|
+
to: '/auth/login',
|
|
32
|
+
search: { redirect: location.href },
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
component: Dashboard,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
function Dashboard() {
|
|
40
|
+
const router = useRouter()
|
|
41
|
+
const trpc = useTRPC()
|
|
42
|
+
const { data: stats } = useQuery(trpc.dashboard.getStats.queryOptions())
|
|
43
|
+
|
|
44
|
+
const handleSignOut = async () => {
|
|
45
|
+
await signOut()
|
|
46
|
+
router.navigate({ to: '/' as const })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<DashboardLayout
|
|
51
|
+
userName={stats?.currentUser?.name}
|
|
52
|
+
onSignOut={handleSignOut}
|
|
53
|
+
>
|
|
54
|
+
<div className="max-w-6xl">
|
|
55
|
+
<h1 className="text-3xl font-bold mb-8">Dashboard</h1>
|
|
56
|
+
|
|
57
|
+
<div className="grid md:grid-cols-2 gap-6">
|
|
58
|
+
<Card>
|
|
59
|
+
<CardHeader>
|
|
60
|
+
<CardTitle>User Stats</CardTitle>
|
|
61
|
+
</CardHeader>
|
|
62
|
+
<CardContent>
|
|
63
|
+
<div className="space-y-4">
|
|
64
|
+
<div>
|
|
65
|
+
<p className="text-sm text-gray-600">Total Users</p>
|
|
66
|
+
<p className="text-3xl font-bold">{stats?.totalUsers ?? 0}</p>
|
|
67
|
+
</div>
|
|
68
|
+
<div>
|
|
69
|
+
<p className="text-sm text-gray-600">Active Users</p>
|
|
70
|
+
<p className="text-3xl font-bold">
|
|
71
|
+
{stats?.activeUsers ?? 0}
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</CardContent>
|
|
76
|
+
</Card>
|
|
77
|
+
|
|
78
|
+
<Card>
|
|
79
|
+
<CardHeader>
|
|
80
|
+
<CardTitle>Your Profile</CardTitle>
|
|
81
|
+
</CardHeader>
|
|
82
|
+
<CardContent>
|
|
83
|
+
<div className="space-y-3">
|
|
84
|
+
<div>
|
|
85
|
+
<p className="text-sm text-gray-600">Name</p>
|
|
86
|
+
<p className="font-medium">{stats?.currentUser?.name}</p>
|
|
87
|
+
</div>
|
|
88
|
+
<div>
|
|
89
|
+
<p className="text-sm text-gray-600">Email</p>
|
|
90
|
+
<p className="font-medium">{stats?.currentUser?.email}</p>
|
|
91
|
+
</div>
|
|
92
|
+
<div>
|
|
93
|
+
<p className="text-sm text-gray-600">Email Verified</p>
|
|
94
|
+
<p className="font-medium">
|
|
95
|
+
{stats?.currentUser?.emailVerified ? 'Yes' : 'No'}
|
|
96
|
+
</p>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</CardContent>
|
|
100
|
+
</Card>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div className="mt-8 bg-blue-50 p-6 rounded-lg border border-blue-200">
|
|
104
|
+
<h2 className="text-xl font-semibold mb-2">
|
|
105
|
+
Welcome to your dashboard!
|
|
106
|
+
</h2>
|
|
107
|
+
<p className="text-gray-700">
|
|
108
|
+
You've successfully set up your micro-SaaS starter. From here, you
|
|
109
|
+
can:
|
|
110
|
+
</p>
|
|
111
|
+
<ul className="mt-4 space-y-2 text-gray-700 list-disc list-inside">
|
|
112
|
+
<li>Add new features and pages</li>
|
|
113
|
+
<li>Explore the database schema in src/server/db/schema.ts</li>
|
|
114
|
+
<li>Create new tRPC procedures in src/server/api/routers/</li>
|
|
115
|
+
<li>Customize the UI components in src/components/</li>
|
|
116
|
+
<li>Deploy to Vercel, Railway, or your preferred platform</li>
|
|
117
|
+
</ul>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</DashboardLayout>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createFileRoute, useRouter, redirect } from '@tanstack/react-router'
|
|
2
|
+
import React, { useState } from 'react'
|
|
3
|
+
import { useQuery, useMutation } from '@tanstack/react-query'
|
|
4
|
+
import { useTRPC } from '@/lib/api'
|
|
5
|
+
import { signOut } from '@/lib/auth'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Input } from '@/components/ui/input'
|
|
8
|
+
import {
|
|
9
|
+
Card,
|
|
10
|
+
CardContent,
|
|
11
|
+
CardDescription,
|
|
12
|
+
CardHeader,
|
|
13
|
+
CardTitle,
|
|
14
|
+
} from '@/components/ui/card'
|
|
15
|
+
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
|
16
|
+
|
|
17
|
+
export const Route = createFileRoute('/dashboard/settings')({
|
|
18
|
+
beforeLoad: async ({ location }) => {
|
|
19
|
+
if (typeof window === 'undefined') {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch('/api/auth/get-session', {
|
|
24
|
+
headers: {
|
|
25
|
+
cookie: document.cookie,
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
const session = await response.json()
|
|
29
|
+
if (!session?.user) {
|
|
30
|
+
throw redirect({
|
|
31
|
+
to: '/auth/login',
|
|
32
|
+
search: { redirect: location.href },
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error instanceof Error && error.message.includes('redirect')) {
|
|
37
|
+
throw error
|
|
38
|
+
}
|
|
39
|
+
throw redirect({
|
|
40
|
+
to: '/auth/login',
|
|
41
|
+
search: { redirect: location.href },
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
component: Settings,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
function Settings() {
|
|
49
|
+
const router = useRouter()
|
|
50
|
+
const trpc = useTRPC()
|
|
51
|
+
const { data: user } = useQuery(trpc.user.getProfile.queryOptions())
|
|
52
|
+
const updateProfile = useMutation(trpc.user.updateProfile.mutationOptions())
|
|
53
|
+
|
|
54
|
+
const [name, setName] = useState('')
|
|
55
|
+
const [loading, setLoading] = useState(false)
|
|
56
|
+
const [message, setMessage] = useState('')
|
|
57
|
+
const [error, setError] = useState('')
|
|
58
|
+
|
|
59
|
+
React.useEffect(() => {
|
|
60
|
+
if (user) {
|
|
61
|
+
setName(user.name)
|
|
62
|
+
}
|
|
63
|
+
}, [user])
|
|
64
|
+
|
|
65
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
setLoading(true)
|
|
68
|
+
setMessage('')
|
|
69
|
+
setError('')
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const result = await updateProfile.mutateAsync({
|
|
73
|
+
name,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
setMessage('Profile updated successfully!')
|
|
77
|
+
setName(result.name)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
setError('Failed to update profile. Please try again.')
|
|
80
|
+
console.error(err)
|
|
81
|
+
} finally {
|
|
82
|
+
setLoading(false)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const handleSignOut = async () => {
|
|
87
|
+
await signOut()
|
|
88
|
+
router.navigate({ to: '/' as const })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<DashboardLayout userName={user?.name} onSignOut={handleSignOut}>
|
|
93
|
+
<div className="max-w-2xl">
|
|
94
|
+
<h1 className="text-3xl font-bold mb-8">Settings</h1>
|
|
95
|
+
|
|
96
|
+
<Card>
|
|
97
|
+
<CardHeader>
|
|
98
|
+
<CardTitle>Profile Settings</CardTitle>
|
|
99
|
+
<CardDescription>Update your account information</CardDescription>
|
|
100
|
+
</CardHeader>
|
|
101
|
+
<CardContent>
|
|
102
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
103
|
+
<div className="space-y-2">
|
|
104
|
+
<label
|
|
105
|
+
htmlFor="name"
|
|
106
|
+
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
107
|
+
>
|
|
108
|
+
Name
|
|
109
|
+
</label>
|
|
110
|
+
<Input
|
|
111
|
+
id="name"
|
|
112
|
+
type="text"
|
|
113
|
+
value={name}
|
|
114
|
+
onChange={(e) => setName(e.target.value)}
|
|
115
|
+
placeholder="Your name"
|
|
116
|
+
required
|
|
117
|
+
disabled={loading}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div className="space-y-2">
|
|
122
|
+
<label
|
|
123
|
+
htmlFor="email-display"
|
|
124
|
+
className="text-sm font-medium leading-none text-gray-600"
|
|
125
|
+
>
|
|
126
|
+
Email
|
|
127
|
+
</label>
|
|
128
|
+
<Input
|
|
129
|
+
id="email-display"
|
|
130
|
+
type="email"
|
|
131
|
+
value={user?.email ?? ''}
|
|
132
|
+
disabled={true}
|
|
133
|
+
className="bg-gray-50"
|
|
134
|
+
/>
|
|
135
|
+
<p className="text-xs text-gray-500">
|
|
136
|
+
Email cannot be changed. Contact support for updates.
|
|
137
|
+
</p>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{message && (
|
|
141
|
+
<div className="p-3 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md">
|
|
142
|
+
{message}
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{error && (
|
|
147
|
+
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
|
148
|
+
{error}
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
<Button type="submit" disabled={loading}>
|
|
153
|
+
{loading ? 'Saving...' : 'Save Changes'}
|
|
154
|
+
</Button>
|
|
155
|
+
</form>
|
|
156
|
+
</CardContent>
|
|
157
|
+
</Card>
|
|
158
|
+
</div>
|
|
159
|
+
</DashboardLayout>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 0 0% 100%;
|
|
8
|
+
--foreground: 222.2 84% 4.9%;
|
|
9
|
+
--card: 0 0% 100%;
|
|
10
|
+
--card-foreground: 222.2 84% 4.9%;
|
|
11
|
+
--primary: 221.2 83.2% 53.3%;
|
|
12
|
+
--primary-foreground: 210 40% 98%;
|
|
13
|
+
--secondary: 210 40% 96.1%;
|
|
14
|
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
15
|
+
--muted: 210 40% 96.1%;
|
|
16
|
+
--muted-foreground: 215.4 16.3% 46.9%;
|
|
17
|
+
--accent: 210 40% 96.1%;
|
|
18
|
+
--accent-foreground: 222.2 47.4% 11.2%;
|
|
19
|
+
--destructive: 0 84.2% 60.2%;
|
|
20
|
+
--destructive-foreground: 210 40% 98%;
|
|
21
|
+
--border: 214.3 31.8% 91.4%;
|
|
22
|
+
--input: 214.3 31.8% 91.4%;
|
|
23
|
+
--ring: 221.2 83.2% 53.3%;
|
|
24
|
+
--radius: 0.5rem;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.dark {
|
|
28
|
+
--background: 222.2 84% 4.9%;
|
|
29
|
+
--foreground: 210 40% 98%;
|
|
30
|
+
--card: 222.2 84% 4.9%;
|
|
31
|
+
--card-foreground: 210 40% 98%;
|
|
32
|
+
--primary: 217.2 91.2% 59.8%;
|
|
33
|
+
--primary-foreground: 222.2 84% 4.9%;
|
|
34
|
+
--secondary: 217.2 32.6% 17.5%;
|
|
35
|
+
--secondary-foreground: 210 40% 98%;
|
|
36
|
+
--muted: 217.2 32.6% 17.5%;
|
|
37
|
+
--muted-foreground: 215 20.2% 65.1%;
|
|
38
|
+
--accent: 217.2 32.6% 17.5%;
|
|
39
|
+
--accent-foreground: 210 40% 98%;
|
|
40
|
+
--destructive: 0 62.8% 30.6%;
|
|
41
|
+
--destructive-foreground: 210 40% 98%;
|
|
42
|
+
--border: 217.2 32.6% 17.5%;
|
|
43
|
+
--input: 217.2 32.6% 17.5%;
|
|
44
|
+
--ring: 224.3 76.3% 48%;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@layer base {
|
|
49
|
+
* {
|
|
50
|
+
@apply border-border;
|
|
51
|
+
}
|
|
52
|
+
body {
|
|
53
|
+
@apply bg-background text-foreground;
|
|
54
|
+
}
|
|
55
|
+
}
|