ar-saas 0.3.3 → 0.4.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.
Files changed (28) hide show
  1. package/dist/generator.js +2 -6
  2. package/package.json +1 -1
  3. package/templates/backend/.env.example +1 -1
  4. package/templates/backend/package.json +5 -2
  5. package/templates/backend/src/app.module.ts +68 -40
  6. package/templates/backend/src/common/interceptors/workspace-tenant.interceptor.ts +27 -45
  7. package/templates/backend/src/main.ts +50 -51
  8. package/templates/backend/src/modules/auth/auth.controller.ts +162 -158
  9. package/templates/backend/src/modules/auth/auth.service.ts +236 -257
  10. package/templates/backend/src/modules/auth/strategies/jwt.strategy.ts +45 -43
  11. package/templates/backend/src/modules/users/users.controller.ts +28 -0
  12. package/templates/backend/src/modules/users/users.module.ts +16 -14
  13. package/templates/backend/src/modules/users/users.repository.ts +57 -51
  14. package/templates/backend/src/modules/users/users.service.ts +130 -104
  15. package/templates/backend/src/modules/workspaces/workspaces.repository.ts +38 -34
  16. package/templates/backend/src/modules/workspaces/workspaces.service.ts +51 -42
  17. package/templates/frontend/package.json +2 -5
  18. package/templates/frontend/pnpm-workspace.yaml +2 -2
  19. package/templates/frontend/src/app/(auth)/layout.tsx +29 -28
  20. package/templates/frontend/src/app/(dashboard)/billing/page.tsx +111 -111
  21. package/templates/frontend/src/app/(dashboard)/profile/page.tsx +241 -226
  22. package/templates/frontend/src/app/(dashboard)/settings/page.tsx +155 -156
  23. package/templates/frontend/src/app/(dashboard)/team/page.tsx +179 -178
  24. package/templates/frontend/src/app/layout.tsx +29 -26
  25. package/templates/frontend/src/app/page.tsx +1 -1
  26. package/templates/frontend/src/app/setup/page.tsx +1 -1
  27. package/templates/frontend/src/components/dashboard/header.tsx +5 -3
  28. package/templates/frontend/src/config/site.ts +1 -1
