Skip to content

Webshop - Admin Interface

Overview

The admin interface is a custom Twig-based backoffice with role-based access control. Three user levels exist: Super Admin, Tenant Owner, and Tenant Admin.

User Roles & Permissions

Role Hierarchy

Super Admin
    └── Tenant Owner
            └── Tenant Admin

Permission Matrix

Feature Super Admin Tenant Owner Tenant Admin
Tenant Management
Create/edit tenants - -
Configure tenant settings Own only -
View all tenants - -
Master Data
Manage products - -
Manage vehicles - -
Manage brands/categories - -
Set visibility rules - -
Customer Management
View customers Own tenant Own tenant
Approve customers Own tenant Configurable
Edit customer margins Own tenant -
Order Management
View orders Own tenant Own tenant
Cancel orders Own tenant Configurable
EDI Management
View EDI clients Own tenant -
Create/edit EDI clients Own tenant -
Pricing
Set base prices - -
Set tenant prices Own tenant -
Loyalty & Coupons
Configure loyalty Own tenant -
Manage coupons Own tenant Configurable
Email Templates
Edit layout/template - -
Edit content -
Branding
Upload logo Own tenant -

User Entity

#[ORM\Entity]
class User implements UserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 180, unique: true)]
    private string $email;

    #[ORM\Column]
    private string $passwordHash;

    #[ORM\Column(length: 100)]
    private string $firstName;

    #[ORM\Column(length: 100)]
    private string $lastName;

    #[ORM\Column(length: 20)]
    private string $role; // 'super_admin', 'tenant_owner', 'tenant_admin'

    #[ORM\ManyToOne(targetEntity: Tenant::class)]
    private ?Tenant $tenant; // null for super_admin

    #[ORM\Column(type: 'json')]
    private array $permissions = []; // For tenant_admin granular permissions

    #[ORM\Column]
    private bool $isActive = true;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $lastLoginAt;

    public function getRoles(): array
    {
        return match ($this->role) {
            'super_admin' => ['ROLE_SUPER_ADMIN', 'ROLE_TENANT_OWNER', 'ROLE_TENANT_ADMIN'],
            'tenant_owner' => ['ROLE_TENANT_OWNER', 'ROLE_TENANT_ADMIN'],
            'tenant_admin' => ['ROLE_TENANT_ADMIN'],
            default => [],
        };
    }

    public function hasPermission(string $permission): bool
    {
        if ($this->role === 'super_admin' || $this->role === 'tenant_owner') {
            return true;
        }

        return in_array($permission, $this->permissions);
    }
}

Security Voter

class AdminVoter extends Voter
{
    protected function supports(string $attribute, mixed $subject): bool
    {
        return str_starts_with($attribute, 'ADMIN_');
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();

        if (!$user instanceof User) {
            return false;
        }

        return match ($attribute) {
            'ADMIN_MANAGE_TENANTS' => $user->getRole() === 'super_admin',
            'ADMIN_MANAGE_PRODUCTS' => $user->getRole() === 'super_admin',
            'ADMIN_MANAGE_VEHICLES' => $user->getRole() === 'super_admin',
            'ADMIN_VIEW_CUSTOMERS' => $this->canAccessTenant($user, $subject),
            'ADMIN_APPROVE_CUSTOMERS' => $this->canApproveCustomers($user),
            'ADMIN_MANAGE_EDI' => $this->canAccessTenant($user, $subject),
            'ADMIN_MANAGE_COUPONS' => $this->canManageCoupons($user),
            default => false,
        };
    }

    private function canAccessTenant(User $user, ?Tenant $tenant): bool
    {
        if ($user->getRole() === 'super_admin') {
            return true;
        }

        return $user->getTenant()?->getId() === $tenant?->getId();
    }

    private function canApproveCustomers(User $user): bool
    {
        if (in_array($user->getRole(), ['super_admin', 'tenant_owner'])) {
            return true;
        }

        return $user->hasPermission('approve_customers');
    }

    private function canManageCoupons(User $user): bool
    {
        if (in_array($user->getRole(), ['super_admin', 'tenant_owner'])) {
            return true;
        }

        return $user->hasPermission('manage_coupons');
    }
}

Admin Controller Structure

