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,121 @@
1
+ import { translate } from '@vitalets/google-translate-api'
2
+ import { getDb } from '../../db/index'
3
+
4
+ // Auto-translate all missing translations for a given target language
5
+ export default defineEventHandler(async (event) => {
6
+ const body = await readBody(event)
7
+ const { project_id, target_language, source_language } = body
8
+
9
+ if (!project_id) throw createError({ statusCode: 400, message: 'project_id is required' })
10
+ if (!target_language) {
11
+ throw createError({ statusCode: 400, message: 'target_language is required' })
12
+ }
13
+
14
+ const db = getDb()
15
+
16
+ // Get all keys that have NO translation for target language
17
+ // but DO have a translation in source_language (or any language)
18
+ const keysWithoutTarget = await db('translation_keys as tk')
19
+ .leftJoin('translations as target_t', function () {
20
+ this.on('target_t.key_id', '=', 'tk.id')
21
+ .andOn('target_t.language_code', '=', db.raw('?', [target_language]))
22
+ })
23
+ .where('tk.project_id', Number(project_id))
24
+ .where(function () {
25
+ this.whereNull('target_t.id').orWhere('target_t.value', '').orWhereNull('target_t.value')
26
+ })
27
+ .select('tk.id', 'tk.key')
28
+
29
+ if (!keysWithoutTarget.length) {
30
+ return { translated: 0, skipped: 0, errors: 0, message: 'All translations already exist' }
31
+ }
32
+
33
+ // Get source translations (prefer source_language, fallback to any available)
34
+ const keyIds = keysWithoutTarget.map((k: any) => k.id)
35
+
36
+ let sourceTranslations: any[]
37
+ if (source_language) {
38
+ sourceTranslations = await db('translations')
39
+ .whereIn('key_id', keyIds)
40
+ .where('language_code', source_language)
41
+ .whereNotNull('value')
42
+ .where('value', '!=', '')
43
+ .select('key_id', 'value', 'language_code')
44
+ } else {
45
+ // Pick any available translation per key (first one found)
46
+ sourceTranslations = await db('translations')
47
+ .whereIn('key_id', keyIds)
48
+ .whereNotNull('value')
49
+ .where('value', '!=', '')
50
+ .select('key_id', 'value', 'language_code')
51
+ .orderBy('key_id', 'asc')
52
+ }
53
+
54
+ // Build map: key_id → { value, language }
55
+ const sourceMap = new Map<number, { value: string; lang: string }>()
56
+ for (const t of sourceTranslations) {
57
+ if (!sourceMap.has(t.key_id)) {
58
+ sourceMap.set(t.key_id, { value: t.value, lang: t.language_code })
59
+ }
60
+ }
61
+
62
+ let translated = 0
63
+ let skipped = 0
64
+ let errors = 0
65
+
66
+ for (const key of keysWithoutTarget) {
67
+ const source = sourceMap.get(key.id)
68
+ if (!source) {
69
+ skipped++
70
+ continue
71
+ }
72
+
73
+ try {
74
+ const result = await translate(source.value, {
75
+ from: source.lang,
76
+ to: target_language,
77
+ })
78
+
79
+ const translatedText = result.text
80
+
81
+ const existing = await db('translations')
82
+ .where({ key_id: key.id, language_code: target_language })
83
+ .first()
84
+
85
+ if (existing) {
86
+ await db('translation_history').insert({
87
+ translation_id: existing.id,
88
+ old_value: existing.value,
89
+ new_value: translatedText,
90
+ changed_by: 'google-translate',
91
+ })
92
+ await db('translations')
93
+ .where({ id: existing.id })
94
+ .update({ value: translatedText, status: 'draft', updated_at: db.fn.now() })
95
+ } else {
96
+ const [id] = await db('translations').insert({
97
+ key_id: key.id,
98
+ language_code: target_language,
99
+ value: translatedText,
100
+ status: 'draft',
101
+ })
102
+ await db('translation_history').insert({
103
+ translation_id: id,
104
+ old_value: null,
105
+ new_value: translatedText,
106
+ changed_by: 'google-translate',
107
+ })
108
+ }
109
+
110
+ translated++
111
+
112
+ // Small delay to be polite to the free API
113
+ await new Promise((r) => setTimeout(r, 200))
114
+ } catch (e: any) {
115
+ errors++
116
+ console.error(`[batch-translate] Failed for key "${key.key}":`, e.message)
117
+ }
118
+ }
119
+
120
+ return { translated, skipped, errors, total: keysWithoutTarget.length }
121
+ })
@@ -0,0 +1,24 @@
1
+ import { getDb } from '../../db/index'
2
+ import { TRANSLATION_STATUS } from '../../enums/translation.enum'
3
+
4
+ // Bulk update status for multiple translations by their IDs
5
+ export default defineEventHandler(async (event) => {
6
+ const body = await readBody(event)
7
+ const { ids, status } = body
8
+
9
+ if (!ids?.length || !status) {
10
+ throw createError({ statusCode: 400, message: 'ids and status are required' })
11
+ }
12
+
13
+ const validStatuses = [TRANSLATION_STATUS.DRAFT, TRANSLATION_STATUS.REVIEWED, TRANSLATION_STATUS.APPROVED]
14
+ if (!validStatuses.includes(status)) {
15
+ throw createError({ statusCode: 400, message: `Status must be one of: ${validStatuses.join(', ')}` })
16
+ }
17
+
18
+ const db = getDb()
19
+ await db('translations')
20
+ .whereIn('id', ids)
21
+ .update({ status, updated_at: db.fn.now() })
22
+
23
+ return { updated: ids.length }
24
+ })
@@ -0,0 +1,62 @@
1
+ import { getDb } from '../../db/index'
2
+ import { TRANSLATION_STATUS } from '../../enums/translation.enum'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const body = await readBody(event)
6
+ const { key_id, language_code, value, status } = body
7
+
8
+ if (!key_id || !language_code) {
9
+ throw createError({ statusCode: 400, message: 'key_id and language_code are required' })
10
+ }
11
+
12
+ const db = getDb()
13
+
14
+ const key = await db('translation_keys').where({ id: Number(key_id) }).first()
15
+ if (!key) throw createError({ statusCode: 404, message: 'Translation key not found' })
16
+
17
+ const lang = await db('languages').where({ code: language_code }).first()
18
+ if (!lang) throw createError({ statusCode: 404, message: 'Language not found' })
19
+
20
+ const existing = await db('translations').where({ key_id: Number(key_id), language_code }).first()
21
+
22
+ if (existing) {
23
+ const oldValue = existing.value
24
+ const updates: Record<string, any> = { updated_at: db.fn.now() }
25
+
26
+ if (value !== undefined) updates.value = value
27
+ if (status !== undefined) updates.status = status
28
+ // Editing a value resets status to draft unless explicitly set
29
+ if (value !== undefined && value !== oldValue && status === undefined) {
30
+ updates.status = TRANSLATION_STATUS.DRAFT
31
+ }
32
+
33
+ if (oldValue !== value && value !== undefined) {
34
+ await db('translation_history').insert({
35
+ translation_id: existing.id,
36
+ old_value: oldValue,
37
+ new_value: value,
38
+ changed_by: 'user',
39
+ })
40
+ }
41
+
42
+ await db('translations').where({ id: existing.id }).update(updates)
43
+ return db('translations').where({ id: existing.id }).first()
44
+ }
45
+
46
+ // Create new translation
47
+ const [id] = await db('translations').insert({
48
+ key_id: Number(key_id),
49
+ language_code,
50
+ value,
51
+ status: status || TRANSLATION_STATUS.DRAFT,
52
+ })
53
+
54
+ await db('translation_history').insert({
55
+ translation_id: id,
56
+ old_value: null,
57
+ new_value: value,
58
+ changed_by: 'user',
59
+ })
60
+
61
+ return db('translations').where({ id }).first()
62
+ })
@@ -0,0 +1,23 @@
1
+ import { getJob } from '../../../utils/translation-job.util'
2
+
3
+ export default defineEventHandler((event) => {
4
+ const id = getRouterParam(event, 'id') || ''
5
+ const job = getJob(id)
6
+
7
+ if (!job) {
8
+ throw createError({ statusCode: 404, message: 'Job not found' })
9
+ }
10
+
11
+ const percent = job.total > 0 ? Math.round((job.done / job.total) * 100) : 0
12
+
13
+ return {
14
+ id: job.id,
15
+ status: job.status,
16
+ languageCode: job.languageCode,
17
+ languageName: job.languageName,
18
+ total: job.total,
19
+ done: job.done,
20
+ errors: job.errors,
21
+ percent,
22
+ }
23
+ })
@@ -0,0 +1,30 @@
1
+ import { getDb } from '../../db/index'
2
+ import { TRANSLATION_STATUS } from '../../enums/translation.enum'
3
+
4
+ // Update only the status of a translation (Draft → Reviewed → Approved)
5
+ export default defineEventHandler(async (event) => {
6
+ const body = await readBody(event)
7
+ const { key_id, language_code, status } = body
8
+
9
+ if (!key_id || !language_code || !status) {
10
+ throw createError({ statusCode: 400, message: 'key_id, language_code and status are required' })
11
+ }
12
+
13
+ const validStatuses = Object.values(TRANSLATION_STATUS)
14
+ if (!validStatuses.includes(status)) {
15
+ throw createError({ statusCode: 400, message: `Status must be one of: ${validStatuses.join(', ')}` })
16
+ }
17
+
18
+ const db = getDb()
19
+
20
+ const existing = await db('translations').where({ key_id: Number(key_id), language_code }).first()
21
+ if (!existing) {
22
+ throw createError({ statusCode: 404, message: 'Translation not found' })
23
+ }
24
+
25
+ await db('translations')
26
+ .where({ id: existing.id })
27
+ .update({ status, updated_at: db.fn.now() })
28
+
29
+ return db('translations').where({ id: existing.id }).first()
30
+ })
@@ -0,0 +1,18 @@
1
+ import { getDb } from '../../db/index'
2
+ import { createJob, runTranslationJob } from '../../utils/translation-job.util'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const { project_id, language_code, language_name } = await readBody(event)
6
+
7
+ if (!project_id || !language_code) {
8
+ throw createError({ statusCode: 400, message: 'project_id and language_code are required' })
9
+ }
10
+
11
+ const db = getDb()
12
+ const job = createJob(Number(project_id), language_code, language_name || language_code)
13
+
14
+ // Fire-and-forget — client polls for progress
15
+ setImmediate(() => runTranslationJob(db, job))
16
+
17
+ return { jobId: job.id, total: job.total }
18
+ })
@@ -0,0 +1,39 @@
1
+ import { getDb } from '../db/index'
2
+
3
+ // Public endpoint — returns Dashboard UI translations as a flat key→value JSON
4
+ export default defineEventHandler(async (event) => {
5
+ const { lang = 'en' } = getQuery(event)
6
+
7
+ const db = getDb()
8
+ const systemProject = await db('projects').where({ is_system: true }).first()
9
+ if (!systemProject) return {}
10
+
11
+ const keys = await db('translation_keys')
12
+ .where({ project_id: systemProject.id })
13
+ .select('id', 'key')
14
+
15
+ const keyIds = keys.map((k: any) => k.id)
16
+ if (!keyIds.length) return {}
17
+
18
+ // Prefer requested lang, fallback to en then fr
19
+ const translations = await db('translations')
20
+ .whereIn('key_id', keyIds)
21
+ .whereIn('language_code', [lang as string, 'en', 'fr'])
22
+ .select('key_id', 'language_code', 'value')
23
+
24
+ // Build map: key_id → { lang: value, en: value, fr: value }
25
+ const byKeyId: Record<number, Record<string, string>> = {}
26
+ for (const tr of translations) {
27
+ if (!byKeyId[tr.key_id]) byKeyId[tr.key_id] = {}
28
+ byKeyId[tr.key_id][tr.language_code] = tr.value
29
+ }
30
+
31
+ const result: Record<string, string> = {}
32
+ for (const k of keys) {
33
+ const langMap = byKeyId[k.id] || {}
34
+ const value = langMap[lang as string] || langMap['en'] || langMap['fr']
35
+ if (value) result[k.key] = value
36
+ }
37
+
38
+ return result
39
+ })
@@ -0,0 +1,107 @@
1
+ import { getDb } from '../../../db/index'
2
+ import { getUserRole } from '../../../utils/auth.util'
3
+ import type { UserProfile } from '../../../interfaces/profile.interface'
4
+
5
+ export default defineEventHandler(async (event): Promise<UserProfile> => {
6
+ const currentUser = event.context.user
7
+ const targetId = Number(getRouterParam(event, 'id'))
8
+ const db = getDb()
9
+
10
+ const target = await db('users')
11
+ .where({ id: targetId })
12
+ .select('id', 'name', 'email', 'is_super_admin', 'is_active', 'last_login_at', 'created_at')
13
+ .first()
14
+
15
+ if (!target) throw createError({ statusCode: 404, message: 'Utilisateur non trouvé' })
16
+
17
+ // Access: own profile, super admin, or admin in a shared project
18
+ const isSelf = currentUser.id === targetId
19
+ if (!isSelf && !currentUser.is_super_admin) {
20
+ const targetProjectIds: number[] = await db('user_project_roles')
21
+ .where({ user_id: targetId })
22
+ .whereNotNull('project_id')
23
+ .pluck('project_id')
24
+
25
+ let hasAccess = false
26
+ for (const pid of targetProjectIds) {
27
+ const role = await getUserRole(currentUser.id, pid)
28
+ if (role === 'admin') { hasAccess = true; break }
29
+ }
30
+ if (!hasAccess) throw createError({ statusCode: 403, message: 'Accès refusé' })
31
+ }
32
+
33
+ // ── Roles ──────────────────────────────────────────────────────────────────
34
+ const roles = await db('user_project_roles as upr')
35
+ .leftJoin('projects as p', 'p.id', 'upr.project_id')
36
+ .where('upr.user_id', targetId)
37
+ .select('upr.role', 'upr.project_id', 'p.name as project_name', 'p.color as project_color')
38
+
39
+ // ── Languages ──────────────────────────────────────────────────────────────
40
+ const projectIds = roles.filter((r: any) => r.project_id).map((r: any) => r.project_id)
41
+
42
+ let languages: any[]
43
+ if (target.is_super_admin) {
44
+ languages = await db('languages as l')
45
+ .join('projects as p', 'p.id', 'l.project_id')
46
+ .where('p.is_system', false)
47
+ .select('l.*', 'p.name as project_name', 'p.color as project_color')
48
+ } else if (projectIds.length) {
49
+ languages = await db('languages as l')
50
+ .join('projects as p', 'p.id', 'l.project_id')
51
+ .whereIn('l.project_id', projectIds)
52
+ .where('p.is_system', false)
53
+ .select('l.*', 'p.name as project_name', 'p.color as project_color')
54
+ } else {
55
+ languages = []
56
+ }
57
+
58
+ // ── Stats ──────────────────────────────────────────────────────────────────
59
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
60
+ const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
61
+
62
+ const [countTotal, countWeek, countMonth] = await Promise.all([
63
+ db('translation_history').where('changed_by', target.name).count('id as n').first(),
64
+ db('translation_history').where('changed_by', target.name).where('changed_at', '>=', weekAgo).count('id as n').first(),
65
+ db('translation_history').where('changed_by', target.name).where('changed_at', '>=', monthAgo).count('id as n').first(),
66
+ ])
67
+
68
+ // ── Recent translations ────────────────────────────────────────────────────
69
+ const recentTranslations = await db('translation_history as th')
70
+ .join('translations as t', 't.id', 'th.translation_id')
71
+ .join('translation_keys as tk', 'tk.id', 't.key_id')
72
+ .join('projects as p', 'p.id', 'tk.project_id')
73
+ .where('th.changed_by', target.name)
74
+ .orderBy('th.changed_at', 'desc')
75
+ .limit(20)
76
+ .select(
77
+ 'th.id',
78
+ 'th.new_value',
79
+ 'th.old_value',
80
+ 'th.changed_at',
81
+ 'tk.key',
82
+ 'tk.id as key_id',
83
+ 'tk.project_id',
84
+ 't.language_code',
85
+ 'p.name as project_name',
86
+ 'p.color as project_color',
87
+ )
88
+
89
+ return {
90
+ user: {
91
+ id: target.id,
92
+ name: target.name,
93
+ email: target.email,
94
+ is_super_admin: !!target.is_super_admin,
95
+ last_login_at: target.last_login_at ?? null,
96
+ created_at: target.created_at,
97
+ },
98
+ roles,
99
+ stats: {
100
+ total: Number(countTotal?.n ?? 0),
101
+ thisWeek: Number(countWeek?.n ?? 0),
102
+ thisMonth: Number(countMonth?.n ?? 0),
103
+ },
104
+ languages,
105
+ recentTranslations,
106
+ }
107
+ })
@@ -0,0 +1,67 @@
1
+ import { getDb } from '../../../db/index'
2
+ import { getUserRole, canManageUsers } from '../../../utils/auth.util'
3
+
4
+ const VALID_ROLES = ['translator', 'moderator', 'admin']
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const currentUser = event.context.user
8
+ const targetId = Number(getRouterParam(event, 'id'))
9
+ const { roles } = await readBody(event)
10
+ // roles: Array<{ project_id: number | null, role: string | null }>
11
+ // role = null means remove access for that project
12
+
13
+ if (!Array.isArray(roles)) {
14
+ throw createError({ statusCode: 400, message: 'roles must be an array' })
15
+ }
16
+
17
+ const db = getDb()
18
+
19
+ const target = await db('users').where({ id: targetId }).first()
20
+ if (!target) throw createError({ statusCode: 404, message: 'User not found' })
21
+
22
+ if (target.is_super_admin && !currentUser.is_super_admin) {
23
+ throw createError({ statusCode: 403, message: 'Forbidden' })
24
+ }
25
+
26
+ for (const { project_id, role } of roles) {
27
+ // Validate role value
28
+ if (role !== null && !VALID_ROLES.includes(role)) {
29
+ throw createError({ statusCode: 400, message: `Invalid role: ${role}` })
30
+ }
31
+
32
+ // Permission check per entry
33
+ if (!currentUser.is_super_admin) {
34
+ if (project_id === null) {
35
+ throw createError({ statusCode: 403, message: 'Only super admins can set global access' })
36
+ }
37
+ const myRole = await getUserRole(currentUser.id, Number(project_id))
38
+ if (!canManageUsers(myRole, false)) {
39
+ throw createError({ statusCode: 403, message: 'Forbidden on this project' })
40
+ }
41
+ }
42
+
43
+ // Build the WHERE clause (NULL-safe)
44
+ const whereClause = (q: any) => {
45
+ q.where('user_id', targetId)
46
+ if (project_id === null) q.whereNull('project_id')
47
+ else q.where('project_id', project_id)
48
+ }
49
+
50
+ if (role === null) {
51
+ await db('user_project_roles').where(whereClause).delete()
52
+ } else {
53
+ const existing = await db('user_project_roles').where(whereClause).first()
54
+ if (existing) {
55
+ await db('user_project_roles').where(whereClause).update({ role })
56
+ } else {
57
+ await db('user_project_roles').insert({
58
+ user_id: targetId,
59
+ project_id: project_id ?? null,
60
+ role,
61
+ })
62
+ }
63
+ }
64
+ }
65
+
66
+ return { success: true }
67
+ })
@@ -0,0 +1,36 @@
1
+ import { getDb } from '../../db/index'
2
+ import { getUserRole, canManageUsers } from '../../utils/auth.util'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const currentUser = event.context.user
6
+ const targetId = Number(getRouterParam(event, 'id'))
7
+
8
+ if (targetId === currentUser.id) {
9
+ throw createError({ statusCode: 400, message: 'Vous ne pouvez pas supprimer votre propre compte' })
10
+ }
11
+
12
+ const db = getDb()
13
+ const target = await db('users').where({ id: targetId }).first()
14
+ if (!target) throw createError({ statusCode: 404, message: 'Utilisateur non trouvé' })
15
+
16
+ if (target.is_super_admin && !currentUser.is_super_admin) {
17
+ throw createError({ statusCode: 403, message: 'Accès refusé' })
18
+ }
19
+
20
+ if (!currentUser.is_super_admin) {
21
+ const { project_id } = getQuery(event)
22
+ if (!project_id) throw createError({ statusCode: 400, message: 'project_id requis' })
23
+ const role = await getUserRole(currentUser.id, Number(project_id))
24
+ if (!canManageUsers(role, false)) throw createError({ statusCode: 403, message: 'Accès refusé' })
25
+
26
+ // Project admin can only remove users from their project (not delete globally)
27
+ await db('user_project_roles')
28
+ .where({ user_id: targetId, project_id: Number(project_id) })
29
+ .delete()
30
+ return { success: true }
31
+ }
32
+
33
+ // Super admin deletes user globally
34
+ await db('users').where({ id: targetId }).delete()
35
+ return { success: true }
36
+ })
@@ -0,0 +1,43 @@
1
+ import { getDb } from '../../db/index'
2
+ import { getUserRole, canManageUsers } from '../../utils/auth.util'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const currentUser = event.context.user
6
+ const targetId = Number(getRouterParam(event, 'id'))
7
+ const body = await readBody(event)
8
+ const { name, is_active, role, project_id } = body
9
+
10
+ const db = getDb()
11
+
12
+ // Only super admin can edit super admins
13
+ const target = await db('users').where({ id: targetId }).first()
14
+ if (!target) throw createError({ statusCode: 404, message: 'Utilisateur non trouvé' })
15
+ if (target.is_super_admin && !currentUser.is_super_admin) {
16
+ throw createError({ statusCode: 403, message: 'Accès refusé' })
17
+ }
18
+
19
+ // Permission check
20
+ if (!currentUser.is_super_admin) {
21
+ if (!project_id) throw createError({ statusCode: 400, message: 'project_id requis' })
22
+ const userRole = await getUserRole(currentUser.id, Number(project_id))
23
+ if (!canManageUsers(userRole, false)) throw createError({ statusCode: 403, message: 'Accès refusé' })
24
+ }
25
+
26
+ // Update user fields
27
+ const updates: any = {}
28
+ if (name) updates.name = name.trim()
29
+ if (is_active !== undefined) updates.is_active = is_active
30
+
31
+ if (Object.keys(updates).length) {
32
+ await db('users').where({ id: targetId }).update(updates)
33
+ }
34
+
35
+ // Update role in project if provided
36
+ if (role && project_id !== undefined) {
37
+ await db('user_project_roles')
38
+ .where({ user_id: targetId, project_id: project_id || null })
39
+ .update({ role })
40
+ }
41
+
42
+ return { success: true }
43
+ })
@@ -0,0 +1,49 @@
1
+ import { getDb } from '../../db/index'
2
+ import { getUserRole, canManageUsers } from '../../utils/auth.util'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const currentUser = event.context.user
6
+ const { project_id } = getQuery(event)
7
+ const db = getDb()
8
+
9
+ // ── Project-scoped view ────────────────────────────────────────────────────
10
+ if (project_id) {
11
+ const pid = Number(project_id)
12
+
13
+ if (!currentUser.is_super_admin) {
14
+ const role = await getUserRole(currentUser.id, pid)
15
+ if (!canManageUsers(role, false)) {
16
+ throw createError({ statusCode: 403, message: 'Accès refusé' })
17
+ }
18
+ }
19
+
20
+ const projectRoles = await db('user_project_roles as r')
21
+ .join('users as u', 'r.user_id', 'u.id')
22
+ .where('r.project_id', pid)
23
+ .select('u.id', 'u.email', 'u.name', 'u.is_active', 'u.last_login_at', 'r.role')
24
+
25
+ return projectRoles
26
+ }
27
+
28
+ // ── Global view (super admin or global admins only) ────────────────────────
29
+ if (!currentUser.is_super_admin) {
30
+ const globalAdminRole = await db('user_project_roles')
31
+ .where({ user_id: currentUser.id, role: 'admin' })
32
+ .whereNull('project_id')
33
+ .first()
34
+ if (!globalAdminRole) throw createError({ statusCode: 403, message: 'Accès refusé' })
35
+ }
36
+
37
+ const users = await db('users')
38
+ .select('id', 'email', 'name', 'is_super_admin', 'is_active', 'last_login_at', 'created_at')
39
+ .orderBy('name')
40
+
41
+ const roles = await db('user_project_roles as r')
42
+ .leftJoin('projects as p', 'r.project_id', 'p.id')
43
+ .select('r.user_id', 'r.role', 'r.project_id', 'p.name as project_name', 'p.color as project_color')
44
+
45
+ return users.map((u: any) => ({
46
+ ...u,
47
+ roles: roles.filter((r: any) => r.user_id === u.id),
48
+ }))
49
+ })