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 yarn-error.log
###< symfony/webpack-encore-bundle ### ###< symfony/webpack-encore-bundle ###
auth.json auth.json
config/routes/billing.yaml

View File

@ -1,35 +1,78 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { usePermissionStore } from '../stores/permissions';
import AppMenuItem from './AppMenuItem.vue'; import AppMenuItem from './AppMenuItem.vue';
const authStore = useAuthStore(); const authStore = useAuthStore();
const permissionStore = usePermissionStore();
// Core-Menü (immer vorhanden) // Core-Menü mit Permission-Prüfungen
const coreMenu = [ const coreMenu = [
{ {
label: 'Home', 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', label: 'CRM',
items: [ items: [
{ label: 'Kontakte', icon: 'pi pi-fw pi-users', to: '/contacts' }, {
{ label: 'Projekte', icon: 'pi pi-fw pi-briefcase', to: '/projects' }, label: 'Kontakte',
{ label: 'Tätigkeiten', icon: 'pi pi-fw pi-list-check', to: '/project-tasks' } 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 = { const adminMenu = {
label: 'Administration', label: 'Administration',
visible: () => authStore.isAdmin,
items: [ 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: 'Projekt-Status',
{ label: 'Rollenverwaltung', icon: 'pi pi-fw pi-shield', to: '/roles' }, icon: 'pi pi-fw pi-tag',
{ label: 'Einstellungen', icon: 'pi pi-fw pi-cog', to: '/settings' } 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 // Konvertiere zu PrimeVue Menü-Format
const menuGroup = { const menuGroup = {
label: groupLabel, label: groupLabel,
items: items.map(item => ({ items: items.map(item => {
label: item.label, // Erstelle Permission-Prüfung
icon: item.icon ? `pi pi-fw ${item.icon}` : 'pi pi-fw pi-circle', let visibleFn;
to: item.to,
...(item.items && { items: item.items }), if (item.permission) {
// Wenn Permission definiert, prüfen // Explizite Permission aus Plugin
...(item.permission && { visibleFn = () => authStore.hasPermission(item.permission);
visible: () => 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); model.value.push(menuGroup);
@ -74,8 +130,13 @@ const loadPluginMenus = async () => {
} }
}; };
// Beim Mount laden // Beim Mount laden - warten bis Permissions geladen sind
onMounted(() => { onMounted(async () => {
// Warten bis Permissions geladen sind
while (!permissionStore.loaded) {
await new Promise(resolve => setTimeout(resolve, 100));
}
loadPluginMenus(); loadPluginMenus();
}); });
</script> </script>

View File

@ -1,6 +1,6 @@
<script setup> <script setup>
import { useLayout } from './composables/layout'; import { useLayout } from './composables/layout';
import { onBeforeMount, ref, watch } from 'vue'; import { onBeforeMount, ref, watch, computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
const route = useRoute(); const route = useRoute();
@ -29,6 +29,24 @@ const props = defineProps({
const isActiveMenu = ref(false); const isActiveMenu = ref(false);
const itemKey = ref(null); 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(() => { onBeforeMount(() => {
itemKey.value = props.parentItemKey ? props.parentItemKey + '-' + props.index : String(props.index); itemKey.value = props.parentItemKey ? props.parentItemKey + '-' + props.index : String(props.index);
@ -69,19 +87,19 @@ function checkActiveRoute(item) {
</script> </script>
<template> <template>
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActiveMenu }"> <li v-if="isVisible" :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActiveMenu }">
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">{{ item.label }}</div> <div v-if="root" 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"> <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> <i :class="item.icon" class="layout-menuitem-icon"></i>
<span class="layout-menuitem-text">{{ item.label }}</span> <span class="layout-menuitem-text">{{ item.label }}</span>
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items"></i> <i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items"></i>
</a> </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> <i :class="item.icon" class="layout-menuitem-icon"></i>
<span class="layout-menuitem-text">{{ item.label }}</span> <span class="layout-menuitem-text">{{ item.label }}</span>
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items"></i> <i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items"></i>
</router-link> </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"> <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> <app-menu-item v-for="(child, i) in item.items" :key="child" :index="i" :item="child" :parentItemKey="itemKey" :root="false"></app-menu-item>
</ul> </ul>

View File

@ -1051,7 +1051,7 @@ onMounted(async () => {
async function loadCustomers() { async function loadCustomers() {
try { 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') if (!response.ok) throw new Error('Fehler beim Laden der Kunden')
const data = await response.json() const data = await response.json()
@ -1174,7 +1174,7 @@ async function loadGitRepositories(projectId) {
async function loadProjectTasks(projectId) { async function loadProjectTasks(projectId) {
try { 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') if (!response.ok) throw new Error('Fehler beim Laden der Tätigkeiten')
const data = await response.json() const data = await response.json()

View File

@ -7,8 +7,7 @@
"php": ">=8.2", "php": ">=8.2",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"api-platform/doctrine-orm": "*", "api-platform/core": "^4.1",
"api-platform/symfony": "*",
"doctrine/dbal": "^3", "doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.18", "doctrine/doctrine-bundle": "^2.18",
"doctrine/doctrine-migrations-bundle": "^3.6", "doctrine/doctrine-migrations-bundle": "^3.6",
@ -19,37 +18,37 @@
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.1.*", "symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.1.*", "symfony/asset-mapper": "7.3.*",
"symfony/cache": "7.1.*", "symfony/cache": "7.3.*",
"symfony/console": "7.1.*", "symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.1.*", "symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.1.*", "symfony/dotenv": "7.3.*",
"symfony/expression-language": "7.1.*", "symfony/expression-language": "7.3.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.1.*", "symfony/form": "7.3.*",
"symfony/framework-bundle": "7.1.*", "symfony/framework-bundle": "7.3.*",
"symfony/http-client": "7.1.*", "symfony/http-client": "7.3.*",
"symfony/intl": "7.1.*", "symfony/intl": "7.3.*",
"symfony/mailer": "7.1.*", "symfony/mailer": "7.3.*",
"symfony/mime": "7.1.*", "symfony/mime": "7.3.*",
"symfony/monolog-bundle": "^3.0", "symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.1.*", "symfony/notifier": "7.3.*",
"symfony/process": "7.1.*", "symfony/process": "7.3.*",
"symfony/property-access": "7.1.*", "symfony/property-access": "7.3.*",
"symfony/property-info": "7.1.*", "symfony/property-info": "7.3.*",
"symfony/runtime": "7.1.*", "symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.1.*", "symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.1.*", "symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.31", "symfony/stimulus-bundle": "^2.31",
"symfony/string": "7.1.*", "symfony/string": "7.3.*",
"symfony/translation": "7.1.*", "symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.1.*", "symfony/twig-bundle": "7.3.*",
"symfony/ux-turbo": "^2.31", "symfony/ux-turbo": "^2.31",
"symfony/validator": "7.1.*", "symfony/validator": "7.3.*",
"symfony/web-link": "7.1.*", "symfony/web-link": "7.3.*",
"symfony/webpack-encore-bundle": "^2.3", "symfony/webpack-encore-bundle": "^2.3",
"symfony/yaml": "7.1.*", "symfony/yaml": "7.3.*",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0" "twig/twig": "^2.12|^3.0"
}, },
@ -99,19 +98,19 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "7.1.*", "require": "7.3.*",
"docker": true "docker": true
} }
}, },
"require-dev": { "require-dev": {
"doctrine/doctrine-fixtures-bundle": "*", "doctrine/doctrine-fixtures-bundle": "*",
"phpunit/phpunit": "^12.4", "phpunit/phpunit": "^12.4",
"symfony/browser-kit": "7.1.*", "symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.1.*", "symfony/css-selector": "7.3.*",
"symfony/debug-bundle": "7.1.*", "symfony/debug-bundle": "7.3.*",
"symfony/maker-bundle": "^1.0", "symfony/maker-bundle": "^1.0",
"symfony/stopwatch": "7.1.*", "symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.1.*" "symfony/web-profiler-bundle": "7.3.*"
}, },
"repositories": { "repositories": {
"mycrm-test-module": { "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\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
MyCRM\BillingModule\BillingModuleBundle::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; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/settings', name: 'api_settings_')] #[Route('/api/settings', name: 'api_settings_')]
#[IsGranted('ROLE_ADMIN')]
class SettingsController extends AbstractController class SettingsController extends AbstractController
{ {
public function __construct( public function __construct(
@ -21,6 +20,8 @@ class SettingsController extends AbstractController
#[Route('', name: 'get', methods: ['GET'])] #[Route('', name: 'get', methods: ['GET'])]
public function getSettings(): JsonResponse public function getSettings(): JsonResponse
{ {
$this->denyAccessUnlessGranted('VIEW', 'settings');
return $this->json([ return $this->json([
'settings' => $this->settingsService->getAllSettings() 'settings' => $this->settingsService->getAllSettings()
]); ]);
@ -29,6 +30,8 @@ class SettingsController extends AbstractController
#[Route('', name: 'update', methods: ['PUT', 'PATCH'])] #[Route('', name: 'update', methods: ['PUT', 'PATCH'])]
public function updateSettings(Request $request): JsonResponse public function updateSettings(Request $request): JsonResponse
{ {
$this->denyAccessUnlessGranted('MANAGE', 'settings');
$data = json_decode($request->getContent(), true); $data = json_decode($request->getContent(), true);
if (!isset($data['settings'])) { if (!isset($data['settings'])) {

View File

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

View File

@ -22,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection( new GetCollection(
security: "is_granted('VIEW', 'project_statuses')", security: "is_granted('VIEW', 'projects')",
stateless: false stateless: false
), ),
new Get( new Get(
@ -30,7 +30,7 @@ use Symfony\Component\Validator\Constraints as Assert;
stateless: false stateless: false
), ),
new Post( new Post(
security: "is_granted('CREATE', 'project_statuses')", security: "is_granted('CREATE', 'projects')",
stateless: false stateless: false
), ),
new Put( new Put(
@ -191,9 +191,10 @@ class ProjectStatus implements ModuleAwareInterface
/** /**
* Returns the module code this entity belongs to. * Returns the module code this entity belongs to.
* Required by ModuleVoter for permission checks. * Required by ModuleVoter for permission checks.
* ProjectStatus inherits permissions from the projects module.
*/ */
public function getModuleName(): string 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']], denormalizationContext: ['groups' => ['project_task:write']],
order: ['createdAt' => 'DESC'] 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(DateFilter::class, properties: ['createdAt', 'updatedAt'])]
#[ApiFilter(ProjectTaskProjectFilter::class)] #[ApiFilter(ProjectTaskProjectFilter::class)]
class ProjectTask implements ModuleAwareInterface class ProjectTask implements ModuleAwareInterface

View File

@ -265,7 +265,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
foreach ($this->userRoles as $role) { foreach ($this->userRoles as $role) {
foreach ($role->getPermissions() as $permission) { foreach ($role->getPermissions() as $permission) {
if ($permission->getModule()->getCode() === $moduleCode) { if ($permission->getModule()->getCode() === $moduleCode) {
return match($action) { $hasPermission = match($action) {
'view' => $permission->canView(), 'view' => $permission->canView(),
'create' => $permission->canCreate(), 'create' => $permission->canCreate(),
'edit' => $permission->canEdit(), 'edit' => $permission->canEdit(),
@ -274,12 +274,58 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
'manage' => $permission->canManage(), 'manage' => $permission->canManage(),
default => false, default => false,
}; };
// If ANY role grants the permission, return true
if ($hasPermission) {
return true;
}
} }
} }
} }
return false; 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 public function getCreatedAt(): ?\DateTimeImmutable
{ {
return $this->createdAt; return $this->createdAt;

View File

@ -1,11 +1,11 @@
{ {
"api-platform/symfony": { "api-platform/core": {
"version": "4.1", "version": "4.1",
"recipe": { "recipe": {
"repo": "github.com/symfony/recipes", "repo": "github.com/symfony/recipes",
"branch": "main", "branch": "main",
"version": "4.0", "version": "4.0",
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b" "ref": "cb9e6b8ceb9b62f32d41fc8ad72a25d5bd674c6d"
}, },
"files": [ "files": [
"config/packages/api_platform.yaml", "config/packages/api_platform.yaml",
@ -155,6 +155,18 @@
".env.dev" ".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": { "symfony/framework-bundle": {
"version": "7.1", "version": "7.1",
"recipe": { "recipe": {
@ -231,6 +243,18 @@
"config/packages/notifier.yaml" "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": { "symfony/routing": {
"version": "7.1", "version": "7.1",
"recipe": { "recipe": {
@ -297,15 +321,6 @@
"templates/base.html.twig" "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": { "symfony/ux-turbo": {
"version": "2.31", "version": "2.31",
"recipe": { "recipe": {

View File

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