Compare commits

...
4 Commits
Author SHA1 Message Date
ghassan 2a2fac9cdf 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.
2026-08-04 14:51:28 +03:00
ghassan 84cd474bcb Rebuild desktop shell on the real AdminLTE 4 package
Install [email protected] (+ bootstrap 5.3.8, bootstrap-icons as peer deps) and
rebuild every desktop component against actual AdminLTE markup pulled from
its live docs/demo, rather than a Tailwind approximation:

- Real app-wrapper/app-header/app-sidebar/app-main/app-footer structure
- Working PushMenu (sidebar collapse), Treeview, ColorMode (light/dark/auto)
- Navbar: functional search (wired to Users list ?search=), notifications
  dropdown (MQTT-driven), user menu dropdown
- Sidebar: grouped nav-header sections, active-route highlighting
- Breadcrumbs, footer, Bootstrap card/table/pagination/progress widgets

Tailwind is now imported utilities-only (no preflight) so it coexists with
Bootstrap's reset; mobile shell and modals are untouched and still Tailwind.
2026-08-04 13:47:02 +03:00
ghassan 7198895444 Serve frontend as static build behind Apache with API reverse-proxied
Build the React app during provisioning and serve it on :80 directly, proxying /api and /sanctum to an internal Laravel vhost on 127.0.0.1:8001. Fixes the login page not appearing at the container's root URL, since previously only the Laravel API vhost was configured and nothing served the built frontend.
2026-08-04 12:38:47 +03:00
ghassan 5c699a221c Remove GIT_REPO and DB_TYPE prompts from bootstrap.sh
Always clone this repo (the full app) and always use SQLite, per updated deployment requirements. Drops now-dead mysql/fresh-scaffold branches accordingly.
2026-08-04 11:51:58 +03:00
49 changed files with 2492 additions and 339 deletions

No files matched your search