@@ -1,178 +1,179 @@
1
- 'use client'
2
-
3
- import { useState } from 'react'
4
- import { useForm } from 'react-hook-form'
5
- import { UserPlus, MoreHorizontal, Crown, User } from 'lucide-react'
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 { Avatar, AvatarFallback } from '@/components/ui/avatar'
10
- import { Badge } from '@/components/ui/badge'
11
- import { Input } from '@/components/ui/input'
12
- import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
13
- import {
14
- Dialog,
15
- DialogContent,
16
- DialogDescription,
17
- DialogFooter,
18
- DialogHeader,
19
- DialogTitle,
20
- DialogTrigger,
21
- } from '@/components/ui/dialog'
22
- import {
23
- DropdownMenu,
24
- DropdownMenuContent,
25
- DropdownMenuItem,
26
- DropdownMenuTrigger,
27
- } from '@/components/ui/dropdown-menu'
28
-
29
- function getInitials(name: string) {
30
- return name.split(' ').map((n) => n[0]).slice(0, 2).join('').toUpperCase()
31
- }
32
-
33
- interface InviteForm {
34
- email: string
35
- }
36
-
37
- export default function TeamPage() {
38
- const { user } = useAuth()
39
- const [inviteOpen, setInviteOpen] = useState(false)
40
- const [invited, setInvited] = useState(false)
41
-
42
- const members = [
43
- { name: user?.name ?? 'Vos', email: user?.email ?? '', role: 'owner' as const },
44
- ]
45
-
46
- const form = useForm<InviteForm>({ defaultValues: { email: '' } })
47
-
48
- async function onInvite(data: InviteForm) {
49
- // TODO: llamar a POST /workspaces/invite con data.email
50
- console.log('Invitar:', data.email)
51
- setInvited(true)
52
- setTimeout(() => {
53
- setInvited(false)
54
- setInviteOpen(false)
55
- form.reset()
56
- }, 1500)
57
- }
58
-
59
- return (
60
- <div className="max-w-2xl space-y-6">
61
- <Card>
62
- <CardHeader>
63
- <div className="flex items-start justify-between">
64
- <div>
65
- <CardTitle>Miembros del equipo</CardTitle>
66
- <CardDescription>
67
- Gestioná quién tiene acceso a tu workspace.
68
- </CardDescription>
69
- </div>
70
- <Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
71
- <DialogTrigger asChild>
72
- <Button size="sm" className="gap-2">
73
- <UserPlus className="size-4" />
74
- Invitar
75
- </Button>
76
- </DialogTrigger>
77
- <DialogContent>
78
- <DialogHeader>
79
- <DialogTitle>Invitar miembro</DialogTitle>
80
- <DialogDescription>
81
- Ingresá el email de la persona que querés agregar al workspace.
82
- </DialogDescription>
83
- </DialogHeader>
84
- <Form {...form}>
85
- <form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
86
- <FormField
87
- control={form.control}
88
- name="email"
89
- rules={{
90
- required: 'El email es requerido',
91
- pattern: { value: /\S+@\S+\.\S+/, message: 'Email inválido' },
92
- }}
93
- render={({ field }) => (
94
- <FormItem>
95
- <FormLabel>Email</FormLabel>
96
- <FormControl>
97
- <Input type="email" placeholder="colaborador@empresa.com" {...field} />
98
- </FormControl>
99
- <FormMessage />
100
- </FormItem>
101
- )}
102
- />
103
- <DialogFooter>
104
- <Button type="button" variant="ghost" onClick={() => setInviteOpen(false)}>
105
- Cancelar
106
- </Button>
107
- <Button type="submit" disabled={form.formState.isSubmitting}>
108
- {invited ? 'Invitación enviada' : 'Enviar invitación'}
109
- </Button>
110
- </DialogFooter>
111
- </form>
112
- </Form>
113
- </DialogContent>
114
- </Dialog>
115
- </div>
116
- </CardHeader>
117
- <CardContent>
118
- <div className="space-y-3">
119
- {members.map((member) => (
120
- <div
121
- key={member.email}
122
- className="flex items-center justify-between rounded-lg border p-3"
123
- >
124
- <div className="flex items-center gap-3">
125
- <Avatar className="size-8">
126
- <AvatarFallback className="text-xs">{getInitials(member.name)}</AvatarFallback>
127
- </Avatar>
128
- <div>
129
- <p className="text-sm font-medium">{member.name}</p>
130
- <p className="text-xs text-muted-foreground">{member.email}</p>
131
- </div>
132
- </div>
133
- <div className="flex items-center gap-2">
134
- <Badge variant={member.role === 'owner' ? 'default' : 'secondary'} className="gap-1 text-xs">
135
- {member.role === 'owner' ? (
136
- <><Crown className="size-3" /> Owner</>
137
- ) : (
138
- <><User className="size-3" /> Miembro</>
139
- )}
140
- </Badge>
141
- {member.role !== 'owner' && (
142
- <DropdownMenu>
143
- <DropdownMenuTrigger asChild>
144
- <Button variant="ghost" size="sm" className="size-7 p-0">
145
- <MoreHorizontal className="size-4" />
146
- </Button>
147
- </DropdownMenuTrigger>
148
- <DropdownMenuContent align="end">
149
- <DropdownMenuItem className="text-destructive focus:text-destructive">
150
- Eliminar miembro
151
- </DropdownMenuItem>
152
- </DropdownMenuContent>
153
- </DropdownMenu>
154
- )}
155
- </div>
156
- </div>
157
- ))}
158
- </div>
159
-
160
- <p className="mt-4 text-center text-xs text-muted-foreground">
161
- Plan Free · 1 de 3 usuarios
162
- </p>
163
- </CardContent>
164
- </Card>
165
-
166
- {/* Pending invitations placeholder */}
167
- <Card>
168
- <CardHeader>
169
- <CardTitle>Invitaciones pendientes</CardTitle>
170
- <CardDescription>Invitaciones enviadas que aún no fueron aceptadas.</CardDescription>
171
- </CardHeader>
172
- <CardContent>
173
- <p className="text-sm text-muted-foreground">No hay invitaciones pendientes.</p>
174
- </CardContent>
175
- </Card>
176
- </div>
177
- )
178
- }
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useForm } from 'react-hook-form'
5
+ import { UserPlus, MoreHorizontal, Crown, User } from 'lucide-react'
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 { Avatar, AvatarFallback } from '@/components/ui/avatar'
10
+ import { Badge } from '@/components/ui/badge'
11
+ import { Input } from '@/components/ui/input'
12
+ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogDescription,
17
+ DialogFooter,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ DialogTrigger,
21
+ } from '@/components/ui/dialog'
22
+ import {
23
+ DropdownMenu,
24
+ DropdownMenuContent,
25
+ DropdownMenuItem,
26
+ DropdownMenuTrigger,
27
+ } from '@/components/ui/dropdown-menu'
28
+
29
+ function getInitials(name: string): string {
30
+ return name
31
+ .trim()
32
+ .split(/\s+/)
33
+ .filter(Boolean)
34
+ .map((n) => n[0])
35
+ .slice(0, 2)
36
+ .join('')
37
+ .toUpperCase() || 'U'
38
+ }
39
+
40
+ interface InviteForm {
41
+ email: string
42
+ }
43
+
44
+ export default function TeamPage() {
45
+ const { user } = useAuth()
46
+ const [inviteOpen, setInviteOpen] = useState(false)
47
+
48
+ const members = [
49
+ { name: user?.name ?? 'Vos', email: user?.email ?? '', role: 'owner' as const },
50
+ ]
51
+
52
+ const form = useForm<InviteForm>({ defaultValues: { email: '' } })
53
+
54
+ function onInvite(data: InviteForm) {
55
+ setInviteOpen(false)
56
+ form.reset()
57
+ }
58
+
59
+ return (
60
+ <div className="max-w-2xl space-y-6">
61
+ <div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
62
+ <p className="text-xs text-amber-800 font-medium">Próximamente</p>
63
+ <p className="text-xs text-amber-700 mt-0.5">La gestión de equipo estará disponible en la próxima actualización.</p>
64
+ </div>
65
+
66
+ <Card>
67
+ <CardHeader>
68
+ <div className="flex items-start justify-between">
69
+ <div>
70
+ <CardTitle>Miembros del equipo</CardTitle>
71
+ <CardDescription>
72
+ Gestioná quién tiene acceso a tu workspace.
73
+ </CardDescription>
74
+ </div>
75
+ <Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
76
+ <DialogTrigger asChild>
77
+ <Button size="sm" className="gap-2" disabled>
78
+ <UserPlus className="size-4" />
79
+ Invitar
80
+ </Button>
81
+ </DialogTrigger>
82
+ <DialogContent>
83
+ <DialogHeader>
84
+ <DialogTitle>Invitar miembro</DialogTitle>
85
+ <DialogDescription>
86
+ Ingresá el email de la persona que querés agregar al workspace.
87
+ </DialogDescription>
88
+ </DialogHeader>
89
+ <Form {...form}>
90
+ <form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
91
+ <FormField
92
+ control={form.control}
93
+ name="email"
94
+ rules={{
95
+ required: 'El email es requerido',
96
+ pattern: { value: /\S+@\S+\.\S+/, message: 'Email inválido' },
97
+ }}
98
+ render={({ field }) => (
99
+ <FormItem>
100
+ <FormLabel>Email</FormLabel>
101
+ <FormControl>
102
+ <Input type="email" placeholder="colaborador@empresa.com" {...field} />
103
+ </FormControl>
104
+ <FormMessage />
105
+ </FormItem>
106
+ )}
107
+ />
108
+ <DialogFooter>
109
+ <Button type="button" variant="ghost" onClick={() => setInviteOpen(false)}>
110
+ Cancelar
111
+ </Button>
112
+ <Button type="submit" disabled>
113
+ Próximamente
114
+ </Button>
115
+ </DialogFooter>
116
+ </form>
117
+ </Form>
118
+ </DialogContent>
119
+ </Dialog>
120
+ </div>
121
+ </CardHeader>
122
+ <CardContent>
123
+ <div className="space-y-3">
124
+ {members.map((member) => (
125
+ <div
126
+ key={member.email}
127
+ className="flex items-center justify-between rounded-lg border p-3"
128
+ >
129
+ <div className="flex items-center gap-3">
130
+ <Avatar className="size-8">
131
+ <AvatarFallback className="text-xs">{getInitials(member.name)}</AvatarFallback>
132
+ </Avatar>
133
+ <div>
134
+ <p className="text-sm font-medium">{member.name}</p>
135
+ <p className="text-xs text-muted-foreground">{member.email}</p>
136
+ </div>
137
+ </div>
138
+ <div className="flex items-center gap-2">
139
+ <Badge variant={member.role === 'owner' ? 'default' : 'secondary'} className="gap-1 text-xs">
140
+ {member.role === 'owner' ? (
141
+ <><Crown className="size-3" /> Owner</>
142
+ ) : (
143
+ <><User className="size-3" /> Miembro</>
144
+ )}
145
+ </Badge>
146
+ {member.role !== 'owner' && (
147
+ <DropdownMenu>
148
+ <DropdownMenuTrigger asChild>
149
+ <Button variant="ghost" size="sm" className="size-7 p-0">
150
+ <MoreHorizontal className="size-4" />
151
+ </Button>
152
+ </DropdownMenuTrigger>
153
+ <DropdownMenuContent align="end">
154
+ <DropdownMenuItem className="text-destructive focus:text-destructive">
155
+ Eliminar miembro
156
+ </DropdownMenuItem>
157
+ </DropdownMenuContent>
158
+ </DropdownMenu>
159
+ )}
160
+ </div>
161
+ </div>
162
+ ))}
163
+ </div>
164
+ </CardContent>
165
+ </Card>
166
+
167
+ {/* Pending invitations placeholder */}
168
+ <Card>
169
+ <CardHeader>
170
+ <CardTitle>Invitaciones pendientes</CardTitle>
171
+ <CardDescription>Invitaciones enviadas que aún no fueron aceptadas.</CardDescription>
172
+ </CardHeader>
173
+ <CardContent>
174
+ <p className="text-sm text-muted-foreground">No hay invitaciones pendientes.</p>
175
+ </CardContent>
176
+ </Card>
177
+ </div>
178
+ )
179
+ }
@@ -1,26 +1,29 @@
1
- import type { Metadata } from 'next'
2
- import { Geist, Geist_Mono } from 'next/font/google'
3
- import './globals.css'
4
- import { AuthProvider } from '@/providers/auth-provider'
5
- import { Toaster } from '@/components/ui/toaster'
6
-
7
- const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] })
8
- const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] })
9
-
10
- export const metadata: Metadata = {
11
- title: 'create-saas-ar',
12
- description: 'Tu SaaS listo para Argentina',
13
- }
14
-
15
- export default function RootLayout({ children }: { children: React.ReactNode }) {
16
- return (
17
- <html lang="es">
18
- <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
19
- <AuthProvider>
20
- {children}
21
- <Toaster />
22
- </AuthProvider>
23
- </body>
24
- </html>
25
- )
26
- }
1
+ import type { Metadata } from 'next'
2
+ import { Geist, Geist_Mono } from 'next/font/google'
3
+ import './globals.css'
4
+ import { AuthProvider } from '@/providers/auth-provider'
5
+ import { Toaster } from '@/components/ui/toaster'
6
+ import { siteConfig } from '@/config/site'
7
+
8
+ const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] })
9
+ const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] })
10
+
11
+ export async function generateMetadata(): Promise<Metadata> {
12
+ return {
13
+ title: siteConfig.name,
14
+ description: siteConfig.description,
15
+ }
16
+ }
17
+
18
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
19
+ return (
20
+ <html lang="es">
21
+ <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
22
+ <AuthProvider>
23
+ {children}
24
+ <Toaster />
25
+ </AuthProvider>
26
+ </body>
27
+ </html>
28
+ )
29
+ }
@@ -1,5 +1,5 @@
1
1
  import { redirect } from 'next/navigation'
