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,345 @@
1
+ <template>
2
+ <div class="p-6">
3
+ <div class="flex items-center justify-between mb-6">
4
+ <div>
5
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('users.all_title', 'All users') }}</h1>
6
+ <p class="text-gray-500 dark:text-gray-400 mt-0.5 text-sm">{{ t('users.subtitle', 'Manage dashboard access') }}</p>
7
+ </div>
8
+ <UButton icon="i-heroicons-plus" @click="openAdd">{{ t('users.add', 'Add a user') }}</UButton>
9
+ </div>
10
+
11
+ <!-- Users table -->
12
+ <UCard>
13
+ <div v-if="pending" class="space-y-3">
14
+ <USkeleton v-for="i in 4" :key="i" class="h-12" />
15
+ </div>
16
+ <div v-else-if="!users.length" class="text-center py-12">
17
+ <UIcon name="i-heroicons-users" class="text-4xl text-gray-300 mb-2" />
18
+ <p class="text-gray-400">{{ t('users.none', 'No users') }}</p>
19
+ </div>
20
+ <div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
21
+ <div
22
+ v-for="user in users"
23
+ :key="user.id"
24
+ class="flex items-center justify-between py-3 gap-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg px-2 -mx-2 transition-colors"
25
+ @click="router.push(`/users/${user.id}/profile`)"
26
+ >
27
+ <div class="flex items-center gap-3 min-w-0">
28
+ <div class="w-9 h-9 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center shrink-0">
29
+ <span class="text-sm font-bold text-primary-600 dark:text-primary-400">
30
+ {{ user.name.charAt(0).toUpperCase() }}
31
+ </span>
32
+ </div>
33
+ <div class="min-w-0">
34
+ <div class="flex items-center gap-2">
35
+ <p class="font-medium text-gray-900 dark:text-white text-sm truncate">{{ user.name }}</p>
36
+ <UBadge v-if="user.is_super_admin" size="xs" color="error">Super Admin</UBadge>
37
+ <UBadge v-if="!user.is_active" size="xs" color="neutral" variant="outline">{{ t('users.inactive', 'Inactive') }}</UBadge>
38
+ </div>
39
+ <p class="text-xs text-gray-400 truncate">{{ user.email }}</p>
40
+ </div>
41
+ </div>
42
+
43
+ <!-- Roles summary -->
44
+ <div class="flex flex-wrap gap-1 flex-1 justify-center">
45
+ <template v-if="user.roles?.length">
46
+ <UBadge
47
+ v-for="r in user.roles.slice(0, 3)"
48
+ :key="`${r.project_id}-${r.role}`"
49
+ size="xs"
50
+ :color="roleColor(r.role)"
51
+ variant="soft"
52
+ >
53
+ {{ r.project_id === null ? t('users.global_access', 'Global') : r.project_name }} · {{ roleLabel(r.role) }}
54
+ </UBadge>
55
+ <UBadge v-if="user.roles.length > 3" size="xs" color="neutral" variant="soft">
56
+ +{{ user.roles.length - 3 }}
57
+ </UBadge>
58
+ </template>
59
+ <span v-else class="text-xs text-gray-400 italic">{{ t('users.no_role', 'No role') }}</span>
60
+ </div>
61
+
62
+ <div class="flex items-center gap-2 shrink-0" @click.stop>
63
+ <span class="text-xs text-gray-400">
64
+ {{ user.last_login_at ? formatRelative(user.last_login_at) : t('users.never_connected', 'Never logged in') }}
65
+ </span>
66
+ <UDropdownMenu :items="userActions(user)">
67
+ <UButton icon="i-heroicons-ellipsis-vertical" color="neutral" variant="ghost" size="xs" />
68
+ </UDropdownMenu>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </UCard>
73
+
74
+ <!-- Add user modal -->
75
+ <UModal v-model:open="showModal" :title="t('users.add_user_title', 'Add a user')">
76
+ <template #body>
77
+ <div class="space-y-4">
78
+ <div class="grid grid-cols-2 gap-4">
79
+ <UFormField :label="t('users.full_name', 'Full name')" required>
80
+ <UInput v-model="form.name" placeholder="Marie Dupont" class="w-full" />
81
+ </UFormField>
82
+ <UFormField :label="t('login.email', 'Email')" required>
83
+ <UInput v-model="form.email" type="email" placeholder="marie@example.com" class="w-full" />
84
+ </UFormField>
85
+ </div>
86
+
87
+ <UFormField :label="t('users.role_label', 'Role')" required>
88
+ <USelect v-model="form.role" :items="roleOptions" class="w-full" />
89
+ </UFormField>
90
+
91
+ <UFormField :label="t('users.project_access_label', 'Project access')">
92
+ <div class="space-y-2">
93
+ <UCheckbox v-model="form.global_access" :label="t('users.global_access_label', 'Global access (all projects)')" />
94
+ <div v-if="!form.global_access" class="space-y-1 pl-1">
95
+ <UCheckbox
96
+ v-for="p in projects"
97
+ :key="p.id"
98
+ :label="p.name"
99
+ :model-value="form.project_ids.includes(p.id)"
100
+ @update:model-value="toggleProject(p.id, $event)"
101
+ />
102
+ </div>
103
+ </div>
104
+ </UFormField>
105
+
106
+ <div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
107
+ <p class="text-xs text-blue-600 dark:text-blue-400">
108
+ <UIcon name="i-heroicons-information-circle" class="inline mr-1" />
109
+ {{ t('users.temp_password_info', 'A temporary password will be generated and shown below.') }}
110
+ </p>
111
+ </div>
112
+
113
+ <div v-if="createdTempPassword" class="bg-green-50 dark:bg-green-900/20 rounded-lg p-3">
114
+ <p class="text-xs text-green-700 dark:text-green-300 font-medium mb-1">
115
+ <UIcon name="i-heroicons-key" class="inline mr-1" />
116
+ {{ t('users.temp_password_label', 'Temporary password (copy it now):') }}
117
+ </p>
118
+ <div class="flex items-center gap-2">
119
+ <code class="text-sm font-mono text-green-800 dark:text-green-200 flex-1">{{ createdTempPassword }}</code>
120
+ <UButton size="xs" icon="i-heroicons-clipboard" color="neutral" variant="ghost" @click="copyTemp" />
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </template>
125
+ <template #footer>
126
+ <div class="flex justify-end gap-3">
127
+ <UButton color="neutral" variant="ghost" @click="closeModal">
128
+ {{ createdTempPassword ? t('common.close', 'Close') : t('common.cancel', 'Cancel') }}
129
+ </UButton>
130
+ <UButton v-if="!createdTempPassword" :loading="saving" @click="saveUser">{{ t('common.create', 'Create') }}</UButton>
131
+ </div>
132
+ </template>
133
+ </UModal>
134
+
135
+ <!-- Roles modal -->
136
+ <UModal v-model:open="showRolesModal" :title="t('users.manage_access_title', 'Manage access')">
137
+ <template #body>
138
+ <div class="space-y-4">
139
+ <div class="flex items-center gap-3 pb-4 border-b border-gray-100 dark:border-gray-800">
140
+ <div class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center shrink-0">
141
+ <span class="font-bold text-primary-600 dark:text-primary-400">
142
+ {{ rolesUser?.name.charAt(0).toUpperCase() }}
143
+ </span>
144
+ </div>
145
+ <div>
146
+ <p class="font-semibold text-gray-900 dark:text-white text-sm">{{ rolesUser?.name }}</p>
147
+ <p class="text-xs text-gray-400">{{ rolesUser?.email }}</p>
148
+ </div>
149
+ </div>
150
+
151
+ <div class="space-y-2">
152
+ <div
153
+ v-for="item in rolesForm"
154
+ :key="item.project_id ?? 'global'"
155
+ class="flex items-center justify-between gap-3"
156
+ >
157
+ <div class="flex items-center gap-2 min-w-0 flex-1">
158
+ <UIcon v-if="item.project_id === null" name="i-heroicons-globe-alt" class="text-gray-400 shrink-0" />
159
+ <span v-else class="w-2 h-2 rounded-full shrink-0" :class="`bg-${item.project_color || 'primary'}-500`" />
160
+ <span class="text-sm text-gray-700 dark:text-gray-300 truncate">{{ item.project_name }}</span>
161
+ </div>
162
+ <USelect v-model="item.role" :items="accessOptions" class="w-44 shrink-0" />
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </template>
167
+ <template #footer>
168
+ <div class="flex justify-end gap-3">
169
+ <UButton color="neutral" variant="ghost" @click="showRolesModal = false">{{ t('common.cancel', 'Cancel') }}</UButton>
170
+ <UButton :loading="rolesSaving" @click="saveRoles">{{ t('common.save', 'Save') }}</UButton>
171
+ </div>
172
+ </template>
173
+ </UModal>
174
+
175
+ <!-- Delete confirm -->
176
+ <UModal v-model:open="showDeleteConfirm" :title="t('users.delete_user_title', 'Delete user')">
177
+ <template #body>
178
+ <p class="text-gray-600 dark:text-gray-400">
179
+ {{ t('users.delete_confirm', 'Permanently delete') }} <strong>{{ deletingUser?.name }}</strong>?
180
+ {{ t('users.delete_warning', 'All their data will be deleted.') }}
181
+ </p>
182
+ <p class="text-red-500 text-sm mt-2 font-medium">{{ t('common.irreversible', 'This action is irreversible.') }}</p>
183
+ </template>
184
+ <template #footer>
185
+ <div class="flex justify-end gap-3">
186
+ <UButton color="neutral" variant="ghost" @click="showDeleteConfirm = false">{{ t('common.cancel', 'Cancel') }}</UButton>
187
+ <UButton color="error" :loading="deleting" @click="deleteUser">{{ t('common.delete', 'Delete') }}</UButton>
188
+ </div>
189
+ </template>
190
+ </UModal>
191
+ </div>
192
+ </template>
193
+
194
+ <script setup lang="ts">
195
+ const router = useRouter()
196
+ const toast = useToast()
197
+ const { currentUser } = useAuth()
198
+ const { t } = useT()
199
+
200
+ // Guard: super admin only
201
+ watch(() => currentUser.value?.is_super_admin, (ok) => { if (ok === false) navigateTo('/') }, { immediate: true })
202
+
203
+ const showModal = ref(false)
204
+ const showDeleteConfirm = ref(false)
205
+ const showRolesModal = ref(false)
206
+ const deletingUser = ref<any>(null)
207
+ const createdTempPassword = ref('')
208
+
209
+ const rolesUser = ref<any>(null)
210
+ const rolesForm = ref<Array<{
211
+ project_id: number | null
212
+ project_name: string
213
+ project_color: string | null
214
+ role: string
215
+ }>>([])
216
+
217
+ const form = ref({ name: '', email: '', role: 'translator', project_ids: [] as number[], global_access: false })
218
+
219
+ const roleOptions = computed(() => [
220
+ { label: t('users.role_translator', 'Translator'), value: 'translator' },
221
+ { label: t('users.role_moderator', 'Moderator'), value: 'moderator' },
222
+ { label: t('users.role_admin', 'Admin'), value: 'admin' },
223
+ ])
224
+
225
+ const accessOptions = computed(() => [
226
+ { label: t('users.no_access', 'No access'), value: 'none' },
227
+ { label: t('users.role_translator', 'Translator'), value: 'translator' },
228
+ { label: t('users.role_moderator', 'Moderator'), value: 'moderator' },
229
+ { label: t('users.role_admin', 'Admin'), value: 'admin' },
230
+ ])
231
+
232
+ const { projects } = useProject()
233
+ const { users, pending, saving, createUser, rolesSaving, updateRoles, toggleActive, deleting, deleteUser: doDeleteUser } = useUsers('global')
234
+
235
+ function roleLabel(role: string) {
236
+ return { translator: t('users.role_translator', 'Translator'), moderator: t('users.role_moderator', 'Moderator'), admin: t('users.role_admin', 'Admin') }[role] || role
237
+ }
238
+
239
+ function roleColor(role: string) {
240
+ return { translator: 'primary', moderator: 'warning', admin: 'success' }[role] || 'neutral'
241
+ }
242
+
243
+ function formatRelative(date: string) {
244
+ const diff = Date.now() - new Date(date).getTime()
245
+ const min = Math.floor(diff / 60000)
246
+ if (min < 1) return t('common.just_now', 'just now')
247
+ if (min < 60) return `${t('common.ago', 'ago')} ${min}min`
248
+ const h = Math.floor(min / 60)
249
+ if (h < 24) return `${t('common.ago', 'ago')} ${h}h`
250
+ return `${t('common.ago', 'ago')} ${Math.floor(h / 24)}d`
251
+ }
252
+
253
+ function toggleProject(id: number, checked: boolean) {
254
+ if (checked) form.value.project_ids = [...form.value.project_ids, id]
255
+ else form.value.project_ids = form.value.project_ids.filter((p) => p !== id)
256
+ }
257
+
258
+ function userActions(user: any) {
259
+ const isSelf = user.id === currentUser.value?.id
260
+ const canEdit = !user.is_super_admin && !isSelf
261
+
262
+ return [
263
+ [
264
+ ...(canEdit ? [{
265
+ label: t('users.manage_access', 'Manage access'),
266
+ icon: 'i-heroicons-shield-check',
267
+ onSelect: () => openRoles(user),
268
+ }] : []),
269
+ {
270
+ label: user.is_active ? t('users.deactivate', 'Deactivate') : t('users.reactivate', 'Reactivate'),
271
+ icon: user.is_active ? 'i-heroicons-pause' : 'i-heroicons-play',
272
+ onSelect: () => toggleActive(user),
273
+ },
274
+ ],
275
+ [
276
+ {
277
+ label: t('common.delete', 'Delete'),
278
+ icon: 'i-heroicons-trash',
279
+ color: 'error' as const,
280
+ onSelect: () => { deletingUser.value = user; showDeleteConfirm.value = true },
281
+ },
282
+ ],
283
+ ]
284
+ }
285
+
286
+ function openAdd() {
287
+ createdTempPassword.value = ''
288
+ form.value = { name: '', email: '', role: 'translator', project_ids: [], global_access: false }
289
+ showModal.value = true
290
+ }
291
+
292
+ function openRoles(user: any) {
293
+ rolesUser.value = user
294
+ const globalRole = user.roles?.find((r: any) => r.project_id === null)
295
+ rolesForm.value = [
296
+ {
297
+ project_id: null,
298
+ project_name: t('users.global_access_all', 'Global access (all projects)'),
299
+ project_color: null,
300
+ role: globalRole?.role ?? 'none',
301
+ },
302
+ ...projects.value.map((p: any) => {
303
+ const existing = user.roles?.find((r: any) => r.project_id === p.id)
304
+ return {
305
+ project_id: p.id,
306
+ project_name: p.name,
307
+ project_color: p.color ?? null,
308
+ role: existing?.role ?? 'none',
309
+ }
310
+ }),
311
+ ]
312
+ showRolesModal.value = true
313
+ }
314
+
315
+ function closeModal() {
316
+ showModal.value = false
317
+ createdTempPassword.value = ''
318
+ }
319
+
320
+ async function saveUser() {
321
+ if (!form.value.name || !form.value.email) return
322
+ const tempPassword = await createUser({ ...form.value })
323
+ if (tempPassword) createdTempPassword.value = tempPassword
324
+ }
325
+
326
+ async function saveRoles() {
327
+ const roles = rolesForm.value.map(({ project_id, role }) => ({
328
+ project_id,
329
+ role: role === 'none' ? null : role,
330
+ }))
331
+ const ok = await updateRoles(rolesUser.value.id, roles)
332
+ if (ok) showRolesModal.value = false
333
+ }
334
+
335
+ async function deleteUser() {
336
+ if (!deletingUser.value) return
337
+ const ok = await doDeleteUser(deletingUser.value.id)
338
+ if (ok) showDeleteConfirm.value = false
339
+ }
340
+
341
+ async function copyTemp() {
342
+ await navigator.clipboard.writeText(createdTempPassword.value)
343
+ toast.add({ title: t('common.copied', 'Copied!'), color: 'success' })
344
+ }
345
+ </script>
@@ -0,0 +1,3 @@
1
+ // NuxtLoadingIndicator is driven automatically by router navigation and useFetch.
2
+ // No additional setup needed.
3
+ export default defineNuxtPlugin(() => {})
@@ -0,0 +1,6 @@
1
+ // Loads Dashboard UI translations on app startup (runs on both server and client)
2
+ export default defineNuxtPlugin(async () => {
3
+ const { loadTranslations } = useT()
4
+ const cookie = useCookie('ui-lang', { default: () => 'en' })
5
+ await loadTranslations(cookie.value)
6
+ })
@@ -0,0 +1,28 @@
1
+ import bcrypt from 'bcryptjs'
2
+ import { getDb } from '../../db/index'
3
+ import { getSession } from '../../utils/auth.util'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const { email, password } = await readBody(event)
7
+
8
+ if (!email || !password) {
9
+ throw createError({ statusCode: 400, message: 'Email et mot de passe requis' })
10
+ }
11
+
12
+ const db = getDb()
13
+ const user = await db('users').where({ email: email.toLowerCase().trim(), is_active: true }).first()
14
+
15
+ if (!user || !(await bcrypt.compare(password, user.password_hash))) {
16
+ throw createError({ statusCode: 401, message: 'Email ou mot de passe incorrect' })
17
+ }
18
+
19
+ // Update last login
20
+ await db('users').where({ id: user.id }).update({ last_login_at: db.fn.now() })
21
+
22
+ // Set session
23
+ const session = await getSession(event)
24
+ await session.update({ userId: user.id })
25
+
26
+ const { password_hash, ...safeUser } = user
27
+ return safeUser
28
+ })
@@ -0,0 +1,7 @@
1
+ import { getSession } from '../../utils/auth.util'
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const session = await getSession(event)
5
+ await session.clear()
6
+ return { success: true }
7
+ })
@@ -0,0 +1,11 @@
1
+ import { getSession, getUserProfile } from '../../utils/auth.util'
2
+ import { getDb } from '../../db/index'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const session = await getSession(event)
6
+ const userId = (session.data as any).userId
7
+ if (!userId) return null
8
+
9
+ const profile = await getUserProfile(userId)
10
+ return profile
11
+ })
@@ -0,0 +1,31 @@
1
+ import { getDb } from '../../db/index'
2
+ import { getUserProfile } from '../../utils/auth.util'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const user = event.context.user
6
+ const { name, email } = await readBody(event)
7
+
8
+ if (!name?.trim() && !email?.trim()) {
9
+ throw createError({ statusCode: 400, message: 'At least one field (name or email) is required' })
10
+ }
11
+
12
+ const db = getDb()
13
+ const updates: Record<string, string> = {}
14
+
15
+ if (name?.trim()) {
16
+ updates.name = name.trim()
17
+ }
18
+
19
+ if (email?.trim()) {
20
+ const normalized = email.trim().toLowerCase()
21
+ const existing = await db('users').where({ email: normalized }).whereNot({ id: user.id }).first()
22
+ if (existing) {
23
+ throw createError({ statusCode: 409, message: 'This email is already in use' })
24
+ }
25
+ updates.email = normalized
26
+ }
27
+
28
+ await db('users').where({ id: user.id }).update(updates)
29
+
30
+ return getUserProfile(user.id)
31
+ })
@@ -0,0 +1,27 @@
1
+ import bcrypt from 'bcryptjs'
2
+ import { getDb } from '../../db/index'
3
+ import { requireAuth } from '../../utils/auth.util'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const user = await requireAuth(event)
7
+ const { current_password, new_password } = await readBody(event)
8
+
9
+ if (!current_password || !new_password) {
10
+ throw createError({ statusCode: 400, message: 'Mot de passe actuel et nouveau requis' })
11
+ }
12
+ if (new_password.length < 8) {
13
+ throw createError({ statusCode: 400, message: 'Le mot de passe doit contenir au moins 8 caractères' })
14
+ }
15
+
16
+ const db = getDb()
17
+ const fullUser = await db('users').where({ id: user.id }).first()
18
+
19
+ if (!(await bcrypt.compare(current_password, fullUser.password_hash))) {
20
+ throw createError({ statusCode: 401, message: 'Mot de passe actuel incorrect' })
21
+ }
22
+
23
+ const hash = await bcrypt.hash(new_password, 12)
24
+ await db('users').where({ id: user.id }).update({ password_hash: hash })
25
+
26
+ return { success: true }
27
+ })
@@ -0,0 +1,16 @@
1
+ import { getDb } from '../../db/index'
2
+ import { getSession } from '../../utils/auth.util'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const db = getDb()
6
+ const count = await db('users').count('* as count').first()
7
+ const hasUsers = Number((count as any)?.count || 0) > 0
8
+
9
+ const session = await getSession(event)
10
+ const isLoggedIn = !!(session.data as any).userId
11
+
12
+ const onboardingSetting = await db('settings').where({ key: 'onboarding_completed' }).first()
13
+ const onboardingCompleted = onboardingSetting?.value === 'true'
14
+
15
+ return { hasUsers, isLoggedIn, onboardingCompleted }
16
+ })
@@ -0,0 +1,10 @@
1
+ import { readProjectConfig } from '../utils/project-config.util'
2
+
3
+ export default defineEventHandler(() => {
4
+ const config = readProjectConfig()
5
+ return {
6
+ uiLanguages: config.uiLanguages || null,
7
+ defaultUiLanguage: config.defaultUiLanguage || null,
8
+ project: config.project || null,
9
+ }
10
+ })
@@ -0,0 +1,18 @@
1
+ import { requireAuth } from '../../utils/auth.util'
2
+ import { getDb } from '../../db/index'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const user = await requireAuth(event)
6
+ const db = getDb()
7
+
8
+ const settingKey = `dashboard_layout_${user.id}`
9
+ const row = await db('settings').where({ key: settingKey }).first()
10
+
11
+ if (!row || !row.value) return null
12
+
13
+ try {
14
+ return JSON.parse(row.value)
15
+ } catch {
16
+ return null
17
+ }
18
+ })
@@ -0,0 +1,18 @@
1
+ import { requireAuth } from '../../utils/auth.util'
2
+ import { getDb } from '../../db/index'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ const user = await requireAuth(event)
6
+ const body = await readBody(event)
7
+ const db = getDb()
8
+
9
+ const settingKey = `dashboard_layout_${user.id}`
10
+ const value = JSON.stringify(body)
11
+
12
+ await db('settings')
13
+ .insert({ key: settingKey, value, updated_at: db.fn.now() })
14
+ .onConflict('key')
15
+ .merge()
16
+
17
+ return { ok: true }
18
+ })
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync } from 'fs'
2
+ import { resolve } from 'path'
3
+
4
+ // Returns the current database configuration (read-only, no secrets)
5
+ // ?checkPath=... can be used to check if a specific SQLite file path exists
6
+ export default defineEventHandler((event) => {
7
+ const query = getQuery(event)
8
+ if (query.checkPath) {
9
+ const p = String(query.checkPath)
10
+ const resolved = p.startsWith('.') ? resolve(process.cwd(), p) : p
11
+ return { fileExists: existsSync(resolved) }
12
+ }
13
+ const config = useRuntimeConfig()
14
+
15
+ // Check for override file
16
+ const overridePath = resolve(process.cwd(), 'i18n-dashboard.db.json')
17
+ let override: Record<string, string> | null = null
18
+ if (existsSync(overridePath)) {
19
+ try { override = JSON.parse(readFileSync(overridePath, 'utf-8')) } catch { /* ignore */ }
20
+ }
21
+
22
+ const client = (override?.dbClient || config.dbClient as string) || 'better-sqlite3'
23
+
24
+ if (client === 'better-sqlite3' || client === 'sqlite3') {
25
+ const connection = (override?.dbConnection || config.dbConnection as string) || './i18n-dashboard.db'
26
+ const resolvedPath = connection.startsWith('.') ? resolve(process.cwd(), connection) : connection
27
+ return {
28
+ client,
29
+ type: 'sqlite',
30
+ connection,
31
+ fileExists: existsSync(resolvedPath),
32
+ }
33
+ }
34
+
35
+ return {
36
+ client,
37
+ type: client === 'pg' || client === 'postgresql' ? 'postgresql' : 'mysql',
38
+ host: (override?.dbHost || config.dbHost as string) || 'localhost',
39
+ port: (override?.dbPort || config.dbPort as string) || (client === 'mysql2' || client === 'mysql' ? '3306' : '5432'),
40
+ database: (override?.dbName || config.dbName as string) || 'i18n_dashboard',
41
+ user: (override?.dbUser || config.dbUser as string) || '',
42
+ // never expose password
43
+ }
44
+ })
@@ -0,0 +1,73 @@
1
+ import knex from 'knex'
2
+ import { existsSync, writeFileSync, mkdirSync } from 'fs'
3
+ import { resolve, dirname } from 'path'
4
+ import { resetDb, saveDbOverride, buildConnectionFromParams } from '../db/index'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const body = await readBody(event)
8
+ const { type, connection, host, port, user, password, database, createFile, testOnly } = body
9
+
10
+ // ── Create SQLite file only ──────────────────────────────────────────────────
11
+ if (createFile && type === 'sqlite') {
12
+ const filePath = connection || './i18n-dashboard.db'
13
+ const resolvedPath = filePath.startsWith('.') ? resolve(process.cwd(), filePath) : filePath
14
+ if (!existsSync(resolvedPath)) {
15
+ mkdirSync(dirname(resolvedPath), { recursive: true })
16
+ writeFileSync(resolvedPath, '')
17
+ }
18
+ return { success: true, fileExists: true }
19
+ }
20
+
21
+ // ── Build knex config from form values ──────────────────────────────────────
22
+ const dbClient = type === 'sqlite'
23
+ ? 'better-sqlite3'
24
+ : type === 'postgresql' ? 'pg' : 'mysql2'
25
+
26
+ const knexConfig = buildConnectionFromParams({
27
+ dbClient,
28
+ dbConnection: type === 'sqlite' ? connection : undefined,
29
+ dbHost: host,
30
+ dbPort: String(port || ''),
31
+ dbUser: user,
32
+ dbPassword: password,
33
+ dbName: database,
34
+ })
35
+
36
+ // ── Test connection ──────────────────────────────────────────────────────────
37
+ const testDb = knex(knexConfig)
38
+ try {
39
+ if (dbClient === 'better-sqlite3') {
40
+ testDb.raw('SELECT 1').toString() // synchronous check — knex will throw if path is invalid
41
+ await testDb.raw('SELECT 1')
42
+ } else {
43
+ await testDb.raw('SELECT 1')
44
+ }
45
+ } catch (e: any) {
46
+ await testDb.destroy().catch(() => {})
47
+ throw createError({ statusCode: 400, message: 'Connection failed: ' + (e.message || String(e)) })
48
+ }
49
+ await testDb.destroy()
50
+
51
+ // ── Test only — stop here, don't persist or reset ────────────────────────────
52
+ if (testOnly) {
53
+ return { success: true }
54
+ }
55
+
56
+ // ── Save override ────────────────────────────────────────────────────────────
57
+ const override: Record<string, string> = { dbClient }
58
+ if (type === 'sqlite') {
59
+ override.dbConnection = connection || './i18n-dashboard.db'
60
+ } else {
61
+ override.dbHost = host || 'localhost'
62
+ override.dbPort = String(port || (type === 'mysql' ? '3306' : '5432'))
63
+ override.dbUser = user || ''
64
+ override.dbPassword = password || ''
65
+ override.dbName = database || ''
66
+ }
67
+ saveDbOverride(override)
68
+
69
+ // ── Reset DB with new config ─────────────────────────────────────────────────
70
+ await resetDb(knexConfig)
71
+
72
+ return { success: true }
73
+ })