diff --git a/app/Livewire/Admin/ConfirmDeleteVpnPeer.php b/app/Livewire/Admin/ConfirmDeleteVpnPeer.php new file mode 100644 index 0000000..b9aa9a0 --- /dev/null +++ b/app/Livewire/Admin/ConfirmDeleteVpnPeer.php @@ -0,0 +1,51 @@ +authorize('vpn.manage'); // modals are reachable without the route middleware + $peer = VpnPeer::query()->with('host')->where('uuid', $uuid)->firstOrFail(); + $this->uuid = $uuid; + $this->name = $peer->name; + $this->hostName = $peer->host?->name; + } + + public function delete(): void + { + $this->authorize('vpn.manage'); + + $peer = VpnPeer::query()->where('uuid', $this->uuid)->first(); + if ($peer !== null) { + // Remove from the hub first: if the row went first and the job then + // failed, the peer would keep its tunnel with nothing left to show it. + ApplyVpnPeer::dispatch($peer->public_key, null, false); + $peer->delete(); + } + + $this->dispatch('vpn-peer-deleted'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-delete-vpn-peer'); + } +} diff --git a/app/Livewire/Admin/Vpn.php b/app/Livewire/Admin/Vpn.php new file mode 100644 index 0000000..7b1be51 --- /dev/null +++ b/app/Livewire/Admin/Vpn.php @@ -0,0 +1,161 @@ +authorize('vpn.manage'); + } + + public function create(): void + { + $this->authorize('vpn.manage'); + + $this->validate([ + 'name' => 'required|string|max:255', + 'publicKey' => 'nullable|string|max:64', + ]); + + $ownKey = trim($this->publicKey) !== ''; + if ($ownKey && ! Keypair::isValidKey(trim($this->publicKey))) { + $this->addError('publicKey', __('vpn.invalid_key')); + + return; + } + + $keypair = $ownKey ? null : Keypair::generate(); + $publicKey = $ownKey ? trim($this->publicKey) : $keypair->publicKey; + + if (VpnPeer::query()->where('public_key', $publicKey)->exists()) { + $this->addError('publicKey', __('vpn.duplicate_key')); + + return; + } + + $hub = app(WireguardHub::class); + + // allocateIp reads the addresses already handed out; two operators + // creating at the same moment can still pick the same one, so the unique + // index is the real guard and we retry once against it. + $peer = null; + foreach (range(1, 3) as $attempt) { + try { + $peer = VpnPeer::create([ + 'name' => trim($this->name), + 'public_key' => $publicKey, + 'allowed_ip' => $hub->allocateIp(), + 'enabled' => true, + 'present' => false, + 'created_by' => auth()->id(), + ]); + break; + } catch (QueryException $e) { + if ($attempt === 3) { + throw $e; + } + } + } + + ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true); + + // Only a key we generated can be turned into a ready-to-use config. + $this->newConfig = $keypair === null ? null : $this->clientConfig($keypair, $peer->allowed_ip); + $this->newConfigName = $peer->name; + + $this->reset('name', 'publicKey'); + $this->dispatch('notify', message: __('vpn.created')); + } + + public function toggle(string $uuid): void + { + $this->authorize('vpn.manage'); + + $peer = VpnPeer::query()->where('uuid', $uuid)->first(); + if ($peer === null) { + return; + } + + $peer->update(['enabled' => ! $peer->enabled]); + ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, $peer->enabled); + + $this->dispatch('notify', message: $peer->enabled ? __('vpn.unblocked') : __('vpn.blocked')); + } + + #[On('vpn-peer-deleted')] + public function peerDeleted(): void + { + $this->dispatch('notify', message: __('vpn.deleted')); + } + + public function dismissConfig(): void + { + $this->reset('newConfig', 'newConfigName'); + } + + /** Polled by the view; throttled so a room full of open tabs cannot flood the queue. */ + public function refreshPeers(): void + { + if (Cache::add('vpn:sync-dispatched', true, 8)) { + SyncVpnPeers::dispatch(); + } + } + + private function clientConfig(Keypair $keypair, string $ip): string + { + $hub = app(WireguardHub::class); + + return implode("\n", [ + '[Interface]', + 'PrivateKey = '.$keypair->privateKey, + 'Address = '.$ip.'/32', + '', + '[Peer]', + 'PublicKey = '.$hub->publicKey(), + 'Endpoint = '.$hub->endpoint(), + 'AllowedIPs = '.config('provisioning.wireguard.subnet', '10.66.0.0/24'), + 'PersistentKeepalive = 25', + '', + ]); + } + + public function render() + { + $hub = app(WireguardHub::class); + + return view('livewire.admin.vpn', [ + 'peers' => VpnPeer::query()->with('host')->orderByDesc('present')->orderBy('name')->get(), + 'hubEndpoint' => $hub->endpoint(), + 'hubPublicKey' => $hub->publicKey(), + 'lastSync' => VpnPeer::query()->max('observed_at'), + ]); + } +} diff --git a/app/Models/VpnPeer.php b/app/Models/VpnPeer.php new file mode 100644 index 0000000..9dd59e8 --- /dev/null +++ b/app/Models/VpnPeer.php @@ -0,0 +1,74 @@ + 'boolean', + 'present' => 'boolean', + 'last_handshake_at' => 'datetime', + 'observed_at' => 'datetime', + 'rx_bytes' => 'integer', + 'tx_bytes' => 'integer', + ]; + } + + public function uniqueIds(): array + { + return ['uuid']; + } + + public function host(): BelongsTo + { + return $this->belongsTo(Host::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function isOnline(): bool + { + return $this->enabled + && $this->present + && $this->last_handshake_at !== null + && $this->last_handshake_at->gt(now()->subMinutes(self::ONLINE_AFTER_MINUTES)); + } + + /** + * blocked — operator revoked it; it must not be on the hub + * pending — desired and observed state disagree; a job is still applying it + * online — configured and handshaking + * idle — configured, but no recent handshake + */ + public function status(): string + { + if (! $this->enabled) { + return $this->present ? 'pending' : 'blocked'; + } + if (! $this->present) { + return 'pending'; + } + + return $this->isOnline() ? 'online' : 'idle'; + } +} diff --git a/app/Provisioning/Jobs/ApplyVpnPeer.php b/app/Provisioning/Jobs/ApplyVpnPeer.php new file mode 100644 index 0000000..6e6a53e --- /dev/null +++ b/app/Provisioning/Jobs/ApplyVpnPeer.php @@ -0,0 +1,54 @@ +onQueue('provisioning'); + } + + public static function for(VpnPeer $peer): self + { + return new self($peer->public_key, $peer->allowed_ip, $peer->enabled); + } + + public function handle(WireguardHub $hub): void + { + if ($this->enabled && $this->allowedIp !== null) { + $hub->addPeer($this->publicKey, $this->allowedIp); + } else { + $hub->removePeer($this->publicKey); + } + + // Reflect the change immediately instead of waiting for the next sync, + // so the console does not sit on "wird angewendet" for a whole minute. + VpnPeer::query()->where('public_key', $this->publicKey)->update([ + 'present' => $this->enabled, + 'observed_at' => now(), + ]); + } +} diff --git a/app/Provisioning/Jobs/SyncVpnPeers.php b/app/Provisioning/Jobs/SyncVpnPeers.php new file mode 100644 index 0000000..d47e01e --- /dev/null +++ b/app/Provisioning/Jobs/SyncVpnPeers.php @@ -0,0 +1,90 @@ +onQueue('provisioning'); + } + + public function handle(WireguardHub $hub): void + { + $snapshots = $hub->peers(); + $now = now(); + + // hosts.wg_pubkey => host id, so pipeline-created peers show up + // under their host name instead of as an unknown key. + $hostsByKey = Host::query() + ->whereNotNull('wg_pubkey') + ->pluck('id', 'wg_pubkey'); + + foreach ($snapshots as $publicKey => $snapshot) { + $peer = VpnPeer::query()->where('public_key', $publicKey)->first(); + $hostId = $hostsByKey[$publicKey] ?? null; + + $observed = [ + 'present' => true, + 'endpoint' => $snapshot->endpoint, + 'last_handshake_at' => $snapshot->latestHandshake, + 'rx_bytes' => $snapshot->rxBytes, + 'tx_bytes' => $snapshot->txBytes, + 'observed_at' => $now, + ]; + + if ($peer === null) { + VpnPeer::create($observed + [ + 'public_key' => $publicKey, + // Fall back to the dump's allowed-ips for peers that were + // never created through the console. + 'allowed_ip' => strtok($snapshot->allowedIps, '/') ?: $snapshot->allowedIps, + 'host_id' => $hostId, + 'name' => $hostId !== null + ? (Host::find($hostId)?->name ?? __('vpn.unknown_peer')) + : __('vpn.unknown_peer'), + 'enabled' => true, + ]); + + continue; + } + + // Never overwrite `enabled`: that is the operator's intent, and a + // peer being present only says the hub has not caught up yet. + $peer->update($observed + ['host_id' => $peer->host_id ?? $hostId]); + } + + VpnPeer::query() + ->whereNotIn('public_key', array_keys($snapshots)) + ->update(['present' => false, 'observed_at' => $now]); + } +} diff --git a/app/Services/Wireguard/FakeWireguardHub.php b/app/Services/Wireguard/FakeWireguardHub.php index 0faa212..7e6422a 100644 --- a/app/Services/Wireguard/FakeWireguardHub.php +++ b/app/Services/Wireguard/FakeWireguardHub.php @@ -13,6 +13,9 @@ class FakeWireguardHub implements WireguardHub public string $publicKeyValue = 'HUBPUBLICKEY0000000000000000000000000000000='; + /** Snapshots the tests can shape; defaults to "configured, never handshaked". */ + public array $snapshots = []; + public function allocateIp(): string { return '10.66.0.'.$this->next++; @@ -28,6 +31,23 @@ class FakeWireguardHub implements WireguardHub unset($this->peers[$publicKey]); } + public function peers(): array + { + $peers = []; + foreach ($this->peers as $publicKey => $ip) { + $peers[$publicKey] = $this->snapshots[$publicKey] ?? new PeerSnapshot( + publicKey: $publicKey, + endpoint: null, + allowedIps: $ip.'/32', + latestHandshake: null, + rxBytes: 0, + txBytes: 0, + ); + } + + return $peers; + } + public function endpoint(): string { return $this->endpointValue; @@ -38,8 +58,9 @@ class FakeWireguardHub implements WireguardHub return $this->publicKeyValue; } - /** @return array */ - public function peers(): array + /** Raw pubkey => ip map — what the tests assert on. Not the interface's + * peers(), which returns live snapshots. */ + public function peerIps(): array { return $this->peers; } diff --git a/app/Services/Wireguard/Keypair.php b/app/Services/Wireguard/Keypair.php new file mode 100644 index 0000000..2ba89f5 --- /dev/null +++ b/app/Services/Wireguard/Keypair.php @@ -0,0 +1,37 @@ +whereNotNull('wg_ip')->pluck('wg_ip')->all()); + // Both tables hand out addresses from the same subnet — an operator VPN + // access and a host peer would otherwise be given the same tunnel IP. + $used = array_flip(array_merge( + Host::query()->whereNotNull('wg_ip')->pluck('wg_ip')->all(), + VpnPeer::query()->pluck('allowed_ip')->all(), + )); $hubIp = (string) config('provisioning.wireguard.hub_ip'); [$network, $bits] = explode('/', config('provisioning.wireguard.subnet', '10.66.0.0/24')); @@ -48,6 +54,28 @@ class LocalWireguardHub implements WireguardHub Process::run('wg-quick save wg0')->throw(); // persist, else the peer is lost on restart } + public function peers(): array + { + $result = Process::run(['wg', 'show', 'wg0', 'dump']); + if (! $result->successful()) { + throw new RuntimeException('wg show failed: '.trim($result->errorOutput())); + } + + $peers = []; + // The first line describes the interface itself, not a peer. + foreach (array_slice(preg_split('/\R/', trim($result->output())) ?: [], 1) as $line) { + if (trim($line) === '') { + continue; + } + $snapshot = PeerSnapshot::fromDumpLine(explode("\t", $line)); + if ($snapshot !== null) { + $peers[$snapshot->publicKey] = $snapshot; + } + } + + return $peers; + } + public function endpoint(): string { return (string) config('provisioning.wireguard.endpoint'); diff --git a/app/Services/Wireguard/PeerSnapshot.php b/app/Services/Wireguard/PeerSnapshot.php new file mode 100644 index 0000000..d92c48a --- /dev/null +++ b/app/Services/Wireguard/PeerSnapshot.php @@ -0,0 +1,41 @@ + dump` — what the hub currently reports about a peer. + * A value object rather than an array so callers cannot silently mistype a key. + */ +final readonly class PeerSnapshot +{ + public function __construct( + public string $publicKey, + public ?string $endpoint, + public string $allowedIps, + public ?CarbonImmutable $latestHandshake, + public int $rxBytes, + public int $txBytes, + ) {} + + /** @param list $columns one dump line, already split on tabs */ + public static function fromDumpLine(array $columns): ?self + { + // publickey, presharedkey, endpoint, allowed-ips, latest-handshake, rx, tx, keepalive + if (count($columns) < 8) { + return null; + } + $handshake = (int) $columns[4]; + + return new self( + publicKey: $columns[0], + endpoint: $columns[2] === '(none)' ? null : $columns[2], + allowedIps: $columns[3], + // 0 means "never handshaked" — not the unix epoch. + latestHandshake: $handshake > 0 ? CarbonImmutable::createFromTimestamp($handshake) : null, + rxBytes: (int) $columns[5], + txBytes: (int) $columns[6], + ); + } +} diff --git a/app/Services/Wireguard/WireguardHub.php b/app/Services/Wireguard/WireguardHub.php index f9289e5..4edc194 100644 --- a/app/Services/Wireguard/WireguardHub.php +++ b/app/Services/Wireguard/WireguardHub.php @@ -20,4 +20,13 @@ interface WireguardHub /** The hub's WireGuard public key. */ public function publicKey(): string; + + /** + * What the interface currently reports, keyed by public key. Live state — + * traffic counters and handshakes only exist here, never in the database + * until SyncVpnPeers copies them over. + * + * @return array + */ + public function peers(): array; } diff --git a/app/Support/Bytes.php b/app/Support/Bytes.php new file mode 100644 index 0000000..ed742f8 --- /dev/null +++ b/app/Support/Bytes.php @@ -0,0 +1,26 @@ += 100 ? 0 : $decimals; + + return number_format($value, $decimals, ',', '.').' '.$units[$power - 1]; + } +} diff --git a/database/factories/VpnPeerFactory.php b/database/factories/VpnPeerFactory.php new file mode 100644 index 0000000..c4736d3 --- /dev/null +++ b/database/factories/VpnPeerFactory.php @@ -0,0 +1,27 @@ + $this->faker->userName(), + 'public_key' => Keypair::generate()->publicKey, + 'allowed_ip' => '10.66.0.'.($octet++), + 'enabled' => true, + 'present' => true, + ]; + } + + public function blocked(): static + { + return $this->state(['enabled' => false, 'present' => false]); + } +} diff --git a/database/migrations/2026_07_25_200000_create_vpn_peers_table.php b/database/migrations/2026_07_25_200000_create_vpn_peers_table.php new file mode 100644 index 0000000..8037d5c --- /dev/null +++ b/database/migrations/2026_07_25_200000_create_vpn_peers_table.php @@ -0,0 +1,44 @@ +id(); + $table->uuid()->unique(); // R11: UUIDs in URLs + $table->string('name'); + $table->string('public_key')->unique(); + $table->string('allowed_ip')->unique(); // one tunnel address per peer + $table->foreignId('host_id')->nullable()->constrained()->nullOnDelete(); + $table->boolean('enabled')->default(true); + $table->boolean('present')->default(false); + $table->string('endpoint')->nullable(); // last source address seen + $table->timestamp('last_handshake_at')->nullable(); + $table->unsignedBigInteger('rx_bytes')->default(0); + $table->unsignedBigInteger('tx_bytes')->default(0); + $table->timestamp('observed_at')->nullable(); // when the hub last reported it + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('vpn_peers'); + } +}; diff --git a/database/migrations/2026_07_25_200001_add_vpn_manage_capability.php b/database/migrations/2026_07_25_200001_add_vpn_manage_capability.php new file mode 100644 index 0000000..661dd99 --- /dev/null +++ b/database/migrations/2026_07_25_200001_add_vpn_manage_capability.php @@ -0,0 +1,33 @@ +forgetCachedPermissions(); + + Permission::findOrCreate('vpn.manage', 'web'); + foreach (['Owner', 'Admin'] as $role) { + Role::findOrCreate($role, 'web')->givePermissionTo('vpn.manage'); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Permission::query()->where('name', 'vpn.manage')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index b709732..7f81f6f 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -13,6 +13,7 @@ return [ 'datacenters' => 'Rechenzentren', 'provisioning' => 'Provisioning', 'maintenance' => 'Wartungen', + 'vpn' => 'VPN', 'revenue' => 'Umsatz', 'settings' => 'Einstellungen', ], diff --git a/lang/de/common.php b/lang/de/common.php index 0966525..18de928 100644 --- a/lang/de/common.php +++ b/lang/de/common.php @@ -1,6 +1,7 @@ 'Schließen', 'tagline' => 'Kontrollzentrum für Ihre Cloud.', 'sign_in' => 'Anmelden', 'save' => 'Speichern', diff --git a/lang/de/vpn.php b/lang/de/vpn.php new file mode 100644 index 0000000..ce8e800 --- /dev/null +++ b/lang/de/vpn.php @@ -0,0 +1,60 @@ + 'VPN-Zugänge', + 'subtitle' => 'Wer ist mit dem Management-Netz verbunden — Zugänge anlegen, sperren oder entfernen.', + + 'peers' => 'Zugänge', + 'peer' => 'Zugang', + 'address' => 'Adresse', + 'traffic' => 'Datenverkehr', + 'handshake' => 'Letzter Kontakt', + 'status' => 'Status', + 'actions' => 'Aktionen', + 'empty' => 'Noch keine Zugänge. Der erste angelegte Zugang erscheint hier, sobald der Hub ihn übernommen hat.', + 'never' => 'nie', + 'last_sync' => 'Stand: :time', + 'never_synced' => 'Noch keine Daten vom Hub', + + 'host_peer' => 'Host-Zugang', + 'operator_peer' => 'Mitarbeiter-Zugang', + 'unknown_peer' => 'Unbekannter Zugang', + + 'status_online' => 'Verbunden', + 'status_idle' => 'Eingerichtet', + 'status_blocked' => 'Gesperrt', + 'status_pending' => 'Wird angewendet', + + 'add' => 'Zugang anlegen', + 'name' => 'Bezeichnung', + 'own_key' => 'Eigener Public Key', + 'own_key_hint' => 'Leer lassen, dann erzeugen wir ein Schlüsselpaar und zeigen die fertige Konfiguration einmalig an.', + 'own_key_placeholder' => 'optional', + 'invalid_key' => 'Das ist kein gültiger WireGuard-Schlüssel (32 Byte, Base64).', + 'duplicate_key' => 'Für diesen Schlüssel gibt es bereits einen Zugang.', + 'created' => 'Zugang angelegt.', + 'blocked' => 'Zugang gesperrt.', + 'unblocked' => 'Zugang wieder freigegeben.', + 'deleted' => 'Zugang entfernt.', + + 'config_ready' => 'Konfiguration für :name', + 'config_once' => 'Der private Schlüssel wird nicht gespeichert und ist nur jetzt sichtbar.', + 'copy' => 'Kopieren', + 'copied' => 'Kopiert', + + 'block' => 'Sperren', + 'unblock' => 'Freigeben', + 'delete' => 'Entfernen', + 'cancel' => 'Abbrechen', + 'delete_title' => ':name endgültig entfernen?', + 'delete_body' => 'Der Zugang wird vom Hub gelöscht. Eine Wiederherstellung ist nur mit einem neuen Schlüsselpaar möglich.', + 'delete_host_warning' => 'Achtung: Dieser Zugang gehört zum Host :host. CluPilot erreicht diesen Host danach nicht mehr.', + 'delete_confirm' => 'Zugang entfernen', + + 'hub' => 'Hub', + 'endpoint' => 'Endpunkt', + 'hub_key' => 'Public Key', + 'subnet' => 'Subnetz', + 'hub_incomplete_title' => 'Hub ist noch nicht vollständig konfiguriert', + 'hub_incomplete_body' => 'CLUPILOT_WG_ENDPOINT und CLUPILOT_WG_HUB_PUBKEY sind leer — erzeugte Konfigurationen wären unbrauchbar.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index e0d5cca..2187d96 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -13,6 +13,7 @@ return [ 'datacenters' => 'Datacenters', 'provisioning' => 'Provisioning', 'maintenance' => 'Maintenance', + 'vpn' => 'VPN', 'revenue' => 'Revenue', 'settings' => 'Settings', ], diff --git a/lang/en/common.php b/lang/en/common.php index 5807a70..aef88d2 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -1,6 +1,7 @@ 'Close', 'tagline' => 'Control center for your cloud.', 'sign_in' => 'Sign in', 'save' => 'Save', diff --git a/lang/en/vpn.php b/lang/en/vpn.php new file mode 100644 index 0000000..0e69260 --- /dev/null +++ b/lang/en/vpn.php @@ -0,0 +1,60 @@ + 'VPN accesses', + 'subtitle' => 'Who is connected to the management network — create, block or remove accesses.', + + 'peers' => 'Accesses', + 'peer' => 'Access', + 'address' => 'Address', + 'traffic' => 'Traffic', + 'handshake' => 'Last contact', + 'status' => 'Status', + 'actions' => 'Actions', + 'empty' => 'No accesses yet. The first one appears here once the hub has picked it up.', + 'never' => 'never', + 'last_sync' => 'As of :time', + 'never_synced' => 'No data from the hub yet', + + 'host_peer' => 'Host access', + 'operator_peer' => 'Staff access', + 'unknown_peer' => 'Unknown access', + + 'status_online' => 'Connected', + 'status_idle' => 'Configured', + 'status_blocked' => 'Blocked', + 'status_pending' => 'Applying', + + 'add' => 'Create access', + 'name' => 'Label', + 'own_key' => 'Own public key', + 'own_key_hint' => 'Leave empty and we generate a keypair, showing the ready-made config once.', + 'own_key_placeholder' => 'optional', + 'invalid_key' => 'That is not a valid WireGuard key (32 bytes, base64).', + 'duplicate_key' => 'An access already exists for this key.', + 'created' => 'Access created.', + 'blocked' => 'Access blocked.', + 'unblocked' => 'Access re-enabled.', + 'deleted' => 'Access removed.', + + 'config_ready' => 'Configuration for :name', + 'config_once' => 'The private key is not stored and is only visible now.', + 'copy' => 'Copy', + 'copied' => 'Copied', + + 'block' => 'Block', + 'unblock' => 'Unblock', + 'delete' => 'Remove', + 'cancel' => 'Cancel', + 'delete_title' => 'Remove :name for good?', + 'delete_body' => 'The access is deleted from the hub. Restoring it requires a new keypair.', + 'delete_host_warning' => 'Careful: this access belongs to host :host. CluPilot will no longer reach that host.', + 'delete_confirm' => 'Remove access', + + 'hub' => 'Hub', + 'endpoint' => 'Endpoint', + 'hub_key' => 'Public key', + 'subnet' => 'Subnet', + 'hub_incomplete_title' => 'Hub is not fully configured yet', + 'hub_incomplete_body' => 'CLUPILOT_WG_ENDPOINT and CLUPILOT_WG_HUB_PUBKEY are empty — generated configs would be unusable.', +]; diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index 5a942ce..0b5d6d2 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -18,6 +18,10 @@ 'download' => '', 'alert-triangle' => '', 'check' => '', + 'lock' => '', + 'unlock' => '', + 'copy' => '', + 'x' => '', 'plus' => '', 'server' => '', 'activity' => '', diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 381e0a5..80021ff 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -29,15 +29,17 @@