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.
Files changed (47) hide show
  1. package/bin/create-tigra.js +14 -1
  2. package/package.json +4 -1
  3. package/template/_claude/commands/create-client.md +1 -4
  4. package/template/_claude/commands/create-server.md +0 -1
  5. package/template/_claude/hooks/restrict-paths.sh +2 -2
  6. package/template/_claude/rules/client/01-project-structure.md +0 -3
  7. package/template/_claude/rules/client/03-data-and-state.md +1 -1
  8. package/template/_claude/rules/server/project-conventions.md +40 -0
  9. package/template/client/package.json +2 -2
  10. package/template/client/public/logo.png +0 -0
  11. package/template/client/src/app/globals.css +61 -59
  12. package/template/client/src/app/icon.png +0 -0
  13. package/template/client/src/app/page.tsx +66 -35
  14. package/template/client/src/app/providers.tsx +0 -2
  15. package/template/client/src/components/common/SafeImage.tsx +48 -0
  16. package/template/client/src/features/auth/hooks/useAuth.ts +2 -2
  17. package/template/client/src/lib/api/axios.config.ts +14 -7
  18. package/template/client/src/middleware.ts +20 -29
  19. package/template/server/.env.example +9 -0
  20. package/template/server/.env.example.production +9 -0
  21. package/template/server/package.json +2 -1
  22. package/template/server/postman/collection.json +114 -5
  23. package/template/server/postman/environment.json +2 -2
  24. package/template/server/prisma/schema.prisma +17 -1
  25. package/template/server/src/app.ts +4 -1
  26. package/template/server/src/config/env.ts +6 -0
  27. package/template/server/src/jobs/cleanup-deleted-accounts.job.ts +3 -6
  28. package/template/server/src/libs/auth.ts +45 -1
  29. package/template/server/src/libs/cookies.ts +35 -4
  30. package/template/server/src/libs/ip-block.ts +90 -29
  31. package/template/server/src/libs/requestLogger.ts +1 -1
  32. package/template/server/src/libs/storage/file-storage.service.ts +65 -18
  33. package/template/server/src/libs/storage/file-validator.ts +0 -8
  34. package/template/server/src/modules/admin/admin.controller.ts +4 -3
  35. package/template/server/src/modules/auth/auth.repo.ts +18 -0
  36. package/template/server/src/modules/auth/auth.service.ts +52 -26
  37. package/template/server/src/modules/users/users.controller.ts +39 -21
  38. package/template/server/src/modules/users/users.routes.ts +127 -6
  39. package/template/server/src/modules/users/users.schemas.ts +24 -4
  40. package/template/server/src/modules/users/users.service.ts +23 -10
  41. package/template/server/src/shared/types/index.ts +2 -0
  42. package/template/client/src/app/(auth)/layout.tsx +0 -18
  43. package/template/client/src/app/(auth)/login/page.tsx +0 -13
  44. package/template/client/src/app/(auth)/register/page.tsx +0 -13
  45. package/template/client/src/app/(main)/dashboard/page.tsx +0 -22
  46. package/template/client/src/app/(main)/layout.tsx +0 -11
  47. package/template/client/src/app/favicon.ico +0 -0
@@ -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
- await fs.writeFile(path.join(targetDir, '.developer-role'), 'fullstack\n', 'utf-8');
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.2.0",
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`, `verifyEmail`, `requestPasswordReset`, `resetPassword`
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
 
@@ -325,7 +325,6 @@ model User {
325
325
  lastName String
326
326
  role String @default("USER")
327
327
  isActive Boolean @default(true)
328
- emailVerified Boolean @default(false)
329
328
  deletedAt DateTime?
330
329
  createdAt DateTime @default(now())
331
330
  updatedAt DateTime @updatedAt
@@ -14,9 +14,9 @@
14
14
 
15
15
  ROLE_FILE="$CLAUDE_PROJECT_DIR/.developer-role"
16
16
 
17
- # Read role from file, trim whitespace
17
+ # Read role from first non-comment, non-empty line
18
18
  if [ -f "$ROLE_FILE" ]; then
19
- ROLE=$(tr -d '[:space:]' < "$ROLE_FILE")
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`, `verifyEmail`, `requestPasswordReset`, `resetPassword`.
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
- --background: oklch(1 0 0);
59
- --foreground: oklch(0.145 0 0);
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.145 0 0);
62
+ --card-foreground: oklch(0.205 0.015 92);
62
63
  --popover: oklch(1 0 0);
63
- --popover-foreground: oklch(0.145 0 0);
64
- --primary: oklch(0.45 0.2 260);
65
- --primary-foreground: oklch(0.985 0 0);
66
- --secondary: oklch(0.97 0 0);
67
- --secondary-foreground: oklch(0.205 0 0);
68
- --muted: oklch(0.97 0 0);
69
- --muted-foreground: oklch(0.556 0 0);
70
- --accent: oklch(0.97 0 0);
71
- --accent-foreground: oklch(0.205 0 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.922 0 0);
74
- --input: oklch(0.922 0 0);
75
- --ring: oklch(0.45 0.2 260);
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.18 75);
79
- --warning-foreground: oklch(0.2 0 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.646 0.222 41.116);
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.189 84.429);
86
- --chart-5: oklch(0.769 0.188 70.08);
87
- --sidebar: oklch(0.985 0 0);
88
- --sidebar-foreground: oklch(0.145 0 0);
89
- --sidebar-primary: oklch(0.205 0 0);
90
- --sidebar-primary-foreground: oklch(0.985 0 0);
91
- --sidebar-accent: oklch(0.97 0 0);
92
- --sidebar-accent-foreground: oklch(0.205 0 0);
93
- --sidebar-border: oklch(0.922 0 0);
94
- --sidebar-ring: oklch(0.708 0 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
- --background: oklch(0.145 0 0);
99
- --foreground: oklch(0.985 0 0);
100
- --card: oklch(0.205 0 0);
101
- --card-foreground: oklch(0.985 0 0);
102
- --popover: oklch(0.205 0 0);
103
- --popover-foreground: oklch(0.985 0 0);
104
- --primary: oklch(0.6 0.2 260);
105
- --primary-foreground: oklch(0.145 0 0);
106
- --secondary: oklch(0.269 0 0);
107
- --secondary-foreground: oklch(0.985 0 0);
108
- --muted: oklch(0.269 0 0);
109
- --muted-foreground: oklch(0.708 0 0);
110
- --accent: oklch(0.269 0 0);
111
- --accent-foreground: oklch(0.985 0 0);
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 0 / 10%);
114
- --input: oklch(1 0 0 / 15%);
115
- --ring: oklch(0.6 0.2 260);
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.18 75);
119
- --warning-foreground: oklch(0.2 0 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.488 0.243 264.376);
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.188 70.08);
125
- --chart-4: oklch(0.627 0.265 303.9);
126
- --chart-5: oklch(0.645 0.246 16.439);
127
- --sidebar: oklch(0.205 0 0);
128
- --sidebar-foreground: oklch(0.985 0 0);
129
- --sidebar-primary: oklch(0.488 0.243 264.376);
130
- --sidebar-primary-foreground: oklch(0.985 0 0);
131
- --sidebar-accent: oklch(0.269 0 0);
132
- --sidebar-accent-foreground: oklch(0.985 0 0);
133
- --sidebar-border: oklch(1 0 0 / 10%);
134
- --sidebar-ring: oklch(0.556 0 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 { LogOut } from 'lucide-react';
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 default function WelcomePage(): React.ReactElement {
13
- const { user } = useAppSelector((state) => state.auth);
14
- const { logout, isLoggingOut } = useAuth();
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
- <header className="flex items-center justify-between px-6 py-4">
19
- <span className="text-lg font-semibold tracking-tight text-foreground">
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
- </span>
22
- <Button
23
- variant="ghost"
24
- size="sm"
25
- onClick={logout}
26
- disabled={isLoggingOut}
27
- className="text-muted-foreground transition-colors duration-150 hover:text-foreground"
28
- >
29
- <LogOut className="mr-2 h-4 w-4" />
30
- Sign out
31
- </Button>
32
- </header>
33
-
34
- <main className="flex flex-1 items-center justify-center">
35
- {user ? (
36
- <h1 className="text-3xl font-light tracking-tight text-foreground">
37
- Welcome, {user.firstName}
38
- </h1>
39
- ) : (
40
- <Skeleton className="h-9 w-64" />
41
- )}
42
- </main>
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.HOME);
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 refresh endpoint itself
50
- if (originalRequest.url === API_ENDPOINTS.AUTH.REFRESH) {
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
  }