2
2
 
3
3
  export default function RootPage() {
4
- redirect('/setup')
4
+ redirect('/login')
5
5
  }
@@ -139,7 +139,7 @@ export default function SetupPage() {
139
139
  <div className="mb-10">
140
140
  <div className="inline-flex items-center gap-2 bg-zinc-900 text-white text-xs font-mono px-3 py-1.5 rounded-full mb-4">
141
141
  <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
142
- create-saas-ar
142
+ ar-saas
143
143
  </div>
144
144
  <h1 className="text-3xl font-bold text-zinc-900 mb-2">
145
145
  Tu proyecto está listo
@@ -22,13 +22,15 @@ const breadcrumbMap: Record<string, string> = {
22
22
  '/team': 'Equipo',
23
23
  }
24
24
 
25
- function getInitials(name: string) {
25
+ function getInitials(name: string): string {
26
26
  return name
27
- .split(' ')
27
+ .trim()
28
+ .split(/\s+/)
29
+ .filter(Boolean)
28
30
  .map((n) => n[0])
29
31
  .slice(0, 2)
30
32
  .join('')
31
- .toUpperCase()
33
+ .toUpperCase() || 'U'
32
34
  }
33
35
 
34
36
  export function DashboardHeader() {
@@ -183,7 +183,7 @@ export const siteConfig = {
183
183
  github: '',
184
184
  linkedin: '',
185
185
  },
186
- copyright: 2024 __SITE_NAME__. Todos los derechos reservados.',
186
+ copyright: ${new Date().getFullYear()} __SITE_NAME__. Todos los derechos reservados.`,
187
187
  },
188
188
 
189
189
  // ── Legal ──────────────────────────────────────────────────────────────────