feat(admin): impersonate customer portal — session login + return banner
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
515f26234e
commit
879697c6ea
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'banner' => 'Verbunden als :name — Admin-Ansicht.',
|
||||
'leave' => 'Zurück zum Admin',
|
||||
];
|
||||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'banner' => 'Connected as :name — admin view.',
|
||||
'leave' => 'Back to admin',
|
||||
];
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue