diff --git a/app/Actions/StartCustomerProvisioning.php b/app/Actions/StartCustomerProvisioning.php index df339a2..b611b23 100644 --- a/app/Actions/StartCustomerProvisioning.php +++ b/app/Actions/StartCustomerProvisioning.php @@ -26,6 +26,7 @@ class StartCustomerProvisioning } $customer = $this->resolveCustomer($event); + $customer->ensureUser(); // portal login (also enables admin impersonation) try { [$order, $run] = DB::transaction(function () use ($event, $customer) { diff --git a/app/Http/Controllers/ImpersonationController.php b/app/Http/Controllers/ImpersonationController.php new file mode 100644 index 0000000..13d5e7d --- /dev/null +++ b/app/Http/Controllers/ImpersonationController.php @@ -0,0 +1,40 @@ +ensureUser(); + + session(['impersonator_id' => Auth::id()]); + Auth::login($user); + + return redirect()->route('dashboard'); + } + + /** Return to the admin session (available while impersonating). */ + public function leave(): RedirectResponse + { + $adminId = session()->pull('impersonator_id'); + + if ($adminId !== null && ($admin = User::query()->find($adminId)) !== null) { + Auth::login($admin); + + return redirect()->route('admin.overview'); + } + + return redirect()->route('dashboard'); + } +} diff --git a/app/Livewire/Admin/Customers.php b/app/Livewire/Admin/Customers.php index dd9fb16..37fd7de 100644 --- a/app/Livewire/Admin/Customers.php +++ b/app/Livewire/Admin/Customers.php @@ -2,6 +2,7 @@ namespace App\Livewire\Admin; +use App\Models\Customer; use Illuminate\Support\Number; use Livewire\Attributes\Layout; use Livewire\Component; @@ -12,25 +13,48 @@ class Customers extends Component public function render() { $locale = app()->getLocale(); - $eur = fn (int $v) => Number::currency($v, in: 'EUR', locale: $locale); + $plans = config('provisioning.plans'); + + $customers = Customer::query() + ->with('instances') + ->orderBy('name') + ->get(); + + $rows = $customers->map(function (Customer $c) use ($plans, $locale) { + $instance = $c->instances->sortByDesc('id')->first(); + $planKey = $instance->plan ?? null; + $priceCents = $planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0; + + return [ + 'uuid' => $c->uuid, + 'name' => $c->name, + 'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—', + 'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale), + 'instance' => $instance->subdomain ?? '—', + 'status' => $instance->status ?? $c->status ?? 'provisioning', + ]; + })->all(); + + // Plan distribution for the doughnut — derived from live instances. + $labels = []; + $counts = []; + foreach (array_keys($plans) as $planKey) { + $n = $customers->filter(fn (Customer $c) => $c->instances->contains('plan', $planKey))->count(); + if ($n > 0) { + $labels[] = __('billing.plan.'.$planKey); + $counts[] = $n; + } + } return view('livewire.admin.customers', [ - 'rows' => [ - ['name' => 'Kanzlei Berger', 'plan' => 'Team', 'mrr' => $eur(198), 'instance' => 'kanzlei-berger', 'status' => 'active'], - ['name' => 'Ordination Dr. Fux', 'plan' => 'Solo', 'mrr' => $eur(79), 'instance' => 'ordination-fux', 'status' => 'provisioning'], - ['name' => 'Architekturbüro Lang', 'plan' => 'Team', 'mrr' => $eur(198), 'instance' => 'buero-lang', 'status' => 'provisioning'], - ['name' => 'Weingut Prantl', 'plan' => 'Solo', 'mrr' => $eur(79), 'instance' => 'weingut-prantl', 'status' => 'provisioning'], - ['name' => 'Steuerberatung Reiss', 'plan' => 'Team', 'mrr' => $eur(198), 'instance' => 'stb-reiss', 'status' => 'active'], - ['name' => 'Notariat Külz', 'plan' => 'Enterprise', 'mrr' => $eur(449), 'instance' => 'notariat-kuelz', 'status' => 'active'], - ['name' => 'Praxis Sommer', 'plan' => 'Solo', 'mrr' => $eur(79), 'instance' => 'praxis-sommer', 'status' => 'suspended'], - ], + 'rows' => $rows, 'plansChart' => [ 'type' => 'doughnut', 'data' => [ - 'labels' => ['Solo', 'Team', 'Enterprise'], + 'labels' => $labels, 'datasets' => [[ - 'data' => [21, 17, 4], - 'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright'], + 'data' => $counts, + 'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright', 'token:warning'], 'borderWidth' => 0, ]], ], diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 2a78f27..1d9379c 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -5,14 +5,22 @@ namespace App\Models; use App\Models\Concerns\HasUuid; 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\Facades\Hash; +use Illuminate\Support\Str; class Customer extends Model { /** @use HasFactory<\Database\Factories\CustomerFactory> */ use HasFactory, HasUuid; - protected $fillable = ['name', 'email', 'locale', 'stripe_customer_id', 'status']; + protected $fillable = ['user_id', 'name', 'email', 'locale', 'stripe_customer_id', 'status']; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } public function orders(): HasMany { @@ -23,4 +31,21 @@ class Customer extends Model { return $this->hasMany(Instance::class); } + + /** Find or create the portal login account for this customer and link it. */ + public function ensureUser(): User + { + if ($this->user) { + return $this->user; + } + + $user = User::query()->firstOrCreate( + ['email' => $this->email], + ['name' => $this->name, 'password' => Hash::make(Str::random(40)), 'is_admin' => false], + ); + + $this->update(['user_id' => $user->id]); + + return $user->refresh(); + } } diff --git a/database/migrations/2026_07_25_090003_add_user_id_to_customers_table.php b/database/migrations/2026_07_25_090003_add_user_id_to_customers_table.php new file mode 100644 index 0000000..306639b --- /dev/null +++ b/database/migrations/2026_07_25_090003_add_user_id_to_customers_table.php @@ -0,0 +1,23 @@ +foreignId('user_id')->nullable()->after('uuid')->constrained('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('customers', function (Blueprint $table) { + $table->dropConstrainedForeignId('user_id'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index fca0c13..9c1450a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -76,6 +76,7 @@ class DatabaseSeeder extends Seeder ['email' => 'kunde@clupilot.local'], ['name' => 'Kanzlei Berger', 'locale' => 'de'], ); + $berger->ensureUser(); // links to the kunde@clupilot.local portal login (admin impersonation) $bergerOrder = Order::updateOrCreate( ['stripe_event_id' => 'seed_berger'], ['customer_id' => $berger->id, 'plan' => 'team', 'amount_cents' => 17900, 'currency' => 'EUR', 'datacenter' => 'fsn', 'status' => 'active'], @@ -100,6 +101,7 @@ class DatabaseSeeder extends Seeder ['email' => "demo{$i}@clupilot.local"], ['name' => $demo['name'], 'locale' => 'de'], ); + $customer->ensureUser(); // portal login so the admin can impersonate $order = Order::updateOrCreate( ['stripe_event_id' => "seed_demo_{$i}"], ['customer_id' => $customer->id, 'plan' => $demo['plan'], 'amount_cents' => 1900, 'currency' => 'EUR', 'datacenter' => 'fsn', 'status' => 'provisioning'], diff --git a/lang/de/admin.php b/lang/de/admin.php index c6fadff..6306cb9 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -53,6 +53,8 @@ return [ ], 'customers_sub' => 'Alle Kunden und ihre Pakete.', + 'customers_empty' => 'Noch keine Kunden.', + 'impersonate' => 'Verbinden', 'by_plan' => 'Nach Paket', 'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.', 'instances_label' => 'Instanzen', @@ -74,6 +76,7 @@ return [ 'version' => 'Version', 'step' => 'Schritt', 'attempt' => 'Versuch', + 'actions' => 'Aktionen', ], 'status' => [ diff --git a/lang/de/impersonate.php b/lang/de/impersonate.php new file mode 100644 index 0000000..ce38526 --- /dev/null +++ b/lang/de/impersonate.php @@ -0,0 +1,6 @@ + 'Verbunden als :name — Admin-Ansicht.', + 'leave' => 'Zurück zum Admin', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 5cc4bff..0559a41 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -53,6 +53,8 @@ return [ ], 'customers_sub' => 'All customers and their plans.', + 'customers_empty' => 'No customers yet.', + 'impersonate' => 'Connect', 'by_plan' => 'By plan', 'instances_sub' => 'All provisioned cloud instances.', 'instances_label' => 'instances', @@ -74,6 +76,7 @@ return [ 'version' => 'Version', 'step' => 'Step', 'attempt' => 'Attempt', + 'actions' => 'Actions', ], 'status' => [ diff --git a/lang/en/impersonate.php b/lang/en/impersonate.php new file mode 100644 index 0000000..6bd7532 --- /dev/null +++ b/lang/en/impersonate.php @@ -0,0 +1,6 @@ + 'Connected as :name — admin view.', + 'leave' => 'Back to admin', +]; diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php index 1ab36a6..271e87c 100644 --- a/resources/views/layouts/portal-app.blade.php +++ b/resources/views/layouts/portal-app.blade.php @@ -9,6 +9,16 @@ @livewireStyles + @if (session('impersonator_id')) + {{-- Admin impersonation banner — visible on every portal page until the admin returns. --}} +
+ {{ __('impersonate.banner', ['name' => auth()->user()->name]) }} + + + {{ __('impersonate.leave') }} + +
+ @endif
{{-- Sidebar: fixed drawer on small screens, fixed full-height column on large (R7). --}}
diff --git a/routes/web.php b/routes/web.php index 9343d7c..cdf3cda 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ group(function () { Route::get('/invoices', Invoices::class)->name('invoices'); Route::get('/billing', Billing::class)->name('billing'); Route::get('/support', Support::class)->name('support'); + + // Return from an admin impersonation session (accessible as the customer user). + Route::get('/impersonate/leave', [ImpersonationController::class, 'leave'])->name('impersonate.leave'); }); // Admin / operator console — dark theme, gated to is_admin users (R1/R2, R13). @@ -54,6 +58,7 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create'); Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show'); Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters'); + Route::get('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); }); diff --git a/tests/Feature/ImpersonationTest.php b/tests/Feature/ImpersonationTest.php new file mode 100644 index 0000000..7423219 --- /dev/null +++ b/tests/Feature/ImpersonationTest.php @@ -0,0 +1,49 @@ +create(['email' => 'c@imp.test', 'name' => 'Imp Kunde']); + + $this->actingAs($admin) + ->get(route('admin.impersonate', $customer->uuid)) + ->assertRedirect(route('dashboard')); + + $customer->refresh(); + expect($customer->user_id)->not->toBeNull() + ->and(auth()->id())->toBe($customer->user_id) + ->and(session('impersonator_id'))->toBe($admin->id); + + // Return to the admin session. + $this->get(route('impersonate.leave'))->assertRedirect(route('admin.overview')); + + expect(auth()->id())->toBe($admin->id) + ->and(session()->has('impersonator_id'))->toBeFalse(); +}); + +it('reuses an existing portal user with the same email', function () { + $existing = User::factory()->create(['email' => 'shared@imp.test', 'is_admin' => false]); + $customer = Customer::factory()->create(['email' => 'shared@imp.test']); + + expect($customer->ensureUser()->id)->toBe($existing->id); + expect($customer->fresh()->user_id)->toBe($existing->id); +}); + +it('forbids a non-admin from impersonating', function () { + $user = User::factory()->create(['is_admin' => false]); + $customer = Customer::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.impersonate', $customer->uuid)) + ->assertForbidden(); +}); + +it('gates impersonation start to authenticated users', function () { + $customer = Customer::factory()->create(); + + $this->get(route('admin.impersonate', $customer->uuid))->assertRedirect('/login'); +});