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 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-dashboard",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "description": "A web dashboard to manage vue-i18n translation keys with database persistence",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,9 +61,81 @@
61
61
  </UCard>
62
62
 
63
63
  <!-- Add user modal -->
64
- <UModal v-model:open="showModal" :title="t('users.add_user_title', 'Add a user')">
64
+ <UModal v-model:open="showModal" :title="addModalTitle">
65
65
  <template #body>
66
- <div class="space-y-4">
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-end gap-3">
105
- <UButton color="neutral" variant="ghost" @click="closeModal">
106
- {{ createdTempPassword ? t('common.close', 'Close') : t('common.cancel', 'Cancel') }}
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
- <UButton v-if="!createdTempPassword" :loading="saving" @click="saveUser">{{ t('common.create', 'Create') }}</UButton>
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')
@@ -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
  }