URL-prefix workspace routing: /w/{public_id}/... persistent in path

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.
master
Boban Blaskovic 2026-06-01 01:34:38 +02:00
parent 4aa7d544b7
commit 030185f02e
7 changed files with 377 additions and 26 deletions

View File

@ -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=<slug> 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=<public_id> 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);
}
}
// 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->update(['current_workspace_id' => $ws->id]);
$user->forceFill(['current_workspace_id' => $ws->id])->saveQuietly();
}
}
// 3. Session sticky.
if (! $ws && $sid = $request->session()->get('workspace_id')) {
$ws = \App\Models\Workspace::find($sid);
$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);

View File

@ -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',

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration {
public function up(): void
{
Schema::table('workspaces', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -77,7 +77,7 @@
<div class="clu-ws-section">WORKSPACES</div>
@foreach ($workspaces as $w)
<a href="{{ url('/dashboard?workspace=' . $w->slug) }}"
<a href="{{ url('/w/' . $w->public_id . '/dashboard') }}"
class="clu-ws-item @if($ws && $ws->id === $w->id) active @endif">
<div class="clu-ws-avatar" style="width:20px;height:20px;font-size:9px;border-radius:5px;">
{{ mb_strtoupper(mb_substr(preg_replace('/[^A-Za-z\s]/', '', $w->name), 0, 2)) }}

View File

@ -5,16 +5,14 @@ use Livewire\Volt\Volt;
Route::view('/', 'welcome');
Route::middleware(['auth', 'verified'])->group(function () {
/* ─────────────────────────────────────────────────────────
* 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');
});
Route::view('profile', 'profile')
->middleware(['auth'])
->name('profile');
/* ───── Placeholder named routes for sidebar links (real Volt pages land later). ───── */
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');
@ -23,6 +21,23 @@ Route::middleware(['auth'])->group(function () {
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');
/* Redirect bare /dashboard etc. to current workspace's URL. */
Route::middleware(['auth'])->group(function () {
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';

View File

@ -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;
}
}

View File

@ -0,0 +1,245 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>CluPilot — Mein Profil</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="clupilot.css">
<style>
.profile{display:grid;grid-template-columns:300px 1fr;gap:18px;align-items:start}
.profile-side{position:sticky;top:84px;display:flex;flex-direction:column;gap:18px}
.profile-col{display:flex;flex-direction:column;gap:18px;min-width:0}
.pcard{text-align:center;padding:24px 20px 20px}
.avatar-xl{width:78px;height:78px;border-radius:24px;display:grid;place-items:center;color:#fff;font-weight:700;font-size:28px;font-family:var(--mono);margin:0 auto;position:relative;box-shadow:0 8px 22px -6px rgba(40,52,92,0.5)}
.avatar-edit{position:absolute;right:-4px;bottom:-4px;width:28px;height:28px;border-radius:50%;background:#fff;border:1px solid var(--hairline);display:grid;place-items:center;color:var(--muted);box-shadow:0 3px 8px -2px rgba(40,52,92,0.3);cursor:pointer}
.avatar-edit:hover{color:var(--accent)}
.pcard h2{margin:14px 0 3px;font-size:16px;font-weight:600;letter-spacing:-0.01em}
.pcard .role{display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);font-size:10.5px;font-weight:600;color:var(--accent);background:var(--accent-soft);padding:3px 10px;border-radius:7px;border:1px solid rgba(59,111,242,0.25)}
.pcard .mail{font-family:var(--mono);font-size:11.5px;color:var(--muted);margin-top:9px}
.side-nav{display:flex;flex-direction:column;gap:2px;padding:10px}
.side-nav a{display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:var(--radius-xs);color:#3a3a47;font-size:12.5px;font-weight:500;cursor:pointer;transition:background .15s,color .15s}
.side-nav a:hover{background:rgba(255,255,255,0.55);color:var(--fg)}
.side-nav a.active{color:var(--fg);background:linear-gradient(180deg,rgba(255,255,255,0.92),rgba(255,255,255,0.66));box-shadow:0 4px 14px -6px rgba(40,52,92,0.3), inset 0 1px 0 rgba(255,255,255,0.9)}
.side-nav a .ico{width:16px;height:16px;color:var(--muted)}
.side-nav a.active .ico{color:var(--accent)}
.set-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 24px}
.form-actions{display:flex;justify-content:flex-end;gap:10px;padding:14px 18px;border-top:1px solid var(--hairline)}
.pw-meter{height:6px;border-radius:4px;background:rgba(30,35,60,0.08);overflow:hidden;margin-top:4px}
.pw-meter span{display:block;height:100%;width:0;border-radius:4px;transition:width .25s, background .25s}
.pw-hint{font-size:11px;color:var(--muted);margin-top:5px;font-family:var(--mono)}
.sess-row{display:flex;align-items:center;gap:13px;padding:14px 0;border-bottom:1px solid var(--hairline)}
.sess-row:last-child{border-bottom:0}
.sess-ico{width:36px;height:36px;border-radius:11px;display:grid;place-items:center;flex:none;color:var(--muted);background:rgba(255,255,255,0.6);border:1px solid var(--hairline)}
.sess-body{flex:1;min-width:0}
.sess-title{font-weight:600;font-size:12.5px;display:flex;align-items:center;gap:8px}
.sess-meta{font-size:11px;color:var(--muted);font-family:var(--mono);margin-top:2px}
@media (max-width:920px){.profile{grid-template-columns:1fr}.profile-side{position:static}.set-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar" data-screen-label="Sidebar">
<div class="brand"><div class="brand-mark" aria-hidden="true"></div><div class="brand-name">CluPilot<span>v2.4</span></div></div>
<button class="ws-switch" aria-label="Workspace wechseln">
<div class="ws-avatar">AC</div>
<div class="ws-meta"><div class="ws-name">Acme Cluster</div><div class="ws-plan">team · 14 user</div></div>
<svg class="ws-chev" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 9l4-4 4 4M8 15l4 4 4-4"/></svg>
</button>
<nav class="nav">
<a class="nav-item" href="CluPilot Dashboard Glass.html"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg><span>Dashboard</span></a>
<a class="nav-item" href="Server.html"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><circle cx="7" cy="7.5" r="0.7" fill="currentColor"/><circle cx="7" cy="16.5" r="0.7" fill="currentColor"/></svg><span>Server</span><span class="count">6</span></a>
<a class="nav-item" href="Sites.html"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg><span>Sites</span><span class="count">87</span></a>
<a class="nav-item" href="Backups.html"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12c0 5-9 9-9 9s-9-4-9-9V5l9-3 9 3v7z"/><path d="M9 11l2 2 4-4"/></svg><span>Backups</span></a>
<a class="nav-item" href="Sicherheit.html"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"/></svg><span>Sicherheit</span><span class="count" style="color:var(--warning);background:var(--warning-soft);border-color:rgba(194,130,15,0.3)">3</span></a>
<div class="nav-title">Konto</div>
<a class="nav-item" href="Team.html"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/></svg><span>Team</span></a>
<a class="nav-item" href="Einstellungen.html"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span>Einstellungen</span></a>
</nav>
<div class="plan-box">
<div class="plan-row"><div class="plan-name">Team Plan</div><div class="plan-tag">PRO</div></div>
<div class="plan-meter"><span></span></div>
<div class="plan-stats"><span>87 / 140 sites</span><span>62%</span></div>
</div>
</aside>
<main class="main" data-screen-label="Profil">
<div class="topbar">
<div>
<div class="page-title">Mein Profil</div>
<div class="page-sub">persönliches konto · j.koch</div>
</div>
<div class="top-spacer"></div>
<button class="btn-ghost" id="btn-logout"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>Abmelden</button>
<button class="btn-primary" id="btn-save-all"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M5 12l5 5L20 7"/></svg>Änderungen speichern</button>
</div>
<div class="content">
<div class="profile">
<!-- ===== SIDE ===== -->
<div class="profile-side">
<section class="card pcard">
<div class="avatar-xl" style="background:linear-gradient(135deg,#3b6ff2,#7c5cff)">
JK
<button class="avatar-edit" title="Foto ändern"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg></button>
</div>
<h2>Julia Koch</h2>
<span class="role"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"/></svg>Owner</span>
<div class="mail"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="fb91d590949893bb9a98969ed59294">[email&#160;protected]</a></div>
<div style="margin-top:16px;text-align:left">
<div class="kv"><span class="k">Mitglied seit</span><span class="v">März 2024</span></div>
<div class="kv"><span class="k">2-Faktor</span><span class="v" style="color:var(--success)">aktiv</span></div>
<div class="kv" style="border-bottom:0"><span class="k">Letzter Login</span><span class="v">gerade eben</span></div>
</div>
</section>
<nav class="card side-nav">
<a class="active"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 12 0v1"/></svg>Profil</a>
<a><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>Passwort</a>
<a><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>Zwei-Faktor</a>
<a><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="2" y="4" width="20" height="14" rx="2"/><path d="M8 21h8M12 18v3"/></svg>Sitzungen</a>
<a><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0"/></svg>Benachrichtigungen</a>
</nav>
</div>
<!-- ===== FORMS ===== -->
<div class="profile-col">
<!-- profile info -->
<section class="card">
<div class="card-head"><h3><svg class="hico" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 12 0v1"/></svg>Persönliche Informationen</h3></div>
<div class="card-body">
<div class="set-grid">
<div class="field"><label>Vorname</label><input class="input" value="Julia" /></div>
<div class="field"><label>Nachname</label><input class="input" value="Koch" /></div>
<div class="field"><label>Anzeigename</label><input class="input" value="Julia Koch" /></div>
<div class="field"><label>Benutzername</label><div class="input-group"><span class="suffix" style="border-left:0;border-right:1px solid var(--hairline)">@</span><input value="j.koch" /></div></div>
<div class="field"><label>E-Mail-Adresse</label><input class="input input-mono" value="j.koch@acme.io" /></div>
<div class="field"><label>Telefon</label><input class="input input-mono" value="+49 151 2345 6789" /></div>
<div class="field"><label>Sprache</label><input class="input" value="Deutsch (DE)" /></div>
<div class="field"><label>Zeitzone</label><input class="input" value="Europe/Berlin (UTC+1)" /></div>
</div>
<div class="field" style="margin-bottom:0"><label>Über mich <span class="hint" style="font-weight:400">(optional)</span></label><textarea class="input" placeholder="Kurze Beschreibung…">Lead DevOps bei der Acme GmbH. Verantwortlich für Hosting & Infrastruktur aller Kundenprojekte.</textarea></div>
</div>
<div class="form-actions"><button class="btn-ghost">Verwerfen</button><button class="btn-primary">Profil speichern</button></div>
</section>
<!-- password -->
<section class="card">
<div class="card-head"><h3><svg class="hico" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>Passwort ändern</h3><span class="meta">zuletzt 14.03.2026</span></div>
<div class="card-body">
<div class="field"><label>Aktuelles Passwort</label><input class="input" type="password" placeholder="••••••••••" /></div>
<div class="set-grid">
<div class="field"><label>Neues Passwort</label><input class="input" type="password" id="pw-new" placeholder="Mind. 12 Zeichen" />
<div class="pw-meter"><span id="pw-bar"></span></div>
<div class="pw-hint" id="pw-hint">Stärke: —</div>
</div>
<div class="field"><label>Neues Passwort bestätigen</label><input class="input" type="password" placeholder="Wiederholen" /></div>
</div>
</div>
<div class="form-actions"><button class="btn-primary">Passwort aktualisieren</button></div>
</section>
<!-- 2FA -->
<section class="card">
<div class="card-head"><h3><svg class="hico" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>Zwei-Faktor-Authentifizierung</h3><span class="pill success">aktiv</span></div>
<div class="card-body" style="padding-top:4px">
<div class="toggle-row"><div class="tr-body"><div class="tr-title">Authenticator-App (TOTP)</div><div class="tr-desc">Google Authenticator · eingerichtet 03/2026</div></div><div class="toggle on"></div></div>
<div class="toggle-row"><div class="tr-body"><div class="tr-title">SMS-Code als Fallback</div><div class="tr-desc">an +49 151 ••• 6789</div></div><div class="toggle"></div></div>
<div class="toggle-row"><div class="tr-body"><div class="tr-title">Passkey / WebAuthn</div><div class="tr-desc">biometrisch anmelden (Touch ID, Yubikey)</div></div><div class="toggle"></div></div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:14px;padding-top:14px">
<div><div class="tr-title" style="font-weight:600;font-size:12.5px">Wiederherstellungs-Codes</div><div class="tr-desc">8 unbenutzte Codes · sicher aufbewahren</div></div>
<button class="btn-ghost">Codes anzeigen</button>
</div>
</div>
</section>
<!-- sessions -->
<section class="card">
<div class="card-head"><h3><svg class="hico" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="14" rx="2"/><path d="M8 21h8M12 18v3"/></svg>Aktive Sitzungen</h3><button class="ghost-btn">Überall abmelden</button></div>
<div class="card-body" style="padding-top:6px;padding-bottom:6px" id="sessions"></div>
</section>
<!-- danger -->
<section class="card" style="border-color:rgba(219,59,59,0.3)">
<div class="card-head"><h3 style="color:var(--danger)"><svg class="hico" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2"><path d="M12 9v4M12 17h.01"/><circle cx="12" cy="12" r="9"/></svg>Gefahrenzone</h3></div>
<div class="card-body" style="display:flex;align-items:center;justify-content:space-between;gap:14px">
<div><div style="font-weight:600;font-size:12.5px">Konto löschen</div><div class="tr-desc">Dein Benutzerkonto und alle persönlichen Daten werden dauerhaft entfernt. Workspaces, deren alleiniger Owner du bist, müssen vorher übertragen werden.</div></div>
<button class="btn-ghost" style="color:var(--danger);border-color:rgba(219,59,59,0.35);flex:none">Konto löschen</button>
</div>
</section>
</div>
</div>
<div class="foot">CluPilot · konto-aktivität wird im audit-log protokolliert</div>
</div>
</main>
</div>
<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script><script>
/* toggles */
document.querySelectorAll('.toggle').forEach(t=>t.addEventListener('click',()=>t.classList.toggle('on')));
/* side-nav active state */
document.querySelectorAll('.side-nav a').forEach(a=>a.addEventListener('click',()=>{
document.querySelectorAll('.side-nav a').forEach(x=>x.classList.remove('active'));
a.classList.add('active');
}));
/* password strength */
const pw = document.getElementById('pw-new');
const bar = document.getElementById('pw-bar');
const hint = document.getElementById('pw-hint');
const levels = [
{w:'12%', c:'#db3b3b', t:'sehr schwach'},
{w:'34%', c:'#db3b3b', t:'schwach'},
{w:'58%', c:'#c2820f', t:'mittel'},
{w:'80%', c:'#3b6ff2', t:'gut'},
{w:'100%',c:'#15a06a', t:'stark'},
];
pw.addEventListener('input',()=>{
const v = pw.value; let s = 0;
if(v.length>=8) s++;
if(v.length>=12) s++;
if(/[A-Z]/.test(v) && /[a-z]/.test(v)) s++;
if(/[0-9]/.test(v)) s++;
if(/[^A-Za-z0-9]/.test(v)) s++;
if(!v){ bar.style.width='0'; hint.textContent='Stärke: —'; return; }
const lv = levels[Math.min(s, levels.length)-1] || levels[0];
bar.style.width = lv.w; bar.style.background = lv.c;
hint.textContent = 'Stärke: '+lv.t;
});
/* sessions */
const sessions = [
{dev:'MacBook Pro · Chrome 126', loc:'Frankfurt, DE · 94.130.* ', when:'Diese Sitzung', cur:true,
ico:'<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M2 20h20"/></svg>'},
{dev:'iPhone 15 · CluPilot App', loc:'Frankfurt, DE · mobil', when:'vor 2 Stunden', cur:false,
ico:'<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg>'},
{dev:'Windows 11 · Firefox 127', loc:'Berlin, DE · 84.140.*', when:'gestern, 17:42', cur:false,
ico:'<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M2 20h20"/></svg>'},
{dev:'iPad Air · Safari', loc:'München, DE · 77.20.*', when:'vor 3 Tagen', cur:false,
ico:'<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><rect x="4" y="3" width="16" height="18" rx="2"/></svg>'},
];
const sessEl = document.getElementById('sessions');
sessions.forEach(s=>{
sessEl.insertAdjacentHTML('beforeend',`
<div class="sess-row">
<div class="sess-ico" ${s.cur?'style="color:var(--success);background:var(--success-soft);border-color:rgba(21,160,106,0.25)"':''}>${s.ico}</div>
<div class="sess-body">
<div class="sess-title">${s.dev} ${s.cur?'<span class="pill success">aktuell</span>':''}</div>
<div class="sess-meta">${s.loc} · ${s.when}</div>
</div>
${s.cur?'':'<button class="ghost-btn">Abmelden</button>'}
</div>`);
});
</script>
</body>
</html>