create-tigra 2.3.0 → 2.5.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 +12 -1
- package/package.json +1 -1
- package/template/_claude/hooks/restrict-paths.sh +2 -2
- package/template/_claude/rules/server/project-conventions.md +28 -1
- package/template/client/.dockerignore +63 -0
- package/template/client/Dockerfile +98 -0
- package/template/client/next.config.ts +1 -0
- package/template/client/package.json +0 -1
- 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/layout.tsx +1 -1
- package/template/client/src/app/page.tsx +66 -35
- package/template/client/src/app/providers.tsx +1 -3
- package/template/client/src/components/common/SafeImage.tsx +48 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +8 -2
- 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/.dockerignore +0 -1
- package/template/server/.env.example +30 -0
- package/template/server/.env.example.production +35 -0
- package/template/server/Dockerfile +8 -0
- package/template/server/package.json +1 -1
- package/template/server/src/config/env.ts +6 -0
- package/template/server/src/libs/cookies.ts +35 -4
- package/template/server/src/server.ts +1 -1
- package/template/server/tsconfig.build.json +4 -0
- package/template/server/tsconfig.json +1 -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
|
@@ -216,7 +216,18 @@ async function main() {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
// Create .developer-role file (default: fullstack = no restrictions)
|
|
219
|
-
|
|
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');
|
|
220
231
|
|
|
221
232
|
spinner.succeed('Project scaffolded successfully!');
|
|
222
233
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -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
|
|
@@ -215,7 +215,15 @@ const apiClient = axios.create({ ...httpClient.defaults, baseURL: 'https://api.e
|
|
|
215
215
|
|
|
216
216
|
## File Storage
|
|
217
217
|
|
|
218
|
-
|
|
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>/`
|
|
219
227
|
|
|
220
228
|
| Media type | Path | Example |
|
|
221
229
|
|---|---|---|
|
|
@@ -225,3 +233,22 @@ Upload directory structure: `uploads/users/{userId}/<media-type>/`
|
|
|
225
233
|
- On account purge, delete the entire `uploads/users/{userId}/` directory via `deleteUserMedia()`.
|
|
226
234
|
- Public URL pattern: `/uploads/users/{userId}/<media-type>/{filename}`
|
|
227
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`).
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Dependencies (will be installed fresh in Docker)
|
|
2
|
+
node_modules
|
|
3
|
+
npm-debug.log*
|
|
4
|
+
yarn-debug.log*
|
|
5
|
+
yarn-error.log*
|
|
6
|
+
pnpm-debug.log*
|
|
7
|
+
|
|
8
|
+
# Build output (will be built fresh in Docker)
|
|
9
|
+
.next
|
|
10
|
+
out
|
|
11
|
+
*.tsbuildinfo
|
|
12
|
+
|
|
13
|
+
# Environment files (use Docker env vars instead)
|
|
14
|
+
.env
|
|
15
|
+
.env.local
|
|
16
|
+
.env.*.local
|
|
17
|
+
.env.development
|
|
18
|
+
.env.test
|
|
19
|
+
|
|
20
|
+
# Git
|
|
21
|
+
.git
|
|
22
|
+
.gitignore
|
|
23
|
+
.gitattributes
|
|
24
|
+
|
|
25
|
+
# IDE
|
|
26
|
+
.vscode
|
|
27
|
+
.idea
|
|
28
|
+
*.swp
|
|
29
|
+
*.swo
|
|
30
|
+
*~
|
|
31
|
+
.DS_Store
|
|
32
|
+
|
|
33
|
+
# Testing
|
|
34
|
+
coverage
|
|
35
|
+
.nyc_output
|
|
36
|
+
**/__tests__
|
|
37
|
+
**/*.test.ts
|
|
38
|
+
**/*.test.tsx
|
|
39
|
+
**/*.spec.ts
|
|
40
|
+
**/*.spec.tsx
|
|
41
|
+
vitest.config.ts
|
|
42
|
+
jest.config.ts
|
|
43
|
+
|
|
44
|
+
# Documentation
|
|
45
|
+
docs
|
|
46
|
+
CHANGELOG.md
|
|
47
|
+
LICENSE
|
|
48
|
+
|
|
49
|
+
# Docker
|
|
50
|
+
Dockerfile
|
|
51
|
+
.dockerignore
|
|
52
|
+
docker-compose.yml
|
|
53
|
+
|
|
54
|
+
# CI/CD
|
|
55
|
+
.github
|
|
56
|
+
.gitlab-ci.yml
|
|
57
|
+
.circleci
|
|
58
|
+
|
|
59
|
+
# Misc
|
|
60
|
+
.prettierrc
|
|
61
|
+
.eslintrc
|
|
62
|
+
.eslintignore
|
|
63
|
+
.editorconfig
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# ===================================================================
|
|
2
|
+
# Multi-stage Dockerfile for Next.js Production Deployment
|
|
3
|
+
# Optimized for Coolify, Docker Compose, and Kubernetes
|
|
4
|
+
# Requires `output: "standalone"` in next.config.ts
|
|
5
|
+
# ===================================================================
|
|
6
|
+
|
|
7
|
+
# ===================================================================
|
|
8
|
+
# Stage 1: Dependencies (cached layer)
|
|
9
|
+
# ===================================================================
|
|
10
|
+
FROM node:20-alpine AS dependencies
|
|
11
|
+
|
|
12
|
+
WORKDIR /app
|
|
13
|
+
|
|
14
|
+
# Copy package files
|
|
15
|
+
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
|
|
16
|
+
|
|
17
|
+
# Install dependencies (use the lockfile that exists)
|
|
18
|
+
RUN if [ -f pnpm-lock.yaml ]; then \
|
|
19
|
+
npm install -g pnpm && pnpm install --frozen-lockfile; \
|
|
20
|
+
elif [ -f yarn.lock ]; then \
|
|
21
|
+
yarn install --frozen-lockfile; \
|
|
22
|
+
else \
|
|
23
|
+
npm ci; \
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
# ===================================================================
|
|
27
|
+
# Stage 2: Build (compile Next.js)
|
|
28
|
+
# ===================================================================
|
|
29
|
+
FROM node:20-alpine AS builder
|
|
30
|
+
|
|
31
|
+
WORKDIR /app
|
|
32
|
+
|
|
33
|
+
# Copy dependencies from previous stage
|
|
34
|
+
COPY --from=dependencies /app/node_modules ./node_modules
|
|
35
|
+
|
|
36
|
+
# Copy source code
|
|
37
|
+
COPY . .
|
|
38
|
+
|
|
39
|
+
# Build arguments for env vars needed at build time
|
|
40
|
+
# Next.js inlines NEXT_PUBLIC_* values during build, so they must
|
|
41
|
+
# be available here. Pass them via --build-arg in Coolify/Docker.
|
|
42
|
+
ARG NEXT_PUBLIC_API_BASE_URL
|
|
43
|
+
ARG NEXT_PUBLIC_APP_NAME
|
|
44
|
+
|
|
45
|
+
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
|
46
|
+
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
|
|
47
|
+
|
|
48
|
+
# Disable Next.js telemetry during build
|
|
49
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
50
|
+
|
|
51
|
+
# Build the application (outputs to .next/standalone)
|
|
52
|
+
RUN npm run build
|
|
53
|
+
|
|
54
|
+
# ===================================================================
|
|
55
|
+
# Stage 3: Production Runtime
|
|
56
|
+
# ===================================================================
|
|
57
|
+
FROM node:20-alpine AS production
|
|
58
|
+
|
|
59
|
+
# Install dumb-init for proper signal handling
|
|
60
|
+
RUN apk add --no-cache dumb-init
|
|
61
|
+
|
|
62
|
+
# Create non-root user for security
|
|
63
|
+
RUN addgroup -g 1001 -S nodejs && \
|
|
64
|
+
adduser -S nextjs -u 1001
|
|
65
|
+
|
|
66
|
+
WORKDIR /app
|
|
67
|
+
|
|
68
|
+
# Copy the standalone server (includes only required node_modules)
|
|
69
|
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
70
|
+
|
|
71
|
+
# Copy static assets (not included in standalone output)
|
|
72
|
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
73
|
+
|
|
74
|
+
# Copy public folder (favicon, images, etc.)
|
|
75
|
+
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
|
76
|
+
|
|
77
|
+
# Switch to non-root user
|
|
78
|
+
USER nextjs
|
|
79
|
+
|
|
80
|
+
# Disable telemetry at runtime
|
|
81
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
82
|
+
|
|
83
|
+
# Set hostname to listen on all interfaces (required in containers)
|
|
84
|
+
ENV HOSTNAME="0.0.0.0"
|
|
85
|
+
|
|
86
|
+
# Default port (override via PORT env var in Coolify)
|
|
87
|
+
ENV PORT=3000
|
|
88
|
+
EXPOSE 3000
|
|
89
|
+
|
|
90
|
+
# Health check for container orchestration
|
|
91
|
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
|
92
|
+
CMD node -e "const p=process.env.PORT||3000;require('http').get('http://localhost:'+p,(r)=>{process.exit(r.statusCode===200?0:1)})"
|
|
93
|
+
|
|
94
|
+
# Use dumb-init to handle signals properly
|
|
95
|
+
ENTRYPOINT ["dumb-init", "--"]
|
|
96
|
+
|
|
97
|
+
# Start the standalone Next.js server
|
|
98
|
+
CMD ["node", "server.js"]
|
|
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
|
|
@@ -27,7 +27,7 @@ export default function RootLayout({
|
|
|
27
27
|
children: React.ReactNode;
|
|
28
28
|
}>): React.ReactElement {
|
|
29
29
|
return (
|
|
30
|
-
<html lang="en" suppressHydrationWarning>
|
|
30
|
+
<html lang="en" className="dark" suppressHydrationWarning>
|
|
31
31
|
<body className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}>
|
|
32
32
|
<Providers>{children}</Providers>
|
|
33
33
|
</body>
|
|
@@ -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';
|
|
@@ -30,13 +29,12 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
|
|
|
30
29
|
return (
|
|
31
30
|
<ReduxProvider store={store}>
|
|
32
31
|
<QueryClientProvider client={queryClient}>
|
|
33
|
-
<ThemeProvider attribute="class" defaultTheme="
|
|
32
|
+
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
|
|
34
33
|
<AuthInitializer>
|
|
35
34
|
{children}
|
|
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
|
+
};
|
|
@@ -10,12 +10,16 @@ import { ROUTES } from '@/lib/constants/routes';
|
|
|
10
10
|
import { authService } from '../services/auth.service';
|
|
11
11
|
import { setUser, setInitialized } from '../store/authSlice';
|
|
12
12
|
|
|
13
|
-
const
|
|
13
|
+
const PROTECTED_PATHS: string[] = [ROUTES.DASHBOARD, ROUTES.PROFILE];
|
|
14
14
|
|
|
15
15
|
interface AuthInitializerProps {
|
|
16
16
|
children: React.ReactNode;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function isProtectedPath(pathname: string): boolean {
|
|
20
|
+
return PROTECTED_PATHS.some((path) => pathname.startsWith(path));
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
export const AuthInitializer = ({ children }: AuthInitializerProps): React.ReactElement => {
|
|
20
24
|
const dispatch = useAppDispatch();
|
|
21
25
|
const pathname = usePathname();
|
|
@@ -23,10 +27,12 @@ export const AuthInitializer = ({ children }: AuthInitializerProps): React.React
|
|
|
23
27
|
const { isAuthenticated, isLoggingOut } = useAppSelector((state) => state.auth);
|
|
24
28
|
|
|
25
29
|
useEffect(() => {
|
|
26
|
-
|
|
30
|
+
// On public pages, skip auth hydration — just mark as initialized
|
|
31
|
+
if (!isProtectedPath(pathname)) {
|
|
27
32
|
dispatch(setInitialized());
|
|
28
33
|
return;
|
|
29
34
|
}
|
|
35
|
+
|
|
30
36
|
if (isAuthenticated || isLoggingOut) return;
|
|
31
37
|
|
|
32
38
|
let cancelled = false;
|
|
@@ -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: string[] = [
|
|
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
|
}
|
|
@@ -2,8 +2,7 @@ import { NextResponse } from 'next/server';
|
|
|
2
2
|
|
|
3
3
|
import type { NextRequest } from 'next/server';
|
|
4
4
|
|
|
5
|
-
const protectedPaths = ['/
|
|
6
|
-
const authPaths = ['/login', '/register'];
|
|
5
|
+
const protectedPaths = ['/dashboard', '/profile', '/admin'];
|
|
7
6
|
|
|
8
7
|
function isTokenExpired(token: string): boolean {
|
|
9
8
|
try {
|
|
@@ -17,38 +16,33 @@ function isTokenExpired(token: string): boolean {
|
|
|
17
16
|
|
|
18
17
|
export function middleware(request: NextRequest): NextResponse {
|
|
19
18
|
const { pathname } = request.nextUrl;
|
|
20
|
-
const
|
|
19
|
+
const accessToken = request.cookies.get('access_token')?.value;
|
|
20
|
+
const authSession = request.cookies.get('auth_session')?.value;
|
|
21
21
|
|
|
22
|
-
const isProtectedPath = protectedPaths.some((path) =>
|
|
23
|
-
path === '/' ? pathname === '/' : pathname.startsWith(path)
|
|
24
|
-
);
|
|
22
|
+
const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path));
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// Expired token on protected path — allow through so client-side can attempt refresh.
|
|
34
|
-
// Delete the stale access_token so AuthInitializer starts fresh: getMe() → 401 → refresh.
|
|
35
|
-
if (isProtectedPath && token && isTokenExpired(token)) {
|
|
36
|
-
const response = NextResponse.next();
|
|
37
|
-
response.cookies.delete('access_token');
|
|
38
|
-
return response;
|
|
39
|
-
}
|
|
24
|
+
if (isProtectedPath) {
|
|
25
|
+
// No access_token AND no auth_session — truly unauthenticated
|
|
26
|
+
if (!accessToken && !authSession) {
|
|
27
|
+
const loginUrl = new URL('/login', request.url);
|
|
28
|
+
loginUrl.searchParams.set('from', pathname);
|
|
29
|
+
return NextResponse.redirect(loginUrl);
|
|
30
|
+
}
|
|
40
31
|
|
|
41
|
-
|
|
32
|
+
// No access_token BUT auth_session exists — session is alive,
|
|
33
|
+
// let through so client-side can do getMe() → 401 → refresh → retry
|
|
34
|
+
if (!accessToken && authSession) {
|
|
35
|
+
return NextResponse.next();
|
|
36
|
+
}
|
|
42
37
|
|
|
43
|
-
|
|
44
|
-
if (isTokenExpired(
|
|
45
|
-
// Only delete the expired access token — keep the refresh token
|
|
46
|
-
// so the client-side interceptor can still recover the session
|
|
38
|
+
// Expired access_token — delete stale cookie, let through for client-side refresh
|
|
39
|
+
if (accessToken && isTokenExpired(accessToken)) {
|
|
47
40
|
const response = NextResponse.next();
|
|
48
41
|
response.cookies.delete('access_token');
|
|
49
42
|
return response;
|
|
50
43
|
}
|
|
51
|
-
|
|
44
|
+
|
|
45
|
+
return NextResponse.next();
|
|
52
46
|
}
|
|
53
47
|
|
|
54
48
|
return NextResponse.next();
|
|
@@ -56,11 +50,8 @@ export function middleware(request: NextRequest): NextResponse {
|
|
|
56
50
|
|
|
57
51
|
export const config = {
|
|
58
52
|
matcher: [
|
|
59
|
-
'/',
|
|
60
53
|
'/dashboard/:path*',
|
|
61
54
|
'/profile/:path*',
|
|
62
55
|
'/admin/:path*',
|
|
63
|
-
'/login',
|
|
64
|
-
'/register',
|
|
65
56
|
],
|
|
66
57
|
};
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
# ===================================================================
|
|
2
2
|
# APPLICATION CONFIGURATION
|
|
3
3
|
# ===================================================================
|
|
4
|
+
#
|
|
5
|
+
# COOLIFY DEPLOYMENT NOTE:
|
|
6
|
+
# When adding environment variables in Coolify, do NOT check
|
|
7
|
+
# "Available at Buildtime" for any variable unless explicitly noted.
|
|
8
|
+
# The server Dockerfile handles build-time config internally.
|
|
9
|
+
# All variables below are RUNTIME-ONLY unless marked otherwise.
|
|
10
|
+
#
|
|
11
|
+
# ===================================================================
|
|
4
12
|
|
|
5
13
|
# Environment: development | production | test
|
|
14
|
+
# COOLIFY: Do NOT check "Available at Buildtime" — the Dockerfile
|
|
15
|
+
# sets NODE_ENV=development during build to ensure devDependencies install.
|
|
6
16
|
NODE_ENV=development
|
|
7
17
|
|
|
8
18
|
# Server port (default: 8000)
|
|
@@ -17,6 +27,7 @@ HOST=0.0.0.0
|
|
|
17
27
|
|
|
18
28
|
# Database connection string
|
|
19
29
|
# Format: mysql://username:password@host:port/database
|
|
30
|
+
# COOLIFY: Runtime only. Do NOT check "Available at Buildtime".
|
|
20
31
|
DATABASE_URL="mysql://root:rootpassword@localhost:{{MYSQL_PORT}}/{{DATABASE_NAME}}"
|
|
21
32
|
|
|
22
33
|
# Connection pool settings (for high-traffic production)
|
|
@@ -31,6 +42,7 @@ DATABASE_POOL_MAX=10
|
|
|
31
42
|
|
|
32
43
|
# Redis connection URL
|
|
33
44
|
# Format: redis://[:password@]host:port[/database]
|
|
45
|
+
# COOLIFY: Runtime only. Do NOT check "Available at Buildtime".
|
|
34
46
|
REDIS_URL="redis://localhost:{{REDIS_PORT}}"
|
|
35
47
|
|
|
36
48
|
# Max retry attempts for failed Redis operations
|
|
@@ -62,6 +74,13 @@ RATE_LIMIT_MULTIPLIER=1
|
|
|
62
74
|
# Maximum file upload size in MB (default: 10)
|
|
63
75
|
MAX_FILE_SIZE_MB=10
|
|
64
76
|
|
|
77
|
+
# COOLIFY PERSISTENT STORAGE (required for uploads to survive redeployments):
|
|
78
|
+
# Go to your service in Coolify → Storages → Add Volume Mount:
|
|
79
|
+
# Name: uploads (or <project-name>-uploads)
|
|
80
|
+
# Source Path: (leave empty — Coolify manages the Docker volume)
|
|
81
|
+
# Destination Path: /app/uploads
|
|
82
|
+
# Without this, all uploaded files are lost on every redeployment.
|
|
83
|
+
|
|
65
84
|
# ===================================================================
|
|
66
85
|
# DOCKER PORTS (auto-generated, unique per project)
|
|
67
86
|
# ===================================================================
|
|
@@ -79,6 +98,7 @@ REDIS_COMMANDER_PORT={{REDIS_COMMANDER_PORT}}
|
|
|
79
98
|
# JWT secret key (MUST be at least 32 characters)
|
|
80
99
|
# CRITICAL: Generate a strong random secret for production!
|
|
81
100
|
# Example: openssl rand -base64 48
|
|
101
|
+
# COOLIFY: Runtime only. Do NOT check "Available at Buildtime" — this is a secret!
|
|
82
102
|
JWT_SECRET="{{JWT_SECRET}}"
|
|
83
103
|
|
|
84
104
|
# Access token expiry (short-lived)
|
|
@@ -92,8 +112,18 @@ JWT_REFRESH_EXPIRY="7d"
|
|
|
92
112
|
# Cookie signing secret (separate from JWT for defense-in-depth)
|
|
93
113
|
# Optional: defaults to JWT_SECRET if not set
|
|
94
114
|
# For production: generate a separate secret: openssl rand -base64 48
|
|
115
|
+
# COOLIFY: Runtime only. Do NOT check "Available at Buildtime" — this is a secret!
|
|
95
116
|
# COOKIE_SECRET="change-this-to-a-different-secret-at-least-32-chars"
|
|
96
117
|
|
|
118
|
+
# Cookie domain for cross-origin deployments
|
|
119
|
+
# REQUIRED when client and API are on different subdomains:
|
|
120
|
+
# Client: https://app.example.com | API: https://api.example.com
|
|
121
|
+
# → Set COOKIE_DOMAIN=".example.com" (note the leading dot)
|
|
122
|
+
# NOT needed when client and API share the same hostname (local dev, same-origin prod)
|
|
123
|
+
# Without this, cookies are scoped to the API hostname only and the browser
|
|
124
|
+
# will silently reject them on cross-origin requests (login appears to do nothing).
|
|
125
|
+
# COOKIE_DOMAIN=".example.com"
|
|
126
|
+
|
|
97
127
|
# ===================================================================
|
|
98
128
|
# CORS (Cross-Origin Resource Sharing)
|
|
99
129
|
# ===================================================================
|
|
@@ -3,12 +3,22 @@
|
|
|
3
3
|
# ===================================================================
|
|
4
4
|
# This file contains production-optimized settings for high-traffic
|
|
5
5
|
# deployments (10K-100K users/day). Copy to .env and customize.
|
|
6
|
+
#
|
|
7
|
+
# COOLIFY DEPLOYMENT NOTE:
|
|
8
|
+
# When adding environment variables in Coolify, do NOT check
|
|
9
|
+
# "Available at Buildtime" for any variable unless explicitly noted.
|
|
10
|
+
# The server Dockerfile handles build-time config internally.
|
|
11
|
+
# Secrets (DATABASE_URL, JWT_SECRET, COOKIE_SECRET, REDIS_URL) must
|
|
12
|
+
# NEVER be available at buildtime — they get baked into the image layer.
|
|
13
|
+
#
|
|
6
14
|
# ===================================================================
|
|
7
15
|
|
|
8
16
|
# ===================================================================
|
|
9
17
|
# APPLICATION
|
|
10
18
|
# ===================================================================
|
|
11
19
|
|
|
20
|
+
# COOLIFY: Do NOT check "Available at Buildtime" — the Dockerfile
|
|
21
|
+
# sets NODE_ENV=development during build to ensure devDependencies install.
|
|
12
22
|
NODE_ENV=production
|
|
13
23
|
PORT=3000
|
|
14
24
|
HOST=0.0.0.0
|
|
@@ -19,6 +29,7 @@ HOST=0.0.0.0
|
|
|
19
29
|
|
|
20
30
|
# Production database connection
|
|
21
31
|
# CRITICAL: Use secure credentials, SSL/TLS, and private network
|
|
32
|
+
# COOLIFY: Runtime only. Do NOT check "Available at Buildtime".
|
|
22
33
|
DATABASE_URL="mysql://prod_user:SECURE_PASSWORD@db.internal:3306/prod_db?ssl=true"
|
|
23
34
|
|
|
24
35
|
# Connection pool for high traffic (10K-100K users/day)
|
|
@@ -33,6 +44,7 @@ DATABASE_POOL_MAX=50
|
|
|
33
44
|
|
|
34
45
|
# Production Redis instance
|
|
35
46
|
# CRITICAL: Use authentication and private network
|
|
47
|
+
# COOLIFY: Runtime only. Do NOT check "Available at Buildtime".
|
|
36
48
|
REDIS_URL="redis://:REDIS_PASSWORD@redis.internal:6379"
|
|
37
49
|
|
|
38
50
|
# Production retry settings
|
|
@@ -60,18 +72,30 @@ RATE_LIMIT_AUTH_REGISTER_MAX=5
|
|
|
60
72
|
# Maximum file upload size in MB
|
|
61
73
|
MAX_FILE_SIZE_MB=10
|
|
62
74
|
|
|
75
|
+
# COOLIFY PERSISTENT STORAGE (required for uploads to survive redeployments):
|
|
76
|
+
# Go to your service in Coolify → Storages → Add Volume Mount:
|
|
77
|
+
# Name: uploads (or <project-name>-uploads)
|
|
78
|
+
# Source Path: (leave empty — Coolify manages the Docker volume)
|
|
79
|
+
# Destination Path: /app/uploads
|
|
80
|
+
# Without this, all uploaded files are lost on every redeployment.
|
|
81
|
+
|
|
63
82
|
# ===================================================================
|
|
64
83
|
# JWT AUTHENTICATION
|
|
65
84
|
# ===================================================================
|
|
66
85
|
|
|
67
86
|
# CRITICAL: Generate a cryptographically secure secret
|
|
68
87
|
# Example: openssl rand -base64 48
|
|
88
|
+
# COOLIFY: Runtime only. Do NOT check "Available at Buildtime" — this is a secret!
|
|
69
89
|
JWT_SECRET="YOUR_PRODUCTION_JWT_SECRET_MUST_BE_AT_LEAST_32_CHARS_LONG"
|
|
70
90
|
|
|
71
91
|
# Production token expiry (tighter security)
|
|
72
92
|
JWT_ACCESS_EXPIRY="15m"
|
|
73
93
|
JWT_REFRESH_EXPIRY="7d"
|
|
74
94
|
|
|
95
|
+
# Cookie signing secret (separate from JWT for defense-in-depth)
|
|
96
|
+
# COOLIFY: Runtime only. Do NOT check "Available at Buildtime" — this is a secret!
|
|
97
|
+
COOKIE_SECRET="YOUR_PRODUCTION_COOKIE_SECRET_MUST_BE_AT_LEAST_32_CHARS"
|
|
98
|
+
|
|
75
99
|
# ===================================================================
|
|
76
100
|
# CORS
|
|
77
101
|
# ===================================================================
|
|
@@ -80,6 +104,14 @@ JWT_REFRESH_EXPIRY="7d"
|
|
|
80
104
|
# Multiple origins separated by commas
|
|
81
105
|
CORS_ORIGIN="https://yourdomain.com,https://app.yourdomain.com"
|
|
82
106
|
|
|
107
|
+
# Cookie domain for cross-origin deployments
|
|
108
|
+
# REQUIRED when client and API are on different subdomains:
|
|
109
|
+
# Client: https://app.example.com | API: https://api.example.com
|
|
110
|
+
# → Set COOKIE_DOMAIN=".example.com" (leading dot = all subdomains)
|
|
111
|
+
# This enables cookies to be shared between client and API subdomains.
|
|
112
|
+
# Without it, login will silently fail (server returns 200 but browser drops cookies).
|
|
113
|
+
COOKIE_DOMAIN=".yourdomain.com"
|
|
114
|
+
|
|
83
115
|
# ===================================================================
|
|
84
116
|
# LOGGING
|
|
85
117
|
# ===================================================================
|
|
@@ -102,6 +134,7 @@ SENTRY_DSN="https://YOUR_PUBLIC_KEY@o0.ingest.sentry.io/YOUR_PROJECT_ID"
|
|
|
102
134
|
# [ ] DATABASE_URL uses secure credentials and SSL
|
|
103
135
|
# [ ] JWT_SECRET is a strong random string (48+ chars)
|
|
104
136
|
# [ ] CORS_ORIGIN is set to your exact frontend URL(s)
|
|
137
|
+
# [ ] COOKIE_DOMAIN is set if client and API are on different subdomains
|
|
105
138
|
# [ ] REDIS_URL uses authentication if Redis is exposed
|
|
106
139
|
# [ ] LOG_LEVEL is set to info or warn
|
|
107
140
|
# [ ] SENTRY_DSN is configured for error tracking
|
|
@@ -109,3 +142,5 @@ SENTRY_DSN="https://YOUR_PUBLIC_KEY@o0.ingest.sentry.io/YOUR_PROJECT_ID"
|
|
|
109
142
|
# [ ] All secrets are stored in environment variables, not in code
|
|
110
143
|
# [ ] SSL/TLS certificates are configured
|
|
111
144
|
# [ ] Firewall rules restrict access to database and Redis
|
|
145
|
+
# [ ] In Coolify: NO secrets have "Available at Buildtime" checked
|
|
146
|
+
# [ ] In Coolify: NODE_ENV does NOT have "Available at Buildtime" checked
|
|
@@ -28,6 +28,11 @@ RUN if [ -f pnpm-lock.yaml ]; then \
|
|
|
28
28
|
# ===================================================================
|
|
29
29
|
FROM node:20-alpine AS builder
|
|
30
30
|
|
|
31
|
+
# Override NODE_ENV to ensure devDependencies are installed.
|
|
32
|
+
# Coolify may inject NODE_ENV=production as a build arg, which causes npm ci
|
|
33
|
+
# to skip devDependencies (typescript, @types/node, etc.) and break the build.
|
|
34
|
+
ENV NODE_ENV=development
|
|
35
|
+
|
|
31
36
|
WORKDIR /app
|
|
32
37
|
|
|
33
38
|
# Copy package files and install ALL dependencies (including dev)
|
|
@@ -77,6 +82,9 @@ COPY --from=builder --chown=nodejs:nodejs /app/package.json ./package.json
|
|
|
77
82
|
# Copy Prisma files (needed for migrations)
|
|
78
83
|
COPY --from=builder --chown=nodejs:nodejs /app/prisma ./prisma
|
|
79
84
|
|
|
85
|
+
# Create uploads directory with correct ownership (must be before USER nodejs)
|
|
86
|
+
RUN mkdir -p /app/uploads && chown nodejs:nodejs /app/uploads
|
|
87
|
+
|
|
80
88
|
# Switch to non-root user
|
|
81
89
|
USER nodejs
|
|
82
90
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"main": "dist/server.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"dev": "tsx watch src/server.ts",
|
|
9
|
-
"build": "tsc && tsc-alias",
|
|
9
|
+
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
|
10
10
|
"start": "node dist/server.js",
|
|
11
11
|
"test": "vitest run",
|
|
12
12
|
"test:watch": "vitest",
|
|
@@ -39,6 +39,12 @@ const envSchema = z.object({
|
|
|
39
39
|
// Separate secret for cookie signing (defaults to JWT_SECRET if not set)
|
|
40
40
|
COOKIE_SECRET: z.string().min(32, 'COOKIE_SECRET must be at least 32 characters').optional(),
|
|
41
41
|
|
|
42
|
+
// Cookie domain for cross-origin deployments (client ≠ server hostname)
|
|
43
|
+
// Required when client and API are on different subdomains (e.g., app.example.com + api.example.com)
|
|
44
|
+
// Set to the shared parent domain with a leading dot: ".example.com"
|
|
45
|
+
// Leave empty for same-origin deployments or local development
|
|
46
|
+
COOKIE_DOMAIN: z.string().optional(),
|
|
47
|
+
|
|
42
48
|
// --- CORS ---
|
|
43
49
|
// In development: CORS_ORIGIN is optional (allows all origins)
|
|
44
50
|
// In production: REQUIRED for security
|
|
@@ -4,6 +4,14 @@ import { parseDurationMs } from '@libs/auth.js';
|
|
|
4
4
|
|
|
5
5
|
const isProduction = env.NODE_ENV === 'production';
|
|
6
6
|
|
|
7
|
+
// Cross-origin deployment: client and API on different subdomains require sameSite 'none'.
|
|
8
|
+
// Same-origin deployment (or local dev): 'strict' is the safest default.
|
|
9
|
+
const isCrossOrigin = Boolean(env.COOKIE_DOMAIN);
|
|
10
|
+
const sameSitePolicy = isProduction && isCrossOrigin ? 'none' as const : 'strict' as const;
|
|
11
|
+
|
|
12
|
+
// When set, cookies are shared across subdomains (e.g., ".example.com" covers app.example.com + api.example.com)
|
|
13
|
+
const cookieDomain = env.COOKIE_DOMAIN || undefined;
|
|
14
|
+
|
|
7
15
|
const ACCESS_TOKEN_MAX_AGE_MS = parseDurationMs(env.JWT_ACCESS_EXPIRY, 15 * 60 * 1000);
|
|
8
16
|
const REFRESH_TOKEN_MAX_AGE_MS = parseDurationMs(env.JWT_REFRESH_EXPIRY, 7 * 24 * 60 * 60 * 1000);
|
|
9
17
|
|
|
@@ -15,7 +23,8 @@ export function setAuthCookies(
|
|
|
15
23
|
reply.setCookie('access_token', accessToken, {
|
|
16
24
|
httpOnly: true,
|
|
17
25
|
secure: isProduction,
|
|
18
|
-
sameSite:
|
|
26
|
+
sameSite: sameSitePolicy,
|
|
27
|
+
domain: cookieDomain,
|
|
19
28
|
path: '/',
|
|
20
29
|
maxAge: Math.floor(ACCESS_TOKEN_MAX_AGE_MS / 1000), // setCookie expects seconds
|
|
21
30
|
});
|
|
@@ -23,24 +32,46 @@ export function setAuthCookies(
|
|
|
23
32
|
reply.setCookie('refresh_token', refreshToken, {
|
|
24
33
|
httpOnly: true,
|
|
25
34
|
secure: isProduction,
|
|
26
|
-
sameSite:
|
|
35
|
+
sameSite: sameSitePolicy,
|
|
36
|
+
domain: cookieDomain,
|
|
27
37
|
path: '/api/v1/auth',
|
|
28
38
|
maxAge: Math.floor(REFRESH_TOKEN_MAX_AGE_MS / 1000),
|
|
29
39
|
});
|
|
40
|
+
|
|
41
|
+
// Non-sensitive session indicator visible to Next.js middleware and client JS.
|
|
42
|
+
// Lets middleware distinguish "never logged in" from "access token expired but session alive."
|
|
43
|
+
reply.setCookie('auth_session', '1', {
|
|
44
|
+
httpOnly: false,
|
|
45
|
+
secure: isProduction,
|
|
46
|
+
sameSite: sameSitePolicy,
|
|
47
|
+
domain: cookieDomain,
|
|
48
|
+
path: '/',
|
|
49
|
+
maxAge: Math.floor(REFRESH_TOKEN_MAX_AGE_MS / 1000),
|
|
50
|
+
});
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
export function clearAuthCookies(reply: FastifyReply): void {
|
|
33
54
|
reply.clearCookie('access_token', {
|
|
34
55
|
httpOnly: true,
|
|
35
56
|
secure: isProduction,
|
|
36
|
-
sameSite:
|
|
57
|
+
sameSite: sameSitePolicy,
|
|
58
|
+
domain: cookieDomain,
|
|
37
59
|
path: '/',
|
|
38
60
|
});
|
|
39
61
|
|
|
40
62
|
reply.clearCookie('refresh_token', {
|
|
41
63
|
httpOnly: true,
|
|
42
64
|
secure: isProduction,
|
|
43
|
-
sameSite:
|
|
65
|
+
sameSite: sameSitePolicy,
|
|
66
|
+
domain: cookieDomain,
|
|
44
67
|
path: '/api/v1/auth',
|
|
45
68
|
});
|
|
69
|
+
|
|
70
|
+
reply.clearCookie('auth_session', {
|
|
71
|
+
httpOnly: false,
|
|
72
|
+
secure: isProduction,
|
|
73
|
+
sameSite: sameSitePolicy,
|
|
74
|
+
domain: cookieDomain,
|
|
75
|
+
path: '/',
|
|
76
|
+
});
|
|
46
77
|
}
|
|
@@ -13,7 +13,7 @@ async function start(): Promise<void> {
|
|
|
13
13
|
if (isShuttingDown) return; // Prevent multiple shutdown attempts
|
|
14
14
|
isShuttingDown = true;
|
|
15
15
|
|
|
16
|
-
logger.info(`[SERVER] Received ${signal}
|
|
16
|
+
logger.info(`[SERVER] Received ${signal} - shutting down gracefully`);
|
|
17
17
|
try {
|
|
18
18
|
if (app) {
|
|
19
19
|
await app.close();
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type React from 'react';
|
|
2
|
-
|
|
3
|
-
import { APP_NAME } from '@/lib/constants/app.constants';
|
|
4
|
-
|
|
5
|
-
export default function AuthLayout({
|
|
6
|
-
children,
|
|
7
|
-
}: {
|
|
8
|
-
children: React.ReactNode;
|
|
9
|
-
}): React.ReactElement {
|
|
10
|
-
return (
|
|
11
|
-
<div className="flex min-h-dvh flex-col items-center justify-center px-4 py-12">
|
|
12
|
-
<span className="mb-8 text-xl font-semibold tracking-tight text-foreground">
|
|
13
|
-
{APP_NAME}
|
|
14
|
-
</span>
|
|
15
|
-
{children}
|
|
16
|
-
</div>
|
|
17
|
-
);
|
|
18
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type React from 'react';
|
|
2
|
-
|
|
3
|
-
import { LoginForm } from '@/features/auth/components/LoginForm';
|
|
4
|
-
|
|
5
|
-
import type { Metadata } from 'next';
|
|
6
|
-
|
|
7
|
-
export const metadata: Metadata = {
|
|
8
|
-
title: 'Sign in',
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export default function LoginPage(): React.ReactElement {
|
|
12
|
-
return <LoginForm />;
|
|
13
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type React from 'react';
|
|
2
|
-
|
|
3
|
-
import { RegisterForm } from '@/features/auth/components/RegisterForm';
|
|
4
|
-
|
|
5
|
-
import type { Metadata } from 'next';
|
|
6
|
-
|
|
7
|
-
export const metadata: Metadata = {
|
|
8
|
-
title: 'Create account',
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export default function RegisterPage(): React.ReactElement {
|
|
12
|
-
return <RegisterForm />;
|
|
13
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import type React from 'react';
|
|
2
|
-
|
|
3
|
-
import type { Metadata } from 'next';
|
|
4
|
-
|
|
5
|
-
export const metadata: Metadata = {
|
|
6
|
-
title: 'Dashboard',
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
export default function DashboardPage(): React.ReactElement {
|
|
10
|
-
return (
|
|
11
|
-
<div className="container mx-auto px-4 py-12 md:px-6 lg:px-8">
|
|
12
|
-
<div className="space-y-6">
|
|
13
|
-
<div>
|
|
14
|
-
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
|
15
|
-
<p className="mt-2 text-muted-foreground">
|
|
16
|
-
Welcome to your dashboard. This is where you'll manage everything.
|
|
17
|
-
</p>
|
|
18
|
-
</div>
|
|
19
|
-
</div>
|
|
20
|
-
</div>
|
|
21
|
-
);
|
|
22
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type React from 'react';
|
|
2
|
-
|
|
3
|
-
import { MainLayout } from '@/components/layout/MainLayout';
|
|
4
|
-
|
|
5
|
-
export default function MainGroupLayout({
|
|
6
|
-
children,
|
|
7
|
-
}: {
|
|
8
|
-
children: React.ReactNode;
|
|
9
|
-
}): React.ReactElement {
|
|
10
|
-
return <MainLayout>{children}</MainLayout>;
|
|
11
|
-
}
|
|
Binary file
|