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.
User: no logout button + clicking workspace switcher in sidebar did nothing.
Sidebar component:
- Workspace switcher now wraps in Alpine x-data={ wsOpen: false }.
Click toggles dropdown, @click.outside + escape-key close it.
- Dropdown lists all workspaces the user can access (owned +
TeamMember memberships), shows active one with accent checkmark.
Fallback: if list is empty, shows just the current workspace so
users never see an empty switcher.
- '+ Neuer Workspace' row at bottom of workspaces list.
- User section: shows auth user's name (header) + email.
- 'Profil' link → route('profile').
- 'Einstellungen' link → route('settings.index').
- 'Abmelden' button: POST form with CSRF to route('logout'),
red-colored row.
- Workspace switch via ?workspace=<slug> query param — middleware
ResolveCurrentWorkspace picks it up, persists in session +
user.current_workspace_id, then page reloads with new scope.
- Sidebar nav title now reads workspace name dynamically.
- Plan-box footer reads real plan + sites/quota + has 'Upgrade →'
link to settings.
CSS additions: clu-ws-dropdown (white 98% bg + glass shadow, z-index
60 to sit above sticky topbar 20 + sidebar 30), clu-ws-section
(uppercase muted header), clu-ws-item (hover bg + active accent
soft), clu-ws-divider (1px hairline). [x-cloak] global rule prevents
FOUC.
Browser-verified: clicking AC avatar + 'Acme Agency PRO' panel
expands dropdown with 'Acme Agency PRO · 87 sites' (current,
checkmark), Neuer Workspace, ADMIN section with email + Profil +
Einstellungen + red Abmelden. 0 console errors. 36/36 Page tests
green.
User accepted 5-step plan: Workspace → Sub-Sidebar → Switcher → Plan/
Quota → Billing.
This step adds the workspace layer underneath everything:
- Workspace model with HasUuids + relations (sites/servers/clusters/
backups/members/invitations/activities) + plan-based quotas + feature
flags + isOverQuota() + hasFeature() helpers.
- Plan presets: free/starter/pro/agency/enterprise with sites,
servers, team, storage_gb, features map (audit/monitoring/
email_relay/multi_workspace/white_label/reports/sso).
- BelongsToWorkspace trait: global scope filters by app('current
Workspace'), creating-hook auto-fills workspace_id from container.
Falls back to no-scope when no workspace bound (tests, console).
- Migrations: workspaces table + workspace_id foreignUuid added to
all 10 domain tables (clusters/servers/sites/backups/team_members/
invitations/security_events/certificates/activities/crons).
- users gets current_workspace_id nullable FK.
- team_members unique changed to composite (workspace_id, user_id) so
one user can belong to multiple workspaces.
- ResolveCurrentWorkspace middleware: resolves active workspace from
?workspace=slug → session → user.current_workspace_id → first owned.
Wired into web middleware group via bootstrap/app.php.
- WorkspaceFactory + workspace_id added to all 10 domain factories
so tests/seed work without explicit workspace context.
- DatabaseSeeder rewritten: creates Marie Weber as owner of 'Acme
Agency' (Pro plan) workspace + 'Marie · Personal' (Free) workspace
for switcher demo. Binds app('currentWorkspace') so seeded sites/
servers/backups/etc. land in the right workspace.
- Sidebar component reads auth user's current workspace for the ws-
switch panel — no more hard-coded 'Acme Cluster' string.
Full Pest regression: 47/47 green. 2 workspaces seeded:
- Acme Agency (pro): 87 sites, 6 servers, 128 sec events, 102 backups
- Marie · Personal (free): 0 sites
Sidebar layout (per user request: header+footer fix, only nav scrollable):
- Restructured clu-sidebar into 3 flex-children: clu-sidebar-head (brand +
ws-switch fixed at top), clu-sidebar-scroll (nav, overflow-y:auto in
middle), clu-sidebar-foot (plan-box fixed at bottom).
Mobile sidebar (per user request: drawer + hamburger):
- <920px: sidebar position fixed, translateX(-110%) hidden, .open class
slides in. clu-sidebar-backdrop overlay dimms main + click-to-close.
- New x-clu.burger component with hamburger icon, visible only <920px
via display:grid in media query. Injected into all 9 topbars.
- Alpine state sidebarOpen on <body>, nav-item click closes drawer
after navigation.
Linking (per user request: sites detail unreachable):
- Sites index table + dashboard top-sites table: domain cell now <a>
to route('sites.show', $site) with wire:navigate.
Charts (per user request: chart.js):
- composer/npm: chart.js ^4.5.
- resources/js/app.js: register all chart.js plugins, expose window.Chart
for inline page scripts.
- Dashboard: added Traffic-24h line chart (Chart.js line+gradient fill)
between metrics and 2-col row. Init re-runs on livewire:navigated for
wire:navigate compatibility.
Modal (per user request: wire-elements):
- @livewire('wire-elements-modal') already in layouts/app.blade.php
master layout per wire-elements/modal v3 docs (single mount). Open
pattern: wire:click="$dispatch('openModal', { component: 'name' })".
Full Pest regression: 47/47 green.