feat: integrate ApiPlatformBundle and enhance settings access control

- Added ApiPlatformBundle to the project configuration.
- Updated SettingsController to use custom access control for viewing and managing settings.
- Modified AppFixtures to reflect new module structure and permissions for project management.
- Adjusted ProjectStatus and ProjectTask entities to align with new permission checks.
- Enhanced User entity to include a method for retrieving module permissions.
- Implemented CleanupModulesCommand to deactivate or remove unimplemented modules.
- Added CSRF protection configuration for forms.
- Introduced property_info configuration for enhanced property handling.
- Updated base template to include user module permissions in the frontend.
- Created test_permissions.php for testing user permissions and roles.
This commit is contained in:
olli 2025-12-28 09:49:18 +01:00
parent 1d85df5f70
commit b4974b93ef
18 changed files with 1325 additions and 1893 deletions

1
.gitignore vendored
View File

@ -26,3 +26,4 @@ npm-debug.log
yarn-error.log
###< symfony/webpack-encore-bundle ###
auth.json
config/routes/billing.yaml

View File

@ -1,35 +1,78 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useAuthStore } from '../stores/auth';
import { usePermissionStore } from '../stores/permissions';
import AppMenuItem from './AppMenuItem.vue';
const authStore = useAuthStore();
const permissionStore = usePermissionStore();
// Core-Menü (immer vorhanden)
// Core-Menü mit Permission-Prüfungen
const coreMenu = [
{
label: 'Home',
items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
items: [
{
label: 'Dashboard',
icon: 'pi pi-fw pi-home',
to: '/',
visible: () => permissionStore.canView('dashboard')
}
]
},
{
label: 'CRM',
items: [
{ label: 'Kontakte', icon: 'pi pi-fw pi-users', to: '/contacts' },
{ label: 'Projekte', icon: 'pi pi-fw pi-briefcase', to: '/projects' },
{ label: 'Tätigkeiten', icon: 'pi pi-fw pi-list-check', to: '/project-tasks' }
{
label: 'Kontakte',
icon: 'pi pi-fw pi-users',
to: '/contacts',
visible: () => permissionStore.canView('contacts')
},
{
label: 'Projekte',
icon: 'pi pi-fw pi-briefcase',
to: '/projects',
visible: () => permissionStore.canView('projects')
},
{
label: 'Tätigkeiten',
icon: 'pi pi-fw pi-list-check',
to: '/project-tasks',
visible: () => permissionStore.canView('project_tasks')
}
]
}
];
// Administration-Menü (immer vorhanden)
// Administration-Menü mit Permission-Prüfungen
const adminMenu = {
label: 'Administration',
visible: () => authStore.isAdmin,
items: [
{ label: 'Projekt-Status', icon: 'pi pi-fw pi-tag', to: '/project-statuses' },
{ label: 'Benutzerverwaltung', icon: 'pi pi-fw pi-user-edit', to: '/users' },
{ label: 'Rollenverwaltung', icon: 'pi pi-fw pi-shield', to: '/roles' },
{ label: 'Einstellungen', icon: 'pi pi-fw pi-cog', to: '/settings' }
{
label: 'Projekt-Status',
icon: 'pi pi-fw pi-tag',
to: '/project-statuses',
visible: () => permissionStore.canView('projects')
},
{
label: 'Benutzerverwaltung',
icon: 'pi pi-fw pi-user-edit',
to: '/users',
visible: () => permissionStore.canView('users')
},
{
label: 'Rollenverwaltung',
icon: 'pi pi-fw pi-shield',
to: '/roles',
visible: () => permissionStore.canView('roles')
},
{
label: 'Einstellungen',
icon: 'pi pi-fw pi-cog',
to: '/settings',
visible: () => permissionStore.canView('settings')
}
]
};
@ -48,16 +91,29 @@ const loadPluginMenus = async () => {
// Konvertiere zu PrimeVue Menü-Format
const menuGroup = {
label: groupLabel,
items: items.map(item => ({
label: item.label,
icon: item.icon ? `pi pi-fw ${item.icon}` : 'pi pi-fw pi-circle',
to: item.to,
...(item.items && { items: item.items }),
// Wenn Permission definiert, prüfen
...(item.permission && {
visible: () => authStore.hasPermission(item.permission)
})
}))
items: items.map(item => {
// Erstelle Permission-Prüfung
let visibleFn;
if (item.permission) {
// Explizite Permission aus Plugin
visibleFn = () => authStore.hasPermission(item.permission);
} else if (item.module) {
// Fallback: Leite von Modulname ab (z.B. 'billing')
visibleFn = () => permissionStore.canView(item.module);
} else if (item.source) {
// Fallback: Nutze Plugin-Identifier als Modulname
visibleFn = () => permissionStore.canView(item.source);
}
return {
label: item.label,
icon: item.icon ? `pi pi-fw ${item.icon}` : 'pi pi-fw pi-circle',
to: item.to,
...(item.items && { items: item.items }),
...(visibleFn && { visible: visibleFn })
};
})
};
model.value.push(menuGroup);
@ -74,8 +130,13 @@ const loadPluginMenus = async () => {
}
};
// Beim Mount laden
onMounted(() => {
// Beim Mount laden - warten bis Permissions geladen sind
onMounted(async () => {
// Warten bis Permissions geladen sind
while (!permissionStore.loaded) {
await new Promise(resolve => setTimeout(resolve, 100));
}
loadPluginMenus();
});
</script>

View File

@ -1,6 +1,6 @@
<script setup>
import { useLayout } from './composables/layout';
import { onBeforeMount, ref, watch } from 'vue';
import { onBeforeMount, ref, watch, computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
@ -29,6 +29,24 @@ const props = defineProps({
const isActiveMenu = ref(false);
const itemKey = ref(null);
// Prüfe ob Item sichtbar sein soll
const isVisible = computed(() => {
const visible = props.item.visible;
// Wenn visible eine Funktion ist, aufrufen
if (typeof visible === 'function') {
return visible();
}
// Wenn visible undefined ist, Item anzeigen
if (visible === undefined) {
return true;
}
// Sonst boolean-Wert verwenden
return visible;
});
onBeforeMount(() => {
itemKey.value = props.parentItemKey ? props.parentItemKey + '-' + props.index : String(props.index);
@ -69,19 +87,19 @@ function checkActiveRoute(item) {
</script>
<template>
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActiveMenu }">
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">{{ item.label }}</div>
<a v-if="(!item.to || item.items) && item.visible !== false" :href="item.url" @click="itemClick($event, item, index)" :class="item.class" :target="item.target" tabindex="0">
<li v-if="isVisible" :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActiveMenu }">
<div v-if="root" class="layout-menuitem-root-text">{{ item.label }}</div>
<a v-if="!item.to || item.items" :href="item.url" @click="itemClick($event, item, index)" :class="item.class" :target="item.target" tabindex="0">
<i :class="item.icon" class="layout-menuitem-icon"></i>
<span class="layout-menuitem-text">{{ item.label }}</span>
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items"></i>
</a>
<router-link v-if="item.to && !item.items && item.visible !== false" @click="itemClick($event, item, index)" :class="[item.class, { 'active-route': checkActiveRoute(item) }]" tabindex="0" :to="item.to">
<router-link v-if="item.to && !item.items" @click="itemClick($event, item, index)" :class="[item.class, { 'active-route': checkActiveRoute(item) }]" tabindex="0" :to="item.to">
<i :class="item.icon" class="layout-menuitem-icon"></i>
<span class="layout-menuitem-text">{{ item.label }}</span>
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items"></i>
</router-link>
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
<Transition v-if="item.items" name="layout-submenu">
<ul v-show="root ? true : isActiveMenu" class="layout-submenu">
<app-menu-item v-for="(child, i) in item.items" :key="child" :index="i" :item="child" :parentItemKey="itemKey" :root="false"></app-menu-item>
</ul>

View File

@ -1051,7 +1051,7 @@ onMounted(async () => {
async function loadCustomers() {
try {
const response = await fetch('/api/contacts?pagination=false')
const response = await fetch('/api/contacts?itemsPerPage=5000')
if (!response.ok) throw new Error('Fehler beim Laden der Kunden')
const data = await response.json()
@ -1174,7 +1174,7 @@ async function loadGitRepositories(projectId) {
async function loadProjectTasks(projectId) {
try {
const response = await fetch(`/api/project_tasks?project=${projectId}`)
const response = await fetch(`/api/project_tasks?project=/api/projects/${projectId}`)
if (!response.ok) throw new Error('Fehler beim Laden der Tätigkeiten')
const data = await response.json()

View File

@ -7,8 +7,7 @@
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/doctrine-orm": "*",
"api-platform/symfony": "*",
"api-platform/core": "^4.1",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.18",
"doctrine/doctrine-migrations-bundle": "^3.6",
@ -19,37 +18,37 @@
"nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.1.*",
"symfony/asset-mapper": "7.1.*",
"symfony/cache": "7.1.*",
"symfony/console": "7.1.*",
"symfony/doctrine-messenger": "7.1.*",
"symfony/dotenv": "7.1.*",
"symfony/expression-language": "7.1.*",
"symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/cache": "7.3.*",
"symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/expression-language": "7.3.*",
"symfony/flex": "^2",
"symfony/form": "7.1.*",
"symfony/framework-bundle": "7.1.*",
"symfony/http-client": "7.1.*",
"symfony/intl": "7.1.*",
"symfony/mailer": "7.1.*",
"symfony/mime": "7.1.*",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/http-client": "7.3.*",
"symfony/intl": "7.3.*",
"symfony/mailer": "7.3.*",
"symfony/mime": "7.3.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.1.*",
"symfony/process": "7.1.*",
"symfony/property-access": "7.1.*",
"symfony/property-info": "7.1.*",
"symfony/runtime": "7.1.*",
"symfony/security-bundle": "7.1.*",
"symfony/serializer": "7.1.*",
"symfony/notifier": "7.3.*",
"symfony/process": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.31",
"symfony/string": "7.1.*",
"symfony/translation": "7.1.*",
"symfony/twig-bundle": "7.1.*",
"symfony/string": "7.3.*",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-turbo": "^2.31",
"symfony/validator": "7.1.*",
"symfony/web-link": "7.1.*",
"symfony/validator": "7.3.*",
"symfony/web-link": "7.3.*",
"symfony/webpack-encore-bundle": "^2.3",
"symfony/yaml": "7.1.*",
"symfony/yaml": "7.3.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
},
@ -99,19 +98,19 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.1.*",
"require": "7.3.*",
"docker": true
}
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "*",
"phpunit/phpunit": "^12.4",
"symfony/browser-kit": "7.1.*",
"symfony/css-selector": "7.1.*",
"symfony/debug-bundle": "7.1.*",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/debug-bundle": "7.3.*",
"symfony/maker-bundle": "^1.0",
"symfony/stopwatch": "7.1.*",
"symfony/web-profiler-bundle": "7.1.*"
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
},
"repositories": {
"mycrm-test-module": {

2617
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,9 @@ return [
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
MyCRM\BillingModule\BillingModuleBundle::class => ['all' => true],
];

11
config/packages/csrf.yaml Normal file
View File

@ -0,0 +1,11 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout

View File

@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View File

@ -0,0 +1,94 @@
<?php
namespace App\Command;
use App\Repository\ModuleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:modules:cleanup',
description: 'Deaktiviert oder entfernt nicht implementierte Module aus der Datenbank',
)]
class CleanupModulesCommand extends Command
{
private const VALID_MODULES = [
'dashboard',
'contacts',
'projects',
'project_tasks',
'documents',
'users',
'roles',
'settings',
'billing', // Plugin-Modul
];
public function __construct(
private EntityManagerInterface $entityManager,
private ModuleRepository $moduleRepository
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Bereinigung von nicht implementierten Modulen');
// Finde alle Module, die nicht in der Liste der validen Module sind
$allModules = $this->moduleRepository->findAll();
$deactivatedModules = [];
$deletedModules = [];
foreach ($allModules as $module) {
if (!in_array($module->getCode(), self::VALID_MODULES, true)) {
// Prüfe, ob das Modul Permissions hat
if ($module->getPermissions()->count() > 0) {
// Deaktiviere nur, da es noch Referenzen hat
$module->setIsActive(false);
$deactivatedModules[] = sprintf(
'%s (%s) - deaktiviert (hat %d Berechtigungen)',
$module->getName(),
$module->getCode(),
$module->getPermissions()->count()
);
} else {
// Kann sicher gelöscht werden
$deletedModules[] = sprintf(
'%s (%s)',
$module->getName(),
$module->getCode()
);
$this->entityManager->remove($module);
}
}
}
$this->entityManager->flush();
if (count($deactivatedModules) > 0) {
$io->section('Deaktivierte Module:');
$io->listing($deactivatedModules);
}
if (count($deletedModules) > 0) {
$io->section('Gelöschte Module:');
$io->listing($deletedModules);
}
if (count($deactivatedModules) === 0 && count($deletedModules) === 0) {
$io->success('Keine nicht implementierten Module gefunden!');
} else {
$io->success('Module wurden bereinigt!');
$io->note('Sie sollten nun "php bin/console doctrine:fixtures:load" ausführen, um die Datenbank neu zu initialisieren (optional).');
}
return Command::SUCCESS;
}
}

View File

@ -11,7 +11,6 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/settings', name: 'api_settings_')]
#[IsGranted('ROLE_ADMIN')]
class SettingsController extends AbstractController
{
public function __construct(
@ -21,6 +20,8 @@ class SettingsController extends AbstractController
#[Route('', name: 'get', methods: ['GET'])]
public function getSettings(): JsonResponse
{
$this->denyAccessUnlessGranted('VIEW', 'settings');
return $this->json([
'settings' => $this->settingsService->getAllSettings()
]);
@ -29,6 +30,8 @@ class SettingsController extends AbstractController
#[Route('', name: 'update', methods: ['PUT', 'PATCH'])]
public function updateSettings(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('MANAGE', 'settings');
$data = json_decode($request->getContent(), true);
if (!isset($data['settings'])) {

View File

@ -36,32 +36,39 @@ class AppFixtures extends Fixture
'sortOrder' => 20
],
[
'name' => 'Unternehmen',
'code' => 'companies',
'description' => 'Firmendatenbank',
'icon' => 'pi-building',
'name' => 'Projekte',
'code' => 'projects',
'description' => 'Projektverwaltung',
'icon' => 'pi-briefcase',
'sortOrder' => 30
],
[
'name' => 'Deals',
'code' => 'deals',
'description' => 'Sales-Pipeline',
'icon' => 'pi-dollar',
'name' => 'Aufgaben',
'code' => 'project_tasks',
'description' => 'Aufgaben- und Task-Management',
'icon' => 'pi-check-square',
'sortOrder' => 40
],
[
'name' => 'Aktivitäten',
'code' => 'activities',
'description' => 'Interaktions-Historie',
'icon' => 'pi-calendar',
'name' => 'Dokumente',
'code' => 'documents',
'description' => 'Dokumentenverwaltung',
'icon' => 'pi-file',
'sortOrder' => 50
],
[
'name' => 'Berichte',
'code' => 'reports',
'description' => 'Analytics und Reports',
'icon' => 'pi-chart-bar',
'sortOrder' => 60
'name' => 'Benutzerverwaltung',
'code' => 'users',
'description' => 'Verwaltung von Benutzern und Zugriffsrechten',
'icon' => 'pi-user',
'sortOrder' => 90
],
[
'name' => 'Rollenverwaltung',
'code' => 'roles',
'description' => 'Verwaltung von Rollen und Berechtigungen',
'icon' => 'pi-shield',
'sortOrder' => 91
],
[
'name' => 'Einstellungen',
@ -108,26 +115,27 @@ class AppFixtures extends Fixture
$manager->persist($permission);
}
// Erstelle Vertriebsmitarbeiter-Rolle
$salesRole = new Role();
$salesRole->setName('Vertriebsmitarbeiter');
$salesRole->setDescription('Zugriff auf Kontakte, Deals und Aktivitäten');
$salesRole->setIsSystem(false);
$manager->persist($salesRole);
// Erstelle Projektmanager-Rolle
$projectManagerRole = new Role();
$projectManagerRole->setName('Projektmanager');
$projectManagerRole->setDescription('Zugriff auf Kontakte, Projekte und Aufgaben');
$projectManagerRole->setIsSystem(false);
$manager->persist($projectManagerRole);
// Berechtigungen für Vertrieb
$salesModules = ['dashboard', 'contacts', 'companies', 'deals', 'activities'];
foreach ($salesModules as $moduleCode) {
// Berechtigungen für Projektmanager
$projectManagerModules = ['dashboard', 'contacts', 'projects', 'project_tasks', 'documents', 'settings'];
foreach ($projectManagerModules as $moduleCode) {
if (isset($moduleEntities[$moduleCode])) {
$permission = new RolePermission();
$permission->setRole($salesRole);
$permission->setRole($projectManagerRole);
$permission->setModule($moduleEntities[$moduleCode]);
$permission->setCanView(true);
$permission->setCanCreate($moduleCode !== 'dashboard');
$permission->setCanEdit($moduleCode !== 'dashboard');
$permission->setCanCreate($moduleCode !== 'dashboard' && $moduleCode !== 'settings');
$permission->setCanEdit($moduleCode !== 'dashboard' && $moduleCode !== 'settings');
$permission->setCanDelete(false);
$permission->setCanExport($moduleCode !== 'dashboard');
$permission->setCanManage(false);
$permission->setCanExport($moduleCode !== 'dashboard' && $moduleCode !== 'settings');
// Settings: VIEW und MANAGE erlaubt für Projektmanager
$permission->setCanManage($moduleCode === 'settings');
$manager->persist($permission);
}
@ -141,7 +149,7 @@ class AppFixtures extends Fixture
$manager->persist($viewerRole);
// Nur Leserechte für bestimmte Module
$viewerModules = ['dashboard', 'contacts', 'companies', 'deals', 'activities', 'reports'];
$viewerModules = ['dashboard', 'contacts', 'projects', 'project_tasks', 'documents'];
foreach ($viewerModules as $moduleCode) {
if (isset($moduleEntities[$moduleCode])) {
$permission = new RolePermission();
@ -172,19 +180,19 @@ class AppFixtures extends Fixture
$manager->persist($admin);
// Erstelle Test-Vertriebsmitarbeiter
$sales = new User();
$sales->setEmail('sales@mycrm.local');
$sales->setFirstName('Max');
$sales->setLastName('Mustermann');
$sales->setIsActive(true);
$sales->setRoles(['ROLE_USER']);
$sales->addUserRole($salesRole);
// Erstelle Test-Projektmanager
$projectManager = new User();
$projectManager->setEmail('pm@mycrm.local');
$projectManager->setFirstName('Max');
$projectManager->setLastName('Mustermann');
$projectManager->setIsActive(true);
$projectManager->setRoles(['ROLE_USER']);
$projectManager->addUserRole($projectManagerRole);
$hashedPassword = $this->passwordHasher->hashPassword($sales, 'sales123');
$sales->setPassword($hashedPassword);
$hashedPassword = $this->passwordHasher->hashPassword($projectManager, 'pm123');
$projectManager->setPassword($hashedPassword);
$manager->persist($sales);
$manager->persist($projectManager);
$manager->flush();
}

View File

@ -22,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('VIEW', 'project_statuses')",
security: "is_granted('VIEW', 'projects')",
stateless: false
),
new Get(
@ -30,7 +30,7 @@ use Symfony\Component\Validator\Constraints as Assert;
stateless: false
),
new Post(
security: "is_granted('CREATE', 'project_statuses')",
security: "is_granted('CREATE', 'projects')",
stateless: false
),
new Put(
@ -191,9 +191,10 @@ class ProjectStatus implements ModuleAwareInterface
/**
* Returns the module code this entity belongs to.
* Required by ModuleVoter for permission checks.
* ProjectStatus inherits permissions from the projects module.
*/
public function getModuleName(): string
{
return 'project_statuses';
return 'projects';
}
}

View File

@ -51,7 +51,7 @@ use Symfony\Component\Validator\Constraints as Assert;
denormalizationContext: ['groups' => ['project_task:write']],
order: ['createdAt' => 'DESC']
)]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial', 'project.name' => 'partial'])]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial', 'project.name' => 'partial', 'project' => 'exact'])]
#[ApiFilter(DateFilter::class, properties: ['createdAt', 'updatedAt'])]
#[ApiFilter(ProjectTaskProjectFilter::class)]
class ProjectTask implements ModuleAwareInterface

View File

@ -265,7 +265,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
foreach ($this->userRoles as $role) {
foreach ($role->getPermissions() as $permission) {
if ($permission->getModule()->getCode() === $moduleCode) {
return match($action) {
$hasPermission = match($action) {
'view' => $permission->canView(),
'create' => $permission->canCreate(),
'edit' => $permission->canEdit(),
@ -274,12 +274,58 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
'manage' => $permission->canManage(),
default => false,
};
// If ANY role grants the permission, return true
if ($hasPermission) {
return true;
}
}
}
}
return false;
}
/**
* Get all module permissions as a map for frontend
* Format: ['module' => ['action1', 'action2', ...]]
*/
public function getModulePermissionsMap(): array
{
$map = [];
foreach ($this->userRoles as $role) {
foreach ($role->getPermissions() as $permission) {
$moduleCode = $permission->getModule()->getCode();
if (!isset($map[$moduleCode])) {
$map[$moduleCode] = [];
}
// Add each action that is allowed
if ($permission->canView() && !in_array('view', $map[$moduleCode])) {
$map[$moduleCode][] = 'view';
}
if ($permission->canCreate() && !in_array('create', $map[$moduleCode])) {
$map[$moduleCode][] = 'create';
}
if ($permission->canEdit() && !in_array('edit', $map[$moduleCode])) {
$map[$moduleCode][] = 'edit';
}
if ($permission->canDelete() && !in_array('delete', $map[$moduleCode])) {
$map[$moduleCode][] = 'delete';
}
if ($permission->canExport() && !in_array('export', $map[$moduleCode])) {
$map[$moduleCode][] = 'export';
}
if ($permission->canManage() && !in_array('manage', $map[$moduleCode])) {
$map[$moduleCode][] = 'manage';
}
}
}
return $map;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;

View File

@ -1,11 +1,11 @@
{
"api-platform/symfony": {
"api-platform/core": {
"version": "4.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.0",
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
"ref": "cb9e6b8ceb9b62f32d41fc8ad72a25d5bd674c6d"
},
"files": [
"config/packages/api_platform.yaml",
@ -155,6 +155,18 @@
".env.dev"
]
},
"symfony/form": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
},
"files": [
"config/packages/csrf.yaml"
]
},
"symfony/framework-bundle": {
"version": "7.1",
"recipe": {
@ -231,6 +243,18 @@
"config/packages/notifier.yaml"
]
},
"symfony/property-info": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.1",
"recipe": {
@ -297,15 +321,6 @@
"templates/base.html.twig"
]
},
"symfony/uid": {
"version": "7.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
}
},
"symfony/ux-turbo": {
"version": "2.31",
"recipe": {

View File

@ -21,7 +21,8 @@
firstName: app.user.firstName,
lastName: app.user.lastName,
fullName: app.user.fullName,
roles: app.user.roles
roles: app.user.roles,
modulePermissions: app.user.modulePermissionsMap
}|json_encode|e('html_attr') : 'null' }}"
></div>
{% block body %}{% endblock %}

78
test_permissions.php Normal file
View File

@ -0,0 +1,78 @@
<?php
require __DIR__.'/vendor/autoload.php';
use Symfony\Component\Dotenv\Dotenv;
(new Dotenv())->bootEnv(__DIR__.'/.env');
$kernel = new App\Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel->boot();
$container = $kernel->getContainer();
$entityManager = $container->get('doctrine.orm.entity_manager');
// Finde einen Nicht-Admin Benutzer
$userRepo = $entityManager->getRepository(App\Entity\User::class);
$user = $userRepo->findOneBy(['email' => 'o.schwarten@osdata.net']);
if (!$user) {
echo "Benutzer nicht gefunden!\n";
exit(1);
}
echo "Benutzer: " . $user->getEmail() . "\n";
echo "Rollen: " . implode(', ', $user->getRoles()) . "\n\n";
echo "User Roles (Entities): Count = " . $user->getUserRoles()->count() . "\n";
// Force load the collection
$userRoles = $user->getUserRoles();
if ($userRoles instanceof \Doctrine\ORM\PersistentCollection) {
$userRoles->initialize();
}
foreach ($userRoles as $role) {
echo " - " . $role->getName() . " (ID: " . $role->getId() . ")\n";
$permissions = $role->getPermissions();
if ($permissions instanceof \Doctrine\ORM\PersistentCollection) {
$permissions->initialize();
}
echo " Permissions count: " . $permissions->count() . "\n";
foreach ($permissions as $permission) {
$module = $permission->getModule();
echo " Module: " . $module->getCode() . " - View: " . ($permission->canView() ? 'YES' : 'NO') . "\n";
}
}
echo "\n";
echo "Testing hasModulePermission('billing', 'view'): ";
echo $user->hasModulePermission('billing', 'view') ? "TRUE" : "FALSE";
echo "\n";
echo "Testing hasModulePermission('invoices', 'view'): ";
echo $user->hasModulePermission('invoices', 'view') ? "TRUE" : "FALSE";
echo "\n";
// Test Voter
$authChecker = $container->get('security.authorization_checker');
$tokenStorage = $container->get('security.token_storage');
// Create a token for the user
$token = new Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken(
$user,
'main',
$user->getRoles()
);
$tokenStorage->setToken($token);
echo "\nTesting Voter with is_granted('VIEW', 'billing'): ";
try {
$result = $authChecker->isGranted('VIEW', 'billing');
echo $result ? "TRUE" : "FALSE";
} catch (Exception $e) {
echo "ERROR: " . $e->getMessage();
}
echo "\n";