nextforge-cli 1.0.0
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 +187 -0
- package/bin/cli.js +666 -0
- package/package.json +45 -0
- package/templates/auth/ldap/ldap-service.ts +146 -0
- package/templates/base/app/dashboard/page.tsx +97 -0
- package/templates/base/app/globals.css +59 -0
- package/templates/base/app/layout.tsx +27 -0
- package/templates/base/app/login/page.tsx +118 -0
- package/templates/base/app/page.tsx +48 -0
- package/templates/base/app/register/page.tsx +164 -0
- package/templates/base/components/ui/button.tsx +52 -0
- package/templates/base/components/ui/card.tsx +78 -0
- package/templates/base/components/ui/input.tsx +24 -0
- package/templates/base/components/ui/label.tsx +23 -0
- package/templates/base/features/auth/server/route.ts +160 -0
- package/templates/base/index.ts +75 -0
- package/templates/base/lib/auth.ts +74 -0
- package/templates/base/lib/logger.ts +66 -0
- package/templates/base/lib/prisma.ts +15 -0
- package/templates/base/lib/rpc.ts +35 -0
- package/templates/base/lib/store.ts +53 -0
- package/templates/base/lib/utils.ts +6 -0
- package/templates/base/middleware/auth-middleware.ts +42 -0
- package/templates/base/next.config.js +10 -0
- package/templates/base/postcss.config.js +6 -0
- package/templates/base/providers/providers.tsx +32 -0
- package/templates/base/tailwind.config.ts +58 -0
- package/templates/base/tsconfig.json +26 -0
- package/templates/email/email-service.ts +120 -0
- package/templates/stripe/route.ts +147 -0
- package/templates/stripe/stripe-service.ts +117 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import ldap from 'ldapjs';
|
|
2
|
+
import { logger } from '@/lib/logger';
|
|
3
|
+
|
|
4
|
+
interface LDAPUser {
|
|
5
|
+
username: string;
|
|
6
|
+
email: string;
|
|
7
|
+
displayName: string;
|
|
8
|
+
dn: string;
|
|
9
|
+
groups?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class LDAPService {
|
|
13
|
+
private url: string;
|
|
14
|
+
private bindDN: string;
|
|
15
|
+
private bindPassword: string;
|
|
16
|
+
private searchBase: string;
|
|
17
|
+
private searchFilter: string;
|
|
18
|
+
|
|
19
|
+
constructor() {
|
|
20
|
+
this.url = process.env.LDAP_URL || 'ldap://localhost:389';
|
|
21
|
+
this.bindDN = process.env.LDAP_BIND_DN || '';
|
|
22
|
+
this.bindPassword = process.env.LDAP_BIND_PASSWORD || '';
|
|
23
|
+
this.searchBase = process.env.LDAP_SEARCH_BASE || '';
|
|
24
|
+
this.searchFilter = process.env.LDAP_SEARCH_FILTER || '(sAMAccountName={{username}})';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private createClient(): ldap.Client {
|
|
28
|
+
return ldap.createClient({
|
|
29
|
+
url: this.url,
|
|
30
|
+
connectTimeout: 5000,
|
|
31
|
+
timeout: 5000,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async authenticate(username: string, password: string): Promise<LDAPUser | null> {
|
|
36
|
+
const client = this.createClient();
|
|
37
|
+
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
client.on('error', (err) => {
|
|
40
|
+
logger.error('LDAP connection error', { error: err.message });
|
|
41
|
+
resolve(null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Bind with service account
|
|
45
|
+
client.bind(this.bindDN, this.bindPassword, (err) => {
|
|
46
|
+
if (err) {
|
|
47
|
+
logger.error('LDAP bind error', { error: err.message });
|
|
48
|
+
client.unbind();
|
|
49
|
+
resolve(null);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Search for user
|
|
54
|
+
const filter = this.searchFilter.replace('{{username}}', username);
|
|
55
|
+
const opts: ldap.SearchOptions = {
|
|
56
|
+
scope: 'sub',
|
|
57
|
+
filter,
|
|
58
|
+
attributes: ['sAMAccountName', 'mail', 'cn', 'dn', 'memberOf'],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
client.search(this.searchBase, opts, (err, res) => {
|
|
62
|
+
if (err) {
|
|
63
|
+
logger.error('LDAP search error', { error: err.message });
|
|
64
|
+
client.unbind();
|
|
65
|
+
resolve(null);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let userEntry: ldap.SearchEntry | null = null;
|
|
70
|
+
|
|
71
|
+
res.on('searchEntry', (entry) => {
|
|
72
|
+
userEntry = entry;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
res.on('error', (err) => {
|
|
76
|
+
logger.error('LDAP search result error', { error: err.message });
|
|
77
|
+
client.unbind();
|
|
78
|
+
resolve(null);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
res.on('end', () => {
|
|
82
|
+
if (!userEntry) {
|
|
83
|
+
logger.warn('LDAP user not found', { username });
|
|
84
|
+
client.unbind();
|
|
85
|
+
resolve(null);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const userDN = userEntry.objectName || '';
|
|
90
|
+
|
|
91
|
+
// Verify user password
|
|
92
|
+
client.bind(userDN, password, (err) => {
|
|
93
|
+
if (err) {
|
|
94
|
+
logger.warn('LDAP authentication failed', { username });
|
|
95
|
+
client.unbind();
|
|
96
|
+
resolve(null);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const getAttribute = (name: string): string => {
|
|
101
|
+
const attr = userEntry!.attributes.find(
|
|
102
|
+
(a) => a.type.toLowerCase() === name.toLowerCase()
|
|
103
|
+
);
|
|
104
|
+
return attr?.values?.[0]?.toString() || '';
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const getGroups = (): string[] => {
|
|
108
|
+
const attr = userEntry!.attributes.find(
|
|
109
|
+
(a) => a.type.toLowerCase() === 'memberof'
|
|
110
|
+
);
|
|
111
|
+
return attr?.values?.map((v) => v.toString()) || [];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const user: LDAPUser = {
|
|
115
|
+
username: getAttribute('sAMAccountName') || username,
|
|
116
|
+
email: getAttribute('mail'),
|
|
117
|
+
displayName: getAttribute('cn'),
|
|
118
|
+
dn: userDN,
|
|
119
|
+
groups: getGroups(),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
logger.info('LDAP authentication successful', { username });
|
|
123
|
+
client.unbind();
|
|
124
|
+
resolve(user);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async testConnection(): Promise<boolean> {
|
|
133
|
+
const client = this.createClient();
|
|
134
|
+
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
client.on('error', () => resolve(false));
|
|
137
|
+
|
|
138
|
+
client.bind(this.bindDN, this.bindPassword, (err) => {
|
|
139
|
+
client.unbind();
|
|
140
|
+
resolve(!err);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const ldapService = new LDAPService();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useAuthStore } from '@/lib/store'
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
7
|
+
import { Button } from '@/components/ui/button'
|
|
8
|
+
|
|
9
|
+
export default function DashboardPage() {
|
|
10
|
+
const { user, isAuthenticated, logout } = useAuthStore()
|
|
11
|
+
const router = useRouter()
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!isAuthenticated) {
|
|
15
|
+
router.push('/login')
|
|
16
|
+
}
|
|
17
|
+
}, [isAuthenticated, router])
|
|
18
|
+
|
|
19
|
+
const handleLogout = async () => {
|
|
20
|
+
try {
|
|
21
|
+
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
|
|
22
|
+
await fetch(`${apiUrl}/api/auth/logout`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
credentials: 'include',
|
|
25
|
+
})
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error('Logout error:', error)
|
|
28
|
+
}
|
|
29
|
+
logout()
|
|
30
|
+
router.push('/login')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!isAuthenticated) {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
39
|
+
<header className="bg-white dark:bg-gray-800 shadow">
|
|
40
|
+
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
|
41
|
+
<h1 className="text-xl font-semibold">Dashboard</h1>
|
|
42
|
+
<div className="flex items-center gap-4">
|
|
43
|
+
<span className="text-sm text-muted-foreground">
|
|
44
|
+
{user?.displayName || user?.username}
|
|
45
|
+
</span>
|
|
46
|
+
<Button variant="outline" size="sm" onClick={handleLogout}>
|
|
47
|
+
Logout
|
|
48
|
+
</Button>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</header>
|
|
52
|
+
|
|
53
|
+
<main className="max-w-7xl mx-auto px-4 py-8">
|
|
54
|
+
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
55
|
+
<Card>
|
|
56
|
+
<CardHeader>
|
|
57
|
+
<CardTitle>Welcome Back!</CardTitle>
|
|
58
|
+
<CardDescription>
|
|
59
|
+
You're logged in as {user?.username}
|
|
60
|
+
</CardDescription>
|
|
61
|
+
</CardHeader>
|
|
62
|
+
<CardContent>
|
|
63
|
+
<div className="space-y-2 text-sm">
|
|
64
|
+
<p><strong>Email:</strong> {user?.email || 'Not set'}</p>
|
|
65
|
+
<p><strong>Role:</strong> {user?.role}</p>
|
|
66
|
+
</div>
|
|
67
|
+
</CardContent>
|
|
68
|
+
</Card>
|
|
69
|
+
|
|
70
|
+
<Card>
|
|
71
|
+
<CardHeader>
|
|
72
|
+
<CardTitle>Quick Stats</CardTitle>
|
|
73
|
+
<CardDescription>Your activity overview</CardDescription>
|
|
74
|
+
</CardHeader>
|
|
75
|
+
<CardContent>
|
|
76
|
+
<p className="text-muted-foreground text-sm">
|
|
77
|
+
Add your dashboard widgets here
|
|
78
|
+
</p>
|
|
79
|
+
</CardContent>
|
|
80
|
+
</Card>
|
|
81
|
+
|
|
82
|
+
<Card>
|
|
83
|
+
<CardHeader>
|
|
84
|
+
<CardTitle>Recent Activity</CardTitle>
|
|
85
|
+
<CardDescription>Latest updates</CardDescription>
|
|
86
|
+
</CardHeader>
|
|
87
|
+
<CardContent>
|
|
88
|
+
<p className="text-muted-foreground text-sm">
|
|
89
|
+
No recent activity
|
|
90
|
+
</p>
|
|
91
|
+
</CardContent>
|
|
92
|
+
</Card>
|
|
93
|
+
</div>
|
|
94
|
+
</main>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
--popover: 0 0% 100%;
|
|
12
|
+
--popover-foreground: 222.2 84% 4.9%;
|
|
13
|
+
--primary: 222.2 47.4% 11.2%;
|
|
14
|
+
--primary-foreground: 210 40% 98%;
|
|
15
|
+
--secondary: 210 40% 96.1%;
|
|
16
|
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
17
|
+
--muted: 210 40% 96.1%;
|
|
18
|
+
--muted-foreground: 215.4 16.3% 46.9%;
|
|
19
|
+
--accent: 210 40% 96.1%;
|
|
20
|
+
--accent-foreground: 222.2 47.4% 11.2%;
|
|
21
|
+
--destructive: 0 84.2% 60.2%;
|
|
22
|
+
--destructive-foreground: 210 40% 98%;
|
|
23
|
+
--border: 214.3 31.8% 91.4%;
|
|
24
|
+
--input: 214.3 31.8% 91.4%;
|
|
25
|
+
--ring: 222.2 84% 4.9%;
|
|
26
|
+
--radius: 0.5rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.dark {
|
|
30
|
+
--background: 222.2 84% 4.9%;
|
|
31
|
+
--foreground: 210 40% 98%;
|
|
32
|
+
--card: 222.2 84% 4.9%;
|
|
33
|
+
--card-foreground: 210 40% 98%;
|
|
34
|
+
--popover: 222.2 84% 4.9%;
|
|
35
|
+
--popover-foreground: 210 40% 98%;
|
|
36
|
+
--primary: 210 40% 98%;
|
|
37
|
+
--primary-foreground: 222.2 47.4% 11.2%;
|
|
38
|
+
--secondary: 217.2 32.6% 17.5%;
|
|
39
|
+
--secondary-foreground: 210 40% 98%;
|
|
40
|
+
--muted: 217.2 32.6% 17.5%;
|
|
41
|
+
--muted-foreground: 215 20.2% 65.1%;
|
|
42
|
+
--accent: 217.2 32.6% 17.5%;
|
|
43
|
+
--accent-foreground: 210 40% 98%;
|
|
44
|
+
--destructive: 0 62.8% 30.6%;
|
|
45
|
+
--destructive-foreground: 210 40% 98%;
|
|
46
|
+
--border: 217.2 32.6% 17.5%;
|
|
47
|
+
--input: 217.2 32.6% 17.5%;
|
|
48
|
+
--ring: 212.7 26.8% 83.9%;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@layer base {
|
|
53
|
+
* {
|
|
54
|
+
@apply border-border;
|
|
55
|
+
}
|
|
56
|
+
body {
|
|
57
|
+
@apply bg-background text-foreground;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { Inter } from 'next/font/google'
|
|
3
|
+
import './globals.css'
|
|
4
|
+
import { Providers } from '@/providers/providers'
|
|
5
|
+
|
|
6
|
+
const inter = Inter({ subsets: ['latin'] })
|
|
7
|
+
|
|
8
|
+
export const metadata: Metadata = {
|
|
9
|
+
title: process.env.APP_NAME || 'My App',
|
|
10
|
+
description: 'Full-stack Next.js application',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function RootLayout({
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
children: React.ReactNode
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<html lang="en" suppressHydrationWarning>
|
|
20
|
+
<body className={inter.className}>
|
|
21
|
+
<Providers>
|
|
22
|
+
{children}
|
|
23
|
+
</Providers>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useForm } from 'react-hook-form'
|
|
6
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
9
|
+
import { Button } from '@/components/ui/button'
|
|
10
|
+
import { Input } from '@/components/ui/input'
|
|
11
|
+
import { Label } from '@/components/ui/label'
|
|
12
|
+
import { useAuthStore } from '@/lib/store'
|
|
13
|
+
import Link from 'next/link'
|
|
14
|
+
|
|
15
|
+
const loginSchema = z.object({
|
|
16
|
+
username: z.string().min(1, 'Username is required'),
|
|
17
|
+
password: z.string().min(1, 'Password is required'),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
type LoginForm = z.infer<typeof loginSchema>
|
|
21
|
+
|
|
22
|
+
export default function LoginPage() {
|
|
23
|
+
const [error, setError] = useState('')
|
|
24
|
+
const [loading, setLoading] = useState(false)
|
|
25
|
+
const router = useRouter()
|
|
26
|
+
const { setUser } = useAuthStore()
|
|
27
|
+
|
|
28
|
+
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
|
29
|
+
resolver: zodResolver(loginSchema),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const onSubmit = async (data: LoginForm) => {
|
|
33
|
+
setError('')
|
|
34
|
+
setLoading(true)
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
|
|
38
|
+
const res = await fetch(`${apiUrl}/api/auth/login`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify(data),
|
|
42
|
+
credentials: 'include',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const result = await res.json()
|
|
46
|
+
|
|
47
|
+
if (result.success) {
|
|
48
|
+
setUser(result.data.user)
|
|
49
|
+
router.push('/dashboard')
|
|
50
|
+
router.refresh()
|
|
51
|
+
} else {
|
|
52
|
+
setError(result.error || 'Login failed')
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
setError('Network error - please try again')
|
|
56
|
+
} finally {
|
|
57
|
+
setLoading(false)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-900 dark:to-gray-800">
|
|
63
|
+
<Card className="w-full max-w-md">
|
|
64
|
+
<CardHeader>
|
|
65
|
+
<CardTitle className="text-2xl text-center">Login</CardTitle>
|
|
66
|
+
<CardDescription className="text-center">
|
|
67
|
+
Enter your credentials to access your account
|
|
68
|
+
</CardDescription>
|
|
69
|
+
</CardHeader>
|
|
70
|
+
<CardContent>
|
|
71
|
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
72
|
+
<div className="space-y-2">
|
|
73
|
+
<Label htmlFor="username">Username</Label>
|
|
74
|
+
<Input
|
|
75
|
+
id="username"
|
|
76
|
+
{...register('username')}
|
|
77
|
+
placeholder="Enter your username"
|
|
78
|
+
/>
|
|
79
|
+
{errors.username && (
|
|
80
|
+
<p className="text-sm text-destructive">{errors.username.message}</p>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div className="space-y-2">
|
|
85
|
+
<Label htmlFor="password">Password</Label>
|
|
86
|
+
<Input
|
|
87
|
+
id="password"
|
|
88
|
+
type="password"
|
|
89
|
+
{...register('password')}
|
|
90
|
+
placeholder="Enter your password"
|
|
91
|
+
/>
|
|
92
|
+
{errors.password && (
|
|
93
|
+
<p className="text-sm text-destructive">{errors.password.message}</p>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{error && (
|
|
98
|
+
<div className="p-3 bg-destructive/10 border border-destructive/20 text-destructive rounded-md text-sm">
|
|
99
|
+
{error}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
104
|
+
{loading ? 'Logging in...' : 'Login'}
|
|
105
|
+
</Button>
|
|
106
|
+
|
|
107
|
+
<p className="text-center text-sm text-muted-foreground">
|
|
108
|
+
Don't have an account?{' '}
|
|
109
|
+
<Link href="/register" className="text-primary hover:underline">
|
|
110
|
+
Register
|
|
111
|
+
</Link>
|
|
112
|
+
</p>
|
|
113
|
+
</form>
|
|
114
|
+
</CardContent>
|
|
115
|
+
</Card>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useAuthStore } from '@/lib/store'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
6
|
+
import Link from 'next/link'
|
|
7
|
+
|
|
8
|
+
export default function HomePage() {
|
|
9
|
+
const { user, isAuthenticated } = useAuthStore()
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-900 dark:to-gray-800">
|
|
13
|
+
<Card className="w-full max-w-md">
|
|
14
|
+
<CardHeader>
|
|
15
|
+
<CardTitle className="text-2xl text-center">Welcome</CardTitle>
|
|
16
|
+
<CardDescription className="text-center">
|
|
17
|
+
{isAuthenticated
|
|
18
|
+
? `Hello, ${user?.displayName || user?.username}!`
|
|
19
|
+
: 'Get started with your full-stack app'}
|
|
20
|
+
</CardDescription>
|
|
21
|
+
</CardHeader>
|
|
22
|
+
<CardContent className="space-y-4">
|
|
23
|
+
{isAuthenticated ? (
|
|
24
|
+
<div className="text-center space-y-4">
|
|
25
|
+
<p className="text-sm text-muted-foreground">
|
|
26
|
+
You are logged in as <strong>{user?.username}</strong>
|
|
27
|
+
</p>
|
|
28
|
+
<div className="flex gap-2 justify-center">
|
|
29
|
+
<Button asChild>
|
|
30
|
+
<Link href="/dashboard">Go to Dashboard</Link>
|
|
31
|
+
</Button>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
) : (
|
|
35
|
+
<div className="flex flex-col gap-2">
|
|
36
|
+
<Button asChild>
|
|
37
|
+
<Link href="/login">Login</Link>
|
|
38
|
+
</Button>
|
|
39
|
+
<Button variant="outline" asChild>
|
|
40
|
+
<Link href="/register">Create Account</Link>
|
|
41
|
+
</Button>
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
</CardContent>
|
|
45
|
+
</Card>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useForm } from 'react-hook-form'
|
|
6
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
9
|
+
import { Button } from '@/components/ui/button'
|
|
10
|
+
import { Input } from '@/components/ui/input'
|
|
11
|
+
import { Label } from '@/components/ui/label'
|
|
12
|
+
import { useAuthStore } from '@/lib/store'
|
|
13
|
+
import Link from 'next/link'
|
|
14
|
+
|
|
15
|
+
const registerSchema = z.object({
|
|
16
|
+
username: z.string().min(3, 'Username must be at least 3 characters'),
|
|
17
|
+
email: z.string().email('Invalid email address'),
|
|
18
|
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
19
|
+
confirmPassword: z.string(),
|
|
20
|
+
displayName: z.string().optional(),
|
|
21
|
+
}).refine((data) => data.password === data.confirmPassword, {
|
|
22
|
+
message: "Passwords don't match",
|
|
23
|
+
path: ['confirmPassword'],
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
type RegisterForm = z.infer<typeof registerSchema>
|
|
27
|
+
|
|
28
|
+
export default function RegisterPage() {
|
|
29
|
+
const [error, setError] = useState('')
|
|
30
|
+
const [loading, setLoading] = useState(false)
|
|
31
|
+
const router = useRouter()
|
|
32
|
+
const { setUser } = useAuthStore()
|
|
33
|
+
|
|
34
|
+
const { register, handleSubmit, formState: { errors } } = useForm<RegisterForm>({
|
|
35
|
+
resolver: zodResolver(registerSchema),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const onSubmit = async (data: RegisterForm) => {
|
|
39
|
+
setError('')
|
|
40
|
+
setLoading(true)
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
|
|
44
|
+
const res = await fetch(`${apiUrl}/api/auth/register`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
username: data.username,
|
|
49
|
+
email: data.email,
|
|
50
|
+
password: data.password,
|
|
51
|
+
displayName: data.displayName,
|
|
52
|
+
}),
|
|
53
|
+
credentials: 'include',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const result = await res.json()
|
|
57
|
+
|
|
58
|
+
if (result.success) {
|
|
59
|
+
setUser(result.data.user)
|
|
60
|
+
router.push('/dashboard')
|
|
61
|
+
router.refresh()
|
|
62
|
+
} else {
|
|
63
|
+
setError(result.error || 'Registration failed')
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
setError('Network error - please try again')
|
|
67
|
+
} finally {
|
|
68
|
+
setLoading(false)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-900 dark:to-gray-800">
|
|
74
|
+
<Card className="w-full max-w-md">
|
|
75
|
+
<CardHeader>
|
|
76
|
+
<CardTitle className="text-2xl text-center">Create Account</CardTitle>
|
|
77
|
+
<CardDescription className="text-center">
|
|
78
|
+
Enter your details to create a new account
|
|
79
|
+
</CardDescription>
|
|
80
|
+
</CardHeader>
|
|
81
|
+
<CardContent>
|
|
82
|
+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
83
|
+
<div className="space-y-2">
|
|
84
|
+
<Label htmlFor="username">Username</Label>
|
|
85
|
+
<Input
|
|
86
|
+
id="username"
|
|
87
|
+
{...register('username')}
|
|
88
|
+
placeholder="Choose a username"
|
|
89
|
+
/>
|
|
90
|
+
{errors.username && (
|
|
91
|
+
<p className="text-sm text-destructive">{errors.username.message}</p>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className="space-y-2">
|
|
96
|
+
<Label htmlFor="email">Email</Label>
|
|
97
|
+
<Input
|
|
98
|
+
id="email"
|
|
99
|
+
type="email"
|
|
100
|
+
{...register('email')}
|
|
101
|
+
placeholder="Enter your email"
|
|
102
|
+
/>
|
|
103
|
+
{errors.email && (
|
|
104
|
+
<p className="text-sm text-destructive">{errors.email.message}</p>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div className="space-y-2">
|
|
109
|
+
<Label htmlFor="displayName">Display Name (optional)</Label>
|
|
110
|
+
<Input
|
|
111
|
+
id="displayName"
|
|
112
|
+
{...register('displayName')}
|
|
113
|
+
placeholder="How should we call you?"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="space-y-2">
|
|
118
|
+
<Label htmlFor="password">Password</Label>
|
|
119
|
+
<Input
|
|
120
|
+
id="password"
|
|
121
|
+
type="password"
|
|
122
|
+
{...register('password')}
|
|
123
|
+
placeholder="Choose a strong password"
|
|
124
|
+
/>
|
|
125
|
+
{errors.password && (
|
|
126
|
+
<p className="text-sm text-destructive">{errors.password.message}</p>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="space-y-2">
|
|
131
|
+
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
|
132
|
+
<Input
|
|
133
|
+
id="confirmPassword"
|
|
134
|
+
type="password"
|
|
135
|
+
{...register('confirmPassword')}
|
|
136
|
+
placeholder="Confirm your password"
|
|
137
|
+
/>
|
|
138
|
+
{errors.confirmPassword && (
|
|
139
|
+
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{error && (
|
|
144
|
+
<div className="p-3 bg-destructive/10 border border-destructive/20 text-destructive rounded-md text-sm">
|
|
145
|
+
{error}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
150
|
+
{loading ? 'Creating account...' : 'Create Account'}
|
|
151
|
+
</Button>
|
|
152
|
+
|
|
153
|
+
<p className="text-center text-sm text-muted-foreground">
|
|
154
|
+
Already have an account?{' '}
|
|
155
|
+
<Link href="/login" className="text-primary hover:underline">
|
|
156
|
+
Login
|
|
157
|
+
</Link>
|
|
158
|
+
</p>
|
|
159
|
+
</form>
|
|
160
|
+
</CardContent>
|
|
161
|
+
</Card>
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|