Claude be codin
All checks were successful
linter / quality (push) Successful in 3m8s
tests / ci (push) Successful in 13m56s

This commit is contained in:
Javier Feliz 2025-07-31 00:56:12 -04:00
parent 392d14e0e1
commit 038ee47fa3
10 changed files with 402 additions and 6 deletions

View File

@ -0,0 +1,42 @@
<?php
namespace App\Livewire;
use App\Models\AuthenticationToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Component;
class ManageAuthenticationTokens extends Component
{
#[Computed]
public function tokens()
{
/** @var \App\Models\User $user */
$user = Auth::user();
return $user->tokens()
->with('application')
->orderBy('issued_at', 'desc')
->get()
->groupBy('application.name')
->sortKeys();
}
public function revokeToken($tokenId)
{
$token = AuthenticationToken::where('id', $tokenId)
->where('user_id', Auth::id())
->first();
if ($token) {
$token->delete();
$this->dispatch('token-revoked');
}
}
public function render()
{
return view('livewire.manage-authentication-tokens');
}
}

View File

@ -2,10 +2,13 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Application extends Model
{
use HasFactory;
protected $guarded = ['id'];
public function getIconUrl()

View File

@ -9,6 +9,14 @@ class AuthenticationToken extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return [
'issued_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);

View File

@ -0,0 +1,114 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Application>
*/
class ApplicationFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
// Popular apps that likely have icons in selfh.st/icons
$apps = [
'Nextcloud',
'Plex',
'Jellyfin',
'Home Assistant',
'Grafana',
'Portainer',
'Sonarr',
'Radarr',
'Lidarr',
'Bazarr',
'Overseerr',
'Tautulli',
'Ombi',
'Jackett',
'qBittorrent',
'Transmission',
'SABnzbd',
'NZBGet',
'Synology',
'FreeNAS',
'UniFi',
'pfSense',
'OPNsense',
'Pi-hole',
'AdGuard',
'Bitwarden',
'Vaultwarden',
'PhotoPrism',
'Immich',
'Paperless',
'Bookstack',
'TiddlyWiki',
'Joplin',
'Standard Notes',
'Trilium',
'Monica',
'Firefly III',
'Invoice Ninja',
'Crater',
'Akaunting',
'Ghost',
'WordPress',
'Discourse',
'Flarum',
'NodeBB',
'Rocket.Chat',
'Mattermost',
'Element',
'Synapse',
'Jitsi',
'BigBlueButton',
'Jami',
'Mumble',
'TeamSpeak',
'Discord',
'Gitea',
'GitLab',
'Forgejo',
'Gogs',
'Sourcehut',
'Drone',
'Jenkins',
'Buildbot',
'TeamCity',
'Bamboo',
'SonarQube',
'Sentry',
'Uptime Kuma',
'Healthchecks',
'Cachet',
'Zabbix',
'Nagios',
'Icinga',
'LibreNMS',
'PRTG',
'Proxmox',
'ESXi',
'XCP-ng',
'Harvester',
'Rancher'
];
$appName = $this->faker->randomElement($apps);
$kebabName = str($appName)->kebab()->toString();
return [
'name' => $appName,
'client_id' => $this->faker->uuid(),
'client_secret' => $this->faker->regexify('[A-Za-z0-9]{40}'),
'redirect_uri' => $this->faker->url() . '/auth/callback',
'icon' => "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/{$kebabName}.webp",
];
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace Database\Seeders;
use App\Models\Application;
use App\Models\AuthenticationToken;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
class AuthenticationTokenSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$user = User::where('email', 'me@javierfeliz.com')->first();
if (!$user) {
$this->command->error('Default user not found. Please run the main seeder first.');
return;
}
$applications = Application::all();
if ($applications->isEmpty()) {
$this->command->error('No applications found. Please run the application seeder first.');
return;
}
// Sample user agents from different browsers and devices
$userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0',
'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 14; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36',
];
// Sample IP addresses (using private/local ranges for testing)
$ipAddresses = [
'192.168.1.101',
'192.168.1.102',
'10.0.0.15',
'10.0.0.23',
'172.16.0.10',
'172.16.0.25',
'127.0.0.1',
'::1',
];
// Create 10-15 tokens for different applications at different times
$tokenCount = rand(10, 15);
$selectedApps = $applications->random(min($tokenCount, $applications->count()));
foreach ($selectedApps as $index => $app) {
// Create 1-3 tokens per app to simulate multiple sessions
$tokensPerApp = rand(1, 3);
for ($i = 0; $i < $tokensPerApp; $i++) {
$issuedAt = now()->subDays(rand(0, 30))->subHours(rand(0, 23))->subMinutes(rand(0, 59));
$expiresAt = $issuedAt->copy()->addDays(rand(7, 90)); // Tokens expire 7-90 days after issue
AuthenticationToken::create([
'user_id' => $user->id,
'application_id' => $app->id,
'token' => Str::random(64), // Simulate an OAuth token
'user_agent' => fake()->randomElement($userAgents),
'ip' => fake()->randomElement($ipAddresses),
'issued_at' => $issuedAt,
'expires_at' => $expiresAt,
'created_at' => $issuedAt,
'updated_at' => $issuedAt,
]);
}
}
$this->command->info("Created authentication tokens for {$user->email}");
}
}

