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 CHANGED
@@ -1,779 +1,96 @@
1
- # i18n-dashboard
1
+ # Vue i18n Dashboard
2
2
 
3
- > A full-featured web dashboard to manage [vue-i18n](https://vue-i18n.intlify.dev/) translation keys. Run it alongside your project, manage all your translations in one place, and consume them via a ready-to-use API.
3
+ Stop editing JSON files manually.
4
4
 
5
- [![npm version](https://img.shields.io/npm/v/i18n-dashboard)](https://www.npmjs.com/package/i18n-dashboard)
6
- [![license](https://img.shields.io/npm/l/i18n-dashboard)](./LICENSE)
7
- [![node](https://img.shields.io/node/v/i18n-dashboard)](https://nodejs.org/)
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
- ## Features
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
- ## Requirements
13
+ Working with vue-i18n can be painful:
77
14
 
78
- - **Node.js** >= 18
79
- - **npm** >= 9
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
- ## Installation
22
+ ## 💥 The Solution
84
23
 
85
- ### As a dev dependency (recommended — per project)
24
+ Vue i18n Dashboard gives you:
86
25
 
87
- ```bash
88
- npm install i18n-dashboard --save-dev
89
- ```
26
+ - A local tool for your project
27
+ - OR a full translation management platform
90
28
 
91
- ### Globally (use across multiple projects)
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
- npx i18n-dashboard init
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
- ```bash
40
+ npx i18n-dashboard init
114
41
  npx i18n-dashboard start
115
42
  ```
116
43
 
117
- Open **http://localhost:3333** in your browser.
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
- ## Database
48
+ ## 🧠 Two Ways to Use It
466
49
 
467
- ### SQLite (default — zero config)
50
+ ### 🟢 Local Mode
468
51
 
469
- ```js
470
- database: {
471
- client: 'better-sqlite3',
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
- ### PostgreSQL
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
- ## REST API
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
- GET /api/formats/snippet?project_id=1 # Generate createI18n() config snippet
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
- ```http
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
- ## User Roles
70
+ ## 🎯 Why this tool?
654
71
 
655
- | Role | Permissions |
656
- |---|---|
657
- | **Super Admin** | Full access to all projects, users, and global settings |
658
- | **Admin** | Full access to assigned projects |
659
- | **Moderator** | Edit translations, approve/reject in review queue |
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
- ## Recommended Workflows
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
- # 3. Start
676
- npx i18n-dashboard start
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
- ## Support
766
-
767
- If this project saved you time, consider buying me a coffee ☕
87
+ ## 💬 Feedback
768
88
 
769
- [![Donate with PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://www.paypal.com/paypalme/arnaudprioul)
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
- Made with ❤️ by [arnaudprioul](https://github.com/arnaudprioul)
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 vue-i18n-dashboard on http://${host}:${port}`)
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
- proc = spawn('npx', ['nuxt', 'dev', '--port', port, '--host', host], {
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: vue-i18n-dashboard stop\n`)
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 vue-i18n-dashboard for production...\n')
199
- const proc = spawn('npx', ['nuxt', 'build'], {
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 "vue-i18n-dashboard start" to launch the dashboard.\n')
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('\nvue-i18n-dashboard — Initialisation\n')
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 : vue-i18n-dashboard start\n')
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 vue-i18n-dashboard start\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 || 'i18n-dashboard-default-secret-change-me-in-production!!',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-dashboard",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "A web dashboard to manage vue-i18n translation keys with database persistence",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,7 +44,10 @@ export default defineEventHandler(async (event) => {
44
44
  }
45
45
  } catch (e: any) {
46
46
  await testDb.destroy().catch(() => {})
47
- throw createError({ statusCode: 400, message: 'Connection failed: ' + (e.message || String(e)) })
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: `Path not found: ${absolutePath}` })
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 { execSync } from 'child_process'
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
- const branchArgs = gitBranch ? `--branch ${gitBranch} ` : ''
34
- execSync(`git clone --depth 1 ${branchArgs}-- "${cloneUrl}" "${tmpDir}"`, { timeout: 60_000, stdio: 'pipe' })
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
- throw createError({ statusCode: 400, message: `Git clone failed: ${e.message ?? 'unknown error'}` })
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 { execSync } from 'child_process'
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
- const branchArgs = gitRepo.branch ? `--branch ${gitRepo.branch} ` : ''
123
- execSync(`git clone --depth 1 ${branchArgs}-- "${cloneUrl}" "${tmpDir}"`, { timeout: 60_000, stdio: 'pipe' })
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
- throw createError({ statusCode: 400, message: `Git sync failed: ${e.message ?? 'unknown'}` })
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
- await db('translations')
20
- .whereIn('id', ids)
21
- .update({ status, updated_at: db.fn.now() })
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({ status, updated_at: db.fn.now() })
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
- return Array.from({ length: 12 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
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 { id: userId, email: email.toLowerCase().trim(), name, tempPassword }
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
  ]
@@ -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.value')
84
- .where('t.value', '!=', '')
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