From 030185f02e569414c8552452f8df8795c6e904fe Mon Sep 17 00:00:00 2001 From: Boban Blaskovic Date: Mon, 1 Jun 2026 01:34:38 +0200 Subject: [PATCH] URL-prefix workspace routing: /w/{public_id}/... persistent in path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User: 'workspace stays in URL only until tab change' + 'use random 25-char string or UUID for workspace identifier'. Migration: - workspaces.public_id: varchar(25) unique nullable. - Backfill existing rows with Str::random(25). Workspace model: - Auto-fill public_id on create via booted() with collision-check loop. - Override getRouteKeyName() → 'public_id' so route-model-binding uses the opaque public_id everywhere (not the guessable slug). Routes: - Authed routes wrapped in prefix 'w/{wsId}' → URL shape /w/bPSyuQpMDI0bl0LBKlwVOLdsl/dashboard etc. - Bare /dashboard /sites /servers etc. now redirect to current workspace's prefixed URL (covers external bookmarks / first visit). ResolveCurrentWorkspace middleware: - Resolution priority: route param {wsId} → ?workspace query → session sticky → user.current_workspace_id → first owned. - On resolve: URL::defaults(['wsId' => public_id]) auto-injects param into every route() call, so all Volt wire:navigate / @route() helpers carry the workspace through the entire SPA session. - forceFill + saveQuietly to avoid triggering observers on user. Sidebar: - Workspace-switch links now point to /w/{public_id}/dashboard (not the slug ?workspace=…). TestCase: - URL::defaults({wsId: 'test-workspace'}) so route('dashboard') etc. generates valid URLs in tests. - actingAsWithWorkspace() helper for tests that need a real workspace bound to a logged-in user. Browser-verified: click Sidebar → Sites navigates to /w/bPSyuQpMDI0bl0LBKlwVOLdsl/sites, public_id sticks across every internal navigation. 36/36 Page-Pest still green. --- .../Middleware/ResolveCurrentWorkspace.php | 47 +++- app/app/Models/Workspace.php | 21 +- ..._31_191000_add_public_id_to_workspaces.php | 30 +++ .../views/components/clu/sidebar.blade.php | 2 +- app/routes/web.php | 39 ++- app/tests/TestCase.php | 19 ++ templates/akutell/Profil.html | 245 ++++++++++++++++++ 7 files changed, 377 insertions(+), 26 deletions(-) create mode 100644 app/database/migrations/2026_05_31_191000_add_public_id_to_workspaces.php create mode 100644 templates/akutell/Profil.html diff --git a/app/app/Http/Middleware/ResolveCurrentWorkspace.php b/app/app/Http/Middleware/ResolveCurrentWorkspace.php index 0ec612c..c597333 100644 --- a/app/app/Http/Middleware/ResolveCurrentWorkspace.php +++ b/app/app/Http/Middleware/ResolveCurrentWorkspace.php @@ -2,19 +2,27 @@ namespace App\Http\Middleware; +use App\Models\Workspace; use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\URL; use Symfony\Component\HttpFoundation\Response; /** - * Resolves the active workspace for the request and binds it to the - * container so the BelongsToWorkspace trait can scope queries. + * Resolves the active workspace and binds it for the rest of the request. * * Resolution order: - * 1. ?workspace= query param (for switching) - * 2. Session 'workspace_id' (sticky after switch) - * 3. authenticated user's current_workspace_id - * 4. authenticated user's first owned workspace + * 1. Route param {wsId} (URL-driven, persistent — primary) + * 2. ?workspace= query (one-off switch) + * 3. Session 'workspace_id' (sticky between requests) + * 4. authenticated user's current_workspace_id + * 5. authenticated user's first owned workspace + * + * Once resolved: + * - container('currentWorkspace') is bound (used by BelongsToWorkspace). + * - URL::defaults(['wsId' => …]) so every route() call auto-prefixes. + * - session('workspace_id') updated for stickiness across requests. + * - user.current_workspace_id updated when an explicit switch happens. */ class ResolveCurrentWorkspace { @@ -27,32 +35,47 @@ class ResolveCurrentWorkspace $ws = null; - if ($slug = $request->query('workspace')) { - $ws = \App\Models\Workspace::where('slug', $slug)->first(); + // 1. Route param (e.g. /w/aBcDeF…/dashboard). + if ($wsId = $request->route('wsId')) { + $ws = Workspace::where('public_id', $wsId)->first(); if ($ws) { + $user->forceFill(['current_workspace_id' => $ws->id])->saveQuietly(); $request->session()->put('workspace_id', $ws->id); - $user->update(['current_workspace_id' => $ws->id]); } } - if (! $ws && $sid = $request->session()->get('workspace_id')) { - $ws = \App\Models\Workspace::find($sid); + // 2. Query-string explicit switch (?workspace=pubId). + if (! $ws && $pid = $request->query('workspace')) { + $ws = Workspace::where('public_id', $pid)->orWhere('slug', $pid)->first(); + if ($ws) { + $request->session()->put('workspace_id', $ws->id); + $user->forceFill(['current_workspace_id' => $ws->id])->saveQuietly(); + } } + // 3. Session sticky. + if (! $ws && $sid = $request->session()->get('workspace_id')) { + $ws = Workspace::find($sid); + } + + // 4. User's saved current. if (! $ws && $user->current_workspace_id) { $ws = $user->currentWorkspace; } + // 5. First owned. if (! $ws) { $ws = $user->ownedWorkspaces()->first(); if ($ws) { - $user->update(['current_workspace_id' => $ws->id]); + $user->forceFill(['current_workspace_id' => $ws->id])->saveQuietly(); } } if ($ws) { app()->instance('currentWorkspace', $ws); $request->session()->put('workspace_id', $ws->id); + // Auto-inject {wsId} into every route() call so links stay scoped. + URL::defaults(['wsId' => $ws->public_id]); } return $next($request); diff --git a/app/app/Models/Workspace.php b/app/app/Models/Workspace.php index e18c9f3..0a33588 100644 --- a/app/app/Models/Workspace.php +++ b/app/app/Models/Workspace.php @@ -7,17 +7,36 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Str; class Workspace extends Model { use HasFactory, HasUuids; protected $fillable = [ - 'name', 'slug', 'owner_user_id', 'plan', + 'name', 'slug', 'public_id', 'owner_user_id', 'plan', 'trial_ends_at', 'logo_url', 'billing_email', 'region', 'quotas_override', ]; + /** Auto-fill public_id on create. */ + protected static function booted(): void + { + static::creating(function (Workspace $w) { + if (! $w->public_id) { + do { + $w->public_id = Str::random(25); + } while (static::where('public_id', $w->public_id)->exists()); + } + }); + } + + /** Override route-model-binding to use public_id (opaque, not the slug). */ + public function getRouteKeyName(): string + { + return 'public_id'; + } + protected $casts = [ 'trial_ends_at' => 'datetime', 'quotas_override' => 'array', diff --git a/app/database/migrations/2026_05_31_191000_add_public_id_to_workspaces.php b/app/database/migrations/2026_05_31_191000_add_public_id_to_workspaces.php new file mode 100644 index 0000000..56fb06f --- /dev/null +++ b/app/database/migrations/2026_05_31_191000_add_public_id_to_workspaces.php @@ -0,0 +1,30 @@ +string('public_id', 25)->nullable()->unique()->after('id'); + }); + + // Backfill existing rows. + \DB::table('workspaces')->whereNull('public_id')->orderBy('id')->each(function ($row) { + \DB::table('workspaces')->where('id', $row->id)->update([ + 'public_id' => Str::random(25), + ]); + }); + } + + public function down(): void + { + Schema::table('workspaces', function (Blueprint $table) { + $table->dropUnique(['public_id']); + $table->dropColumn('public_id'); + }); + } +}; diff --git a/app/resources/views/components/clu/sidebar.blade.php b/app/resources/views/components/clu/sidebar.blade.php index 51b52df..e77a462 100644 --- a/app/resources/views/components/clu/sidebar.blade.php +++ b/app/resources/views/components/clu/sidebar.blade.php @@ -77,7 +77,7 @@
WORKSPACES
@foreach ($workspaces as $w) -
{{ mb_strtoupper(mb_substr(preg_replace('/[^A-Za-z\s]/', '', $w->name), 0, 2)) }} diff --git a/app/routes/web.php b/app/routes/web.php index 9a16430..c14c1b8 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -5,24 +5,39 @@ use Livewire\Volt\Volt; Route::view('/', 'welcome'); -Route::middleware(['auth', 'verified'])->group(function () { - Volt::route('dashboard', 'pages.dashboard')->name('dashboard'); -}); +/* ───────────────────────────────────────────────────────── + * Workspace-scoped routes (everything authenticated) + * URL shape: /w/{wsId}/dashboard, /w/{wsId}/sites, … + * ───────────────────────────────────────────────────────── */ +Route::middleware(['auth', 'verified']) + ->prefix('w/{wsId}') + ->group(function () { + Volt::route('dashboard', 'pages.dashboard')->name('dashboard'); + Volt::route('sites', 'pages.sites.index')->name('sites.index'); + Volt::route('sites/{site}', 'pages.sites.show')->name('sites.show'); + Volt::route('servers', 'pages.servers.index')->name('servers.index'); + Volt::route('clusters/new', 'pages.clusters.create')->name('clusters.create'); + Volt::route('backups', 'pages.backups.index')->name('backups.index'); + Volt::route('security', 'pages.security.index')->name('security.index'); + Volt::route('team', 'pages.team.index')->name('team.index'); + Volt::route('settings', 'pages.settings.index')->name('settings.index'); + }); +/* Profile (no workspace prefix — user-scoped). */ Route::view('profile', 'profile') ->middleware(['auth']) ->name('profile'); -/* ───── Placeholder named routes for sidebar links (real Volt pages land later). ───── */ +/* Redirect bare /dashboard etc. to current workspace's URL. */ Route::middleware(['auth'])->group(function () { - Volt::route('sites', 'pages.sites.index')->name('sites.index'); - Volt::route('sites/{site}', 'pages.sites.show')->name('sites.show'); - Volt::route('servers', 'pages.servers.index')->name('servers.index'); - Volt::route('clusters/new', 'pages.clusters.create')->name('clusters.create'); - Volt::route('backups', 'pages.backups.index')->name('backups.index'); - Volt::route('security', 'pages.security.index')->name('security.index'); - Volt::route('team', 'pages.team.index')->name('team.index'); - Volt::route('settings', 'pages.settings.index')->name('settings.index'); + foreach (['dashboard', 'sites', 'servers', 'backups', 'security', 'team', 'settings'] as $shortcut) { + Route::get($shortcut, function () use ($shortcut) { + $ws = auth()->user()?->currentWorkspace; + return $ws + ? redirect("/w/{$ws->public_id}/{$shortcut}") + : redirect('/'); + }); + } }); require __DIR__ . '/auth.php'; diff --git a/app/tests/TestCase.php b/app/tests/TestCase.php index b9096da..cb06d8c 100644 --- a/app/tests/TestCase.php +++ b/app/tests/TestCase.php @@ -30,5 +30,24 @@ abstract class TestCase extends BaseTestCase 'foreign_key_constraints' => true, ], ]); + + // Provide a dummy {wsId} so route('dashboard') etc. generate URLs. + // Real test workspaces get created per-test via actingAsWithWorkspace(). + \Illuminate\Support\Facades\URL::defaults(['wsId' => 'test-workspace']); + } + + /** Helper: log in a user with a fresh workspace bound. */ + protected function actingAsWithWorkspace(\App\Models\User $user = null): \App\Models\User + { + $user ??= \App\Models\User::factory()->create(['email_verified_at' => now()]); + $workspace = \App\Models\Workspace::factory()->create([ + 'owner_user_id' => $user->id, + 'public_id' => 'test-workspace', + ]); + $user->update(['current_workspace_id' => $workspace->id]); + $this->actingAs($user); + app()->instance('currentWorkspace', $workspace); + + return $user; } } diff --git a/templates/akutell/Profil.html b/templates/akutell/Profil.html new file mode 100644 index 0000000..847b019 --- /dev/null +++ b/templates/akutell/Profil.html @@ -0,0 +1,245 @@ + + + + + +CluPilot — Mein Profil + + + + + + + +
+ + +
+
+
+
Mein Profil
+
persönliches konto · j.koch
+
+
+ + +
+ +
+
+ +
+
+
+ JK + +
+

