From 2a2fac9cdfa18edd91dd7af9461c43fbdc9330fe Mon Sep 17 00:00:00 2001 From: Ghassan Yusuf Date: Tue, 4 Aug 2026 14:51:28 +0300 Subject: [PATCH] Add Roles/MQTT-settings/Backup pages, AdminLTE demo shells, and a separate mobile design system Backend: real endpoints for role-permission overview, MQTT broker settings (with live reachability check), and SQLite backup download/restore (new system.manage permission, super-admin only; restore validates the SQLite file header and keeps a pre-restore safety copy). Desktop frontend: Roles (read-only permissions view), MQTT Settings, Backup & Restore wired to those endpoints; plus static AdminLTE-matching shells for Profile, Settings (embeds the real 2FA flow in its Security tab), Invoice, Calendar (FullCalendar), Chat, File Manager, and Projects. Mobile frontend: new frontend/src/styles/mobile.css, a from-scratch design system (purple theme, cards, slide-out sidebar, bottom tab bar) scoped entirely under a .mob-app root and mob- prefixed classes so it can never collide with the desktop Bootstrap/AdminLTE styling. Rebuilt TopBar/ BottomNav, added a new SlideOutNav, and reskinned the Dashboard and Users pages to it with real data. --- .../Http/Controllers/Api/BackupController.php | 58 +++ .../Http/Controllers/Api/MqttController.php | 49 ++ .../Http/Controllers/Api/RoleController.php | 14 + backend/app/Http/Resources/RoleResource.php | 1 + .../database/seeders/RolePermissionSeeder.php | 1 + backend/routes/api.php | 6 + frontend/package-lock.json | 63 +++ frontend/package.json | 5 + frontend/src/components/mobile/BottomNav.tsx | 15 +- .../src/components/mobile/SlideOutNav.tsx | 48 ++ frontend/src/components/mobile/TopBar.tsx | 35 +- .../src/features/calendar/CalendarPage.tsx | 79 +++ frontend/src/features/chat/ChatPage.tsx | 95 ++++ .../dashboard/DashboardPage.mobile.tsx | 94 ++++ .../src/features/dashboard/DashboardRoute.tsx | 8 + .../features/file-manager/FileManagerPage.tsx | 91 ++++ frontend/src/features/invoice/InvoicePage.tsx | 103 ++++ frontend/src/features/profile/ProfilePage.tsx | 101 ++++ .../src/features/projects/ProjectsPage.tsx | 96 ++++ frontend/src/features/roles/RolesPage.tsx | 46 ++ frontend/src/features/roles/rolesApi.ts | 7 + .../src/features/settings/SettingsPage.tsx | 102 ++++ .../src/features/system/BackupRestorePage.tsx | 91 ++++ .../src/features/system/MqttSettingsPage.tsx | 73 +++ frontend/src/features/system/systemApi.ts | 32 ++ .../features/users/UsersListPage.mobile.tsx | 105 ++-- frontend/src/layouts/shell/MobileShell.tsx | 12 +- frontend/src/layouts/shell/navConfig.ts | 24 +- frontend/src/routes/index.tsx | 32 +- frontend/src/styles/mobile.css | 476 ++++++++++++++++++ frontend/src/types/role.ts | 1 + 31 files changed, 1894 insertions(+), 69 deletions(-) create mode 100644 backend/app/Http/Controllers/Api/BackupController.php create mode 100644 frontend/src/components/mobile/SlideOutNav.tsx create mode 100644 frontend/src/features/calendar/CalendarPage.tsx create mode 100644 frontend/src/features/chat/ChatPage.tsx create mode 100644 frontend/src/features/dashboard/DashboardPage.mobile.tsx create mode 100644 frontend/src/features/dashboard/DashboardRoute.tsx create mode 100644 frontend/src/features/file-manager/FileManagerPage.tsx create mode 100644 frontend/src/features/invoice/InvoicePage.tsx create mode 100644 frontend/src/features/profile/ProfilePage.tsx create mode 100644 frontend/src/features/projects/ProjectsPage.tsx create mode 100644 frontend/src/features/roles/RolesPage.tsx create mode 100644 frontend/src/features/roles/rolesApi.ts create mode 100644 frontend/src/features/settings/SettingsPage.tsx create mode 100644 frontend/src/features/system/BackupRestorePage.tsx create mode 100644 frontend/src/features/system/MqttSettingsPage.tsx create mode 100644 frontend/src/features/system/systemApi.ts create mode 100644 frontend/src/styles/mobile.css diff --git a/backend/app/Http/Controllers/Api/BackupController.php b/backend/app/Http/Controllers/Api/BackupController.php new file mode 100644 index 0000000..7985dcd --- /dev/null +++ b/backend/app/Http/Controllers/Api/BackupController.php @@ -0,0 +1,58 @@ +user()->can('system.manage'), 403); + + $path = config('database.connections.sqlite.database'); + abort_unless($path && is_file($path), 404, 'No database file to back up.'); + + $audit->log($request->user(), 'system.backup_downloaded'); + + return response()->download($path, 'backup-'.now()->format('Y-m-d-His').'.sqlite'); + } + + public function restore(Request $request, AuditLogger $audit) + { + abort_unless($request->user()->can('system.manage'), 403); + + $request->validate(['backup' => ['required', 'file']]); + + $uploaded = $request->file('backup'); + $header = file_get_contents($uploaded->getRealPath(), false, null, 0, 16); + + if ($header !== self::SQLITE_HEADER) { + throw ValidationException::withMessages([ + 'backup' => 'That file is not a valid SQLite database.', + ]); + } + + $path = config('database.connections.sqlite.database'); + + // Release Laravel's open handle on the current file before swapping it. + DB::disconnect(); + + $safetyCopy = dirname($path).'/pre-restore-'.now()->format('Y-m-d-His').'.sqlite'; + if (is_file($path)) { + copy($path, $safetyCopy); + } + + $uploaded->move(dirname($path), basename($path)); + + $audit->log($request->user(), 'system.backup_restored'); + + return response()->noContent(); + } +} diff --git a/backend/app/Http/Controllers/Api/MqttController.php b/backend/app/Http/Controllers/Api/MqttController.php index c82eff6..84a732c 100644 --- a/backend/app/Http/Controllers/Api/MqttController.php +++ b/backend/app/Http/Controllers/Api/MqttController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use Illuminate\Http\Request; +use PhpMqtt\Client\ConnectionSettings; +use PhpMqtt\Client\MqttClient; class MqttController extends Controller { @@ -25,4 +27,51 @@ class MqttController extends Controller 'password' => $credentials['password'], ]); } + + /** + * Read-only broker configuration + a live reachability check, for the + * MQTT settings page. Never exposes the publisher/role credentials. + */ + public function settings(Request $request) + { + abort_unless($request->user()->can('system.manage'), 403); + + $host = config('mqtt-client.connections.default.host'); + $port = config('mqtt-client.connections.default.port'); + $tls = (bool) config('mqtt-client.connections.default.connection_settings.tls.enabled'); + $username = config('mqtt-client.connections.default.connection_settings.auth.username'); + $password = config('mqtt-client.connections.default.connection_settings.auth.password'); + + $connected = $this->checkBrokerReachable($host, $port, $tls, $username, $password); + + return response()->json([ + 'host' => $host, + 'port' => $port, + 'ws_url' => config('mqtt.ws_url'), + 'tls_enabled' => $tls, + 'connected' => $connected, + ]); + } + + private function checkBrokerReachable(?string $host, ?int $port, bool $tls, ?string $username, ?string $password): bool + { + if (! $host || ! $port) { + return false; + } + + try { + $client = new MqttClient($host, $port, 'health-check-'.uniqid()); + $settings = (new ConnectionSettings) + ->setConnectTimeout(2) + ->setUseTls($tls) + ->setUsername($username) + ->setPassword($password); + $client->connect($settings, true); + $client->disconnect(); + + return true; + } catch (\Throwable) { + return false; + } + } } diff --git a/backend/app/Http/Controllers/Api/RoleController.php b/backend/app/Http/Controllers/Api/RoleController.php index 4a61d0f..e92daca 100644 --- a/backend/app/Http/Controllers/Api/RoleController.php +++ b/backend/app/Http/Controllers/Api/RoleController.php @@ -17,4 +17,18 @@ class RoleController extends Controller // seeding/tinker, never assignable through the Users module. return RoleResource::collection(Role::where('name', '!=', 'super-admin')->orderBy('name')->get()); } + + /** + * Read-only view of every role (including Super Admin) and its + * permission set — for the Roles page, distinct from index() which + * feeds the assignable-role dropdown in the Users module. + */ + public function overview(Request $request) + { + abort_unless($request->user()->can('roles.view'), 403); + + $roles = Role::with('permissions')->orderBy('name')->get(); + + return RoleResource::collection($roles); + } } diff --git a/backend/app/Http/Resources/RoleResource.php b/backend/app/Http/Resources/RoleResource.php index eeffe0c..d8b93fb 100644 --- a/backend/app/Http/Resources/RoleResource.php +++ b/backend/app/Http/Resources/RoleResource.php @@ -14,6 +14,7 @@ class RoleResource extends JsonResource { return [ 'name' => $this->name, + 'permissions' => $this->whenLoaded('permissions', fn () => $this->permissions->pluck('name')), ]; } } diff --git a/backend/database/seeders/RolePermissionSeeder.php b/backend/database/seeders/RolePermissionSeeder.php index 8fa97fc..f0acb26 100644 --- a/backend/database/seeders/RolePermissionSeeder.php +++ b/backend/database/seeders/RolePermissionSeeder.php @@ -17,6 +17,7 @@ class RolePermissionSeeder extends Seeder 'roles.view', 'roles.assign', 'dashboard.view', 'audit.view', + 'system.manage', ]; private const ROLE_PERMISSIONS = [ diff --git a/backend/routes/api.php b/backend/routes/api.php index 19d97a8..a9196fc 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,7 @@ middleware('throttle:api')->group(function () { Route::post('/auth/2fa/disable', [AuthController::class, 'disableTwoFactor']); Route::get('/mqtt/token', [MqttController::class, 'token']); + Route::get('/mqtt/settings', [MqttController::class, 'settings']); Route::get('/roles', [RoleController::class, 'index']); + Route::get('/roles/overview', [RoleController::class, 'overview']); Route::get('/users', [UserController::class, 'index']); Route::post('/users', [UserController::class, 'store']); @@ -31,5 +34,8 @@ Route::prefix('v1')->middleware('throttle:api')->group(function () { Route::get('/dashboard/stats', [DashboardController::class, 'stats']); Route::get('/dashboard/activity', [DashboardController::class, 'activity']); + + Route::get('/backup/download', [BackupController::class, 'download']); + Route::post('/backup/restore', [BackupController::class, 'restore']); }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 04fdb07..38890df 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,11 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@fullcalendar/core": "^6.1.21", + "@fullcalendar/daygrid": "^6.1.21", + "@fullcalendar/interaction": "^6.1.21", + "@fullcalendar/list": "^6.1.21", + "@fullcalendar/react": "^6.1.21", "@tanstack/react-query": "^5.101.4", "admin-lte": "^4.1.0", "axios": "^1.19.0", @@ -40,6 +45,54 @@ "node": ">=6.9.0" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz", + "integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz", + "integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.21.tgz", + "integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/list": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.21.tgz", + "integrity": "sha512-2rpIhs5pJmV7jyk4oX4bckNqurt6iHcsweE3FDYDdNpmRukPrARnyQYcaVFNVw2bnBFeR/jQW/St2MlauxF3GQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.21.tgz", + "integrity": "sha512-TLpmGUd5k/PMdCh8XbeFC9PW9wuGvMms1oCxWgXyjK3EFPXAAd0PLfcvwKdyxoAS5eK1E4RJFkjMHvsYHpimcg==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21", + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2626,6 +2679,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", diff --git a/frontend/package.json b/frontend/package.json index e1639ec..22985c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@fullcalendar/core": "^6.1.21", + "@fullcalendar/daygrid": "^6.1.21", + "@fullcalendar/interaction": "^6.1.21", + "@fullcalendar/list": "^6.1.21", + "@fullcalendar/react": "^6.1.21", "@tanstack/react-query": "^5.101.4", "admin-lte": "^4.1.0", "axios": "^1.19.0", diff --git a/frontend/src/components/mobile/BottomNav.tsx b/frontend/src/components/mobile/BottomNav.tsx index 7f7e18b..6376a8e 100644 --- a/frontend/src/components/mobile/BottomNav.tsx +++ b/frontend/src/components/mobile/BottomNav.tsx @@ -1,25 +1,20 @@ import { NavLink } from 'react-router-dom' -import { NAV_ITEMS } from '../../layouts/shell/navConfig' +import { MOBILE_NAV_ITEMS } from '../../layouts/shell/navConfig' import { usePermission } from '../../hooks/usePermission' export function BottomNav() { const { can } = usePermission() - const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission)) + const items = MOBILE_NAV_ITEMS.filter((item) => !item.permission || can(item.permission)) return ( -