i18n-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +715 -0
  3. package/app.vue +8 -0
  4. package/assets/css/main.css +21 -0
  5. package/assets/locales/en.json +380 -0
  6. package/bin/cli.mjs +279 -0
  7. package/components/LinkedKeyPicker.vue +135 -0
  8. package/components/PathPicker.vue +153 -0
  9. package/components/PluralEditor.vue +295 -0
  10. package/components/ScanModal.vue +153 -0
  11. package/components/TranslationHistoryModal.vue +66 -0
  12. package/components/TranslationRow.vue +541 -0
  13. package/components/dashboard/WidgetConfigModal.vue +121 -0
  14. package/components/dashboard/WidgetGrid.vue +190 -0
  15. package/components/dashboard/WidgetPicker.vue +75 -0
  16. package/components/dashboard/widgets/ActivityWidget.vue +109 -0
  17. package/components/dashboard/widgets/LanguagesCoverageWidget.vue +104 -0
  18. package/components/dashboard/widgets/ProjectsWidget.vue +77 -0
  19. package/components/dashboard/widgets/ReviewWidget.vue +150 -0
  20. package/components/dashboard/widgets/StatWidget.vue +133 -0
  21. package/composables/useAuth.ts +72 -0
  22. package/composables/useConfig.ts +14 -0
  23. package/composables/useDashboard.ts +89 -0
  24. package/composables/useFormats.ts +100 -0
  25. package/composables/useKeys.ts +231 -0
  26. package/composables/useLanguages.ts +221 -0
  27. package/composables/useProfile.ts +76 -0
  28. package/composables/useProject.ts +180 -0
  29. package/composables/useReview.ts +94 -0
  30. package/composables/useSettings.ts +30 -0
  31. package/composables/useStats.ts +16 -0
  32. package/composables/useT.ts +38 -0
  33. package/composables/useUsers.ts +101 -0
  34. package/composables/useWidgetData.ts +50 -0
  35. package/consts/commons.const.ts +6 -0
  36. package/consts/dashboard.const.ts +94 -0
  37. package/consts/languages.const.ts +223 -0
  38. package/enums/commons.enum.ts +7 -0
  39. package/i18n-dashboard.config.example.js +40 -0
  40. package/interfaces/commons.interface.ts +23 -0
  41. package/interfaces/job.interface.ts +10 -0
  42. package/interfaces/key.interface.ts +39 -0
  43. package/interfaces/languages.interface.ts +23 -0
  44. package/interfaces/project.interface.ts +9 -0
  45. package/interfaces/scan.interface.ts +12 -0
  46. package/interfaces/settings.interface.ts +4 -0
  47. package/interfaces/stat.interface.ts +30 -0
  48. package/interfaces/translation.interface.ts +11 -0
  49. package/interfaces/user.interface.ts +24 -0
  50. package/layouts/auth.vue +5 -0
  51. package/layouts/default.vue +327 -0
  52. package/middleware/auth.global.ts +26 -0
  53. package/nuxt.config.ts +66 -0
  54. package/package.json +89 -0
  55. package/pages/index.vue +5 -0
  56. package/pages/login.vue +74 -0
  57. package/pages/onboarding.vue +563 -0
  58. package/pages/projects/[id]/formats/datetime.vue +240 -0
  59. package/pages/projects/[id]/formats/modifiers.vue +194 -0
  60. package/pages/projects/[id]/formats/number.vue +250 -0
  61. package/pages/projects/[id]/index.vue +182 -0
  62. package/pages/projects/[id]/languages.vue +537 -0
  63. package/pages/projects/[id]/review.vue +109 -0
  64. package/pages/projects/[id]/settings.vue +515 -0
  65. package/pages/projects/[id]/translations/[keyId].vue +642 -0
  66. package/pages/projects/[id]/translations/index.vue +250 -0
  67. package/pages/projects/[id]/users.vue +276 -0
  68. package/pages/projects/index.vue +334 -0
  69. package/pages/users/[id]/profile.vue +421 -0
  70. package/pages/users/index.vue +345 -0
  71. package/plugins/loading.client.ts +3 -0
  72. package/plugins/ui-i18n.ts +6 -0
  73. package/server/api/auth/login.post.ts +28 -0
  74. package/server/api/auth/logout.post.ts +7 -0
  75. package/server/api/auth/me.get.ts +11 -0
  76. package/server/api/auth/me.put.ts +31 -0
  77. package/server/api/auth/password.put.ts +27 -0
  78. package/server/api/auth/status.get.ts +16 -0
  79. package/server/api/config.get.ts +10 -0
  80. package/server/api/dashboard/layout.get.ts +18 -0
  81. package/server/api/dashboard/layout.post.ts +18 -0
  82. package/server/api/db-config.get.ts +44 -0
  83. package/server/api/db-config.post.ts +73 -0
  84. package/server/api/export.get.ts +64 -0
  85. package/server/api/formats/datetime/[id].delete.ts +8 -0
  86. package/server/api/formats/datetime/[id].put.ts +15 -0
  87. package/server/api/formats/datetime.get.ts +11 -0
  88. package/server/api/formats/datetime.post.ts +16 -0
  89. package/server/api/formats/modifiers/[id].delete.ts +8 -0
  90. package/server/api/formats/modifiers/[id].put.ts +10 -0
  91. package/server/api/formats/modifiers.get.ts +10 -0
  92. package/server/api/formats/modifiers.post.ts +14 -0
  93. package/server/api/formats/number/[id].delete.ts +8 -0
  94. package/server/api/formats/number/[id].put.ts +15 -0
  95. package/server/api/formats/number.get.ts +11 -0
  96. package/server/api/formats/number.post.ts +16 -0
  97. package/server/api/formats/snippet.get.ts +87 -0
  98. package/server/api/fs/browse.get.ts +50 -0
  99. package/server/api/history/[translationId].get.ts +13 -0
  100. package/server/api/keys/[id].delete.ts +14 -0
  101. package/server/api/keys/[id].get.ts +41 -0
  102. package/server/api/keys/[id].patch.ts +20 -0
  103. package/server/api/keys/index.get.ts +98 -0
  104. package/server/api/keys/index.post.ts +17 -0
  105. package/server/api/languages/[code].delete.ts +15 -0
  106. package/server/api/languages/[id].put.ts +24 -0
  107. package/server/api/languages/index.get.ts +13 -0
  108. package/server/api/languages/index.post.ts +42 -0
  109. package/server/api/onboarding.post.ts +56 -0
  110. package/server/api/profile.get.ts +81 -0
  111. package/server/api/project-snapshot.get.ts +73 -0
  112. package/server/api/project-snapshot.post.ts +160 -0
  113. package/server/api/projects/[id].delete.ts +13 -0
  114. package/server/api/projects/[id].put.ts +40 -0
  115. package/server/api/projects/index.get.ts +19 -0
  116. package/server/api/projects/index.post.ts +34 -0
  117. package/server/api/scan.post.ts +165 -0
  118. package/server/api/settings/index.get.ts +9 -0
  119. package/server/api/settings/index.post.ts +20 -0
  120. package/server/api/setup.post.ts +39 -0
  121. package/server/api/stats/global.get.ts +126 -0
  122. package/server/api/stats.get.ts +70 -0
  123. package/server/api/sync.post.ts +179 -0
  124. package/server/api/translate.post.ts +52 -0
  125. package/server/api/translations/batch-translate.post.ts +121 -0
  126. package/server/api/translations/bulk-status.post.ts +24 -0
  127. package/server/api/translations/index.post.ts +62 -0
  128. package/server/api/translations/job/[id].get.ts +23 -0
  129. package/server/api/translations/status.post.ts +30 -0
  130. package/server/api/translations/translate-all.post.ts +18 -0
  131. package/server/api/ui-locale.get.ts +39 -0
  132. package/server/api/users/[id]/profile.get.ts +107 -0
  133. package/server/api/users/[id]/roles.put.ts +67 -0
  134. package/server/api/users/[id].delete.ts +36 -0
  135. package/server/api/users/[id].put.ts +43 -0
  136. package/server/api/users/index.get.ts +49 -0
  137. package/server/api/users/index.post.ts +89 -0
  138. package/server/consts/auto-translate.const.ts +2 -0
  139. package/server/consts/commons.const.ts +10 -0
  140. package/server/consts/db.const.ts +3 -0
  141. package/server/consts/scanner.const.ts +4 -0
  142. package/server/consts/translation-job.const.ts +8 -0
  143. package/server/db/index.ts +672 -0
  144. package/server/enums/auth.enum.ts +5 -0
  145. package/server/enums/translation.enum.ts +6 -0
  146. package/server/interfaces/profile.interface.ts +48 -0
  147. package/server/interfaces/project-config.interface.ts +9 -0
  148. package/server/interfaces/scanner.interface.ts +18 -0
  149. package/server/interfaces/translation-job.interface.ts +13 -0
  150. package/server/middleware/auth.ts +32 -0
  151. package/server/plugins/db.ts +6 -0
  152. package/server/routes/locale/[lang].get.ts +179 -0
  153. package/server/types/auth.type.ts +3 -0
  154. package/server/utils/auth.util.ts +89 -0
  155. package/server/utils/auto-translate.util.ts +112 -0
  156. package/server/utils/lang-api.util.ts +24 -0
  157. package/server/utils/mailer.util.ts +80 -0
  158. package/server/utils/project-config.util.ts +37 -0
  159. package/server/utils/scanner.uti.ts +307 -0
  160. package/server/utils/translation-job.util.ts +142 -0
  161. package/services/auth.service.ts +31 -0
  162. package/services/base.service.ts +140 -0
  163. package/services/job.service.ts +10 -0
  164. package/services/key.service.ts +26 -0
  165. package/services/language.service.ts +26 -0
  166. package/services/profile.service.ts +14 -0
  167. package/services/project.service.ts +23 -0
  168. package/services/scan.service.ts +14 -0
  169. package/services/settings.service.ts +14 -0
  170. package/services/stats.service.ts +11 -0
  171. package/services/translation.service.ts +36 -0
  172. package/services/user.service.ts +28 -0
  173. package/tsconfig.json +3 -0
  174. package/types/commons.type.ts +3 -0
  175. package/types/dashboard.type.ts +26 -0
  176. package/utils/config.util.ts +60 -0