src/Presentation/Admin/Controller/
├── DashboardController.php
├── Tenant/
│   ├── TenantController.php
│   └── TenantSettingsController.php
├── Product/
│   ├── ArticleController.php
│   ├── BrandController.php
│   └── CategoryController.php
├── Vehicle/
│   ├── VehicleController.php
│   └── VehicleMappingController.php
├── Customer/
│   ├── CustomerController.php
│   └── CustomerApprovalController.php
├── Order/
│   └── OrderController.php
├── Edi/
│   └── EdiClientController.php
├── Coupon/
│   └── CouponController.php
├── Loyalty/
│   └── LoyaltyConfigController.php
├── Email/
│   └── EmailTemplateController.php
└── User/
    └── UserController.php

Dashboard

class DashboardController
{
    #[Route('/admin', name: 'admin_dashboard')]
    public function index(): Response
    {
        $user = $this->getUser();
        $tenant = $user->getTenant();

        if ($user->getRole() === 'super_admin') {
            return $this->renderSuperAdminDashboard();
        }

        return $this->renderTenantDashboard($tenant);
    }

    private function renderSuperAdminDashboard(): Response
    {
        return $this->render('admin/dashboard/super_admin.html.twig', [
            'stats' => [
                'total_tenants' => $this->tenantRepository->count([]),
                'total_orders_today' => $this->orderRepository->countToday(),
                'total_revenue_today' => $this->orderRepository->sumRevenueToday(),
                'pending_approvals' => $this->customerRepository->countPendingApprovals(),
            ],
            'recent_orders' => $this->orderRepository->findRecent(10),
        ]);
    }

    private function renderTenantDashboard(Tenant $tenant): Response
    {
        return $this->render('admin/dashboard/tenant.html.twig', [
            'stats' => [
                'orders_today' => $this->orderRepository->countTodayForTenant($tenant),
                'revenue_today' => $this->orderRepository->sumRevenueTodayForTenant($tenant),
                'pending_approvals' => $this->customerRepository->countPendingForTenant($tenant),
                'active_customers' => $this->customerRepository->countActiveForTenant($tenant),
            ],
            'recent_orders' => $this->orderRepository->findRecentForTenant($tenant, 10),
        ]);
    }
}

Customer Management

class CustomerController
{
    #[Route('/admin/customers', name: 'admin_customers')]
    #[IsGranted('ADMIN_VIEW_CUSTOMERS', subject: 'tenant')]
    public function index(Request $request): Response
    {
        $user = $this->getUser();

        $criteria = new CustomerSearchCriteria();
        $criteria->status = $request->query->get('status');
        $criteria->search = $request->query->get('search');

        if ($user->getRole() !== 'super_admin') {
            $criteria->tenant = $user->getTenant();
        }

        $customers = $this->customerRepository->search($criteria);

        return $this->render('admin/customers/index.html.twig', [
            'customers' => $customers,
            'filters' => $criteria,
        ]);
    }

    #[Route('/admin/customers/pending', name: 'admin_customers_pending')]
    #[IsGranted('ADMIN_APPROVE_CUSTOMERS')]
    public function pending(): Response
    {
        $user = $this->getUser();
        $tenant = $user->getRole() === 'super_admin' ? null : $user->getTenant();

        $customers = $this->customerRepository->findPending($tenant);

        return $this->render('admin/customers/pending.html.twig', [
            'customers' => $customers,
        ]);
    }

    #[Route('/admin/customers/{id}/approve', name: 'admin_customer_approve', methods: ['POST'])]
    #[IsGranted('ADMIN_APPROVE_CUSTOMERS')]
    public function approve(int $id): Response
    {
        $customer = $this->customerRepository->find($id);

        $this->denyAccessUnlessGranted('ADMIN_VIEW_CUSTOMERS', $customer->getTenant());

        $customer->setStatus('approved');
        $customer->setApprovedAt(new \DateTimeImmutable());
        $customer->setApprovedBy($this->getUser());
        $customer->setApprovalType('manual');

        $this->entityManager->flush();

        // Send approval email
        $this->eventDispatcher->dispatch(new CustomerApprovedEvent($customer));

        return $this->redirectToRoute('admin_customers_pending');
    }