+5 -18
View File
@@ -1,6 +1,6 @@
# webappAIO LXC Bootstrap
Spin up a fully configured development container in one step: SSH, Apache/PHP/MariaDB or SQLite, a Laravel API backend (`backend/`) + React/Vite frontend (`frontend/`), a self-hosted Mosquitto MQTT broker (Docker), [code-server](https://code-server.dev), and [Claude Code](https://docs.claude.com/en/docs/claude-code) — all from a single command pasted into a fresh Proxmox LXC.
Spin up a fully configured development container in one step: SSH, Apache/PHP/SQLite, the Laravel API backend (`backend/`) + React/Vite frontend (`frontend/`) from this repo, a self-hosted Mosquitto MQTT broker (Docker), [code-server](https://code-server.dev), and [Claude Code](https://docs.claude.com/en/docs/claude-code) — all from a single command pasted into a fresh Proxmox LXC.
## Quick start
@@ -13,26 +13,18 @@ curl -fsSL https://git.innovator.bh/ghassan/webappAIO/raw/branch/main/bootstrap.
You'll be prompted for:
- Project name
- Git repository to clone (leave blank to scaffold a bare backend/frontend pair instead — see below)
- Database: `mysql` or `sqlite`
- MariaDB username/password/database (only if you chose `mysql`)
- Seeded Super Admin email/username/password
The script always clones this repo (`https://git.innovator.bh/ghassan/webappAIO.git`) — the full RBAC + 2FA + MQTT-realtime + Users-management app described in [`claude-project-prompt.md`](./claude-project-prompt.md) — and always uses SQLite. Neither is prompted for.
When it finishes you'll have a running API, a Mosquitto broker, code-server, and Claude Code installed, plus a Super Admin account ready to log in with.
### Blank `GIT_REPO` vs. cloning this repo
- **Blank** scaffolds a fresh Laravel install in `backend/` and a fresh Vite React-TS app in `frontend/`, with the core packages already required (Sanctum, `spatie/laravel-permission`, `pragmarx/google2fa-laravel`, `php-mqtt/laravel-client`) — the foundation to build the rest of [`claude-project-prompt.md`](./claude-project-prompt.md)'s architecture on top of, not the finished app.
- **`GIT_REPO=https://git.innovator.bh/ghassan/webappAIO.git`** clones this repo, which already contains the full RBAC + 2FA + MQTT-realtime + Users-management implementation described in that prompt.
## Fully unattended install
Skip every prompt by exporting variables before running the script (useful for scripted LXC provisioning):
```bash
APP_NAME=myapp \
GIT_REPO="" \
DB_TYPE=sqlite \
SUPER_ADMIN_EMAIL=[email protected] \
SUPER_ADMIN_USERNAME=admin \
SUPER_ADMIN_PASSWORD='change-me' \
@@ -44,11 +36,6 @@ bash bootstrap.sh
| Variable | Description | Default |
|---|---|---|
| `APP_NAME` | Project directory name under `/var/www` | `laravel-app` |
| `GIT_REPO` | Git URL to clone; blank scaffolds a bare backend/frontend pair | *(blank)* |
| `DB_TYPE` | `mysql` or `sqlite` | `sqlite` |
| `DB_USER` | MariaDB username (`mysql` only) | `laravel_user` |
| `DB_PASS` | MariaDB password (`mysql` only) | random |
| `DB_NAME` | MariaDB database name (`mysql` only) | `laravel_db` |
| `SUPER_ADMIN_EMAIL` | Seeded Super Admin email | `[email protected]` |
| `SUPER_ADMIN_USERNAME` | Seeded Super Admin username | `superadmin` |
| `SUPER_ADMIN_PASSWORD` | Seeded Super Admin password | random |
@@ -61,11 +48,11 @@ bash bootstrap.sh
1. **Base system** — `apt update/upgrade`, installs `sudo curl git wget unzip`.
2. **SSH** — installs and enables `openssh-server`; on TurnKey Core containers enables `AllowTcpForwarding`, on other distros enables `PermitRootLogin yes` (needed for VS Code Remote-SSH / port tunneling).
3. **Docker + Mosquitto** — installs Docker Engine + Compose plugin, generates per-role Mosquitto broker credentials, brings up the `mosquitto` container (MQTT on 1883, WebSockets on 9001).
4. **Laravel + React stack** — Apache, PHP + required extensions, MariaDB or SQLite, Composer, Node.js, clones or scaffolds `backend/` (Laravel) and `frontend/` (Vite React-TS), sets permissions, configures an Apache vhost pointing at `backend/public`, generates `.env` (DB, MQTT, Super Admin, Sanctum/CORS), runs `key:generate`, `migrate`, and `db:seed`.
4. **Laravel + React stack** — Apache, PHP + SQLite extension, Composer, Node.js, clones this repo into `backend/` (Laravel) and `frontend/` (Vite React-TS), sets permissions, configures an Apache vhost pointing at `backend/public`, generates `.env` (DB, MQTT, Super Admin, Sanctum/CORS), runs `key:generate`, `migrate`, and `db:seed`.
5. **Queue worker** — installs and starts a systemd unit (`<app-name>-queue`) running `php artisan queue:work`, required for MQTT events to actually publish.
6. **code-server** — browser-based VS Code, so you can edit the app over SSH without a local editor.
7. **Claude Code** — `npm install -g @anthropic-ai/claude-code`.
8. Prints a summary with the app path, DB type, container IP, Super Admin credentials, and next steps.
8. Prints a summary with the app path, container IP, Super Admin credentials, and next steps.
## After install
@@ -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();
}
}
@@ -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;
}
}
}
@@ -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);
}
}
@@ -14,6 +14,7 @@ class RoleResource extends JsonResource
{
return [
'name' => $this->name,
'permissions' => $this->whenLoaded('permissions', fn () => $this->permissions->pluck('name')),
];
}
}
@@ -17,6 +17,7 @@ class RolePermissionSeeder extends Seeder
'roles.view', 'roles.assign',
'dashboard.view',
'audit.view',
'system.manage',
];
private const ROLE_PERMISSIONS = [
+6
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\BackupController;
use App\Http\Controllers\Api\DashboardController;
use App\Http\Controllers\Api\MqttController;
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::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']);
});
});
+66 -91
View File
@@ -10,22 +10,15 @@
#
# All prompts can be skipped by exporting env vars before running, e.g.:
#
# APP_NAME=myapp GIT_REPO="" DB_TYPE=sqlite \
# APP_NAME=myapp SUPER_ADMIN_PASSWORD='change-me' \
# bash bootstrap.sh
#
# GIT_REPO blank scaffolds a bare Laravel (backend/) + Vite React (frontend/)
# pair with the core packages (Sanctum, spatie/laravel-permission,
# google2fa-laravel, php-mqtt/laravel-client) installed — the foundation to
# build on, not the full RBAC/2FA/Users-module app. Point GIT_REPO at this
# repo (https://git.innovator.bh/ghassan/webappAIO.git) to get that app.
# This script always clones https://git.innovator.bh/ghassan/webappAIO.git
# (the full RBAC/2FA/MQTT/Users-module app) and always uses SQLite — neither
# is prompted for.
#
# Supported env vars (all optional, will be prompted for if unset and a tty is available):
# APP_NAME Project directory name (default: laravel-app)
# GIT_REPO Git URL to clone, blank = new project (default: "")
# DB_TYPE mysql | sqlite (default: sqlite)
# DB_USER MariaDB user (only used if DB_TYPE=mysql)
# DB_PASS MariaDB password (only used if DB_TYPE=mysql)
# DB_NAME MariaDB database name (only used if DB_TYPE=mysql)
# SUPER_ADMIN_EMAIL Seeded Super Admin email (default: [email protected])
# SUPER_ADMIN_USERNAME Seeded Super Admin username (default: superadmin)
# SUPER_ADMIN_PASSWORD Seeded Super Admin password (default: random)
@@ -49,7 +42,7 @@ prompt() {
local var_name="$1" prompt_text="$2" default_value="${3:-}" silent="${4:-no}"
# A var counts as "provided" if it's set at all, even to an intentionally
# blank value (e.g. GIT_REPO="" means "scaffold a new project").
# blank value.
if declare -p "$var_name" &>/dev/null; then
return
fi
@@ -84,19 +77,11 @@ fi
log "webappAIO LXC Bootstrap"
prompt APP_NAME "Project name (directory name) [laravel-app]: " "laravel-app"
prompt GIT_REPO "Git repository URL to clone (blank = new project): " ""
prompt DB_TYPE "Database type, mysql or sqlite [sqlite]: " "sqlite"
if [[ "$DB_TYPE" != "mysql" && "$DB_TYPE" != "sqlite" ]]; then
warn "Unrecognized DB_TYPE '$DB_TYPE', defaulting to sqlite."
DB_TYPE="sqlite"
fi
if [ "$DB_TYPE" = "mysql" ]; then
prompt DB_USER "MariaDB username [laravel_user]: " "laravel_user"
prompt DB_PASS "MariaDB password: " "$(openssl rand -hex 12)" "yes"
prompt DB_NAME "MariaDB database name [laravel_db]: " "laravel_db"
fi
# Fixed, not prompted for: this script always deploys the full webappAIO app
# against SQLite.
GIT_REPO="https://git.innovator.bh/ghassan/webappAIO.git"
DB_TYPE="sqlite"
prompt SUPER_ADMIN_EMAIL "Super Admin email [[email protected]]: " "[email protected]"
prompt SUPER_ADMIN_USERNAME "Super Admin username [superadmin]: " "superadmin"
@@ -160,13 +145,8 @@ systemctl start docker
log "Installing Apache, PHP and common extensions"
apt-get install -y apache2 php php-curl php-bcmath php-json php-mbstring php-xml php-tokenizer php-zip php-gd
if [ "$DB_TYPE" = "mysql" ]; then
log "Installing MariaDB and PHP MySQL extension"
apt-get install -y mariadb-server php-mysql
else
log "Installing PHP SQLite extension"
apt-get install -y php-sqlite3
fi
log "Installing PHP SQLite extension"
apt-get install -y php-sqlite3
log "Installing Composer"
curl -sS https://getcomposer.org/installer | php
@@ -176,64 +156,36 @@ log "Installing Node.js LTS"
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
apt-get install -y nodejs
if [ "$DB_TYPE" = "mysql" ]; then
log "Creating MariaDB database and user"
mysql -e "CREATE DATABASE IF NOT EXISTS \`$DB_NAME\`;"
mysql -e "CREATE USER IF NOT EXISTS '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';"
mysql -e "GRANT ALL PRIVILEGES ON \`$DB_NAME\`.* TO '$DB_USER'@'localhost';"
mysql -e "FLUSH PRIVILEGES;"
fi
mkdir -p /var/www
cd /var/www
if [ -z "$GIT_REPO" ]; then
log "Scaffolding new project: $APP_NAME (backend/ + frontend/)"
mkdir -p "$APP_NAME"
composer create-project --prefer-dist laravel/laravel "$APP_NAME/backend"
cd "$APP_NAME/backend"
log "Installing core backend packages (Sanctum, RBAC, 2FA, MQTT)"
php artisan install:api --without-migration-prompt
composer require spatie/laravel-permission pragmarx/google2fa-laravel bacon/bacon-qr-code php-mqtt/laravel-client
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan vendor:publish --provider="PhpMqtt\Client\MqttClientServiceProvider"
php artisan vendor:publish --provider="PragmaRX\Google2FALaravel\ServiceProvider"
cd /var/www/"$APP_NAME"
npx --yes create-vite@latest frontend -- --template react-ts
cd frontend
npm install
npm install -D tailwindcss @tailwindcss/vite
npm install react-router-dom @tanstack/react-query axios mqtt qrcode
cd /var/www/"$APP_NAME"
else
log "Cloning $GIT_REPO into $APP_NAME"
git clone "$GIT_REPO" "$APP_NAME"
cd "$APP_NAME/backend"
composer install
cd ../frontend
npm install
cd /var/www/"$APP_NAME"
fi
log "Cloning $GIT_REPO into $APP_NAME"
git clone "$GIT_REPO" "$APP_NAME"
cd "$APP_NAME/backend"
composer install
cd ../frontend
npm install
log "Building the frontend for production"
npm run build
cd /var/www/"$APP_NAME"
cd "/var/www/$APP_NAME/backend"
if [ "$DB_TYPE" = "sqlite" ]; then
log "Creating SQLite database file"
mkdir -p database
touch database/database.sqlite
fi
log "Creating SQLite database file"
mkdir -p database
touch database/database.sqlite
log "Setting file permissions"
chown -R www-data:www-data "/var/www/$APP_NAME"
chmod -R 775 storage bootstrap/cache
log "Configuring Apache virtual host"
a2enmod rewrite
log "Configuring Apache virtual hosts (frontend on :80, API proxied internally on 127.0.0.1:8001)"
a2enmod rewrite proxy proxy_http
cat > "/etc/apache2/sites-available/$APP_NAME.conf" <<EOF
<VirtualHost *:80>
cat > "/etc/apache2/sites-available/$APP_NAME-api.conf" <<EOF
Listen 127.0.0.1:8001
<VirtualHost 127.0.0.1:8001>
ServerAdmin webmaster@localhost
DocumentRoot /var/www/$APP_NAME/backend/public
@@ -242,12 +194,40 @@ cat > "/etc/apache2/sites-available/$APP_NAME.conf" <<EOF
Require all granted
</Directory>
ErrorLog \${APACHE_LOG_DIR}/$APP_NAME-api-error.log
CustomLog \${APACHE_LOG_DIR}/$APP_NAME-api-access.log combined
</VirtualHost>
EOF
cat > "/etc/apache2/sites-available/$APP_NAME.conf" <<EOF
<VirtualHost *:80>
ServerAdmin webmaster@localhost
DocumentRoot /var/www/$APP_NAME/frontend/dist
<Directory /var/www/$APP_NAME/frontend/dist>
AllowOverride All
Require all granted
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</Directory>
ProxyPreserveHost On
ProxyPass /api http://127.0.0.1:8001/api
ProxyPassReverse /api http://127.0.0.1:8001/api
ProxyPass /sanctum http://127.0.0.1:8001/sanctum
ProxyPassReverse /sanctum http://127.0.0.1:8001/sanctum
ErrorLog \${APACHE_LOG_DIR}/$APP_NAME-error.log
CustomLog \${APACHE_LOG_DIR}/$APP_NAME-access.log combined
</VirtualHost>
EOF
a2dissite 000-default.conf || true
a2ensite "$APP_NAME-api.conf"
a2ensite "$APP_NAME.conf"
systemctl reload apache2
systemctl restart apache2
@@ -258,16 +238,10 @@ if [ ! -f .env ] && [ -f .env.example ]; then
fi
if [ -f .env ]; then
sed -i "s/^DB_CONNECTION=.*/DB_CONNECTION=$DB_TYPE/" .env
if [ "$DB_TYPE" = "mysql" ]; then
sed -i "s/^DB_DATABASE=.*/DB_DATABASE=$DB_NAME/" .env
sed -i "s/^DB_USERNAME=.*/DB_USERNAME=$DB_USER/" .env
sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=$DB_PASS/" .env
else
sed -i "s|^DB_DATABASE=.*|DB_DATABASE=$(pwd)/database/database.sqlite|" .env
sed -i "s/^DB_USERNAME=.*/DB_USERNAME=/" .env
sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=/" .env
fi
sed -i "s/^DB_CONNECTION=.*/DB_CONNECTION=sqlite/" .env
sed -i "s|^DB_DATABASE=.*|DB_DATABASE=$(pwd)/database/database.sqlite|" .env
sed -i "s/^DB_USERNAME=.*/DB_USERNAME=/" .env
sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=/" .env
fi
# ---------------------------------------------------------------------------
@@ -383,8 +357,8 @@ SUPER_ADMIN_EMAIL=$SUPER_ADMIN_EMAIL
SUPER_ADMIN_USERNAME=$SUPER_ADMIN_USERNAME
SUPER_ADMIN_PASSWORD=$SUPER_ADMIN_PASSWORD
FRONTEND_URL=http://${IP_ADDR:-localhost}:5173
SANCTUM_STATEFUL_DOMAINS=localhost:5173,localhost,127.0.0.1
FRONTEND_URL=http://${IP_ADDR:-localhost}
SANCTUM_STATEFUL_DOMAINS=${IP_ADDR:-localhost},localhost,127.0.0.1,localhost:5173
EOF
log "Generating application key, running migrations and seeders"
@@ -440,13 +414,14 @@ cat <<SUMMARY
Project: $APP_NAME
Location: /var/www/$APP_NAME (backend/, frontend/)
Database: $DB_TYPE
API URL: http://${IP_ADDR:-<container-ip>}/
Web URL: http://${IP_ADDR:-<container-ip>}/ (login page — served by Apache; API is proxied internally, nothing extra to run)
MQTT broker: mqtt://${IP_ADDR:-<container-ip>}:1883, ws://${IP_ADDR:-<container-ip>}:9001
Super Admin: $SUPER_ADMIN_USERNAME / $SUPER_ADMIN_PASSWORD
Frontend dev server: run 'cd /var/www/$APP_NAME/frontend && npm run dev -- --host' then tunnel port 5173.
Queue worker: running as systemd unit '$APP_NAME-queue' (needed for MQTT event publishing).
Active development: 'cd /var/www/$APP_NAME/frontend && npm run dev -- --host' gives hot-reload on :5173;
rerun 'npm run build' there and it's picked up at the Web URL above (no restart needed).
$( [ "$INSTALL_CODE_SERVER" = "yes" ] && echo "code-server: run 'code-server /var/www/$APP_NAME' then tunnel port 8080 over SSH" )
$( [ "$INSTALL_CLAUDE_CODE" = "yes" ] && echo "Claude Code: run 'claude' inside /var/www/$APP_NAME to start" )
+124
View File
@@ -8,8 +8,15 @@
"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",
"bootstrap-icons": "^1.13.1",
"mqtt": "^5.15.2",
"qrcode": "^1.5.4",
"react": "^19.2.8",
@@ -38,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",
@@ -421,6 +476,17 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
@@ -1321,6 +1387,18 @@
"node": ">=6.5"
}
},
"node_modules/admin-lte": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/admin-lte/-/admin-lte-4.1.0.tgz",
"integrity": "sha512-XsBJSYMYjUD0PYgTeI2/3WIeYGPtr2tUAsd7ei8Zgh5bYhGzLkTxAy7aIV/k19zifmHY9llaEZ0dPIUOKCzArA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"bootstrap": "^5.3.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -1407,6 +1485,42 @@
"readable-stream": "^4.2.0"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peer": true,
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/bootstrap-icons": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
"integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT"
},
"node_modules/broker-factory": {
"version": "3.1.15",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
@@ -2565,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",
+7
View File
@@ -10,8 +10,15 @@
"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",
"bootstrap-icons": "^1.13.1",
"mqtt": "^5.15.2",
"qrcode": "^1.5.4",
"react": "^19.2.8",
@@ -14,20 +14,14 @@ export function ActivityTimeline({ items }: ActivityTimelineProps) {
return (
<Card title="Recent activity">
{items.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-500">No activity yet.</p>
<p className="text-secondary mb-0">No activity yet.</p>
) : (
<ul className="flex flex-col gap-3">
<ul className="list-group list-group-flush">
{items.map((item, i) => (
<li key={i} className="flex items-start gap-3 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500" />
<div>
<p className="text-gray-800 dark:text-gray-200">
<span className="font-medium">{item.actor_name}</span> — {item.action.replace('.', ' ')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500">
{new Date(item.created_at).toLocaleString()}
</p>
</div>
<li key={i} className="list-group-item px-0">
<i className="bi bi-dot text-primary fs-4 align-middle"></i>
<span className="fw-semibold">{item.actor_name}</span> — {item.action.replace('.', ' ')}
<div className="text-secondary fs-7">{new Date(item.created_at).toLocaleString()}</div>
</li>
))}
</ul>
+7 -3
View File
@@ -7,9 +7,13 @@ interface CardProps {
export function Card({ title, children }: CardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
{title && <h2 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</h2>}
{children}
<div className="card">
{title && (
<div className="card-header">
<h3 className="card-title">{title}</h3>
</div>
)}
<div className="card-body">{children}</div>
</div>
)
}
+14 -11
View File
@@ -5,7 +5,7 @@ interface ChartPanelProps {
data: Record<string, number>
}
const BAR_COLOR = 'bg-blue-500'
const BAR_COLORS = ['bg-primary', 'bg-success', 'bg-warning', 'bg-info', 'bg-danger']
export function ChartPanel({ title, data }: ChartPanelProps) {
const entries = Object.entries(data)
@@ -13,17 +13,20 @@ export function ChartPanel({ title, data }: ChartPanelProps) {
return (
<Card title={title}>
<div className="flex flex-col gap-2">
{entries.map(([label, value]) => (
<div key={label} className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs capitalize text-gray-600 dark:text-gray-400">{label}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div className={`h-full ${BAR_COLOR}`} style={{ width: `${(value / max) * 100}%` }} />
</div>
<span className="w-6 shrink-0 text-right text-xs text-gray-500 dark:text-gray-500">{value}</span>
{entries.map(([label, value], i) => (
<div key={label} className="mb-3">
<div className="d-flex justify-content-between mb-1">
<span className="text-capitalize">{label}</span>
<span className="text-secondary">{value}</span>
</div>
))}
</div>
<div className="progress" role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={max}>
<div
className={`progress-bar ${BAR_COLORS[i % BAR_COLORS.length]}`}
style={{ width: `${(value / max) * 100}%` }}
></div>
</div>
</div>
))}
</Card>
)
}
@@ -0,0 +1,8 @@
export function Footer() {
return (
<footer className="app-footer">
<div className="float-end d-none d-sm-inline">v1.0.0</div>
<strong>Copyright &copy; {new Date().getFullYear()} webappAIO.</strong> All rights reserved.
</footer>
)
}
+11 -4
View File
@@ -1,13 +1,20 @@
interface InfoBoxProps {
label: string
value: string | number
icon: string
color?: string
}
export function InfoBox({ label, value }: InfoBoxProps) {
export function InfoBox({ label, value, icon, color = 'text-bg-primary' }: InfoBoxProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-500">{label}</p>
<p className="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">{value}</p>
<div className="info-box">
<span className={`info-box-icon shadow-sm ${color}`}>
<i className={`bi ${icon}`}></i>
</span>
<div className="info-box-content">
<span className="info-box-text">{label}</span>
<span className="info-box-number">{value}</span>
</div>
</div>
)
}
+123 -18
View File
@@ -1,27 +1,132 @@
import { Link } from 'react-router-dom'
import { useState, type FormEvent } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../../features/auth/AuthContext'
import { useNotifications } from './useNotifications'
export function Navbar() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [search, setSearch] = useState('')
const notifications = useNotifications()
function handleSearch(e: FormEvent) {
e.preventDefault()
if (search.trim()) {
navigate(`/users?search=${encodeURIComponent(search.trim())}`)
}
}
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">
<span className="font-semibold text-gray-900 dark:text-gray-100">webappAIO</span>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 dark:text-gray-400">{user?.name}</span>
<Link
to="/settings/2fa"
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"
>
2FA Settings
</Link>
<button
onClick={() => logout()}
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"
>
Sign out
</button>
<nav className="app-header navbar navbar-expand bg-body">
<div className="container-fluid">
<ul className="navbar-nav">
<li className="nav-item">
<a className="nav-link" data-lte-toggle="sidebar" href="#" role="button" aria-label="Toggle sidebar">
<i className="bi bi-list"></i>
</a>
</li>
</ul>
<form className="d-none d-md-flex ms-3" role="search" onSubmit={handleSearch}>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="form-control form-control-sm"
placeholder="Search users…"
aria-label="Search"
/>
</form>
<ul className="navbar-nav ms-auto">
<li className="nav-item dropdown">
<a
className="nav-link"
data-bs-toggle="dropdown"
href="#"
aria-label={`Notifications: ${notifications.length} unread`}
>
<i className="bi bi-bell-fill"></i>
{notifications.length > 0 && (
<span className="navbar-badge badge text-bg-warning">{notifications.length}</span>
)}
</a>
<div className="dropdown-menu dropdown-menu-lg dropdown-menu-end">
<span className="dropdown-item dropdown-header">{notifications.length} Notifications</span>
<div className="dropdown-divider"></div>
{notifications.length === 0 ? (
<span className="dropdown-item text-secondary">No new notifications</span>
) : (
notifications.map((n, i) => (
<div key={i}>
<a href="#" className="dropdown-item">
<i className="bi bi-person-fill me-2"></i>
{n.message}
</a>
{i < notifications.length - 1 && <div className="dropdown-divider"></div>}
</div>
))
)}
</div>
</li>
<li className="nav-item dropdown">
<a
className="nav-link"
href="#"
id="bd-theme"
aria-label="Toggle color scheme"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i className="bi bi-sun-fill d-none" data-lte-theme-icon="light"></i>
<i className="bi bi-moon-fill" data-lte-theme-icon="dark"></i>
<i className="bi bi-circle-half d-none" data-lte-theme-icon="auto"></i>
</a>
<ul className="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme">
<li>
<button type="button" className="dropdown-item d-flex align-items-center" data-bs-theme-value="light">
<i className="bi bi-sun-fill me-2"></i>Light
</button>
</li>
<li>
<button type="button" className="dropdown-item d-flex align-items-center" data-bs-theme-value="dark">
<i className="bi bi-moon-fill me-2"></i>Dark
</button>
</li>
<li>
<button type="button" className="dropdown-item d-flex align-items-center" data-bs-theme-value="auto">
<i className="bi bi-circle-half me-2"></i>Auto
</button>
</li>
</ul>
</li>
<li className="nav-item dropdown user-menu">
<a href="#" className="nav-link dropdown-toggle" data-bs-toggle="dropdown">
<i className="bi bi-person-circle fs-5"></i>
<span className="d-none d-md-inline ms-1">{user?.name}</span>
</a>
<ul className="dropdown-menu dropdown-menu-lg dropdown-menu-end">
<li className="user-header text-bg-primary">
<i className="bi bi-person-circle" style={{ fontSize: '3rem' }}></i>
<p>
{user?.name}
<small>{user?.roles.join(', ')}</small>
</p>
</li>
<li className="user-footer">
<Link to="/settings/2fa" className="btn btn-outline-secondary">
2FA Settings
</Link>
<button onClick={() => logout()} className="btn btn-outline-danger float-end">
Sign out
</button>
</li>
</ul>
</li>
</ul>
</div>
</header>
</nav>
)
}
@@ -0,0 +1,43 @@
import { Link } from 'react-router-dom'
export interface Breadcrumb {
label: string
to?: string
}
interface PageHeaderProps {
title: string
breadcrumbs: Breadcrumb[]
}
export function PageHeader({ title, breadcrumbs }: PageHeaderProps) {
return (
<div className="app-content-header">
<div className="container-fluid">
<div className="row">
<div className="col-sm-6">
<h1 className="mb-0 fs-3">{title}</h1>
</div>
<div className="col-sm-6">
<nav aria-label="breadcrumb">
<ol className="breadcrumb float-sm-end">
{breadcrumbs.map((crumb, i) => {
const isLast = i === breadcrumbs.length - 1
return (
<li
key={crumb.label}
className={`breadcrumb-item ${isLast ? 'active' : ''}`}
aria-current={isLast ? 'page' : undefined}
>
{!isLast && crumb.to ? <Link to={crumb.to}>{crumb.label}</Link> : crumb.label}
</li>
)
})}
</ol>
</nav>
</div>
</div>
</div>
</div>
)
}
+33 -20
View File
@@ -1,30 +1,43 @@
import { Fragment } from 'react'
import { NavLink } from 'react-router-dom'
import { NAV_ITEMS } from '../../layouts/shell/navConfig'
import { NAV_GROUPS } from '../../layouts/shell/navConfig'
import { usePermission } from '../../hooks/usePermission'
export function Sidebar() {
const { can } = usePermission()
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
return (
<aside className="hidden w-56 shrink-0 border-r border-gray-200 bg-white lg:block dark:border-gray-800 dark:bg-gray-900">
<nav className="flex flex-col gap-1 p-3">
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`rounded-md px-3 py-2 text-sm font-medium ${
isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-400'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`
}
>
{item.label}
</NavLink>
))}
</nav>
<aside className="app-sidebar bg-body-secondary shadow" data-bs-theme="dark">
<div className="sidebar-brand">
<a href="/dashboard" className="brand-link">
<span className="brand-text fw-light">webappAIO</span>
</a>
</div>
<div className="sidebar-wrapper">
<nav className="mt-2" aria-label="Main navigation">
<ul className="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false">
{NAV_GROUPS.map((group) => {
const items = group.items.filter((item) => !item.permission || can(item.permission))
if (items.length === 0) return null
return (
<Fragment key={group.header}>
<li className="nav-header">{group.header}</li>
{items.map((item) => (
<li key={item.to} className="nav-item">
<NavLink to={item.to} className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}>
<i className={`nav-icon bi ${item.icon}`}></i>
<p>{item.label}</p>
</NavLink>
</li>
))}
</Fragment>
)
})}
</ul>
</nav>
</div>
</aside>
)
}
@@ -0,0 +1,26 @@
import { Link } from 'react-router-dom'
interface SmallBoxProps {
value: string | number
label: string
icon: string
color?: string
to?: string
}
export function SmallBox({ value, label, icon, color = 'text-bg-primary', to }: SmallBoxProps) {
return (
<div className={`small-box ${color}`}>
<div className="inner">
<h3>{value}</h3>
<p>{label}</p>
</div>
<i className={`bi ${icon} small-box-icon`}></i>
{to && (
<Link to={to} className="small-box-footer link-light link-underline-opacity-0 link-underline-opacity-50-hover">
More info <i className="bi bi-arrow-right-short"></i>
</Link>
)}
</div>
)
}
@@ -0,0 +1,33 @@
import { useState } from 'react'
import { useMqttTopic } from '../../hooks/useMqtt'
interface Notification {
message: string
}
interface UsersEventPayload {
type: 'UserCreated' | 'UserUpdated' | 'UserDeleted' | 'RoleAssigned'
user: { name: string }
}
const MESSAGES: Record<UsersEventPayload['type'], (name: string) => string> = {
UserCreated: (name) => `${name} was added.`,
UserUpdated: (name) => `${name} was updated.`,
UserDeleted: (name) => `${name} was removed.`,
RoleAssigned: (name) => `${name}'s role was changed.`,
}
const MAX_NOTIFICATIONS = 10
/** Rolling list of recent Users-module events shown in the navbar bell. */
export function useNotifications(): Notification[] {
const [notifications, setNotifications] = useState<Notification[]>([])
useMqttTopic('module/users/events', (payload) => {
const event = payload as UsersEventPayload
const message = MESSAGES[event.type]?.(event.user.name) ?? 'Users list updated.'
setNotifications((prev) => [{ message }, ...prev].slice(0, MAX_NOTIFICATIONS))
})
return notifications
}
+5 -10
View File
@@ -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 (
<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) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`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'
}`
}
className={({ isActive }) => `mob-tab-item ${isActive ? 'mob-active' : ''}`}
>
<span className={`mob-nav-icon bi ${item.icon}`}></span>
{item.label}
</NavLink>
))}
@@ -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>
</>
)
}
+26 -9
View File
@@ -1,17 +1,34 @@
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 notifications = useNotifications()
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">
<span className="font-semibold text-gray-900 dark:text-gray-100">webappAIO</span>
<button
onClick={() => logout()}
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"
>
Sign out
</button>
<header className="mob-header">
<div className="mob-header-left">
<button className="mob-menu-btn" onClick={onMenuClick} aria-label="Open menu">
<i className="bi bi-list"></i>
</button>
<div className="mob-title">
<span>webappAIO</span>
<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>
)
}
+36 -34
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import { Card } from '../desktop/Card'
export interface DataTableColumn<T> {
key: string
@@ -32,21 +33,22 @@ export function DataTable<T>({
onPageChange,
isLoading,
}: DataTableProps<T>) {
const pages = Array.from({ length: lastPage }, (_, i) => i + 1)
return (
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-800/50">
<Card>
<div className="table-responsive">
<table className="table table-striped table-hover align-middle mb-0">
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} className="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">
<th key={col.key}>
{col.sortable ? (
<button
onClick={() => onSortChange?.(col.key)}
className="flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-100"
className="btn btn-link p-0 text-decoration-none fw-semibold"
>
{col.label}
{sort === col.key && <span>{direction === 'asc' ? '↑' : '↓'}</span>}
{col.label} {sort === col.key && <i className={`bi bi-caret-${direction === 'asc' ? 'up' : 'down'}-fill`}></i>}
</button>
) : (
col.label
@@ -58,23 +60,21 @@ export function DataTable<T>({
<tbody>
{isLoading ? (
<tr>
<td colSpan={columns.length} className="px-4 py-6 text-center text-gray-500">
<td colSpan={columns.length} className="text-center text-secondary py-4">
Loading…
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-6 text-center text-gray-500">
<td colSpan={columns.length} className="text-center text-secondary py-4">
No results.
</td>
</tr>
) : (
rows.map((row) => (
<tr key={rowKey(row)} className="border-b border-gray-100 last:border-0 dark:border-gray-800">
<tr key={rowKey(row)}>
{columns.map((col) => (
<td key={col.key} className="px-4 py-2 text-gray-800 dark:text-gray-200">
{col.render(row)}
</td>
<td key={col.key}>{col.render(row)}</td>
))}
</tr>
))
@@ -84,26 +84,28 @@ export function DataTable<T>({
</div>
{lastPage > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 px-4 py-2 text-sm dark:border-gray-800">
<button
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
className="rounded-md px-2 py-1 text-gray-600 hover:bg-gray-100 disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800"
>
Previous
</button>
<span className="text-gray-500 dark:text-gray-500">
Page {page} of {lastPage}
</span>
<button
disabled={page >= lastPage}
onClick={() => onPageChange(page + 1)}
className="rounded-md px-2 py-1 text-gray-600 hover:bg-gray-100 disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800"
>
Next
</button>
</div>
<nav className="mt-3" aria-label="Table pagination">
<ul className="pagination pagination-sm justify-content-end mb-0">
<li className={`page-item ${page <= 1 ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => onPageChange(page - 1)}>
Previous
</button>
</li>
{pages.map((p) => (
<li key={p} className={`page-item ${p === page ? 'active' : ''}`}>
<button className="page-link" onClick={() => onPageChange(p)}>
{p}
</button>
</li>
))}
<li className={`page-item ${page >= lastPage ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => onPageChange(page + 1)}>
Next
</button>
</li>
</ul>
</nav>
)}
</div>
</Card>
)
}
@@ -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>
</>
)
}
+95
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>
</>
)
}
@@ -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>
)
}
@@ -2,7 +2,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../auth/AuthContext'
import { usePermission } from '../../hooks/usePermission'
import { useMqttTopic } from '../../hooks/useMqtt'
import { InfoBox } from '../../components/desktop/InfoBox'
import { PageHeader } from '../../components/desktop/PageHeader'
import { SmallBox } from '../../components/desktop/SmallBox'
import { ChartPanel } from '../../components/desktop/ChartPanel'
import { ActivityTimeline } from '../../components/desktop/ActivityTimeline'
import { fetchStats, fetchActivity } from './dashboardApi'
@@ -24,20 +25,30 @@ export function DashboardPage() {
})
return (
<div>
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Dashboard</h1>
<p className="mt-1 mb-6 text-sm text-gray-600 dark:text-gray-400">Welcome, {user?.name}.</p>
<>
<PageHeader title="Dashboard" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Dashboard' }]} />
<div className="app-content">
<div className="container-fluid">
<p className="text-secondary">Welcome, {user?.name}.</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<InfoBox label="Total users" value={stats?.total_users ?? '—'} />
{stats && <ChartPanel title="Users by role" data={stats.users_by_role} />}
</div>
<div className="row">
<div className="col-lg-3 col-6">
<SmallBox value={stats?.total_users ?? '—'} label="Total Users" icon="bi-people-fill" to="/users" />
</div>
<div className="col-lg-6 col-12">
{stats && <ChartPanel title="Users by role" data={stats.users_by_role} />}
</div>
</div>
{can('audit.view') && (
<div className="mt-4">
<ActivityTimeline items={activity ?? []} />
{can('audit.view') && (
<div className="row">
<div className="col-12">
<ActivityTimeline items={activity ?? []} />
</div>
</div>
)}
</div>
)}
</div>
</div>
</>
)
}
@@ -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 />
}
@@ -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>
</>
)
}
@@ -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>
</>
)
}
@@ -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>
</>
)
}
@@ -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>
</>
)
}
+46
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>
</>
)
}
+7
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[]
}
@@ -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>
</>
)
}
@@ -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>
</>
)
}
@@ -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>
</>
)
}
+32
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' } })
}
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import { useConfirm } from '../../components/shared/ConfirmDialog'
import { useToast } from '../../components/shared/Toast/ToastProvider'
import { usePermission } from '../../hooks/usePermission'
@@ -16,15 +17,22 @@ export function UsersListPageMobile() {
const queryClient = useQueryClient()
useUsersRealtime()
const [searchParams, setSearchParams] = useSearchParams()
const search = searchParams.get('search') ?? ''
const [page, setPage] = useState(1)
const [formUser, setFormUser] = useState<User | null | undefined>(undefined)
const [roleUser, setRoleUser] = useState<User | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['users', { page, sort: 'created_at', direction: 'desc' as const }],
queryFn: () => fetchUsers({ page, sort: 'created_at', direction: 'desc' }),
queryKey: ['users', { page, sort: 'created_at', direction: 'desc' as const, search }],
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) {
const ok = await confirm({
title: 'Delete user',
@@ -41,68 +49,77 @@ export function UsersListPageMobile() {
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Users</h1>
<div className="mob-search-bar">
<input
type="search"
defaultValue={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="mob-search-input"
placeholder="🔍 Search users…"
/>
{can('users.create') && (
<button
onClick={() => setFormUser(null)}
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add
<button onClick={() => setFormUser(null)} className="mob-pill-btn">
<span>➕</span> Add
</button>
)}
</div>
{isLoading ? (
<p className="text-sm text-gray-500">Loading…</p>
) : (
<div className="flex flex-col gap-3">
{(data?.data ?? []).map((u) => (
<div
key={u.id}
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
>
<p className="font-medium text-gray-900 dark:text-gray-100">{u.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-500">{u.email}</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-600">{u.roles.join(', ')}</p>
<div className="mt-3 flex gap-4 text-sm">
{u.can_edit && (
<button onClick={() => setFormUser(u)} className="text-blue-600 dark:text-blue-400">
Edit
</button>
)}
{can('roles.assign') && (
<button onClick={() => setRoleUser(u)} className="text-blue-600 dark:text-blue-400">
Role
</button>
)}
{u.can_delete && (
<button onClick={() => handleDelete(u)} className="text-red-600 dark:text-red-400">
Delete
</button>
)}
<div className="mob-section-card">
{isLoading ? (
<p style={{ color: 'var(--mob-text-light)' }}>Loading…</p>
) : (data?.data ?? []).length === 0 ? (
<p style={{ color: 'var(--mob-text-light)' }}>No results.</p>
) : (
(data?.data ?? []).map((u) => (
<div key={u.id} className="mob-list-item" style={{ alignItems: 'flex-start' }}>
<span className="mob-avatar">{u.name.slice(0, 2).toUpperCase()}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: 0, fontWeight: 700, fontSize: 14 }}>{u.name}</p>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--mob-text-light)' }}>{u.email}</p>
<span className="mob-status-badge mob-neutral" style={{ marginTop: 6 }}>
{u.roles.join(', ')}
</span>
<div style={{ marginTop: 8, display: 'flex', gap: 14 }}>
{u.can_edit && (
<button onClick={() => setFormUser(u)} className="mob-text-btn">
Edit
</button>
)}
{can('roles.assign') && (
<button onClick={() => setRoleUser(u)} className="mob-text-btn">
Role
</button>
)}
{u.can_delete && (
<button onClick={() => handleDelete(u)} className="mob-text-btn mob-danger">
Delete
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
))
)}
</div>
{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
disabled={page <= 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
</button>
<span className="text-gray-500">
<span style={{ color: 'var(--mob-text-light)' }}>
Page {page} of {data.meta.last_page}
</span>
<button
disabled={page >= data.meta.last_page}
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
</button>
+49 -32
View File
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import { DataTable, type DataTableColumn } from '../../components/shared/DataTable'
import { useConfirm } from '../../components/shared/ConfirmDialog'
import { useToast } from '../../components/shared/Toast/ToastProvider'
import { usePermission } from '../../hooks/usePermission'
import { PageHeader } from '../../components/desktop/PageHeader'
import { fetchUsers, deleteUser } from './usersApi'
import { useUsersRealtime } from './useUsersRealtime'
import { UserFormModal } from './UserFormModal'
@@ -17,6 +19,8 @@ export function UsersListPage() {
const queryClient = useQueryClient()
useUsersRealtime()
const [searchParams, setSearchParams] = useSearchParams()
const search = searchParams.get('search') ?? ''
const [page, setPage] = useState(1)
const [sort, setSort] = useState('created_at')
const [direction, setDirection] = useState<'asc' | 'desc'>('desc')
@@ -24,8 +28,8 @@ export function UsersListPage() {
const [roleUser, setRoleUser] = useState<User | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['users', { page, sort, direction }],
queryFn: () => fetchUsers({ page, sort, direction }),
queryKey: ['users', { page, sort, direction, search }],
queryFn: () => fetchUsers({ page, sort, direction, search: search || undefined }),
})
function handleSort(key: string) {
@@ -37,6 +41,11 @@ export function UsersListPage() {
}
}
function handleSearchChange(value: string) {
setPage(1)
setSearchParams(value ? { search: value } : {})
}
async function handleDelete(user: User) {
const ok = await confirm({
title: 'Delete user',
@@ -55,24 +64,24 @@ export function UsersListPage() {
{ key: 'name', label: 'Name', sortable: true, render: (u) => u.name },
{ key: 'username', label: 'Username', sortable: true, render: (u) => u.username },
{ key: 'email', label: 'Email', sortable: true, render: (u) => u.email },
{ key: 'roles', label: 'Role', render: (u) => u.roles.join(', ') },
{ key: 'roles', label: 'Role', render: (u) => <span className="badge text-bg-secondary">{u.roles.join(', ')}</span> },
{
key: 'actions',
label: '',
render: (u) => (
<div className="flex gap-2">
<div className="btn-group btn-group-sm">
{u.can_edit && (
<button onClick={() => setFormUser(u)} className="text-blue-600 hover:underline dark:text-blue-400">
<button onClick={() => setFormUser(u)} className="btn btn-outline-primary">
Edit
</button>
)}
{can('roles.assign') && (
<button onClick={() => setRoleUser(u)} className="text-blue-600 hover:underline dark:text-blue-400">
<button onClick={() => setRoleUser(u)} className="btn btn-outline-secondary">
Role
</button>
)}
{u.can_delete && (
<button onClick={() => handleDelete(u)} className="text-red-600 hover:underline dark:text-red-400">
<button onClick={() => handleDelete(u)} className="btn btn-outline-danger">
Delete
</button>
)}
@@ -82,36 +91,44 @@ export function UsersListPage() {
]
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Users</h1>
{can('users.create') && (
<button
onClick={() => setFormUser(null)}
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add user
</button>
)}
</div>
<>
<PageHeader title="Users" breadcrumbs={[{ label: 'Home', to: '/dashboard' }, { label: 'Users' }]} />
<div className="app-content">
<div className="container-fluid">
<div className="d-flex justify-content-between align-items-center mb-3">
<input
type="search"
defaultValue={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="form-control form-control-sm w-auto"
placeholder="Search users…"
/>
{can('users.create') && (
<button onClick={() => setFormUser(null)} className="btn btn-primary btn-sm">
<i className="bi bi-plus-lg me-1"></i>Add user
</button>
)}
</div>
<DataTable
columns={columns}
rows={data?.data ?? []}
rowKey={(u) => u.id}
sort={sort}
direction={direction}
onSortChange={handleSort}
page={page}
lastPage={data?.meta.last_page ?? 1}
onPageChange={setPage}
isLoading={isLoading}
/>
<DataTable
columns={columns}
rows={data?.data ?? []}
rowKey={(u) => u.id}
sort={sort}
direction={direction}
onSortChange={handleSort}
page={page}
lastPage={data?.meta.last_page ?? 1}
onPageChange={setPage}
isLoading={isLoading}
/>
</div>
</div>
{formUser !== undefined && (
<UserFormModal open onClose={() => setFormUser(undefined)} user={formUser ?? undefined} />
)}
{roleUser && <AssignRoleModal open onClose={() => setRoleUser(null)} user={roleUser} />}
</div>
</>
)
}
+10 -5
View File
@@ -1,9 +1,14 @@
@import "tailwindcss";
/* AdminLTE (Bootstrap 5) supplies the base/reset layer for the desktop shell.
Tailwind is imported utilities-only (no preflight) so its reset doesn't
fight Bootstrap's — both utility systems coexist: AdminLTE/Bootstrap
classes drive the desktop dashboard, Tailwind utilities style everything
else (mobile shell, modals, forms, login page). */
@import "bootstrap-icons/font/bootstrap-icons.min.css";
@import "admin-lte/dist/css/adminlte.min.css";
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
:root {
color-scheme: light dark;
}
body {
margin: 0;
}
+18 -7
View File
@@ -1,17 +1,28 @@
import { useEffect } from 'react'
import { Outlet } from 'react-router-dom'
import { Navbar } from '../../components/desktop/Navbar'
import { Sidebar } from '../../components/desktop/Sidebar'
import { Footer } from '../../components/desktop/Footer'
const BODY_CLASSES = ['layout-fixed', 'sidebar-expand-lg', 'bg-body-tertiary']
export function DesktopShell() {
// AdminLTE's layout CSS keys off classes on <body>, not a scoped wrapper —
// add them only while the desktop shell is mounted so the mobile shell
// (Tailwind-only) isn't affected.
useEffect(() => {
document.body.classList.add(...BODY_CLASSES)
return () => document.body.classList.remove(...BODY_CLASSES)
}, [])
return (
<div className="flex h-screen flex-col">
<div className="app-wrapper">
<Navbar />
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto bg-gray-50 p-6 dark:bg-gray-950">
<Outlet />
</main>
</div>
<Sidebar />
<main className="app-main">
<Outlet />
</main>
<Footer />
</div>
)
}
+9 -3
View File
@@ -1,12 +1,18 @@
import { useState } from 'react'
import { Outlet } from 'react-router-dom'
import '../../styles/mobile.css'
import { TopBar } from '../../components/mobile/TopBar'
import { BottomNav } from '../../components/mobile/BottomNav'
import { SlideOutNav } from '../../components/mobile/SlideOutNav'
export function MobileShell() {
const [menuOpen, setMenuOpen] = useState(false)
return (
<div className="flex h-screen flex-col">
<TopBar />
<main className="flex-1 overflow-y-auto bg-gray-50 p-4 dark:bg-gray-950">
<div className="mob-app">
<TopBar onMenuClick={() => setMenuOpen(true)} />
<SlideOutNav open={menuOpen} onClose={() => setMenuOpen(false)} />
<main className="mob-content">
<Outlet />
</main>
<BottomNav />
+39 -3
View File
@@ -1,10 +1,46 @@
export interface NavItem {
label: string
to: string
icon: string
permission?: string
}
export const NAV_ITEMS: NavItem[] = [
{ label: 'Dashboard', to: '/dashboard', permission: 'dashboard.view' },
{ label: 'Users', to: '/users', permission: 'users.view' },
export interface NavGroup {
header: string
items: NavItem[]
}
export const NAV_GROUPS: NavGroup[] = [
{
header: 'MAIN',
items: [{ label: 'Dashboard', to: '/dashboard', icon: 'bi-speedometer', permission: 'dashboard.view' }],
},
{
header: 'ADMINISTRATION',
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)
// 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))
+7
View File
@@ -1,6 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
// Bootstrap's own JS (dropdowns, the navbar/user-menu use data-bs-toggle)
// is a separate concern from AdminLTE's plugins below.
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
// Side-effect import: binds AdminLTE's delegated document-level click
// listeners (sidebar toggle, treeview, card widgets, fullscreen, color
// mode) once at app startup — works across all client-side route changes.
import 'admin-lte'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
+30 -2
View File
@@ -2,8 +2,18 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'
import { LoginPage } from '../features/auth/LoginPage'
import { TwoFactorChallengePage } from '../features/auth/TwoFactorChallengePage'
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 { 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 { ProtectedRoute } from './ProtectedRoute'
import { RequirePermission } from './RequirePermission'
@@ -17,12 +27,30 @@ export const router = createBrowserRouter([
{
element: <AppShell />,
children: [
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/dashboard', element: <DashboardRoute /> },
{ path: '/settings', element: <SettingsPage /> },
{ 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" />,
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 /> },
],
},
],
},
],
+476
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;
}
}
+1
View File
@@ -1,3 +1,4 @@
export interface Role {
name: string
permissions?: string[]
}