create-tigra 2.2.0 → 2.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.
- package/bin/create-tigra.js +14 -1
- package/package.json +4 -1
- package/template/_claude/commands/create-client.md +1 -4
- package/template/_claude/commands/create-server.md +0 -1
- package/template/_claude/hooks/restrict-paths.sh +2 -2
- package/template/_claude/rules/client/01-project-structure.md +0 -3
- package/template/_claude/rules/client/03-data-and-state.md +1 -1
- package/template/_claude/rules/server/project-conventions.md +40 -0
- package/template/client/package.json +2 -2
- package/template/client/public/logo.png +0 -0
- package/template/client/src/app/globals.css +61 -59
- package/template/client/src/app/icon.png +0 -0
- package/template/client/src/app/page.tsx +66 -35
- package/template/client/src/app/providers.tsx +0 -2
- package/template/client/src/components/common/SafeImage.tsx +48 -0
- package/template/client/src/features/auth/hooks/useAuth.ts +2 -2
- package/template/client/src/lib/api/axios.config.ts +14 -7
- package/template/client/src/middleware.ts +20 -29
- package/template/server/.env.example +9 -0
- package/template/server/.env.example.production +9 -0
- package/template/server/package.json +2 -1
- package/template/server/postman/collection.json +114 -5
- package/template/server/postman/environment.json +2 -2
- package/template/server/prisma/schema.prisma +17 -1
- package/template/server/src/app.ts +4 -1
- package/template/server/src/config/env.ts +6 -0
- package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +3 -6
- package/template/server/src/libs/auth.ts +45 -1
- package/template/server/src/libs/cookies.ts +35 -4
- package/template/server/src/libs/ip-block.ts +90 -29
- package/template/server/src/libs/requestLogger.ts +1 -1
- package/template/server/src/libs/storage/file-storage.service.ts +65 -18
- package/template/server/src/libs/storage/file-validator.ts +0 -8
- package/template/server/src/modules/admin/admin.controller.ts +4 -3
- package/template/server/src/modules/auth/auth.repo.ts +18 -0
- package/template/server/src/modules/auth/auth.service.ts +52 -26
- package/template/server/src/modules/users/users.controller.ts +39 -21
- package/template/server/src/modules/users/users.routes.ts +127 -6
- package/template/server/src/modules/users/users.schemas.ts +24 -4
- package/template/server/src/modules/users/users.service.ts +23 -10
- package/template/server/src/shared/types/index.ts +2 -0
- package/template/client/src/app/(auth)/layout.tsx +0 -18
- package/template/client/src/app/(auth)/login/page.tsx +0 -13
- package/template/client/src/app/(auth)/register/page.tsx +0 -13
- package/template/client/src/app/(main)/dashboard/page.tsx +0 -22
- package/template/client/src/app/(main)/layout.tsx +0 -11
- package/template/client/src/app/favicon.ico +0 -0
package/bin/create-tigra.js
CHANGED
|
@@ -21,6 +21,8 @@ const FILES_TO_REPLACE = [
|
|
|
21
21
|
'server/docker-compose.yml',
|
|
22
22
|
'client/package.json',
|
|
23
23
|
'client/.env.example',
|
|
24
|
+
'server/postman/collection.json',
|
|
25
|
+
'server/postman/environment.json',
|
|
24
26
|
];
|
|
25
27
|
|
|
26
28
|
// Directories/files to skip when copying
|
|
@@ -214,7 +216,18 @@ async function main() {
|
|
|
214
216
|
}
|
|
215
217
|
|
|
216
218
|
// Create .developer-role file (default: fullstack = no restrictions)
|
|
217
|
-
|
|
219
|
+
const developerRoleContent = [
|
|
220
|
+
'fullstack',
|
|
221
|
+
'# Available roles (change the first line to switch):',
|
|
222
|
+
'#',
|
|
223
|
+
'# frontend - Can edit client/ only. Cannot edit server/ files. Can read everything.',
|
|
224
|
+
'# backend - Can edit server/ only. Cannot edit client/ files. Can read everything.',
|
|
225
|
+
'# fullstack - Can edit everything. No restrictions.',
|
|
226
|
+
'#',
|
|
227
|
+
'# You can also switch roles using the /role command in Claude.',
|
|
228
|
+
'',
|
|
229
|
+
].join('\n');
|
|
230
|
+
await fs.writeFile(path.join(targetDir, '.developer-role'), developerRoleContent, 'utf-8');
|
|
218
231
|
|
|
219
232
|
spinner.succeed('Project scaffolded successfully!');
|
|
220
233
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-tigra",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Create a production-ready full-stack app with Next.js 16 + Fastify 5 + Prisma + Redis",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
"url": "https://github.com/blessandsoul/create-tigra/issues"
|
|
40
40
|
},
|
|
41
41
|
"homepage": "https://github.com/blessandsoul/create-tigra#readme",
|
|
42
|
+
"scripts": {
|
|
43
|
+
"create:test": "node -e \"import('fs').then(f=>f.rmSync('testapp',{recursive:true,force:true}))\" && node bin/create-tigra.js testapp"
|
|
44
|
+
},
|
|
42
45
|
"dependencies": {
|
|
43
46
|
"chalk": "^5.4.1",
|
|
44
47
|
"commander": "^13.1.0",
|
|
@@ -180,8 +180,6 @@ export const API_ENDPOINTS = {
|
|
|
180
180
|
LOGOUT: '/auth/logout',
|
|
181
181
|
REFRESH: '/auth/refresh',
|
|
182
182
|
ME: '/auth/me',
|
|
183
|
-
VERIFY_EMAIL: '/auth/verify-email',
|
|
184
|
-
RESEND_VERIFICATION: '/auth/resend-verification',
|
|
185
183
|
REQUEST_PASSWORD_RESET: '/auth/request-password-reset',
|
|
186
184
|
RESET_PASSWORD: '/auth/reset-password',
|
|
187
185
|
},
|
|
@@ -199,7 +197,6 @@ export const ROUTES = {
|
|
|
199
197
|
HOME: '/',
|
|
200
198
|
LOGIN: '/login',
|
|
201
199
|
REGISTER: '/register',
|
|
202
|
-
VERIFY_EMAIL: '/verify-email',
|
|
203
200
|
RESET_PASSWORD: '/reset-password',
|
|
204
201
|
DASHBOARD: '/dashboard',
|
|
205
202
|
PROFILE: '/profile',
|
|
@@ -369,7 +366,7 @@ Auth types from `02-components-and-types.md`:
|
|
|
369
366
|
|
|
370
367
|
#### `src/features/auth/services/auth.service.ts`
|
|
371
368
|
Auth service class from `03-data-and-state.md`:
|
|
372
|
-
- `register`, `login`, `logout`, `refreshToken`, `getMe`, `
|
|
369
|
+
- `register`, `login`, `logout`, `refreshToken`, `getMe`, `requestPasswordReset`, `resetPassword`
|
|
373
370
|
- Uses `apiClient` and `API_ENDPOINTS`
|
|
374
371
|
- Singleton export: `export const authService = new AuthService();`
|
|
375
372
|
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
ROLE_FILE="$CLAUDE_PROJECT_DIR/.developer-role"
|
|
16
16
|
|
|
17
|
-
# Read role from
|
|
17
|
+
# Read role from first non-comment, non-empty line
|
|
18
18
|
if [ -f "$ROLE_FILE" ]; then
|
|
19
|
-
ROLE=$(tr -d '[:space:]'
|
|
19
|
+
ROLE=$(grep -v '^\s*#' "$ROLE_FILE" | grep -v '^\s*$' | head -1 | tr -d '[:space:]')
|
|
20
20
|
else
|
|
21
21
|
ROLE=""
|
|
22
22
|
fi
|
|
@@ -78,8 +78,6 @@ export const API_ENDPOINTS = {
|
|
|
78
78
|
LOGOUT: '/auth/logout',
|
|
79
79
|
REFRESH: '/auth/refresh',
|
|
80
80
|
ME: '/auth/me',
|
|
81
|
-
VERIFY_EMAIL: '/auth/verify-email',
|
|
82
|
-
RESEND_VERIFICATION: '/auth/resend-verification',
|
|
83
81
|
REQUEST_PASSWORD_RESET: '/auth/request-password-reset',
|
|
84
82
|
RESET_PASSWORD: '/auth/reset-password',
|
|
85
83
|
},
|
|
@@ -103,7 +101,6 @@ export const ROUTES = {
|
|
|
103
101
|
HOME: '/',
|
|
104
102
|
LOGIN: '/login',
|
|
105
103
|
REGISTER: '/register',
|
|
106
|
-
VERIFY_EMAIL: '/verify-email',
|
|
107
104
|
RESET_PASSWORD: '/reset-password',
|
|
108
105
|
DASHBOARD: '/dashboard',
|
|
109
106
|
PROFILE: '/profile',
|
|
@@ -92,7 +92,7 @@ class ItemService {
|
|
|
92
92
|
export const itemService = new ItemService();
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
Auth service methods: `register`, `login`, `logout`, `refreshToken`, `getMe`, `
|
|
95
|
+
Auth service methods: `register`, `login`, `logout`, `refreshToken`, `getMe`, `requestPasswordReset`, `resetPassword`.
|
|
96
96
|
|
|
97
97
|
---
|
|
98
98
|
|
|
@@ -212,3 +212,43 @@ const apiClient = axios.create({ ...httpClient.defaults, baseURL: 'https://api.e
|
|
|
212
212
|
- Always use `httpClient` — never `fetch`, `node:http`, or inline `axios.create()`.
|
|
213
213
|
- Never add auth headers, cookies, or credentials to the singleton itself.
|
|
214
214
|
- Never log response bodies (may contain PII or secrets).
|
|
215
|
+
|
|
216
|
+
## File Storage
|
|
217
|
+
|
|
218
|
+
### Directory Structure Principles
|
|
219
|
+
|
|
220
|
+
Upload folders must be **scalable and manageable**. Never dump all files into a single flat directory (e.g., all product images under `/uploads/products/`). Instead, organize by **owner/entity ID** so each entity's files are isolated and easy to find, move, or delete.
|
|
221
|
+
|
|
222
|
+
**Pattern**: `uploads/<domain>/{entityId}/<media-type>/`
|
|
223
|
+
|
|
224
|
+
### User Uploads
|
|
225
|
+
|
|
226
|
+
Structure: `uploads/users/{userId}/<media-type>/`
|
|
227
|
+
|
|
228
|
+
| Media type | Path | Example |
|
|
229
|
+
|---|---|---|
|
|
230
|
+
| Avatar | `uploads/users/{userId}/avatar/` | `uploads/users/abc123/avatar/john-doe-avatar.webp` |
|
|
231
|
+
|
|
232
|
+
- All user media lives under `uploads/users/{userId}/` for easy per-user cleanup.
|
|
233
|
+
- On account purge, delete the entire `uploads/users/{userId}/` directory via `deleteUserMedia()`.
|
|
234
|
+
- Public URL pattern: `/uploads/users/{userId}/<media-type>/{filename}`
|
|
235
|
+
- New media types follow the same pattern: add a subfolder under the user directory.
|
|
236
|
+
|
|
237
|
+
### Domain Entity Uploads (Products, Articles, etc.)
|
|
238
|
+
|
|
239
|
+
Structure: `uploads/<domain>/{entityId}/<media-type>/`
|
|
240
|
+
|
|
241
|
+
| Domain | Media type | Path | Example |
|
|
242
|
+
|---|---|---|---|
|
|
243
|
+
| Products | Images | `uploads/products/{productId}/images/` | `uploads/products/prod-456/images/front-view.webp` |
|
|
244
|
+
| Products | Thumbnails | `uploads/products/{productId}/thumbnails/` | `uploads/products/prod-456/thumbnails/front-view-thumb.webp` |
|
|
245
|
+
| Articles | Cover | `uploads/articles/{articleId}/cover/` | `uploads/articles/art-789/cover/hero.webp` |
|
|
246
|
+
| Articles | Content images | `uploads/articles/{articleId}/content/` | `uploads/articles/art-789/content/diagram-1.webp` |
|
|
247
|
+
|
|
248
|
+
### Rules
|
|
249
|
+
|
|
250
|
+
1. **Never use flat directories** — `uploads/products/img1.jpg, img2.jpg, ...` is forbidden. Always namespace by entity ID.
|
|
251
|
+
2. **One folder per entity instance** — makes deletion, migration, and backup trivial (`rm -rf uploads/products/{id}/`).
|
|
252
|
+
3. **Separate media types into subfolders** — don't mix avatars, thumbnails, and full-size images in the same folder.
|
|
253
|
+
4. **Entity cleanup** — when an entity is deleted, remove its entire upload directory (e.g., `uploads/products/{productId}/`).
|
|
254
|
+
5. **Consistent naming** — use kebab-case for filenames, domain plural for top-level folders (`products`, `articles`, `users`).
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
"dev": "next dev",
|
|
7
7
|
"build": "next build",
|
|
8
8
|
"start": "next start",
|
|
9
|
-
"lint": "eslint src/"
|
|
9
|
+
"lint": "eslint src/",
|
|
10
|
+
"generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
12
13
|
"@hookform/resolvers": "^5.2.2",
|
|
@@ -33,7 +34,6 @@
|
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"@tailwindcss/postcss": "^4",
|
|
36
|
-
"@tanstack/react-query-devtools": "^5.91.3",
|
|
37
37
|
"@types/node": "^20",
|
|
38
38
|
"@types/react": "^19",
|
|
39
39
|
"@types/react-dom": "^19",
|
|
Binary file
|
|
@@ -55,83 +55,85 @@
|
|
|
55
55
|
|
|
56
56
|
:root {
|
|
57
57
|
--radius: 0.625rem;
|
|
58
|
-
|
|
59
|
-
--
|
|
58
|
+
/* Claude palette: #f4f3ee (cream), #ffffff (white), #b1ada1 (warm gray), #c15f3c (orange) */
|
|
59
|
+
--background: oklch(0.964 0.007 97.5);
|
|
60
|
+
--foreground: oklch(0.205 0.015 92);
|
|
60
61
|
--card: oklch(1 0 0);
|
|
61
|
-
--card-foreground: oklch(0.
|
|
62
|
+
--card-foreground: oklch(0.205 0.015 92);
|
|
62
63
|
--popover: oklch(1 0 0);
|
|
63
|
-
--popover-foreground: oklch(0.
|
|
64
|
-
--primary: oklch(0.
|
|
65
|
-
--primary-foreground: oklch(
|
|
66
|
-
--secondary: oklch(0.
|
|
67
|
-
--secondary-foreground: oklch(0.
|
|
68
|
-
--muted: oklch(0.
|
|
69
|
-
--muted-foreground: oklch(0.
|
|
70
|
-
--accent: oklch(0.
|
|
71
|
-
--accent-foreground: oklch(0.
|
|
64
|
+
--popover-foreground: oklch(0.205 0.015 92);
|
|
65
|
+
--primary: oklch(0.597 0.135 39.9);
|
|
66
|
+
--primary-foreground: oklch(1 0 0);
|
|
67
|
+
--secondary: oklch(0.935 0.009 97.5);
|
|
68
|
+
--secondary-foreground: oklch(0.3 0.015 92);
|
|
69
|
+
--muted: oklch(0.935 0.009 97.5);
|
|
70
|
+
--muted-foreground: oklch(0.748 0.017 91.6);
|
|
71
|
+
--accent: oklch(0.935 0.009 97.5);
|
|
72
|
+
--accent-foreground: oklch(0.3 0.015 92);
|
|
72
73
|
--destructive: oklch(0.577 0.245 27.325);
|
|
73
|
-
--border: oklch(0.
|
|
74
|
-
--input: oklch(0.
|
|
75
|
-
--ring: oklch(0.
|
|
74
|
+
--border: oklch(0.895 0.012 97.5);
|
|
75
|
+
--input: oklch(0.895 0.012 97.5);
|
|
76
|
+
--ring: oklch(0.597 0.135 39.9);
|
|
76
77
|
--success: oklch(0.52 0.17 155);
|
|
77
78
|
--success-foreground: oklch(1 0 0);
|
|
78
|
-
--warning: oklch(0.75 0.
|
|
79
|
-
--warning-foreground: oklch(0.
|
|
79
|
+
--warning: oklch(0.75 0.15 70);
|
|
80
|
+
--warning-foreground: oklch(0.25 0.015 92);
|
|
80
81
|
--info: oklch(0.55 0.15 240);
|
|
81
82
|
--info-foreground: oklch(1 0 0);
|
|
82
|
-
--chart-1: oklch(0.
|
|
83
|
+
--chart-1: oklch(0.597 0.135 39.9);
|
|
83
84
|
--chart-2: oklch(0.6 0.118 184.704);
|
|
84
85
|
--chart-3: oklch(0.398 0.07 227.392);
|
|
85
|
-
--chart-4: oklch(0.828 0.
|
|
86
|
-
--chart-5: oklch(0.
|
|
87
|
-
--sidebar: oklch(0.
|
|
88
|
-
--sidebar-foreground: oklch(0.
|
|
89
|
-
--sidebar-primary: oklch(0.
|
|
90
|
-
--sidebar-primary-foreground: oklch(0.
|
|
91
|
-
--sidebar-accent: oklch(0.
|
|
92
|
-
--sidebar-accent-foreground: oklch(0.
|
|
93
|
-
--sidebar-border: oklch(0.
|
|
94
|
-
--sidebar-ring: oklch(0.
|
|
86
|
+
--chart-4: oklch(0.828 0.12 84);
|
|
87
|
+
--chart-5: oklch(0.748 0.017 91.6);
|
|
88
|
+
--sidebar: oklch(0.95 0.007 97.5);
|
|
89
|
+
--sidebar-foreground: oklch(0.205 0.015 92);
|
|
90
|
+
--sidebar-primary: oklch(0.3 0.015 92);
|
|
91
|
+
--sidebar-primary-foreground: oklch(0.964 0.007 97.5);
|
|
92
|
+
--sidebar-accent: oklch(0.935 0.009 97.5);
|
|
93
|
+
--sidebar-accent-foreground: oklch(0.3 0.015 92);
|
|
94
|
+
--sidebar-border: oklch(0.895 0.012 97.5);
|
|
95
|
+
--sidebar-ring: oklch(0.597 0.135 39.9);
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
.dark {
|
|
98
|
-
|
|
99
|
-
--
|
|
100
|
-
--
|
|
101
|
-
--card
|
|
102
|
-
--
|
|
103
|
-
--popover
|
|
104
|
-
--
|
|
105
|
-
--primary
|
|
106
|
-
--
|
|
107
|
-
--secondary
|
|
108
|
-
--
|
|
109
|
-
--muted
|
|
110
|
-
--
|
|
111
|
-
--accent
|
|
99
|
+
/* Dark mode: inverted Claude palette with same warm undertone */
|
|
100
|
+
--background: oklch(0.185 0.012 92);
|
|
101
|
+
--foreground: oklch(0.93 0.007 97.5);
|
|
102
|
+
--card: oklch(0.235 0.012 92);
|
|
103
|
+
--card-foreground: oklch(0.93 0.007 97.5);
|
|
104
|
+
--popover: oklch(0.235 0.012 92);
|
|
105
|
+
--popover-foreground: oklch(0.93 0.007 97.5);
|
|
106
|
+
--primary: oklch(0.66 0.135 39.9);
|
|
107
|
+
--primary-foreground: oklch(0.185 0.012 92);
|
|
108
|
+
--secondary: oklch(0.28 0.012 92);
|
|
109
|
+
--secondary-foreground: oklch(0.93 0.007 97.5);
|
|
110
|
+
--muted: oklch(0.28 0.012 92);
|
|
111
|
+
--muted-foreground: oklch(0.65 0.015 91.6);
|
|
112
|
+
--accent: oklch(0.28 0.012 92);
|
|
113
|
+
--accent-foreground: oklch(0.93 0.007 97.5);
|
|
112
114
|
--destructive: oklch(0.704 0.191 22.216);
|
|
113
|
-
--border: oklch(1 0
|
|
114
|
-
--input: oklch(1 0
|
|
115
|
-
--ring: oklch(0.
|
|
115
|
+
--border: oklch(1 0.007 97.5 / 12%);
|
|
116
|
+
--input: oklch(1 0.007 97.5 / 15%);
|
|
117
|
+
--ring: oklch(0.66 0.135 39.9);
|
|
116
118
|
--success: oklch(0.6 0.17 155);
|
|
117
119
|
--success-foreground: oklch(1 0 0);
|
|
118
|
-
--warning: oklch(0.8 0.
|
|
119
|
-
--warning-foreground: oklch(0.
|
|
120
|
+
--warning: oklch(0.8 0.15 70);
|
|
121
|
+
--warning-foreground: oklch(0.25 0.015 92);
|
|
120
122
|
--info: oklch(0.65 0.15 240);
|
|
121
123
|
--info-foreground: oklch(1 0 0);
|
|
122
|
-
--chart-1: oklch(0.
|
|
124
|
+
--chart-1: oklch(0.66 0.135 39.9);
|
|
123
125
|
--chart-2: oklch(0.696 0.17 162.48);
|
|
124
|
-
--chart-3: oklch(0.769 0.
|
|
125
|
-
--chart-4: oklch(0.627 0.
|
|
126
|
-
--chart-5: oklch(0.645 0.
|
|
127
|
-
--sidebar: oklch(0.
|
|
128
|
-
--sidebar-foreground: oklch(0.
|
|
129
|
-
--sidebar-primary: oklch(0.
|
|
130
|
-
--sidebar-primary-foreground: oklch(0.
|
|
131
|
-
--sidebar-accent: oklch(0.
|
|
132
|
-
--sidebar-accent-foreground: oklch(0.
|
|
133
|
-
--sidebar-border: oklch(1 0
|
|
134
|
-
--sidebar-ring: oklch(0.
|
|
126
|
+
--chart-3: oklch(0.769 0.12 70);
|
|
127
|
+
--chart-4: oklch(0.627 0.18 303.9);
|
|
128
|
+
--chart-5: oklch(0.645 0.17 16.439);
|
|
129
|
+
--sidebar: oklch(0.235 0.012 92);
|
|
130
|
+
--sidebar-foreground: oklch(0.93 0.007 97.5);
|
|
131
|
+
--sidebar-primary: oklch(0.66 0.135 39.9);
|
|
132
|
+
--sidebar-primary-foreground: oklch(0.93 0.007 97.5);
|
|
133
|
+
--sidebar-accent: oklch(0.28 0.012 92);
|
|
134
|
+
--sidebar-accent-foreground: oklch(0.93 0.007 97.5);
|
|
135
|
+
--sidebar-border: oklch(1 0.007 97.5 / 12%);
|
|
136
|
+
--sidebar-ring: oklch(0.66 0.135 39.9);
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
@layer base {
|
|
Binary file
|
|
@@ -1,45 +1,76 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
1
|
import type React from 'react';
|
|
4
|
-
import {
|
|
2
|
+
import type { Metadata } from 'next';
|
|
3
|
+
|
|
4
|
+
import Image from 'next/image';
|
|
5
5
|
|
|
6
|
-
import { Button } from '@/components/ui/button';
|
|
7
|
-
import { Skeleton } from '@/components/ui/skeleton';
|
|
8
|
-
import { useAppSelector } from '@/store/hooks';
|
|
9
|
-
import { useAuth } from '@/features/auth/hooks/useAuth';
|
|
10
6
|
import { APP_NAME } from '@/lib/constants/app.constants';
|
|
11
7
|
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
export const metadata: Metadata = {
|
|
9
|
+
title: APP_NAME,
|
|
10
|
+
description: `Welcome to ${APP_NAME} — generated with create-tigra`,
|
|
11
|
+
};
|
|
15
12
|
|
|
13
|
+
export default function WelcomePage(): React.ReactElement {
|
|
16
14
|
return (
|
|
17
|
-
<div className="flex min-h-dvh flex-col">
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
<div className="relative flex min-h-dvh flex-col items-center justify-center overflow-hidden px-6">
|
|
16
|
+
{/* Ambient glow */}
|
|
17
|
+
<div
|
|
18
|
+
aria-hidden="true"
|
|
19
|
+
className="pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
|
|
20
|
+
>
|
|
21
|
+
<div className="motion-safe:animate-pulse h-64 w-64 rounded-full bg-primary/15 blur-3xl md:h-96 md:w-96" />
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
{/* Content */}
|
|
25
|
+
<div className="relative z-10 flex max-w-md flex-col items-center text-center">
|
|
26
|
+
{/* Logo */}
|
|
27
|
+
<div className="mb-8">
|
|
28
|
+
<Image
|
|
29
|
+
src="/logo.png"
|
|
30
|
+
alt={`${APP_NAME} logo`}
|
|
31
|
+
width={160}
|
|
32
|
+
height={160}
|
|
33
|
+
priority
|
|
34
|
+
className="h-32 w-32 md:h-40 md:w-40"
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
|
|
20
39
|
{APP_NAME}
|
|
21
|
-
</
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
>
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
</h1>
|
|
41
|
+
|
|
42
|
+
<p className="mt-3 text-base text-muted-foreground md:text-lg">
|
|
43
|
+
Generated with{' '}
|
|
44
|
+
<span className="font-semibold text-foreground">create-tigra</span>
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:gap-4">
|
|
48
|
+
<a
|
|
49
|
+
href="https://github.com/BehzodKarimov/create-tigra"
|
|
50
|
+
target="_blank"
|
|
51
|
+
rel="noopener noreferrer"
|
|
52
|
+
className="inline-flex min-h-11 items-center justify-center rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground shadow-sm transition-all duration-200 active:scale-[0.97] md:hover:brightness-110"
|
|
53
|
+
>
|
|
54
|
+
Documentation
|
|
55
|
+
</a>
|
|
56
|
+
<a
|
|
57
|
+
href="https://github.com/BehzodKarimov/create-tigra/issues"
|
|
58
|
+
target="_blank"
|
|
59
|
+
rel="noopener noreferrer"
|
|
60
|
+
className="inline-flex min-h-11 items-center justify-center rounded-lg border border-border bg-secondary px-5 py-2.5 text-sm font-medium text-secondary-foreground transition-all duration-200 active:scale-[0.97] md:hover:bg-accent"
|
|
61
|
+
>
|
|
62
|
+
Report an issue
|
|
63
|
+
</a>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<p className="mt-12 font-mono text-xs text-muted-foreground md:text-sm">
|
|
67
|
+
Edit{' '}
|
|
68
|
+
<code className="rounded-md bg-muted px-1.5 py-0.5">
|
|
69
|
+
src/app/page.tsx
|
|
70
|
+
</code>{' '}
|
|
71
|
+
to get started
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
43
74
|
</div>
|
|
44
75
|
);
|
|
45
76
|
}
|
|
@@ -4,7 +4,6 @@ import type React from 'react';
|
|
|
4
4
|
import { useState } from 'react';
|
|
5
5
|
|
|
6
6
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
7
|
-
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
|
8
7
|
import { Provider as ReduxProvider } from 'react-redux';
|
|
9
8
|
import { ThemeProvider } from 'next-themes';
|
|
10
9
|
import { Toaster } from 'sonner';
|
|
@@ -36,7 +35,6 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
|
|
|
36
35
|
</AuthInitializer>
|
|
37
36
|
<Toaster position="top-right" richColors />
|
|
38
37
|
</ThemeProvider>
|
|
39
|
-
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen={false} />}
|
|
40
38
|
</QueryClientProvider>
|
|
41
39
|
</ReduxProvider>
|
|
42
40
|
);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type React from 'react';
|
|
4
|
+
|
|
5
|
+
import Image from 'next/image';
|
|
6
|
+
import type { ImageProps } from 'next/image';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wraps Next.js <Image> to bypass the optimization proxy for localhost URLs.
|
|
10
|
+
*
|
|
11
|
+
* Next.js refuses to fetch images from loopback IPs (127.0.0.1, ::1) through
|
|
12
|
+
* its optimization proxy — even when remotePatterns allows localhost. This is
|
|
13
|
+
* a security restriction to prevent SSRF attacks.
|
|
14
|
+
*
|
|
15
|
+
* SafeImage detects localhost/loopback URLs and sets `unoptimized` so the
|
|
16
|
+
* image loads directly via a regular <img> tag. In production with a real
|
|
17
|
+
* domain, images are optimized normally.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const LOOPBACK_PATTERNS = [
|
|
21
|
+
'localhost',
|
|
22
|
+
'127.0.0.1',
|
|
23
|
+
'0.0.0.0',
|
|
24
|
+
'[::1]',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
function isLoopbackUrl(src: ImageProps['src']): boolean {
|
|
28
|
+
if (typeof src !== 'string') return false;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const url = new URL(src);
|
|
32
|
+
return LOOPBACK_PATTERNS.some((pattern) => url.hostname === pattern);
|
|
33
|
+
} catch {
|
|
34
|
+
// Relative path or invalid URL — not a loopback issue
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const SafeImage = (props: ImageProps): React.ReactElement => {
|
|
40
|
+
const shouldSkipOptimization = isLoopbackUrl(props.src);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Image
|
|
44
|
+
{...props}
|
|
45
|
+
unoptimized={props.unoptimized || shouldSkipOptimization}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
};
|
|
@@ -38,7 +38,7 @@ export const useAuth = (): UseAuthReturn => {
|
|
|
38
38
|
dispatch(setUser(data.user));
|
|
39
39
|
toast.success('Signed in successfully');
|
|
40
40
|
const from = searchParams.get('from');
|
|
41
|
-
const redirectTo = from && from.startsWith('/') && !from.startsWith('//') ? from :
|
|
41
|
+
const redirectTo = from && from.startsWith('/') && !from.startsWith('//') ? from : ROUTES.DASHBOARD;
|
|
42
42
|
router.push(redirectTo);
|
|
43
43
|
},
|
|
44
44
|
onError: (error) => {
|
|
@@ -51,7 +51,7 @@ export const useAuth = (): UseAuthReturn => {
|
|
|
51
51
|
onSuccess: (data) => {
|
|
52
52
|
dispatch(setUser(data.user));
|
|
53
53
|
toast.success('Account created successfully');
|
|
54
|
-
router.push(ROUTES.
|
|
54
|
+
router.push(ROUTES.DASHBOARD);
|
|
55
55
|
},
|
|
56
56
|
onError: (error) => {
|
|
57
57
|
toast.error(getErrorMessage(error));
|
|
@@ -9,9 +9,6 @@ const apiClient = axios.create({
|
|
|
9
9
|
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000/api/v1',
|
|
10
10
|
timeout: 30000,
|
|
11
11
|
withCredentials: true, // Send cookies with every request
|
|
12
|
-
headers: {
|
|
13
|
-
'Content-Type': 'application/json',
|
|
14
|
-
},
|
|
15
12
|
});
|
|
16
13
|
|
|
17
14
|
// No request interceptor needed — cookies are sent automatically
|
|
@@ -46,8 +43,15 @@ apiClient.interceptors.response.use(
|
|
|
46
43
|
return Promise.reject(error);
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
// Don't retry
|
|
50
|
-
|
|
46
|
+
// Don't retry auth endpoints that don't use tokens —
|
|
47
|
+
// a 401 here means wrong credentials, not an expired token.
|
|
48
|
+
const noRetryEndpoints = [
|
|
49
|
+
API_ENDPOINTS.AUTH.LOGIN,
|
|
50
|
+
API_ENDPOINTS.AUTH.REGISTER,
|
|
51
|
+
API_ENDPOINTS.AUTH.REFRESH,
|
|
52
|
+
API_ENDPOINTS.AUTH.RESET_PASSWORD,
|
|
53
|
+
];
|
|
54
|
+
if (noRetryEndpoints.includes(originalRequest.url ?? '')) {
|
|
51
55
|
return Promise.reject(error);
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -57,6 +61,8 @@ apiClient.interceptors.response.use(
|
|
|
57
61
|
resetRefreshState();
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
originalRequest._retry = true;
|
|
65
|
+
|
|
60
66
|
if (isRefreshing) {
|
|
61
67
|
return new Promise<void>((resolve, reject) => {
|
|
62
68
|
failedQueue.push({ resolve, reject });
|
|
@@ -64,8 +70,6 @@ apiClient.interceptors.response.use(
|
|
|
64
70
|
return apiClient(originalRequest);
|
|
65
71
|
});
|
|
66
72
|
}
|
|
67
|
-
|
|
68
|
-
originalRequest._retry = true;
|
|
69
73
|
isRefreshing = true;
|
|
70
74
|
refreshTimestamp = Date.now();
|
|
71
75
|
|
|
@@ -94,6 +98,9 @@ apiClient.interceptors.response.use(
|
|
|
94
98
|
store.dispatch(logout());
|
|
95
99
|
|
|
96
100
|
if (typeof window !== 'undefined') {
|
|
101
|
+
// Clear session indicator so middleware won't let user through
|
|
102
|
+
// to protected pages — prevents redirect loops
|
|
103
|
+
document.cookie = 'auth_session=; Max-Age=0; path=/; SameSite=Strict';
|
|
97
104
|
window.location.href = ROUTES.LOGIN;
|
|
98
105
|
}
|
|
99
106
|
}
|