    #[Route('/admin/customers/{id}/reject', name: 'admin_customer_reject', methods: ['POST'])]
    #[IsGranted('ADMIN_APPROVE_CUSTOMERS')]
    public function reject(int $id, Request $request): Response
    {
        $customer = $this->customerRepository->find($id);

        $this->denyAccessUnlessGranted('ADMIN_VIEW_CUSTOMERS', $customer->getTenant());

        $customer->setStatus('rejected');
        $reason = $request->request->get('reason');

        $this->entityManager->flush();

        // Send rejection email
        $this->eventDispatcher->dispatch(new CustomerRejectedEvent($customer, $reason));

        return $this->redirectToRoute('admin_customers_pending');
    }
}

Tenant Admin Permission Configuration

class TenantAdminController
{
    #[Route('/admin/users/{id}/permissions', name: 'admin_user_permissions')]
    #[IsGranted('ROLE_TENANT_OWNER')]
    public function editPermissions(int $id, Request $request): Response
    {
        $adminUser = $this->userRepository->find($id);

        // Can only edit users in own tenant
        if ($adminUser->getTenant() !== $this->getUser()->getTenant()) {
            throw $this->createAccessDeniedException();
        }

        // Can only edit tenant_admin users
        if ($adminUser->getRole() !== 'tenant_admin') {
            throw $this->createAccessDeniedException();
        }

        $availablePermissions = [
            'approve_customers' => 'Approve customer registrations',
            'cancel_orders' => 'Cancel orders',
            'manage_coupons' => 'Create and edit coupons',
            'view_reports' => 'View reports and statistics',
        ];

        if ($request->isMethod('POST')) {
            $selectedPermissions = $request->request->all('permissions');
            $adminUser->setPermissions($selectedPermissions);

            $this->entityManager->flush();

            return $this->redirectToRoute('admin_users');
        }

        return $this->render('admin/users/permissions.html.twig', [
            'user' => $adminUser,
            'availablePermissions' => $availablePermissions,
        ]);
    }
}

Gherkin Scenarios

Feature: Admin Access Control
  As an admin user
  I want appropriate access to features
  Based on my role

  Scenario: Super Admin access
    Given I am logged in as Super Admin
    Then I should see the tenant management menu
    And I should see the product management menu
    And I should be able to view all tenants' data

  Scenario: Tenant Owner access
    Given I am logged in as Tenant Owner for "ACME"
    Then I should not see the tenant management menu
    And I should not see the product management menu
    And I should only see ACME customers
    And I should be able to manage ACME coupons

  Scenario: Tenant Admin with limited permissions
    Given I am logged in as Tenant Admin
    And I have permission to approve customers
    But I don't have permission to manage coupons
    Then I should be able to approve customers
    But I should not see the coupons menu

  Scenario: Tenant Owner configures Tenant Admin permissions
    Given I am logged in as Tenant Owner
    When I edit a Tenant Admin's permissions
    And I enable "approve_customers" permission
    Then that admin should be able to approve customers

  Scenario: Cross-tenant access denied
    Given I am logged in as Tenant Owner for "ACME"
    When I try to view a customer from tenant "OTHER"
    Then I should see "Access Denied"

Feature: Customer Approval
  As a Tenant Owner
  I want to approve or reject customer registrations
  So that only valid businesses can order

  Scenario: Approve pending customer
    Given a pending customer registration
    When I approve the customer
    Then the customer status should be "approved"
    And the customer should receive an approval email

  Scenario: Reject pending customer
    Given a pending customer registration
    When I reject the customer with reason "Invalid VAT"
    Then the customer status should be "rejected"
    And the customer should receive a rejection email with the reason

Database Schema

-- Admin users
CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(180) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    role VARCHAR(20) NOT NULL,
    tenant_id INT,
    permissions JSON NOT NULL DEFAULT '[]',
    is_active BOOLEAN DEFAULT TRUE,
    last_login_at DATETIME,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id),
    INDEX idx_tenant (tenant_id),
    INDEX idx_role (role)
);

-- Admin activity log
CREATE TABLE admin_activity_log (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    action VARCHAR(100) NOT NULL,
    entity_type VARCHAR(50),
    entity_id INT,
    details JSON,
    ip_address VARCHAR(45),
    created_at DATETIME NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id),
    INDEX idx_user (user_id),
    INDEX idx_created (created_at)
);