i18n-dashboard 0.12.1 → 0.13.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/README.md +47 -730
- package/bin/cli.mjs +13 -9
- package/nuxt.config.ts +34 -2
- package/package.json +1 -1
- package/src/server/api/db-config.post.ts +4 -1
- package/src/server/api/fs/browse.get.ts +15 -1
- package/src/server/api/scan.post.ts +14 -4
- package/src/server/api/sync.post.ts +12 -4
- package/src/server/api/translations/bulk-status.post.ts +11 -3
- package/src/server/api/translations/status.post.ts +8 -1
- package/src/server/api/users/index.post.ts +7 -2
- package/src/server/consts/commons.const.ts +10 -0
- package/src/server/db/index.ts +13 -0
- package/src/server/middleware/auth.ts +18 -2
- package/src/server/routes/locale/[lang].get.ts +6 -3
package/README.md
CHANGED
|
@@ -1,779 +1,96 @@
|
|
|
1
|
-
# i18n
|
|
1
|
+
# Vue i18n Dashboard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Stop editing JSON files manually.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Manage your Vue i18n translations in a real-time dashboard — with two possible workflows:
|
|
6
|
+
- Local (per project)
|
|
7
|
+
- Server (multi-project platform)
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
##
|
|
12
|
-
|
|
13
|
-
### Core
|
|
14
|
-
- **Multi-project** — manage multiple Vue.js projects from one dashboard, each with its own languages, keys, and settings
|
|
15
|
-
- **Translation editor** — inline editing per language, keyboard shortcuts (`Ctrl+Enter` to save, `Esc` to cancel)
|
|
16
|
-
- **Plural forms** — dedicated plural editor supporting 2-form (EN/DE), 3-form (FR/ES), 4-form (Slavic), or custom rules
|
|
17
|
-
- **Key linking** — insert `@:key` linked references with optional modifiers (`.lower`, `.upper`, `.capitalize`) via a searchable modal picker
|
|
18
|
-
- **Escape helpers** — quick-insert toolbar for vue-i18n special characters (`{'@'}`, `{'{'}`, `\\{`, etc.)
|
|
19
|
-
- **Translation history** — every change (manual, sync, auto-translate) is tracked; restore any previous version
|
|
20
|
-
- **Inline auto-translate** — Google Translate per key (free tier, no API key required)
|
|
21
|
-
- **Batch auto-translate** — translate all missing keys for an entire language at once
|
|
22
|
-
- **Review workflow** — `Draft → Reviewed → Approved` status pipeline with role-based access control
|
|
23
|
-
- **Dashboard widgets** — customizable overview: coverage, total keys, unused keys, recent activity, per-language progress
|
|
24
|
-
|
|
25
|
-
### Languages
|
|
26
|
-
- **BCP 47 support** — full regional codes: `fr-CA`, `en-GB`, `pt-BR`, `zh-CN`, `sr-Latn`, etc. (170+ locales)
|
|
27
|
-
- **Fallback chains** — configure explicit fallbacks per language (`fr-CA → fr → en`); automatic BCP 47 parent resolution
|
|
28
|
-
- **Auto-detect** — scan or sync automatically detects locale files and creates the corresponding languages
|
|
29
|
-
- **Default language** — designate one language as the source for auto-translate
|
|
30
|
-
|
|
31
|
-
### Scan & Sync
|
|
32
|
-
- **Source scan (local)** — browse your file system with the built-in folder picker, detect all `$t()`, `t()`, `<i18n-t>`, `v-t`, and `<i18n>` block usages across `.vue`, `.ts`, `.js` files
|
|
33
|
-
- **Source scan (git)** — provide a git repository URL (+ optional branch and access token); the dashboard clones the repo, scans source files, and imports locale files in one step
|
|
34
|
-
- **Sync (local)** — import existing `.json` locale files from a local path into the database
|
|
35
|
-
- **Sync (git)** — clone the configured git repository and import locale JSON files; only fills missing or empty translations — never overwrites existing values
|
|
36
|
-
- **Unused key detection** — keys not found in source files are automatically flagged
|
|
37
|
-
- **Conflict-safe import** — sync and scan never overwrite a translation that already has a value in the database
|
|
38
|
-
|
|
39
|
-
### Advanced Formats
|
|
40
|
-
*(enable per project in Settings)*
|
|
41
|
-
- **Number formats** — configure `$n(value, 'currency')` presets using `Intl.NumberFormat` with live preview
|
|
42
|
-
- **Datetime formats** — configure `$d(date, 'short')` presets using `Intl.DateTimeFormat` with live preview
|
|
43
|
-
- **Custom modifiers** — define `@.modifier:key` transform functions with a built-in test runner
|
|
44
|
-
- **Snippet generator** — generates a ready-to-paste `createI18n()` configuration block
|
|
45
|
-
|
|
46
|
-
### Projects
|
|
47
|
-
- **3-step creation wizard** — Source → Info → Languages: add a local path or git repo, auto-detect project name, locales folder and existing languages, then pick languages with the built-in BCP 47 picker
|
|
48
|
-
- **Auto-detect on creation** — reads `package.json` for the project name and scans the locale folder to pre-fill languages; works with both local paths and git repos
|
|
49
|
-
- **Auto-scan on creation** — a scan is automatically triggered after project creation so keys are immediately available
|
|
50
|
-
- **Git repository per project** — configure a git URL, branch, and optional access token; used for scan and sync operations without requiring a local clone
|
|
51
|
-
- **Inline settings** — edit project name, root path, git repo, locales folder, key separator, color, and description directly from the project settings page
|
|
52
|
-
- **Folder browser** — navigate your file system visually to pick the project root path
|
|
53
|
-
- **Project snapshot** — export a complete backup (config + languages + all keys + translations) as a single JSON file, import it on any other instance (merge or replace mode)
|
|
54
|
-
|
|
55
|
-
### Users & Authentication
|
|
56
|
-
- **Role-based access** — `Super Admin`, `Admin`, `Moderator`, `Translator` — per-project assignments
|
|
57
|
-
- **User search when adding to a project** — search existing users by name or email, select one and choose their role directly; or switch to the creation form for a brand new account
|
|
58
|
-
- **User activity profile** — view translation statistics per user with a configurable time period (last 24h, 7d, 30d, 1 year, or since account creation)
|
|
59
|
-
- **Onboarding wizard** — guided setup on first launch
|
|
60
|
-
- **Multi-language UI** — the dashboard interface itself is translatable
|
|
61
|
-
|
|
62
|
-
### Technical
|
|
63
|
-
- **Multi-database** — SQLite (default, zero config), PostgreSQL, MySQL/MariaDB
|
|
64
|
-
- **Auto-migration** — schema is created and updated automatically on startup
|
|
65
|
-
- **REST API** — full API for all operations, consume locale JSON from your Vue app
|
|
66
|
-
- **CORS auto-detection** — multiple app URLs per project; all are checked for CORS on `/locale/[lang].json`
|
|
67
|
-
- **Global loading overlay** — a full-page loading screen prevents interaction before data is ready, including on direct page load (F5) for any route
|
|
68
|
-
- **Dark mode** — system preference + manual toggle
|
|
69
|
-
- **Cypress E2E test suite** — full test coverage across all pages (auth, dashboard, projects, translations, languages, users, review, settings); CI-ready with GitHub Actions
|
|
70
|
-
- **GitHub Actions CI** — E2E tests run automatically on every push to `develop` and `main`; Cypress screenshots uploaded as artifacts on failure
|
|
71
|
-
- **Vitest unit test suite** — 344 unit tests covering all composables, services, and server utilities; runs in under 2 minutes with zero infrastructure required
|
|
72
|
-
- **Dual CI pipelines** — unit tests (`unit.yml`) and E2E tests (`e2e.yml`) run in parallel on every push; any regression blocks the pipeline
|
|
73
|
-
- **`src/` layout** — all source files live under `src/` (components, composables, pages, server, services, etc.) for a clean project root
|
|
74
|
-
---
|
|
11
|
+
## 🚨 The Problem
|
|
75
12
|
|
|
76
|
-
|
|
13
|
+
Working with vue-i18n can be painful:
|
|
77
14
|
|
|
78
|
-
-
|
|
79
|
-
-
|
|
15
|
+
- Editing JSON files manually
|
|
16
|
+
- No real-time feedback
|
|
17
|
+
- Difficult collaboration
|
|
18
|
+
- No centralized management across projects
|
|
80
19
|
|
|
81
20
|
---
|
|
82
21
|
|
|
83
|
-
##
|
|
22
|
+
## 💥 The Solution
|
|
84
23
|
|
|
85
|
-
|
|
24
|
+
Vue i18n Dashboard gives you:
|
|
86
25
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
```
|
|
26
|
+
- A local tool for your project
|
|
27
|
+
- OR a full translation management platform
|
|
90
28
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
```bash
|
|
94
|
-
npm install -g i18n-dashboard
|
|
95
|
-
```
|
|
29
|
+
No migration. No vendor lock-in. Works with vue-i18n.
|
|
96
30
|
|
|
97
31
|
---
|
|
98
32
|
|
|
99
|
-
## Quick Start
|
|
100
|
-
|
|
101
|
-
### 1 — Initialize
|
|
102
|
-
|
|
103
|
-
Run the interactive setup wizard from your project root:
|
|
33
|
+
## ⚡ Quick Start
|
|
104
34
|
|
|
105
35
|
```bash
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
This creates an `i18n-dashboard.config.js` file at the root of your project.
|
|
110
|
-
|
|
111
|
-
### 2 — Start
|
|
36
|
+
npm install i18n-dashboard --save-dev
|
|
37
|
+
or
|
|
38
|
+
npm install -g i18n-dashboard --save-dev
|
|
112
39
|
|
|
113
|
-
|
|
40
|
+
npx i18n-dashboard init
|
|
114
41
|
npx i18n-dashboard start
|
|
115
42
|
```
|
|
116
43
|
|
|
117
|
-
Open
|
|
118
|
-
|
|
119
|
-
The onboarding wizard will guide you through:
|
|
120
|
-
1. Creating an administrator account
|
|
121
|
-
2. Selecting the dashboard UI language
|
|
122
|
-
3. Configuring your first project
|
|
123
|
-
|
|
124
|
-
### 3 — Import existing locale files (optional)
|
|
125
|
-
|
|
126
|
-
If you already have `.json` locale files, import them into the database:
|
|
127
|
-
|
|
128
|
-
```bash
|
|
129
|
-
npx i18n-dashboard sync
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
> The dashboard must be running for this command to work.
|
|
133
|
-
|
|
134
|
-
### 4 — Use the API in your Vue app
|
|
135
|
-
|
|
136
|
-
```js
|
|
137
|
-
// src/i18n.js
|
|
138
|
-
import { createI18n } from 'vue-i18n'
|
|
139
|
-
|
|
140
|
-
export const i18n = createI18n({
|
|
141
|
-
locale: 'en',
|
|
142
|
-
fallbackLocale: 'en',
|
|
143
|
-
messages: {
|
|
144
|
-
en: await fetch('http://localhost:3333/locale/en.json').then(r => r.json()),
|
|
145
|
-
fr: await fetch('http://localhost:3333/locale/fr.json').then(r => r.json()),
|
|
146
|
-
},
|
|
147
|
-
})
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
### 5 — Add a script to your package.json
|
|
151
|
-
|
|
152
|
-
```json
|
|
153
|
-
{
|
|
154
|
-
"scripts": {
|
|
155
|
-
"dev": "vite",
|
|
156
|
-
"i18n": "i18n-dashboard start"
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
---
|
|
162
|
-
|
|
163
|
-
## Configuration
|
|
164
|
-
|
|
165
|
-
### i18n-dashboard.config.js
|
|
166
|
-
|
|
167
|
-
```js
|
|
168
|
-
// i18n-dashboard.config.js
|
|
169
|
-
export default {
|
|
170
|
-
// Port the dashboard will run on
|
|
171
|
-
port: 3333,
|
|
172
|
-
|
|
173
|
-
// Key separator for nested keys ('home.title' uses '.')
|
|
174
|
-
keySeparator: '.',
|
|
175
|
-
|
|
176
|
-
// URL path pattern for serving locale JSON files
|
|
177
|
-
apiPath: '/locale/[lang].json',
|
|
178
|
-
|
|
179
|
-
// Root path of your Vue project (absolute or relative)
|
|
180
|
-
projectRoot: './',
|
|
181
|
-
|
|
182
|
-
// Database configuration
|
|
183
|
-
database: {
|
|
184
|
-
// Options: 'better-sqlite3' (default), 'pg', 'mysql2'
|
|
185
|
-
client: 'better-sqlite3',
|
|
186
|
-
|
|
187
|
-
// SQLite: path to the .db file
|
|
188
|
-
connection: './i18n-dashboard.db',
|
|
189
|
-
|
|
190
|
-
// PostgreSQL / MySQL: use a connection object instead:
|
|
191
|
-
// connection: {
|
|
192
|
-
// host: 'localhost',
|
|
193
|
-
// port: 5432,
|
|
194
|
-
// user: 'myuser',
|
|
195
|
-
// password: 'mypassword',
|
|
196
|
-
// database: 'i18n_dashboard',
|
|
197
|
-
// },
|
|
198
|
-
},
|
|
199
|
-
|
|
200
|
-
// Google Translate API key (optional — free tier works without a key)
|
|
201
|
-
// googleTranslate: {
|
|
202
|
-
// apiKey: process.env.GOOGLE_TRANSLATE_API_KEY,
|
|
203
|
-
// },
|
|
204
|
-
}
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
### Environment variables
|
|
208
|
-
|
|
209
|
-
All options can be passed as environment variables (useful for CI/CD and Docker):
|
|
210
|
-
|
|
211
|
-
| Variable | Description | Default |
|
|
212
|
-
|---|---|---|
|
|
213
|
-
| `I18N_PORT` | Server port | `3333` |
|
|
214
|
-
| `I18N_DB_CLIENT` | DB driver (`better-sqlite3` / `pg` / `mysql2`) | `better-sqlite3` |
|
|
215
|
-
| `I18N_DB_CONNECTION` | SQLite file path | `./i18n-dashboard.db` |
|
|
216
|
-
| `I18N_DB_HOST` | PostgreSQL/MySQL host | `localhost` |
|
|
217
|
-
| `I18N_DB_PORT` | PostgreSQL/MySQL port | `5432` |
|
|
218
|
-
| `I18N_DB_USER` | Database user | — |
|
|
219
|
-
| `I18N_DB_PASSWORD` | Database password | — |
|
|
220
|
-
| `I18N_DB_NAME` | Database name | `i18n_dashboard` |
|
|
221
|
-
| `I18N_KEY_SEPARATOR` | Key separator | `.` |
|
|
222
|
-
| `I18N_API_PATH` | Locale API path pattern | `/locale/[lang].json` |
|
|
223
|
-
| `I18N_PROJECT_ROOT` | Project root path | `process.cwd()` |
|
|
224
|
-
| `I18N_LOCALES_PATH` | Locales folder (relative to root) | `src/locales` |
|
|
225
|
-
| `GOOGLE_TRANSLATE_API_KEY` | Google Translate API key | — |
|
|
226
|
-
| `SESSION_SECRET` | Session encryption secret | *(default, change in prod)* |
|
|
227
|
-
| `SMTP_HOST` | SMTP server for email | — |
|
|
228
|
-
| `DASHBOARD_URL` | Public URL of the dashboard | `http://localhost:3333` |
|
|
229
|
-
|
|
230
|
-
---
|
|
231
|
-
|
|
232
|
-
## CLI Commands
|
|
233
|
-
|
|
234
|
-
```
|
|
235
|
-
i18n-dashboard <command> [options]
|
|
236
|
-
|
|
237
|
-
Commands:
|
|
238
|
-
start Start the dashboard server
|
|
239
|
-
stop Stop a running detached dashboard
|
|
240
|
-
build Build for production
|
|
241
|
-
init Run the interactive setup wizard
|
|
242
|
-
sync Import locale JSON files into the database
|
|
243
|
-
|
|
244
|
-
Options for start:
|
|
245
|
-
-p, --port <port> Server port (default: 3333)
|
|
246
|
-
--detach Run in the background (writes PID file)
|
|
247
|
-
|
|
248
|
-
Global options:
|
|
249
|
-
-V, --version Show version
|
|
250
|
-
-h, --help Show help
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
### Background mode
|
|
254
|
-
|
|
255
|
-
```bash
|
|
256
|
-
# Start in background
|
|
257
|
-
npx i18n-dashboard start --detach
|
|
258
|
-
|
|
259
|
-
# Stop the background process
|
|
260
|
-
npx i18n-dashboard stop
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
---
|
|
264
|
-
|
|
265
|
-
## Interface
|
|
266
|
-
|
|
267
|
-
### Dashboard
|
|
268
|
-
|
|
269
|
-
Customizable widget grid. Available widgets:
|
|
270
|
-
- **Total keys** — overall key count
|
|
271
|
-
- **Coverage** — global translation coverage percentage
|
|
272
|
-
- **Languages** — number of configured languages
|
|
273
|
-
- **Unused keys** — keys not found in source code
|
|
274
|
-
- **Language coverage** — per-language progress bars
|
|
275
|
-
- **Recent activity** — latest translation edits
|
|
276
|
-
- **Review queue** — translations awaiting approval
|
|
277
|
-
- **Projects** — quick project overview
|
|
278
|
-
|
|
279
|
-
Click **Edit** to add, remove, or rearrange widgets.
|
|
280
|
-
|
|
281
|
-
### Translations
|
|
282
|
-
|
|
283
|
-
The main translation table with:
|
|
284
|
-
- Search by key name (real-time)
|
|
285
|
-
- Filter by language and status (`All`, `Draft`, `Reviewed`, `Approved`, `Missing`, `Unused`)
|
|
286
|
-
- Status badge per language
|
|
287
|
-
- Auto-translate per key (Google Translate)
|
|
288
|
-
- Batch auto-translate for an entire language
|
|
289
|
-
|
|
290
|
-
Click any key to open its **detail page**:
|
|
291
|
-
- Edit each language with the full toolbar (params, escape helpers, plural editor, linked key picker)
|
|
292
|
-
- View translation history with one-click restore
|
|
293
|
-
- See which source files reference this key (after scan)
|
|
294
|
-
|
|
295
|
-
#### Plural editor
|
|
296
|
-
|
|
297
|
-
Switch any translation to plural mode to get a template-based form:
|
|
298
|
-
|
|
299
|
-
| Template | Languages | Example |
|
|
300
|
-
|---|---|---|
|
|
301
|
-
| 2 forms — standard | English, German, Dutch… | `car \| cars` |
|
|
302
|
-
| 3 forms — zero/one/many | French, Spanish, Italian… | `no cars \| {count} car \| {count} cars` |
|
|
303
|
-
| 4 forms — Slavic | Russian, Polish, Ukrainian… | `0 машин \| {n} машина \| {n} машины \| {n} машин` |
|
|
304
|
-
| Custom | Any | Define your own forms |
|
|
305
|
-
|
|
306
|
-
The `{count}` and `{n}` parameters are always available implicitly.
|
|
307
|
-
|
|
308
|
-
### Languages
|
|
309
|
-
|
|
310
|
-
- Add languages from 170+ BCP 47 locale codes (`fr`, `fr-CA`, `en-GB`, `zh-CN`, `sr-Latn`…)
|
|
311
|
-
- Type any custom BCP 47 code if it's not in the list
|
|
312
|
-
- Set a default language (source for auto-translate)
|
|
313
|
-
- Configure fallback chains:
|
|
314
|
-
- **Automatic** — `fr-CA → fr` (BCP 47 parent)
|
|
315
|
-
- **Manual** — pick any other configured language
|
|
316
|
-
- **None** — no fallback
|
|
317
|
-
- The API resolves the fallback chain transparently and merges translations
|
|
318
|
-
|
|
319
|
-
### Scan
|
|
320
|
-
|
|
321
|
-
Click **Scan project** in the sidebar or on any project card to open the scan modal.
|
|
322
|
-
|
|
323
|
-
**Local mode** — browse your file system with the folder picker and scan `.vue`, `.ts`, `.js` files for:
|
|
324
|
-
- `$t('key')`, `$tc()`, `$te()`, `$tm()`
|
|
325
|
-
- `t('key')` via `useI18n()`
|
|
326
|
-
- `<i18n-t keypath="key">`
|
|
327
|
-
- `v-t="'key'"`
|
|
328
|
-
- `<i18n>` SFC blocks
|
|
329
|
-
|
|
330
|
-
**Git mode** — enter a git repository URL (and optionally a branch and access token); the scanner clones the repo (shallow, depth 1), scans all source files, and also imports locale JSON files to populate missing translations in one step. Useful for CI environments or when you don't have a local checkout.
|
|
331
|
-
|
|
332
|
-
> Required token permission: **Contents → Read** (GitHub fine-grained PAT) or **repo** scope (classic PAT).
|
|
333
|
-
|
|
334
|
-
Results displayed inline: keys found, new keys, unused keys, files scanned, languages added, translations imported.
|
|
335
|
-
|
|
336
|
-
### Settings
|
|
337
|
-
|
|
338
|
-
Per-project settings (editable inline):
|
|
339
|
-
- **Project name, root path, locales folder, key separator, color, description**
|
|
340
|
-
- **App URLs** — one URL per line; all are accepted for CORS on `/locale/[lang].json`, the first is used for URL-based scan/sync
|
|
341
|
-
- **Git repository** — URL, branch, and access token for Git scan mode (token stored in DB, never exposed in the UI after save)
|
|
342
|
-
- **Advanced features** — enable/disable Number formats, Datetime formats, Custom modifiers pages
|
|
343
|
-
- **Scanner** — configure excluded directories
|
|
344
|
-
- **Google Translate** — optional API key
|
|
345
|
-
- **Export** — download locale JSON files per language or all at once
|
|
346
|
-
- **Snapshot** — export/import a full project backup
|
|
347
|
-
|
|
348
|
-
### Number Formats *(requires enable in Settings)*
|
|
349
|
-
|
|
350
|
-
Configure `$n(value, 'formatName')` presets:
|
|
351
|
-
- Group by locale
|
|
352
|
-
- Style: `decimal`, `currency`, `percent`, `unit`
|
|
353
|
-
- Currency / unit selection
|
|
354
|
-
- Live `Intl.NumberFormat` preview
|
|
355
|
-
|
|
356
|
-
### Datetime Formats *(requires enable in Settings)*
|
|
357
|
-
|
|
358
|
-
Configure `$d(date, 'formatName')` presets:
|
|
359
|
-
- Shortcut styles: `dateStyle` / `timeStyle`
|
|
360
|
-
- Individual fields: `year`, `month`, `day`, `hour`, `minute`, `second`, `weekday`, `era`, `timeZone`
|
|
361
|
-
- Live `Intl.DateTimeFormat` preview
|
|
362
|
-
|
|
363
|
-
### Modifiers *(requires enable in Settings)*
|
|
364
|
-
|
|
365
|
-
Define custom `@.modifier:key` transform functions:
|
|
366
|
-
- Write JS function body
|
|
367
|
-
- Live test runner
|
|
368
|
-
- Quick templates: `snakeCase`, `camelCase`, `kebabCase`, `titleCase`
|
|
369
|
-
|
|
370
|
-
### Snippet generator
|
|
371
|
-
|
|
372
|
-
Available on all format pages — generates a ready-to-paste `createI18n()` configuration including all your number formats, datetime formats, and custom modifiers.
|
|
373
|
-
|
|
374
|
-
### Project Snapshot
|
|
375
|
-
|
|
376
|
-
Export a complete project backup:
|
|
377
|
-
|
|
378
|
-
```json
|
|
379
|
-
{
|
|
380
|
-
"version": 1,
|
|
381
|
-
"exportedAt": "2026-01-01T00:00:00.000Z",
|
|
382
|
-
"project": { "name": "My App", "locales_path": "src/locales", "key_separator": "." },
|
|
383
|
-
"languages": [{ "code": "en", "name": "English", "is_default": true }],
|
|
384
|
-
"keys": [
|
|
385
|
-
{
|
|
386
|
-
"key": "home.title",
|
|
387
|
-
"description": "Homepage title",
|
|
388
|
-
"translations": {
|
|
389
|
-
"en": { "value": "Welcome", "status": "approved" },
|
|
390
|
-
"fr": { "value": "Bienvenue", "status": "reviewed" }
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
]
|
|
394
|
-
}
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
Import modes:
|
|
398
|
-
- **Merge** — add/update keys without touching existing ones
|
|
399
|
-
- **Replace** — delete everything and reimport clean
|
|
400
|
-
|
|
401
|
-
---
|
|
402
|
-
|
|
403
|
-
## vue-i18n Integration
|
|
404
|
-
|
|
405
|
-
### Basic (load all on startup)
|
|
406
|
-
|
|
407
|
-
```js
|
|
408
|
-
// src/i18n.js
|
|
409
|
-
import { createI18n } from 'vue-i18n'
|
|
410
|
-
|
|
411
|
-
const DASHBOARD = 'http://localhost:3333'
|
|
412
|
-
|
|
413
|
-
export const i18n = createI18n({
|
|
414
|
-
locale: 'en',
|
|
415
|
-
fallbackLocale: 'en',
|
|
416
|
-
messages: {
|
|
417
|
-
en: await fetch(`${DASHBOARD}/locale/en.json`).then(r => r.json()),
|
|
418
|
-
fr: await fetch(`${DASHBOARD}/locale/fr.json`).then(r => r.json()),
|
|
419
|
-
},
|
|
420
|
-
})
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
### With lazy loading
|
|
424
|
-
|
|
425
|
-
```js
|
|
426
|
-
import { createI18n } from 'vue-i18n'
|
|
427
|
-
|
|
428
|
-
const DASHBOARD = 'http://localhost:3333'
|
|
429
|
-
|
|
430
|
-
export const i18n = createI18n({ locale: 'en', fallbackLocale: 'en', messages: {} })
|
|
431
|
-
|
|
432
|
-
export async function setLocale(locale) {
|
|
433
|
-
if (!i18n.global.availableLocales.includes(locale)) {
|
|
434
|
-
const messages = await fetch(`${DASHBOARD}/locale/${locale}.json`).then(r => r.json())
|
|
435
|
-
i18n.global.setLocaleMessage(locale, messages)
|
|
436
|
-
}
|
|
437
|
-
i18n.global.locale.value = locale
|
|
438
|
-
}
|
|
439
|
-
```
|
|
440
|
-
|
|
441
|
-
### With BCP 47 fallbacks
|
|
442
|
-
|
|
443
|
-
The API automatically resolves fallback chains. If `fr-CA` isn't fully translated, missing keys fall back to `fr`, then to the next configured fallback:
|
|
444
|
-
|
|
445
|
-
```
|
|
446
|
-
GET /locale/fr-CA.json → merges fr + fr-CA (fr-CA takes precedence)
|
|
447
|
-
X-I18n-Fallback-Chain: fr-CA → fr
|
|
448
|
-
```
|
|
449
|
-
|
|
450
|
-
No configuration needed on the client side.
|
|
451
|
-
|
|
452
|
-
### For production
|
|
453
|
-
|
|
454
|
-
Export locale files into your project and include them in your build:
|
|
455
|
-
|
|
456
|
-
```bash
|
|
457
|
-
curl http://localhost:3333/locale/en.json -o src/locales/en.json
|
|
458
|
-
curl http://localhost:3333/locale/fr.json -o src/locales/fr.json
|
|
459
|
-
```
|
|
460
|
-
|
|
461
|
-
Or deploy the dashboard on an internal server and keep using the API.
|
|
44
|
+
👉 Open http://localhost:3333
|
|
462
45
|
|
|
463
46
|
---
|
|
464
47
|
|
|
465
|
-
##
|
|
48
|
+
## 🧠 Two Ways to Use It
|
|
466
49
|
|
|
467
|
-
###
|
|
50
|
+
### 🟢 Local Mode
|
|
468
51
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
connection: './i18n-dashboard.db',
|
|
473
|
-
}
|
|
474
|
-
```
|
|
475
|
-
|
|
476
|
-
The `.db` file is created automatically. No setup needed.
|
|
52
|
+
- Run inside your project
|
|
53
|
+
- Sync keys from your code
|
|
54
|
+
- Export JSON files
|
|
477
55
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
```bash
|
|
481
|
-
npm install pg
|
|
482
|
-
```
|
|
483
|
-
|
|
484
|
-
```js
|
|
485
|
-
database: {
|
|
486
|
-
client: 'pg',
|
|
487
|
-
connection: {
|
|
488
|
-
host: 'localhost',
|
|
489
|
-
port: 5432,
|
|
490
|
-
user: 'myuser',
|
|
491
|
-
password: 'mypassword',
|
|
492
|
-
database: 'i18n_dashboard',
|
|
493
|
-
},
|
|
494
|
-
}
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
### MySQL / MariaDB
|
|
498
|
-
|
|
499
|
-
```bash
|
|
500
|
-
npm install mysql2
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
```js
|
|
504
|
-
database: {
|
|
505
|
-
client: 'mysql2',
|
|
506
|
-
connection: {
|
|
507
|
-
host: 'localhost',
|
|
508
|
-
port: 3306,
|
|
509
|
-
user: 'myuser',
|
|
510
|
-
password: 'mypassword',
|
|
511
|
-
database: 'i18n_dashboard',
|
|
512
|
-
},
|
|
513
|
-
}
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
### Schema
|
|
517
|
-
|
|
518
|
-
Tables are created and migrated automatically on startup:
|
|
519
|
-
|
|
520
|
-
```
|
|
521
|
-
projects — multi-project support
|
|
522
|
-
languages — per-project language list (BCP 47 codes, fallback_code)
|
|
523
|
-
translation_keys — key registry (key, description, is_unused, usages)
|
|
524
|
-
translations — values per key per language (value, status)
|
|
525
|
-
translation_history — full edit history (old_value, new_value, changed_by)
|
|
526
|
-
key_usages — source file references (file_path, line_number, function)
|
|
527
|
-
settings — global settings (scan_exclude, google_translate_api_key)
|
|
528
|
-
users — dashboard users (name, email, role, bcrypt password)
|
|
529
|
-
project_number_formats — Intl.NumberFormat presets
|
|
530
|
-
project_datetime_formats — Intl.DateTimeFormat presets
|
|
531
|
-
project_modifiers — custom @.modifier functions
|
|
532
|
-
```
|
|
56
|
+
👉 Best for simple setups
|
|
533
57
|
|
|
534
58
|
---
|
|
535
59
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
All endpoints require a `project_id` parameter.
|
|
539
|
-
|
|
540
|
-
### Locale export (main endpoint for vue-i18n)
|
|
541
|
-
|
|
542
|
-
```http
|
|
543
|
-
GET /locale/:lang.json?project_id=1
|
|
544
|
-
```
|
|
545
|
-
|
|
546
|
-
Returns nested JSON with all translations for the given language. Resolves fallback chains automatically.
|
|
547
|
-
|
|
548
|
-
**Response headers:**
|
|
549
|
-
- `X-I18n-Fallback-Chain: fr-CA → fr` — debug the resolved chain
|
|
550
|
-
|
|
551
|
-
### Projects
|
|
552
|
-
|
|
553
|
-
```http
|
|
554
|
-
GET /api/projects
|
|
555
|
-
POST /api/projects
|
|
556
|
-
PUT /api/projects/:id
|
|
557
|
-
DELETE /api/projects/:id
|
|
558
|
-
```
|
|
559
|
-
|
|
560
|
-
### Languages
|
|
561
|
-
|
|
562
|
-
```http
|
|
563
|
-
GET /api/languages?project_id=1
|
|
564
|
-
POST /api/languages
|
|
565
|
-
PUT /api/languages/:id
|
|
566
|
-
DELETE /api/languages/:code?project_id=1
|
|
567
|
-
```
|
|
568
|
-
|
|
569
|
-
### Translation keys
|
|
570
|
-
|
|
571
|
-
```http
|
|
572
|
-
GET /api/keys?project_id=1&search=&lang=&status=&page=1&limit=50
|
|
573
|
-
GET /api/keys/:id
|
|
574
|
-
POST /api/keys
|
|
575
|
-
PATCH /api/keys/:id
|
|
576
|
-
DELETE /api/keys/:id
|
|
577
|
-
```
|
|
578
|
-
|
|
579
|
-
### Translations
|
|
580
|
-
|
|
581
|
-
```http
|
|
582
|
-
POST /api/translations # Save a translation value
|
|
583
|
-
POST /api/translations/status # Update status only
|
|
584
|
-
POST /api/translations/batch-translate # Auto-translate all missing for a language
|
|
585
|
-
```
|
|
586
|
-
|
|
587
|
-
### Scan & Sync
|
|
588
|
-
|
|
589
|
-
```http
|
|
590
|
-
POST /api/scan # body: { project_id, mode: 'local'|'git', root_path?, git_url?, git_branch?, git_token? }
|
|
591
|
-
POST /api/sync # body: { project_id } — uses project's configured git repo or local path
|
|
592
|
-
POST /api/projects/detect # body: { root_path?, git_url?, git_branch?, git_token? }
|
|
593
|
-
GET /api/projects/check-name?name=&exclude_id= # Availability check
|
|
594
|
-
```
|
|
595
|
-
|
|
596
|
-
### Project Snapshot
|
|
597
|
-
|
|
598
|
-
```http
|
|
599
|
-
GET /api/project-snapshot?project_id=1 # Export
|
|
600
|
-
POST /api/project-snapshot # Import
|
|
601
|
-
body: { snapshot, project_id?, mode: 'merge'|'replace' }
|
|
602
|
-
```
|
|
603
|
-
|
|
604
|
-
### Advanced formats
|
|
605
|
-
|
|
606
|
-
```http
|
|
607
|
-
GET /api/formats/number?project_id=1
|
|
608
|
-
POST /api/formats/number
|
|
609
|
-
PUT /api/formats/number/:id
|
|
610
|
-
DELETE /api/formats/number/:id
|
|
611
|
-
|
|
612
|
-
GET /api/formats/datetime?project_id=1
|
|
613
|
-
POST /api/formats/datetime
|
|
614
|
-
PUT /api/formats/datetime/:id
|
|
615
|
-
DELETE /api/formats/datetime/:id
|
|
616
|
-
|
|
617
|
-
GET /api/formats/modifiers?project_id=1
|
|
618
|
-
POST /api/formats/modifiers
|
|
619
|
-
PUT /api/formats/modifiers/:id
|
|
620
|
-
DELETE /api/formats/modifiers/:id
|
|
60
|
+
### 🔵 Server Mode
|
|
621
61
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
### Users
|
|
626
|
-
|
|
627
|
-
```http
|
|
628
|
-
GET /api/users # All users (super admin / global admin)
|
|
629
|
-
GET /api/users?project_id=1 # Members of a project
|
|
630
|
-
GET /api/users?exclude_project_id=1 # Users not yet in a project (for the add picker)
|
|
631
|
-
POST /api/users # Create user
|
|
632
|
-
PUT /api/users/:id # Update user (name, is_active, role)
|
|
633
|
-
PUT /api/users/:id/roles # Bulk-update role assignments across projects
|
|
634
|
-
DELETE /api/users/:id?project_id=1 # Remove user from project (or globally if super admin)
|
|
635
|
-
GET /api/users/:id/profile?period=30d # Full user profile with activity stats
|
|
636
|
-
```
|
|
637
|
-
|
|
638
|
-
### Settings
|
|
639
|
-
|
|
640
|
-
```http
|
|
641
|
-
GET /api/settings
|
|
642
|
-
POST /api/settings
|
|
643
|
-
```
|
|
644
|
-
|
|
645
|
-
### File system browser
|
|
62
|
+
- Central dashboard for multiple projects
|
|
63
|
+
- API-based (no JSON handling)
|
|
64
|
+
- Git sync
|
|
646
65
|
|
|
647
|
-
|
|
648
|
-
GET /api/fs/browse?path=/some/path # List subdirectories; defaults to home dir
|
|
649
|
-
```
|
|
66
|
+
👉 Best for teams & scaling
|
|
650
67
|
|
|
651
68
|
---
|
|
652
69
|
|
|
653
|
-
##
|
|
70
|
+
## 🎯 Why this tool?
|
|
654
71
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
| **Translator** | Edit translations, mark as reviewed (cannot approve) |
|
|
72
|
+
- Works with vue-i18n (no migration)
|
|
73
|
+
- Self-hosted (no lock-in)
|
|
74
|
+
- Multi-project support
|
|
75
|
+
- Git-friendly workflow
|
|
76
|
+
- Developer-first experience
|
|
661
77
|
|
|
662
78
|
---
|
|
663
79
|
|
|
664
|
-
##
|
|
665
|
-
|
|
666
|
-
### New project from scratch
|
|
667
|
-
|
|
668
|
-
```bash
|
|
669
|
-
# 1. Install
|
|
670
|
-
npm install i18n-dashboard --save-dev
|
|
671
|
-
|
|
672
|
-
# 2. Initialize
|
|
673
|
-
npx i18n-dashboard init
|
|
80
|
+
## 📘 Documentation
|
|
674
81
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
# 4. In the UI:
|
|
679
|
-
# - Add languages (Languages tab)
|
|
680
|
-
# - Add translation keys (Translations tab)
|
|
681
|
-
# - Use the API in your Vue app
|
|
682
|
-
```
|
|
683
|
-
|
|
684
|
-
### Migrate from existing JSON files
|
|
685
|
-
|
|
686
|
-
```bash
|
|
687
|
-
# 1. Install and initialize
|
|
688
|
-
npm install i18n-dashboard --save-dev
|
|
689
|
-
npx i18n-dashboard init
|
|
690
|
-
|
|
691
|
-
# 2. Start
|
|
692
|
-
npx i18n-dashboard start
|
|
693
|
-
|
|
694
|
-
# 3. Sync your existing locale files
|
|
695
|
-
npx i18n-dashboard sync
|
|
696
|
-
|
|
697
|
-
# 4. All keys and translations are now in the database
|
|
698
|
-
```
|
|
699
|
-
|
|
700
|
-
### Team workflow with review
|
|
701
|
-
|
|
702
|
-
1. **Translators** edit keys → status: `Draft`
|
|
703
|
-
2. **Translators** mark ready → status: `Reviewed`
|
|
704
|
-
3. **Moderators/Admins** approve → status: `Approved`
|
|
705
|
-
4. Only `Approved` translations are exported (or all, depending on your needs)
|
|
706
|
-
|
|
707
|
-
### Move between environments (local → production)
|
|
708
|
-
|
|
709
|
-
```bash
|
|
710
|
-
# On local machine: export snapshot
|
|
711
|
-
# Dashboard UI → Settings → Snapshot → Export
|
|
712
|
-
|
|
713
|
-
# On production server: import snapshot
|
|
714
|
-
# Dashboard UI → Settings → Snapshot → Import (Merge or Replace mode)
|
|
715
|
-
```
|
|
716
|
-
|
|
717
|
-
---
|
|
718
|
-
|
|
719
|
-
## Stack
|
|
720
|
-
|
|
721
|
-
| Technology | Version | Role |
|
|
722
|
-
|---|---|---|
|
|
723
|
-
| [Nuxt 3](https://nuxt.com/) | 3.21+ | Full-stack framework (Nitro backend + Vue 3 frontend) |
|
|
724
|
-
| [Nuxt UI](https://ui.nuxt.com/) | 3.3+ | UI components (Tailwind CSS v4 + Reka UI) |
|
|
725
|
-
| [Knex.js](https://knexjs.org/) | 3.x | Multi-database abstraction |
|
|
726
|
-
| [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | 11.x | SQLite driver |
|
|
727
|
-
| [@vitalets/google-translate-api](https://github.com/vitalets/google-translate-api) | 9.x | Google Translate (free tier) |
|
|
728
|
-
| [Commander.js](https://github.com/tj/commander.js) | 13.x | CLI |
|
|
729
|
-
| [bcryptjs](https://github.com/dcodeIO/bcrypt.js) | 2.x | Password hashing |
|
|
730
|
-
| [Cypress](https://www.cypress.io/) | 13.x | E2E test suite |
|
|
731
|
-
| [Vitest](https://vitest.dev/) | 4.x | Unit test suite (composables, services, server utils) |
|
|
732
|
-
---
|
|
733
|
-
|
|
734
|
-
## Contributing
|
|
735
|
-
|
|
736
|
-
Contributions are welcome. Please open an issue before submitting a pull request for significant changes.
|
|
737
|
-
|
|
738
|
-
```bash
|
|
739
|
-
git clone https://github.com/arnaudprioul/i18n-dashboard.git
|
|
740
|
-
cd i18n-dashboard
|
|
741
|
-
npm install
|
|
742
|
-
|
|
743
|
-
# Register the project git hooks (one-time, per clone)
|
|
744
|
-
git config core.hooksPath .githooks
|
|
745
|
-
```
|
|
746
|
-
|
|
747
|
-
### Commit message convention
|
|
748
|
-
|
|
749
|
-
Every push to `main` must include at least one commit message with a version bump indicator:
|
|
750
|
-
|
|
751
|
-
| Indicator | Bump | Example |
|
|
752
|
-
|---|---|---|
|
|
753
|
-
| `[patch]` | `0.3.8 → 0.3.9` | `fix: correct typo in error message [patch]` |
|
|
754
|
-
| `[minor]` | `0.3.8 → 0.4.0` | `feat: add git scan mode [minor]` |
|
|
755
|
-
| `[major]` | `0.3.8 → 1.0.0` | `feat!: breaking API change [major]` |
|
|
756
|
-
|
|
757
|
-
The pre-push hook will block the push if:
|
|
758
|
-
- No `[major]`, `[minor]`, or `[patch]` indicator is found in the commits
|
|
759
|
-
- `README.md` has not been updated
|
|
760
|
-
|
|
761
|
-
If both checks pass, the CI automatically bumps `package.json`, creates the git tag, and triggers the npm publish workflow.
|
|
82
|
+
- See USAGE.md for workflows
|
|
83
|
+
- See DOCUMENTATION.md for full technical details
|
|
762
84
|
|
|
763
85
|
---
|
|
764
86
|
|
|
765
|
-
##
|
|
766
|
-
|
|
767
|
-
If this project saved you time, consider buying me a coffee ☕
|
|
87
|
+
## 💬 Feedback
|
|
768
88
|
|
|
769
|
-
|
|
89
|
+
Looking for feedback 👀
|
|
90
|
+
Open an issue or discussion!
|
|
770
91
|
|
|
771
92
|
---
|
|
772
93
|
|
|
773
|
-
## License
|
|
774
|
-
|
|
775
|
-
[MIT](./LICENSE)
|
|
776
|
-
|
|
777
|
-
---
|
|
94
|
+
## 📄 License
|
|
778
95
|
|
|
779
|
-
|
|
96
|
+
MIT
|
package/bin/cli.mjs
CHANGED
|
@@ -100,7 +100,7 @@ program
|
|
|
100
100
|
const port = env.I18N_PORT || '3333'
|
|
101
101
|
const host = env.I18N_HOST || 'localhost'
|
|
102
102
|
|
|
103
|
-
console.log(`\n🌐 Starting
|
|
103
|
+
console.log(`\n🌐 Starting i18n-dashboard on http://${host}:${port}`)
|
|
104
104
|
console.log(`📁 Project root: ${env.I18N_PROJECT_ROOT}\n`)
|
|
105
105
|
|
|
106
106
|
const outputDir = resolve(packageRoot, '.output')
|
|
@@ -115,7 +115,10 @@ program
|
|
|
115
115
|
detached: options.detach || false,
|
|
116
116
|
})
|
|
117
117
|
} else {
|
|
118
|
-
|
|
118
|
+
// Use the nuxt binary bundled with the package so it works whether
|
|
119
|
+
// i18n-dashboard is installed locally (node_modules/.bin) or globally.
|
|
120
|
+
const nuxtBin = resolve(packageRoot, 'node_modules/.bin/nuxt')
|
|
121
|
+
proc = spawn(nuxtBin, ['dev', '--port', port, '--host', host], {
|
|
119
122
|
env,
|
|
120
123
|
stdio: options.detach ? 'ignore' : 'inherit',
|
|
121
124
|
cwd: packageRoot,
|
|
@@ -130,7 +133,7 @@ program
|
|
|
130
133
|
if (options.detach) {
|
|
131
134
|
proc.unref()
|
|
132
135
|
console.log(`✅ Dashboard started in background (PID: ${proc.pid})`)
|
|
133
|
-
console.log(` Stop it with:
|
|
136
|
+
console.log(` Stop it with: i18n-dashboard stop\n`)
|
|
134
137
|
} else {
|
|
135
138
|
proc.on('exit', (code) => {
|
|
136
139
|
if (existsSync(pidFile)) unlinkSync(pidFile)
|
|
@@ -195,8 +198,9 @@ program
|
|
|
195
198
|
.command('build')
|
|
196
199
|
.description('Build the dashboard for production (run once after install)')
|
|
197
200
|
.action(async () => {
|
|
198
|
-
console.log('\n🔨 Building
|
|
199
|
-
const
|
|
201
|
+
console.log('\n🔨 Building i18n-dashboard for production...\n')
|
|
202
|
+
const nuxtBin = resolve(packageRoot, 'node_modules/.bin/nuxt')
|
|
203
|
+
const proc = spawn(nuxtBin, ['build'], {
|
|
200
204
|
env: process.env,
|
|
201
205
|
stdio: 'inherit',
|
|
202
206
|
cwd: packageRoot,
|
|
@@ -204,7 +208,7 @@ program
|
|
|
204
208
|
proc.on('exit', (code) => {
|
|
205
209
|
if (code === 0) {
|
|
206
210
|
console.log('\n✅ Build complete!')
|
|
207
|
-
console.log(' Run "
|
|
211
|
+
console.log(' Run "i18n-dashboard start" to launch the dashboard.\n')
|
|
208
212
|
} else {
|
|
209
213
|
console.error('\n❌ Build failed.\n')
|
|
210
214
|
}
|
|
@@ -222,7 +226,7 @@ program
|
|
|
222
226
|
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
223
227
|
const question = (q) => new Promise((resolve) => rl.question(q, resolve))
|
|
224
228
|
|
|
225
|
-
console.log('\
|
|
229
|
+
console.log('\ni18n-dashboard — Initialisation\n')
|
|
226
230
|
|
|
227
231
|
const dbClient = options.db || await question('Base de données [sqlite3/postgresql/mysql] (défaut: sqlite3): ') || 'sqlite3'
|
|
228
232
|
const localesPath = await question('Dossier des locales (défaut: src/locales): ') || 'src/locales'
|
|
@@ -298,7 +302,7 @@ export default {
|
|
|
298
302
|
writeFileSync(resolve(process.cwd(), 'i18n-dashboard.config.js'), configContent)
|
|
299
303
|
|
|
300
304
|
console.log('\n✅ Fichier de configuration créé : i18n-dashboard.config.js')
|
|
301
|
-
console.log(' Lancez le dashboard avec :
|
|
305
|
+
console.log(' Lancez le dashboard avec : i18n-dashboard start\n')
|
|
302
306
|
|
|
303
307
|
rl.close()
|
|
304
308
|
})
|
|
@@ -332,7 +336,7 @@ program
|
|
|
332
336
|
const data = await res.json()
|
|
333
337
|
console.log(`✅ Sync done: ${data.added} ajoutées · ${data.updated} mises à jour · ${data.total} total\n`)
|
|
334
338
|
} catch (e) {
|
|
335
|
-
console.error('Could not connect to dashboard. Is it running?\n
|
|
339
|
+
console.error('Could not connect to dashboard. Is it running?\n i18n-dashboard start\n')
|
|
336
340
|
}
|
|
337
341
|
})
|
|
338
342
|
|
package/nuxt.config.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
|
2
|
+
|
|
3
|
+
// Warn at build/start time if SESSION_SECRET is still the insecure default.
|
|
4
|
+
// A weak, publicly known secret allows anyone to forge session tokens.
|
|
5
|
+
const DEFAULT_SECRET = 'i18n-dashboard-default-secret-change-me-in-production!!'
|
|
6
|
+
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === DEFAULT_SECRET) {
|
|
7
|
+
console.warn(
|
|
8
|
+
'\n⚠️ [i18n-dashboard] SESSION_SECRET is not set or is using the default value.\n' +
|
|
9
|
+
' Set a strong random secret in your environment:\n' +
|
|
10
|
+
' SESSION_SECRET=$(openssl rand -hex 32)\n',
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
2
14
|
export default defineNuxtConfig({
|
|
3
15
|
compatibilityDate: '2025-01-01',
|
|
4
16
|
devtools: { enabled: false },
|
|
@@ -27,8 +39,8 @@ export default defineNuxtConfig({
|
|
|
27
39
|
// Only set when the CLI passes I18N_PROJECT_ROOT explicitly (not defaulted)
|
|
28
40
|
projectRoot: process.env.I18N_PROJECT_ROOT || '',
|
|
29
41
|
localesPath: process.env.I18N_LOCALES_PATH || 'src/locales',
|
|
30
|
-
// Auth
|
|
31
|
-
sessionSecret: process.env.SESSION_SECRET ||
|
|
42
|
+
// Auth — set SESSION_SECRET to a strong random value in production
|
|
43
|
+
sessionSecret: process.env.SESSION_SECRET || DEFAULT_SECRET,
|
|
32
44
|
// Email (SMTP) — optional
|
|
33
45
|
smtpHost: process.env.SMTP_HOST || '',
|
|
34
46
|
smtpPort: process.env.SMTP_PORT || '587',
|
|
@@ -57,6 +69,26 @@ export default defineNuxtConfig({
|
|
|
57
69
|
dir: './src/assets/locales',
|
|
58
70
|
},
|
|
59
71
|
],
|
|
72
|
+
// ── Security headers ─────────────────────────────────────────────────────
|
|
73
|
+
// Applied to every response from the Nitro server.
|
|
74
|
+
routeRules: {
|
|
75
|
+
'/**': {
|
|
76
|
+
headers: {
|
|
77
|
+
// Prevent the dashboard from being embedded in iframes (clickjacking)
|
|
78
|
+
'X-Frame-Options': 'DENY',
|
|
79
|
+
// Stop browsers from MIME-sniffing away from the declared content-type
|
|
80
|
+
'X-Content-Type-Options': 'nosniff',
|
|
81
|
+
// Disable legacy XSS auditor (modern browsers use CSP instead)
|
|
82
|
+
'X-XSS-Protection': '0',
|
|
83
|
+
// Remove the server fingerprint
|
|
84
|
+
'Server': '',
|
|
85
|
+
// Don't leak the referrer when navigating to external sites
|
|
86
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
87
|
+
// Restrict powerful browser features
|
|
88
|
+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
60
92
|
},
|
|
61
93
|
|
|
62
94
|
typescript: {
|
package/package.json
CHANGED
|
@@ -44,7 +44,10 @@ export default defineEventHandler(async (event) => {
|
|
|
44
44
|
}
|
|
45
45
|
} catch (e: any) {
|
|
46
46
|
await testDb.destroy().catch(() => {})
|
|
47
|
-
|
|
47
|
+
// Log the full error server-side only — never expose DB host, port,
|
|
48
|
+
// credentials hints, or driver internals to the client.
|
|
49
|
+
console.error('[i18n-dashboard] DB connection test failed:', e.message)
|
|
50
|
+
throw createError({ statusCode: 400, message: 'Database connection failed. Please check your settings.' })
|
|
48
51
|
}
|
|
49
52
|
await testDb.destroy()
|
|
50
53
|
|
|
@@ -2,14 +2,28 @@ import { readdirSync, statSync, existsSync } from 'fs'
|
|
|
2
2
|
import { resolve, join, dirname, sep } from 'path'
|
|
3
3
|
import { homedir } from 'os'
|
|
4
4
|
|
|
5
|
+
// Allowed root anchors — browsing is limited to the user's home directory and
|
|
6
|
+
// any filesystem root. This prevents authenticated users from enumerating
|
|
7
|
+
// arbitrary paths outside their working area (e.g. /etc, /proc, /var/...).
|
|
8
|
+
function isAllowedPath(absolutePath: string): boolean {
|
|
9
|
+
const home = homedir()
|
|
10
|
+
// Allow: anything under home, filesystem roots (/ or C:\), and /tmp
|
|
11
|
+
const allowedRoots = [home, sep, resolve('/tmp')]
|
|
12
|
+
return allowedRoots.some(root => absolutePath === root || absolutePath.startsWith(root + sep))
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
export default defineEventHandler(async (event) => {
|
|
6
16
|
const query = getQuery(event)
|
|
7
17
|
const rawPath = query.path && String(query.path).trim() ? String(query.path).trim() : homedir()
|
|
8
18
|
|
|
9
19
|
const absolutePath = resolve(rawPath)
|
|
10
20
|
|
|
21
|
+
if (!isAllowedPath(absolutePath)) {
|
|
22
|
+
throw createError({ statusCode: 403, message: 'Access to this path is not allowed.' })
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
if (!existsSync(absolutePath)) {
|
|
12
|
-
throw createError({ statusCode: 404, message:
|
|
26
|
+
throw createError({ statusCode: 404, message: 'Path not found.' })
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
const stat = statSync(absolutePath)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve, extname, basename } from 'path'
|
|
2
2
|
import { mkdtempSync, rmSync, readdirSync, readFileSync, existsSync } from 'fs'
|
|
3
3
|
import { tmpdir } from 'os'
|
|
4
|
-
import {
|
|
4
|
+
import { spawnSync } from 'child_process'
|
|
5
5
|
import { getDb } from '../db/index'
|
|
6
6
|
import { scanProject, detectLanguages } from '../utils/scanner.uti'
|
|
7
7
|
|
|
@@ -30,11 +30,21 @@ export default defineEventHandler(async (event) => {
|
|
|
30
30
|
parsed.password = gitToken
|
|
31
31
|
cloneUrl = parsed.toString()
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Use spawnSync with an argument array — never concatenate user-supplied
|
|
34
|
+
// branch names into a shell string (command injection risk).
|
|
35
|
+
const gitArgs = ['clone', '--depth', '1']
|
|
36
|
+
if (gitBranch) gitArgs.push('--branch', gitBranch)
|
|
37
|
+
gitArgs.push('--', cloneUrl, tmpDir)
|
|
38
|
+
const gitResult = spawnSync('git', gitArgs, { timeout: 60_000, stdio: 'pipe' })
|
|
39
|
+
if (gitResult.status !== 0) {
|
|
40
|
+
throw new Error(gitResult.stderr?.toString().trim() || 'git clone failed')
|
|
41
|
+
}
|
|
35
42
|
} catch (e: any) {
|
|
36
43
|
rmSync(tmpDir, { recursive: true, force: true })
|
|
37
|
-
|
|
44
|
+
// Log the full error server-side; return a generic message to the client
|
|
45
|
+
// to avoid leaking git URL, token hints, or server paths.
|
|
46
|
+
console.error('[i18n-dashboard] git clone error:', e.message)
|
|
47
|
+
throw createError({ statusCode: 400, message: 'Git clone failed. Check the URL, branch, and access token.' })
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
try {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve, extname, basename } from 'path'
|
|
2
2
|
import { readdirSync, readFileSync, existsSync, mkdtempSync, rmSync } from 'fs'
|
|
3
3
|
import { tmpdir } from 'os'
|
|
4
|
-
import {
|
|
4
|
+
import { spawnSync } from 'child_process'
|
|
5
5
|
import { getDb } from '../db/index'
|
|
6
6
|
|
|
7
7
|
function flattenObject(obj: Record<string, any>, separator: string, prefix = ''): Record<string, string> {
|
|
@@ -119,8 +119,15 @@ export default defineEventHandler(async (event) => {
|
|
|
119
119
|
parsed.password = gitRepo.token
|
|
120
120
|
cloneUrl = parsed.toString()
|
|
121
121
|
}
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
// Use spawnSync with an argument array — never concatenate user-supplied
|
|
123
|
+
// branch names into a shell string (command injection risk).
|
|
124
|
+
const gitArgs = ['clone', '--depth', '1']
|
|
125
|
+
if (gitRepo.branch) gitArgs.push('--branch', gitRepo.branch)
|
|
126
|
+
gitArgs.push('--', cloneUrl, tmpDir)
|
|
127
|
+
const gitResult = spawnSync('git', gitArgs, { timeout: 60_000, stdio: 'pipe' })
|
|
128
|
+
if (gitResult.status !== 0) {
|
|
129
|
+
throw new Error(gitResult.stderr?.toString().trim() || 'git clone failed')
|
|
130
|
+
}
|
|
124
131
|
|
|
125
132
|
const localesDirPath = resolve(tmpDir, project.locales_path || 'src/locales')
|
|
126
133
|
const { added, skipped, files } = await syncFromDir(db, Number(project_id), localesDirPath, separator)
|
|
@@ -128,7 +135,8 @@ export default defineEventHandler(async (event) => {
|
|
|
128
135
|
const total = await db('translation_keys').where({ project_id: Number(project_id) }).count('* as count').first()
|
|
129
136
|
return { added, skipped, updated: 0, total: Number((total as any)?.count || 0), files }
|
|
130
137
|
} catch (e: any) {
|
|
131
|
-
|
|
138
|
+
console.error('[i18n-dashboard] git sync error:', e.message)
|
|
139
|
+
throw createError({ statusCode: 400, message: 'Git sync failed. Check the URL, branch, and access token.' })
|
|
132
140
|
} finally {
|
|
133
141
|
rmSync(tmpDir, { recursive: true, force: true })
|
|
134
142
|
}
|
|
@@ -16,9 +16,17 @@ export default defineEventHandler(async (event) => {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const db = getDb()
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
if (status === TRANSLATION_STATUS.APPROVED) {
|
|
20
|
+
// Freeze approved_value for each translation individually so we capture
|
|
21
|
+
// its current working value at approval time.
|
|
22
|
+
await db('translations')
|
|
23
|
+
.whereIn('id', ids)
|
|
24
|
+
.update({ status, approved_value: db.ref('value'), updated_at: db.fn.now() })
|
|
25
|
+
} else {
|
|
26
|
+
await db('translations')
|
|
27
|
+
.whereIn('id', ids)
|
|
28
|
+
.update({ status, updated_at: db.fn.now() })
|
|
29
|
+
}
|
|
22
30
|
|
|
23
31
|
return { updated: ids.length }
|
|
24
32
|
})
|
|
@@ -22,9 +22,16 @@ export default defineEventHandler(async (event) => {
|
|
|
22
22
|
throw createError({ statusCode: 404, message: 'Translation not found' })
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
const updates: Record<string, any> = { status, updated_at: db.fn.now() }
|
|
26
|
+
// Freeze the approved value so the locale API can serve it even if the
|
|
27
|
+
// translation is later edited back to draft.
|
|
28
|
+
if (status === TRANSLATION_STATUS.APPROVED) {
|
|
29
|
+
updates.approved_value = existing.value
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
await db('translations')
|
|
26
33
|
.where({ id: existing.id })
|
|
27
|
-
.update(
|
|
34
|
+
.update(updates)
|
|
28
35
|
|
|
29
36
|
return db('translations').where({ id: existing.id }).first()
|
|
30
37
|
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import bcrypt from 'bcryptjs'
|
|
2
|
+
import { randomBytes } from 'crypto'
|
|
2
3
|
import { getDb } from '../../db/index'
|
|
3
4
|
import { getUserRole, canManageUsers } from '../../utils/auth.util'
|
|
4
5
|
import { sendEmail, inviteEmailHtml } from '../../utils/mailer.util'
|
|
@@ -6,7 +7,8 @@ import { useRuntimeConfig } from '#imports'
|
|
|
6
7
|
|
|
7
8
|
function generateTempPassword(): string {
|
|
8
9
|
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
|
|
9
|
-
|
|
10
|
+
const bytes = randomBytes(12)
|
|
11
|
+
return Array.from(bytes, (b) => chars[b % chars.length]).join('')
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export default defineEventHandler(async (event) => {
|
|
@@ -85,5 +87,8 @@ export default defineEventHandler(async (event) => {
|
|
|
85
87
|
console.warn('[i18n-dashboard] Email non envoyé:', e.message)
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
return
|
|
90
|
+
// Never return the temporary password in the API response — it was already
|
|
91
|
+
// sent via email. Returning it here would expose it in browser DevTools,
|
|
92
|
+
// proxy logs, and any API monitoring tool.
|
|
93
|
+
return { id: userId, email: email.toLowerCase().trim(), name }
|
|
89
94
|
})
|
|
@@ -7,5 +7,15 @@ export const PUBLIC_ROUTES = [
|
|
|
7
7
|
'/api/setup',
|
|
8
8
|
'/api/ui-locale',
|
|
9
9
|
'/api/config',
|
|
10
|
+
// '/api/db-config' is intentionally NOT public — it can reconfigure and
|
|
11
|
+
// reset the entire database. It is accessible only during the onboarding
|
|
12
|
+
// wizard (before any user exists) via the auth middleware's setup-mode
|
|
13
|
+
// bypass, and requires super_admin authentication at all other times.
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
// Routes accessible without authentication only while onboarding is incomplete
|
|
17
|
+
// (no users in the database yet). Once a super admin exists, these routes
|
|
18
|
+
// require a valid session.
|
|
19
|
+
export const SETUP_ONLY_ROUTES = [
|
|
10
20
|
'/api/db-config',
|
|
11
21
|
]
|
package/src/server/db/index.ts
CHANGED
|
@@ -1018,6 +1018,19 @@ export async function initDb(): Promise<void> {
|
|
|
1018
1018
|
t.text('git_repos').nullable(),
|
|
1019
1019
|
)
|
|
1020
1020
|
|
|
1021
|
+
// ── migration: approved_value column on translations ────────────────────
|
|
1022
|
+
// Stores the last approved value independently from the working `value`.
|
|
1023
|
+
// The locale JSON endpoint serves this column so that in-progress drafts
|
|
1024
|
+
// are never exposed to end-users; only explicitly approved content is published.
|
|
1025
|
+
await addColumnIfMissing(db, 'translations', 'approved_value', (t) =>
|
|
1026
|
+
t.text('approved_value').nullable(),
|
|
1027
|
+
)
|
|
1028
|
+
// Backfill: seed approved_value for translations that are already approved
|
|
1029
|
+
await db('translations')
|
|
1030
|
+
.where('status', 'approved')
|
|
1031
|
+
.whereNull('approved_value')
|
|
1032
|
+
.update({ approved_value: db.ref('value') })
|
|
1033
|
+
|
|
1021
1034
|
// ── refresh_tokens ────────────────────────────────────────────────────────
|
|
1022
1035
|
const hasRefreshTokens = await db.schema.hasTable('refresh_tokens')
|
|
1023
1036
|
if (!hasRefreshTokens) {
|
|
@@ -2,7 +2,7 @@ import { useSession } from 'h3'
|
|
|
2
2
|
|
|
3
3
|
import { getDb } from '../db/index'
|
|
4
4
|
import { sessionConfig } from '../utils/auth.util'
|
|
5
|
-
import { PUBLIC_ROUTES } from '../consts/commons.const'
|
|
5
|
+
import { PUBLIC_ROUTES, SETUP_ONLY_ROUTES } from '../consts/commons.const'
|
|
6
6
|
|
|
7
7
|
export default defineEventHandler(async (event) => {
|
|
8
8
|
const path = event.path || ''
|
|
@@ -10,9 +10,20 @@ export default defineEventHandler(async (event) => {
|
|
|
10
10
|
// Only protect API routes
|
|
11
11
|
if (!path.startsWith('/api/')) return
|
|
12
12
|
|
|
13
|
-
// Allow public endpoints
|
|
13
|
+
// Allow fully public endpoints (no auth ever required)
|
|
14
14
|
if (PUBLIC_ROUTES.includes(path)) return
|
|
15
15
|
|
|
16
|
+
// Setup-only routes: allowed without auth ONLY while no user exists yet
|
|
17
|
+
// (i.e. onboarding is not yet complete). Once any user is created they
|
|
18
|
+
// require a valid super_admin session.
|
|
19
|
+
if (SETUP_ONLY_ROUTES.includes(path)) {
|
|
20
|
+
const db = getDb()
|
|
21
|
+
const userCount = await db('users').count('* as count').first()
|
|
22
|
+
const count = Number((userCount as any)?.count ?? 0)
|
|
23
|
+
if (count === 0) return // still in onboarding — allow through
|
|
24
|
+
// Onboarding complete: fall through to normal session check below
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
// Check session
|
|
17
28
|
const session = await useSession(event, sessionConfig())
|
|
18
29
|
const userId = (session.data as any).userId
|
|
@@ -28,5 +39,10 @@ export default defineEventHandler(async (event) => {
|
|
|
28
39
|
throw createError({ statusCode: 401, message: 'Session invalide' })
|
|
29
40
|
}
|
|
30
41
|
|
|
42
|
+
// Extra guard for setup-only routes: require super_admin once users exist
|
|
43
|
+
if (SETUP_ONLY_ROUTES.includes(path) && !user.is_super_admin) {
|
|
44
|
+
throw createError({ statusCode: 403, message: 'Accès réservé au super administrateur' })
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
event.context.user = user
|
|
32
48
|
})
|
|
@@ -76,13 +76,16 @@ async function loadTranslations(
|
|
|
76
76
|
projectId: number,
|
|
77
77
|
langCode: string,
|
|
78
78
|
): Promise<Record<string, string>> {
|
|
79
|
+
// Only serve the `approved_value` — the value frozen at last approval.
|
|
80
|
+
// Translations that have never been approved (approved_value IS NULL) are
|
|
81
|
+
// intentionally excluded so that drafts/reviews never leak to end-users.
|
|
79
82
|
const rows = await db('translations as t')
|
|
80
83
|
.join('translation_keys as k', 't.key_id', 'k.id')
|
|
81
84
|
.where('t.language_code', langCode)
|
|
82
85
|
.where('k.project_id', projectId)
|
|
83
|
-
.whereNotNull('t.
|
|
84
|
-
.where('t.
|
|
85
|
-
.select('k.key', 't.value')
|
|
86
|
+
.whereNotNull('t.approved_value')
|
|
87
|
+
.where('t.approved_value', '!=', '')
|
|
88
|
+
.select('k.key', 't.approved_value as value')
|
|
86
89
|
|
|
87
90
|
const flat: Record<string, string> = {}
|
|
88
91
|
for (const row of rows) flat[row.key] = row.value
|