Fix tenant event form package selector so it no longer renders empty-value options, handles loading/empty

states, and pulls data from the authenticated /api/v1/tenant/packages endpoint.
    (resources/js/admin/pages/EventFormPage.tsx, resources/js/admin/api.ts)
  - Harden tenant-admin auth flow: prevent PKCE state loss, scope out StrictMode double-processing, add SPA
    routes for /event-admin/login and /event-admin/logout, and tighten token/session clearing semantics (resources/js/admin/auth/{context,tokens}.tsx, resources/js/admin/pages/{AuthCallbackPage,LogoutPage}.tsx,
    resources/js/admin/router.tsx, routes/web.php)
This commit is contained in:
Codex Agent
2025-10-19 23:00:47 +02:00
parent a949c8d3af
commit 6290a3a448
95 changed files with 3708 additions and 394 deletions

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class OAuthListKeysCommand extends Command
{
protected $signature = 'oauth:list-keys {--json : Output as JSON for scripting}';
protected $description = 'List available JWT signing key directories and their status.';
public function handle(): int
{
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
if (! File::exists($storage)) {
$this->error("Key store path does not exist: {$storage}");
return self::FAILURE;
}
$directories = collect(File::directories($storage))
->filter(fn ($path) => Str::lower(basename($path)) !== 'archive')
->values()
->map(function (string $path) use ($currentKid) {
$kid = basename($path);
$publicKey = $path.DIRECTORY_SEPARATOR.'public.key';
$privateKey = $path.DIRECTORY_SEPARATOR.'private.key';
return [
'kid' => $kid,
'status' => $kid === $currentKid ? 'current' : 'legacy',
'public' => File::exists($publicKey),
'private' => File::exists($privateKey),
'updated_at' => File::exists($path) ? date('c', File::lastModified($path)) : null,
'path' => $path,
];
})
->sortBy(fn ($entry) => ($entry['status'] === 'current' ? '0-' : '1-').$entry['kid'])
->values();
if ($this->option('json')) {
$this->line($directories->toJson(JSON_PRETTY_PRINT));
return self::SUCCESS;
}
if ($directories->isEmpty()) {
$this->warn('No signing key directories found.');
return self::SUCCESS;
}
$this->table(
['KID', 'Status', 'Public.key', 'Private.key', 'Updated At', 'Path'],
$directories->map(fn ($entry) => [
$entry['kid'],
$entry['status'],
$entry['public'] ? 'yes' : 'no',
$entry['private'] ? 'yes' : 'no',
$entry['updated_at'] ?? 'n/a',
$entry['path'],
])
);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class OAuthPruneKeysCommand extends Command
{
protected $signature = 'oauth:prune-keys
{--days=90 : Prune keys whose directories were last modified before this many days ago}
{--dry-run : Show which keys would be removed without deleting}
{--force : Skip confirmation prompt}';
protected $description = 'Remove legacy JWT signing keys older than the configured threshold.';
public function handle(): int
{
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
if (! File::exists($storage)) {
$this->error("Key store path does not exist: {$storage}");
return self::FAILURE;
}
$days = (int) $this->option('days');
$cutoff = now()->subDays($days);
$candidates = collect(File::directories($storage))
->reject(fn ($path) => Str::lower(basename($path)) === 'archive')
->filter(function (string $path) use ($currentKid, $cutoff) {
$kid = basename($path);
if ($kid === $currentKid) {
return false;
}
$lastModified = File::lastModified($path);
return $lastModified !== false && $cutoff->greaterThan(\Carbon\Carbon::createFromTimestamp($lastModified));
})
->values();
if ($candidates->isEmpty()) {
$this->info("No legacy key directories older than {$days} days were found.");
return self::SUCCESS;
}
$this->table(
['KID', 'Last Modified', 'Path'],
$candidates->map(fn ($path) => [
basename($path),
date('c', File::lastModified($path)),
$path,
])
);
if ($this->option('dry-run')) {
$this->info('Dry run complete. No keys were removed.');
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm('Remove the listed legacy key directories?', false)) {
$this->warn('Prune cancelled.');
return self::SUCCESS;
}
foreach ($candidates as $path) {
File::deleteDirectory($path);
}
$this->info('Legacy key directories pruned.');
return self::SUCCESS;
}
}

View File

@@ -42,9 +42,12 @@ class OAuthRotateKeysCommand extends Command
if ($archiveDir) {
$this->line("Previous keys archived at: {$archiveDir}");
$this->line('Existing key remains available for token verification until you prune it.');
}
$this->warn("Update OAUTH_JWT_KID in your environment configuration to: {$newKid}");
$this->info('Run `php artisan oauth:list-keys` to verify active signing directories.');
$this->info('Once legacy tokens expire, run `php artisan oauth:prune-keys` to remove retired keys.');
return self::SUCCESS;
}
@@ -58,7 +61,7 @@ class OAuthRotateKeysCommand extends Command
if (File::exists($existingDir)) {
$archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.$kid.'-'.now()->format('YmdHis');
File::ensureDirectoryExists(dirname($archiveDir));
File::moveDirectory($existingDir, $archiveDir);
File::copyDirectory($existingDir, $archiveDir);
return $archiveDir;
}
@@ -67,11 +70,11 @@ class OAuthRotateKeysCommand extends Command
File::ensureDirectoryExists($archiveDir);
if (File::exists($legacyPublic)) {
File::move($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key');
File::copy($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key');
}
if (File::exists($legacyPrivate)) {
File::move($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key');
File::copy($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key');
}
return $archiveDir;
@@ -108,4 +111,3 @@ class OAuthRotateKeysCommand extends Command
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
}
}

View File

@@ -63,19 +63,24 @@ class SendAbandonedCheckoutReminders extends Command
$resumeUrl = $this->generateResumeUrl($checkout);
if (!$isDryRun) {
Mail::to($checkout->user)->queue(
new AbandonedCheckout(
$checkout->user,
$checkout->package,
$stage,
$resumeUrl
)
);
$mailLocale = $checkout->user->preferred_locale ?? config('app.locale');
Mail::to($checkout->user)
->locale($mailLocale)
->queue(
new AbandonedCheckout(
$checkout->user,
$checkout->package,
$stage,
$resumeUrl
)
);
$checkout->updateReminderStage($stage);
$totalSent++;
} else {
$this->line(" 📧 Would send {$stage} reminder to: {$checkout->email} for package: {$checkout->package->name}");
$packageName = $checkout->package->getNameForLocale($checkout->user->preferred_locale ?? config('app.locale'));
$this->line(" 📧 Would send {$stage} reminder to: {$checkout->email} for package: {$packageName}");
}
$totalProcessed++;