feat: Implement permission management with dynamic access control for contacts

This commit is contained in:
olli 2025-11-10 10:36:02 +01:00
parent 47b7099ba6
commit 684d0deaaa
9 changed files with 413 additions and 21 deletions

View File

@ -187,3 +187,9 @@ if (savedPrimary || savedSurface) {
const authStore = useAuthStore();
authStore.initializeFromElement();
// Load user permissions
import { usePermissionStore } from './js/stores/permissions';
const permissionStore = usePermissionStore();
if (authStore.isAuthenticated) {
permissionStore.loadPermissions();
}

View File

@ -88,6 +88,16 @@
<slot name="actions" v-bind="slotProps">
<div class="flex gap-2">
<Button
v-if="showViewButton"
icon="pi pi-eye"
outlined
rounded
@click="$emit('view', slotProps.data)"
size="small"
severity="secondary"
/>
<Button
v-if="showEditButton"
icon="pi pi-pencil"
outlined
rounded
@ -95,6 +105,7 @@
size="small"
/>
<Button
v-if="showDeleteButton"
icon="pi pi-trash"
outlined
rounded
@ -161,6 +172,18 @@ const props = defineProps({
type: Boolean,
default: true
},
showViewButton: {
type: Boolean,
default: false
},
showEditButton: {
type: Boolean,
default: true
},
showDeleteButton: {
type: Boolean,
default: true
},
emptyMessage: {
type: String,
default: 'Keine Daten gefunden.'
@ -171,7 +194,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['create', 'edit', 'delete', 'data-loaded'])
const emit = defineEmits(['create', 'edit', 'delete', 'view', 'data-loaded'])
const toast = useToast()
const data = ref([])

View File

@ -0,0 +1,68 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const usePermissionStore = defineStore('permissions', () => {
const permissions = ref({})
const isAdmin = ref(false)
const loaded = ref(false)
async function loadPermissions() {
try {
const response = await fetch('/api/permissions', {
credentials: 'include'
})
if (response.ok) {
const data = await response.json()
permissions.value = data.permissions
isAdmin.value = data.isAdmin
loaded.value = true
}
} catch (error) {
console.error('Failed to load permissions:', error)
}
}
function hasPermission(module, action) {
if (isAdmin.value) return true
return permissions.value[module]?.[action] || false
}
function canView(module) {
return hasPermission(module, 'view')
}
function canCreate(module) {
return hasPermission(module, 'create')
}
function canEdit(module) {
return hasPermission(module, 'edit')
}
function canDelete(module) {
return hasPermission(module, 'delete')
}
function canExport(module) {
return hasPermission(module, 'export')
}
function canManage(module) {
return hasPermission(module, 'manage')
}
return {
permissions,
isAdmin,
loaded,
loadPermissions,
hasPermission,
canView,
canCreate,
canEdit,
canDelete,
canExport,
canManage
}
})

View File

@ -7,6 +7,10 @@
:columns="contactColumns"
data-source="/api/contacts"
storage-key="contactTableColumns"
:show-view-button="canView"
:show-edit-button="canEdit"
:show-delete-button="canDelete"
@view="viewContact"
@create="openNewContactDialog"
@edit="editContact"
@delete="confirmDelete"
@ -353,12 +357,192 @@
<Button label="Löschen" severity="danger" @click="deleteContact" :loading="deleting" />
</template>
</Dialog>
<!-- View Contact Dialog (Read-Only) -->
<Dialog
v-model:visible="viewDialog"
header="Kontakt anzeigen"
:modal="true"
:style="{ width: '800px' }"
>
<div v-if="viewingContact" class="flex flex-col gap-4">
<!-- Company Information -->
<div class="font-semibold">Firmeninformationen</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Firmenname</label>
<div>{{ viewingContact.companyName }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Kundennummer</label>
<div>{{ viewingContact.companyNumber || '-' }}</div>
</div>
</div>
<!-- Address -->
<Divider />
<div class="font-semibold">Adresse</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2 col-span-2">
<label class="font-medium text-sm text-500">Straße</label>
<div>{{ viewingContact.street || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">PLZ</label>
<div>{{ viewingContact.zipCode || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Ort</label>
<div>{{ viewingContact.city || '-' }}</div>
</div>
<div class="flex flex-col gap-2 col-span-2">
<label class="font-medium text-sm text-500">Land</label>
<div>{{ viewingContact.country || '-' }}</div>
</div>
</div>
<!-- Contact Information -->
<Divider />
<div class="font-semibold">Kontaktinformationen</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Telefon</label>
<div>
<a v-if="viewingContact.phone" :href="'tel:' + viewingContact.phone" class="text-primary">
{{ viewingContact.phone }}
</a>
<span v-else>-</span>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Fax</label>
<div>{{ viewingContact.fax || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">E-Mail</label>
<div>
<a v-if="viewingContact.email" :href="'mailto:' + viewingContact.email" class="text-primary">
{{ viewingContact.email }}
</a>
<span v-else>-</span>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Website</label>
<div>
<a v-if="viewingContact.website" :href="viewingContact.website" target="_blank" class="text-primary">
{{ viewingContact.website }}
</a>
<span v-else>-</span>
</div>
</div>
</div>
<!-- Tax Information -->
<Divider />
<div class="font-semibold">Steuerinformationen</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Steuernummer</label>
<div>{{ viewingContact.taxNumber || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">USt-IdNr.</label>
<div>{{ viewingContact.vatNumber || '-' }}</div>
</div>
</div>
<!-- Type and Status -->
<Divider />
<div class="font-semibold">Typ und Status</div>
<div class="flex flex-wrap gap-2">
<Tag v-if="viewingContact.isDebtor" value="Debitor" severity="success" />
<Tag v-if="viewingContact.isCreditor" value="Kreditor" severity="warning" />
<Tag :value="viewingContact.isActive ? 'Aktiv' : 'Inaktiv'"
:severity="viewingContact.isActive ? 'success' : 'secondary'" />
</div>
<!-- Notes -->
<Divider />
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Notizen</label>
<div class="whitespace-pre-wrap">{{ viewingContact.notes || '-' }}</div>
</div>
<!-- Contact Persons -->
<Divider />
<div class="font-semibold">Ansprechpartner</div>
<div v-if="viewingContact.contactPersons?.length > 0" class="flex flex-col gap-3">
<div v-for="(person, index) in viewingContact.contactPersons" :key="index"
class="border p-3 rounded-md">
<div class="flex justify-between items-start mb-3">
<div class="font-medium">
{{ person.salutation }} {{ person.title }} {{ person.firstName }} {{ person.lastName }}
<Tag v-if="person.isPrimary" value="Primär" severity="info" class="ml-2" />
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div v-if="person.position">
<span class="text-500">Position:</span> {{ person.position }}
</div>
<div v-if="person.department">
<span class="text-500">Abteilung:</span> {{ person.department }}
</div>
<div v-if="person.phone">
<span class="text-500">Telefon:</span>
<a :href="'tel:' + person.phone" class="text-primary">{{ person.phone }}</a>
</div>
<div v-if="person.mobile">
<span class="text-500">Mobil:</span>
<a :href="'tel:' + person.mobile" class="text-primary">{{ person.mobile }}</a>
</div>
<div v-if="person.email" class="col-span-2">
<span class="text-500">E-Mail:</span>
<a :href="'mailto:' + person.email" class="text-primary">{{ person.email }}</a>
</div>
</div>
</div>
</div>
<div v-else class="text-500">Keine Ansprechpartner</div>
<!-- Timestamps -->
<Divider />
<div class="grid grid-cols-2 gap-4 text-sm text-500">
<div>
<span class="font-medium">Erstellt am:</span> {{ formatDate(viewingContact.createdAt) }}
</div>
<div>
<span class="font-medium">Zuletzt geändert:</span> {{ formatDate(viewingContact.updatedAt) }}
</div>
</div>
</div>
<template #footer>
<Button label="Schließen" @click="viewDialog = false" />
<Button
v-if="canEdit"
label="Bearbeiten"
icon="pi pi-pencil"
@click="editFromView"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { usePermissionStore } from '../stores/permissions'
import CrudDataTable from '../components/CrudDataTable.vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
@ -371,6 +555,13 @@ import Divider from 'primevue/divider'
const toast = useToast()
const tableRef = ref(null)
const permissionStore = usePermissionStore()
// Permissions
const canView = computed(() => permissionStore.canView('contacts'))
const canCreate = computed(() => permissionStore.canCreate('contacts'))
const canEdit = computed(() => permissionStore.canEdit('contacts'))
const canDelete = computed(() => permissionStore.canDelete('contacts'))
// Column definitions
const contactColumns = [
@ -396,8 +587,10 @@ const contactColumns = [
// State
const contactDialog = ref(false)
const viewDialog = ref(false)
const deleteDialog = ref(false)
const editingContact = ref(null)
const viewingContact = ref(null)
const submitted = ref(false)
const saving = ref(false)
const deleting = ref(false)
@ -470,6 +663,11 @@ const openNewContactDialog = () => {
contactDialog.value = true
}
const viewContact = (contact) => {
viewingContact.value = { ...contact }
viewDialog.value = true
}
const editContact = (contact) => {
editingContact.value = { ...contact }
submitted.value = false
@ -600,6 +798,13 @@ const setPrimaryContact = (index) => {
}
}
const editFromView = () => {
editingContact.value = { ...viewingContact.value }
viewDialog.value = false
submitted.value = false
contactDialog.value = true
}
const onDataLoaded = (data) => {
// Optional: Do something when data is loaded
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_USER')]
class PermissionController extends AbstractController
{
#[Route('/api/permissions', name: 'api_permissions', methods: ['GET'])]
public function getPermissions(): JsonResponse
{
/** @var User|null $user */
$user = $this->getUser();
if (!$user) {
return new JsonResponse(['error' => 'Not authenticated'], 401);
}
$permissions = [];
// Liste aller Module die geprüft werden sollen
$modules = ['contacts', 'users', 'roles', 'settings'];
foreach ($modules as $module) {
$permissions[$module] = [
'view' => $user->hasModulePermission($module, 'view'),
'create' => $user->hasModulePermission($module, 'create'),
'edit' => $user->hasModulePermission($module, 'edit'),
'delete' => $user->hasModulePermission($module, 'delete'),
'export' => $user->hasModulePermission($module, 'export'),
'manage' => $user->hasModulePermission($module, 'manage'),
];
}
return new JsonResponse([
'permissions' => $permissions,
'isAdmin' => in_array('ROLE_ADMIN', $user->getRoles())
]);
}
}

View File

@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use App\Entity\Interface\ModuleAwareInterface;
use App\Repository\ContactRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@ -23,11 +24,26 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'contacts')]
#[ApiResource(
operations: [
new GetCollection(stateless: false),
new Get(stateless: false),
new Post(security: "is_granted('ROLE_USER')", stateless: false),
new Put(security: "is_granted('ROLE_USER')", stateless: false),
new Delete(security: "is_granted('ROLE_ADMIN')", stateless: false)
new GetCollection(
security: "is_granted('VIEW', 'contacts')",
stateless: false
),
new Get(
security: "is_granted('VIEW', object)",
stateless: false
),
new Post(
security: "is_granted('CREATE', 'contacts')",
stateless: false
),
new Put(
security: "is_granted('EDIT', object)",
stateless: false
),
new Delete(
security: "is_granted('DELETE', object)",
stateless: false
)
],
paginationClientItemsPerPage: true,
paginationItemsPerPage: 30,
@ -38,7 +54,7 @@ use Symfony\Component\Validator\Constraints as Assert;
)]
#[ApiFilter(BooleanFilter::class, properties: ['isDebtor', 'isCreditor', 'isActive'])]
#[ApiFilter(SearchFilter::class, properties: ['companyName' => 'partial', 'city' => 'partial', 'email' => 'partial'])]
class Contact
class Contact implements ModuleAwareInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
@ -394,4 +410,13 @@ class Contact
{
return $this->companyName ?? '';
}
/**
* Returns the module code this entity belongs to.
* Required by ModuleVoter for permission checks.
*/
public function getModuleName(): string
{
return 'contacts';
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Entity\Interface;
interface ModuleAwareInterface
{
/**
* Returns the module code this entity belongs to.
* Must match the 'code' field in the modules table.
*
* @return string Module code (e.g., 'contacts', 'deals', 'users')
*/
public function getModuleName(): string;
}

View File

@ -60,7 +60,7 @@ class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->router->generate('app_dashboard'));
return new RedirectResponse($this->router->generate('app_home'));
}
protected function getLoginUrl(Request $request): string

View File

@ -2,31 +2,37 @@
namespace App\Security\Voter;
use App\Entity\Interface\ModuleAwareInterface;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ModuleVoter extends Voter
{
public const VIEW = 'MODULE_VIEW';
public const CREATE = 'MODULE_CREATE';
public const EDIT = 'MODULE_EDIT';
public const DELETE = 'MODULE_DELETE';
public const EXPORT = 'MODULE_EXPORT';
public const MANAGE = 'MODULE_MANAGE';
public const VIEW = 'VIEW';
public const CREATE = 'CREATE';
public const EDIT = 'EDIT';
public const DELETE = 'DELETE';
public const EXPORT = 'EXPORT';
public const MANAGE = 'MANAGE';
protected function supports(string $attribute, mixed $subject): bool
{
// Der Voter unterstützt MODULE_* Attribute
// Subject ist der Module-Code als String (z.B. 'contacts', 'deals')
return in_array($attribute, [
$supportedAttributes = [
self::VIEW,
self::CREATE,
self::EDIT,
self::DELETE,
self::EXPORT,
self::MANAGE,
]) && is_string($subject);
];
if (!in_array($attribute, $supportedAttributes)) {
return false;
}
// Subject can be a string (module name) or an object implementing ModuleAwareInterface
return is_string($subject) || $subject instanceof ModuleAwareInterface;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
@ -48,8 +54,8 @@ class ModuleVoter extends Voter
return true;
}
// $subject ist der Module-Code (z.B. 'contacts')
$moduleCode = $subject;
// Extract module name from subject
$moduleCode = is_string($subject) ? $subject : $subject->getModuleName();
// Map Voter-Attribute auf Permission-Actions
$action = match($attribute) {