getWlans())->map(fn ($w) => $this->mapWlan($w))->values(); $raw = Setting::get('unifi.ssid_groups', '{}'); $groups = json_decode($raw, true); if (! is_array($groups) || array_is_list($groups)) $groups = []; $groups = (object) $groups; $rotateWlanIds = json_decode(Setting::get('unifi.password_rotation.wlan_ids', '[]'), true) ?: []; return Inertia::render('Unifi/Wifi', [ 'wlans' => $wlans, 'groups' => $groups, 'rotateWlanIds' => $rotateWlanIds, 'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false), ]); } catch (\Throwable $e) { return Inertia::render('Unifi/Wifi', [ 'wlans' => [], 'groups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(), ]); } } public function update(Request $request, string $wlanId, UnifiApiClient $unifi) { $data = $request->validate([ 'x_passphrase' => 'sometimes|string|min:8|max:63', 'hide_ssid' => 'sometimes|boolean', 'mac_filter_enabled' => 'sometimes|boolean', 'mac_filter_policy' => 'sometimes|string|in:allow,deny', 'rotate_password' => 'sometimes|boolean', ]); try { // Password/hide changes apply to all grouped WLANs $shared = array_filter($data, fn ($k) => in_array($k, ['x_passphrase', 'hide_ssid']), ARRAY_FILTER_USE_KEY); $groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: []; $groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids)); if (! empty($shared)) { if ($groupedIds) { foreach ($groupedIds as $id) { $unifi->updateWlan($id, $shared); } } else { $unifi->updateWlan($wlanId, $shared); } } // MAC filter changes apply to this WLAN only $perWlan = array_filter($data, fn ($k) => in_array($k, ['mac_filter_enabled', 'mac_filter_policy']), ARRAY_FILTER_USE_KEY); if (! empty($perWlan)) { $unifi->updateWlan($wlanId, $perWlan); } // Toggle wlan_id in the rotation list if ($request->has('rotate_password')) { $rotateIds = json_decode(Setting::get('unifi.password_rotation.wlan_ids', '[]'), true) ?: []; $targetIds = $groupedIds ?: [$wlanId]; if ($request->boolean('rotate_password')) { $rotateIds = array_values(array_unique(array_merge($rotateIds, $targetIds))); } else { $rotateIds = array_values(array_diff($rotateIds, $targetIds)); } Setting::set('unifi.password_rotation.wlan_ids', json_encode($rotateIds)); } return back()->with('success', 'WiFi network updated.'); } catch (\Throwable $e) { return back()->withErrors(['error' => $e->getMessage()]); } } public function toggle(Request $request, string $wlanId, UnifiApiClient $unifi) { $request->validate(['enabled' => 'required|boolean']); try { $groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: []; $groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids)); $enabled = $request->boolean('enabled'); if ($groupedIds) { foreach ($groupedIds as $id) { $unifi->updateWlan($id, ['enabled' => $enabled]); } } else { $unifi->updateWlan($wlanId, ['enabled' => $enabled]); } return back()->with('success', 'WiFi network ' . ($enabled ? 'enabled' : 'disabled') . '.'); } catch (\Throwable $e) { return back()->withErrors(['error' => $e->getMessage()]); } } // ── PPSK ───────────────────────────────────────────────────────────────── public function ppskIndex(string $wlanId, UnifiApiClient $unifi) { try { $liveEntries = $unifi->getPpskEntries($wlanId); // Network confs are best-effort — don't let a failure block PPSK display try { $networksRaw = $unifi->getNetworkConfs(); } catch (\Throwable $e) { $networksRaw = []; } $networksById = collect($networksRaw)->keyBy('_id'); // ── Sync live entries into DB ──────────────────────────────────── $liveIds = []; foreach ($liveEntries as $entry) { $pass = $entry['x_passphrase'] ?? $entry['password'] ?? null; $uid = $entry['_id'] ?? $entry['id'] ?? null; // wlan_embedded PPSKs have no _id — derive a stable synthetic ID from the passphrase if (! $uid && $pass) { $uid = 'emb_' . substr(hash('sha256', $wlanId . ':' . $pass), 0, 32); } if (! $uid) continue; $liveIds[] = $uid; $nconfId = $entry['networkconf_id'] ?? null; $vlan = ($nconfId && isset($networksById[$nconfId])) ? ($networksById[$nconfId]['vlan'] ?? null) : null; if ($vlan === null && ! empty($entry['vlan_id'])) { $vlan = (int) $entry['vlan_id']; } $name = $entry['name'] ?? $entry['label'] ?? $entry['username'] ?? null; // For anonymous PPSKs, use the associated network name as the default label if (! $name && $nconfId && isset($networksById[$nconfId])) { $name = $networksById[$nconfId]['name'] ?? null; } // Match by unifi_id, or by passphrase for a held embedded record re-appearing $record = UnifiPpsk::where('unifi_id', $uid)->first() ?? UnifiPpsk::where('wlan_id', $wlanId) ->where('x_passphrase', $pass) ->where('state', 'held') ->first(); if ($record) { $upd = ['unifi_id' => $uid, 'state' => 'active']; if ($name) $upd['name'] = $name; if ($pass) $upd['x_passphrase'] = $pass; if ($vlan !== null) $upd['vlan'] = $vlan; $record->update($upd); } else { UnifiPpsk::create([ 'wlan_id' => $wlanId, 'unifi_id' => $uid, 'name' => $name ?? 'PPSK', 'x_passphrase' => $pass ?? '', 'vlan' => $vlan, 'state' => 'active', ]); } } // Only mark as held when we have confirmed live IDs — // never wipe on an empty API response (prevents false-holds on API failures) if (! empty($liveIds)) { UnifiPpsk::where('wlan_id', $wlanId) ->where('state', 'active') ->whereNotNull('unifi_id') ->whereNotIn('unifi_id', $liveIds) ->update(['state' => 'held', 'unifi_id' => null]); } $dbRecords = UnifiPpsk::where('wlan_id', $wlanId) ->orderByRaw("FIELD(state, 'active', 'held')") ->orderBy('name') ->get(); // Fallback: if DB still empty but live entries exist, return live entries directly. // Applies the same synthetic-ID and networkconf logic so IDs are always non-null. if ($dbRecords->isEmpty() && ! empty($liveEntries)) { $entries = collect($liveEntries)->map(function ($e) use ($wlanId, $networksById) { $pass = $e['x_passphrase'] ?? $e['password'] ?? null; $uid = $e['_id'] ?? $e['id'] ?? null; if (! $uid && $pass) { $uid = 'emb_' . substr(hash('sha256', $wlanId . ':' . $pass), 0, 32); } $nconfId = $e['networkconf_id'] ?? null; $vlan = ($nconfId && isset($networksById[$nconfId])) ? ($networksById[$nconfId]['vlan'] ?? null) : null; if ($vlan === null && ! empty($e['vlan_id'])) { $vlan = (int) $e['vlan_id']; } $name = $e['name'] ?? $e['label'] ?? $e['username'] ?? null; if (! $name && $nconfId && isset($networksById[$nconfId])) { $name = $networksById[$nconfId]['name'] ?? null; } return [ 'id' => $uid, 'unifi_id' => $uid, 'name' => $name ?? 'PPSK', 'x_passphrase' => $pass, 'vlan' => $vlan, 'state' => 'active', 'rotate_password' => false, 'schedule' => null, ]; })->values(); } else { $entries = $dbRecords->map(fn ($r) => $this->mapPpsk($r)); } $networks = $networksById->values()->map(fn ($n) => [ '_id' => $n['_id'], 'name' => $n['name'] ?? 'Unnamed', 'vlan' => $n['vlan'] ?? null, ]); return response()->json(['ok' => true, 'entries' => $entries, 'networks' => $networks]); } catch (\Throwable $e) { return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); } } public function ppskStore(Request $request, string $wlanId, UnifiApiClient $unifi) { $data = $request->validate([ 'name' => 'required|string|max:100', 'x_passphrase' => 'required|string|min:8|max:63', 'networkconf_id' => 'nullable|string', 'vlan' => 'nullable|integer', ]); try { $pushData = [ 'name' => $data['name'], 'x_passphrase' => $data['x_passphrase'], 'wlan_id' => $wlanId, ]; if (! empty($data['networkconf_id'])) { $pushData['networkconf_id'] = $data['networkconf_id']; } $result = $unifi->createPpsk($pushData); $raw = $result[0] ?? $result; $unifiId = $raw['_id'] ?? null; $record = UnifiPpsk::create([ 'wlan_id' => $wlanId, 'unifi_id' => $unifiId, 'name' => $data['name'], 'x_passphrase' => $data['x_passphrase'], 'vlan' => $data['vlan'] ?? null, 'state' => 'active', ]); return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record)]); } catch (\Throwable $e) { return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); } } public function ppskUpdate(Request $request, string $wlanId, string $ppskId, UnifiApiClient $unifi) { $record = UnifiPpsk::findOrFail($ppskId); $data = $request->validate([ 'name' => 'sometimes|string|max:100', 'x_passphrase' => 'sometimes|string|min:8|max:63', 'networkconf_id' => 'nullable|string', 'vlan' => 'nullable|integer', ]); try { if ($record->unifi_id && $record->state === 'active') { $unifiUpdate = array_filter( array_intersect_key($data, array_flip(['name', 'x_passphrase', 'networkconf_id'])), fn ($v) => $v !== null ); if (! empty($unifiUpdate)) { $unifi->updatePpsk($record->unifi_id, $unifiUpdate); } } $dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase'])); // vlan can be explicitly set to null if (array_key_exists('vlan', $data)) $dbUpdate['vlan'] = $data['vlan']; if (! empty($dbUpdate)) $record->update($dbUpdate); return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record->fresh())]); } catch (\Throwable $e) { return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); } } public function ppskDestroy(string $wlanId, string $ppskId, UnifiApiClient $unifi) { $record = UnifiPpsk::findOrFail($ppskId); try { if ($record->unifi_id) { try { $unifi->kickClientsForPpsk($record->unifi_id); } catch (\Throwable) {} // Embedded PPSKs (synthetic emb_ IDs) aren't deletable via the PPSK REST endpoint; // skip the API call — the entry will disappear from UniFi when the WLAN is reconfigured. if (! str_starts_with($record->unifi_id, 'emb_')) { $unifi->deletePpsk($record->unifi_id); } } $record->delete(); return response()->json(['ok' => true]); } catch (\Throwable $e) { return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); } } public function ppskSchedule(Request $request, string $wlanId, string $ppskId) { $record = UnifiPpsk::findOrFail($ppskId); $request->validate([ 'schedule' => 'nullable|array', 'schedule.*' => 'boolean', ]); $record->update(['schedule' => $request->schedule]); return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record->fresh())]); } public function ppskToggleRotation(Request $request, string $wlanId, string $ppskId) { $record = UnifiPpsk::findOrFail($ppskId); $request->validate(['rotate_password' => 'required|boolean']); $record->update(['rotate_password' => $request->boolean('rotate_password')]); return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record)]); } private function mapPpsk(UnifiPpsk $r): array { return [ 'id' => $r->id, 'unifi_id' => $r->unifi_id, 'name' => $r->name, 'x_passphrase' => $r->x_passphrase, 'vlan' => $r->vlan, 'state' => $r->state, 'rotate_password' => $r->rotate_password, 'schedule' => $r->schedule, ]; } /** * Group/ungroup SSIDs. Groups are stored as { "GroupName": ["wlan_id_1", "wlan_id_2"] } */ public function saveGroups(Request $request) { $request->validate(['groups' => 'present']); $groups = $request->input('groups', []); if (is_array($groups) && array_is_list($groups)) $groups = (object) []; Setting::set('unifi.ssid_groups', json_encode($groups ?: (object) [])); return back()->with('success', 'SSID groups saved.'); } // ── Helpers ─────────────────────────────────────────────────────────────── private function mapWlan(array $w): array { return [ 'id' => $w['_id'], 'name' => $w['name'], 'enabled' => $w['enabled'] ?? true, 'security' => $w['security'] ?? 'open', 'wpa_mode' => $w['wpa_mode'] ?? '', 'is_guest' => $w['is_guest'] ?? false, 'vlan_enabled' => $w['vlan_enabled'] ?? false, 'vlan' => $w['vlan'] ?? null, 'hide_ssid' => $w['hide_ssid'] ?? false, 'passphrase' => $w['x_passphrase'] ?? '', 'band' => $this->detectBand($w), 'mac_filter_enabled' => $w['mac_filter_enabled'] ?? false, 'mac_filter_policy' => $w['mac_filter_policy'] ?? 'deny', 'ppsk_enabled' => ($w['wpa3_ppsk'] ?? false) || ($w['ppsk'] ?? false) || ($w['private_preshared_keys_enabled'] ?? false) || ! empty($w['private_preshared_keys']), ]; } private function detectBand(array $w): string { $band = $w['wlan_band'] ?? null; if ($band === 'ng' || $band === '2g') return '2.4 GHz'; if ($band === 'na' || $band === '5g') return '5 GHz'; if ($band === 'a6' || $band === '6g' || $band === '6e') return '6 GHz'; if ($band === 'both' || $band === null) return 'All bands'; $name = strtolower($w['name'] ?? ''); if (preg_match('/2\.?4\s*g/i', $name)) return '2.4 GHz'; if (preg_match('/5\s*g/i', $name)) return '5 GHz'; if (preg_match('/6\s*g/i', $name)) return '6 GHz'; return 'All bands'; } }