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.
This commit is contained in:
Ghassan Yusuf 2026-08-04 14:51:28 +03:00
parent 84cd474bcb
commit 2a2fac9cdf
31 changed files with 1894 additions and 69 deletions

View File

@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Audit\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class BackupController extends Controller
{
private const SQLITE_HEADER = "SQLite format 3\000";
public function download(Request $request, AuditLogger $audit)
{
abort_unless($request->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();
}
}

View File

@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use PhpMqtt\Client\ConnectionSettings;
use PhpMqtt\Client\MqttClient;
class MqttController extends Controller class MqttController extends Controller
{ {
@ -25,4 +27,51 @@ class MqttController extends Controller
'password' => $credentials['password'], '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;
}
}
} }

View File

@ -17,4 +17,18 @@ class RoleController extends Controller
// seeding/tinker, never assignable through the Users module. // seeding/tinker, never assignable through the Users module.
return RoleResource::collection(Role::where('name', '!=', 'super-admin')->orderBy('name')->get()); 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);
}
} }

View File

@ -14,6 +14,7 @@ class RoleResource extends JsonResource
{ {
return [ return [
'name' => $this->name, 'name' => $this->name,
'permissions' => $this->whenLoaded('permissions', fn () => $this->permissions->pluck('name')),
]; ];
} }
} }

View File

@ -17,6 +17,7 @@ class RolePermissionSeeder extends Seeder
'roles.view', 'roles.assign', 'roles.view', 'roles.assign',
'dashboard.view', 'dashboard.view',
'audit.view', 'audit.view',
'system.manage',
]; ];
private const ROLE_PERMISSIONS = [ private const ROLE_PERMISSIONS = [

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\Api\AuthController; use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\BackupController;
use App\Http\Controllers\Api\DashboardController; use App\Http\Controllers\Api\DashboardController;
use App\Http\Controllers\Api\MqttController; use App\Http\Controllers\Api\MqttController;
use App\Http\Controllers\Api\RoleController; use App\Http\Controllers\Api\RoleController;
@ -19,8 +20,10 @@ Route::prefix('v1')->middleware('throttle:api')->group(function () {
Route::post('/auth/2fa/disable', [AuthController::class, 'disableTwoFactor']); Route::post('/auth/2fa/disable', [AuthController::class, 'disableTwoFactor']);
Route::get('/mqtt/token', [MqttController::class, 'token']); Route::get('/mqtt/token', [MqttController::class, 'token']);
Route::get('/mqtt/settings', [MqttController::class, 'settings']);
Route::get('/roles', [RoleController::class, 'index']); Route::get('/roles', [RoleController::class, 'index']);
Route::get('/roles/overview', [RoleController::class, 'overview']);
Route::get('/users', [UserController::class, 'index']); Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store']); 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/stats', [DashboardController::class, 'stats']);
Route::get('/dashboard/activity', [DashboardController::class, 'activity']); Route::get('/dashboard/activity', [DashboardController::class, 'activity']);
Route::get('/backup/download', [BackupController::class, 'download']);
Route::post('/backup/restore', [BackupController::class, 'restore']);
}); });
}); });

View File

@ -8,6 +8,11 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "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", "@tanstack/react-query": "^5.101.4",
"admin-lte": "^4.1.0", "admin-lte": "^4.1.0",
"axios": "^1.19.0", "axios": "^1.19.0",
@ -40,6 +45,54 @@
"node": ">=6.9.0" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -2626,6 +2679,16 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/process": {
"version": "0.11.10", "version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",

View File

@ -10,6 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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", "@tanstack/react-query": "^5.101.4",
"admin-lte": "^4.1.0", "admin-lte": "^4.1.0",
"axios": "^1.19.0", "axios": "^1.19.0",

View File

@ -1,25 +1,20 @@
import { NavLink } from 'react-router-dom' 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' import { usePermission } from '../../hooks/usePermission'
export function BottomNav() { export function BottomNav() {
const { can } = usePermission() 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 ( return (
<nav className="flex h-14 items-stretch border-t border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"> <nav className="mob-tab-bar">
{items.map((item) => ( {items.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
className={({ isActive }) => className={({ isActive }) => `mob-tab-item ${isActive ? 'mob-active' : ''}`}
`flex flex-1 items-center justify-center text-sm font-medium ${
isActive
? 'text-blue-700 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400'
}`
}
> >
<span className={`mob-nav-icon bi ${item.icon}`}></span>
{item.label} {item.label}
</NavLink> </NavLink>
))} ))}

View File

@ -0,0 +1,48 @@
import { NavLink } from 'react-router-dom'
import { NAV_GROUPS } from '../../layouts/shell/navConfig'
import { usePermission } from '../../hooks/usePermission'
interface SlideOutNavProps {
open: boolean
onClose: () => void
}
export function SlideOutNav({ open, onClose }: SlideOutNavProps) {
const { can } = usePermission()
return (
<>
<div className={`mob-sidebar-backdrop ${open ? 'mob-show' : ''}`} onClick={onClose} />
<nav className={`mob-sidebar ${open ? 'mob-open' : ''}`} aria-label="Main navigation">
<div className="mob-sidebar-header">
<span className="mob-sidebar-title">webappAIO</span>
<button className="mob-sidebar-close" onClick={onClose} aria-label="Close menu">
</button>
</div>
{NAV_GROUPS.map((group) => {
const items = group.items.filter((item) => !item.permission || can(item.permission))
if (items.length === 0) return null
return (
<div key={group.header}>
<div className="mob-sidebar-section-label">{group.header}</div>
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
onClick={onClose}
className={({ isActive }) => `mob-sidebar-link ${isActive ? 'mob-active' : ''}`}
>
<span className={`mob-nav-icon bi ${item.icon}`}></span>
{item.label}
</NavLink>
))}
</div>
)
})}
</nav>
</>
)
}

View File

@ -1,17 +1,34 @@
import { useAuth } from '../../features/auth/AuthContext' import { useAuth } from '../../features/auth/AuthContext'
import { useNotifications } from '../desktop/useNotifications'
export function TopBar() { interface TopBarProps {
onMenuClick: () => void
}
export function TopBar({ onMenuClick }: TopBarProps) {
const { logout } = useAuth() const { logout } = useAuth()
const notifications = useNotifications()
return ( return (
<header className="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900"> <header className="mob-header">
<span className="font-semibold text-gray-900 dark:text-gray-100">webappAIO</span> <div className="mob-header-left">
<button <button className="mob-menu-btn" onClick={onMenuClick} aria-label="Open menu">
onClick={() => logout()} <i className="bi bi-list"></i>
className="rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800" </button>
> <div className="mob-title">
Sign out <span>webappAIO</span>
</button> <span>Dashboard</span>
</div>
</div>
<div className="mob-header-actions">
<button className="mob-icon-btn" aria-label={`Notifications: ${notifications.length} unread`}>
<i className="bi bi-bell-fill"></i>
{notifications.length > 0 && <span className="mob-badge-dot"></span>}
</button>
<button className="mob-icon-btn" onClick={() => logout()} aria-label="Sign out">
<i className="bi bi-box-arrow-right"></i>
</button>
</div>
</header> </header>
) )
} }

