ar-saas 0.1.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 +62 -0
- package/dist/cli.js +67 -0
- package/dist/generator.js +242 -0
- package/dist/index.js +13 -0
- package/dist/license.js +71 -0
- package/package.json +46 -0
- package/templates/backend/.env.example +67 -0
- package/templates/backend/.prettierrc +4 -0
- package/templates/backend/README.md +168 -0
- package/templates/backend/eslint.config.mjs +35 -0
- package/templates/backend/nest-cli.json +8 -0
- package/templates/backend/package-lock.json +10979 -0
- package/templates/backend/package.json +88 -0
- package/templates/backend/src/app.controller.spec.ts +24 -0
- package/templates/backend/src/app.controller.ts +15 -0
- package/templates/backend/src/app.module.ts +40 -0
- package/templates/backend/src/app.service.ts +11 -0
- package/templates/backend/src/common/base/base.repository.ts +221 -0
- package/templates/backend/src/common/base/base.schema.ts +24 -0
- package/templates/backend/src/common/decorators/cookie.decorator.ts +9 -0
- package/templates/backend/src/common/decorators/current-user.decorator.ts +20 -0
- package/templates/backend/src/common/decorators/workspace-id.decorator.ts +14 -0
- package/templates/backend/src/common/filters/global-exception.filter.ts +61 -0
- package/templates/backend/src/common/guards/jwt-auth.guard.ts +5 -0
- package/templates/backend/src/common/interceptors/workspace-tenant.interceptor.ts +45 -0
- package/templates/backend/src/main.ts +51 -0
- package/templates/backend/src/modules/auth/auth.controller.ts +158 -0
- package/templates/backend/src/modules/auth/auth.module.ts +20 -0
- package/templates/backend/src/modules/auth/auth.service.ts +257 -0
- package/templates/backend/src/modules/auth/dto/forgot-password.dto.ts +9 -0
- package/templates/backend/src/modules/auth/dto/login.dto.ts +14 -0
- package/templates/backend/src/modules/auth/dto/refresh-token.dto.ts +12 -0
- package/templates/backend/src/modules/auth/dto/register.dto.ts +26 -0
- package/templates/backend/src/modules/auth/dto/reset-password.dto.ts +16 -0
- package/templates/backend/src/modules/auth/dto/verify-email.dto.ts +9 -0
- package/templates/backend/src/modules/auth/strategies/jwt.strategy.ts +43 -0
- package/templates/backend/src/modules/mail/mail.module.ts +9 -0
- package/templates/backend/src/modules/mail/mail.service.ts +141 -0
- package/templates/backend/src/modules/users/schemas/user.schema.ts +54 -0
- package/templates/backend/src/modules/users/users.module.ts +14 -0
- package/templates/backend/src/modules/users/users.repository.ts +51 -0
- package/templates/backend/src/modules/users/users.service.ts +104 -0
- package/templates/backend/src/modules/workspaces/schemas/workspace.schema.ts +26 -0
- package/templates/backend/src/modules/workspaces/workspaces.module.ts +16 -0
- package/templates/backend/src/modules/workspaces/workspaces.repository.ts +34 -0
- package/templates/backend/src/modules/workspaces/workspaces.service.ts +42 -0
- package/templates/backend/test/app.e2e-spec.ts +25 -0
- package/templates/backend/test/jest-e2e.json +9 -0
- package/templates/backend/tsconfig.build.json +4 -0
- package/templates/backend/tsconfig.json +26 -0
- package/templates/frontend/.env.local.example +1 -0
- package/templates/frontend/components.json +20 -0
- package/templates/frontend/eslint.config.mjs +14 -0
- package/templates/frontend/next.config.ts +5 -0
- package/templates/frontend/package-lock.json +6722 -0
- package/templates/frontend/package.json +40 -0
- package/templates/frontend/postcss.config.mjs +7 -0
- package/templates/frontend/src/app/(auth)/forgot-password/page.tsx +84 -0
- package/templates/frontend/src/app/(auth)/layout.tsx +28 -0
- package/templates/frontend/src/app/(auth)/login/page.tsx +111 -0
- package/templates/frontend/src/app/(auth)/register/page.tsx +119 -0
- package/templates/frontend/src/app/(auth)/reset-password/page.tsx +120 -0
- package/templates/frontend/src/app/(auth)/verify-email/page.tsx +78 -0
- package/templates/frontend/src/app/(dashboard)/dashboard/page.tsx +36 -0
- package/templates/frontend/src/app/(dashboard)/layout.tsx +59 -0
- package/templates/frontend/src/app/globals.css +81 -0
- package/templates/frontend/src/app/layout.tsx +26 -0
- package/templates/frontend/src/app/page.tsx +5 -0
- package/templates/frontend/src/app/setup/page.tsx +278 -0
- package/templates/frontend/src/components/ui/button.tsx +52 -0
- package/templates/frontend/src/components/ui/card.tsx +50 -0
- package/templates/frontend/src/components/ui/form.tsx +158 -0
- package/templates/frontend/src/components/ui/input.tsx +21 -0
- package/templates/frontend/src/components/ui/label.tsx +22 -0
- package/templates/frontend/src/components/ui/toast.tsx +109 -0
- package/templates/frontend/src/components/ui/toaster.tsx +30 -0
- package/templates/frontend/src/hooks/use-toast.ts +116 -0
- package/templates/frontend/src/lib/api/auth.ts +39 -0
- package/templates/frontend/src/lib/api/client.ts +66 -0
- package/templates/frontend/src/lib/hooks/use-auth.ts +1 -0
- package/templates/frontend/src/lib/utils.ts +6 -0
- package/templates/frontend/src/providers/auth-provider.tsx +60 -0
- package/templates/frontend/src/types/api.ts +12 -0
- package/templates/frontend/src/types/auth.ts +27 -0
- package/templates/frontend/tsconfig.json +23 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-saas-ar-frontend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev --turbopack",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"lint": "next lint"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"next": "^15.3.3",
|
|
13
|
+
"react": "^19.1.0",
|
|
14
|
+
"react-dom": "^19.1.0",
|
|
15
|
+
"axios": "^1.7.9",
|
|
16
|
+
"js-cookie": "^3.0.5",
|
|
17
|
+
"@radix-ui/react-slot": "^1.1.2",
|
|
18
|
+
"@radix-ui/react-label": "^2.1.2",
|
|
19
|
+
"@radix-ui/react-toast": "^1.2.6",
|
|
20
|
+
"class-variance-authority": "^0.7.1",
|
|
21
|
+
"clsx": "^2.1.1",
|
|
22
|
+
"tailwind-merge": "^2.6.0",
|
|
23
|
+
"lucide-react": "^0.477.0",
|
|
24
|
+
"react-hook-form": "^7.54.2",
|
|
25
|
+
"@hookform/resolvers": "^3.9.1",
|
|
26
|
+
"zod": "^3.24.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"typescript": "^5.7.3",
|
|
30
|
+
"@types/node": "^22.10.7",
|
|
31
|
+
"@types/react": "^19.1.0",
|
|
32
|
+
"@types/react-dom": "^19.1.0",
|
|
33
|
+
"@types/js-cookie": "^3.0.6",
|
|
34
|
+
"tailwindcss": "^4.1.0",
|
|
35
|
+
"@tailwindcss/postcss": "^4.1.0",
|
|
36
|
+
"eslint": "^9.18.0",
|
|
37
|
+
"eslint-config-next": "^15.3.3",
|
|
38
|
+
"@eslint/eslintrc": "^3.2.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { useForm } from 'react-hook-form'
|
|
6
|
+
import { authApi } from '@/lib/api/auth'
|
|
7
|
+
import { Button } from '@/components/ui/button'
|
|
8
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
9
|
+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
|
10
|
+
import { Input } from '@/components/ui/input'
|
|
11
|
+
|
|
12
|
+
interface ForgotPasswordForm {
|
|
13
|
+
email: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function ForgotPasswordPage() {
|
|
17
|
+
const [sent, setSent] = useState(false)
|
|
18
|
+
|
|
19
|
+
const form = useForm<ForgotPasswordForm>({
|
|
20
|
+
defaultValues: { email: '' },
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
async function onSubmit(data: ForgotPasswordForm) {
|
|
24
|
+
await authApi.forgotPassword(data.email).catch(() => null)
|
|
25
|
+
setSent(true)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (sent) {
|
|
29
|
+
return (
|
|
30
|
+
<Card>
|
|
31
|
+
<CardHeader>
|
|
32
|
+
<CardTitle>Revisá tu email</CardTitle>
|
|
33
|
+
<CardDescription>
|
|
34
|
+
Si el email está registrado, te enviamos un link para restablecer tu contraseña.
|
|
35
|
+
</CardDescription>
|
|
36
|
+
</CardHeader>
|
|
37
|
+
<CardContent>
|
|
38
|
+
<Button asChild variant="outline" className="w-full">
|
|
39
|
+
<Link href="/login">Volver al inicio de sesión</Link>
|
|
40
|
+
</Button>
|
|
41
|
+
</CardContent>
|
|
42
|
+
</Card>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Card>
|
|
48
|
+
<CardHeader>
|
|
49
|
+
<CardTitle>Olvidé mi contraseña</CardTitle>
|
|
50
|
+
<CardDescription>
|
|
51
|
+
Ingresá tu email y te enviamos el link de restablecimiento.
|
|
52
|
+
</CardDescription>
|
|
53
|
+
</CardHeader>
|
|
54
|
+
<CardContent>
|
|
55
|
+
<Form {...form}>
|
|
56
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
57
|
+
<FormField
|
|
58
|
+
control={form.control}
|
|
59
|
+
name="email"
|
|
60
|
+
rules={{ required: 'El email es requerido', pattern: { value: /\S+@\S+\.\S+/, message: 'Email inválido' } }}
|
|
61
|
+
render={({ field }) => (
|
|
62
|
+
<FormItem>
|
|
63
|
+
<FormLabel>Email</FormLabel>
|
|
64
|
+
<FormControl>
|
|
65
|
+
<Input type="email" placeholder="juan@empresa.com" {...field} />
|
|
66
|
+
</FormControl>
|
|
67
|
+
<FormMessage />
|
|
68
|
+
</FormItem>
|
|
69
|
+
)}
|
|
70
|
+
/>
|
|
71
|
+
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
|
72
|
+
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar link de restablecimiento'}
|
|
73
|
+
</Button>
|
|
74
|
+
</form>
|
|
75
|
+
</Form>
|
|
76
|
+
<p className="mt-4 text-center text-sm text-muted-foreground">
|
|
77
|
+
<Link href="/login" className="underline underline-offset-4 hover:text-foreground">
|
|
78
|
+
Volver al inicio de sesión
|
|
79
|
+
</Link>
|
|
80
|
+
</p>
|
|
81
|
+
</CardContent>
|
|
82
|
+
</Card>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useAuth } from '@/lib/hooks/use-auth'
|
|
6
|
+
|
|
7
|
+
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
|
8
|
+
const { isAuthenticated, isLoading } = useAuth()
|
|
9
|
+
const router = useRouter()
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!isLoading && isAuthenticated) {
|
|
13
|
+
router.replace('/dashboard')
|
|
14
|
+
}
|
|
15
|
+
}, [isAuthenticated, isLoading, router])
|
|
16
|
+
|
|
17
|
+
if (isLoading) return null
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="min-h-screen flex flex-col items-center justify-center bg-muted/40 px-4">
|
|
21
|
+
<div className="mb-8 text-center">
|
|
22
|
+
<h1 className="text-2xl font-bold tracking-tight">create-saas-ar</h1>
|
|
23
|
+
<p className="text-sm text-muted-foreground mt-1">Tu SaaS listo para Argentina</p>
|
|
24
|
+
</div>
|
|
25
|
+
<div className="w-full max-w-md">{children}</div>
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { Eye, EyeOff } from 'lucide-react'
|
|
6
|
+
import { useForm } from 'react-hook-form'
|
|
7
|
+
import { useAuth } from '@/lib/hooks/use-auth'
|
|
8
|
+
import { Button } from '@/components/ui/button'
|
|
9
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
10
|
+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
|
11
|
+
import { Input } from '@/components/ui/input'
|
|
12
|
+
|
|
13
|
+
interface LoginForm {
|
|
14
|
+
email: string
|
|
15
|
+
password: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function LoginPage() {
|
|
19
|
+
const { login } = useAuth()
|
|
20
|
+
const [showPassword, setShowPassword] = useState(false)
|
|
21
|
+
const [apiError, setApiError] = useState('')
|
|
22
|
+
|
|
23
|
+
const form = useForm<LoginForm>({
|
|
24
|
+
defaultValues: { email: '', password: '' },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
async function onSubmit(data: LoginForm) {
|
|
28
|
+
setApiError('')
|
|
29
|
+
try {
|
|
30
|
+
await login(data.email, data.password)
|
|
31
|
+
} catch (err: unknown) {
|
|
32
|
+
const msg = err instanceof Error
|
|
33
|
+
? err.message
|
|
34
|
+
: (err as { message?: string })?.message ?? 'Error al iniciar sesión'
|
|
35
|
+
setApiError(msg)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Card>
|
|
41
|
+
<CardHeader>
|
|
42
|
+
<CardTitle>Iniciar sesión</CardTitle>
|
|
43
|
+
<CardDescription>Ingresá tu email y contraseña</CardDescription>
|
|
44
|
+
</CardHeader>
|
|
45
|
+
<CardContent>
|
|
46
|
+
<Form {...form}>
|
|
47
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
48
|
+
<FormField
|
|
49
|
+
control={form.control}
|
|
50
|
+
name="email"
|
|
51
|
+
rules={{ required: 'El email es requerido', pattern: { value: /\S+@\S+\.\S+/, message: 'Email inválido' } }}
|
|
52
|
+
render={({ field }) => (
|
|
53
|
+
<FormItem>
|
|
54
|
+
<FormLabel>Email</FormLabel>
|
|
55
|
+
<FormControl>
|
|
56
|
+
<Input type="email" placeholder="juan@empresa.com" {...field} />
|
|
57
|
+
</FormControl>
|
|
58
|
+
<FormMessage />
|
|
59
|
+
</FormItem>
|
|
60
|
+
)}
|
|
61
|
+
/>
|
|
62
|
+
<FormField
|
|
63
|
+
control={form.control}
|
|
64
|
+
name="password"
|
|
65
|
+
rules={{ required: 'La contraseña es requerida' }}
|
|
66
|
+
render={({ field }) => (
|
|
67
|
+
<FormItem>
|
|
68
|
+
<div className="flex items-center justify-between">
|
|
69
|
+
<FormLabel>Contraseña</FormLabel>
|
|
70
|
+
<Link href="/forgot-password" className="text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground">
|
|
71
|
+
¿Olvidaste tu contraseña?
|
|
72
|
+
</Link>
|
|
73
|
+
</div>
|
|
74
|
+
<FormControl>
|
|
75
|
+
<div className="relative">
|
|
76
|
+
<Input
|
|
77
|
+
type={showPassword ? 'text' : 'password'}
|
|
78
|
+
placeholder="Tu contraseña"
|
|
79
|
+
className="pr-10"
|
|
80
|
+
{...field}
|
|
81
|
+
/>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
onClick={() => setShowPassword((v) => !v)}
|
|
85
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
86
|
+
tabIndex={-1}
|
|
87
|
+
>
|
|
88
|
+
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
</FormControl>
|
|
92
|
+
<FormMessage />
|
|
93
|
+
</FormItem>
|
|
94
|
+
)}
|
|
95
|
+
/>
|
|
96
|
+
{apiError && <p className="text-sm text-destructive">{apiError}</p>}
|
|
97
|
+
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
|
98
|
+
{form.formState.isSubmitting ? 'Ingresando...' : 'Iniciar sesión'}
|
|
99
|
+
</Button>
|
|
100
|
+
</form>
|
|
101
|
+
</Form>
|
|
102
|
+
<p className="mt-4 text-center text-sm text-muted-foreground">
|
|
103
|
+
¿No tenés cuenta?{' '}
|
|
104
|
+
<Link href="/register" className="underline underline-offset-4 hover:text-foreground">
|
|
105
|
+
Registrate
|
|
106
|
+
</Link>
|
|
107
|
+
</p>
|
|
108
|
+
</CardContent>
|
|
109
|
+
</Card>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { useForm } from 'react-hook-form'
|
|
6
|
+
import { useAuth } from '@/lib/hooks/use-auth'
|
|
7
|
+
import { Button } from '@/components/ui/button'
|
|
8
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
9
|
+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
|
10
|
+
import { Input } from '@/components/ui/input'
|
|
11
|
+
|
|
12
|
+
interface RegisterForm {
|
|
13
|
+
name: string
|
|
14
|
+
email: string
|
|
15
|
+
password: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function RegisterPage() {
|
|
19
|
+
const { register } = useAuth()
|
|
20
|
+
const [success, setSuccess] = useState(false)
|
|
21
|
+
const [apiError, setApiError] = useState('')
|
|
22
|
+
|
|
23
|
+
const form = useForm<RegisterForm>({
|
|
24
|
+
defaultValues: { name: '', email: '', password: '' },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
async function onSubmit(data: RegisterForm) {
|
|
28
|
+
setApiError('')
|
|
29
|
+
try {
|
|
30
|
+
await register(data.name, data.email, data.password)
|
|
31
|
+
setSuccess(true)
|
|
32
|
+
} catch (err: unknown) {
|
|
33
|
+
const msg = err instanceof Error
|
|
34
|
+
? err.message
|
|
35
|
+
: (err as { message?: string })?.message ?? 'Error al crear la cuenta'
|
|
36
|
+
setApiError(msg)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (success) {
|
|
41
|
+
return (
|
|
42
|
+
<Card>
|
|
43
|
+
<CardHeader>
|
|
44
|
+
<CardTitle>¡Revisá tu email!</CardTitle>
|
|
45
|
+
<CardDescription>
|
|
46
|
+
Te enviamos un link de verificación. Hacé click en el link para activar tu cuenta.
|
|
47
|
+
</CardDescription>
|
|
48
|
+
</CardHeader>
|
|
49
|
+
</Card>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Card>
|
|
55
|
+
<CardHeader>
|
|
56
|
+
<CardTitle>Crear cuenta</CardTitle>
|
|
57
|
+
<CardDescription>Completá tus datos para empezar</CardDescription>
|
|
58
|
+
</CardHeader>
|
|
59
|
+
<CardContent>
|
|
60
|
+
<Form {...form}>
|
|
61
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
62
|
+
<FormField
|
|
63
|
+
control={form.control}
|
|
64
|
+
name="name"
|
|
65
|
+
rules={{ required: 'El nombre es requerido', minLength: { value: 2, message: 'Mínimo 2 caracteres' } }}
|
|
66
|
+
render={({ field }) => (
|
|
67
|
+
<FormItem>
|
|
68
|
+
<FormLabel>Nombre completo</FormLabel>
|
|
69
|
+
<FormControl>
|
|
70
|
+
<Input placeholder="Juan Pérez" {...field} />
|
|
71
|
+
</FormControl>
|
|
72
|
+
<FormMessage />
|
|
73
|
+
</FormItem>
|
|
74
|
+
)}
|
|
75
|
+
/>
|
|
76
|
+
<FormField
|
|
77
|
+
control={form.control}
|
|
78
|
+
name="email"
|
|
79
|
+
rules={{ required: 'El email es requerido', pattern: { value: /\S+@\S+\.\S+/, message: 'Email inválido' } }}
|
|
80
|
+
render={({ field }) => (
|
|
81
|
+
<FormItem>
|
|
82
|
+
<FormLabel>Email</FormLabel>
|
|
83
|
+
<FormControl>
|
|
84
|
+
<Input type="email" placeholder="juan@empresa.com" {...field} />
|
|
85
|
+
</FormControl>
|
|
86
|
+
<FormMessage />
|
|
87
|
+
</FormItem>
|
|
88
|
+
)}
|
|
89
|
+
/>
|
|
90
|
+
<FormField
|
|
91
|
+
control={form.control}
|
|
92
|
+
name="password"
|
|
93
|
+
rules={{ required: 'La contraseña es requerida', minLength: { value: 8, message: 'Mínimo 8 caracteres' } }}
|
|
94
|
+
render={({ field }) => (
|
|
95
|
+
<FormItem>
|
|
96
|
+
<FormLabel>Contraseña</FormLabel>
|
|
97
|
+
<FormControl>
|
|
98
|
+
<Input type="password" placeholder="Mínimo 8 caracteres" {...field} />
|
|
99
|
+
</FormControl>
|
|
100
|
+
<FormMessage />
|
|
101
|
+
</FormItem>
|
|
102
|
+
)}
|
|
103
|
+
/>
|
|
104
|
+
{apiError && <p className="text-sm text-destructive">{apiError}</p>}
|
|
105
|
+
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
|
106
|
+
{form.formState.isSubmitting ? 'Creando cuenta...' : 'Crear cuenta'}
|
|
107
|
+
</Button>
|
|
108
|
+
</form>
|
|
109
|
+
</Form>
|
|
110
|
+
<p className="mt-4 text-center text-sm text-muted-foreground">
|
|
111
|
+
¿Ya tenés cuenta?{' '}
|
|
112
|
+
<Link href="/login" className="underline underline-offset-4 hover:text-foreground">
|
|
113
|
+
Iniciá sesión
|
|
114
|
+
</Link>
|
|
115
|
+
</p>
|
|
116
|
+
</CardContent>
|
|
117
|
+
</Card>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Suspense, useState } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { useRouter, useSearchParams } from 'next/navigation'
|
|
6
|
+
import { useForm } from 'react-hook-form'
|
|
7
|
+
import { authApi } from '@/lib/api/auth'
|
|
8
|
+
import { Button } from '@/components/ui/button'
|
|
9
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
10
|
+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
|
11
|
+
import { Input } from '@/components/ui/input'
|
|
12
|
+
|
|
13
|
+
interface ResetPasswordForm {
|
|
14
|
+
newPassword: string
|
|
15
|
+
confirmPassword: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ResetPasswordContent() {
|
|
19
|
+
const router = useRouter()
|
|
20
|
+
const searchParams = useSearchParams()
|
|
21
|
+
const token = searchParams.get('token')
|
|
22
|
+
const [apiError, setApiError] = useState('')
|
|
23
|
+
|
|
24
|
+
const form = useForm<ResetPasswordForm>({
|
|
25
|
+
defaultValues: { newPassword: '', confirmPassword: '' },
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
async function onSubmit(data: ResetPasswordForm) {
|
|
29
|
+
if (!token) {
|
|
30
|
+
setApiError('Token inválido o faltante.')
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
setApiError('')
|
|
34
|
+
try {
|
|
35
|
+
await authApi.resetPassword(token, data.newPassword)
|
|
36
|
+
router.push('/login')
|
|
37
|
+
} catch (err: unknown) {
|
|
38
|
+
const msg = (err as { message?: string })?.message ?? 'Error al restablecer la contraseña'
|
|
39
|
+
setApiError(msg)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!token) {
|
|
44
|
+
return (
|
|
45
|
+
<Card>
|
|
46
|
+
<CardHeader>
|
|
47
|
+
<CardTitle>Link inválido</CardTitle>
|
|
48
|
+
<CardDescription>El link de restablecimiento es inválido o expiró.</CardDescription>
|
|
49
|
+
</CardHeader>
|
|
50
|
+
<CardContent>
|
|
51
|
+
<Button asChild variant="outline" className="w-full">
|
|
52
|
+
<Link href="/forgot-password">Solicitar nuevo link</Link>
|
|
53
|
+
</Button>
|
|
54
|
+
</CardContent>
|
|
55
|
+
</Card>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Card>
|
|
61
|
+
<CardHeader>
|
|
62
|
+
<CardTitle>Nueva contraseña</CardTitle>
|
|
63
|
+
<CardDescription>Elegí una nueva contraseña para tu cuenta.</CardDescription>
|
|
64
|
+
</CardHeader>
|
|
65
|
+
<CardContent>
|
|
66
|
+
<Form {...form}>
|
|
67
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
68
|
+
<FormField
|
|
69
|
+
control={form.control}
|
|
70
|
+
name="newPassword"
|
|
71
|
+
rules={{
|
|
72
|
+
required: 'La contraseña es requerida',
|
|
73
|
+
minLength: { value: 8, message: 'Mínimo 8 caracteres' },
|
|
74
|
+
}}
|
|
75
|
+
render={({ field }) => (
|
|
76
|
+
<FormItem>
|
|
77
|
+
<FormLabel>Nueva contraseña</FormLabel>
|
|
78
|
+
<FormControl>
|
|
79
|
+
<Input type="password" placeholder="Mínimo 8 caracteres" {...field} />
|
|
80
|
+
</FormControl>
|
|
81
|
+
<FormMessage />
|
|
82
|
+
</FormItem>
|
|
83
|
+
)}
|
|
84
|
+
/>
|
|
85
|
+
<FormField
|
|
86
|
+
control={form.control}
|
|
87
|
+
name="confirmPassword"
|
|
88
|
+
rules={{
|
|
89
|
+
required: 'Confirmá la contraseña',
|
|
90
|
+
validate: (val) =>
|
|
91
|
+
val === form.getValues('newPassword') || 'Las contraseñas no coinciden',
|
|
92
|
+
}}
|
|
93
|
+
render={({ field }) => (
|
|
94
|
+
<FormItem>
|
|
95
|
+
<FormLabel>Confirmar contraseña</FormLabel>
|
|
96
|
+
<FormControl>
|
|
97
|
+
<Input type="password" placeholder="Repetí la contraseña" {...field} />
|
|
98
|
+
</FormControl>
|
|
99
|
+
<FormMessage />
|
|
100
|
+
</FormItem>
|
|
101
|
+
)}
|
|
102
|
+
/>
|
|
103
|
+
{apiError && <p className="text-sm text-destructive">{apiError}</p>}
|
|
104
|
+
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
|
|
105
|
+
{form.formState.isSubmitting ? 'Guardando...' : 'Guardar nueva contraseña'}
|
|
106
|
+
</Button>
|
|
107
|
+
</form>
|
|
108
|
+
</Form>
|
|
109
|
+
</CardContent>
|
|
110
|
+
</Card>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default function ResetPasswordPage() {
|
|
115
|
+
return (
|
|
116
|
+
<Suspense fallback={<Card><CardHeader><CardTitle>Cargando...</CardTitle></CardHeader></Card>}>
|
|
117
|
+
<ResetPasswordContent />
|
|
118
|
+
</Suspense>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Suspense, useEffect, useState } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { useSearchParams } from 'next/navigation'
|
|
6
|
+
import { authApi } from '@/lib/api/auth'
|
|
7
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
8
|
+
import { Button } from '@/components/ui/button'
|
|
9
|
+
|
|
10
|
+
type Status = 'loading' | 'success' | 'error' | 'no-token'
|
|
11
|
+
|
|
12
|
+
function VerifyEmailContent() {
|
|
13
|
+
const searchParams = useSearchParams()
|
|
14
|
+
const token = searchParams.get('token')
|
|
15
|
+
const [status, setStatus] = useState<Status>(token ? 'loading' : 'no-token')
|
|
16
|
+
const [error, setError] = useState('')
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!token) return
|
|
20
|
+
authApi
|
|
21
|
+
.verifyEmail(token)
|
|
22
|
+
.then(() => setStatus('success'))
|
|
23
|
+
.catch((err: unknown) => {
|
|
24
|
+
const msg = (err as { message?: string })?.message ?? 'Error al verificar el email'
|
|
25
|
+
setError(msg)
|
|
26
|
+
setStatus('error')
|
|
27
|
+
})
|
|
28
|
+
}, [token])
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Card>
|
|
32
|
+
<CardHeader>
|
|
33
|
+
<CardTitle>Verificación de email</CardTitle>
|
|
34
|
+
</CardHeader>
|
|
35
|
+
<CardContent className="space-y-4">
|
|
36
|
+
{status === 'loading' && (
|
|
37
|
+
<CardDescription>Verificando tu email...</CardDescription>
|
|
38
|
+
)}
|
|
39
|
+
{status === 'success' && (
|
|
40
|
+
<>
|
|
41
|
+
<CardDescription>
|
|
42
|
+
✓ Email verificado correctamente. Ya podés iniciar sesión.
|
|
43
|
+
</CardDescription>
|
|
44
|
+
<Button asChild className="w-full">
|
|
45
|
+
<Link href="/login">Ir al inicio de sesión</Link>
|
|
46
|
+
</Button>
|
|
47
|
+
</>
|
|
48
|
+
)}
|
|
49
|
+
{status === 'error' && (
|
|
50
|
+
<>
|
|
51
|
+
<CardDescription className="text-destructive">{error}</CardDescription>
|
|
52
|
+
<Button asChild variant="outline" className="w-full">
|
|
53
|
+
<Link href="/login">Volver al inicio de sesión</Link>
|
|
54
|
+
</Button>
|
|
55
|
+
</>
|
|
56
|
+
)}
|
|
57
|
+
{status === 'no-token' && (
|
|
58
|
+
<>
|
|
59
|
+
<CardDescription className="text-destructive">
|
|
60
|
+
Link de verificación inválido o faltante.
|
|
61
|
+
</CardDescription>
|
|
62
|
+
<Button asChild variant="outline" className="w-full">
|
|
63
|
+
<Link href="/login">Volver al inicio de sesión</Link>
|
|
64
|
+
</Button>
|
|
65
|
+
</>
|
|
66
|
+
)}
|
|
67
|
+
</CardContent>
|
|
68
|
+
</Card>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default function VerifyEmailPage() {
|
|
73
|
+
return (
|
|
74
|
+
<Suspense fallback={<Card><CardHeader><CardTitle>Verificando...</CardTitle></CardHeader></Card>}>
|
|
75
|
+
<VerifyEmailContent />
|
|
76
|
+
</Suspense>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useAuth } from '@/lib/hooks/use-auth'
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
5
|
+
import { Rocket } from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
export default function DashboardPage() {
|
|
8
|
+
const { user } = useAuth()
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="space-y-6">
|
|
12
|
+
<div>
|
|
13
|
+
<h1 className="text-2xl font-bold">Bienvenido, {user?.name}</h1>
|
|
14
|
+
<p className="text-muted-foreground">{user?.email}</p>
|
|
15
|
+
</div>
|
|
16
|
+
<Card className="max-w-md">
|
|
17
|
+
<CardHeader>
|
|
18
|
+
<div className="flex items-center gap-2">
|
|
19
|
+
<Rocket className="size-5" />
|
|
20
|
+
<CardTitle>Tu SaaS está listo para construir</CardTitle>
|
|
21
|
+
</div>
|
|
22
|
+
<CardDescription>
|
|
23
|
+
El backend y el frontend están configurados y funcionando. Agregá tus módulos de negocio.
|
|
24
|
+
</CardDescription>
|
|
25
|
+
</CardHeader>
|
|
26
|
+
<CardContent>
|
|
27
|
+
<p className="text-sm text-muted-foreground">
|
|
28
|
+
Consultá la documentación en{' '}
|
|
29
|
+
<span className="font-mono text-foreground">.ai-docs/</span> para ver los patrones
|
|
30
|
+
disponibles.
|
|
31
|
+
</p>
|
|
32
|
+
</CardContent>
|
|
33
|
+
</Card>
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
36
|
+
}
|