Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion ui/src/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ export default {
'delegate.resourceDnHint': 'LDAP DN of the subtree (e.g. ou=project,dc=acme,dc=com)',
'user.deleteConfirmBefore': 'Are you sure you want to delete ',
'user.deleteConfirmAfter': '?',
// Chantier D4 — multi-email input (v-combobox)
'user.emailHint': 'Press Enter or Tab to confirm each email',
// Chantier D2 (rattrapage) — fragments wrapping the bulk-delete count
// in bold red.
'common.bulkDeleteConfirmBefore': 'Are you sure you want to delete ',
'common.bulkDeleteConfirmAfter': ' items? This cannot be undone.',
// Chantier D2 — sensitive confirmations split in two fragments so the
// login can be wrapped in bold red between them. The monolithic
// `user.<action>Confirm` keys stay on the host side for any other
// consumer; we just stop relying on them here.
'user.lockConfirmBefore': 'Lock user ',
'user.lockConfirmAfter': '? They will no longer be able to log in.',
'user.unlockConfirmBefore': 'Unlock user ',
'user.unlockConfirmAfter': '? They will be able to log in again.',
'user.isolateConfirmBefore': 'Isolate user ',
'user.isolateConfirmAfter': '? This will remove all group memberships.',
'user.restoreConfirmBefore': 'Restore user ',
'user.restoreConfirmAfter': '?',
'user.resetPasswordConfirmBefore': 'Reset password for user ',
'user.resetPasswordConfirmAfter': '? A new password will be sent.',
'group.deleteConfirmBefore': 'Are you sure you want to delete ',
'group.deleteConfirmAfter': '?',
'company.deleteConfirmBefore': 'Are you sure you want to delete ',
Expand All @@ -53,7 +73,9 @@ export default {
'id.group.add': 'Add',
'id.group.addedToast': 'Added {user} to {group}',
'id.group.removeTitle': 'Remove member',
'id.group.removeConfirm': 'Remove {user} from group {group}?',
// Chantier D2 — fragments wrapping the user identifier in bold red.
'id.group.removeConfirmBefore': 'Remove ',
'id.group.removeConfirmAfter': ' from group {group}?',
'id.group.removedToast': 'Removed {user} from {group}',
'id.group.transitive': 'Indirect member through a sub-group — manage them on the parent.',
}
24 changes: 23 additions & 1 deletion ui/src/i18n/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ export default {
'delegate.resourceDnHint': 'DN LDAP du sous-arbre (ex. ou=project,dc=acme,dc=com)',
'user.deleteConfirmBefore': 'Êtes-vous certain de supprimer ',
'user.deleteConfirmAfter': ' ?',
// Chantier D4 — saisie multi-email (v-combobox)
'user.emailHint': 'Appuyez sur Entrée ou Tab pour valider chaque email',
// Chantier D2 (rattrapage) — fragments encadrant le nombre d'éléments
// en gras-rouge pour la suppression en masse.
'common.bulkDeleteConfirmBefore': 'Supprimer ',
'common.bulkDeleteConfirmAfter': ' éléments ? Cette action est irréversible.',
// Chantier D2 — confirmations sensibles découpées en deux fragments
// pour insérer l'identifiant en gras-rouge entre eux. Les clés
// monolithiques `user.<action>Confirm` restent côté host pour
// d'éventuels autres usages, on les surcharge plus.
'user.lockConfirmBefore': 'Verrouiller l\'utilisateur ',
'user.lockConfirmAfter': ' ? Il ne pourra plus se connecter.',
'user.unlockConfirmBefore': 'Déverrouiller l\'utilisateur ',
'user.unlockConfirmAfter': ' ? Il pourra à nouveau se connecter.',
'user.isolateConfirmBefore': 'Isoler l\'utilisateur ',
'user.isolateConfirmAfter': ' ? Cela supprimera toutes ses appartenances aux groupes.',
'user.restoreConfirmBefore': 'Restaurer l\'utilisateur ',
'user.restoreConfirmAfter': ' ?',
'user.resetPasswordConfirmBefore': 'Réinitialiser le mot de passe de l\'utilisateur ',
'user.resetPasswordConfirmAfter': ' ? Un nouveau mot de passe lui sera envoyé.',
'group.deleteConfirmBefore': 'Êtes-vous certain de supprimer ',
'group.deleteConfirmAfter': ' ?',
'company.deleteConfirmBefore': 'Êtes-vous certain de supprimer ',
Expand All @@ -45,7 +65,9 @@ export default {
'id.group.add': 'Ajouter',
'id.group.addedToast': '{user} ajouté à {group}',
'id.group.removeTitle': 'Retirer un membre',
'id.group.removeConfirm': 'Retirer {user} du groupe {group} ?',
// Chantier D2 — fragments encadrant l'identifiant utilisateur en gras-rouge.
'id.group.removeConfirmBefore': 'Retirer ',
'id.group.removeConfirmAfter': ' du groupe {group} ?',
'id.group.removedToast': '{user} retiré de {group}',
'id.group.transitive': 'Membre indirect via un sous-groupe — à gérer depuis le groupe parent.',
}
8 changes: 6 additions & 2 deletions ui/src/views/GroupMembersView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,19 @@
</LigojDataTableServer>
</template>

<!-- Chantier D2: the user being removed is rendered in bold red
via the default slot so the action carries the same visual
weight as the delete dialogs across the rest of the screen. -->
<LigojConfirmDialog
v-model="removeDialog"
:title="t('id.group.removeTitle')"
:message="t('id.group.removeConfirm', { user: removeTarget?.id || '', group: groupName || '' })"
:confirm-label="t('common.remove')"
confirm-color="error"
:loading="removing"
@confirm="confirmRemove"
/>
>
{{ t('id.group.removeConfirmBefore') }}<strong class="text-error">{{ removeTarget?.id }}</strong>{{ t('id.group.removeConfirmAfter', { group: groupName }) }}
</LigojConfirmDialog>
</div>
</template>

Expand Down
46 changes: 38 additions & 8 deletions ui/src/views/UserEditDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,21 @@
</v-list-item>
</template>
</v-autocomplete>
<v-text-field v-model="form.mail" :label="t('user.email')" type="email" variant="outlined" class="mb-2" />
<!-- Free-text multi-email input (chantier D4). v-combobox +
multiple + chips lets the user type any email (no
autocomplete source) and confirm with Enter or Tab;
existing emails are restored as chips at load time. -->
<v-combobox
v-model="form.mails"
:label="t('user.email')"
multiple
chips
closable-chips
variant="outlined"
class="mb-2"
:hint="t('user.emailHint')"
persistent-hint
/>
<!-- Auto-suggest for groups (multi-select). Queries
rest/service/id/group as the user types (300 ms debounced).
v-model holds an array of group **names** (strings),
Expand Down Expand Up @@ -157,13 +171,19 @@
@cancel="onGuardCancel"
/>

<!-- Chantier D2 (rattrapage): mirror the UserListView pattern so the
login is rendered in bold red here too. The previous monolithic
`user.<action>Confirm` message with {id} interpolation kept the
name as plain text, which felt visually weaker than the same
action triggered from the list row. -->
<LigojConfirmDialog
v-model="actionDialog"
:title="t('user.' + actionType)"
:message="t('user.' + actionType + 'Confirm', { id: form.id })"
:loading="actionLoading"
@confirm="confirmAction"
/>
>
{{ t('user.' + actionType + 'ConfirmBefore') }}<strong class="text-error">{{ form.id }}</strong>{{ t('user.' + actionType + 'ConfirmAfter') }}
</LigojConfirmDialog>
</div>
</template>

Expand Down Expand Up @@ -219,7 +239,9 @@ const form = ref({
firstName: '',
lastName: '',
company: '',
mail: '',
// Chantier D4: emails as a list. Backend response carries `mails: [...]`;
// a string fallback at load time keeps tolerance for any legacy payload.
mails: [],
})

// useFormGuard still drives the dirty tracking (snapshot + deep watch) and
Expand Down Expand Up @@ -399,7 +421,9 @@ function loadDemoUser(id) {
form.value.firstName = user.firstName
form.value.lastName = user.lastName
form.value.company = user.company
form.value.mail = user.mails?.[0] || ''
// Chantier D4: hydrate the full mails list (the demo seed already
// carries an array, but be defensive against any string fallback).
form.value.mails = Array.isArray(user.mails) ? [...user.mails] : user.mails ? [user.mails] : []
// Normalize groups to an array of names (strings) so v-autocomplete
// with item-value="name" can roundtrip them through v-model.
groups.value = (user.groups || []).map(g => g.name || g)
Expand All @@ -412,7 +436,7 @@ function loadDemoUser(id) {
/** Reset all form state to a clean create-mode baseline. Called every time
* the dialog opens so a previous edit never bleeds into the next one. */
function resetForm() {
form.value = { id: '', firstName: '', lastName: '', company: '', mail: '' }
form.value = { id: '', firstName: '', lastName: '', company: '', mails: [] }
groups.value = []
locked.value = false
isolated.value = false
Expand Down Expand Up @@ -440,7 +464,10 @@ async function loadOnOpen() {
form.value.firstName = data.firstName || ''
form.value.lastName = data.lastName || ''
form.value.company = data.company || ''
form.value.mail = data.mails?.[0] || ''
// Chantier D4: keep the entire mails array, with string fallback
// for any legacy payload that stored a single email as `mail`.
form.value.mails = Array.isArray(data.mails) ? [...data.mails]
: data.mail ? [data.mail] : []
// Normalize groups to an array of names (strings) so v-autocomplete
// with item-value="name" can roundtrip them through v-model.
groups.value = (data.groups || []).map(g => g.name || g)
Expand Down Expand Up @@ -553,7 +580,10 @@ async function save() {
firstName: form.value.firstName,
lastName: form.value.lastName,
company: form.value.company,
mail: form.value.mail,
// Chantier D4: send the full mails list — fixes a latent bug where
// the previous single-string `mail` field would drop every address
// past the first at save time.
mails: form.value.mails,
// groups is an array of names (strings). Defensive `.map(g => g.name || g)`
// in case any legacy object slipped through.
groups: groups.value.map(g => g.name || g),
Expand Down
105 changes: 63 additions & 42 deletions ui/src/views/UserListView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,17 @@
<LigojDataTableServer filename="users.csv" :fetch-all="dt.loadAll" v-if="!dt.error.value" v-show="dt.items.value.length > 0 || !dt.loading.value" v-model="selected"
v-model:items-per-page="itemsPerPage" :headers="headers" :items="dt.items.value" :items-length="dt.totalItems.value" :loading="dt.loading.value" item-value="id" show-select hover
@update:options="loadData" @click:row="(_, { item }) => openEdit(item.id)">
<!-- Mails column (chantier D4): render each address as a v-chip, cap
at 2 visible and fold the rest into a "+N" indicator so rows stay
on a single line even when a user has many addresses. Pattern
matches the existing groups-cell rendering below. -->
<template #item.mails="{ item }">
{{ item.mails?.[0] || '' }}
<div class="mails-cell">
<v-chip v-for="m in (item.mails || []).slice(0, 2)" :key="m" size="small" variant="tonal" class="mr-1">{{ m }}</v-chip>
<span v-if="(item.mails || []).length > 2" class="text-caption text-medium-emphasis">
+{{ item.mails.length - 2 }}
</span>
</div>
</template>
<template #item.groups="{ item }">
<div class="groups-cell">
Expand Down Expand Up @@ -64,7 +73,7 @@
user is locked/isolated. -->
<v-menu>
<template #activator="{ props }">
<v-btn icon size="small" variant="text" :aria-label="t('user.actions')" v-bind="props" @click.stop>
<v-btn icon size="x-small" variant="text" :aria-label="t('user.actions')" v-bind="props" @click.stop>
<v-icon size="small">mdi-cog</v-icon>
</v-btn>
</template>
Expand All @@ -82,46 +91,48 @@
</template>
</LigojDataTableServer>

<v-dialog v-model="deleteDialog" max-width="400">
<v-card>
<v-card-title>{{ t('user.deleteTitle') }}</v-card-title>
<v-card-text>
{{ t('user.deleteConfirmBefore') }}<strong class="text-error">{{ deleteTarget?.id }}</strong>{{ t('user.deleteConfirmAfter') }}
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">{{ t('common.cancel') }}</v-btn>
<v-btn color="error" variant="elevated" :loading="deleting" @click="confirmDeleteUser">{{ t('common.delete') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Single-user delete (chantier D2): name in bold red via the
default slot of LigojConfirmDialog. -->
<LigojConfirmDialog
v-model="deleteDialog"
:title="t('user.deleteTitle')"
:confirm-label="t('common.delete')"
confirm-color="error"
:loading="deleting"
@confirm="confirmDeleteUser"
>
{{ t('user.deleteConfirmBefore') }}<strong class="text-error">{{ deleteTarget?.id }}</strong>{{ t('user.deleteConfirmAfter') }}
</LigojConfirmDialog>

<v-dialog v-model="bulkDeleteDialog" max-width="400">
<v-card>
<v-card-title>{{ t('common.bulkDeleteTitle') }}</v-card-title>
<v-card-text>{{ t('common.bulkDeleteConfirm', { count: selected.length }) }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="bulkDeleteDialog = false">{{ t('common.cancel') }}</v-btn>
<v-btn color="error" variant="elevated" :loading="deleting" @click="confirmBulkDelete">{{ t('common.delete') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Bulk delete (chantier D2 rattrapage): the selected count is
rendered in bold red via the default slot, same visual weight
as a sensitive single-item confirmation. The host's monolithic
`common.bulkDeleteConfirm` key stays intact; we just use two
new plugin-local fragments around the count. -->
<LigojConfirmDialog
v-model="bulkDeleteDialog"
:title="t('common.bulkDeleteTitle')"
:confirm-label="t('common.delete')"
confirm-color="error"
:loading="deleting"
@confirm="confirmBulkDelete"
>
{{ t('common.bulkDeleteConfirmBefore') }}<strong class="text-error">{{ selected.length }}</strong>{{ t('common.bulkDeleteConfirmAfter') }}
</LigojConfirmDialog>

<!-- Confirmation for the sensitive lock/isolate/reset actions
triggered from the row gear menu. Mirrors the actionDialog
pattern of UserEditView so the two screens stay consistent. -->
<v-dialog v-model="actionDialog" max-width="400">
<v-card>
<v-card-title>{{ t('user.' + actionType) }}</v-card-title>
<v-card-text>{{ t('user.' + actionType + 'Confirm', { id: actionTarget?.id }) }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="actionDialog = false">{{ t('common.cancel') }}</v-btn>
<v-btn color="primary" variant="elevated" :loading="actionLoading" @click="confirmUserAction">{{ t('common.confirm') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
triggered from the row gear menu (chantier D2): the login is
now bolded and coloured via two i18n fragments around it
(user.<action>ConfirmBefore / user.<action>ConfirmAfter). -->
<LigojConfirmDialog
v-model="actionDialog"
:title="t('user.' + actionType)"
:confirm-label="t('common.confirm')"
:loading="actionLoading"
@confirm="confirmUserAction"
>
{{ t('user.' + actionType + 'ConfirmBefore') }}<strong class="text-error">{{ actionTarget?.id }}</strong>{{ t('user.' + actionType + 'ConfirmAfter') }}
</LigojConfirmDialog>

<!-- User create/edit popup (chantier I.2). userId null = create mode. -->
<UserEditDialog v-model="editDialog" :user-id="editUserId" @saved="onUserSaved" />
Expand All @@ -130,7 +141,7 @@

<script setup>
import { ref, computed, onMounted } from 'vue'
import { useDataTable, useApi, useAppStore, useErrorStore, useI18nStore, ImportExportBar, LigojDataTableServer } from '@ligoj/host'
import { useDataTable, useApi, useAppStore, useErrorStore, useI18nStore, ImportExportBar, LigojDataTableServer, LigojConfirmDialog } from '@ligoj/host'
import UserEditDialog from './UserEditDialog.vue'

const appStore = useAppStore()
Expand Down Expand Up @@ -176,8 +187,8 @@ const headers = computed(() => [
{ title: t('user.company'), key: 'company', sortable: true },
{ title: t('user.email'), key: 'mails', sortable: false },
{ title: t('user.groups'), key: 'groups', sortable: false },
{ title: t('common.status'), key: 'locked', sortable: false, width: '80px' },
{ title: '', key: 'actions', sortable: false, width: '120px', align: 'center' },
{ title: t('common.status'), key: 'locked', sortable: false, width: '80px', align: 'center' },
{ title: '', key: 'actions', sortable: false, width: '120px', align: 'end' },
])

function loadData(options) {
Expand Down Expand Up @@ -304,4 +315,14 @@ onMounted(() => {
overflow: hidden;
white-space: nowrap;
}

/* Mails column (chantier D4). Soft-wrap is acceptable here because
email addresses can be long: stacking onto a 2nd line keeps the
column readable. The +N indicator at the end of (item.mails || [])
keeps the visual footprint bounded. */
.mails-cell {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
</style>
Loading