myCRM/assets/js/views/UserManagement.vue

668 lines
17 KiB
Vue

<template>
<div class="user-management">
<div class="page-header">
<h2>Benutzerverwaltung</h2>
<Button
label="Neuer Benutzer"
icon="pi pi-plus"
@click="openCreateDialog"
v-if="authStore.isAdmin"
/>
</div>
<DataTable
:value="users"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[10, 20, 50]"
tableStyle="min-width: 50rem"
stripedRows
sortField="lastName"
:sortOrder="1"
>
<Column field="id" header="ID" sortable style="width: 80px"></Column>
<Column field="lastName" header="Nachname" sortable></Column>
<Column field="firstName" header="Vorname" sortable></Column>
<Column field="email" header="E-Mail" sortable></Column>
<Column field="isActive" header="Status" sortable style="width: 120px">
<template #body="{ data }">
<Tag :value="data.isActive ? 'Aktiv' : 'Inaktiv'" :severity="data.isActive ? 'success' : 'danger'" />
</template>
</Column>
<Column field="userRoles" header="Rollen" style="width: 200px">
<template #body="{ data }">
<Tag
v-for="role in data.userRoles"
:key="role['@id'] || role.id"
:value="role.name"
severity="info"
class="mr-1"
v-tooltip.top="role.description || role.name"
/>
<span v-if="!data.userRoles || data.userRoles.length === 0" class="text-muted">
Keine Rollen
</span>
</template>
</Column>
<Column field="lastLoginAt" header="Letzter Login" sortable style="width: 180px">
<template #body="{ data }">
{{ formatDate(data.lastLoginAt) }}
</template>
</Column>
<Column header="Aktionen" style="width: 150px">
<template #body="{ data }">
<div class="action-buttons">
<Button
icon="pi pi-pencil"
severity="info"
text
rounded
@click="openEditDialog(data)"
v-tooltip.top="'Bearbeiten'"
/>
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
@click="confirmDelete(data)"
v-tooltip.top="'Löschen'"
:disabled="data.id === authStore.user?.id"
/>
</div>
</template>
</Column>
</DataTable>
<!-- Create/Edit Dialog -->
<Dialog
v-model:visible="dialogVisible"
:header="isEditMode ? 'Benutzer bearbeiten' : 'Neuer Benutzer'"
:modal="true"
:style="{ width: '600px' }"
>
<div class="user-form">
<div class="form-row">
<div class="form-field">
<label for="firstName">Vorname *</label>
<InputText
id="firstName"
v-model="formData.firstName"
:class="{ 'p-invalid': errors.firstName }"
placeholder="Max"
/>
<small class="p-error" v-if="errors.firstName">{{ errors.firstName }}</small>
</div>
<div class="form-field">
<label for="lastName">Nachname *</label>
<InputText
id="lastName"
v-model="formData.lastName"
:class="{ 'p-invalid': errors.lastName }"
placeholder="Mustermann"
/>
<small class="p-error" v-if="errors.lastName">{{ errors.lastName }}</small>
</div>
</div>
<div class="form-field">
<label for="email">E-Mail *</label>
<InputText
id="email"
v-model="formData.email"
type="email"
:class="{ 'p-invalid': errors.email }"
placeholder="max@example.com"
/>
<small class="p-error" v-if="errors.email">{{ errors.email }}</small>
</div>
<div class="form-field" v-if="!isEditMode || showPasswordField">
<label for="password">{{ isEditMode ? 'Neues Passwort' : 'Passwort' }} {{ isEditMode ? '' : '*' }}</label>
<Password
id="password"
v-model="formData.plainPassword"
toggleMask
:class="{ 'p-invalid': errors.plainPassword }"
:feedback="false"
placeholder="••••••••"
/>
<small class="p-error" v-if="errors.plainPassword">{{ errors.plainPassword }}</small>
<Button
v-if="isEditMode && !showPasswordField"
label="Passwort ändern"
text
size="small"
@click="showPasswordField = true"
/>
</div>
<div class="form-field">
<label for="userRoles">Rollen</label>
<MultiSelect
id="userRoles"
v-model="formData.userRoles"
:options="roles"
optionLabel="name"
optionValue="@id"
placeholder="Rollen auswählen"
display="chip"
filter
:loading="roles.length === 0"
class="w-full"
>
<template #option="slotProps">
<div class="role-option">
<div class="role-name">{{ slotProps.option.name }}</div>
<div class="role-description">{{ slotProps.option.description }}</div>
</div>
</template>
</MultiSelect>
</div>
<div class="form-field">
<label>Symfony Sicherheitsrollen</label>
<div class="checkbox-group">
<div class="checkbox-item">
<Checkbox
v-model="formData.roles"
inputId="role_user"
value="ROLE_USER"
/>
<label for="role_user">ROLE_USER</label>
</div>
<div class="checkbox-item">
<Checkbox
v-model="formData.roles"
inputId="role_admin"
value="ROLE_ADMIN"
/>
<label for="role_admin">ROLE_ADMIN</label>
</div>
</div>
<small class="text-muted">Diese Rollen sind für die Symfony-Sicherheit erforderlich.</small>
</div>
<div class="form-field">
<div class="checkbox-item">
<Checkbox
v-model="formData.isActive"
inputId="isActive"
:binary="true"
/>
<label for="isActive">Benutzer ist aktiv</label>
</div>
</div>
</div>
<template #footer>
<Button label="Abbrechen" severity="secondary" @click="dialogVisible = false" />
<Button label="Speichern" @click="saveUser" :loading="saving" />
</template>
</Dialog>
<!-- Delete Confirmation -->
<Dialog
v-model:visible="deleteDialogVisible"
header="Benutzer löschen"
:modal="true"
:style="{ width: '450px' }"
>
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle" style="font-size: 3rem; color: var(--red-500)"></i>
<p>Möchten Sie den Benutzer <strong>{{ userToDelete?.firstName }} {{ userToDelete?.lastName }}</strong> wirklich löschen?</p>
<p class="text-secondary">Diese Aktion kann nicht rückgängig gemacht werden.</p>
</div>
<template #footer>
<Button label="Abbrechen" severity="secondary" @click="deleteDialogVisible = false" />
<Button label="Löschen" severity="danger" @click="deleteUser" :loading="deleting" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useAuthStore } from '../stores/auth';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Password from 'primevue/password';
import Checkbox from 'primevue/checkbox';
import MultiSelect from 'primevue/multiselect';
import Tag from 'primevue/tag';
import { useToast } from 'primevue/usetoast';
const authStore = useAuthStore();
const toast = useToast();
const users = ref([]);
const roles = ref([]);
const modules = ref([]);
const loading = ref(false);
const dialogVisible = ref(false);
const deleteDialogVisible = ref(false);
const isEditMode = ref(false);
const saving = ref(false);
const deleting = ref(false);
const showPasswordField = ref(false);
const userToDelete = ref(null);
const formData = ref({
firstName: '',
lastName: '',
email: '',
plainPassword: '',
roles: ['ROLE_USER'],
userRoles: [],
isActive: true
});
const errors = ref({});
const fetchUsers = async () => {
loading.value = true;
try {
const response = await fetch('/api/users', {
credentials: 'same-origin',
headers: {
'Accept': 'application/ld+json'
}
});
if (!response.ok) {
const errorText = await response.text();
console.error('API Error:', response.status, errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// API Platform JSON-LD uses 'member' (hydra:member becomes 'member' in JS)
users.value = data.member || data['hydra:member'] || [];
console.log('Loaded users:', users.value);
console.log('First user userRoles:', users.value[0]?.userRoles);
} catch (error) {
console.error('Error fetching users:', error);
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Benutzer konnten nicht geladen werden: ' + error.message,
life: 5000
});
} finally {
loading.value = false;
}
};
const fetchRoles = async () => {
try {
const response = await fetch('/api/roles', {
credentials: 'same-origin',
headers: { 'Accept': 'application/ld+json' }
});
if (!response.ok) throw new Error('Failed to fetch roles');
const data = await response.json();
roles.value = data.member || [];
} catch (error) {
console.error('Error fetching roles:', error);
}
};
const fetchModules = async () => {
try {
const response = await fetch('/api/modules', {
credentials: 'same-origin',
headers: { 'Accept': 'application/ld+json' }
});
if (!response.ok) throw new Error('Failed to fetch modules');
const data = await response.json();
modules.value = (data.member || []).filter(m => m.isActive).sort((a, b) => a.sortOrder - b.sortOrder);
} catch (error) {
console.error('Error fetching modules:', error);
}
};
const openCreateDialog = () => {
isEditMode.value = false;
showPasswordField.value = false;
formData.value = {
firstName: '',
lastName: '',
email: '',
plainPassword: '',
roles: ['ROLE_USER'],
userRoles: [],
isActive: true
};
errors.value = {};
dialogVisible.value = true;
};
const openEditDialog = (user) => {
isEditMode.value = true;
showPasswordField.value = false;
formData.value = {
...user,
plainPassword: '',
roles: user.roles || ['ROLE_USER'],
userRoles: (user.userRoles || []).map(role => role['@id'] || role)
};
errors.value = {};
dialogVisible.value = true;
};
const validateForm = () => {
errors.value = {};
if (!formData.value.firstName) errors.value.firstName = 'Vorname ist erforderlich';
if (!formData.value.lastName) errors.value.lastName = 'Nachname ist erforderlich';
if (!formData.value.email) errors.value.email = 'E-Mail ist erforderlich';
if (!isEditMode.value && !formData.value.plainPassword) {
errors.value.plainPassword = 'Passwort ist erforderlich';
}
return Object.keys(errors.value).length === 0;
};
const saveUser = async () => {
if (!validateForm()) return;
saving.value = true;
try {
const payload = { ...formData.value };
if (isEditMode.value && !showPasswordField.value) {
delete payload.plainPassword;
}
if (!payload.plainPassword) {
delete payload.plainPassword;
}
const url = isEditMode.value ? `/api/users/${formData.value.id}` : '/api/users';
const method = isEditMode.value ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/ld+json',
'Accept': 'application/ld+json'
},
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Fehler beim Speichern');
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: `Benutzer wurde ${isEditMode.value ? 'aktualisiert' : 'erstellt'}`,
life: 3000
});
dialogVisible.value = false;
await fetchUsers();
} catch (error) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Benutzer konnte nicht gespeichert werden', life: 3000 });
} finally {
saving.value = false;
}
};
const confirmDelete = (user) => {
userToDelete.value = user;
deleteDialogVisible.value = true;
};
const deleteUser = async () => {
deleting.value = true;
try {
const response = await fetch(`/api/users/${userToDelete.value.id}`, {
method: 'DELETE',
credentials: 'same-origin'
});
if (!response.ok) throw new Error('Fehler beim Löschen');
toast.add({ severity: 'success', summary: 'Erfolg', detail: 'Benutzer wurde gelöscht', life: 3000 });
deleteDialogVisible.value = false;
await fetchUsers();
} catch (error) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Benutzer konnte nicht gelöscht werden', life: 3000 });
} finally {
deleting.value = false;
}
};
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
onMounted(() => {
fetchUsers();
fetchRoles();
fetchModules();
});
</script>
<style scoped lang="scss">
.user-management {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
@media (min-width: 768px) {
margin-bottom: 2rem;
flex-wrap: nowrap;
}
h2 {
margin: 0;
font-size: 1.5rem;
@media (min-width: 768px) {
font-size: 2rem;
}
}
}
.action-buttons {
display: flex;
gap: 0.25rem;
}
}
.user-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
@media (min-width: 768px) {
gap: 1.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
label {
font-weight: 600;
color: #374151;
font-size: 0.9rem;
@media (min-width: 768px) {
font-size: 1rem;
}
}
.p-error {
color: var(--red-500);
font-size: 0.875rem;
}
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 0.5rem;
label {
margin: 0;
cursor: pointer;
font-weight: normal;
}
}
}
.confirmation-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
padding: 1rem 0;
@media (min-width: 768px) {
padding: 0;
}
i {
font-size: 2.5rem;
color: var(--red-500);
@media (min-width: 768px) {
font-size: 3rem;
}
}
p {
margin: 0;
font-size: 0.95rem;
@media (min-width: 768px) {
font-size: 1rem;
}
}
.text-secondary {
color: #6b7280;
font-size: 0.85rem;
@media (min-width: 768px) {
font-size: 0.9rem;
}
}
}
// Responsive DataTable overrides
:deep(.p-datatable) {
font-size: 0.875rem;
@media (min-width: 768px) {
font-size: 1rem;
}
.p-datatable-wrapper {
overflow-x: auto;
}
th, td {
padding: 0.5rem;
@media (min-width: 768px) {
padding: 0.75rem 1rem;
}
}
}
// Responsive Dialog
:deep(.p-dialog) {
width: 95vw !important;
max-width: 600px;
.p-dialog-header {
padding: 1rem;
@media (min-width: 768px) {
padding: 1.25rem 1.5rem;
}
}
.p-dialog-content {
padding: 1rem;
@media (min-width: 768px) {
padding: 1.5rem;
}
}
.p-dialog-footer {
padding: 1rem;
gap: 0.5rem;
@media (min-width: 768px) {
padding: 1.25rem 1.5rem;
}
.p-button {
flex: 1;
@media (min-width: 768px) {
flex: 0 0 auto;
}
}
}
.role-option {
.role-name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.role-description {
font-size: 0.875rem;
color: var(--p-text-muted-color);
}
}
.text-muted {
display: block;
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--p-text-muted-color);
}
}
</style>