View File

@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from 'react'
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin, { Draggable } from '@fullcalendar/interaction'
import listPlugin from '@fullcalendar/list'
import { PageHeader } from '../../components/desktop/PageHeader'
import { Card } from '../../components/desktop/Card'
const INITIAL_EVENTS = [
{ title: 'Team standup', date: new Date().toISOString().slice(0, 10) },
]
const DRAGGABLE_EVENTS = ['Customer call', 'Design review', 'Sprint planning', 'Deploy release']
export function CalendarPage() {
const draggableRef = useRef<HTMLDivElement>(null)
const [removeAfterDrop, setRemoveAfterDrop] = useState(true)
useEffect(() => {
if (!draggableRef.current) return
const draggable = new Draggable(draggableRef.current, {
itemSelector: '.draggable-event',
eventData: (el) => ({ title: el.innerText }),
})
return () => draggable.destroy()
}, [])
return (
<>
<PageHeader title="Calendar" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Calendar' }]} />
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-md-3">
<Card title="Draggable events">
<div ref={draggableRef}>
{DRAGGABLE_EVENTS.map((title) => (
<div key={title} className="draggable-event fc-event p-2 mb-2 bg-primary text-white rounded">
{title}
</div>
))}
</div>
<div className="form-check mt-3">
<input
type="checkbox"
className="form-check-input"
checked={removeAfterDrop}
onChange={(e) => setRemoveAfterDrop(e.target.checked)}
/>
<label className="form-check-label">Remove after drop</label>
</div>
<p className="text-secondary fs-7 mt-2 mb-0">
Static demo dropped events aren't persisted to a backend.
</p>
</Card>
</div>
<div className="col-md-9">
<Card>
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin, listPlugin]}
initialView="dayGridMonth"
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,listWeek' }}
droppable
editable
events={INITIAL_EVENTS}
eventReceive={(info) => {
if (removeAfterDrop) {
info.draggedEl.parentElement?.removeChild(info.draggedEl)
}
}}
/>
</Card>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,95 @@
import { useState } from 'react'
import { PageHeader } from '../../components/desktop/PageHeader'
interface Contact {
id: number
name: string
preview: string
time: string
}
interface Message {
from: 'me' | 'them'
text: string
}
const CONTACTS: Contact[] = [
{ id: 1, name: 'Alice Manager', preview: 'Sounds good, thanks!', time: '2:30 PM' },
{ id: 2, name: 'Bob Admin', preview: 'Can you review the PR?', time: '11:05 AM' },
{ id: 3, name: 'Carol User', preview: 'See you tomorrow', time: 'Yesterday' },
]
const MESSAGES: Record<number, Message[]> = {
1: [
{ from: 'them', text: 'Hey, got a minute?' },
{ from: 'me', text: 'Sure, what\'s up?' },
{ from: 'them', text: 'Sounds good, thanks!' },
],
2: [{ from: 'them', text: 'Can you review the PR?' }],
3: [{ from: 'them', text: 'See you tomorrow' }],
}
export function ChatPage() {
const [activeId, setActiveId] = useState(1)
const [draft, setDraft] = useState('')
const active = CONTACTS.find((c) => c.id === activeId)!
return (
<>
<PageHeader title="Chat" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Chat' }]} />
<div className="app-content">
<div className="container-fluid">
<div className="card">
<div className="row g-0" style={{ minHeight: '480px' }}>
<div className="col-md-4 border-end">
<div className="list-group list-group-flush">
{CONTACTS.map((c) => (
<button
key={c.id}
onClick={() => setActiveId(c.id)}
className={`list-group-item list-group-item-action d-flex justify-content-between align-items-start ${
c.id === activeId ? 'active' : ''
}`}
>
<div>
<div className="fw-semibold">{c.name}</div>
<div className="fs-7 text-truncate" style={{ maxWidth: '160px' }}>
{c.preview}
</div>
</div>
<span className="fs-7 text-secondary">{c.time}</span>
</button>
))}
</div>
</div>
<div className="col-md-8 d-flex flex-column">
<div className="p-3 border-bottom fw-semibold">{active.name}</div>
<div className="flex-grow-1 p-3" style={{ overflowY: 'auto' }}>
{(MESSAGES[activeId] ?? []).map((m, i) => (
<div key={i} className={`d-flex mb-2 ${m.from === 'me' ? 'justify-content-end' : ''}`}>
<span className={`px-3 py-2 rounded-3 ${m.from === 'me' ? 'bg-primary text-white' : 'bg-body-secondary'}`}>
{m.text}
</span>
</div>
))}
</div>
<div className="p-3 border-top d-flex gap-2">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="form-control"
placeholder="Type a message… (not sent anywhere)"
/>
<button className="btn btn-primary" disabled>
Send
</button>
</div>
</div>
</div>
</div>
<p className="text-secondary fs-7 mt-2 mb-0">Static demo no messaging backend exists yet.</p>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,94 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../auth/AuthContext'
import { usePermission } from '../../hooks/usePermission'
import { useMqttTopic } from '../../hooks/useMqtt'
import { fetchStats, fetchActivity } from './dashboardApi'
export function DashboardPageMobile() {
const { user } = useAuth()
const { can } = usePermission()
const queryClient = useQueryClient()
const { data: stats } = useQuery({ queryKey: ['dashboard', 'stats'], queryFn: fetchStats })
const { data: activity } = useQuery({
queryKey: ['dashboard', 'activity'],
queryFn: fetchActivity,
enabled: can('audit.view'),
})
useMqttTopic('module/users/events', () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
})
const roleEntries = Object.entries(stats?.users_by_role ?? {})
const maxRoleCount = Math.max(1, ...roleEntries.map(([, v]) => v))
return (
<div>
<p style={{ color: 'var(--mob-text-medium)', marginBottom: 14 }}>Welcome, {user?.name}.</p>
<div className="mob-kpi-grid">
<div className="mob-kpi-card">
<div className="mob-kpi-label">Total Users</div>
<div className="mob-kpi-value">{stats?.total_users ?? '—'}</div>
</div>
</div>
<div className="mob-section-card">
<div className="mob-section-header">
<h4>Users by role</h4>
</div>
{roleEntries.map(([label, value]) => (
<div key={label} style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span style={{ textTransform: 'capitalize' }}>{label}</span>
<span style={{ color: 'var(--mob-text-light)' }}>{value}</span>
</div>
<div
style={{
height: 7,
background: 'var(--mob-border)',
borderRadius: 999,
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${(value / maxRoleCount) * 100}%`,
background: 'linear-gradient(90deg, var(--mob-primary), var(--mob-primary-light))',
borderRadius: 999,
}}
/>
</div>
</div>
))}
</div>
{can('audit.view') && (
<div className="mob-section-card">
<div className="mob-section-header">
<h4>Recent activity</h4>
</div>
{(activity ?? []).length === 0 ? (
<p style={{ color: 'var(--mob-text-light)', margin: 0 }}>No activity yet.</p>
) : (
(activity ?? []).map((item, i) => (
<div key={i} className="mob-list-item">
<span className="mob-avatar">{item.actor_name.slice(0, 2).toUpperCase()}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{item.actor_name} {item.action.replace('.', ' ')}
</div>
<div style={{ fontSize: 11, color: 'var(--mob-text-light)' }}>
{new Date(item.created_at).toLocaleString()}
</div>
</div>
</div>
))
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,8 @@
import { useIsMobile } from '../../layouts/shell/useIsMobile'
import { DashboardPage } from './DashboardPage'
import { DashboardPageMobile } from './DashboardPage.mobile'
export function DashboardRoute() {
const isMobile = useIsMobile()
return isMobile ? <DashboardPageMobile /> : <DashboardPage />
}

View File

@ -0,0 +1,91 @@
import { PageHeader } from '../../components/desktop/PageHeader'
const CATEGORIES = ['My Drive', 'Documents', 'Shared with me', 'Starred', 'Recent', 'Trash']
interface Entry {
name: string
type: 'folder' | 'file'
size?: string
modified: string
}
const ENTRIES: Entry[] = [
{ name: 'Reports', type: 'folder', modified: 'Aug 1, 2026' },
{ name: 'Contracts', type: 'folder', modified: 'Jul 28, 2026' },
{ name: 'Q3-summary.pdf', type: 'file', size: '2.4 MB', modified: 'Aug 2, 2026' },
{ name: 'logo.png', type: 'file', size: '340 KB', modified: 'Jul 20, 2026' },
]
export function FileManagerPage() {
return (
<>
<PageHeader title="File Manager" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'File Manager' }]} />
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-md-3">
<div className="card">
<div className="card-body">
<button className="btn btn-primary w-100 mb-2" disabled>
<i className="bi bi-upload me-1"></i>Upload
</button>
<button className="btn btn-outline-secondary w-100 mb-3" disabled>
<i className="bi bi-folder-plus me-1"></i>New folder
</button>
<ul className="list-group list-group-flush">
{CATEGORIES.map((c) => (
<li key={c} className="list-group-item">
<i className="bi bi-folder me-2"></i>
{c}
</li>
))}
</ul>
<div className="mt-3">
<div className="fs-7 text-secondary mb-1">6.2 GB of 15 GB used</div>
<div className="progress">
<div className="progress-bar" style={{ width: '41%' }}></div>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-9">
<nav aria-label="breadcrumb" className="mb-2">
<ol className="breadcrumb">
<li className="breadcrumb-item">My Drive</li>
</ol>
</nav>
<div className="card">
<div className="table-responsive">
<table className="table table-hover mb-0">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
{ENTRIES.map((entry) => (
<tr key={entry.name}>
<td>
<i className={`bi ${entry.type === 'folder' ? 'bi-folder-fill text-warning' : 'bi-file-earmark'} me-2`}></i>
{entry.name}
</td>
<td>{entry.size ?? '—'}</td>
<td>{entry.modified}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<p className="text-secondary fs-7 mt-2 mb-0">Static demo no file storage backend exists yet.</p>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,103 @@
import { PageHeader } from '../../components/desktop/PageHeader'
import { Card } from '../../components/desktop/Card'
const LINE_ITEMS = [
{ description: 'Premium support plan', qty: 1, unitPrice: 200, amount: 200 },
{ description: 'Extra seats (5)', qty: 5, unitPrice: 20, amount: 100 },
{ description: 'Onboarding session', qty: 1, unitPrice: 50, amount: 50 },
]
export function InvoicePage() {
const subtotal = LINE_ITEMS.reduce((sum, item) => sum + item.amount, 0)
const tax = subtotal * 0.1
const total = subtotal + tax
return (
<>
<PageHeader title="Invoice" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Invoice' }]} />
<div className="app-content">
<div className="container-fluid">
<Card>
<div className="row mb-4">
<div className="col-6">
<h4>webappAIO Inc.</h4>
<p className="text-secondary mb-0">
1234 Example Street
<br />
Manama, Bahrain
<br />
hello@webappaio.example
</p>
</div>
<div className="col-6 text-end">
<h4>Invoice #00123</h4>
<p className="text-secondary mb-0">
Bill to: Jane Doe
<br />
Date: {new Date().toLocaleDateString()}
</p>
</div>
</div>
<table className="table table-striped">
<thead>
<tr>
<th>Description</th>
<th className="text-end">Qty</th>
<th className="text-end">Unit Price</th>
<th className="text-end">Amount</th>
</tr>
</thead>
<tbody>
{LINE_ITEMS.map((item) => (
<tr key={item.description}>
<td>{item.description}</td>
<td className="text-end">{item.qty}</td>
<td className="text-end">${item.unitPrice.toFixed(2)}</td>
<td className="text-end">${item.amount.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
<div className="row">
<div className="col-md-4 offset-md-8">
<table className="table mb-0">
<tbody>
<tr>
<th>Subtotal</th>
<td className="text-end">${subtotal.toFixed(2)}</td>
</tr>
<tr>
<th>Tax (10%)</th>
<td className="text-end">${tax.toFixed(2)}</td>
</tr>
<tr>
<th>Total</th>
<td className="text-end fw-bold">${total.toFixed(2)}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="mt-4">
<button className="btn btn-primary me-2" disabled>
<i className="bi bi-printer me-1"></i>Print
</button>
<button className="btn btn-outline-secondary me-2" disabled>
<i className="bi bi-file-earmark-pdf me-1"></i>Download PDF
</button>
<button className="btn btn-outline-secondary" disabled>
<i className="bi bi-send me-1"></i>Send
</button>
</div>
<p className="text-secondary fs-7 mt-2 mb-0">
Static template no invoicing/billing backend exists yet.
</p>
</Card>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,101 @@
import { useState } from 'react'
import { useAuth } from '../auth/AuthContext'
import { PageHeader } from '../../components/desktop/PageHeader'
import { Card } from '../../components/desktop/Card'
type Tab = 'activity' | 'timeline' | 'about'
const ACTIVITY = [
{ text: 'Signed in to the dashboard', time: 'Just now' },
{ text: 'Updated a user record', time: '2 hours ago' },
{ text: 'Assigned a new role', time: 'Yesterday' },
]
export function ProfilePage() {
const { user } = useAuth()
const [tab, setTab] = useState<Tab>('activity')
return (
<>
<PageHeader title="Profile" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Profile' }]} />
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-md-3">
<Card>
<div className="text-center">
<i className="bi bi-person-circle" style={{ fontSize: '5rem' }}></i>
<h3 className="mt-2 mb-0">{user?.name}</h3>
<p className="text-secondary">{user?.roles.join(', ')}</p>
</div>
<div className="d-flex justify-content-around text-center border-top pt-3 mt-3">
<div>
<strong>24</strong>
<div className="text-secondary fs-7">Followers</div>
</div>
<div>
<strong>13</strong>
<div className="text-secondary fs-7">Following</div>
</div>
<div>
<strong>8</strong>
<div className="text-secondary fs-7">Friends</div>
</div>
</div>
</Card>
</div>
<div className="col-md-9">
<ul className="nav nav-tabs mb-3">
{(['activity', 'timeline', 'about'] as Tab[]).map((t) => (
<li key={t} className="nav-item">
<button
className={`nav-link text-capitalize ${tab === t ? 'active' : ''}`}
onClick={() => setTab(t)}
type="button"
>
{t}
</button>
</li>
))}
</ul>
{tab === 'activity' && (
<Card>
<ul className="list-group list-group-flush">
{ACTIVITY.map((item, i) => (
<li key={i} className="list-group-item px-0">
{item.text}
<div className="text-secondary fs-7">{item.time}</div>
</li>
))}
</ul>
</Card>
)}
{tab === 'timeline' && (
<Card>
<p className="text-secondary mb-0">No timeline entries yet.</p>
</Card>
)}
{tab === 'about' && (
<Card>
<p>
<strong>Education:</strong>
</p>
<p>
<strong>Location:</strong>
</p>
<p className="mb-0">
<strong>Skills:</strong>
</p>
</Card>
)}
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,96 @@
import { PageHeader } from '../../components/desktop/PageHeader'
import { SmallBox } from '../../components/desktop/SmallBox'
interface Project {
name: string
status: 'On track' | 'At risk' | 'Completed'
progress: number
team: string
budget: string
due: string
priority: 'High' | 'Medium' | 'Low'
}
const PROJECTS: Project[] = [
{ name: 'Website redesign', status: 'On track', progress: 65, team: 'Alice, Bob', budget: '$12,000', due: 'Sep 1, 2026', priority: 'High' },
{ name: 'Mobile app v2', status: 'At risk', progress: 30, team: 'Carol, Dave', budget: '$25,000', due: 'Oct 15, 2026', priority: 'High' },
{ name: 'Internal tooling', status: 'Completed', progress: 100, team: 'Eve', budget: '$4,000', due: 'Jul 20, 2026', priority: 'Low' },
{ name: 'API v3 migration', status: 'On track', progress: 48, team: 'Frank, Grace', budget: '$18,000', due: 'Nov 1, 2026', priority: 'Medium' },
]
const STATUS_BADGE: Record<Project['status'], string> = {
'On track': 'text-bg-success',
'At risk': 'text-bg-danger',
Completed: 'text-bg-secondary',
}
export function ProjectsPage() {
const total = PROJECTS.length
const onTrack = PROJECTS.filter((p) => p.status === 'On track').length
const atRisk = PROJECTS.filter((p) => p.status === 'At risk').length
const completed = PROJECTS.filter((p) => p.status === 'Completed').length
return (
<>
<PageHeader title="Projects" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Projects' }]} />
<div className="app-content">
<div className="container-fluid">
<div className="row mb-3">
<div className="col-lg-3 col-6">
<SmallBox value={total} label="Total Projects" icon="bi-kanban" color="text-bg-primary" />
</div>
<div className="col-lg-3 col-6">
<SmallBox value={onTrack} label="On Track" icon="bi-check-circle" color="text-bg-success" />
</div>
<div className="col-lg-3 col-6">
<SmallBox value={atRisk} label="At Risk" icon="bi-exclamation-triangle" color="text-bg-danger" />
</div>
<div className="col-lg-3 col-6">
<SmallBox value={completed} label="Completed" icon="bi-flag" color="text-bg-secondary" />
</div>
</div>
<div className="card">
<div className="table-responsive">
<table className="table table-hover mb-0">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Progress</th>
<th>Team</th>
<th>Budget</th>
<th>Due</th>
<th>Priority</th>
</tr>
</thead>
<tbody>
{PROJECTS.map((p) => (
<tr key={p.name}>
<td>{p.name}</td>
<td>
<span className={`badge ${STATUS_BADGE[p.status]}`}>{p.status}</span>
</td>
<td style={{ minWidth: '120px' }}>
<div className="progress">
<div className="progress-bar" style={{ width: `${p.progress}%` }}>
{p.progress}%
</div>
</div>
</td>
<td>{p.team}</td>
<td>{p.budget}</td>
<td>{p.due}</td>
<td>{p.priority}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<p className="text-secondary fs-7 mt-2 mb-0">Static demo no project-tracking backend exists yet.</p>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,46 @@
import { useQuery } from '@tanstack/react-query'
import { PageHeader } from '../../components/desktop/PageHeader'
import { Card } from '../../components/desktop/Card'
import { fetchRoleOverview } from './rolesApi'
export function RolesPage() {
const { data: roles = [], isLoading } = useQuery({ queryKey: ['roles', 'overview'], queryFn: fetchRoleOverview })
return (
<>
<PageHeader title="Roles" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Roles' }]} />
<div className="app-content">
<div className="container-fluid">
<p className="text-secondary">
Read-only view of each role's permissions, as seeded by <code>RolePermissionSeeder</code>. Permissions
aren't editable from here — they're fixed by the backend seed data.
</p>
{isLoading ? (
<p className="text-secondary">Loading</p>
) : (
<div className="row">
{roles.map((role) => (
<div key={role.name} className="col-md-6 col-lg-3 mb-4">
<Card title={role.name}>
{role.permissions?.length ? (
<div className="d-flex flex-wrap gap-1">
{role.permissions.map((p) => (
<span key={p} className="badge text-bg-secondary">
{p}
</span>
))}
</div>
) : (
<p className="text-secondary mb-0">No permissions.</p>
)}
</Card>
</div>
))}
</div>
)}
</div>
</div>
</>
)
}

View File

@ -0,0 +1,7 @@
import { apiClient } from '../../services/apiClient'
import type { Role } from '../../types/role'
export async function fetchRoleOverview(): Promise<Role[]> {
const { data } = await apiClient.get('/roles/overview')
return data.data as Role[]
}

View File

@ -0,0 +1,102 @@
import { useState } from 'react'
import { useAuth } from '../auth/AuthContext'
import { PageHeader } from '../../components/desktop/PageHeader'
import { Card } from '../../components/desktop/Card'
import { TwoFactorSetupPage } from '../auth/TwoFactorSetupPage'
type Tab = 'account' | 'notifications' | 'security' | 'billing' | 'danger'
const TABS: { key: Tab; label: string }[] = [
{ key: 'account', label: 'Account' },
{ key: 'notifications', label: 'Notifications' },
{ key: 'security', label: 'Security' },
{ key: 'billing', label: 'Billing' },
{ key: 'danger', label: 'Danger Zone' },
]
export function SettingsPage() {
const { user } = useAuth()
const [tab, setTab] = useState<Tab>('account')
return (
<>
<PageHeader title="Settings" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Settings' }]} />
<div className="app-content">
<div className="container-fluid">
<ul className="nav nav-tabs mb-3">
{TABS.map((t) => (
<li key={t.key} className="nav-item">
<button
className={`nav-link ${tab === t.key ? 'active' : ''}`}
onClick={() => setTab(t.key)}
type="button"
>
{t.label}
</button>
</li>
))}
</ul>
{tab === 'account' && (
<Card>
<div className="mb-3">
<label className="form-label">Name</label>
<input type="text" defaultValue={user?.name} className="form-control" disabled />
</div>
<div className="mb-3">
<label className="form-label">Email</label>
<input type="email" defaultValue={user?.email} className="form-control" disabled />
</div>
<div className="mb-3">
<label className="form-label">Timezone</label>
<select className="form-select" disabled>
<option>UTC</option>
</select>
</div>
<p className="text-secondary fs-7 mb-0">
Account fields are managed via the Users module (or by an admin) not editable here yet.
</p>
</Card>
)}
{tab === 'notifications' && (
<Card>
{['Email notifications', 'Push notifications', 'Weekly digest', 'Security alerts'].map((label) => (
<div key={label} className="form-check form-switch mb-2">
<input className="form-check-input" type="checkbox" disabled defaultChecked />
<label className="form-check-label">{label}</label>
</div>
))}
<p className="text-secondary fs-7 mb-0 mt-2">Placeholder no notification-preferences backend yet.</p>
</Card>
)}
{tab === 'security' && <TwoFactorSetupPage />}
{tab === 'billing' && (
<Card title="Plan">
<p>
Current plan: <span className="badge text-bg-primary">Self-hosted</span>
</p>
<p className="text-secondary fs-7 mb-0">
No billing integration this app is self-hosted, so there's nothing to bill.
</p>
</Card>
)}
{tab === 'danger' && (
<Card title="Danger zone">
<p className="text-secondary">Manage account deletion and data export here.</p>
<button className="btn btn-outline-danger" disabled>
Delete account
</button>
<p className="text-secondary fs-7 mb-0 mt-2">
Disabled placeholder account deletion for the signed-in user isn't implemented.
</p>
</Card>
)}
</div>
</div>
</>
)
}

View File

@ -0,0 +1,91 @@
import { useRef, useState } from 'react'
import { PageHeader } from '../../components/desktop/PageHeader'
import { Card } from '../../components/desktop/Card'
import { useConfirm } from '../../components/shared/ConfirmDialog'
import { useToast } from '../../components/shared/Toast/ToastProvider'
import { downloadBackup, restoreBackup } from './systemApi'
export function BackupRestorePage() {
const confirm = useConfirm()
const toast = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDownloading, setIsDownloading] = useState(false)
const [isRestoring, setIsRestoring] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleDownload() {
setIsDownloading(true)
try {
await downloadBackup()
toast.success('Backup downloaded.')
} catch {
toast.error('Could not download the backup.')
} finally {
setIsDownloading(false)
}
}
async function handleRestore() {
const file = fileInputRef.current?.files?.[0]
if (!file) return
const ok = await confirm({
title: 'Restore database',
message: `This will replace the current database with "${file.name}". This cannot be undone from here. Continue?`,
confirmLabel: 'Restore',
danger: true,
})
if (!ok) return
setIsRestoring(true)
setError(null)
try {
await restoreBackup(file)
toast.success('Database restored. You may need to sign in again.')
if (fileInputRef.current) fileInputRef.current.value = ''
} catch {
setError('That file could not be restored — make sure it is a valid SQLite backup.')
} finally {
setIsRestoring(false)
}
}
return (
<>
<PageHeader
title="Backup & Restore"
breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Backup & Restore' }]}
/>
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-md-6">
<Card title="Download backup">
<p className="text-secondary">Download the current SQLite database file.</p>
<button onClick={handleDownload} disabled={isDownloading} className="btn btn-primary">
<i className="bi bi-download me-1"></i>
{isDownloading ? 'Downloading…' : 'Download backup'}
</button>
</Card>
</div>
<div className="col-md-6">
<Card title="Restore from backup">
{error && <div className="alert alert-danger">{error}</div>}
<p className="text-secondary">
Upload a previously downloaded <code>.sqlite</code> file to replace the current database. A safety
copy of the current database is kept on the server before restoring.
</p>
<input ref={fileInputRef} type="file" accept=".sqlite" className="form-control mb-3" />
<button onClick={handleRestore} disabled={isRestoring} className="btn btn-danger">
<i className="bi bi-upload me-1"></i>
{isRestoring ? 'Restoring…' : 'Restore'}
</button>
</Card>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,73 @@
import { useQuery } from '@tanstack/react-query'
import { PageHeader } from '../../components/desktop/PageHeader'
import { Card } from '../../components/desktop/Card'
import { fetchMqttSettings } from './systemApi'
export function MqttSettingsPage() {
const { data, isLoading } = useQuery({
queryKey: ['mqtt', 'settings'],
queryFn: fetchMqttSettings,
refetchInterval: 15000,
})
return (
<>
<PageHeader
title="MQTT Settings"
breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'MQTT Settings' }]}
/>
<div className="app-content">
<div className="container-fluid">
<div className="row">
<div className="col-md-6">
<Card title="Broker connection">
{isLoading ? (
<p className="text-secondary">Loading</p>
) : (
<table className="table table-borderless mb-0">
<tbody>
<tr>
<th scope="row">Status</th>
<td>
{data?.connected ? (
<span className="badge text-bg-success">
<i className="bi bi-check-circle-fill me-1"></i>Connected
</span>
) : (
<span className="badge text-bg-danger">
<i className="bi bi-x-circle-fill me-1"></i>Unreachable
</span>
)}
</td>
</tr>
<tr>
<th scope="row">Host</th>
<td>{data?.host}</td>
</tr>
<tr>
<th scope="row">Port</th>
<td>{data?.port}</td>
</tr>
<tr>
<th scope="row">WebSocket URL</th>
<td>{data?.ws_url}</td>
</tr>
<tr>
<th scope="row">TLS</th>
<td>{data?.tls_enabled ? 'Enabled' : 'Disabled'}</td>
</tr>
</tbody>
</table>
)}
</Card>
<p className="text-secondary fs-7">
Broker credentials aren't shown here — role-scoped tokens are issued per-session via{' '}
<code>/api/v1/mqtt/token</code>. This page reflects config only super-admins can view.
</p>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,32 @@
import { apiClient } from '../../services/apiClient'
export interface MqttSettings {
host: string
port: number
ws_url: string
tls_enabled: boolean
connected: boolean
}
export async function fetchMqttSettings(): Promise<MqttSettings> {
const { data } = await apiClient.get('/mqtt/settings')
return data
}
export async function downloadBackup(): Promise<void> {
const response = await apiClient.get('/backup/download', { responseType: 'blob' })
const url = URL.createObjectURL(response.data)
const a = document.createElement('a')
a.href = url
a.download = `backup-${new Date().toISOString().replace(/[:.]/g, '-')}.sqlite`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
export async function restoreBackup(file: File): Promise<void> {
const formData = new FormData()
formData.append('backup', file)
await apiClient.post('/backup/restore', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
}

View File

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import { useConfirm } from '../../components/shared/ConfirmDialog' import { useConfirm } from '../../components/shared/ConfirmDialog'
import { useToast } from '../../components/shared/Toast/ToastProvider' import { useToast } from '../../components/shared/Toast/ToastProvider'
import { usePermission } from '../../hooks/usePermission' import { usePermission } from '../../hooks/usePermission'
@ -16,15 +17,22 @@ export function UsersListPageMobile() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
useUsersRealtime() useUsersRealtime()
const [searchParams, setSearchParams] = useSearchParams()
const search = searchParams.get('search') ?? ''
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [formUser, setFormUser] = useState<User | null | undefined>(undefined) const [formUser, setFormUser] = useState<User | null | undefined>(undefined)
const [roleUser, setRoleUser] = useState<User | null>(null) const [roleUser, setRoleUser] = useState<User | null>(null)
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['users', { page, sort: 'created_at', direction: 'desc' as const }], queryKey: ['users', { page, sort: 'created_at', direction: 'desc' as const, search }],
queryFn: () => fetchUsers({ page, sort: 'created_at', direction: 'desc' }), queryFn: () => fetchUsers({ page, sort: 'created_at', direction: 'desc', search: search || undefined }),
}) })
function handleSearchChange(value: string) {
setPage(1)
setSearchParams(value ? { search: value } : {})
}
async function handleDelete(user: User) { async function handleDelete(user: User) {
const ok = await confirm({ const ok = await confirm({
title: 'Delete user', title: 'Delete user',
@ -41,68 +49,77 @@ export function UsersListPageMobile() {
return ( return (
<div> <div>
<div className="mb-4 flex items-center justify-between"> <div className="mob-search-bar">
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Users</h1> <input
type="search"
defaultValue={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="mob-search-input"
placeholder="🔍 Search users…"
/>
{can('users.create') && ( {can('users.create') && (
<button <button onClick={() => setFormUser(null)} className="mob-pill-btn">
onClick={() => setFormUser(null)} <span></span> Add
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add
</button> </button>
)} )}
</div> </div>
{isLoading ? ( <div className="mob-section-card">
<p className="text-sm text-gray-500">Loading</p> {isLoading ? (
) : ( <p style={{ color: 'var(--mob-text-light)' }}>Loading</p>
<div className="flex flex-col gap-3"> ) : (data?.data ?? []).length === 0 ? (
{(data?.data ?? []).map((u) => ( <p style={{ color: 'var(--mob-text-light)' }}>No results.</p>
<div ) : (
key={u.id} (data?.data ?? []).map((u) => (
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900" <div key={u.id} className="mob-list-item" style={{ alignItems: 'flex-start' }}>
> <span className="mob-avatar">{u.name.slice(0, 2).toUpperCase()}</span>
<p className="font-medium text-gray-900 dark:text-gray-100">{u.name}</p> <div style={{ flex: 1, minWidth: 0 }}>
<p className="text-sm text-gray-500 dark:text-gray-500">{u.email}</p> <p style={{ margin: 0, fontWeight: 700, fontSize: 14 }}>{u.name}</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-600">{u.roles.join(', ')}</p> <p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--mob-text-light)' }}>{u.email}</p>
<div className="mt-3 flex gap-4 text-sm"> <span className="mob-status-badge mob-neutral" style={{ marginTop: 6 }}>
{u.can_edit && ( {u.roles.join(', ')}
<button onClick={() => setFormUser(u)} className="text-blue-600 dark:text-blue-400"> </span>
Edit <div style={{ marginTop: 8, display: 'flex', gap: 14 }}>
</button> {u.can_edit && (
)} <button onClick={() => setFormUser(u)} className="mob-text-btn">
{can('roles.assign') && ( Edit
<button onClick={() => setRoleUser(u)} className="text-blue-600 dark:text-blue-400"> </button>
Role )}
</button> {can('roles.assign') && (
)} <button onClick={() => setRoleUser(u)} className="mob-text-btn">
{u.can_delete && ( Role
<button onClick={() => handleDelete(u)} className="text-red-600 dark:text-red-400"> </button>
Delete )}
</button> {u.can_delete && (
)} <button onClick={() => handleDelete(u)} className="mob-text-btn mob-danger">
Delete
</button>
)}
</div>
</div> </div>
</div> </div>
))} ))
</div> )}
)} </div>
{data && data.meta.last_page > 1 && ( {data && data.meta.last_page > 1 && (
<div className="mt-4 flex items-center justify-between text-sm"> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<button <button
disabled={page <= 1} disabled={page <= 1}
onClick={() => setPage((p) => p - 1)} onClick={() => setPage((p) => p - 1)}
className="text-gray-600 disabled:opacity-40 dark:text-gray-400" className="mob-text-btn"
style={{ opacity: page <= 1 ? 0.4 : 1 }}
> >
Previous Previous
</button> </button>
<span className="text-gray-500"> <span style={{ color: 'var(--mob-text-light)' }}>
Page {page} of {data.meta.last_page} Page {page} of {data.meta.last_page}
</span> </span>
<button <button
disabled={page >= data.meta.last_page} disabled={page >= data.meta.last_page}
onClick={() => setPage((p) => p + 1)} onClick={() => setPage((p) => p + 1)}
className="text-gray-600 disabled:opacity-40 dark:text-gray-400" className="mob-text-btn"
style={{ opacity: page >= data.meta.last_page ? 0.4 : 1 }}
> >
Next Next
</button> </button>

View File

@ -1,12 +1,18 @@
import { useState } from 'react'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import '../../styles/mobile.css'
import { TopBar } from '../../components/mobile/TopBar' import { TopBar } from '../../components/mobile/TopBar'
import { BottomNav } from '../../components/mobile/BottomNav' import { BottomNav } from '../../components/mobile/BottomNav'
import { SlideOutNav } from '../../components/mobile/SlideOutNav'
export function MobileShell() { export function MobileShell() {
const [menuOpen, setMenuOpen] = useState(false)
return ( return (
<div className="flex h-screen flex-col"> <div className="mob-app">
<TopBar /> <TopBar onMenuClick={() => setMenuOpen(true)} />
<main className="flex-1 overflow-y-auto bg-gray-50 p-4 dark:bg-gray-950"> <SlideOutNav open={menuOpen} onClose={() => setMenuOpen(false)} />
<main className="mob-content">
<Outlet /> <Outlet />
</main> </main>
<BottomNav /> <BottomNav />

View File

@ -17,8 +17,30 @@ export const NAV_GROUPS: NavGroup[] = [
}, },
{ {
header: 'ADMINISTRATION', header: 'ADMINISTRATION',
items: [{ label: 'Users', to: '/users', icon: 'bi-people-fill', permission: 'users.view' }], items: [
{ label: 'Users', to: '/users', icon: 'bi-people-fill', permission: 'users.view' },
{ label: 'Roles', to: '/roles', icon: 'bi-shield-lock-fill', permission: 'roles.view' },
{ label: 'MQTT Settings', to: '/system/mqtt', icon: 'bi-broadcast', permission: 'system.manage' },
{ label: 'Backup & Restore', to: '/system/backup', icon: 'bi-database-down', permission: 'system.manage' },
],
},
{
header: 'WORKSPACE',
items: [
{ label: 'Profile', to: '/profile', icon: 'bi-person-circle' },
{ label: 'Settings', to: '/settings', icon: 'bi-gear-fill' },
{ label: 'Calendar', to: '/calendar', icon: 'bi-calendar3' },
{ label: 'Chat', to: '/chat', icon: 'bi-chat-dots-fill' },
{ label: 'File Manager', to: '/files', icon: 'bi-folder-fill' },
{ label: 'Projects', to: '/projects', icon: 'bi-kanban-fill' },
{ label: 'Invoice', to: '/invoice', icon: 'bi-receipt' },
],
}, },
] ]
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items) export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items)
// The mobile bottom nav only has room for a handful of icons — cap it to
// the items most people need daily rather than flattening all 12.
const MOBILE_ITEM_LABELS = ['Dashboard', 'Users', 'Profile', 'Settings']
export const MOBILE_NAV_ITEMS: NavItem[] = NAV_ITEMS.filter((item) => MOBILE_ITEM_LABELS.includes(item.label))

View File

@ -2,8 +2,18 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'
import { LoginPage } from '../features/auth/LoginPage' import { LoginPage } from '../features/auth/LoginPage'
import { TwoFactorChallengePage } from '../features/auth/TwoFactorChallengePage' import { TwoFactorChallengePage } from '../features/auth/TwoFactorChallengePage'
import { TwoFactorSetupPage } from '../features/auth/TwoFactorSetupPage' import { TwoFactorSetupPage } from '../features/auth/TwoFactorSetupPage'
import { DashboardPage } from '../features/dashboard/DashboardPage' import { DashboardRoute } from '../features/dashboard/DashboardRoute'
import { UsersPage } from '../features/users/UsersPage' import { UsersPage } from '../features/users/UsersPage'
import { RolesPage } from '../features/roles/RolesPage'
import { MqttSettingsPage } from '../features/system/MqttSettingsPage'
import { BackupRestorePage } from '../features/system/BackupRestorePage'
import { SettingsPage } from '../features/settings/SettingsPage'
import { ProfilePage } from '../features/profile/ProfilePage'
import { InvoicePage } from '../features/invoice/InvoicePage'
import { CalendarPage } from '../features/calendar/CalendarPage'
import { ChatPage } from '../features/chat/ChatPage'
import { FileManagerPage } from '../features/file-manager/FileManagerPage'
import { ProjectsPage } from '../features/projects/ProjectsPage'
import { AppShell } from '../layouts/shell/AppShell' import { AppShell } from '../layouts/shell/AppShell'
import { ProtectedRoute } from './ProtectedRoute' import { ProtectedRoute } from './ProtectedRoute'
import { RequirePermission } from './RequirePermission' import { RequirePermission } from './RequirePermission'
@ -17,12 +27,30 @@ export const router = createBrowserRouter([
{ {
element: <AppShell />, element: <AppShell />,
children: [ children: [
{ path: '/dashboard', element: <DashboardPage /> }, { path: '/dashboard', element: <DashboardRoute /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/settings/2fa', element: <TwoFactorSetupPage /> }, { path: '/settings/2fa', element: <TwoFactorSetupPage /> },
{ path: '/profile', element: <ProfilePage /> },
{ path: '/invoice', element: <InvoicePage /> },
{ path: '/calendar', element: <CalendarPage /> },
{ path: '/chat', element: <ChatPage /> },
{ path: '/files', element: <FileManagerPage /> },
{ path: '/projects', element: <ProjectsPage /> },
{ {
element: <RequirePermission permission="users.view" />, element: <RequirePermission permission="users.view" />,
children: [{ path: '/users', element: <UsersPage /> }], children: [{ path: '/users', element: <UsersPage /> }],
}, },
{
element: <RequirePermission permission="roles.view" />,
children: [{ path: '/roles', element: <RolesPage /> }],
},
{
element: <RequirePermission permission="system.manage" />,
children: [
{ path: '/system/mqtt', element: <MqttSettingsPage /> },
{ path: '/system/backup', element: <BackupRestorePage /> },
],
},
], ],
}, },
], ],

View File

@ -0,0 +1,476 @@
/*
* Mobile design system entirely separate from the desktop AdminLTE/
* Bootstrap styling (frontend/src/index.css). Every selector is scoped
* under `.mob-app` and every class is prefixed `mob-` so it can never
* collide with or be affected by AdminLTE's global classes (e.g. both
* define an "app-sidebar" concept, but under different names/rules).
* Adapted from a provided reference design (purple theme, card system,
* slide-out sidebar, bottom tab bar).
*/
.mob-app {
--mob-primary: #6c63ff;
--mob-primary-light: #8a84ff;
--mob-bg: #f5f5f9;
--mob-white: #ffffff;
--mob-text-dark: #333333;
--mob-text-medium: #555555;
--mob-text-light: #999999;
--mob-border: #eeeeee;
--mob-light-gray-bg: #f7f7f7;
--mob-success: #4caf50;
--mob-warning: #ff9800;
--mob-danger: #f44336;
--mob-tab-bar-height: 62px;
--mob-padding: 16px;
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-color: var(--mob-bg);
color: var(--mob-text-dark);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.mob-app * {
box-sizing: border-box;
}
/* Header */
.mob-header {
flex-shrink: 0;
padding: 10px var(--mob-padding);
padding-top: 14px;
background: var(--mob-white);
border-bottom: 1px solid var(--mob-border);
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
}
.mob-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.mob-menu-btn {
border: none;
background: var(--mob-light-gray-bg);
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: var(--mob-text-medium);
cursor: pointer;
transition: all 0.2s;
}
.mob-menu-btn:hover {
background: var(--mob-primary);
color: var(--mob-white);
}
.mob-title {
display: flex;
flex-direction: column;
}
.mob-title span:first-child {
font-size: 10px;
color: var(--mob-text-light);
font-weight: 500;
}
.mob-title span:last-child {
font-size: 17px;
font-weight: 800;
color: var(--mob-primary);
}
.mob-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.mob-icon-btn {
border: none;
background: var(--mob-light-gray-bg);
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
color: var(--mob-text-medium);
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.mob-icon-btn:hover {
background: var(--mob-primary);
color: var(--mob-white);
}
.mob-badge-dot {
position: absolute;
top: 6px;
right: 6px;
width: 9px;
height: 9px;
background: var(--mob-danger);
border-radius: 50%;
border: 2px solid var(--mob-white);
}
/* Slide-out sidebar */
.mob-sidebar {
position: fixed;
top: 0;
left: -280px;
width: 280px;
height: 100vh;
background: var(--mob-white);
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.12);
z-index: 60;
display: flex;
flex-direction: column;
padding: 14px;
transition: left 0.25s ease-out;
overflow-y: auto;
}
.mob-sidebar.mob-open {
left: 0;
}
.mob-sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 55;
display: none;
backdrop-filter: blur(2px);
}
.mob-sidebar-backdrop.mob-show {
display: block;
}
.mob-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 2px solid var(--mob-border);
}
.mob-sidebar-title {
font-weight: 800;
color: var(--mob-primary);
font-size: 17px;
}
.mob-sidebar-close {
border: none;
background: var(--mob-light-gray-bg);
font-size: 18px;
width: 32px;
height: 32px;
border-radius: 8px;
color: var(--mob-text-medium);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.mob-sidebar-close:hover {
background: var(--mob-danger);
color: var(--mob-white);
}
.mob-sidebar-section-label {
font-size: 10px;
text-transform: uppercase;
color: var(--mob-text-light);
margin-top: 14px;
margin-bottom: 6px;
font-weight: 700;
letter-spacing: 0.5px;
padding-left: 4px;
}
.mob-sidebar-link {
width: 100%;
border: none;
background: transparent;
display: flex;
align-items: center;
gap: 12px;
padding: 11px 10px;
border-radius: 10px;
font-size: 14px;
color: var(--mob-text-medium);
text-align: left;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
text-decoration: none;
}
.mob-sidebar-link .mob-nav-icon {
font-size: 18px;
width: 24px;
text-align: center;
}
.mob-sidebar-link:hover {
background: linear-gradient(135deg, rgba(108, 99, 255, 0.1), rgba(108, 99, 255, 0.05));
transform: translateX(3px);
}
.mob-sidebar-link.mob-active {
background: linear-gradient(135deg, var(--mob-primary), var(--mob-primary-light));
color: var(--mob-white);
box-shadow: 0 4px 12px rgba(108, 99, 255, 0.3);
}
/* Content */
.mob-content {
flex: 1;
overflow-y: auto;
padding: 14px var(--mob-padding);
padding-bottom: calc(var(--mob-tab-bar-height) + 14px);
}
/* Bottom tab bar */
.mob-tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: var(--mob-tab-bar-height);
background: var(--mob-white);
border-top: 1px solid var(--mob-border);
display: flex;
justify-content: space-around;
align-items: center;
z-index: 20;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.08);
}
.mob-tab-item {
flex: 1;
text-align: center;
font-size: 10px;
color: var(--mob-text-light);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
padding: 6px 0;
transition: all 0.2s;
text-decoration: none;
}
.mob-tab-item .mob-nav-icon {
font-size: 20px;
}
.mob-tab-item.mob-active {
color: var(--mob-primary);
font-weight: 700;
}
/* KPI cards */
.mob-kpi-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
@media (min-width: 540px) {
.mob-kpi-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.mob-kpi-card {
background: var(--mob-white);
padding: 16px;
border-radius: 14px;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.06);
border-left: 5px solid var(--mob-primary);
}
.mob-kpi-label {
font-size: 11px;
color: var(--mob-text-light);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.mob-kpi-value {
font-size: 22px;
font-weight: 800;
}
.mob-kpi-change {
font-size: 11px;
margin-top: 8px;
font-weight: 600;
}
.mob-kpi-change.mob-positive {
color: var(--mob-success);
}
.mob-kpi-change.mob-negative {
color: var(--mob-danger);
}
/* Section / generic cards */
.mob-section-card {
background: var(--mob-white);
border-radius: 16px;
padding: 18px;
box-shadow: 0 3px 15px rgba(0, 0, 0, 0.06);
margin-bottom: 16px;
}
.mob-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
flex-wrap: wrap;
gap: 10px;
}
.mob-section-header h4 {
font-size: 16px;
margin: 0;
color: var(--mob-primary);
font-weight: 700;
}
.mob-section-header small {
font-size: 12px;
color: var(--mob-text-light);
font-weight: 500;
}
.mob-card {
background: var(--mob-white);
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 12px;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.06);
}
/* List rows (users, activity, etc.) */
.mob-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px dashed var(--mob-border);
}
.mob-list-item:last-child {
border-bottom: none;
}
.mob-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, var(--mob-primary), var(--mob-primary-light));
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: white;
font-size: 13px;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(108, 99, 255, 0.3);
}
/* Badges */
.mob-status-badge {
padding: 4px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
display: inline-block;
}
.mob-status-badge.mob-success {
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
color: #2e7d32;
}
.mob-status-badge.mob-warning {
background: linear-gradient(135deg, #fff3e0, #ffe0b2);
color: #ef6c00;
}
.mob-status-badge.mob-danger {
background: linear-gradient(135deg, #ffebee, #ffcdd2);
color: #c62828;
}
.mob-status-badge.mob-neutral {
background: var(--mob-light-gray-bg);
color: var(--mob-text-medium);
}
/* Search + filters */
.mob-search-bar {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.mob-search-input {
flex: 1;
min-width: 160px;
padding: 11px 16px;
border: 2px solid var(--mob-border);
border-radius: 12px;
font-size: 14px;
background: white;
font-weight: 500;
}
.mob-search-input:focus {
outline: none;
border-color: var(--mob-primary);
box-shadow: 0 0 0 4px rgba(108, 99, 255, 0.15);
}
/* Buttons */
.mob-pill-btn {
border-radius: 999px;
padding: 9px 18px;
font-size: 13px;
border: none;
background: linear-gradient(135deg, var(--mob-primary), var(--mob-primary-light));
color: var(--mob-white);
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(108, 99, 255, 0.3);
}
.mob-pill-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(108, 99, 255, 0.4);
}
.mob-pill-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.mob-text-btn {
border: none;
background: none;
color: var(--mob-primary);
font-weight: 600;
font-size: 13px;
cursor: pointer;
padding: 4px 8px;
}
.mob-text-btn.mob-danger {
color: var(--mob-danger);
}
@media (prefers-color-scheme: dark) {
.mob-app {
--mob-bg: #17181f;
--mob-white: #23242e;
--mob-text-dark: #e6e6f0;
--mob-text-medium: #b7b7c7;
--mob-text-light: #7d7d8f;
--mob-border: #33343f;
--mob-light-gray-bg: #2b2c37;
}
}

View File

@ -1,3 +1,4 @@
export interface Role { export interface Role {
name: string name: string
permissions?: string[]
} }