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,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,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,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,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
|
+
}
|