project-mcp-server 2.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/LICENSE +21 -0
- package/README.md +335 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/scanner/actions.d.ts +9 -0
- package/dist/scanner/actions.js +77 -0
- package/dist/scanner/actions.js.map +1 -0
- package/dist/scanner/cache.d.ts +4 -0
- package/dist/scanner/cache.js +34 -0
- package/dist/scanner/cache.js.map +1 -0
- package/dist/scanner/components.d.ts +17 -0
- package/dist/scanner/components.js +74 -0
- package/dist/scanner/components.js.map +1 -0
- package/dist/scanner/prisma.d.ts +17 -0
- package/dist/scanner/prisma.js +75 -0
- package/dist/scanner/prisma.js.map +1 -0
- package/dist/scanner/routes.d.ts +11 -0
- package/dist/scanner/routes.js +98 -0
- package/dist/scanner/routes.js.map +1 -0
- package/dist/setup.d.ts +10 -0
- package/dist/setup.js +289 -0
- package/dist/setup.js.map +1 -0
- package/dist/tools/env/index.d.ts +2 -0
- package/dist/tools/env/index.js +152 -0
- package/dist/tools/env/index.js.map +1 -0
- package/dist/tools/generate/index.d.ts +2 -0
- package/dist/tools/generate/index.js +320 -0
- package/dist/tools/generate/index.js.map +1 -0
- package/dist/tools/project/index.d.ts +2 -0
- package/dist/tools/project/index.js +193 -0
- package/dist/tools/project/index.js.map +1 -0
- package/package.json +35 -0
- package/skills/commit.md +109 -0
- package/skills/infra.md +218 -0
- package/skills/nextauth.md +256 -0
- package/skills/nextjs.md +262 -0
- package/skills/prisma.md +281 -0
- package/skills/project-intelligence.SKILL.md +141 -0
- package/skills/security.md +353 -0
- package/skills/shadcn.md +299 -0
- package/skills/testing.md +188 -0
- package/skills/zod.md +253 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security
|
|
3
|
+
description: "Activar cuando se implementa autenticacion, se manejan inputs de usuario, se crean API endpoints, se trabaja con secrets o se prepara un deploy a produccion"
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
version: "1.0.0"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Skill: Seguridad — OWASP para Next.js + Prisma
|
|
10
|
+
|
|
11
|
+
## Cuándo cargar esta skill
|
|
12
|
+
|
|
13
|
+
- Implementar autenticación o autorización
|
|
14
|
+
- Manejar input de usuario o file uploads
|
|
15
|
+
- Crear API endpoints o Route Handlers
|
|
16
|
+
- Trabajar con secrets o credenciales
|
|
17
|
+
- Preparar deploy a producción
|
|
18
|
+
- Integrar APIs de terceros
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. Secrets Management
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
// ✗ NUNCA hardcodear secrets
|
|
26
|
+
const apiKey = "sk-proj-xxxxx"
|
|
27
|
+
|
|
28
|
+
// ✓ SIEMPRE usar variables de entorno
|
|
29
|
+
const apiKey = process.env.OPENAI_API_KEY
|
|
30
|
+
if (!apiKey) throw new Error("OPENAI_API_KEY no configurada")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Checklist:**
|
|
34
|
+
- No hardcodear API keys, tokens ni passwords
|
|
35
|
+
- Todos los secrets en variables de entorno
|
|
36
|
+
- `.env.local` en `.gitignore`
|
|
37
|
+
- No commitear secrets al historial de git
|
|
38
|
+
- Secrets de producción en la plataforma de hosting (Vercel, Railway)
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 2. Validación de Input
|
|
43
|
+
|
|
44
|
+
### Con Zod (patrón estándar del proyecto)
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { z } from "zod"
|
|
48
|
+
|
|
49
|
+
const CreateUserSchema = z.object({
|
|
50
|
+
email: z.string().email({ message: "Email inválido" }),
|
|
51
|
+
name: z.string().min(1).max(100),
|
|
52
|
+
age: z.number().int().min(0).max(150)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// En Server Action
|
|
56
|
+
export async function createUser(formData: FormData) {
|
|
57
|
+
"use server"
|
|
58
|
+
const parsed = CreateUserSchema.safeParse({
|
|
59
|
+
email: formData.get("email"),
|
|
60
|
+
name: formData.get("name"),
|
|
61
|
+
age: Number(formData.get("age"))
|
|
62
|
+
})
|
|
63
|
+
if (!parsed.success) {
|
|
64
|
+
return { error: parsed.error.flatten().fieldErrors }
|
|
65
|
+
}
|
|
66
|
+
// Continuar con datos validados
|
|
67
|
+
await prisma.user.create({ data: parsed.data })
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### File Uploads
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
function validateFileUpload(file: File) {
|
|
75
|
+
const maxSize = 5 * 1024 * 1024 // 5MB
|
|
76
|
+
if (file.size > maxSize) throw new Error("Archivo muy grande (máx 5MB)")
|
|
77
|
+
|
|
78
|
+
const allowedTypes = ["image/jpeg", "image/png", "image/webp"]
|
|
79
|
+
if (!allowedTypes.includes(file.type)) throw new Error("Tipo de archivo no permitido")
|
|
80
|
+
|
|
81
|
+
const ext = file.name.toLowerCase().match(/\.[^.]+$/)?.[0]
|
|
82
|
+
const allowedExts = [".jpg", ".jpeg", ".png", ".webp"]
|
|
83
|
+
if (!ext || !allowedExts.includes(ext)) throw new Error("Extensión no permitida")
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Checklist:**
|
|
88
|
+
- Todos los inputs validados con Zod antes de procesarlos
|
|
89
|
+
- File uploads restringidos (tamaño, tipo, extensión)
|
|
90
|
+
- No usar input de usuario directo en queries
|
|
91
|
+
- Validar con whitelist (no blacklist)
|
|
92
|
+
- Mensajes de error sin información sensible
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 3. SQL Injection — Prisma
|
|
97
|
+
|
|
98
|
+
Prisma previene SQL injection por defecto con queries parametrizadas. El riesgo está en `$queryRaw`:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
// ✗ NUNCA concatenar strings en raw queries
|
|
102
|
+
const users = await prisma.$queryRaw`SELECT * FROM users WHERE email = '${email}'`
|
|
103
|
+
|
|
104
|
+
// ✓ SIEMPRE usar tagged template de Prisma (parametrizado)
|
|
105
|
+
const users = await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`
|
|
106
|
+
|
|
107
|
+
// ✓ MEJOR: usar el query builder de Prisma
|
|
108
|
+
const users = await prisma.user.findMany({
|
|
109
|
+
where: { email },
|
|
110
|
+
select: { id: true, email: true, name: true } // select explícito
|
|
111
|
+
})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Checklist:**
|
|
115
|
+
- Usar el query builder de Prisma (no raw queries salvo necesidad)
|
|
116
|
+
- Si se usa `$queryRaw`, usar tagged template literals (no concatenación)
|
|
117
|
+
- Select explícito — nunca devolver todos los campos sin filtrar
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 4. Autenticación y Autorización
|
|
122
|
+
|
|
123
|
+
### Tokens en httpOnly cookies (no localStorage)
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
// ✗ localStorage es vulnerable a XSS
|
|
127
|
+
localStorage.setItem("token", token)
|
|
128
|
+
|
|
129
|
+
// ✓ httpOnly cookies
|
|
130
|
+
cookies().set("session", token, {
|
|
131
|
+
httpOnly: true,
|
|
132
|
+
secure: true,
|
|
133
|
+
sameSite: "strict",
|
|
134
|
+
maxAge: 60 * 60 // 1 hora
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Verificar autorización antes de operar
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
export async function deleteUser(userId: string) {
|
|
142
|
+
"use server"
|
|
143
|
+
const session = await auth()
|
|
144
|
+
if (!session?.user) throw new Error("No autenticado")
|
|
145
|
+
|
|
146
|
+
// Verificar permisos
|
|
147
|
+
if (!hasPermission(session.user, "users:delete")) {
|
|
148
|
+
throw new Error("Sin permisos")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await prisma.user.delete({ where: { id: userId } })
|
|
152
|
+
revalidateTag("users")
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Checklist:**
|
|
157
|
+
- Tokens en httpOnly cookies, nunca en localStorage
|
|
158
|
+
- Verificar auth en cada Server Action y Route Handler
|
|
159
|
+
- RBAC implementado con `hasPermission()`
|
|
160
|
+
- Sesiones con expiración configurada
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## 5. XSS Prevention
|
|
165
|
+
|
|
166
|
+
### Sanitizar HTML de usuario
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
import DOMPurify from "isomorphic-dompurify"
|
|
170
|
+
|
|
171
|
+
function UserContent({ html }: { html: string }) {
|
|
172
|
+
const clean = DOMPurify.sanitize(html, {
|
|
173
|
+
ALLOWED_TAGS: ["b", "i", "em", "strong", "p", "br", "ul", "li"],
|
|
174
|
+
ALLOWED_ATTR: []
|
|
175
|
+
})
|
|
176
|
+
return <div dangerouslySetInnerHTML={{ __html: clean }} />
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Content Security Policy en Next.js
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
// next.config.ts
|
|
184
|
+
const securityHeaders = [
|
|
185
|
+
{
|
|
186
|
+
key: "Content-Security-Policy",
|
|
187
|
+
value: [
|
|
188
|
+
"default-src 'self'",
|
|
189
|
+
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
|
190
|
+
"style-src 'self' 'unsafe-inline'",
|
|
191
|
+
"img-src 'self' data: https:",
|
|
192
|
+
"font-src 'self'",
|
|
193
|
+
"connect-src 'self' https://api.tudominio.com"
|
|
194
|
+
].join("; ")
|
|
195
|
+
},
|
|
196
|
+
{ key: "X-Frame-Options", value: "DENY" },
|
|
197
|
+
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
198
|
+
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }
|
|
199
|
+
]
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Checklist:**
|
|
203
|
+
- HTML de usuario sanitizado con DOMPurify
|
|
204
|
+
- CSP headers configurados
|
|
205
|
+
- X-Frame-Options, X-Content-Type-Options configurados
|
|
206
|
+
- No renderizar contenido dinámico sin sanitizar
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## 6. CSRF Protection
|
|
211
|
+
|
|
212
|
+
Next.js Server Actions incluyen protección CSRF por defecto (Origin header check). Para Route Handlers custom:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
export async function POST(request: Request) {
|
|
216
|
+
const origin = request.headers.get("origin")
|
|
217
|
+
const allowedOrigins = [process.env.NEXT_PUBLIC_APP_URL]
|
|
218
|
+
|
|
219
|
+
if (!origin || !allowedOrigins.includes(origin)) {
|
|
220
|
+
return Response.json({ error: "Forbidden" }, { status: 403 })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Procesar request
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Checklist:**
|
|
228
|
+
- Server Actions usan CSRF nativo de Next.js
|
|
229
|
+
- Route Handlers validan Origin header
|
|
230
|
+
- Cookies con `SameSite=Strict`
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## 7. Rate Limiting
|
|
235
|
+
|
|
236
|
+
### En Route Handlers con headers
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
|
240
|
+
|
|
241
|
+
function rateLimit(ip: string, limit = 100, windowMs = 15 * 60 * 1000): boolean {
|
|
242
|
+
const now = Date.now()
|
|
243
|
+
const record = rateLimitMap.get(ip)
|
|
244
|
+
|
|
245
|
+
if (!record || now > record.resetTime) {
|
|
246
|
+
rateLimitMap.set(ip, { count: 1, resetTime: now + windowMs })
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
record.count++
|
|
251
|
+
return record.count <= limit
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function POST(request: Request) {
|
|
255
|
+
const ip = request.headers.get("x-forwarded-for") ?? "unknown"
|
|
256
|
+
|
|
257
|
+
if (!rateLimit(ip, 10, 60_000)) { // 10 req/min
|
|
258
|
+
return Response.json({ error: "Too many requests" }, { status: 429 })
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Procesar request
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**Checklist:**
|
|
266
|
+
- Rate limiting en todos los API endpoints
|
|
267
|
+
- Límites más estrictos en operaciones costosas (búsquedas, auth)
|
|
268
|
+
- Rate limiting por IP y por usuario autenticado
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## 8. Logging Seguro
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
// ✗ NUNCA loguear datos sensibles
|
|
276
|
+
console.log("Login:", { email, password })
|
|
277
|
+
console.log("Payment:", { cardNumber, cvv })
|
|
278
|
+
|
|
279
|
+
// ✓ Redactar datos sensibles
|
|
280
|
+
console.log("Login:", { email, userId })
|
|
281
|
+
console.log("Payment:", { last4: card.last4, userId })
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Errores al usuario vs servidor
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// ✗ NUNCA exponer detalles internos
|
|
288
|
+
catch (error) {
|
|
289
|
+
return Response.json({ error: error.message, stack: error.stack }, { status: 500 })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ✓ Mensajes genéricos al usuario, detalle en server logs
|
|
293
|
+
catch (error) {
|
|
294
|
+
console.error("Error interno:", error)
|
|
295
|
+
return Response.json({ error: "Ocurrió un error. Intentá de nuevo." }, { status: 500 })
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**Checklist:**
|
|
300
|
+
- No loguear passwords, tokens ni secrets
|
|
301
|
+
- Errores genéricos para el usuario
|
|
302
|
+
- Errores detallados solo en server logs
|
|
303
|
+
- No exponer stack traces al usuario
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## 9. Dependencias
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
# Verificar vulnerabilidades
|
|
311
|
+
pnpm audit
|
|
312
|
+
|
|
313
|
+
# Actualizar dependencias
|
|
314
|
+
pnpm update
|
|
315
|
+
|
|
316
|
+
# Ver paquetes desactualizados
|
|
317
|
+
pnpm outdated
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**Checklist:**
|
|
321
|
+
- Dependencias actualizadas
|
|
322
|
+
- Sin vulnerabilidades conocidas (`pnpm audit` limpio)
|
|
323
|
+
- Lock file commiteado
|
|
324
|
+
- Dependabot o Renovate habilitado en GitHub
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Checklist Pre-Deploy
|
|
329
|
+
|
|
330
|
+
Antes de CUALQUIER deploy a producción:
|
|
331
|
+
|
|
332
|
+
- [ ] **Secrets**: No hardcodeados, todos en env vars
|
|
333
|
+
- [ ] **Input Validation**: Todos los inputs validados con Zod
|
|
334
|
+
- [ ] **SQL Injection**: Queries parametrizadas (Prisma builder o tagged template)
|
|
335
|
+
- [ ] **XSS**: Contenido de usuario sanitizado
|
|
336
|
+
- [ ] **CSRF**: Protección habilitada (Server Actions + Origin check)
|
|
337
|
+
- [ ] **Auth**: Tokens en httpOnly cookies, RBAC verificado
|
|
338
|
+
- [ ] **Rate Limiting**: Habilitado en todos los endpoints
|
|
339
|
+
- [ ] **HTTPS**: Forzado en producción
|
|
340
|
+
- [ ] **Security Headers**: CSP, X-Frame-Options configurados
|
|
341
|
+
- [ ] **Error Handling**: Sin datos sensibles en errores
|
|
342
|
+
- [ ] **Logging**: Sin datos sensibles en logs
|
|
343
|
+
- [ ] **Dependencias**: Actualizadas, sin vulnerabilidades
|
|
344
|
+
- [ ] **CORS**: Configurado correctamente
|
|
345
|
+
- [ ] **File Uploads**: Validados (tamaño, tipo)
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Referencias
|
|
350
|
+
|
|
351
|
+
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
|
352
|
+
- [Next.js Security](https://nextjs.org/docs/app/building-your-application/configuring/security-headers)
|
|
353
|
+
- [Prisma Security Best Practices](https://www.prisma.io/docs/orm/prisma-client/queries/raw-database-access)
|
package/skills/shadcn.md
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: shadcn
|
|
3
|
+
description: "Activar cuando se crean o modifican componentes de UI, se agregan componentes de shadcn o se trabaja con Tailwind CSS"
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
version: "1.0.0"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Skill: shadcn/ui + Tailwind CSS 4
|
|
10
|
+
|
|
11
|
+
## Cuándo cargar esta skill
|
|
12
|
+
- Crear o modificar componentes de UI
|
|
13
|
+
- Agregar nuevos componentes de shadcn
|
|
14
|
+
- Trabajar con Tailwind CSS 4
|
|
15
|
+
- Implementar dark/light mode
|
|
16
|
+
- Usar Radix UI primitivos directamente
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Tailwind CSS 4 — diferencias clave con v3
|
|
21
|
+
|
|
22
|
+
```css
|
|
23
|
+
/* ✓ Tailwind 4: importar en CSS, no usar tailwind.config.js */
|
|
24
|
+
@import "tailwindcss";
|
|
25
|
+
|
|
26
|
+
/* Definir design tokens como variables CSS */
|
|
27
|
+
@theme {
|
|
28
|
+
--color-primary: oklch(0.5 0.2 260);
|
|
29
|
+
--color-primary-foreground: oklch(0.99 0 0);
|
|
30
|
+
--font-sans: "Inter", sans-serif;
|
|
31
|
+
--radius: 0.5rem;
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// ✗ Tailwind 4: ya no existe tailwind.config.js como antes
|
|
37
|
+
// ✓ Toda la configuración va en globals.css con @theme
|
|
38
|
+
|
|
39
|
+
// Las clases siguen siendo las mismas en templates
|
|
40
|
+
<div className="bg-primary text-primary-foreground rounded-lg p-4">
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Variables CSS de shadcn en Tailwind 4
|
|
44
|
+
```css
|
|
45
|
+
/* globals.css */
|
|
46
|
+
@import "tailwindcss";
|
|
47
|
+
|
|
48
|
+
@theme {
|
|
49
|
+
--background: 0 0% 100%;
|
|
50
|
+
--foreground: 240 10% 3.9%;
|
|
51
|
+
--card: 0 0% 100%;
|
|
52
|
+
--card-foreground: 240 10% 3.9%;
|
|
53
|
+
--primary: 240 5.9% 10%;
|
|
54
|
+
--primary-foreground: 0 0% 98%;
|
|
55
|
+
--muted: 240 4.8% 95.9%;
|
|
56
|
+
--muted-foreground: 240 3.8% 46.1%;
|
|
57
|
+
--border: 240 5.9% 90%;
|
|
58
|
+
--radius: 0.5rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.dark {
|
|
62
|
+
--background: 240 10% 3.9%;
|
|
63
|
+
--foreground: 0 0% 98%;
|
|
64
|
+
/* ... resto de dark mode vars */
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## shadcn/ui — agregar componentes
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Agregar un componente nuevo (siempre con pnpm dlx)
|
|
74
|
+
pnpm dlx shadcn@latest add button
|
|
75
|
+
pnpm dlx shadcn@latest add card dialog form input table
|
|
76
|
+
pnpm dlx shadcn@latest add data-table # con columnas configurables
|
|
77
|
+
|
|
78
|
+
# Ver todos los disponibles
|
|
79
|
+
pnpm dlx shadcn@latest add --list
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Los componentes se generan en `src/components/ui/`. **No modificar** esos archivos
|
|
83
|
+
directamente — crear wrappers en `src/components/[feature]/`.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Patrones de componentes
|
|
88
|
+
|
|
89
|
+
### Componente con CVA (variantes)
|
|
90
|
+
```typescript
|
|
91
|
+
// components/ui/badge.tsx — patrón típico de shadcn
|
|
92
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
93
|
+
import { cn } from "@/lib/utils"
|
|
94
|
+
|
|
95
|
+
const badgeVariants = cva(
|
|
96
|
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
|
97
|
+
{
|
|
98
|
+
variants: {
|
|
99
|
+
variant: {
|
|
100
|
+
default: "border-transparent bg-primary text-primary-foreground",
|
|
101
|
+
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
|
102
|
+
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
|
103
|
+
outline: "text-foreground"
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
defaultVariants: { variant: "default" }
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
interface BadgeProps
|
|
111
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
112
|
+
VariantProps<typeof badgeVariants> {}
|
|
113
|
+
|
|
114
|
+
export function Badge({ className, variant, ...props }: BadgeProps) {
|
|
115
|
+
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Componente wrapper de shadcn (extensión)
|
|
120
|
+
```typescript
|
|
121
|
+
// components/users/user-card.tsx — wrapper, no modifica el original
|
|
122
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
123
|
+
import { Badge } from "@/components/ui/badge"
|
|
124
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
125
|
+
import { cn } from "@/lib/utils"
|
|
126
|
+
|
|
127
|
+
interface UserCardProps {
|
|
128
|
+
user: { name: string; email: string; role: string; avatar?: string }
|
|
129
|
+
className?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function UserCard({ user, className }: UserCardProps) {
|
|
133
|
+
return (
|
|
134
|
+
<Card className={cn("hover:shadow-md transition-shadow", className)}>
|
|
135
|
+
<CardHeader className="flex flex-row items-center gap-3 space-y-0">
|
|
136
|
+
<Avatar>
|
|
137
|
+
<AvatarImage src={user.avatar} />
|
|
138
|
+
<AvatarFallback>{user.name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
139
|
+
</Avatar>
|
|
140
|
+
<div>
|
|
141
|
+
<CardTitle className="text-base">{user.name}</CardTitle>
|
|
142
|
+
<p className="text-sm text-muted-foreground">{user.email}</p>
|
|
143
|
+
</div>
|
|
144
|
+
<Badge variant="secondary" className="ml-auto">{user.role}</Badge>
|
|
145
|
+
</CardHeader>
|
|
146
|
+
</Card>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Forms con shadcn/ui + React Hook Form + Zod
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
"use client"
|
|
157
|
+
import { useForm } from "react-hook-form"
|
|
158
|
+
import { zodResolver } from "@hookform/resolvers/zod"
|
|
159
|
+
import { z } from "zod"
|
|
160
|
+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
|
161
|
+
import { Input } from "@/components/ui/input"
|
|
162
|
+
import { Button } from "@/components/ui/button"
|
|
163
|
+
import { useActionState } from "react"
|
|
164
|
+
import { createUser } from "@/server/actions/users"
|
|
165
|
+
|
|
166
|
+
const FormSchema = z.object({
|
|
167
|
+
name: z.string().min(2, "Mínimo 2 caracteres"),
|
|
168
|
+
email: z.string().email("Email inválido")
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
export function CreateUserForm() {
|
|
172
|
+
const form = useForm<z.infer<typeof FormSchema>>({
|
|
173
|
+
resolver: zodResolver(FormSchema),
|
|
174
|
+
defaultValues: { name: "", email: "" }
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const [state, action, isPending] = useActionState(createUser, null)
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<Form {...form}>
|
|
181
|
+
<form action={action} className="space-y-4">
|
|
182
|
+
<FormField
|
|
183
|
+
control={form.control}
|
|
184
|
+
name="name"
|
|
185
|
+
render={({ field }) => (
|
|
186
|
+
<FormItem>
|
|
187
|
+
<FormLabel>Nombre</FormLabel>
|
|
188
|
+
<FormControl>
|
|
189
|
+
<Input placeholder="Alan García" {...field} />
|
|
190
|
+
</FormControl>
|
|
191
|
+
<FormMessage />
|
|
192
|
+
</FormItem>
|
|
193
|
+
)}
|
|
194
|
+
/>
|
|
195
|
+
<FormField
|
|
196
|
+
control={form.control}
|
|
197
|
+
name="email"
|
|
198
|
+
render={({ field }) => (
|
|
199
|
+
<FormItem>
|
|
200
|
+
<FormLabel>Email</FormLabel>
|
|
201
|
+
<FormControl>
|
|
202
|
+
<Input type="email" {...field} />
|
|
203
|
+
</FormControl>
|
|
204
|
+
<FormMessage />
|
|
205
|
+
</FormItem>
|
|
206
|
+
)}
|
|
207
|
+
/>
|
|
208
|
+
<Button type="submit" disabled={isPending}>
|
|
209
|
+
{isPending ? "Creando..." : "Crear usuario"}
|
|
210
|
+
</Button>
|
|
211
|
+
</form>
|
|
212
|
+
</Form>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Dark mode con next-themes
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
// app/layout.tsx
|
|
223
|
+
import { ThemeProvider } from "next-themes"
|
|
224
|
+
|
|
225
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
226
|
+
return (
|
|
227
|
+
<html lang="es" suppressHydrationWarning>
|
|
228
|
+
<body>
|
|
229
|
+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
|
230
|
+
{children}
|
|
231
|
+
</ThemeProvider>
|
|
232
|
+
</body>
|
|
233
|
+
</html>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// components/theme-toggle.tsx
|
|
238
|
+
"use client"
|
|
239
|
+
import { useTheme } from "next-themes"
|
|
240
|
+
import { Button } from "@/components/ui/button"
|
|
241
|
+
import { Moon, Sun } from "lucide-react"
|
|
242
|
+
|
|
243
|
+
export function ThemeToggle() {
|
|
244
|
+
const { theme, setTheme } = useTheme()
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<Button
|
|
248
|
+
variant="ghost"
|
|
249
|
+
size="icon"
|
|
250
|
+
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
251
|
+
>
|
|
252
|
+
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
|
253
|
+
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
|
254
|
+
</Button>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Toasts con Sonner
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
// app/layout.tsx — agregar el Toaster
|
|
265
|
+
import { Toaster } from "sonner"
|
|
266
|
+
|
|
267
|
+
// En el layout
|
|
268
|
+
<Toaster richColors position="bottom-right" />
|
|
269
|
+
|
|
270
|
+
// Uso en Client Components
|
|
271
|
+
import { toast } from "sonner"
|
|
272
|
+
|
|
273
|
+
// Éxito, error, promesa
|
|
274
|
+
toast.success("Usuario creado")
|
|
275
|
+
toast.error("Error al guardar")
|
|
276
|
+
toast.promise(saveUser(), {
|
|
277
|
+
loading: "Guardando...",
|
|
278
|
+
success: "Guardado",
|
|
279
|
+
error: "Error al guardar"
|
|
280
|
+
})
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## cn() — utilitaria de clases
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
// lib/utils.ts
|
|
289
|
+
import { clsx, type ClassValue } from "clsx"
|
|
290
|
+
import { twMerge } from "tailwind-merge"
|
|
291
|
+
|
|
292
|
+
export function cn(...inputs: ClassValue[]) {
|
|
293
|
+
return twMerge(clsx(inputs))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Uso: merge condicional sin conflictos de Tailwind
|
|
297
|
+
cn("px-4 py-2", isActive && "bg-primary", className)
|
|
298
|
+
// ✓ Resuelve conflictos: cn("p-4", "px-2") → "p-4 px-2" (px-2 gana)
|
|
299
|
+
```
|