feat(admin): impersonate customer portal — session login + return banner

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 13:46:48 +02:00
parent 515f26234e
commit 879697c6ea
14 changed files with 225 additions and 16 deletions

View File

@ -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) {

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
/**
* Admin impersonation: an admin can log in as a customer's portal account to
* inspect their portal during an incident, then return to the admin session.
*/
class ImpersonationController extends Controller
{
/** Admin-gated: become the customer. */
public function start(Customer $customer): RedirectResponse
{
$user = $customer->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');
}
}

View File

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

View File

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

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Portal login account for the customer — enables admin impersonation.
Schema::table('customers', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->after('uuid')->constrained('users')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->dropConstrainedForeignId('user_id');
});
}
};

View File

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

View File

@ -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' => [

6
lang/de/impersonate.php Normal file
View File

@ -0,0 +1,6 @@
<?php
return [
'banner' => 'Verbunden als :name — Admin-Ansicht.',
'leave' => 'Zurück zum Admin',
];

View File

@ -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' => [

6
lang/en/impersonate.php Normal file
View File

@ -0,0 +1,6 @@
<?php
return [
'banner' => 'Connected as :name — admin view.',
'leave' => 'Back to admin',
];

View File

@ -9,6 +9,16 @@
@livewireStyles
</head>
<body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }">
@if (session('impersonator_id'))
{{-- Admin impersonation banner visible on every portal page until the admin returns. --}}
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2 text-center text-sm font-medium text-ink">
<span>{{ __('impersonate.banner', ['name' => auth()->user()->name]) }}</span>
<a href="{{ route('impersonate.leave') }}" class="inline-flex items-center gap-1 rounded-pill border border-warning-border px-3 py-0.5 font-semibold text-warning underline-offset-2 hover:underline">
<x-ui.icon name="log-out" class="size-4" />
{{ __('impersonate.leave') }}
</a>
</div>
@endif
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
{{-- Sidebar: fixed drawer on small screens, fixed full-height column on large (R7). --}}
<aside

View File

@ -14,10 +14,11 @@
<th class="px-4 py-3 font-semibold">{{ __('admin.col.plan') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.mrr') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.status') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('admin.col.actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($rows as $r)
@forelse ($rows as $r)
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3">
<p class="font-medium text-ink">{{ $r['name'] }}</p>
@ -26,8 +27,19 @@
<td class="px-4 py-3 text-body">{{ $r['plan'] }}</td>
<td class="px-4 py-3 font-mono text-body">{{ $r['mrr'] }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td>
<td class="px-4 py-3 text-right">
<a href="{{ route('admin.impersonate', $r['uuid']) }}"
class="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="log-out" class="size-3.5" />
{{ __('admin.impersonate') }}
</a>
</td>
</tr>
@endforeach
@empty
<tr>
<td colspan="5" class="px-4 py-10 text-center text-sm text-muted">{{ __('admin.customers_empty') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\ImpersonationController;
use App\Http\Controllers\StripeWebhookController;
use App\Livewire\Admin;
use App\Livewire\Billing;
@ -43,6 +44,9 @@ Route::middleware('auth')->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');
});

View File

@ -0,0 +1,49 @@
<?php
use App\Models\Customer;
use App\Models\User;
// admin() helper is defined in HostManagementTest.
it('lets an admin impersonate a customer and returns to the admin session', function () {
$admin = admin();
$customer = Customer::factory()->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');
});