sitepaige-mcp-server 0.7.14 → 1.0.2
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/README.md +94 -42
- package/components/form.tsx +133 -6
- package/components/login.tsx +173 -21
- package/components/menu.tsx +128 -3
- package/components/testimonial.tsx +1 -1
- package/defaultapp/api/Auth/route.ts +105 -3
- package/defaultapp/api/Auth/signup/route.ts +143 -0
- package/defaultapp/api/Auth/verify-email/route.ts +98 -0
- package/defaultapp/db-password-auth.ts +325 -0
- package/defaultapp/storage/email.ts +162 -0
- package/dist/blueprintWriter.js +15 -1
- package/dist/blueprintWriter.js.map +1 -1
- package/dist/components/form.tsx +133 -6
- package/dist/components/login.tsx +173 -21
- package/dist/components/menu.tsx +128 -3
- package/dist/components/testimonial.tsx +1 -1
- package/dist/defaultapp/api/Auth/route.ts +105 -3
- package/dist/defaultapp/api/Auth/signup/route.ts +143 -0
- package/dist/defaultapp/api/Auth/verify-email/route.ts +98 -0
- package/dist/defaultapp/db-password-auth.ts +325 -0
- package/dist/defaultapp/storage/email.ts +162 -0
- package/dist/generators/apis.js +1 -0
- package/dist/generators/apis.js.map +1 -1
- package/dist/generators/defaultapp.js +2 -2
- package/dist/generators/defaultapp.js.map +1 -1
- package/dist/generators/env-example-template.txt +27 -0
- package/dist/generators/images.js +38 -13
- package/dist/generators/images.js.map +1 -1
- package/dist/generators/pages.js +1 -1
- package/dist/generators/pages.js.map +1 -1
- package/dist/generators/sql.js +19 -0
- package/dist/generators/sql.js.map +1 -1
- package/dist/generators/views.js +17 -2
- package/dist/generators/views.js.map +1 -1
- package/dist/index.js +15 -116
- package/dist/index.js.map +1 -1
- package/dist/sitepaige.js +20 -127
- package/dist/sitepaige.js.map +1 -1
- package/manifest.json +5 -24
- package/package.json +3 -2
package/components/menu.tsx
CHANGED
|
@@ -26,8 +26,10 @@ interface MenuItem {
|
|
|
26
26
|
page: string | null;
|
|
27
27
|
menu: string | null;
|
|
28
28
|
untouchable: boolean;
|
|
29
|
-
link_type?: 'page' | 'external';
|
|
29
|
+
link_type?: 'page' | 'external' | 'file';
|
|
30
30
|
external_url?: string | null;
|
|
31
|
+
file_id?: string | null;
|
|
32
|
+
file_name?: string | null;
|
|
31
33
|
hiddenOnDesktop?: boolean; // New field to hide item on desktop (shows in icon bar instead)
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -57,6 +59,8 @@ export default function Menu({ menu, onClick, pages = [] }: MenuProps) {
|
|
|
57
59
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
|
58
60
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
59
61
|
const [isMobile, setIsMobile] = useState(false);
|
|
62
|
+
const [selectedPage, setSelectedPage] = useState<string | null>(null);
|
|
63
|
+
const [isPaigeLoading, setIsPaigeLoading] = useState(false);
|
|
60
64
|
|
|
61
65
|
// Handle case where menu is undefined/null
|
|
62
66
|
if (!menu) {
|
|
@@ -88,7 +92,8 @@ export default function Menu({ menu, onClick, pages = [] }: MenuProps) {
|
|
|
88
92
|
return () => window.removeEventListener('resize', checkMobile);
|
|
89
93
|
}, []);
|
|
90
94
|
|
|
91
|
-
const direction = menu.direction === 'vertical' ? 'vertical' :
|
|
95
|
+
const direction = menu.direction === 'vertical' ? 'vertical' :
|
|
96
|
+
menu.direction === 'tiled' ? 'tiled' : 'horizontal';
|
|
92
97
|
|
|
93
98
|
const renderMenuItem = (item: MenuItem, index: number) => {
|
|
94
99
|
const handleClick = () => {
|
|
@@ -187,8 +192,128 @@ export default function Menu({ menu, onClick, pages = [] }: MenuProps) {
|
|
|
187
192
|
);
|
|
188
193
|
};
|
|
189
194
|
|
|
195
|
+
// Render a tiled menu item
|
|
196
|
+
const renderTiledMenuItem = (item: MenuItem, index: number) => {
|
|
197
|
+
const isSelected = item.page === selectedPage;
|
|
198
|
+
|
|
199
|
+
// Helper function to get font size value
|
|
200
|
+
const getFontSizeValue = (sizeClass: string) => {
|
|
201
|
+
const sizeMap: Record<string, string> = {
|
|
202
|
+
'text-xs': '0.75rem',
|
|
203
|
+
'text-sm': '0.875rem',
|
|
204
|
+
'text-base': '1rem',
|
|
205
|
+
'text-lg': '1.125rem',
|
|
206
|
+
'text-xl': '1.25rem',
|
|
207
|
+
'text-2xl': '1.5rem',
|
|
208
|
+
'text-3xl': '1.875rem',
|
|
209
|
+
};
|
|
210
|
+
return sizeMap[sizeClass] || '1.25rem';
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const tileContent = (
|
|
214
|
+
<div className={`
|
|
215
|
+
p-6
|
|
216
|
+
bg-white
|
|
217
|
+
border-2
|
|
218
|
+
border-gray-300
|
|
219
|
+
rounded-lg
|
|
220
|
+
shadow-md
|
|
221
|
+
hover:shadow-lg
|
|
222
|
+
hover:border-blue-500
|
|
223
|
+
transition-all
|
|
224
|
+
duration-200
|
|
225
|
+
cursor-pointer
|
|
226
|
+
text-center
|
|
227
|
+
h-full
|
|
228
|
+
flex
|
|
229
|
+
flex-col
|
|
230
|
+
items-center
|
|
231
|
+
justify-center
|
|
232
|
+
${isSelected ? 'border-blue-600 bg-blue-50' : ''}
|
|
233
|
+
${isPaigeLoading ? 'opacity-50 cursor-not-allowed' : ''}
|
|
234
|
+
`}>
|
|
235
|
+
<h3
|
|
236
|
+
className={`${isSelected ? 'font-bold' : 'font-medium'} text-gray-800`}
|
|
237
|
+
style={{ fontFamily: menu.font, fontSize: getFontSizeValue(menu.fontSize || 'text-xl') }}
|
|
238
|
+
>
|
|
239
|
+
{item.name}
|
|
240
|
+
</h3>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
if (item.link_type === 'external' && item.external_url) {
|
|
245
|
+
return (
|
|
246
|
+
<a
|
|
247
|
+
key={item.name}
|
|
248
|
+
href={item.external_url}
|
|
249
|
+
target="_blank"
|
|
250
|
+
rel="noopener noreferrer"
|
|
251
|
+
className="block h-full"
|
|
252
|
+
>
|
|
253
|
+
{tileContent}
|
|
254
|
+
</a>
|
|
255
|
+
);
|
|
256
|
+
} else if (item.link_type === 'file' && item.file_name) {
|
|
257
|
+
return (
|
|
258
|
+
<a
|
|
259
|
+
key={item.name}
|
|
260
|
+
href={`/library/files/${item.file_name}`}
|
|
261
|
+
target="_blank"
|
|
262
|
+
rel="noopener noreferrer"
|
|
263
|
+
className="block h-full"
|
|
264
|
+
>
|
|
265
|
+
{tileContent}
|
|
266
|
+
</a>
|
|
267
|
+
);
|
|
268
|
+
} else if (item.page) {
|
|
269
|
+
const page = pages.find(p => p.id === item.page);
|
|
270
|
+
let linkUrl = '#';
|
|
271
|
+
|
|
272
|
+
if (page) {
|
|
273
|
+
let urlPath = page.name
|
|
274
|
+
.replace(/[^a-zA-Z0-9\s]/g, '')
|
|
275
|
+
.trim()
|
|
276
|
+
.replace(/\s+/g, '_')
|
|
277
|
+
.toLowerCase();
|
|
278
|
+
linkUrl = urlPath === 'home' || urlPath === 'index' ? '/' : `/${urlPath}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<Link
|
|
283
|
+
key={item.name}
|
|
284
|
+
href={linkUrl}
|
|
285
|
+
onClick={(e) => {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
if (isPaigeLoading) {
|
|
288
|
+
console.log('Navigation blocked: Paige is currently processing a request');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
setSelectedPage(item.page);
|
|
292
|
+
onClick?.();
|
|
293
|
+
}}
|
|
294
|
+
className="block h-full"
|
|
295
|
+
>
|
|
296
|
+
{tileContent}
|
|
297
|
+
</Link>
|
|
298
|
+
);
|
|
299
|
+
} else {
|
|
300
|
+
return (
|
|
301
|
+
<div key={item.name} className="block h-full">
|
|
302
|
+
{tileContent}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
190
308
|
return (
|
|
191
309
|
<>
|
|
310
|
+
{/* Tiled menu layout */}
|
|
311
|
+
{direction === 'tiled' && (
|
|
312
|
+
<div className={`grid gap-4 ${isMobile ? 'grid-cols-1' : 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4'}`}>
|
|
313
|
+
{menu.items?.map((item, index) => renderTiledMenuItem(item, index)) || []}
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
|
|
192
317
|
{/* Hamburger menu for mobile horizontal menus */}
|
|
193
318
|
{isMobile && direction === 'horizontal' && (
|
|
194
319
|
<div className="relative">
|
|
@@ -211,7 +336,7 @@ export default function Menu({ menu, onClick, pages = [] }: MenuProps) {
|
|
|
211
336
|
)}
|
|
212
337
|
|
|
213
338
|
{/* Regular menu for desktop or vertical menus */}
|
|
214
|
-
{(!isMobile || direction === 'vertical') && (
|
|
339
|
+
{(!isMobile || direction === 'vertical') && direction !== 'tiled' && (
|
|
215
340
|
<nav className={`${direction === 'horizontal' ? 'space-x-4' : 'flex flex-col'} ${menu.align === 'Left' ? 'justify-start' : menu.align === 'Center' ? 'justify-center' : menu.align === 'Right' ? 'justify-end' : ''}`}>
|
|
216
341
|
{menu.items?.filter(item => !item.hiddenOnDesktop).map((item, index) => renderMenuItem(item, index)) || []}
|
|
217
342
|
</nav>
|
|
@@ -112,7 +112,7 @@ export default function RTestimonial({ name, custom_view_description, design }:
|
|
|
112
112
|
<div className="flex items-center">
|
|
113
113
|
{testimonial.photoId && (
|
|
114
114
|
<img
|
|
115
|
-
src={`/
|
|
115
|
+
src={`/images/${testimonial.photoId}.jpg`}
|
|
116
116
|
alt={testimonial.attribution}
|
|
117
117
|
className="w-12 h-12 rounded-full object-cover mr-4"
|
|
118
118
|
onError={(e) => {
|
|
@@ -32,11 +32,113 @@ export async function POST(request: Request) {
|
|
|
32
32
|
const db = await db_init();
|
|
33
33
|
|
|
34
34
|
try {
|
|
35
|
-
const { code, provider } = await request.json();
|
|
35
|
+
const { code, provider, email, password } = await request.json();
|
|
36
36
|
|
|
37
|
-
if (!
|
|
37
|
+
if (!provider) {
|
|
38
38
|
return NextResponse.json(
|
|
39
|
-
{ error: 'No
|
|
39
|
+
{ error: 'No provider specified' },
|
|
40
|
+
{ status: 400 }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Handle username/password authentication
|
|
45
|
+
if (provider === 'username') {
|
|
46
|
+
if (!email || !password) {
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{ error: 'Email and password are required' },
|
|
49
|
+
{ status: 400 }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { authenticateUser, createPasswordAuthTable } = await import('../../db-password-auth');
|
|
54
|
+
const { upsertUser } = await import('../../db-users');
|
|
55
|
+
|
|
56
|
+
// Ensure password auth table exists
|
|
57
|
+
await createPasswordAuthTable();
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Authenticate the user
|
|
61
|
+
const authRecord = await authenticateUser(email, password);
|
|
62
|
+
|
|
63
|
+
if (!authRecord) {
|
|
64
|
+
return NextResponse.json(
|
|
65
|
+
{ error: 'Invalid email or password' },
|
|
66
|
+
{ status: 401 }
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Create or update user in the main Users table
|
|
71
|
+
const user = await upsertUser(
|
|
72
|
+
`password_${authRecord.id}`, // Unique OAuth ID for password users
|
|
73
|
+
'username' as any, // Source type
|
|
74
|
+
email.split('@')[0], // Username from email
|
|
75
|
+
email,
|
|
76
|
+
undefined // No avatar for password auth
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Delete existing sessions for this user
|
|
80
|
+
const existingSessions = await db_query(db,
|
|
81
|
+
"SELECT ID FROM usersession WHERE userid = ?",
|
|
82
|
+
[user.userid]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (existingSessions && existingSessions.length > 0) {
|
|
86
|
+
const sessionIds = existingSessions.map(session => session.ID);
|
|
87
|
+
const placeholders = sessionIds.map(() => '?').join(',');
|
|
88
|
+
await db_query(db, `DELETE FROM usersession WHERE ID IN (${placeholders})`, sessionIds);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Generate secure session token and ID
|
|
92
|
+
const sessionId = crypto.randomUUID();
|
|
93
|
+
const sessionToken = crypto.randomBytes(32).toString('base64url');
|
|
94
|
+
|
|
95
|
+
// Create new session with secure token
|
|
96
|
+
await db_query(db,
|
|
97
|
+
"INSERT INTO usersession (ID, SessionToken, userid, ExpirationDate) VALUES (?, ?, ?, ?)",
|
|
98
|
+
[sessionId, sessionToken, user.userid, new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Set session cookie with secure token
|
|
102
|
+
const sessionCookie = await cookies();
|
|
103
|
+
sessionCookie.set({
|
|
104
|
+
name: 'session_id',
|
|
105
|
+
value: sessionToken,
|
|
106
|
+
httpOnly: true,
|
|
107
|
+
secure: process.env.NODE_ENV === 'production',
|
|
108
|
+
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
|
|
109
|
+
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
110
|
+
path: '/',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Create a completely clean object to avoid any database result object issues
|
|
114
|
+
const cleanUserData = {
|
|
115
|
+
userid: String(user.userid),
|
|
116
|
+
userName: String(user.UserName),
|
|
117
|
+
avatarURL: String(user.AvatarURL || ''),
|
|
118
|
+
userLevel: Number(user.UserLevel),
|
|
119
|
+
isAdmin: Number(user.UserLevel) === 2
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return NextResponse.json({
|
|
123
|
+
success: true,
|
|
124
|
+
user: cleanUserData
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
} catch (error: any) {
|
|
128
|
+
if (error.message === 'Email not verified') {
|
|
129
|
+
return NextResponse.json(
|
|
130
|
+
{ error: 'Please verify your email before logging in' },
|
|
131
|
+
{ status: 403 }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Handle OAuth authentication
|
|
139
|
+
if (!code) {
|
|
140
|
+
return NextResponse.json(
|
|
141
|
+
{ error: 'No authorization code specified' },
|
|
40
142
|
{ status: 400 }
|
|
41
143
|
);
|
|
42
144
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Signup endpoint for username/password authentication
|
|
3
|
+
* Handles user registration with email verification
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextResponse } from 'next/server';
|
|
7
|
+
import { validateCsrfToken } from '../../../csrf';
|
|
8
|
+
import { createPasswordAuth } from '../../../db-password-auth';
|
|
9
|
+
import { send_email } from '../../../storage/email';
|
|
10
|
+
|
|
11
|
+
// Email validation regex
|
|
12
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
13
|
+
|
|
14
|
+
// Password validation - at least 8 characters, one uppercase, one lowercase, one number
|
|
15
|
+
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;
|
|
16
|
+
|
|
17
|
+
export async function POST(request: Request) {
|
|
18
|
+
// Validate CSRF token
|
|
19
|
+
const isValidCsrf = await validateCsrfToken(request);
|
|
20
|
+
if (!isValidCsrf) {
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
{ error: 'Invalid CSRF token' },
|
|
23
|
+
{ status: 403 }
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const { email, password } = await request.json();
|
|
29
|
+
|
|
30
|
+
// Validate email format
|
|
31
|
+
if (!email || !emailRegex.test(email)) {
|
|
32
|
+
return NextResponse.json(
|
|
33
|
+
{ error: 'Invalid email address' },
|
|
34
|
+
{ status: 400 }
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Validate password strength
|
|
39
|
+
if (!password || !passwordRegex.test(password)) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
{ error: 'Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number' },
|
|
42
|
+
{ status: 400 }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Create password auth record
|
|
47
|
+
const { passwordAuth, verificationToken } = await createPasswordAuth(email, password);
|
|
48
|
+
|
|
49
|
+
// Get site domain from environment or default
|
|
50
|
+
const siteDomain = process.env.SITE_DOMAIN || 'https://sitepaige.com';
|
|
51
|
+
const verificationUrl = `${siteDomain}/api/Auth/verify-email?token=${verificationToken}`;
|
|
52
|
+
|
|
53
|
+
// Send verification email
|
|
54
|
+
try {
|
|
55
|
+
const emailHtml = `
|
|
56
|
+
<!DOCTYPE html>
|
|
57
|
+
<html>
|
|
58
|
+
<head>
|
|
59
|
+
<style>
|
|
60
|
+
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
|
61
|
+
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
62
|
+
.button {
|
|
63
|
+
display: inline-block;
|
|
64
|
+
padding: 12px 24px;
|
|
65
|
+
background-color: #4F46E5;
|
|
66
|
+
color: white;
|
|
67
|
+
text-decoration: none;
|
|
68
|
+
border-radius: 6px;
|
|
69
|
+
font-weight: bold;
|
|
70
|
+
}
|
|
71
|
+
.footer { margin-top: 30px; font-size: 12px; color: #666; }
|
|
72
|
+
</style>
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<div class="container">
|
|
76
|
+
<h2>Welcome to ${siteDomain}!</h2>
|
|
77
|
+
<p>Thank you for signing up. Please verify your email address by clicking the button below:</p>
|
|
78
|
+
<p style="margin: 30px 0;">
|
|
79
|
+
<a href="${verificationUrl}" class="button">Verify Email Address</a>
|
|
80
|
+
</p>
|
|
81
|
+
<p>Or copy and paste this link into your browser:</p>
|
|
82
|
+
<p style="word-break: break-all; color: #4F46E5;">${verificationUrl}</p>
|
|
83
|
+
<p>This link will expire in 24 hours.</p>
|
|
84
|
+
<div class="footer">
|
|
85
|
+
<p>If you didn't create an account, you can safely ignore this email.</p>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</body>
|
|
89
|
+
</html>
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const emailText = `
|
|
93
|
+
Welcome to ${siteDomain}!
|
|
94
|
+
|
|
95
|
+
Thank you for signing up. Please verify your email address by clicking the link below:
|
|
96
|
+
|
|
97
|
+
${verificationUrl}
|
|
98
|
+
|
|
99
|
+
This link will expire in 24 hours.
|
|
100
|
+
|
|
101
|
+
If you didn't create an account, you can safely ignore this email.
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
await send_email({
|
|
105
|
+
to: email,
|
|
106
|
+
from: process.env.EMAIL_FROM || 'noreply@sitepaige.com',
|
|
107
|
+
subject: 'Verify your email address',
|
|
108
|
+
html: emailHtml,
|
|
109
|
+
text: emailText
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return NextResponse.json({
|
|
113
|
+
success: true,
|
|
114
|
+
message: 'Registration successful! Please check your email to verify your account.'
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
} catch (emailError) {
|
|
118
|
+
console.error('Failed to send verification email:', emailError);
|
|
119
|
+
// Still return success but with a warning
|
|
120
|
+
return NextResponse.json({
|
|
121
|
+
success: true,
|
|
122
|
+
message: 'Registration successful! However, we could not send the verification email. Please contact support.',
|
|
123
|
+
warning: 'Email sending failed'
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
} catch (error: any) {
|
|
128
|
+
console.error('Signup error:', error);
|
|
129
|
+
|
|
130
|
+
// Handle specific errors
|
|
131
|
+
if (error.message === 'Email already registered') {
|
|
132
|
+
return NextResponse.json(
|
|
133
|
+
{ error: 'This email is already registered. Please sign in instead.' },
|
|
134
|
+
{ status: 409 }
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return NextResponse.json(
|
|
139
|
+
{ error: error.message || 'Signup failed' },
|
|
140
|
+
{ status: 500 }
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Email verification endpoint
|
|
3
|
+
* Handles email verification tokens from signup emails
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextResponse } from 'next/server';
|
|
7
|
+
import { verifyEmailWithToken } from '../../../db-password-auth';
|
|
8
|
+
import { upsertUser } from '../../../db-users';
|
|
9
|
+
import { db_init, db_query } from '../../../db';
|
|
10
|
+
import { cookies } from 'next/headers';
|
|
11
|
+
import * as crypto from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
export async function GET(request: Request) {
|
|
14
|
+
try {
|
|
15
|
+
const { searchParams } = new URL(request.url);
|
|
16
|
+
const token = searchParams.get('token');
|
|
17
|
+
|
|
18
|
+
if (!token) {
|
|
19
|
+
return NextResponse.json(
|
|
20
|
+
{ error: 'Verification token is required' },
|
|
21
|
+
{ status: 400 }
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Verify the email with token
|
|
26
|
+
const authRecord = await verifyEmailWithToken(token);
|
|
27
|
+
|
|
28
|
+
if (!authRecord) {
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{ error: 'Invalid or expired verification token' },
|
|
31
|
+
{ status: 400 }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Create or update user in the main Users table
|
|
36
|
+
const user = await upsertUser(
|
|
37
|
+
`password_${authRecord.id}`, // Unique OAuth ID for password users
|
|
38
|
+
'username' as any, // Source type
|
|
39
|
+
authRecord.email.split('@')[0], // Username from email
|
|
40
|
+
authRecord.email,
|
|
41
|
+
undefined // No avatar for password auth
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Auto-login the user after verification
|
|
45
|
+
const db = await db_init();
|
|
46
|
+
|
|
47
|
+
// Delete existing sessions for this user
|
|
48
|
+
const existingSessions = await db_query(db,
|
|
49
|
+
"SELECT ID FROM usersession WHERE userid = ?",
|
|
50
|
+
[user.userid]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (existingSessions && existingSessions.length > 0) {
|
|
54
|
+
const sessionIds = existingSessions.map(session => session.ID);
|
|
55
|
+
const placeholders = sessionIds.map(() => '?').join(',');
|
|
56
|
+
await db_query(db, `DELETE FROM usersession WHERE ID IN (${placeholders})`, sessionIds);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Generate secure session token and ID
|
|
60
|
+
const sessionId = crypto.randomUUID();
|
|
61
|
+
const sessionToken = crypto.randomBytes(32).toString('base64url');
|
|
62
|
+
|
|
63
|
+
// Create new session with secure token
|
|
64
|
+
await db_query(db,
|
|
65
|
+
"INSERT INTO usersession (ID, SessionToken, userid, ExpirationDate) VALUES (?, ?, ?, ?)",
|
|
66
|
+
[sessionId, sessionToken, user.userid, new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Set session cookie with secure token
|
|
70
|
+
const sessionCookie = await cookies();
|
|
71
|
+
sessionCookie.set({
|
|
72
|
+
name: 'session_id',
|
|
73
|
+
value: sessionToken,
|
|
74
|
+
httpOnly: true,
|
|
75
|
+
secure: process.env.NODE_ENV === 'production',
|
|
76
|
+
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
|
|
77
|
+
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
78
|
+
path: '/',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Redirect to home page or dashboard with success message
|
|
82
|
+
const siteDomain = process.env.SITE_DOMAIN || 'https://sitepaige.com';
|
|
83
|
+
const redirectUrl = new URL('/', siteDomain);
|
|
84
|
+
redirectUrl.searchParams.set('verified', 'true');
|
|
85
|
+
|
|
86
|
+
return NextResponse.redirect(redirectUrl);
|
|
87
|
+
|
|
88
|
+
} catch (error: any) {
|
|
89
|
+
console.error('Email verification error:', error);
|
|
90
|
+
|
|
91
|
+
// Redirect to home with error
|
|
92
|
+
const siteDomain = process.env.SITE_DOMAIN || 'https://sitepaige.com';
|
|
93
|
+
const redirectUrl = new URL('/', siteDomain);
|
|
94
|
+
redirectUrl.searchParams.set('error', 'verification_failed');
|
|
95
|
+
|
|
96
|
+
return NextResponse.redirect(redirectUrl);
|
|
97
|
+
}
|
|
98
|
+
}
|