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)
);