create-velox-app 0.6.31 → 0.6.51
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/CHANGELOG.md +120 -0
- package/GUIDE.md +230 -0
- package/dist/cli.js +1 -0
- package/dist/index.js +14 -4
- package/dist/templates/auth.js +10 -0
- package/dist/templates/index.js +30 -1
- package/dist/templates/placeholders.js +0 -3
- package/dist/templates/rsc-auth.d.ts +12 -0
- package/dist/templates/rsc-auth.js +208 -0
- package/dist/templates/rsc.js +40 -1
- package/dist/templates/shared/css-generator.d.ts +26 -0
- package/dist/templates/shared/css-generator.js +553 -0
- package/dist/templates/shared/index.d.ts +3 -0
- package/dist/templates/shared/index.js +3 -0
- package/dist/templates/shared/rsc-styles.d.ts +54 -0
- package/dist/templates/shared/rsc-styles.js +68 -0
- package/dist/templates/shared/theme.d.ts +133 -0
- package/dist/templates/shared/theme.js +141 -0
- package/dist/templates/spa.js +10 -0
- package/dist/templates/trpc.js +10 -0
- package/dist/templates/types.d.ts +2 -1
- package/dist/templates/types.js +6 -0
- package/package.json +6 -3
- package/src/templates/source/api/config/database.ts +13 -32
- package/src/templates/source/api/docker-compose.yml +21 -0
- package/src/templates/source/root/CLAUDE.auth.md +6 -0
- package/src/templates/source/root/CLAUDE.default.md +6 -0
- package/src/templates/source/rsc/CLAUDE.md +56 -2
- package/src/templates/source/rsc/app/actions/posts.ts +1 -1
- package/src/templates/source/rsc/app/actions/users.ts +111 -20
- package/src/templates/source/rsc/app/layouts/dashboard.tsx +21 -16
- package/src/templates/source/rsc/app/layouts/marketing.tsx +34 -0
- package/src/templates/source/rsc/app/layouts/minimal-content.tsx +21 -0
- package/src/templates/source/rsc/app/layouts/minimal.tsx +86 -5
- package/src/templates/source/rsc/app/layouts/root.tsx +148 -44
- package/src/templates/source/rsc/docker-compose.yml +21 -0
- package/src/templates/source/rsc/package.json +3 -3
- package/src/templates/source/rsc/src/api/database.ts +13 -32
- package/src/templates/source/rsc/src/api/handler.ts +1 -1
- package/src/templates/source/rsc/src/entry.client.tsx +65 -18
- package/src/templates/source/rsc-auth/CLAUDE.md +230 -0
- package/src/templates/source/rsc-auth/app/actions/auth.ts +112 -0
- package/src/templates/source/rsc-auth/app/actions/users.ts +289 -0
- package/src/templates/source/rsc-auth/app/layouts/dashboard.tsx +132 -0
- package/src/templates/source/rsc-auth/app/layouts/marketing.tsx +59 -0
- package/src/templates/source/rsc-auth/app/layouts/minimal-content.tsx +21 -0
- package/src/templates/source/rsc-auth/app/layouts/minimal.tsx +111 -0
- package/src/templates/source/rsc-auth/app/layouts/root.tsx +355 -0
- package/src/templates/source/rsc-auth/app/pages/_not-found.tsx +15 -0
- package/src/templates/source/rsc-auth/app/pages/auth/login.tsx +198 -0
- package/src/templates/source/rsc-auth/app/pages/auth/register.tsx +225 -0
- package/src/templates/source/rsc-auth/app/pages/dashboard/index.tsx +267 -0
- package/src/templates/source/rsc-auth/app/pages/index.tsx +83 -0
- package/src/templates/source/rsc-auth/app/pages/users.tsx +47 -0
- package/src/templates/source/rsc-auth/app.config.ts +12 -0
- package/src/templates/source/rsc-auth/docker-compose.yml +21 -0
- package/src/templates/source/rsc-auth/env.example +11 -0
- package/src/templates/source/rsc-auth/gitignore +34 -0
- package/src/templates/source/rsc-auth/package.json +44 -0
- package/src/templates/source/rsc-auth/prisma/schema.prisma +23 -0
- package/src/templates/source/rsc-auth/prisma.config.ts +22 -0
- package/src/templates/source/rsc-auth/public/favicon.svg +4 -0
- package/src/templates/source/rsc-auth/src/api/database.ts +129 -0
- package/src/templates/source/rsc-auth/src/api/handler.ts +85 -0
- package/src/templates/source/rsc-auth/src/api/procedures/auth.ts +262 -0
- package/src/templates/source/rsc-auth/src/api/procedures/health.ts +48 -0
- package/src/templates/source/rsc-auth/src/api/procedures/users.ts +87 -0
- package/src/templates/source/rsc-auth/src/api/schemas/auth.ts +79 -0
- package/src/templates/source/rsc-auth/src/api/schemas/user.ts +38 -0
- package/src/templates/source/rsc-auth/src/api/utils/auth.ts +157 -0
- package/src/templates/source/rsc-auth/src/entry.client.tsx +63 -0
- package/src/templates/source/rsc-auth/src/entry.server.tsx +262 -0
- package/src/templates/source/rsc-auth/tsconfig.json +24 -0
- package/src/templates/source/shared/scripts/check-client-imports.sh +75 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This is a VeloxTS full-stack application using React Server Components with JWT Authentication.
|
|
4
|
+
|
|
5
|
+
## Project Structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
__PROJECT_NAME__/
|
|
9
|
+
├── app/ # Application layer (RSC)
|
|
10
|
+
│ ├── pages/ # File-based routing (RSC pages)
|
|
11
|
+
│ │ ├── index.tsx # Home page (/)
|
|
12
|
+
│ │ ├── users.tsx # Users page (/users)
|
|
13
|
+
│ │ ├── auth/ # Auth pages
|
|
14
|
+
│ │ │ ├── login.tsx # Login page
|
|
15
|
+
│ │ │ └── register.tsx # Registration page
|
|
16
|
+
│ │ └── dashboard/ # Protected pages
|
|
17
|
+
│ │ └── index.tsx # Dashboard (requires auth)
|
|
18
|
+
│ ├── layouts/ # Layout components
|
|
19
|
+
│ │ └── root.tsx # Root layout
|
|
20
|
+
│ └── actions/ # Server actions
|
|
21
|
+
│ ├── users.ts # User actions with validated()
|
|
22
|
+
│ └── auth.ts # Auth actions
|
|
23
|
+
├── src/ # Source layer
|
|
24
|
+
│ ├── api/ # API layer (Fastify embedded in Vinxi)
|
|
25
|
+
│ │ ├── handler.ts # API handler with auth plugin
|
|
26
|
+
│ │ ├── database.ts # Prisma client
|
|
27
|
+
│ │ ├── procedures/ # API procedure definitions
|
|
28
|
+
│ │ │ ├── auth.ts # Authentication procedures
|
|
29
|
+
│ │ │ ├── health.ts # Health check
|
|
30
|
+
│ │ │ └── users.ts # User CRUD
|
|
31
|
+
│ │ ├── schemas/ # Zod validation schemas
|
|
32
|
+
│ │ └── utils/ # Utilities
|
|
33
|
+
│ │ └── auth.ts # JWT helpers, token store
|
|
34
|
+
│ ├── entry.client.tsx # Client hydration
|
|
35
|
+
│ └── entry.server.tsx # Server rendering
|
|
36
|
+
├── prisma/
|
|
37
|
+
│ └── schema.prisma # Database schema (with password field)
|
|
38
|
+
├── app.config.ts # Vinxi configuration
|
|
39
|
+
└── package.json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Authentication
|
|
43
|
+
|
|
44
|
+
### JWT-Based Authentication with httpOnly Cookies
|
|
45
|
+
- Access tokens (15 min expiry) stored in httpOnly cookies
|
|
46
|
+
- Refresh tokens (7 day expiry) stored in httpOnly cookies
|
|
47
|
+
- Secure password hashing (bcrypt)
|
|
48
|
+
- Rate limiting on auth endpoints
|
|
49
|
+
- Token revocation support
|
|
50
|
+
- Tokens are NOT accessible to JavaScript (XSS protection)
|
|
51
|
+
|
|
52
|
+
### Auth Architecture
|
|
53
|
+
|
|
54
|
+
This template uses the **Procedure Bridge Pattern** for authentication:
|
|
55
|
+
|
|
56
|
+
1. **Server Actions** (`app/actions/auth.ts`) call auth procedures directly
|
|
57
|
+
2. **Tokens are stored in httpOnly cookies** by server actions (not localStorage)
|
|
58
|
+
3. **API requests use cookies** via `credentials: 'include'`
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// app/actions/auth.ts - Procedure Bridge Pattern
|
|
62
|
+
'use server';
|
|
63
|
+
import { authAction } from '@veloxts/web/server';
|
|
64
|
+
import { authProcedures } from '@/api/procedures/auth';
|
|
65
|
+
import { db } from '@/api/database';
|
|
66
|
+
|
|
67
|
+
// Login: executes procedure directly, stores tokens in httpOnly cookies
|
|
68
|
+
export const login = authAction.fromTokenProcedure(
|
|
69
|
+
authProcedures.procedures.createSession,
|
|
70
|
+
{ parseFormData: true, contextExtensions: { db }, skipGuards: true }
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Register: same pattern
|
|
74
|
+
export const register = authAction.fromTokenProcedure(
|
|
75
|
+
authProcedures.procedures.createAccount,
|
|
76
|
+
{ parseFormData: true, contextExtensions: { db }, skipGuards: true }
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Logout: clears auth cookies
|
|
80
|
+
export const logout = authAction.fromLogoutProcedure(
|
|
81
|
+
authProcedures.procedures.deleteSession,
|
|
82
|
+
{ contextExtensions: { db }, skipGuards: true }
|
|
83
|
+
);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Why Procedure Bridge vs HTTP Fetch?
|
|
87
|
+
|
|
88
|
+
| Aspect | Procedure Bridge | HTTP Fetch |
|
|
89
|
+
|--------|-----------------|------------|
|
|
90
|
+
| Network | Direct in-process | HTTP round-trip |
|
|
91
|
+
| Type Safety | Full inference | Lost at boundary |
|
|
92
|
+
| Cookie Access | Yes (server action) | No (client-side) |
|
|
93
|
+
| Guards/Middleware | Reused | Bypassed |
|
|
94
|
+
| URL Hardcoding | None | Required |
|
|
95
|
+
|
|
96
|
+
### Auth Endpoints (REST API)
|
|
97
|
+
```
|
|
98
|
+
POST /api/auth/register - Create new account
|
|
99
|
+
POST /api/auth/login - Authenticate, get tokens
|
|
100
|
+
POST /api/auth/refresh - Refresh access token
|
|
101
|
+
POST /api/auth/logout - Revoke current token
|
|
102
|
+
GET /api/auth/me - Get current user (protected)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Client-Side Auth
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
// Dashboard - uses cookies automatically
|
|
109
|
+
const response = await fetch('/api/auth/me', {
|
|
110
|
+
credentials: 'include', // Sends httpOnly cookies
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Logout - uses server action
|
|
114
|
+
import { logout } from '@/app/actions/auth';
|
|
115
|
+
await logout(); // Clears cookies server-side
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Password Requirements
|
|
119
|
+
- Minimum 12 characters
|
|
120
|
+
- At least one uppercase letter
|
|
121
|
+
- At least one lowercase letter
|
|
122
|
+
- At least one number
|
|
123
|
+
- Not a common password
|
|
124
|
+
|
|
125
|
+
## Server Actions with validated()
|
|
126
|
+
|
|
127
|
+
Use the `validated()` helper for secure server actions:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// app/actions/users.ts
|
|
131
|
+
'use server';
|
|
132
|
+
import { validated, validatedMutation, validatedQuery } from '@veloxts/web/server';
|
|
133
|
+
import { z } from 'zod';
|
|
134
|
+
|
|
135
|
+
// Public query (no auth required)
|
|
136
|
+
export const searchUsers = validatedQuery(
|
|
137
|
+
z.object({ query: z.string().optional() }),
|
|
138
|
+
async (input) => {
|
|
139
|
+
return db.user.findMany({ where: { name: { contains: input.query } } });
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Authenticated mutation (ctx.user available)
|
|
144
|
+
export const updateProfile = validatedMutation(
|
|
145
|
+
z.object({ name: z.string() }),
|
|
146
|
+
async (input, ctx) => {
|
|
147
|
+
return db.user.update({ where: { id: ctx.user.id }, data: input });
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Custom security options with rate limiting
|
|
152
|
+
export const createUser = validated(
|
|
153
|
+
CreateUserSchema,
|
|
154
|
+
async (input) => { /* ... */ },
|
|
155
|
+
{
|
|
156
|
+
rateLimit: { maxRequests: 10, windowMs: 60_000 },
|
|
157
|
+
maxInputSize: 10 * 1024,
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Role-based authorization
|
|
162
|
+
export const adminDeleteUser = validated(
|
|
163
|
+
DeleteUserSchema,
|
|
164
|
+
async (input) => { /* ... */ },
|
|
165
|
+
{
|
|
166
|
+
requireAuth: true,
|
|
167
|
+
requireRoles: ['admin'],
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Security Features
|
|
173
|
+
- **Input validation** - Zod schema validation
|
|
174
|
+
- **Input sanitization** - Prototype pollution prevention
|
|
175
|
+
- **Input size limits** - DoS protection (default 1MB)
|
|
176
|
+
- **Rate limiting** - Sliding window per IP
|
|
177
|
+
- **Authentication** - Optional via `requireAuth: true`
|
|
178
|
+
- **Authorization** - Role-based via `requireRoles`
|
|
179
|
+
|
|
180
|
+
## Commands
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
# Development
|
|
184
|
+
__RUN_CMD__ dev # Start dev server
|
|
185
|
+
|
|
186
|
+
# Database
|
|
187
|
+
__RUN_CMD__ db:generate # Generate Prisma client
|
|
188
|
+
__RUN_CMD__ db:push # Push schema to database
|
|
189
|
+
__RUN_CMD__ db:migrate # Run migrations
|
|
190
|
+
__RUN_CMD__ db:studio # Open Prisma Studio
|
|
191
|
+
|
|
192
|
+
# Production
|
|
193
|
+
__RUN_CMD__ build # Build for production
|
|
194
|
+
__RUN_CMD__ start # Start production server
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
1. Run `__RUN_CMD__ db:push` to set up the database
|
|
200
|
+
2. Run `__RUN_CMD__ dev` to start the development server
|
|
201
|
+
3. Open http://localhost:__API_PORT__ in your browser
|
|
202
|
+
4. Register an account at /auth/register
|
|
203
|
+
5. Access protected pages at /dashboard
|
|
204
|
+
|
|
205
|
+
## Environment Variables
|
|
206
|
+
|
|
207
|
+
Required for production:
|
|
208
|
+
```bash
|
|
209
|
+
# Generate with: openssl rand -base64 64
|
|
210
|
+
JWT_SECRET="..."
|
|
211
|
+
JWT_REFRESH_SECRET="..."
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## API Endpoints
|
|
215
|
+
|
|
216
|
+
### Public
|
|
217
|
+
- `GET /api/health` - Health check
|
|
218
|
+
- `GET /api/users` - List users
|
|
219
|
+
|
|
220
|
+
### Authentication
|
|
221
|
+
- `POST /api/auth/register` - Create account (rate limited)
|
|
222
|
+
- `POST /api/auth/login` - Get tokens (rate limited)
|
|
223
|
+
- `POST /api/auth/refresh` - Refresh token
|
|
224
|
+
- `POST /api/auth/logout` - Logout (protected)
|
|
225
|
+
- `GET /api/auth/me` - Current user (protected)
|
|
226
|
+
|
|
227
|
+
### Protected (require Bearer token)
|
|
228
|
+
- `POST /api/users` - Create user
|
|
229
|
+
- `PUT /api/users/:id` - Update user
|
|
230
|
+
- `DELETE /api/users/:id` - Delete user
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Authentication Server Actions - Procedure Bridge Pattern
|
|
5
|
+
*
|
|
6
|
+
* Uses authAction helpers to execute auth procedures directly,
|
|
7
|
+
* storing tokens in httpOnly cookies for security.
|
|
8
|
+
*
|
|
9
|
+
* This is the recommended pattern for authentication in VeloxTS RSC apps:
|
|
10
|
+
* - Tokens are stored in httpOnly cookies (not accessible to JavaScript)
|
|
11
|
+
* - The procedure's validation, guards, and business logic are reused
|
|
12
|
+
* - No HTTP round-trip overhead (direct in-process execution)
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* // In a client component
|
|
17
|
+
* const result = await login({ email: 'user@example.com', password: '...' });
|
|
18
|
+
*
|
|
19
|
+
* if (result.success) {
|
|
20
|
+
* // Tokens are stored in cookies automatically
|
|
21
|
+
* redirect('/dashboard');
|
|
22
|
+
* } else {
|
|
23
|
+
* setError(result.error.message);
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { authAction, validated } from '@veloxts/web/server';
|
|
29
|
+
import { z } from 'zod';
|
|
30
|
+
|
|
31
|
+
import { db } from '@/api/database';
|
|
32
|
+
import { authProcedures } from '@/api/procedures/auth';
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Auth Actions (Procedure Bridge Pattern)
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Login action - validates credentials, stores tokens in httpOnly cookies
|
|
40
|
+
*
|
|
41
|
+
* Uses the procedure bridge pattern to:
|
|
42
|
+
* 1. Execute the createSession procedure directly (no HTTP)
|
|
43
|
+
* 2. Store tokens in httpOnly cookies via onSuccess callback
|
|
44
|
+
* 3. Return a sanitized response (tokens stripped for security)
|
|
45
|
+
*
|
|
46
|
+
* Rate limited via the procedure's middleware (5 attempts per 15 minutes).
|
|
47
|
+
*/
|
|
48
|
+
export const login = authAction.fromTokenProcedure(authProcedures.procedures.createSession, {
|
|
49
|
+
parseFormData: true,
|
|
50
|
+
contextExtensions: { db },
|
|
51
|
+
skipGuards: true, // Login has no guards, only rate limit middleware
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register action - creates new account, stores tokens in httpOnly cookies
|
|
56
|
+
*
|
|
57
|
+
* Uses the procedure bridge pattern to:
|
|
58
|
+
* 1. Execute the createAccount procedure directly
|
|
59
|
+
* 2. Store tokens in httpOnly cookies
|
|
60
|
+
* 3. Return sanitized response
|
|
61
|
+
*
|
|
62
|
+
* Rate limited via the procedure's middleware (3 attempts per hour).
|
|
63
|
+
*/
|
|
64
|
+
export const register = authAction.fromTokenProcedure(authProcedures.procedures.createAccount, {
|
|
65
|
+
parseFormData: true,
|
|
66
|
+
contextExtensions: { db },
|
|
67
|
+
skipGuards: true, // Register has no guards, only rate limit middleware
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Logout action - clears auth cookies
|
|
72
|
+
*
|
|
73
|
+
* For logout, we clear cookies client-side since the deleteSession procedure
|
|
74
|
+
* requires authenticated context which is complex to set up in server actions.
|
|
75
|
+
* The token will naturally expire.
|
|
76
|
+
*
|
|
77
|
+
* For production apps needing token revocation, call the API endpoint directly
|
|
78
|
+
* from the client or implement a custom logout procedure without guards.
|
|
79
|
+
*/
|
|
80
|
+
export const logout = authAction.fromLogoutProcedure(authProcedures.procedures.deleteSession, {
|
|
81
|
+
contextExtensions: { db },
|
|
82
|
+
skipGuards: true, // Skip auth guard - we'll clear cookies regardless
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// Standalone Actions (No Procedure Required)
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if email is available for registration
|
|
91
|
+
*
|
|
92
|
+
* This is a simple database lookup that doesn't need procedure bridge.
|
|
93
|
+
* Rate limited to prevent email enumeration attacks.
|
|
94
|
+
*/
|
|
95
|
+
export const checkEmailAvailable = validated(
|
|
96
|
+
z.object({ email: z.string().email() }),
|
|
97
|
+
async (input) => {
|
|
98
|
+
const existing = await db.user.findUnique({
|
|
99
|
+
where: { email: input.email.toLowerCase().trim() },
|
|
100
|
+
select: { id: true },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Always return the same timing to prevent enumeration
|
|
104
|
+
return { available: !existing };
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
rateLimit: {
|
|
108
|
+
maxRequests: 10,
|
|
109
|
+
windowMs: 60_000, // 10 checks per minute
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
);
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* User Server Actions with Authentication
|
|
5
|
+
*
|
|
6
|
+
* Type-safe server actions with built-in security using VeloxTS validated() helper.
|
|
7
|
+
* These demonstrate authenticated actions with role-based authorization.
|
|
8
|
+
*
|
|
9
|
+
* Security features included:
|
|
10
|
+
* - Input validation via Zod schemas
|
|
11
|
+
* - Input size limits (DoS protection)
|
|
12
|
+
* - Input sanitization (prototype pollution prevention)
|
|
13
|
+
* - Rate limiting (sliding window)
|
|
14
|
+
* - Authentication checks
|
|
15
|
+
* - Role-based authorization
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* // In a client component
|
|
20
|
+
* const result = await updateProfile({ name: 'John' });
|
|
21
|
+
*
|
|
22
|
+
* if (result.success) {
|
|
23
|
+
* console.log('Updated:', result.data);
|
|
24
|
+
* } else {
|
|
25
|
+
* if (result.error.code === 'AUTHENTICATION_REQUIRED') {
|
|
26
|
+
* redirect('/auth/login');
|
|
27
|
+
* }
|
|
28
|
+
* console.log('Error:', result.error.message);
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { validated, validatedMutation, validatedQuery } from '@veloxts/web/server';
|
|
34
|
+
import { z } from 'zod';
|
|
35
|
+
|
|
36
|
+
import { db } from '@/api/database';
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Schemas
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Schema for updating own profile (authenticated users)
|
|
44
|
+
*/
|
|
45
|
+
const UpdateProfileSchema = z.object({
|
|
46
|
+
name: z.string().min(1, 'Name is required').max(100, 'Name is too long').optional(),
|
|
47
|
+
email: z.string().email('Invalid email address').optional(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Schema for admin user creation
|
|
52
|
+
*/
|
|
53
|
+
const AdminCreateUserSchema = z.object({
|
|
54
|
+
name: z.string().min(1, 'Name is required').max(100, 'Name is too long'),
|
|
55
|
+
email: z.string().email('Invalid email address'),
|
|
56
|
+
roles: z.array(z.string()).default(['user']),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Schema for admin user deletion
|
|
61
|
+
*/
|
|
62
|
+
const AdminDeleteUserSchema = z.object({
|
|
63
|
+
id: z.string().min(1, 'User ID is required'),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Schema for searching users
|
|
68
|
+
*/
|
|
69
|
+
const SearchUsersSchema = z.object({
|
|
70
|
+
query: z.string().max(100).optional(),
|
|
71
|
+
limit: z.number().min(1).max(100).default(10),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Public Actions (no authentication required)
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Search users - public query action
|
|
80
|
+
*
|
|
81
|
+
* Uses validatedQuery() which allows unauthenticated access by default.
|
|
82
|
+
* Only returns public user information (no sensitive fields).
|
|
83
|
+
*/
|
|
84
|
+
export const searchUsers = validatedQuery(SearchUsersSchema, async (input) => {
|
|
85
|
+
const users = await db.user.findMany({
|
|
86
|
+
where: input.query
|
|
87
|
+
? {
|
|
88
|
+
OR: [{ name: { contains: input.query } }, { email: { contains: input.query } }],
|
|
89
|
+
}
|
|
90
|
+
: undefined,
|
|
91
|
+
take: input.limit,
|
|
92
|
+
orderBy: { createdAt: 'desc' },
|
|
93
|
+
select: {
|
|
94
|
+
id: true,
|
|
95
|
+
name: true,
|
|
96
|
+
email: true,
|
|
97
|
+
createdAt: true,
|
|
98
|
+
// Exclude password, roles, etc.
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return users;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// Authenticated Actions (require login)
|
|
107
|
+
// ============================================================================
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get current user's profile
|
|
111
|
+
*
|
|
112
|
+
* Uses validatedMutation() which requires authentication by default.
|
|
113
|
+
* Returns the authenticated user's full profile.
|
|
114
|
+
*/
|
|
115
|
+
export const getProfile = validatedMutation(
|
|
116
|
+
z.object({}), // No input needed
|
|
117
|
+
async (_input, ctx) => {
|
|
118
|
+
// ctx.user is available because validatedMutation requires auth
|
|
119
|
+
const user = await db.user.findUnique({
|
|
120
|
+
where: { id: ctx.user.id },
|
|
121
|
+
select: {
|
|
122
|
+
id: true,
|
|
123
|
+
name: true,
|
|
124
|
+
email: true,
|
|
125
|
+
roles: true,
|
|
126
|
+
createdAt: true,
|
|
127
|
+
updatedAt: true,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!user) {
|
|
132
|
+
throw new Error('User not found');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return user;
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Update current user's profile
|
|
141
|
+
*
|
|
142
|
+
* Users can only update their own profile.
|
|
143
|
+
* Includes rate limiting to prevent abuse.
|
|
144
|
+
*/
|
|
145
|
+
export const updateProfile = validated(
|
|
146
|
+
UpdateProfileSchema,
|
|
147
|
+
async (input, ctx) => {
|
|
148
|
+
const user = await db.user.update({
|
|
149
|
+
where: { id: ctx.user.id },
|
|
150
|
+
data: {
|
|
151
|
+
...(input.name && { name: input.name.trim() }),
|
|
152
|
+
...(input.email && { email: input.email.toLowerCase().trim() }),
|
|
153
|
+
},
|
|
154
|
+
select: {
|
|
155
|
+
id: true,
|
|
156
|
+
name: true,
|
|
157
|
+
email: true,
|
|
158
|
+
updatedAt: true,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return user;
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
requireAuth: true,
|
|
166
|
+
rateLimit: {
|
|
167
|
+
maxRequests: 10,
|
|
168
|
+
windowMs: 60_000, // 10 updates per minute
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// ============================================================================
|
|
174
|
+
// Admin Actions (require admin role)
|
|
175
|
+
// ============================================================================
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Admin: Create a new user
|
|
179
|
+
*
|
|
180
|
+
* Only administrators can create users directly.
|
|
181
|
+
* Regular users must go through the registration flow.
|
|
182
|
+
*/
|
|
183
|
+
export const adminCreateUser = validated(
|
|
184
|
+
AdminCreateUserSchema,
|
|
185
|
+
async (input) => {
|
|
186
|
+
const user = await db.user.create({
|
|
187
|
+
data: {
|
|
188
|
+
name: input.name.trim(),
|
|
189
|
+
email: input.email.toLowerCase().trim(),
|
|
190
|
+
roles: JSON.stringify(input.roles),
|
|
191
|
+
// Note: No password - admin-created users need to set password via reset flow
|
|
192
|
+
},
|
|
193
|
+
select: {
|
|
194
|
+
id: true,
|
|
195
|
+
name: true,
|
|
196
|
+
email: true,
|
|
197
|
+
roles: true,
|
|
198
|
+
createdAt: true,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return user;
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
requireAuth: true,
|
|
206
|
+
requireRoles: ['admin'],
|
|
207
|
+
rateLimit: {
|
|
208
|
+
maxRequests: 20,
|
|
209
|
+
windowMs: 60_000,
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Admin: Delete a user
|
|
216
|
+
*
|
|
217
|
+
* Only administrators can delete users.
|
|
218
|
+
* Cannot delete own account (safety measure).
|
|
219
|
+
*/
|
|
220
|
+
export const adminDeleteUser = validated(
|
|
221
|
+
AdminDeleteUserSchema,
|
|
222
|
+
async (input, ctx) => {
|
|
223
|
+
// Prevent self-deletion
|
|
224
|
+
if (input.id === ctx.user.id) {
|
|
225
|
+
throw new Error('Cannot delete your own account');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await db.user.delete({
|
|
229
|
+
where: { id: input.id },
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return { deleted: true, id: input.id };
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
requireAuth: true,
|
|
236
|
+
requireRoles: ['admin'],
|
|
237
|
+
rateLimit: {
|
|
238
|
+
maxRequests: 10,
|
|
239
|
+
windowMs: 60_000,
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Admin: List all users with full details
|
|
246
|
+
*
|
|
247
|
+
* Returns all user data including roles.
|
|
248
|
+
* Only accessible to administrators.
|
|
249
|
+
*/
|
|
250
|
+
export const adminListUsers = validated(
|
|
251
|
+
z.object({
|
|
252
|
+
page: z.number().min(1).default(1),
|
|
253
|
+
limit: z.number().min(1).max(100).default(20),
|
|
254
|
+
}),
|
|
255
|
+
async (input) => {
|
|
256
|
+
const skip = (input.page - 1) * input.limit;
|
|
257
|
+
|
|
258
|
+
const [users, total] = await Promise.all([
|
|
259
|
+
db.user.findMany({
|
|
260
|
+
skip,
|
|
261
|
+
take: input.limit,
|
|
262
|
+
orderBy: { createdAt: 'desc' },
|
|
263
|
+
select: {
|
|
264
|
+
id: true,
|
|
265
|
+
name: true,
|
|
266
|
+
email: true,
|
|
267
|
+
roles: true,
|
|
268
|
+
createdAt: true,
|
|
269
|
+
updatedAt: true,
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
db.user.count(),
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
users,
|
|
277
|
+
pagination: {
|
|
278
|
+
page: input.page,
|
|
279
|
+
limit: input.limit,
|
|
280
|
+
total,
|
|
281
|
+
pages: Math.ceil(total / input.limit),
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
requireAuth: true,
|
|
287
|
+
requireRoles: ['admin'],
|
|
288
|
+
}
|
|
289
|
+
);
|