Guest Wi-Fi dashboard card (network + rotating password + join QR)

New home card 'unifi.guest_wifi': shows the guest SSID, the current
rotated password (unifi.password_rotation.last_password), and a WPA
join QR built from the WIFI: string. Polls app/unifi/guest-card/data
(auth) every 60s so it updates the moment the rotation engine changes
the password — the QR regenerates client-side to match. Self-hides
until enabled with an SSID and a rotated password; grant-gated via
Admin -> Settings -> Dashboard Cards. New settings (WiFi rotation
section): enable + network name + card title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 19:37:44 -04:00
parent 71807c8815
commit 070001ffff
5 changed files with 98 additions and 1 deletions

View File

@@ -0,0 +1,65 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Routing\Controller;
/**
* The "Guest Wi-Fi" dashboard home card: network name, the current
* rotating password, and a QR code that re-generates when the password
* changes. The password is the rotation engine's last-applied value
* (unifi.password_rotation.last_password), so the card tracks whatever
* unifi:rotate-passwords sets. The card polls data() so it updates
* without a page reload.
*/
class GuestCardController extends Controller
{
/** Payload shared by the card resolver and the poll endpoint. */
public static function payload(): ?array
{
if (! Setting::get('unifi.guest_card.enabled')) {
return null;
}
$password = Setting::get('unifi.password_rotation.last_password');
$ssid = trim((string) Setting::get('unifi.guest_card.ssid', ''));
if (! $password || $ssid === '') {
return null; // nothing meaningful to show yet
}
$freq = Setting::get('unifi.password_rotation.frequency', 'weekly');
return [
'title' => trim((string) Setting::get('unifi.guest_card.title', '')) ?: 'Guest Wi-Fi',
'ssid' => $ssid,
'password' => $password,
'rotated_at' => Setting::get('unifi.password_rotation.last_rotated_at'),
// WPA/WPA2 join string a phone camera understands. Special
// chars in SSID/password must be backslash-escaped per the
// Wi-Fi QR spec.
'qr' => self::wifiQrString($ssid, $password),
'frequency' => in_array($freq, ['daily', 'weekly'], true) ? $freq : 'weekly',
];
}
/** Live poll for the card (any logged-in user who can see the card). */
public function data()
{
return response()->json(self::payload() ?? ['title' => null]);
}
/** Card registry resolver. */
public static function resolve(User $user): ?array
{
return self::payload();
}
private static function wifiQrString(string $ssid, string $password): string
{
$esc = fn (string $v) => preg_replace('/([\\\\;,:"])/', '\\\\$1', $v);
return sprintf('WIFI:T:WPA;S:%s;P:%s;;', $esc($ssid), $esc($password));
}
}

View File

@@ -36,6 +36,9 @@ class UnifiSettingsController extends Controller
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false),
'apiToken' => Setting::get('unifi.api_token', null),
'guestCardEnabled' => (bool) Setting::get('unifi.guest_card.enabled', false),
'guestCardSsid' => Setting::get('unifi.guest_card.ssid', ''),
'guestCardTitle' => Setting::get('unifi.guest_card.title', ''),
]);
}
@@ -74,6 +77,9 @@ class UnifiSettingsController extends Controller
'rotation_wordlist' => 'nullable|string|max:20000',
'ppsk_scheduling_enabled' => 'boolean',
'api_enabled' => 'boolean',
'guest_card_enabled' => 'boolean',
'guest_card_ssid' => 'nullable|string|max:100',
'guest_card_title' => 'nullable|string|max:100',
]);
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
@@ -101,6 +107,9 @@ class UnifiSettingsController extends Controller
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : '');
Setting::set('unifi.api.enabled', $request->boolean('api_enabled') ? '1' : '');
Setting::set('unifi.guest_card.enabled', $request->boolean('guest_card_enabled') ? '1' : '');
Setting::set('unifi.guest_card.ssid', trim((string) $request->input('guest_card_ssid', '')));
Setting::set('unifi.guest_card.title', trim((string) $request->input('guest_card_title', '')));
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));

View File

@@ -24,6 +24,22 @@ class UnifiServiceProvider extends ServiceProvider
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// "/" home card: guest Wi-Fi network + current rotating password +
// QR (re-renders when the password changes). Grant-gated via
// Admin → Settings → Dashboard Cards; self-hides until enabled and
// a password has been rotated.
try {
app(\App\Support\DashboardCardRegistry::class)->register(
'unifi.guest_wifi',
'Guest Wi-Fi',
'UnifiGuestWifi',
fn (\App\Models\User $user) => \Dashboard\Unifi\Http\Controllers\GuestCardController::resolve($user),
'The guest network name, current password, and a join QR code — updates automatically when the password rotates.',
);
} catch (\Throwable) {
// Older shell without the card registry — skip.
}
// Tell the shell's nav sidebar which unifi nav items the user
// can see: pages whose required_permission the user holds through
// groups, plus pages granted via the Settings → Access tab. The

View File

@@ -103,6 +103,13 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
});
});
// ── Guest Wi-Fi home-card poll (any logged-in user; card visibility is
// grant-gated by the shell). Exempt from the unifi page-grant middleware
// because it's a home-page widget, not a unifi page. ──
Route::middleware(['web', 'auth'])
->get('/app/unifi/guest-card/data', [\Dashboard\Unifi\Http\Controllers\GuestCardController::class, 'data'])
->name('unifi.guest-card.data');
// ── Public API (token-protected) ──────────────────────────────────────────
// External integrations (signage, kiosks) hit these without session auth.
Route::prefix('api/unifi')->name('unifi.api.')->group(function () {