@@ -0,0 +1,5 @@
1
+ export enum ROLES {
2
+ ADMIN = 'admin',
3
+ MODERATOR = 'moderator',
4
+ TRANSLATOR = 'translator',
5
+ }
@@ -0,0 +1,6 @@
1
+ export enum TRANSLATION_STATUS {
2
+ DRAFT = 'draft',
3
+ REVIEWED = 'reviewed',
4
+ APPROVED = 'approved',
5
+ REJECTED = 'rejected',
6
+ }
@@ -0,0 +1,48 @@
1
+ export interface ProfileStats {
2
+ total: number
3
+ thisWeek: number
4
+ thisMonth: number
5
+ }
6
+
7
+ export interface ProfileRole {
8
+ role: string
9
+ project_id: number | null
10
+ project_name: string | null
11
+ project_color: string | null
12
+ }
13
+
14
+ export interface ProfileLanguage {
15
+ id: number
16
+ code: string
17
+ name: string
18
+ project_id: number
19
+ project_name: string
20
+ }
21
+
22
+ export interface ProfileRecentTranslation {
23
+ id: number
24
+ key: string
25
+ key_id: number
26
+ project_id?: number
27
+ language_code: string
28
+ new_value: string
29
+ old_value: string | null
30
+ changed_at: string
31
+ project_name: string
32
+ project_color: string
33
+ }
34
+
35
+ export interface UserProfile {
36
+ user: {
37
+ id: number
38
+ name: string
39
+ email: string
40
+ is_super_admin: boolean
41
+ last_login_at: string | null
42
+ created_at: string
43
+ }
44
+ roles: ProfileRole[]
45
+ stats: ProfileStats
46
+ languages: ProfileLanguage[]
47
+ recentTranslations: ProfileRecentTranslation[]
48
+ }
@@ -0,0 +1,9 @@
1
+ export interface DashboardConfig {
2
+ uiLanguages?: string[] // e.g. ["fr", "en", "es"]
3
+ defaultUiLanguage?: string // e.g. "fr"
4
+ project?: {
5
+ name?: string
6
+ localesPath?: string
7
+ keySeparator?: string
8
+ }
9
+ }
@@ -0,0 +1,18 @@
1
+ export interface KeyUsage {
2
+ key: string
3
+ filePath: string
4
+ lineNumber: number
5
+ detectedFunction: string
6
+ }
7
+
8
+ export interface DetectedLanguage {
9
+ code: string
10
+ name: string
11
+ source: 'locales-dir' | 'config-file'
12
+ }
13
+
14
+ export interface ScanResult {
15
+ usages: KeyUsage[]
16
+ scannedFiles: string[]
17
+ errors: string[]
18
+ }
@@ -0,0 +1,13 @@
1
+ import type { JobStatus } from '../consts/translation-job.const'
2
+
3
+ export interface TranslationJob {
4
+ id: string
5
+ status: JobStatus
6
+ projectId: number
7
+ languageCode: string
8
+ languageName: string
9
+ total: number
10
+ done: number
11
+ errors: number
12
+ startedAt: number
13
+ }
@@ -0,0 +1,32 @@
1
+ import { useSession } from 'h3'
2
+
3
+ import { getDb } from '../db/index'
4
+ import { sessionConfig } from '../utils/auth.util'
5
+ import { PUBLIC_ROUTES } from '../consts/commons.const'
6
+
7
+ export default defineEventHandler(async (event) => {
8
+ const path = event.path || ''
9
+
10
+ // Only protect API routes
11
+ if (!path.startsWith('/api/')) return
12
+
13
+ // Allow public endpoints
14
+ if (PUBLIC_ROUTES.includes(path)) return
15
+
16
+ // Check session
17
+ const session = await useSession(event, sessionConfig())
18
+ const userId = (session.data as any).userId
19
+
20
+ if (!userId) {
21
+ throw createError({ statusCode: 401, message: 'Non authentifié' })
22
+ }
23
+
24
+ // Attach user to context for downstream handlers
25
+ const db = getDb()
26
+ const user = await db('users').where({ id: userId, is_active: true }).first()
27
+ if (!user) {
28
+ throw createError({ statusCode: 401, message: 'Session invalide' })
29
+ }
30
+
31
+ event.context.user = user
32
+ })
@@ -0,0 +1,6 @@
1
+ import { initDb } from '../db/index'
2
+
3
+ export default defineNitroPlugin(async () => {
4
+ await initDb()
5
+ console.log('[i18n-dashboard] Database initialized')
6
+ })
@@ -0,0 +1,179 @@
1
+ import { getDb } from '../../db/index'
2
+ import { unflattenObject } from '#server/utils/lang-api.util'
3
+ import type { Knex } from 'knex'
4
+
5
+ /**
6
+ * Normalise une URL en gardant uniquement origin (scheme + host + port).
7
+ */
8
+ function normalizeOrigin(url: string): string {
9
+ try {
10
+ return new URL(url).origin.toLowerCase()
11
+ } catch {
12
+ return url.toLowerCase().replace(/\/$/, '')
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Remonte d'un niveau dans un tag BCP 47.
18
+ * fr-CA → fr | zh-Hant-TW → zh-Hant | fr → null
19
+ */
20
+ function parentBcp47(code: string): string | null {
21
+ const parts = code.split('-')
22
+ if (parts.length <= 1) return null
23
+ parts.pop()
24
+ return parts.join('-')
25
+ }
26
+
27
+ /**
28
+ * Construit la chaîne de fallback pour `requestedCode` dans un projet.
29
+ * Résolution :
30
+ * 1. Si la langue existe dans le projet → l'ajoute à la chaîne
31
+ * puis suit son fallback_code explicite (ou auto BCP 47 si absent)
32
+ * 2. Si la langue n'existe PAS → remonte le tag BCP 47 (fr-CA → fr)
33
+ * jusqu'à trouver une langue configurée dans le projet
34
+ * La chaîne est ordonnée du plus spécifique au moins spécifique.
35
+ */
36
+ async function buildFallbackChain(
37
+ db: Knex,
38
+ projectId: number,
39
+ requestedCode: string,
40
+ ): Promise<string[]> {
41
+ const chain: string[] = []
42
+ const visited = new Set<string>()
43
+ let current: string | null = requestedCode
44
+
45
+ while (current && !visited.has(current) && chain.length < 10) {
46
+ visited.add(current)
47
+ const langRow = await db('languages')
48
+ .where({ project_id: projectId, code: current })
49
+ .first()
50
+
51
+ if (!langRow) {
52
+ // Langue non configurée dans ce projet → tenter le parent BCP 47
53
+ current = parentBcp47(current)
54
+ continue
55
+ }
56
+
57
+ chain.push(current)
58
+
59
+ if (langRow.fallback_code) {
60
+ // Fallback explicitement configuré
61
+ current = langRow.fallback_code
62
+ } else {
63
+ // Auto-fallback BCP 47 : fr-CA → fr → null
64
+ current = parentBcp47(current)
65
+ }
66
+ }
67
+
68
+ return chain
69
+ }
70
+
71
+ /**
72
+ * Charge toutes les traductions non-nulles d'une locale donnée dans un projet.
73
+ */
74
+ async function loadTranslations(
75
+ db: Knex,
76
+ projectId: number,
77
+ langCode: string,
78
+ ): Promise<Record<string, string>> {
79
+ const rows = await db('translations as t')
80
+ .join('translation_keys as k', 't.key_id', 'k.id')
81
+ .where('t.language_code', langCode)
82
+ .where('k.project_id', projectId)
83
+ .whereNotNull('t.value')
84
+ .where('t.value', '!=', '')
85
+ .select('k.key', 't.value')
86
+
87
+ const flat: Record<string, string> = {}
88
+ for (const row of rows) flat[row.key] = row.value
89
+ return flat
90
+ }
91
+
92
+ export default defineEventHandler(async (event) => {
93
+ // Extract lang from the URL - handles /locale/fr-CA.json
94
+ const url = event.path || getRequestURL(event).pathname
95
+ const match = url.match(/\/locale\/([^/]+)\.json/)
96
+ const lang = match ? match[1] : getRouterParam(event, 'lang')
97
+
98
+ if (!lang) {
99
+ throw createError({ statusCode: 400, message: 'Language code is required' })
100
+ }
101
+
102
+ const db = getDb()
103
+ const query = getQuery(event)
104
+
105
+ let projectId: number | null = query.project_id ? Number(query.project_id) : null
106
+
107
+ if (!projectId) {
108
+ // 1. Match by project_name query param
109
+ if (query.project_name) {
110
+ const p = await db('projects').where({ name: query.project_name.toString() }).first()
111
+ if (p) projectId = p.id
112
+ }
113
+
114
+ // 2. Match by request Origin / Referer against project source_url
115
+ if (!projectId) {
116
+ const requestOrigin = getHeader(event, 'origin') || getHeader(event, 'referer') || ''
117
+ if (requestOrigin) {
118
+ const normalizedRequest = normalizeOrigin(requestOrigin)
119
+ const projectsWithUrl = await db('projects')
120
+ .whereNotNull('source_url')
121
+ .where('source_url', '!=', '')
122
+ .select('id', 'source_url')
123
+
124
+ for (const p of projectsWithUrl) {
125
+ if (normalizeOrigin(p.source_url) === normalizedRequest) {
126
+ projectId = p.id
127
+ break
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ // 3. Fallback: first non-system project
134
+ if (!projectId) {
135
+ const firstProject = await db('projects')
136
+ .where({ is_system: false })
137
+ .orderBy('id', 'asc')
138
+ .first()
139
+ if (firstProject) projectId = firstProject.id
140
+ }
141
+ }
142
+
143
+ if (!projectId) {
144
+ throw createError({ statusCode: 404, message: 'No project found' })
145
+ }
146
+
147
+ // ── Fallback chain resolution ─────────────────────────────────────────────
148
+ const chain = await buildFallbackChain(db, projectId, lang)
149
+
150
+ if (chain.length === 0) {
151
+ throw createError({
152
+ statusCode: 404,
153
+ message: `Language '${lang}' not found in this project and no BCP 47 fallback available`,
154
+ })
155
+ }
156
+
157
+ const project = await db('projects').where({ id: projectId }).first()
158
+ const separator = project?.key_separator || '.'
159
+
160
+ // Merge translations: du moins spécifique au plus spécifique
161
+ // chain[0] = fr-CA (priorité max), chain[last] = fr ou en (priorité min)
162
+ // On charge du dernier au premier et on écrase — ainsi fr-CA prime sur fr
163
+ const flat: Record<string, string> = {}
164
+ for (const code of [...chain].reverse()) {
165
+ const translations = await loadTranslations(db, projectId, code)
166
+ Object.assign(flat, translations)
167
+ }
168
+
169
+ const nested = unflattenObject(flat, separator)
170
+
171
+ setHeader(event, 'Content-Type', 'application/json')
172
+ setHeader(event, 'Cache-Control', 'no-cache')
173
+ setHeader(event, 'Access-Control-Allow-Origin', '*')
174
+
175
+ // Expose la chaîne résolue dans les headers pour debug
176
+ setHeader(event, 'X-I18n-Fallback-Chain', chain.join(' → '))
177
+
178
+ return nested
179
+ })
@@ -0,0 +1,3 @@
1
+ import type { ROLES } from '../enums/auth.enum'
2
+
3
+ export type Role = `${ROLES}`
@@ -0,0 +1,89 @@
1
+ import type { H3Event } from 'h3'
2
+ import { useSession } from 'h3'
3
+ import { useRuntimeConfig } from '#imports'
4
+
5
+ import { getDb } from '../db/index'
6
+ import { ROLES } from '../enums/auth.enum'
7
+ import type { Role } from '../types/auth.type'
8
+
9
+ export function sessionConfig() {
10
+ const config = useRuntimeConfig()
11
+ return {
12
+ password: config.sessionSecret as string,
13
+ name: 'i18n-dashboard-session',
14
+ maxAge: 60 * 60 * 24 * 7, // 7 days
15
+ }
16
+ }
17
+
18
+ /** Get the current session data */
19
+ export async function getSession(event: H3Event) {
20
+ return useSession(event, sessionConfig())
21
+ }
22
+
23
+ /** Require a logged-in user. Returns user row or throws 401. */
24
+ export async function requireAuth(event: H3Event) {
25
+ const session = await getSession(event)
26
+ const userId = (session.data as any).userId
27
+ if (!userId) throw createError({ statusCode: 401, message: 'Non authentifié' })
28
+
29
+ const db = getDb()
30
+ const user = await db('users').where({ id: userId, is_active: true }).first()
31
+ if (!user) throw createError({ statusCode: 401, message: 'Session invalide' })
32
+
33
+ return user
34
+ }
35
+
36
+ /** Get effective role for a user on a specific project. Returns null if no access. */
37
+ export async function getUserRole(userId: number, projectId: number): Promise<Role | null> {
38
+ const db = getDb()
39
+
40
+ // Specific project role takes priority
41
+ const specific = await db('user_project_roles')
42
+ .where({ user_id: userId, project_id: projectId })
43
+ .first()
44
+ if (specific) return specific.role as Role
45
+
46
+ // Global role (project_id IS NULL) — access to all projects
47
+ const global_ = await db('user_project_roles')
48
+ .where({ user_id: userId })
49
+ .whereNull('project_id')
50
+ .first()
51
+ if (global_) return global_.role as Role
52
+
53
+ return null
54
+ }
55
+
56
+ /** Check if user can edit translations (translator+) */
57
+ export function canEdit(role: Role | null, isSuperAdmin: boolean) {
58
+ return isSuperAdmin || role !== null
59
+ }
60
+
61
+ /** Check if user can approve translations (moderator+) */
62
+ export function canApprove(role: Role | null, isSuperAdmin: boolean) {
63
+ return isSuperAdmin || role === ROLES.MODERATOR || role === ROLES.ADMIN
64
+ }
65
+
66
+ /** Check if user can manage project settings, scan, sync (admin+) */
67
+ export function canManageProject(role: Role | null, isSuperAdmin: boolean) {
68
+ return isSuperAdmin || role === ROLES.ADMIN
69
+ }
70
+
71
+ /** Check if user can manage users (admin+ of that project, or super_admin) */
72
+ export function canManageUsers(role: Role | null, isSuperAdmin: boolean) {
73
+ return isSuperAdmin || role === ROLES.ADMIN
74
+ }
75
+
76
+ /** Full user profile with roles */
77
+ export async function getUserProfile(userId: number) {
78
+ const db = getDb()
79
+ const user = await db('users').where({ id: userId }).first()
80
+ if (!user) return null
81
+
82
+ const roles = await db('user_project_roles as r')
83
+ .leftJoin('projects as p', 'r.project_id', 'p.id')
84
+ .where('r.user_id', userId)
85
+ .select('r.role', 'r.project_id', 'p.name as project_name')
86
+
87
+ const { password_hash, ...safeUser } = user
88
+ return { ...safeUser, roles }
89
+ }
@@ -0,0 +1,112 @@
1
+ import { translate } from '@vitalets/google-translate-api'
2
+ import type { Knex } from 'knex'
3
+
4
+ import { BATCH_DELAY_MS, BATCH_SIZE } from '../consts/auto-translate.const'
5
+
6
+ /**
7
+ * Auto-translates all missing Dashboard UI strings for a given language,
8
+ * using the EN translations as the source.
9
+ * Runs in batches to stay within free API limits.
10
+ */
11
+ export async function autoTranslateUtil(db: Knex, targetLang: string): Promise<void> {
12
+ const systemProject = await db('projects').where({ is_system: true }).first()
13
+ if (!systemProject) return
14
+
15
+ // Get all EN translations for the system project
16
+ const enTranslations = await db('translation_keys as tk')
17
+ .join('translations as t', function () {
18
+ this.on('t.key_id', '=', 'tk.id').andOn('t.language_code', '=', db.raw('?', ['en']))
19
+ })
20
+ .where('tk.project_id', systemProject.id)
21
+ .whereNotNull('t.value')
22
+ .where('t.value', '!=', '')
23
+ .select('tk.id as key_id', 't.value as en_value')
24
+
25
+ if (!enTranslations.length) return
26
+
27
+ // Find which keys already have a translation in targetLang
28
+ const existingTargetKeyIds = new Set(
29
+ (await db('translations')
30
+ .whereIn('key_id', enTranslations.map((r: any) => r.key_id))
31
+ .where('language_code', targetLang)
32
+ .whereNotNull('value')
33
+ .where('value', '!=', '')
34
+ .select('key_id')
35
+ ).map((r: any) => r.key_id),
36
+ )
37
+
38
+ const toTranslate = enTranslations.filter((r: any) => !existingTargetKeyIds.has(r.key_id))
39
+ if (!toTranslate.length) return
40
+
41
+ console.log(`[autoTranslateUI] Translating ${toTranslate.length} UI strings to "${targetLang}"…`)
42
+
43
+ // Process in batches
44
+ const separator = '\n\n'
45
+
46
+ for (let i = 0; i < toTranslate.length; i += BATCH_SIZE) {
47
+ const chunk = toTranslate.slice(i, i + BATCH_SIZE)
48
+
49
+ let translatedTexts: string[] | null = null
50
+
51
+ // Attempt batch translation — faster but may fail for some languages
52
+ try {
53
+ const combined = chunk.map((r: any) => r.en_value).join(separator)
54
+ const result = await translate(combined, { from: 'en', to: targetLang })
55
+ const parts = result.text.split(separator)
56
+
57
+ // Only use batch result if the split produced the expected number of parts
58
+ if (parts.length === chunk.length) {
59
+ translatedTexts = parts
60
+ } else {
61
+ console.warn(`[autoTranslateUI] Batch split mismatch for "${targetLang}" (got ${parts.length}, expected ${chunk.length}) — falling back to individual calls`)
62
+ }
63
+ } catch (e: any) {
64
+ console.error(`[autoTranslateUI] Batch ${i / BATCH_SIZE + 1} failed for "${targetLang}":`, e.message)
65
+ }
66
+
67
+ // Fallback: translate each string individually when batch split is unreliable
68
+ if (!translatedTexts) {
69
+ translatedTexts = []
70
+ for (const item of chunk) {
71
+ try {
72
+ const r = await translate(item.en_value, { from: 'en', to: targetLang })
73
+ translatedTexts.push(r.text)
74
+ } catch (e: any) {
75
+ console.error(`[autoTranslateUI] Individual translation failed for "${targetLang}":`, e.message)
76
+ translatedTexts.push('')
77
+ }
78
+ await new Promise((r) => setTimeout(r, BATCH_DELAY_MS))
79
+ }
80
+ }
81
+
82
+ for (let j = 0; j < chunk.length; j++) {
83
+ const value = (translatedTexts[j] || '').trim()
84
+ if (!value) continue
85
+
86
+ const existing = await db('translations')
87
+ .where({ key_id: chunk[j].key_id, language_code: targetLang })
88
+ .first()
89
+
90
+ if (existing) {
91
+ if (!existing.value) {
92
+ await db('translations')
93
+ .where({ id: existing.id })
94
+ .update({ value, status: 'draft', updated_at: db.fn.now() })
95
+ }
96
+ } else {
97
+ await db('translations').insert({
98
+ key_id: chunk[j].key_id,
99
+ language_code: targetLang,
100
+ value,
101
+ status: 'draft',
102
+ })
103
+ }
104
+ }
105
+
106
+ if (!translatedTexts && i + BATCH_SIZE < toTranslate.length) {
107
+ await new Promise((r) => setTimeout(r, BATCH_DELAY_MS))
108
+ }
109
+ }
110
+
111
+ console.log(`[autoTranslateUI] Done translating UI to "${targetLang}".`)
112
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Reconstruct a nested object from flat dot-separated keys
3
+ * e.g. { 'a.b': 'hello' } => { a: { b: 'hello' } }
4
+ */
5
+ export function unflattenObject(flat: Record<string, string>, separator: string): Record<string, any> {
6
+ const result: Record<string, any> = {}
7
+
8
+ for (const [key, value] of Object.entries(flat)) {
9
+ const parts = key.split(separator)
10
+ let current = result
11
+
12
+ for (let i = 0; i < parts.length - 1; i++) {
13
+ const part = parts[i]
14
+ if (!(part in current) || typeof current[part] !== 'object') {
15
+ current[part] = {}
16
+ }
17
+ current = current[part]
18
+ }
19
+
20
+ current[parts[parts.length - 1]] = value
21
+ }
22
+
23
+ return result
24
+ }
@@ -0,0 +1,80 @@
1
+ import { useRuntimeConfig } from '#imports'
2
+
3
+ export async function sendEmail({ to, subject, html }: { to: string; subject: string; html: string }) {
4
+ const config = useRuntimeConfig()
5
+
6
+ if (!config.smtpHost) {
7
+ // No SMTP configured — log to console as fallback
8
+ console.log(`\n[i18n-dashboard] 📧 Email (non envoyé — SMTP non configuré)`)
9
+ console.log(` À : ${to}`)
10
+ console.log(` Sujet : ${subject}`)
11
+ console.log(` Contenu: ${html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()}\n`)
12
+ return
13
+ }
14
+
15
+ const nodemailer = await import('nodemailer')
16
+ const transporter = nodemailer.createTransport({
17
+ host: config.smtpHost as string,
18
+ port: Number(config.smtpPort) || 587,
19
+ secure: config.smtpSecure === 'true',
20
+ auth: config.smtpUser
21
+ ? { user: config.smtpUser as string, pass: config.smtpPass as string }
22
+ : undefined,
23
+ })
24
+
25
+ await transporter.sendMail({
26
+ from: config.smtpFrom as string || 'noreply@i18n-dashboard.local',
27
+ to,
28
+ subject,
29
+ html,
30
+ })
31
+ }
32
+
33
+ export function inviteEmailHtml({
34
+ name,
35
+ email,
36
+ tempPassword,
37
+ dashboardUrl,
38
+ projectName,
39
+ role,
40
+ }: {
41
+ name: string
42
+ email: string
43
+ tempPassword: string
44
+ dashboardUrl: string
45
+ projectName?: string
46
+ role: string
47
+ }) {
48
+ const roleLabels: Record<string, string> = {
49
+ translator: 'Traducteur',
50
+ moderator: 'Modérateur',
51
+ admin: 'Administrateur',
52
+ }
53
+ const scopeMsg = projectName
54
+ ? `sur le projet <strong>${projectName}</strong>`
55
+ : `sur tous les projets`
56
+
57
+ return `
58
+ <!DOCTYPE html>
59
+ <html>
60
+ <body style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #333;">
61
+ <div style="background: #4f46e5; padding: 24px; border-radius: 8px 8px 0 0;">
62
+ <h1 style="color: white; margin: 0; font-size: 1.5rem;">🌐 i18n Dashboard</h1>
63
+ </div>
64
+ <div style="background: #f9fafb; padding: 24px; border-radius: 0 0 8px 8px; border: 1px solid #e5e7eb;">
65
+ <h2>Bonjour ${name},</h2>
66
+ <p>Un compte a été créé pour vous sur <strong>i18n-dashboard</strong>.</p>
67
+ <p>Vous avez le rôle <strong>${roleLabels[role] || role}</strong> ${scopeMsg}.</p>
68
+ <div style="background: white; padding: 16px; border-radius: 6px; border: 1px solid #e5e7eb; margin: 20px 0;">
69
+ <p style="margin: 4px 0;"><strong>Email :</strong> ${email}</p>
70
+ <p style="margin: 4px 0;"><strong>Mot de passe temporaire :</strong> <code style="background: #f3f4f6; padding: 2px 8px; border-radius: 4px;">${tempPassword}</code></p>
71
+ </div>
72
+ <p>Connectez-vous et changez votre mot de passe dès que possible :</p>
73
+ <a href="${dashboardUrl}/login" style="display: inline-block; background: #4f46e5; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600;">Se connecter</a>
74
+ <p style="margin-top: 24px; font-size: 0.85rem; color: #6b7280;">
75
+ Si vous n'attendiez pas cet email, vous pouvez l'ignorer.
76
+ </p>
77
+ </div>
78
+ </body>
79
+ </html>`
80
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync, readFileSync } from 'fs'
2
+ import { resolve } from 'path'
3
+ import { useRuntimeConfig } from '#imports'
4
+
5
+ import type { DashboardConfig } from '../interfaces/project-config.interface'
6
+
7
+ let _cachedConfig: DashboardConfig | null = null
8
+
9
+ export function readProjectConfig(): DashboardConfig {
10
+ if (_cachedConfig) return _cachedConfig
11
+
12
+ const config = useRuntimeConfig()
13
+ const projectRoot = (config.projectRoot as string || '').trim() || process.cwd()
14
+
15
+ const candidates = [
16
+ resolve(projectRoot, 'i18n-dashboard.config.json'),
17
+ resolve(projectRoot, 'i18n-dashboard.config.js'),
18
+ resolve(process.cwd(), 'i18n-dashboard.config.json'),
19
+ resolve(process.cwd(), 'i18n-dashboard.config.js'),
20
+ ]
21
+
22
+ for (const filePath of candidates) {
23
+ if (existsSync(filePath)) {
24
+ try {
25
+ const raw = readFileSync(filePath, 'utf-8')
26
+ _cachedConfig = JSON.parse(raw)
27
+ console.log(`[i18n-dashboard] Config file loaded: ${filePath}`)
28
+ return _cachedConfig!
29
+ } catch (e) {
30
+ console.warn(`[i18n-dashboard] Failed to parse config file: ${filePath}`, e)
31
+ }
32
+ }
33
+ }
34
+
35
+ _cachedConfig = {}
36
+ return _cachedConfig
37
+ }