- Added UserManagement.vue component for managing users with PrimeVue DataTable. - Integrated API endpoints for user CRUD operations in the backend. - Implemented user password hashing using a custom state processor. - Updated router to include user management route with admin access control. - Enhanced Dashboard.vue and app.scss for improved styling and responsiveness. - Documented user management features and API usage in USER-CRUD.md.
552 lines
14 KiB
Vue
552 lines
14 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="roles" header="Rollen" style="width: 150px">
|
|
<template #body="{ data }">
|
|
<Tag v-for="role in data.roles" :key="role" :value="role" severity="info" class="mr-1" />
|
|
</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>Symfony Rollen</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>
|
|
</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 Tag from 'primevue/tag';
|
|
import { useToast } from 'primevue/usetoast';
|
|
|
|
const authStore = useAuthStore();
|
|
const toast = useToast();
|
|
|
|
const users = 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'],
|
|
isActive: true
|
|
});
|
|
|
|
const errors = ref({});
|
|
|
|
const fetchUsers = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const response = await fetch('/api/users');
|
|
const data = await response.json();
|
|
users.value = data['hydra:member'] || data;
|
|
} catch (error) {
|
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Benutzer konnten nicht geladen werden', life: 3000 });
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const openCreateDialog = () => {
|
|
isEditMode.value = false;
|
|
showPasswordField.value = false;
|
|
formData.value = {
|
|
firstName: '',
|
|
lastName: '',
|
|
email: '',
|
|
plainPassword: '',
|
|
roles: ['ROLE_USER'],
|
|
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']
|
|
};
|
|
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,
|
|
headers: { 'Content-Type': 'application/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'
|
|
});
|
|
|
|
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();
|
|
});
|
|
</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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|