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