View File

@ -2,9 +2,11 @@
namespace Database\Seeders;
use App\Models\Application;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder
{
@ -13,11 +15,16 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
// $user = User::create([
// 'name' => 'Javier Feliz',
// 'email' => 'me@javierfeliz.com',
// 'password' => Hash::make('password')
// ]);
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
// // Create 8 sample applications
// Application::factory(8)->create();
// Create authentication tokens for testing
$this->call(AuthenticationTokenSeeder::class);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class UserSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void {}
}

View File

@ -1,3 +1,6 @@
<div class="flex w-full flex-col gap-2 bg-white dark:bg-zinc-800 p-4">
@props(['class' => ''])
<div {{ $attributes->merge(['class' => 'bg-white dark:bg-zinc-800 shadow-sm border border-gray-200 dark:border-zinc-700
rounded-lg ' . $class]) }}>
{{ $slot }}
</div>

View File

@ -3,6 +3,9 @@
<div class="grid grid-cols-2">
<livewire:forms.user-profile />
</div>
<div class="mt-4">
<livewire:manage-authentication-tokens />
</div>
<div class="mt-4">
<livewire:app-container />
</div>

View File

@ -0,0 +1,114 @@
<div>
<x-card class="space-y-6 p-6">
<flux:heading size="lg">Authentication Tokens</flux:heading>
<flux:subheading>
Manage your active authentication tokens for different applications.
</flux:subheading>
@if($this->tokens->isEmpty())
<div class="text-center py-12">
<flux:icon.shield-check variant="outline" class="mx-auto h-12 w-12 text-zinc-400" />
<flux:heading size="md" class="mt-4">No tokens</flux:heading>
<flux:subheading class="mt-2">
You haven't authorized any applications yet.
</flux:subheading>
</div>
@else
<div class="space-y-6">
@foreach($this->tokens as $appName => $appTokens)
<div class="space-y-3" x-data="{ expanded: false }"
wire:key="app-{{ $appTokens->first()->application_id }}">
<div class="flex items-center space-x-3 cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800/50 p-2 rounded-lg transition-colors"
x-on:click="expanded = !expanded">
<div class="flex-shrink-0">
@if($appTokens->first()->application->icon)
<img class="h-8 w-8 rounded-lg object-cover"
src="{{ $appTokens->first()->application->getIconUrl() }}" alt="{{ $appName }}"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="h-8 w-8 rounded-lg bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center"
style="display: none;">
<flux:icon.computer-desktop class="h-5 w-5 text-zinc-500" />
</div>
@else
<div class="h-8 w-8 rounded-lg bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center">
<flux:icon.computer-desktop class="h-5 w-5 text-zinc-500" />
</div>
@endif
</div>
<div class="flex-1">
<flux:heading size="sm">{{ $appName }}</flux:heading>
<flux:subheading size="xs">{{ $appTokens->count() }} {{ $appTokens->count() === 1 ? 'token' :
'tokens' }}</flux:subheading>
</div>
<div class="flex-shrink-0">
<flux:icon.chevron-down class="h-5 w-5 text-zinc-400 transition-transform duration-200"
x-bind:class="expanded ? 'rotate-180' : ''" />
</div>
</div>
<div class="ml-11 space-y-2" x-show="expanded" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform -translate-y-2">
@foreach($appTokens as $token)
<x-card class="p-4" wire:key="token-{{ $token->id }}">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="space-y-1">
<flux:subheading size="xs">
<flux:icon.calendar class="inline h-3 w-3 mr-1" />
Issued: {{ $token->issued_at->format('M j, Y \a\t g:i A') }}
</flux:subheading>
@if($token->expires_at)
<flux:subheading size="xs">
<flux:icon.clock class="inline h-3 w-3 mr-1" />
Expires: {{ $token->expires_at->format('M j, Y \a\t g:i A') }}
</flux:subheading>
@endif
@if($token->ip)
<flux:subheading size="xs">
<flux:icon.globe-alt class="inline h-3 w-3 mr-1" />
IP: {{ $token->ip }}
</flux:subheading>
@endif
@if($token->user_agent)
<flux:subheading size="xs" class="truncate">
<flux:icon.device-phone-mobile class="inline h-3 w-3 mr-1" />
<span title="{{ $token->user_agent }}">
{{ strlen($token->user_agent) > 60 ? substr($token->user_agent, 0, 60) .
'...' :
$token->user_agent }}
</span>
</flux:subheading>
@endif
</div>
</div>
<div class="flex-shrink-0 ml-4">
<flux:button wire:click="revokeToken({{ $token->id }})" icon="trash"
wire:confirm="Are you sure you want to revoke this token? The application will need to be re-authorized."
variant="danger" size="sm">
Revoke
</flux:button>
</div>
</div>
</x-card>
@endforeach
</div>
</div>
@endforeach
</div>
@endif
</x-card>
@script
<script>
$wire.on('token-revoked', () => {
// You can add a toast notification here if you have one
console.log('Token revoked successfully');
});
</script>
@endscript
</div>