Julia Koch

+ Owner + +
+
Mitglied seitMärz 2024
+
2-Faktoraktiv
+
Letzter Logingerade eben
+
+
+ + +
+ + +
+ +
+

Persönliche Informationen

+
+
+
+
+
+
@
+
+
+
+
+
+
+
+
+
+ + +
+

Passwort ändern

zuletzt 14.03.2026
+
+
+
+
+
+
Stärke: —
+
+
+
+
+
+
+ + +
+

Zwei-Faktor-Authentifizierung

aktiv
+
+
Authenticator-App (TOTP)
Google Authenticator · eingerichtet 03/2026
+
SMS-Code als Fallback
an +49 151 ••• 6789
+
Passkey / WebAuthn
biometrisch anmelden (Touch ID, Yubikey)
+
+
Wiederherstellungs-Codes
8 unbenutzte Codes · sicher aufbewahren
+ +
+
+
+ + +
+

Aktive Sitzungen

+
+
+ + +
+

Gefahrenzone

+
+
Konto löschen
Dein Benutzerkonto und alle persönlichen Daten werden dauerhaft entfernt. Workspaces, deren alleiniger Owner du bist, müssen vorher übertragen werden.
+ +
+
+
+
+ +
CluPilot · konto-aktivität wird im audit-log protokolliert
+
+
+
+ + + +