doo-boilerplate 0.1.1 → 0.1.3
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/dist/index.js +7 -9
- package/package.json +1 -1
- package/templates/template-nextjs/README.md +230 -148
- package/templates/template-nextjs/src/features/auth/components/sign-in-form.tsx +1 -1
- package/templates/template-nextjs/src/features/auth/schemas/auth-schema.ts +2 -2
- package/templates/template-nextjs/src/features/auth/services/auth-api.ts +22 -2
- package/templates/template-vite/README.md +230 -166
- package/templates/template-vite/package.json +10 -7
- package/templates/template-vite/src/features/auth/components/sign-in-form.tsx +1 -1
- package/templates/template-vite/src/features/auth/schemas/auth-schema.ts +1 -1
- package/templates/template-vite/src/features/auth/services/auth-api.ts +22 -2
- package/templates/template-vite/vite.config.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -28,7 +28,7 @@ function validateProjectName(name) {
|
|
|
28
28
|
async function collectOptions(defaults, isTTY2 = true) {
|
|
29
29
|
if (!isTTY2) {
|
|
30
30
|
const valid = ["editor", "charts", "dnd", "sentry"];
|
|
31
|
-
const validPMs2 = ["
|
|
31
|
+
const validPMs2 = ["pnpm", "bun", "yarn"];
|
|
32
32
|
const projectName2 = defaults.projectName ?? "my-portal";
|
|
33
33
|
const framework2 = defaults.framework === "nextjs" || defaults.framework === "vite" ? defaults.framework : "vite";
|
|
34
34
|
const packageManager2 = validPMs2.includes(defaults.pm) ? defaults.pm : "pnpm";
|
|
@@ -64,8 +64,8 @@ async function collectOptions(defaults, isTTY2 = true) {
|
|
|
64
64
|
const answer = await p.select({
|
|
65
65
|
message: "Framework",
|
|
66
66
|
options: [
|
|
67
|
-
{ value: "
|
|
68
|
-
{ value: "
|
|
67
|
+
{ value: "vite", label: "Vite 8", hint: "SPA \xB7 faster builds \xB7 TanStack Router" },
|
|
68
|
+
{ value: "nextjs", label: "Next.js 16", hint: "SSR \xB7 App Router \xB7 i18n-ready" }
|
|
69
69
|
]
|
|
70
70
|
});
|
|
71
71
|
if (p.isCancel(answer)) {
|
|
@@ -74,7 +74,7 @@ async function collectOptions(defaults, isTTY2 = true) {
|
|
|
74
74
|
}
|
|
75
75
|
framework = answer;
|
|
76
76
|
}
|
|
77
|
-
const validPMs = ["
|
|
77
|
+
const validPMs = ["pnpm", "bun", "yarn"];
|
|
78
78
|
let packageManager;
|
|
79
79
|
if (validPMs.includes(defaults.pm)) {
|
|
80
80
|
packageManager = defaults.pm;
|
|
@@ -82,11 +82,9 @@ async function collectOptions(defaults, isTTY2 = true) {
|
|
|
82
82
|
const answer = await p.select({
|
|
83
83
|
message: "Package manager",
|
|
84
84
|
options: [
|
|
85
|
-
{ value: "
|
|
86
|
-
{ value: "
|
|
87
|
-
{ value: "
|
|
88
|
-
{ value: "bun", label: "bun", hint: "All-in-one runtime" },
|
|
89
|
-
{ value: "deno", label: "deno", hint: "Secure by default" }
|
|
85
|
+
{ value: "pnpm", label: "pnpm", hint: "recommended \xB7 efficient disk usage" },
|
|
86
|
+
{ value: "bun", label: "bun", hint: "fastest \xB7 all-in-one runtime" },
|
|
87
|
+
{ value: "yarn", label: "yarn", hint: "reliable \xB7 stable" }
|
|
90
88
|
]
|
|
91
89
|
});
|
|
92
90
|
if (p.isCancel(answer)) {
|
package/package.json
CHANGED
|
@@ -1,215 +1,297 @@
|
|
|
1
1
|
# {{PROJECT_NAME}}
|
|
2
2
|
|
|
3
|
-
> Next.js 16
|
|
4
|
-
|
|
5
|
-
Production-ready Next.js 16 application with App Router, i18n, dark mode, authentication, and DevOps integration.
|
|
3
|
+
> Next.js 16 SSR portal — scaffolded by [doo-boilerplate](https://www.npmjs.com/package/doo-boilerplate)
|
|
6
4
|
|
|
7
5
|
## Quick Start
|
|
8
6
|
|
|
9
7
|
```bash
|
|
10
|
-
|
|
8
|
+
cp .env.example .env # configure API URLs
|
|
9
|
+
pnpm dev # http://localhost:3000
|
|
11
10
|
```
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
---
|
|
14
13
|
|
|
15
|
-
##
|
|
14
|
+
## Scripts
|
|
16
15
|
|
|
17
16
|
| Command | Purpose |
|
|
18
17
|
|---------|---------|
|
|
19
|
-
| `pnpm dev` |
|
|
20
|
-
| `pnpm build` |
|
|
18
|
+
| `pnpm dev` | Dev server with Turbopack |
|
|
19
|
+
| `pnpm build` | Production build |
|
|
21
20
|
| `pnpm start` | Run production server |
|
|
22
|
-
| `pnpm lint` |
|
|
23
|
-
| `pnpm
|
|
24
|
-
| `pnpm
|
|
25
|
-
| `pnpm
|
|
26
|
-
| `pnpm
|
|
27
|
-
| `pnpm
|
|
28
|
-
| `pnpm
|
|
29
|
-
| `pnpm
|
|
30
|
-
| `pnpm docker:build` | Build & scan Docker image |
|
|
31
|
-
| `pnpm docker:scan` | Security scan image with Trivy |
|
|
32
|
-
|
|
33
|
-
## Tech Stack
|
|
34
|
-
|
|
35
|
-
- **Framework:** Next.js 16 with App Router
|
|
36
|
-
- **Styling:** Tailwind CSS 4 + Shadcn/ui
|
|
37
|
-
- **i18n:** next-intl (EN/VI built-in)
|
|
38
|
-
- **State:** Zustand 5 (global) + TanStack Query 5 (server)
|
|
39
|
-
- **Forms:** React Hook Form + Zod
|
|
40
|
-
- **HTTP:** Axios with JWT interceptors
|
|
41
|
-
- **Auth:** JWT store with refresh token persistence
|
|
42
|
-
- **DevOps:** Docker (multi-stage) + Trivy security scan
|
|
43
|
-
|
|
44
|
-
## Environment Variables
|
|
45
|
-
|
|
46
|
-
Copy `.env.example` to `.env`:
|
|
21
|
+
| `pnpm lint` / `lint:fix` | ESLint |
|
|
22
|
+
| `pnpm type-check` | TypeScript |
|
|
23
|
+
| `pnpm format` | Prettier |
|
|
24
|
+
| `pnpm knip` | Find unused code |
|
|
25
|
+
| `pnpm gen:api` | Generate types from `docs/swagger/api.json` |
|
|
26
|
+
| `pnpm gen:api:watch` | Watch mode |
|
|
27
|
+
| `pnpm docker:build` | Build image + Trivy security scan |
|
|
28
|
+
| `pnpm docker:scan` | Scan existing image |
|
|
47
29
|
|
|
48
|
-
|
|
49
|
-
cp .env.example .env
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Key variables:
|
|
53
|
-
|
|
54
|
-
```env
|
|
55
|
-
# API Configuration
|
|
56
|
-
NEXT_PUBLIC_API_URL=http://localhost:8000
|
|
57
|
-
NEXT_PUBLIC_API_AUTH_URL=http://localhost:8001
|
|
58
|
-
|
|
59
|
-
# App
|
|
60
|
-
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
61
|
-
NEXT_PUBLIC_APP_NAME={{PROJECT_NAME}}
|
|
62
|
-
```
|
|
30
|
+
---
|
|
63
31
|
|
|
64
|
-
##
|
|
32
|
+
## Folder Structure
|
|
65
33
|
|
|
66
34
|
```
|
|
67
35
|
src/
|
|
68
|
-
├── app/
|
|
69
|
-
│ ├── (auth)/
|
|
70
|
-
│ ├──
|
|
71
|
-
│
|
|
72
|
-
│
|
|
36
|
+
├── app/ # Next.js App Router (pages + layouts)
|
|
37
|
+
│ ├── (auth)/ # Unauthenticated route group
|
|
38
|
+
│ │ ├── layout.tsx # Minimal layout (no sidebar)
|
|
39
|
+
│ │ └── sign-in/page.tsx
|
|
40
|
+
│ ├── (dashboard)/ # Protected route group
|
|
41
|
+
│ │ ├── layout.tsx # Full layout: sidebar + header
|
|
42
|
+
│ │ ├── page.tsx # /dashboard
|
|
43
|
+
│ │ ├── profile/page.tsx # /profile
|
|
44
|
+
│ │ └── settings/page.tsx # /settings
|
|
45
|
+
│ ├── api/health/route.ts # Health check: GET /api/health
|
|
46
|
+
│ ├── layout.tsx # Root: fonts, providers, html lang
|
|
47
|
+
│ └── not-found.tsx # 404 page
|
|
48
|
+
│
|
|
73
49
|
├── components/
|
|
74
|
-
│ ├── ui/
|
|
75
|
-
│ └──
|
|
76
|
-
├──
|
|
77
|
-
│ ├──
|
|
78
|
-
│ │ ├──
|
|
79
|
-
│ │
|
|
80
|
-
│
|
|
81
|
-
│
|
|
82
|
-
│
|
|
83
|
-
│
|
|
84
|
-
|
|
50
|
+
│ ├── ui/ # Shadcn/ui primitives — DO NOT EDIT
|
|
51
|
+
│ │ └── button, card, dialog, form, input, select, ...
|
|
52
|
+
│ ├── common/ # Shared app-wide components
|
|
53
|
+
│ │ ├── error-boundary.tsx
|
|
54
|
+
│ │ ├── loading-spinner.tsx
|
|
55
|
+
│ │ └── theme-toggle.tsx # Dark/light/system toggle
|
|
56
|
+
│ └── layout/ # Page chrome
|
|
57
|
+
│ ├── sidebar.tsx # Collapsible nav sidebar
|
|
58
|
+
│ ├── header.tsx # Top bar with user menu
|
|
59
|
+
│ └── page-layout.tsx # Wrapper: sidebar + main content
|
|
60
|
+
│
|
|
61
|
+
├── features/ # Domain modules (feature-first)
|
|
62
|
+
│ └── auth/
|
|
63
|
+
│ ├── components/
|
|
64
|
+
│ │ └── sign-in-form.tsx
|
|
65
|
+
│ ├── hooks/
|
|
66
|
+
│ │ └── use-auth.ts # useSignIn, useSignOut, useCurrentUser
|
|
67
|
+
│ ├── schemas/
|
|
68
|
+
│ │ └── auth-schema.ts # Zod: LoginSchema, RegisterSchema
|
|
69
|
+
│ └── services/
|
|
70
|
+
│ ├── auth-api.ts # API calls: login, logout, me
|
|
71
|
+
│ └── gen/ # Auto-generated types (pnpm gen:api)
|
|
72
|
+
│
|
|
73
|
+
├── hooks/ # Shared custom hooks
|
|
74
|
+
│ └── use-media-query.ts
|
|
75
|
+
│
|
|
85
76
|
├── i18n/
|
|
86
|
-
│
|
|
87
|
-
│
|
|
88
|
-
|
|
89
|
-
├──
|
|
90
|
-
│ ├──
|
|
91
|
-
│
|
|
92
|
-
│
|
|
93
|
-
├── middleware.ts
|
|
77
|
+
│ └── config.ts # locales: ['en', 'vi'], defaultLocale: 'en'
|
|
78
|
+
│
|
|
79
|
+
├── lib/ # Utilities & singletons
|
|
80
|
+
│ ├── api-client.ts # Axios instance (auto Bearer token + 401 handler)
|
|
81
|
+
│ ├── query-client.ts # TanStack Query client config
|
|
82
|
+
│ └── utils.ts # cn() = clsx + tailwind-merge
|
|
83
|
+
│
|
|
84
|
+
├── middleware.ts # next-intl routing (locale prefix)
|
|
85
|
+
│
|
|
94
86
|
├── providers/
|
|
95
|
-
│ ├──
|
|
96
|
-
│
|
|
97
|
-
|
|
87
|
+
│ ├── app-providers.tsx # Compose all providers here
|
|
88
|
+
│ ├── query-provider.tsx # <QueryClientProvider>
|
|
89
|
+
│ └── theme-provider.tsx # <ThemeProvider> (next-themes)
|
|
90
|
+
│
|
|
91
|
+
├── stores/
|
|
92
|
+
│ └── auth-store.ts # Zustand auth (persisted to localStorage)
|
|
93
|
+
│
|
|
98
94
|
├── styles/
|
|
99
|
-
│
|
|
100
|
-
│
|
|
95
|
+
│ └── globals.css # @import tailwindcss + CSS variables
|
|
96
|
+
│
|
|
101
97
|
└── types/
|
|
102
|
-
└── index.ts
|
|
98
|
+
└── index.ts # Shared TS types (ApiError, PaginatedResponse, etc.)
|
|
103
99
|
```
|
|
104
100
|
|
|
105
|
-
|
|
101
|
+
---
|
|
106
102
|
|
|
107
|
-
|
|
103
|
+
## Design Patterns
|
|
108
104
|
|
|
109
|
-
|
|
110
|
-
# Place spec at docs/swagger/api.json
|
|
111
|
-
curl https://api.example.com/swagger.json > docs/swagger/api.json
|
|
105
|
+
### Feature-First Modules
|
|
112
106
|
|
|
113
|
-
|
|
114
|
-
pnpm gen:api
|
|
107
|
+
Add new domain logic as a feature module:
|
|
115
108
|
|
|
116
|
-
|
|
117
|
-
|
|
109
|
+
```
|
|
110
|
+
features/users/
|
|
111
|
+
├── components/user-table.tsx
|
|
112
|
+
├── hooks/use-users.ts ← TanStack Query hooks
|
|
113
|
+
├── schemas/user-schema.ts ← Zod validation
|
|
114
|
+
└── services/
|
|
115
|
+
├── users-api.ts ← API calls
|
|
116
|
+
└── gen/ ← Generated types (optional)
|
|
118
117
|
```
|
|
119
118
|
|
|
120
|
-
|
|
119
|
+
### State Layers
|
|
121
120
|
|
|
122
|
-
|
|
121
|
+
| State type | Tool | Location |
|
|
122
|
+
|-----------|------|----------|
|
|
123
|
+
| Server / async data | TanStack Query | `features/{x}/hooks/` |
|
|
124
|
+
| Global client state | Zustand | `src/stores/` |
|
|
125
|
+
| Form state | React Hook Form | component-local |
|
|
126
|
+
| URL / route params | `useSearchParams` | component-local |
|
|
123
127
|
|
|
124
|
-
|
|
128
|
+
**Rule:** Never put fetched data in Zustand. Only put truly global client state (auth, UI prefs) in stores.
|
|
125
129
|
|
|
126
|
-
|
|
127
|
-
pnpm docker:build
|
|
128
|
-
```
|
|
130
|
+
### Auth Store
|
|
129
131
|
|
|
130
|
-
|
|
132
|
+
```ts
|
|
133
|
+
import { useAuthStore } from '@/stores/auth-store'
|
|
131
134
|
|
|
132
|
-
|
|
135
|
+
// Read
|
|
136
|
+
const { user, isAuthenticated } = useAuthStore()
|
|
133
137
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
138
|
+
// RBAC
|
|
139
|
+
const isAdmin = useAuthStore(s => s.hasRole('admin'))
|
|
140
|
+
const canPublish = useAuthStore(s => s.hasPermission('publish:articles'))
|
|
141
|
+
|
|
142
|
+
// Mutations
|
|
143
|
+
const { login, logout } = useAuthStore()
|
|
137
144
|
```
|
|
138
145
|
|
|
139
|
-
|
|
146
|
+
Persisted fields: `user`, `accessToken`, `refreshToken`, `isAuthenticated`.
|
|
140
147
|
|
|
141
|
-
###
|
|
148
|
+
### API Client
|
|
142
149
|
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
```ts
|
|
151
|
+
// All features use the shared client from lib/api-client.ts
|
|
152
|
+
import apiClient from '@/lib/api-client'
|
|
146
153
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
// features/users/services/users-api.ts
|
|
155
|
+
export const usersApi = {
|
|
156
|
+
list: (params?: ListParams) =>
|
|
157
|
+
apiClient.get<User[]>('/users', { params }).then(r => r.data),
|
|
158
|
+
create: (data: CreateUserDto) =>
|
|
159
|
+
apiClient.post<User>('/users', data).then(r => r.data),
|
|
160
|
+
update: (id: string, data: UpdateUserDto) =>
|
|
161
|
+
apiClient.patch<User>(`/users/${id}`, data).then(r => r.data),
|
|
162
|
+
}
|
|
150
163
|
```
|
|
151
164
|
|
|
152
|
-
|
|
165
|
+
The client auto-injects `Authorization: Bearer <token>` and clears auth + redirects to `/sign-in` on 401.
|
|
153
166
|
|
|
154
|
-
###
|
|
167
|
+
### TanStack Query Hooks
|
|
155
168
|
|
|
156
|
-
|
|
169
|
+
```ts
|
|
170
|
+
// features/users/hooks/use-users.ts
|
|
171
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
172
|
+
import { usersApi } from '../services/users-api'
|
|
157
173
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
174
|
+
export function useUsers(params?: ListParams) {
|
|
175
|
+
return useQuery({
|
|
176
|
+
queryKey: ['users', params],
|
|
177
|
+
queryFn: () => usersApi.list(params),
|
|
178
|
+
staleTime: 5 * 60_000,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function useCreateUser() {
|
|
183
|
+
const qc = useQueryClient()
|
|
184
|
+
return useMutation({
|
|
185
|
+
mutationFn: usersApi.create,
|
|
186
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
168
189
|
```
|
|
169
190
|
|
|
170
|
-
|
|
191
|
+
### Form Pattern
|
|
171
192
|
|
|
172
|
-
|
|
193
|
+
```ts
|
|
194
|
+
import { useForm } from 'react-hook-form'
|
|
195
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
196
|
+
import { z } from 'zod'
|
|
173
197
|
|
|
174
|
-
|
|
198
|
+
const schema = z.object({ name: z.string().min(2) })
|
|
199
|
+
type Values = z.infer<typeof schema>
|
|
175
200
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
201
|
+
function CreateUserForm() {
|
|
202
|
+
const form = useForm<Values>({ resolver: zodResolver(schema) })
|
|
203
|
+
const { mutate, isPending } = useCreateUser()
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<Form {...form}>
|
|
207
|
+
<form onSubmit={form.handleSubmit(data => mutate(data))}>
|
|
208
|
+
<FormField control={form.control} name="name" render={...} />
|
|
209
|
+
<Button disabled={isPending}>Create</Button>
|
|
210
|
+
</form>
|
|
211
|
+
</Form>
|
|
212
|
+
)
|
|
180
213
|
}
|
|
181
214
|
```
|
|
182
215
|
|
|
183
|
-
|
|
216
|
+
### Protected Routes
|
|
184
217
|
|
|
185
|
-
|
|
186
|
-
// src/components/Counter.tsx (Client Component)
|
|
187
|
-
'use client'
|
|
218
|
+
Route groups handle protection at the layout level:
|
|
188
219
|
|
|
189
|
-
|
|
220
|
+
```ts
|
|
221
|
+
// app/(dashboard)/layout.tsx
|
|
222
|
+
import { redirect } from 'next/navigation'
|
|
223
|
+
import { getAuthFromCookies } from '@/lib/auth-server'
|
|
190
224
|
|
|
191
|
-
export function
|
|
192
|
-
const
|
|
193
|
-
|
|
225
|
+
export default async function DashboardLayout({ children }) {
|
|
226
|
+
const auth = await getAuthFromCookies()
|
|
227
|
+
if (!auth?.isAuthenticated) redirect('/sign-in')
|
|
228
|
+
return <PageLayout>{children}</PageLayout>
|
|
194
229
|
}
|
|
195
230
|
```
|
|
196
231
|
|
|
197
|
-
###
|
|
232
|
+
### Internationalization
|
|
198
233
|
|
|
199
|
-
|
|
234
|
+
```ts
|
|
235
|
+
// Server Component
|
|
236
|
+
import { getTranslations } from 'next-intl/server'
|
|
237
|
+
const t = await getTranslations('dashboard')
|
|
200
238
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
<main>{children}</main>
|
|
208
|
-
</div>
|
|
209
|
-
)
|
|
210
|
-
}
|
|
239
|
+
// Client Component ('use client')
|
|
240
|
+
import { useTranslations } from 'next-intl'
|
|
241
|
+
const t = useTranslations('dashboard')
|
|
242
|
+
|
|
243
|
+
t('welcome') // → "Welcome" / "Chào mừng"
|
|
244
|
+
t('items', { n: 5 }) // with interpolation
|
|
211
245
|
```
|
|
212
246
|
|
|
247
|
+
Add translations in `messages/en.json` and `messages/vi.json`.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## API Type Generation
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
# 1. Place your backend OpenAPI spec
|
|
255
|
+
curl https://api.example.com/swagger.json > docs/swagger/api.json
|
|
256
|
+
|
|
257
|
+
# 2. Generate TypeScript types
|
|
258
|
+
pnpm gen:api
|
|
259
|
+
|
|
260
|
+
# 3. Import generated types
|
|
261
|
+
import type { LoginRequest } from '@/features/auth/services/gen'
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
To generate types for a different feature, update the `-o` path in `package.json`:
|
|
265
|
+
|
|
266
|
+
```json
|
|
267
|
+
"gen:api": "swagger-typescript-api -p ./docs/swagger/api.json -o ./src/features/users/services/gen --no-client --modular"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Environment Variables
|
|
273
|
+
|
|
274
|
+
```env
|
|
275
|
+
# API
|
|
276
|
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
|
277
|
+
NEXT_PUBLIC_API_AUTH_URL=http://localhost:8001
|
|
278
|
+
|
|
279
|
+
# App
|
|
280
|
+
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
281
|
+
NEXT_PUBLIC_APP_NAME={{PROJECT_NAME}}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Docker
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
pnpm docker:build # Build + Trivy scan
|
|
290
|
+
docker-compose up # Local dev stack on :3000
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The Dockerfile uses 3 stages: `deps` → `builder` → `runner` (minimal Node image). Uses `output: 'standalone'` for smallest container size.
|
|
294
|
+
|
|
213
295
|
---
|
|
214
296
|
|
|
215
|
-
**
|
|
297
|
+
**Framework:** Next.js 16 · **Styling:** Tailwind CSS 4 + Shadcn/ui · **i18n:** next-intl (EN/VI) · **State:** Zustand 5 + TanStack Query 5
|
|
@@ -40,7 +40,7 @@ export function SignInForm() {
|
|
|
40
40
|
<Card className="w-full max-w-md">
|
|
41
41
|
<CardHeader className="space-y-1">
|
|
42
42
|
<CardTitle className="text-2xl font-bold">Sign in</CardTitle>
|
|
43
|
-
<CardDescription>Enter your credentials to access your account</CardDescription>
|
|
43
|
+
<CardDescription>Enter your credentials to access your account · or try <strong>demo / demo</strong></CardDescription>
|
|
44
44
|
</CardHeader>
|
|
45
45
|
<CardContent>
|
|
46
46
|
<Form {...form}>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
3
|
export const signInSchema = z.object({
|
|
4
|
-
email: z.string().email('Invalid email address'),
|
|
5
|
-
password: z.string().min(6, 'Password must be at least 6 characters'),
|
|
4
|
+
email: z.union([z.string().email('Invalid email address'), z.literal('demo')]),
|
|
5
|
+
password: z.union([z.string().min(6, 'Password must be at least 6 characters'), z.literal('demo')]),
|
|
6
6
|
})
|
|
7
7
|
|
|
8
8
|
export const signUpSchema = z
|
|
@@ -14,9 +14,29 @@ interface LoginResponse {
|
|
|
14
14
|
refreshToken: string
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Build a minimal JWT (not server-verified) for the demo account */
|
|
18
|
+
function makeDemoToken(): string {
|
|
19
|
+
const b64 = (obj: object) =>
|
|
20
|
+
btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
|
21
|
+
const header = b64({ alg: 'HS256', typ: 'JWT' })
|
|
22
|
+
const payload = b64({ sub: 'demo', roles: ['admin'], permissions: ['*'], exp: 9999999999 })
|
|
23
|
+
return `${header}.${payload}.demo`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEMO_RESPONSE: LoginResponse = {
|
|
27
|
+
user: { id: 'demo', email: 'demo', name: 'Demo User', roles: ['admin'], permissions: ['*'] },
|
|
28
|
+
accessToken: makeDemoToken(),
|
|
29
|
+
refreshToken: 'demo-refresh',
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
export const authApi = {
|
|
18
|
-
login: (data: SignInValues) =>
|
|
19
|
-
|
|
33
|
+
login: (data: SignInValues): Promise<LoginResponse> => {
|
|
34
|
+
// Demo account — works without a real backend
|
|
35
|
+
if (data.email === 'demo' && data.password === 'demo') {
|
|
36
|
+
return Promise.resolve(DEMO_RESPONSE)
|
|
37
|
+
}
|
|
38
|
+
return apiClient.post<LoginResponse>('/auth/login', data).then((r) => r.data)
|
|
39
|
+
},
|
|
20
40
|
|
|
21
41
|
logout: () => apiClient.post('/auth/logout'),
|
|
22
42
|
|
|
@@ -1,241 +1,305 @@
|
|
|
1
1
|
# {{PROJECT_NAME}}
|
|
2
2
|
|
|
3
|
-
> Vite
|
|
4
|
-
|
|
5
|
-
Lightning-fast Vite 7 application with TanStack Router, i18n, dark mode, authentication, and Nginx deployment ready.
|
|
3
|
+
> Vite 8 SPA portal — scaffolded by [doo-boilerplate](https://www.npmjs.com/package/doo-boilerplate)
|
|
6
4
|
|
|
7
5
|
## Quick Start
|
|
8
6
|
|
|
9
7
|
```bash
|
|
10
|
-
|
|
8
|
+
cp .env.example .env # configure API URLs
|
|
9
|
+
pnpm dev # http://localhost:5173
|
|
11
10
|
```
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
---
|
|
14
13
|
|
|
15
|
-
##
|
|
14
|
+
## Scripts
|
|
16
15
|
|
|
17
16
|
| Command | Purpose |
|
|
18
17
|
|---------|---------|
|
|
19
|
-
| `pnpm dev` |
|
|
20
|
-
| `pnpm build` |
|
|
21
|
-
| `pnpm preview` | Preview production build
|
|
22
|
-
| `pnpm lint` |
|
|
23
|
-
| `pnpm
|
|
24
|
-
| `pnpm
|
|
25
|
-
| `pnpm
|
|
26
|
-
| `pnpm
|
|
27
|
-
| `pnpm
|
|
28
|
-
| `pnpm
|
|
29
|
-
| `pnpm
|
|
30
|
-
| `pnpm docker:build` | Build & scan Docker image |
|
|
31
|
-
| `pnpm docker:scan` | Security scan image with Trivy |
|
|
32
|
-
|
|
33
|
-
## Tech Stack
|
|
34
|
-
|
|
35
|
-
- **Bundler:** Vite 7 with SWC (lightning-fast)
|
|
36
|
-
- **Routing:** TanStack Router 1 (file-based)
|
|
37
|
-
- **Styling:** Tailwind CSS 4 + Shadcn/ui
|
|
38
|
-
- **i18n:** i18next with auto-detection
|
|
39
|
-
- **State:** Zustand 5 (global) + TanStack Query 5 (server)
|
|
40
|
-
- **Forms:** React Hook Form + Zod
|
|
41
|
-
- **HTTP:** Axios with JWT interceptors
|
|
42
|
-
- **Auth:** JWT store with refresh token persistence
|
|
43
|
-
- **DevOps:** Docker (multi-stage) + Nginx + Trivy
|
|
18
|
+
| `pnpm dev` | Dev server with HMR |
|
|
19
|
+
| `pnpm build` | Production build |
|
|
20
|
+
| `pnpm preview` | Preview production build |
|
|
21
|
+
| `pnpm lint` / `lint:fix` | ESLint |
|
|
22
|
+
| `pnpm type-check` | TypeScript |
|
|
23
|
+
| `pnpm format` | Prettier |
|
|
24
|
+
| `pnpm knip` | Find unused code |
|
|
25
|
+
| `pnpm gen:api` | Generate types from `docs/swagger/api.json` |
|
|
26
|
+
| `pnpm gen:api:watch` | Watch mode |
|
|
27
|
+
| `pnpm docker:build` | Build image + Trivy scan |
|
|
28
|
+
| `pnpm docker:scan` | Scan existing image |
|
|
44
29
|
|
|
45
|
-
|
|
30
|
+
---
|
|
46
31
|
|
|
47
|
-
|
|
32
|
+
## Folder Structure
|
|
48
33
|
|
|
49
|
-
```
|
|
50
|
-
|
|
34
|
+
```
|
|
35
|
+
src/
|
|
36
|
+
├── routes/ # TanStack Router (file-based, type-safe)
|
|
37
|
+
│ ├── __root.tsx # Root layout: providers, loading bar
|
|
38
|
+
│ ├── _authenticated.tsx # Auth guard: redirects to /sign-in if no token
|
|
39
|
+
│ ├── _authenticated/
|
|
40
|
+
│ │ ├── dashboard.tsx # /dashboard
|
|
41
|
+
│ │ ├── profile.tsx # /profile
|
|
42
|
+
│ │ └── settings.tsx # /settings
|
|
43
|
+
│ ├── (auth)/
|
|
44
|
+
│ │ └── sign-in.tsx # /sign-in (public)
|
|
45
|
+
│ └── (errors)/
|
|
46
|
+
│ └── 404.tsx # 404 page
|
|
47
|
+
│
|
|
48
|
+
├── routeTree.gen.ts # Auto-generated — NEVER edit manually
|
|
49
|
+
│
|
|
50
|
+
├── main.tsx # Entry: i18n init → <RouterProvider>
|
|
51
|
+
│
|
|
52
|
+
├── components/
|
|
53
|
+
│ ├── ui/ # Shadcn/ui primitives — DO NOT EDIT
|
|
54
|
+
│ │ └── button, card, dialog, form, input, select, ...
|
|
55
|
+
│ ├── common/ # Shared app-wide components
|
|
56
|
+
│ │ ├── error-boundary.tsx
|
|
57
|
+
│ │ ├── loading-spinner.tsx
|
|
58
|
+
│ │ └── theme-toggle.tsx # Dark/light/system toggle
|
|
59
|
+
│ └── layout/ # Page chrome
|
|
60
|
+
│ ├── sidebar.tsx # Collapsible nav sidebar
|
|
61
|
+
│ ├── header.tsx # Top bar with user menu
|
|
62
|
+
│ └── page-layout.tsx # Wrapper: sidebar + main content
|
|
63
|
+
│
|
|
64
|
+
├── context/
|
|
65
|
+
│ └── theme-provider.tsx # next-themes wrapper for Vite
|
|
66
|
+
│
|
|
67
|
+
├── features/ # Domain modules (feature-first)
|
|
68
|
+
│ └── auth/
|
|
69
|
+
│ ├── components/
|
|
70
|
+
│ │ └── sign-in-form.tsx
|
|
71
|
+
│ ├── hooks/
|
|
72
|
+
│ │ └── use-auth.ts # useSignIn, useSignOut, useCurrentUser
|
|
73
|
+
│ ├── schemas/
|
|
74
|
+
│ │ └── auth-schema.ts # Zod: LoginSchema
|
|
75
|
+
│ └── services/
|
|
76
|
+
│ ├── auth-api.ts # API calls: login, logout, me
|
|
77
|
+
│ └── gen/ # Auto-generated types (pnpm gen:api)
|
|
78
|
+
│
|
|
79
|
+
├── hooks/ # Shared custom hooks
|
|
80
|
+
│ └── use-media-query.ts
|
|
81
|
+
│
|
|
82
|
+
├── lib/ # Utilities & singletons
|
|
83
|
+
│ ├── api-client.ts # Axios instance (auto Bearer token + 401 handler)
|
|
84
|
+
│ ├── i18n.ts # i18next init with EN/VI resources
|
|
85
|
+
│ ├── query-client.ts # TanStack Query client config
|
|
86
|
+
│ └── utils.ts # cn() = clsx + tailwind-merge
|
|
87
|
+
│
|
|
88
|
+
├── stores/
|
|
89
|
+
│ └── auth-store.ts # Zustand auth (persisted to localStorage)
|
|
90
|
+
│
|
|
91
|
+
├── styles/
|
|
92
|
+
│ └── globals.css # @import tailwindcss + CSS variables
|
|
93
|
+
│
|
|
94
|
+
└── types/
|
|
95
|
+
└── index.ts # Shared TS types
|
|
51
96
|
```
|
|
52
97
|
|
|
53
|
-
|
|
98
|
+
---
|
|
54
99
|
|
|
55
|
-
|
|
56
|
-
# API Configuration
|
|
57
|
-
VITE_API_URL=http://localhost:8000
|
|
58
|
-
VITE_API_AUTH_URL=http://localhost:8001
|
|
100
|
+
## Design Patterns
|
|
59
101
|
|
|
60
|
-
|
|
61
|
-
VITE_APP_URL=http://localhost:5173
|
|
62
|
-
VITE_APP_NAME={{PROJECT_NAME}}
|
|
63
|
-
```
|
|
102
|
+
### File-Based Routing (TanStack Router)
|
|
64
103
|
|
|
65
|
-
|
|
104
|
+
Routes are defined by files in `src/routes/`. The router plugin auto-generates `routeTree.gen.ts` on save.
|
|
66
105
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
106
|
+
| File naming | Result |
|
|
107
|
+
|-------------|--------|
|
|
108
|
+
| `_authenticated.tsx` | Layout route — all nested routes require auth |
|
|
109
|
+
| `_authenticated/dashboard.tsx` | `/dashboard` (protected) |
|
|
110
|
+
| `(auth)/sign-in.tsx` | `/sign-in` (parentheses = pathless group) |
|
|
111
|
+
| `__root.tsx` | Root layout (wraps everything) |
|
|
70
112
|
|
|
71
|
-
|
|
113
|
+
**Add a new protected page:**
|
|
72
114
|
|
|
115
|
+
```bash
|
|
116
|
+
touch src/routes/_authenticated/reports.tsx
|
|
73
117
|
```
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
├── components/
|
|
87
|
-
│ ├── ui/ # Shadcn/ui components
|
|
88
|
-
│ └── **/ # Feature-specific components
|
|
89
|
-
├── features/
|
|
90
|
-
│ ├── auth/
|
|
91
|
-
│ │ ├── hooks/
|
|
92
|
-
│ │ ├── stores/ # Zustand auth store
|
|
93
|
-
│ │ ├── services/
|
|
94
|
-
│ │ │ └── gen/ # Generated API types (gen:api)
|
|
95
|
-
│ │ └── types/
|
|
96
|
-
│ └── **/
|
|
97
|
-
├── hooks/ # Custom React hooks
|
|
98
|
-
├── context/ # React context providers
|
|
99
|
-
├── lib/
|
|
100
|
-
│ ├── cn.ts # Tailwind merge utility
|
|
101
|
-
│ ├── http.ts # Axios instance
|
|
102
|
-
│ └── **/
|
|
103
|
-
├── stores/ # Zustand stores
|
|
104
|
-
├── styles/
|
|
105
|
-
│ ├── globals.css
|
|
106
|
-
│ └── variables.css # CSS variables
|
|
107
|
-
└── types/
|
|
108
|
-
└── index.ts
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// src/routes/_authenticated/reports.tsx
|
|
121
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
122
|
+
|
|
123
|
+
export const Route = createFileRoute('/_authenticated/reports')({
|
|
124
|
+
component: ReportsPage,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
function ReportsPage() {
|
|
128
|
+
return <PageLayout title="Reports">...</PageLayout>
|
|
129
|
+
}
|
|
109
130
|
```
|
|
110
131
|
|
|
111
|
-
|
|
132
|
+
The route is auto-discovered and added to the type-safe route tree.
|
|
112
133
|
|
|
113
|
-
###
|
|
134
|
+
### Feature-First Modules
|
|
114
135
|
|
|
115
|
-
|
|
136
|
+
Add new domain logic as a self-contained feature:
|
|
116
137
|
|
|
117
138
|
```
|
|
118
|
-
|
|
119
|
-
├──
|
|
120
|
-
├──
|
|
121
|
-
├──
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
└── dashboard.index.tsx → /dashboard
|
|
139
|
+
features/reports/
|
|
140
|
+
├── components/report-table.tsx
|
|
141
|
+
├── hooks/use-reports.ts ← TanStack Query hooks
|
|
142
|
+
├── schemas/report-schema.ts ← Zod validation
|
|
143
|
+
└── services/
|
|
144
|
+
├── reports-api.ts ← API calls
|
|
145
|
+
└── gen/ ← Generated types (optional)
|
|
126
146
|
```
|
|
127
147
|
|
|
128
|
-
|
|
148
|
+
### State Layers
|
|
129
149
|
|
|
130
|
-
|
|
150
|
+
| State type | Tool | Location |
|
|
151
|
+
|-----------|------|----------|
|
|
152
|
+
| Server / async data | TanStack Query | `features/{x}/hooks/` |
|
|
153
|
+
| Global client state | Zustand | `src/stores/` |
|
|
154
|
+
| Form state | React Hook Form | component-local |
|
|
155
|
+
| URL / search params | TanStack Router | `Route.useSearch()` |
|
|
131
156
|
|
|
132
|
-
|
|
157
|
+
**Rule:** Never put fetched data in Zustand. Only truly global client state (auth, UI prefs) goes in stores.
|
|
133
158
|
|
|
134
|
-
###
|
|
159
|
+
### Auth Store
|
|
135
160
|
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
import { createFileRoute } from '@tanstack/react-router'
|
|
161
|
+
```ts
|
|
162
|
+
import { useAuthStore } from '@/stores/auth-store'
|
|
139
163
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
})
|
|
164
|
+
// Read
|
|
165
|
+
const { user, isAuthenticated } = useAuthStore()
|
|
143
166
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
167
|
+
// RBAC
|
|
168
|
+
const isAdmin = useAuthStore(s => s.hasRole('admin'))
|
|
169
|
+
const canPublish = useAuthStore(s => s.hasPermission('publish:articles'))
|
|
170
|
+
|
|
171
|
+
// Mutations
|
|
172
|
+
const { login, logout } = useAuthStore()
|
|
147
173
|
```
|
|
148
174
|
|
|
149
|
-
|
|
175
|
+
Persisted fields: `user`, `accessToken`, `refreshToken`, `isAuthenticated`.
|
|
150
176
|
|
|
151
|
-
|
|
152
|
-
import { Link } from '@tanstack/react-router'
|
|
177
|
+
### Auth Guard (Route Protection)
|
|
153
178
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
179
|
+
```ts
|
|
180
|
+
// routes/_authenticated.tsx
|
|
181
|
+
export const Route = createFileRoute('/_authenticated')({
|
|
182
|
+
beforeLoad: () => {
|
|
183
|
+
const { isAuthenticated } = useAuthStore.getState()
|
|
184
|
+
if (!isAuthenticated) throw redirect({ to: '/sign-in' })
|
|
185
|
+
},
|
|
186
|
+
})
|
|
162
187
|
```
|
|
163
188
|
|
|
164
|
-
|
|
189
|
+
All routes nested under `_authenticated/` inherit this guard automatically.
|
|
165
190
|
|
|
166
|
-
|
|
191
|
+
### API Client
|
|
167
192
|
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
curl https://api.example.com/swagger.json > docs/swagger/api.json
|
|
193
|
+
```ts
|
|
194
|
+
import apiClient from '@/lib/api-client'
|
|
171
195
|
|
|
172
|
-
|
|
173
|
-
|
|
196
|
+
// features/reports/services/reports-api.ts
|
|
197
|
+
export const reportsApi = {
|
|
198
|
+
list: (params?: FilterParams) =>
|
|
199
|
+
apiClient.get<Report[]>('/reports', { params }).then(r => r.data),
|
|
200
|
+
create: (data: CreateReportDto) =>
|
|
201
|
+
apiClient.post<Report>('/reports', data).then(r => r.data),
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Auto-injects `Authorization: Bearer <token>` and redirects to `/sign-in` on 401.
|
|
206
|
+
|
|
207
|
+
### TanStack Query Hooks
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
// features/reports/hooks/use-reports.ts
|
|
211
|
+
export function useReports(params?: FilterParams) {
|
|
212
|
+
return useQuery({
|
|
213
|
+
queryKey: ['reports', params],
|
|
214
|
+
queryFn: () => reportsApi.list(params),
|
|
215
|
+
staleTime: 5 * 60_000,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
174
218
|
|
|
175
|
-
|
|
176
|
-
|
|
219
|
+
export function useCreateReport() {
|
|
220
|
+
const qc = useQueryClient()
|
|
221
|
+
return useMutation({
|
|
222
|
+
mutationFn: reportsApi.create,
|
|
223
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ['reports'] }),
|
|
224
|
+
})
|
|
225
|
+
}
|
|
177
226
|
```
|
|
178
227
|
|
|
179
|
-
|
|
228
|
+
### Form Pattern
|
|
180
229
|
|
|
181
|
-
|
|
230
|
+
```ts
|
|
231
|
+
const schema = z.object({ title: z.string().min(3) })
|
|
232
|
+
type Values = z.infer<typeof schema>
|
|
182
233
|
|
|
183
|
-
|
|
234
|
+
function CreateReportForm() {
|
|
235
|
+
const form = useForm<Values>({ resolver: zodResolver(schema) })
|
|
236
|
+
const { mutate, isPending } = useCreateReport()
|
|
184
237
|
|
|
185
|
-
|
|
186
|
-
|
|
238
|
+
return (
|
|
239
|
+
<Form {...form}>
|
|
240
|
+
<form onSubmit={form.handleSubmit(data => mutate(data))}>
|
|
241
|
+
<FormField control={form.control} name="title" render={...} />
|
|
242
|
+
<Button disabled={isPending}>Create</Button>
|
|
243
|
+
</form>
|
|
244
|
+
</Form>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
187
247
|
```
|
|
188
248
|
|
|
189
|
-
|
|
249
|
+
### Internationalization
|
|
190
250
|
|
|
191
|
-
|
|
251
|
+
```ts
|
|
252
|
+
import { useTranslation } from 'react-i18next'
|
|
192
253
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
254
|
+
function Dashboard() {
|
|
255
|
+
const { t, i18n } = useTranslation('dashboard')
|
|
256
|
+
return <h1>{t('welcome')}</h1>
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Switch language
|
|
260
|
+
i18n.changeLanguage('vi')
|
|
196
261
|
```
|
|
197
262
|
|
|
198
|
-
|
|
263
|
+
Translation resources are in `src/lib/i18n.ts`. Add new namespaces or languages there.
|
|
199
264
|
|
|
200
|
-
|
|
265
|
+
---
|
|
201
266
|
|
|
202
|
-
|
|
267
|
+
## API Type Generation
|
|
203
268
|
|
|
204
269
|
```bash
|
|
205
|
-
|
|
206
|
-
|
|
270
|
+
# 1. Place your backend OpenAPI spec
|
|
271
|
+
curl https://api.example.com/swagger.json > docs/swagger/api.json
|
|
207
272
|
|
|
208
|
-
|
|
209
|
-
|
|
273
|
+
# 2. Generate TypeScript types
|
|
274
|
+
pnpm gen:api
|
|
275
|
+
|
|
276
|
+
# 3. Import generated types
|
|
277
|
+
import type { LoginRequest } from '@/features/auth/services/gen'
|
|
210
278
|
```
|
|
211
279
|
|
|
212
|
-
|
|
280
|
+
---
|
|
213
281
|
|
|
214
|
-
|
|
282
|
+
## Environment Variables
|
|
283
|
+
|
|
284
|
+
```env
|
|
285
|
+
VITE_API_URL=http://localhost:8000
|
|
286
|
+
VITE_API_AUTH_URL=http://localhost:8001
|
|
287
|
+
VITE_APP_NAME={{PROJECT_NAME}}
|
|
288
|
+
```
|
|
215
289
|
|
|
216
|
-
|
|
290
|
+
Access in code: `import.meta.env.VITE_API_URL`
|
|
217
291
|
|
|
218
|
-
|
|
292
|
+
---
|
|
219
293
|
|
|
220
|
-
|
|
221
|
-
import { defineConfig } from 'vite'
|
|
222
|
-
import react from '@vitejs/plugin-react-swc'
|
|
223
|
-
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
|
294
|
+
## Docker
|
|
224
295
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
react(),
|
|
229
|
-
],
|
|
230
|
-
server: {
|
|
231
|
-
port: 5173,
|
|
232
|
-
},
|
|
233
|
-
build: {
|
|
234
|
-
target: 'ES2020',
|
|
235
|
-
},
|
|
236
|
-
})
|
|
296
|
+
```bash
|
|
297
|
+
pnpm docker:build # Build + Trivy scan
|
|
298
|
+
docker-compose up # Local dev stack on :3000
|
|
237
299
|
```
|
|
238
300
|
|
|
301
|
+
Vite builds a static bundle served by Nginx. The `nginx.conf` uses `try_files $uri /index.html` for SPA routing.
|
|
302
|
+
|
|
239
303
|
---
|
|
240
304
|
|
|
241
|
-
**
|
|
305
|
+
**Framework:** Vite 8 + React 19 · **Routing:** TanStack Router (file-based) · **Styling:** Tailwind CSS 4 + Shadcn/ui · **i18n:** i18next (EN/VI) · **State:** Zustand 5 + TanStack Query 5 · **Node:** ≥20.19.0
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"react-i18next": "^15.5.2",
|
|
26
26
|
"i18next-browser-languagedetector": "^8.0.5",
|
|
27
27
|
"next-themes": "^0.4.6",
|
|
28
|
-
"@tanstack/react-router": "^1.
|
|
28
|
+
"@tanstack/react-router": "^1.166.12",
|
|
29
29
|
"@tanstack/react-query": "^5.90.2",
|
|
30
30
|
"@tanstack/react-table": "^8.21.3",
|
|
31
31
|
"zustand": "^5.0.8",
|
|
@@ -63,13 +63,13 @@
|
|
|
63
63
|
"@types/react-dom": "^19",
|
|
64
64
|
"@types/node": "^22",
|
|
65
65
|
"typescript": "^5.9.3",
|
|
66
|
-
"@vitejs/plugin-react-swc": "^4.
|
|
67
|
-
"vite": "^
|
|
68
|
-
"@tailwindcss/vite": "^4.1
|
|
69
|
-
"tailwindcss": "^4.1
|
|
70
|
-
"@tanstack/router-plugin": "^1.
|
|
66
|
+
"@vitejs/plugin-react-swc": "^4.3.0",
|
|
67
|
+
"vite": "^8.0.0",
|
|
68
|
+
"@tailwindcss/vite": "^4.2.1",
|
|
69
|
+
"tailwindcss": "^4.2.1",
|
|
70
|
+
"@tanstack/router-plugin": "^1.166.12",
|
|
71
71
|
"@tanstack/react-query-devtools": "^5.90.2",
|
|
72
|
-
"@tanstack/router-devtools": "^1.
|
|
72
|
+
"@tanstack/router-devtools": "^1.166.12",
|
|
73
73
|
"eslint": "^9",
|
|
74
74
|
"eslint-plugin-react-hooks": "^5.2.0",
|
|
75
75
|
"eslint-plugin-unused-imports": "^4.1.4",
|
|
@@ -87,5 +87,8 @@
|
|
|
87
87
|
},
|
|
88
88
|
"lint-staged": {
|
|
89
89
|
"*.{js,ts,tsx,css}": ["eslint --fix", "prettier --write"]
|
|
90
|
+
},
|
|
91
|
+
"engines": {
|
|
92
|
+
"node": ">=20.19.0"
|
|
90
93
|
}
|
|
91
94
|
}
|
|
@@ -25,7 +25,7 @@ export function SignInForm() {
|
|
|
25
25
|
<div className='text-center'>
|
|
26
26
|
<h1 className='text-2xl font-bold tracking-tight'>Sign in</h1>
|
|
27
27
|
<p className='mt-1 text-sm text-muted-foreground'>
|
|
28
|
-
Enter your credentials to access your account
|
|
28
|
+
Enter your credentials to access your account · or try <strong>demo / demo</strong>
|
|
29
29
|
</p>
|
|
30
30
|
</div>
|
|
31
31
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
3
|
export const signInSchema = z.object({
|
|
4
|
-
email: z.string().email('Please enter a valid email address'),
|
|
4
|
+
email: z.union([z.string().email('Please enter a valid email address'), z.literal('demo')]),
|
|
5
5
|
password: z.string().min(1, 'Password is required'),
|
|
6
6
|
})
|
|
7
7
|
|
|
@@ -14,9 +14,29 @@ interface LoginResponse {
|
|
|
14
14
|
refreshToken: string
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Build a minimal JWT (not server-verified) for the demo account */
|
|
18
|
+
function makeDemoToken(): string {
|
|
19
|
+
const b64 = (obj: object) =>
|
|
20
|
+
btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
|
21
|
+
const header = b64({ alg: 'HS256', typ: 'JWT' })
|
|
22
|
+
const payload = b64({ sub: 'demo', roles: ['admin'], permissions: ['*'], exp: 9999999999 })
|
|
23
|
+
return `${header}.${payload}.demo`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEMO_RESPONSE: LoginResponse = {
|
|
27
|
+
user: { id: 'demo', email: 'demo', name: 'Demo User', roles: ['admin'], permissions: ['*'] },
|
|
28
|
+
accessToken: makeDemoToken(),
|
|
29
|
+
refreshToken: 'demo-refresh',
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
export const authApi = {
|
|
18
|
-
login: (data: SignInValues) =>
|
|
19
|
-
|
|
33
|
+
login: (data: SignInValues): Promise<LoginResponse> => {
|
|
34
|
+
// Demo account — works without a real backend
|
|
35
|
+
if (data.email === 'demo' && data.password === 'demo') {
|
|
36
|
+
return Promise.resolve(DEMO_RESPONSE)
|
|
37
|
+
}
|
|
38
|
+
return apiClient.post<LoginResponse>('/auth/login', data).then((r) => r.data)
|
|
39
|
+
},
|
|
20
40
|
|
|
21
41
|
logout: () => apiClient.post('/auth/logout'),
|
|
22
42
|
|