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.
- package/LICENSE +21 -0
- package/README.md +715 -0
- package/app.vue +8 -0
- package/assets/css/main.css +21 -0
- package/assets/locales/en.json +380 -0
- package/bin/cli.mjs +279 -0
- package/components/LinkedKeyPicker.vue +135 -0
- package/components/PathPicker.vue +153 -0
- package/components/PluralEditor.vue +295 -0
- package/components/ScanModal.vue +153 -0
- package/components/TranslationHistoryModal.vue +66 -0
- package/components/TranslationRow.vue +541 -0
- package/components/dashboard/WidgetConfigModal.vue +121 -0
- package/components/dashboard/WidgetGrid.vue +190 -0
- package/components/dashboard/WidgetPicker.vue +75 -0
- package/components/dashboard/widgets/ActivityWidget.vue +109 -0
- package/components/dashboard/widgets/LanguagesCoverageWidget.vue +104 -0
- package/components/dashboard/widgets/ProjectsWidget.vue +77 -0
- package/components/dashboard/widgets/ReviewWidget.vue +150 -0
- package/components/dashboard/widgets/StatWidget.vue +133 -0
- package/composables/useAuth.ts +72 -0
- package/composables/useConfig.ts +14 -0
- package/composables/useDashboard.ts +89 -0
- package/composables/useFormats.ts +100 -0
- package/composables/useKeys.ts +231 -0
- package/composables/useLanguages.ts +221 -0
- package/composables/useProfile.ts +76 -0
- package/composables/useProject.ts +180 -0
- package/composables/useReview.ts +94 -0
- package/composables/useSettings.ts +30 -0
- package/composables/useStats.ts +16 -0
- package/composables/useT.ts +38 -0
- package/composables/useUsers.ts +101 -0
- package/composables/useWidgetData.ts +50 -0
- package/consts/commons.const.ts +6 -0
- package/consts/dashboard.const.ts +94 -0
- package/consts/languages.const.ts +223 -0
- package/enums/commons.enum.ts +7 -0
- package/i18n-dashboard.config.example.js +40 -0
- package/interfaces/commons.interface.ts +23 -0
- package/interfaces/job.interface.ts +10 -0
- package/interfaces/key.interface.ts +39 -0
- package/interfaces/languages.interface.ts +23 -0
- package/interfaces/project.interface.ts +9 -0
- package/interfaces/scan.interface.ts +12 -0
- package/interfaces/settings.interface.ts +4 -0
- package/interfaces/stat.interface.ts +30 -0
- package/interfaces/translation.interface.ts +11 -0
- package/interfaces/user.interface.ts +24 -0
- package/layouts/auth.vue +5 -0
- package/layouts/default.vue +327 -0
- package/middleware/auth.global.ts +26 -0
- package/nuxt.config.ts +66 -0
- package/package.json +89 -0
- package/pages/index.vue +5 -0
- package/pages/login.vue +74 -0
- package/pages/onboarding.vue +563 -0
- package/pages/projects/[id]/formats/datetime.vue +240 -0
- package/pages/projects/[id]/formats/modifiers.vue +194 -0
- package/pages/projects/[id]/formats/number.vue +250 -0
- package/pages/projects/[id]/index.vue +182 -0
- package/pages/projects/[id]/languages.vue +537 -0
- package/pages/projects/[id]/review.vue +109 -0
- package/pages/projects/[id]/settings.vue +515 -0
- package/pages/projects/[id]/translations/[keyId].vue +642 -0
- package/pages/projects/[id]/translations/index.vue +250 -0
- package/pages/projects/[id]/users.vue +276 -0
- package/pages/projects/index.vue +334 -0
- package/pages/users/[id]/profile.vue +421 -0
- package/pages/users/index.vue +345 -0
- package/plugins/loading.client.ts +3 -0
- package/plugins/ui-i18n.ts +6 -0
- package/server/api/auth/login.post.ts +28 -0
- package/server/api/auth/logout.post.ts +7 -0
- package/server/api/auth/me.get.ts +11 -0
- package/server/api/auth/me.put.ts +31 -0
- package/server/api/auth/password.put.ts +27 -0
- package/server/api/auth/status.get.ts +16 -0
- package/server/api/config.get.ts +10 -0
- package/server/api/dashboard/layout.get.ts +18 -0
- package/server/api/dashboard/layout.post.ts +18 -0
- package/server/api/db-config.get.ts +44 -0
- package/server/api/db-config.post.ts +73 -0
- package/server/api/export.get.ts +64 -0
- package/server/api/formats/datetime/[id].delete.ts +8 -0
- package/server/api/formats/datetime/[id].put.ts +15 -0
- package/server/api/formats/datetime.get.ts +11 -0
- package/server/api/formats/datetime.post.ts +16 -0
- package/server/api/formats/modifiers/[id].delete.ts +8 -0
- package/server/api/formats/modifiers/[id].put.ts +10 -0
- package/server/api/formats/modifiers.get.ts +10 -0
- package/server/api/formats/modifiers.post.ts +14 -0
- package/server/api/formats/number/[id].delete.ts +8 -0
- package/server/api/formats/number/[id].put.ts +15 -0
- package/server/api/formats/number.get.ts +11 -0
- package/server/api/formats/number.post.ts +16 -0
- package/server/api/formats/snippet.get.ts +87 -0
- package/server/api/fs/browse.get.ts +50 -0
- package/server/api/history/[translationId].get.ts +13 -0
- package/server/api/keys/[id].delete.ts +14 -0
- package/server/api/keys/[id].get.ts +41 -0
- package/server/api/keys/[id].patch.ts +20 -0
- package/server/api/keys/index.get.ts +98 -0
- package/server/api/keys/index.post.ts +17 -0
- package/server/api/languages/[code].delete.ts +15 -0
- package/server/api/languages/[id].put.ts +24 -0
- package/server/api/languages/index.get.ts +13 -0
- package/server/api/languages/index.post.ts +42 -0
- package/server/api/onboarding.post.ts +56 -0
- package/server/api/profile.get.ts +81 -0
- package/server/api/project-snapshot.get.ts +73 -0
- package/server/api/project-snapshot.post.ts +160 -0
- package/server/api/projects/[id].delete.ts +13 -0
- package/server/api/projects/[id].put.ts +40 -0
- package/server/api/projects/index.get.ts +19 -0
- package/server/api/projects/index.post.ts +34 -0
- package/server/api/scan.post.ts +165 -0
- package/server/api/settings/index.get.ts +9 -0
- package/server/api/settings/index.post.ts +20 -0
- package/server/api/setup.post.ts +39 -0
- package/server/api/stats/global.get.ts +126 -0
- package/server/api/stats.get.ts +70 -0
- package/server/api/sync.post.ts +179 -0
- package/server/api/translate.post.ts +52 -0
- package/server/api/translations/batch-translate.post.ts +121 -0
- package/server/api/translations/bulk-status.post.ts +24 -0
- package/server/api/translations/index.post.ts +62 -0
- package/server/api/translations/job/[id].get.ts +23 -0
- package/server/api/translations/status.post.ts +30 -0
- package/server/api/translations/translate-all.post.ts +18 -0
- package/server/api/ui-locale.get.ts +39 -0
- package/server/api/users/[id]/profile.get.ts +107 -0
- package/server/api/users/[id]/roles.put.ts +67 -0
- package/server/api/users/[id].delete.ts +36 -0
- package/server/api/users/[id].put.ts +43 -0
- package/server/api/users/index.get.ts +49 -0
- package/server/api/users/index.post.ts +89 -0
- package/server/consts/auto-translate.const.ts +2 -0
- package/server/consts/commons.const.ts +10 -0
- package/server/consts/db.const.ts +3 -0
- package/server/consts/scanner.const.ts +4 -0
- package/server/consts/translation-job.const.ts +8 -0
- package/server/db/index.ts +672 -0
- package/server/enums/auth.enum.ts +5 -0
- package/server/enums/translation.enum.ts +6 -0
- package/server/interfaces/profile.interface.ts +48 -0
- package/server/interfaces/project-config.interface.ts +9 -0
- package/server/interfaces/scanner.interface.ts +18 -0
- package/server/interfaces/translation-job.interface.ts +13 -0
- package/server/middleware/auth.ts +32 -0
- package/server/plugins/db.ts +6 -0
- package/server/routes/locale/[lang].get.ts +179 -0
- package/server/types/auth.type.ts +3 -0
- package/server/utils/auth.util.ts +89 -0
- package/server/utils/auto-translate.util.ts +112 -0
- package/server/utils/lang-api.util.ts +24 -0
- package/server/utils/mailer.util.ts +80 -0
- package/server/utils/project-config.util.ts +37 -0
- package/server/utils/scanner.uti.ts +307 -0
- package/server/utils/translation-job.util.ts +142 -0
- package/services/auth.service.ts +31 -0
- package/services/base.service.ts +140 -0
- package/services/job.service.ts +10 -0
- package/services/key.service.ts +26 -0
- package/services/language.service.ts +26 -0
- package/services/profile.service.ts +14 -0
- package/services/project.service.ts +23 -0
- package/services/scan.service.ts +14 -0
- package/services/settings.service.ts +14 -0
- package/services/stats.service.ts +11 -0
- package/services/translation.service.ts +36 -0
- package/services/user.service.ts +28 -0
- package/tsconfig.json +3 -0
- package/types/commons.type.ts +3 -0
- package/types/dashboard.type.ts +26 -0
- 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
|
+
})
|