i18n-dashboard 0.6.5 → 0.7.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/README.md +16 -0
- package/assets/locales/en.json +7 -0
- package/package.json +1 -1
- package/pages/projects/[id]/users.vue +184 -6
- package/server/api/users/index.get.ts +23 -1
- package/services/user.service.ts +4 -0
package/README.md
CHANGED
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
|
|
55
55
|
### Users & Authentication
|
|
56
56
|
- **Role-based access** — `Super Admin`, `Admin`, `Moderator`, `Translator` — per-project assignments
|
|
57
|
+
- **User search when adding to a project** — search existing users by name or email, select one and choose their role directly; or switch to the creation form for a brand new account
|
|
58
|
+
- **User activity profile** — view translation statistics per user with a configurable time period (last 24h, 7d, 30d, 1 year, or since account creation)
|
|
57
59
|
- **Onboarding wizard** — guided setup on first launch
|
|
58
60
|
- **Multi-language UI** — the dashboard interface itself is translatable
|
|
59
61
|
|
|
@@ -62,6 +64,7 @@
|
|
|
62
64
|
- **Auto-migration** — schema is created and updated automatically on startup
|
|
63
65
|
- **REST API** — full API for all operations, consume locale JSON from your Vue app
|
|
64
66
|
- **CORS auto-detection** — multiple app URLs per project; all are checked for CORS on `/locale/[lang].json`
|
|
67
|
+
- **Global loading overlay** — a full-page loading screen prevents interaction before data is ready, including on direct page load (F5) for any route
|
|
65
68
|
- **Dark mode** — system preference + manual toggle
|
|
66
69
|
|
|
67
70
|
---
|
|
@@ -615,6 +618,19 @@ DELETE /api/formats/modifiers/:id
|
|
|
615
618
|
GET /api/formats/snippet?project_id=1 # Generate createI18n() config snippet
|
|
616
619
|
```
|
|
617
620
|
|
|
621
|
+
### Users
|
|
622
|
+
|
|
623
|
+
```http
|
|
624
|
+
GET /api/users # All users (super admin / global admin)
|
|
625
|
+
GET /api/users?project_id=1 # Members of a project
|
|
626
|
+
GET /api/users?exclude_project_id=1 # Users not yet in a project (for the add picker)
|
|
627
|
+
POST /api/users # Create user
|
|
628
|
+
PUT /api/users/:id # Update user (name, is_active, role)
|
|
629
|
+
PUT /api/users/:id/roles # Bulk-update role assignments across projects
|
|
630
|
+
DELETE /api/users/:id?project_id=1 # Remove user from project (or globally if super admin)
|
|
631
|
+
GET /api/users/:id/profile?period=30d # Full user profile with activity stats
|
|
632
|
+
```
|
|
633
|
+
|
|
618
634
|
### Settings
|
|
619
635
|
|
|
620
636
|
```http
|
package/assets/locales/en.json
CHANGED
|
@@ -201,6 +201,13 @@
|
|
|
201
201
|
"users.project_members": "Project members",
|
|
202
202
|
"users.none_in_project": "No members in this project",
|
|
203
203
|
"users.add_user_title": "Add a user",
|
|
204
|
+
"users.add_to_project_title": "Add a user to the project",
|
|
205
|
+
"users.add_to_project": "Add to project",
|
|
206
|
+
"users.create_new_user": "Create a new user",
|
|
207
|
+
"users.back_to_select": "Back",
|
|
208
|
+
"users.search_placeholder": "Search by name or email…",
|
|
209
|
+
"users.no_match": "No user matches your search",
|
|
210
|
+
"users.all_already_members": "All users are already members of this project",
|
|
204
211
|
"users.full_name": "Full name",
|
|
205
212
|
"users.role_label": "Role",
|
|
206
213
|
"users.project_label": "Project",
|
package/package.json
CHANGED
|
@@ -61,9 +61,81 @@
|
|
|
61
61
|
</UCard>
|
|
62
62
|
|
|
63
63
|
<!-- Add user modal -->
|
|
64
|
-
<UModal v-model:open="showModal" :title="
|
|
64
|
+
<UModal v-model:open="showModal" :title="addModalTitle">
|
|
65
65
|
<template #body>
|
|
66
|
-
|
|
66
|
+
|
|
67
|
+
<!-- ── Mode: select existing ──────────────────────────────────────── -->
|
|
68
|
+
<div v-if="addMode === 'select'" class="space-y-4">
|
|
69
|
+
<!-- Search -->
|
|
70
|
+
<UInput
|
|
71
|
+
v-model="search"
|
|
72
|
+
:placeholder="t('users.search_placeholder', 'Search by name or email…')"
|
|
73
|
+
icon="i-heroicons-magnifying-glass"
|
|
74
|
+
class="w-full"
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
<!-- User list -->
|
|
78
|
+
<div class="max-h-64 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
|
79
|
+
<div v-if="loadingAvailable" class="flex items-center justify-center py-8">
|
|
80
|
+
<UIcon name="i-heroicons-arrow-path" class="animate-spin text-primary-500 text-xl" />
|
|
81
|
+
</div>
|
|
82
|
+
<div v-else-if="!filteredAvailable.length" class="py-8 text-center text-sm text-gray-400">
|
|
83
|
+
<UIcon name="i-heroicons-users" class="text-2xl mb-1 block mx-auto text-gray-300" />
|
|
84
|
+
{{ search ? t('users.no_match', 'No user matches your search') : t('users.all_already_members', 'All users are already members of this project') }}
|
|
85
|
+
</div>
|
|
86
|
+
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
|
|
87
|
+
<button
|
|
88
|
+
v-for="u in filteredAvailable"
|
|
89
|
+
:key="u.id"
|
|
90
|
+
type="button"
|
|
91
|
+
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors text-left"
|
|
92
|
+
:class="selectedUser?.id === u.id ? 'bg-primary-50 dark:bg-primary-900/20' : ''"
|
|
93
|
+
@click="selectUser(u)"
|
|
94
|
+
>
|
|
95
|
+
<div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center shrink-0">
|
|
96
|
+
<span class="text-xs font-bold text-primary-600 dark:text-primary-400">{{ u.name.charAt(0).toUpperCase() }}</span>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="min-w-0 flex-1">
|
|
99
|
+
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ u.name }}</p>
|
|
100
|
+
<p class="text-xs text-gray-400 truncate">{{ u.email }}</p>
|
|
101
|
+
</div>
|
|
102
|
+
<UIcon v-if="selectedUser?.id === u.id" name="i-heroicons-check-circle" class="text-primary-500 shrink-0" />
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<!-- Role picker (shown when a user is selected) -->
|
|
108
|
+
<Transition name="slide-down">
|
|
109
|
+
<div v-if="selectedUser" class="rounded-lg border border-primary-200 dark:border-primary-800 bg-primary-50 dark:bg-primary-900/20 p-4 space-y-3">
|
|
110
|
+
<div class="flex items-center gap-3">
|
|
111
|
+
<div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center shrink-0">
|
|
112
|
+
<span class="text-xs font-bold text-primary-600">{{ selectedUser.name.charAt(0).toUpperCase() }}</span>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="min-w-0">
|
|
115
|
+
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ selectedUser.name }}</p>
|
|
116
|
+
<p class="text-xs text-gray-400">{{ selectedUser.email }}</p>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
<UFormField :label="t('users.role_label', 'Role')" required>
|
|
120
|
+
<USelect v-model="selectRole" :items="roleOptions" class="w-full" />
|
|
121
|
+
</UFormField>
|
|
122
|
+
</div>
|
|
123
|
+
</Transition>
|
|
124
|
+
|
|
125
|
+
<!-- Divider + create new -->
|
|
126
|
+
<div class="flex items-center gap-3">
|
|
127
|
+
<div class="flex-1 border-t border-gray-200 dark:border-gray-700" />
|
|
128
|
+
<span class="text-xs text-gray-400">{{ t('common.or', 'or') }}</span>
|
|
129
|
+
<div class="flex-1 border-t border-gray-200 dark:border-gray-700" />
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<UButton block color="neutral" variant="outline" icon="i-heroicons-user-plus" @click="switchToCreate">
|
|
133
|
+
{{ t('users.create_new_user', 'Create a new user') }}
|
|
134
|
+
</UButton>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<!-- ── Mode: create new ───────────────────────────────────────────── -->
|
|
138
|
+
<div v-else class="space-y-4">
|
|
67
139
|
<div class="grid grid-cols-2 gap-4">
|
|
68
140
|
<UFormField :label="t('users.full_name', 'Full name')" required>
|
|
69
141
|
<UInput v-model="form.name" placeholder="Marie Dupont" class="w-full" />
|
|
@@ -100,12 +172,39 @@
|
|
|
100
172
|
</div>
|
|
101
173
|
</div>
|
|
102
174
|
</template>
|
|
175
|
+
|
|
103
176
|
<template #footer>
|
|
104
|
-
<div class="flex justify-
|
|
105
|
-
|
|
106
|
-
|
|
177
|
+
<div class="flex items-center justify-between gap-3">
|
|
178
|
+
<!-- Back button in create mode -->
|
|
179
|
+
<UButton
|
|
180
|
+
v-if="addMode === 'create' && !createdTempPassword"
|
|
181
|
+
color="neutral"
|
|
182
|
+
variant="ghost"
|
|
183
|
+
icon="i-heroicons-arrow-left"
|
|
184
|
+
@click="addMode = 'select'"
|
|
185
|
+
>
|
|
186
|
+
{{ t('users.back_to_select', 'Back') }}
|
|
107
187
|
</UButton>
|
|
108
|
-
<
|
|
188
|
+
<div v-else class="flex-1" />
|
|
189
|
+
|
|
190
|
+
<div class="flex gap-3">
|
|
191
|
+
<UButton color="neutral" variant="ghost" @click="closeModal">
|
|
192
|
+
{{ createdTempPassword ? t('common.close', 'Close') : t('common.cancel', 'Cancel') }}
|
|
193
|
+
</UButton>
|
|
194
|
+
<!-- Select mode: add existing user -->
|
|
195
|
+
<UButton
|
|
196
|
+
v-if="addMode === 'select'"
|
|
197
|
+
:disabled="!selectedUser"
|
|
198
|
+
:loading="rolesSaving"
|
|
199
|
+
@click="addExistingUser"
|
|
200
|
+
>
|
|
201
|
+
{{ t('users.add_to_project', 'Add to project') }}
|
|
202
|
+
</UButton>
|
|
203
|
+
<!-- Create mode: create new user -->
|
|
204
|
+
<UButton v-else-if="!createdTempPassword" :loading="saving" @click="saveUser">
|
|
205
|
+
{{ t('common.create', 'Create') }}
|
|
206
|
+
</UButton>
|
|
207
|
+
</div>
|
|
109
208
|
</div>
|
|
110
209
|
</template>
|
|
111
210
|
</UModal>
|
|
@@ -157,6 +256,9 @@
|
|
|
157
256
|
</template>
|
|
158
257
|
|
|
159
258
|
<script setup lang="ts">
|
|
259
|
+
import { userService } from '~/services/user.service'
|
|
260
|
+
import type { UserItem } from '~/interfaces/user.interface'
|
|
261
|
+
|
|
160
262
|
const toast = useToast()
|
|
161
263
|
const { currentUser } = useAuth()
|
|
162
264
|
const { currentProject } = useProject()
|
|
@@ -165,6 +267,7 @@ const { t } = useT()
|
|
|
165
267
|
// Guard: requires project context
|
|
166
268
|
watch(currentProject, (p) => { if (!p) navigateTo('/projects') }, { immediate: true })
|
|
167
269
|
|
|
270
|
+
// ── Modal state ────────────────────────────────────────────────────────────────
|
|
168
271
|
const showModal = ref(false)
|
|
169
272
|
const showDeleteConfirm = ref(false)
|
|
170
273
|
const showRoleModal = ref(false)
|
|
@@ -173,8 +276,57 @@ const createdTempPassword = ref('')
|
|
|
173
276
|
const roleModalUser = ref<any>(null)
|
|
174
277
|
const roleModalValue = ref('translator')
|
|
175
278
|
|
|
279
|
+
// ── Add modal modes ────────────────────────────────────────────────────────────
|
|
280
|
+
const addMode = ref<'select' | 'create'>('select')
|
|
281
|
+
|
|
282
|
+
const addModalTitle = computed(() => {
|
|
283
|
+
if (addMode.value === 'create') return t('users.add_user_title', 'Add a user')
|
|
284
|
+
return t('users.add_to_project_title', 'Add a user to the project')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// ── Select mode: existing users ────────────────────────────────────────────────
|
|
288
|
+
const search = ref('')
|
|
289
|
+
const availableUsers = ref<UserItem[]>([])
|
|
290
|
+
const loadingAvailable = ref(false)
|
|
291
|
+
const selectedUser = ref<UserItem | null>(null)
|
|
292
|
+
const selectRole = ref('translator')
|
|
293
|
+
|
|
294
|
+
const filteredAvailable = computed(() => {
|
|
295
|
+
const q = search.value.toLowerCase().trim()
|
|
296
|
+
if (!q) return availableUsers.value
|
|
297
|
+
return availableUsers.value.filter(u =>
|
|
298
|
+
u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q),
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
async function loadAvailableUsers() {
|
|
303
|
+
if (!currentProject.value) return
|
|
304
|
+
loadingAvailable.value = true
|
|
305
|
+
try {
|
|
306
|
+
availableUsers.value = await userService.getAvailableUsers(currentProject.value.id)
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
availableUsers.value = []
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
loadingAvailable.value = false
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function selectUser(u: UserItem) {
|
|
317
|
+
selectedUser.value = selectedUser.value?.id === u.id ? null : u
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function switchToCreate() {
|
|
321
|
+
addMode.value = 'create'
|
|
322
|
+
form.value = { name: '', email: '', role: 'translator' }
|
|
323
|
+
createdTempPassword.value = ''
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Create mode: new user form ─────────────────────────────────────────────────
|
|
176
327
|
const form = ref({ name: '', email: '', role: 'translator' })
|
|
177
328
|
|
|
329
|
+
// ── Shared ─────────────────────────────────────────────────────────────────────
|
|
178
330
|
const roleOptions = computed(() => [
|
|
179
331
|
{ label: t('users.role_translator', 'Translator'), value: 'translator' },
|
|
180
332
|
{ label: t('users.role_moderator', 'Moderator'), value: 'moderator' },
|
|
@@ -228,9 +380,14 @@ function userActions(user: any) {
|
|
|
228
380
|
}
|
|
229
381
|
|
|
230
382
|
function openAdd() {
|
|
383
|
+
addMode.value = 'select'
|
|
384
|
+
search.value = ''
|
|
385
|
+
selectedUser.value = null
|
|
386
|
+
selectRole.value = 'translator'
|
|
231
387
|
createdTempPassword.value = ''
|
|
232
388
|
form.value = { name: '', email: '', role: 'translator' }
|
|
233
389
|
showModal.value = true
|
|
390
|
+
loadAvailableUsers()
|
|
234
391
|
}
|
|
235
392
|
|
|
236
393
|
function openRoleModal(user: any) {
|
|
@@ -242,6 +399,15 @@ function openRoleModal(user: any) {
|
|
|
242
399
|
function closeModal() {
|
|
243
400
|
showModal.value = false
|
|
244
401
|
createdTempPassword.value = ''
|
|
402
|
+
selectedUser.value = null
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function addExistingUser() {
|
|
406
|
+
if (!selectedUser.value || !currentProject.value) return
|
|
407
|
+
const ok = await updateRoles(selectedUser.value.id, [
|
|
408
|
+
{ project_id: currentProject.value.id, role: selectRole.value },
|
|
409
|
+
])
|
|
410
|
+
if (ok) closeModal()
|
|
245
411
|
}
|
|
246
412
|
|
|
247
413
|
async function saveUser() {
|
|
@@ -274,3 +440,15 @@ async function copyTemp() {
|
|
|
274
440
|
toast.add({ title: t('common.copied', 'Copied!'), color: 'success' })
|
|
275
441
|
}
|
|
276
442
|
</script>
|
|
443
|
+
|
|
444
|
+
<style scoped>
|
|
445
|
+
.slide-down-enter-active,
|
|
446
|
+
.slide-down-leave-active {
|
|
447
|
+
transition: all 0.2s ease;
|
|
448
|
+
}
|
|
449
|
+
.slide-down-enter-from,
|
|
450
|
+
.slide-down-leave-to {
|
|
451
|
+
opacity: 0;
|
|
452
|
+
transform: translateY(-8px);
|
|
453
|
+
}
|
|
454
|
+
</style>
|
|
@@ -3,7 +3,7 @@ import { getUserRole, canManageUsers } from '../../utils/auth.util'
|
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event) => {
|
|
5
5
|
const currentUser = event.context.user
|
|
6
|
-
const { project_id } = getQuery(event)
|
|
6
|
+
const { project_id, exclude_project_id } = getQuery(event)
|
|
7
7
|
const db = getDb()
|
|
8
8
|
|
|
9
9
|
// ── Project-scoped view ────────────────────────────────────────────────────
|
|
@@ -25,6 +25,28 @@ export default defineEventHandler(async (event) => {
|
|
|
25
25
|
return projectRoles
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// ── Users not yet in a given project (for "add existing user" picker) ──────
|
|
29
|
+
if (exclude_project_id) {
|
|
30
|
+
const pid = Number(exclude_project_id)
|
|
31
|
+
|
|
32
|
+
if (!currentUser.is_super_admin) {
|
|
33
|
+
const role = await getUserRole(currentUser.id, pid)
|
|
34
|
+
if (!canManageUsers(role, false)) {
|
|
35
|
+
throw createError({ statusCode: 403, message: 'Accès refusé' })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const alreadyIn = await db('user_project_roles').where('project_id', pid).pluck('user_id')
|
|
40
|
+
|
|
41
|
+
const query = db('users')
|
|
42
|
+
.select('id', 'email', 'name', 'is_active', 'last_login_at')
|
|
43
|
+
.orderBy('name')
|
|
44
|
+
|
|
45
|
+
if (alreadyIn.length) query.whereNotIn('id', alreadyIn)
|
|
46
|
+
|
|
47
|
+
return query
|
|
48
|
+
}
|
|
49
|
+
|
|
28
50
|
// ── Global view (super admin or global admins only) ────────────────────────
|
|
29
51
|
if (!currentUser.is_super_admin) {
|
|
30
52
|
const globalAdminRole = await db('user_project_roles')
|
package/services/user.service.ts
CHANGED
|
@@ -6,6 +6,10 @@ class UserService extends BaseService {
|
|
|
6
6
|
return this.get<UserItem[]>('/api/users', { query })
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
async getAvailableUsers(excludeProjectId: number): Promise<UserItem[]> {
|
|
10
|
+
return this.get<UserItem[]>('/api/users', { query: { exclude_project_id: excludeProjectId }, skipErrorToast: true })
|
|
11
|
+
}
|
|
12
|
+
|
|
9
13
|
async create(data: CreateUserPayload): Promise<{ id: number; tempPassword: string; email: string; name: string }> {
|
|
10
14
|
return this.post('/api/users', { body: data, skipDedup: true })
|
|
11
15
|
}
|