refactor(settings): split into per-tab nested components (Profile, Security) + tab hosts for Users/Sessions/Email

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 23:23:25 +02:00
parent 5eb3deff0b
commit 5f6ad27329
16 changed files with 300 additions and 177 deletions

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Component;
class Email extends Component
{
public function render()
{
return view('livewire.settings.email');
}
}

View File

@ -2,12 +2,7 @@
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
@ -19,99 +14,16 @@ class Index extends Component
public bool $openRecoveryModal = false;
public string $name = '';
public string $email = '';
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
if (session('open_recovery_modal')) {
$this->tab = 'security';
$this->openRecoveryModal = true;
}
}
public function updateProfile(): void
{
$data = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore(Auth::id())],
]);
Auth::user()->update($data);
$this->audit('profile.update', $data['email']);
$this->dispatch('notify', message: __('settings.profile_saved'));
}
public function updatePassword(): void
{
$this->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(10)],
]);
Auth::user()->update(['password' => $this->password, 'must_change_password' => false]);
$this->reset('current_password', 'password', 'password_confirmation');
$this->audit('password.change', Auth::user()->email);
$this->dispatch('notify', message: __('settings.password_changed'));
}
public function confirmDisableTwoFactor(): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('settings.disable_2fa_heading'),
'body' => __('settings.disable_2fa_body'),
'confirmLabel' => __('common.disable'),
'danger' => true,
'icon' => 'shield',
'auditAction' => 'two_factor.disable',
'auditTarget' => Auth::user()?->email,
'event' => 'twoFactorDisabled',
'notify' => __('settings.disable_2fa_notify'),
],
);
}
#[On('twoFactorDisabled')]
public function disableTwoFactor(): void
{
// Remove the TOTP factor only — security keys are managed on their own card. If no
// factor remains afterwards, the backup codes are dropped too.
Auth::user()->forceFill([
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
])->save();
Auth::user()->resetIfNoFactor();
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render()
{
return view('livewire.settings.index', [
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
'hasTotp' => Auth::user()->hasTotp(),
])->title(__('settings.title'));
return view('livewire.settings.index')->title(__('settings.title'));
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Livewire\Settings;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Livewire\Component;
class Profile extends Component
{
public string $name = '';
public string $email = '';
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
}
public function updateProfile(): void
{
$data = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore(Auth::id())],
]);
Auth::user()->update($data);
$this->audit('profile.update', $data['email']);
$this->dispatch('notify', message: __('settings.profile_saved'));
}
public function updatePassword(): void
{
$this->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(10)],
]);
Auth::user()->update(['password' => $this->password, 'must_change_password' => false]);
$this->reset('current_password', 'password', 'password_confirmation');
$this->audit('password.change', Auth::user()->email);
$this->dispatch('notify', message: __('settings.password_changed'));
}
private function audit(string $action, string $target): void
{
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => $action,
'target' => $target,
'ip' => request()->ip(),
]);
}
public function render()
{
return view('livewire.settings.profile');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Settings;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class Security extends Component
{
public function confirmDisableTwoFactor(): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => __('settings.disable_2fa_heading'),
'body' => __('settings.disable_2fa_body'),
'confirmLabel' => __('common.disable'),
'danger' => true,
'icon' => 'shield',
'auditAction' => 'two_factor.disable',
'auditTarget' => Auth::user()?->email,
'event' => 'twoFactorDisabled',
'notify' => __('settings.disable_2fa_notify'),
],
);
}
#[On('twoFactorDisabled')]
public function disableTwoFactor(): void
{
// Remove the TOTP factor only — security keys are managed on their own card. If no
// factor remains afterwards, the backup codes are dropped too.
Auth::user()->forceFill([
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
])->save();
Auth::user()->resetIfNoFactor();
}
public function render()
{
return view('livewire.settings.security', [
'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
'hasTotp' => Auth::user()->hasTotp(),
]);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Component;
class Sessions extends Component
{
public function render()
{
return view('livewire.settings.sessions');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Component;
class Users extends Component
{
public function render()
{
return view('livewire.settings.users');
}
}

View File

@ -11,6 +11,9 @@ return [
// Section nav tabs
'tab_profile' => 'Profil',
'tab_security' => 'Sicherheit',
'tab_users' => 'Benutzer',
'tab_sessions' => 'Sitzungen',
'tab_email' => 'E-Mail',
// Profile panel
'profile_title' => 'Profil',
@ -46,6 +49,15 @@ return [
'disable_2fa_body' => 'Den Authenticator (TOTP) als Faktor entfernen? Security-Keys bleiben bestehen.',
'disable_2fa_notify' => 'Authenticator entfernt.',
// Stub tabs (filled in 0.9.0)
'coming_soon' => 'Wird in 0.9.0 ergänzt.',
'users_title' => 'Benutzer',
'users_subtitle' => 'Konten und Rollen verwalten',
'sessions_title' => 'Sitzungen',
'sessions_subtitle' => 'Aktive Anmeldungen',
'email_title' => 'E-Mail',
'email_subtitle' => 'SMTP und Benachrichtigungen',
// Page title
'title' => 'Einstellungen — Clusev',
];

View File

@ -11,6 +11,9 @@ return [
// Section nav tabs
'tab_profile' => 'Profile',
'tab_security' => 'Security',
'tab_users' => 'Users',
'tab_sessions' => 'Sessions',
'tab_email' => 'Email',
// Profile panel
'profile_title' => 'Profile',
@ -46,6 +49,15 @@ return [
'disable_2fa_body' => 'Remove the authenticator (TOTP) as a factor? Security keys are kept.',
'disable_2fa_notify' => 'Authenticator removed.',
// Stub tabs (filled in 0.9.0)
'coming_soon' => 'Coming in 0.9.0.',
'users_title' => 'Users',
'users_subtitle' => 'Manage accounts and roles',
'sessions_title' => 'Sessions',
'sessions_subtitle' => 'Active sign-ins',
'email_title' => 'Email',
'email_subtitle' => 'SMTP and notifications',
// Page title
'title' => 'Settings — Clusev',
];

View File

@ -32,6 +32,7 @@
'eye' => '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
'eye-off' => '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>',
'user-plus' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
'mail' => '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
];
@endphp
<svg {{ $attributes->merge(['class' => $class]) }} viewBox="0 0 24 24" fill="none" stroke="currentColor"

View File

@ -0,0 +1,5 @@
<div class="space-y-5">
<x-panel :title="__('settings.email_title')" :subtitle="__('settings.email_subtitle')">
<p class="font-mono text-[11px] text-ink-4">{{ __('settings.coming_soon') }}</p>
</x-panel>
</div>

View File

@ -1,12 +1,13 @@
@php
$u = auth()->user();
$initials = strtoupper(mb_substr($u->name, 0, 2));
$field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-sans text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline';
$twoFactorEnabled = $u->hasTwoFactorEnabled();
$tabs = [
['key' => 'profile', 'label' => __('settings.tab_profile'), 'icon' => 'settings'],
['key' => 'security', 'label' => __('settings.tab_security'), 'icon' => 'shield'],
['key' => 'users', 'label' => __('settings.tab_users'), 'icon' => 'user-plus'],
['key' => 'sessions', 'label' => __('settings.tab_sessions'), 'icon' => 'logout'],
['key' => 'email', 'label' => __('settings.tab_email'), 'icon' => 'mail'],
];
@endphp
@ -49,88 +50,12 @@
</nav>
{{-- Content --}}
<div class="space-y-5">
@if ($tab === 'profile')
<x-panel :title="__('settings.profile_title')" :subtitle="__('settings.profile_subtitle')">
<form wire:submit="updateProfile" class="space-y-4">
<div>
<label class="{{ $label }}">{{ __('settings.name') }}</label>
<input wire:model="name" type="text" class="{{ $field }}" />
@error('name') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('settings.email') }}</label>
<input wire:model="email" type="text" class="{{ $field }} font-mono" />
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="flex justify-end">
<x-btn variant="primary" type="submit" wire:target="updateProfile" wire:loading.attr="disabled">
<svg wire:loading wire:target="updateProfile" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('common.save') }}
</x-btn>
</div>
</form>
</x-panel>
@else
<x-panel :title="__('settings.password_title')" :subtitle="__('settings.password_subtitle')">
<form wire:submit="updatePassword" class="space-y-4">
<div>
<label class="{{ $label }}">{{ __('settings.current_password') }}</label>
<input wire:model="current_password" type="password" autocomplete="current-password" class="{{ $field }} font-mono" />
@error('current_password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="{{ $label }}">{{ __('settings.new_password') }}</label>
<input wire:model="password" type="password" autocomplete="new-password" class="{{ $field }} font-mono" />
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('settings.repeat_password') }}</label>
<input wire:model="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }} font-mono" />
</div>
</div>
<div class="flex justify-end">
<x-btn variant="primary" type="submit" wire:target="updatePassword" wire:loading.attr="disabled">
<svg wire:loading wire:target="updatePassword" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('settings.change_password') }}
</x-btn>
</div>
</form>
</x-panel>
<x-panel :title="__('settings.twofa_title')" :subtitle="__('settings.twofa_subtitle')">
@unless ($twoFactorEnabled)
<div class="mb-4 flex items-start gap-2.5 rounded-md border border-accent/25 bg-accent/10 px-4 py-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
<p class="text-sm text-ink-2">{{ __('settings.twofa_recommended') }}</p>
</div>
@endunless
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<x-status-dot :status="$hasTotp ? 'online' : 'offline'" />
<div>
<p class="text-sm text-ink">{{ $hasTotp ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}</p>
<p class="font-mono text-[11px] text-ink-3">{{ $hasTotp ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}</p>
</div>
</div>
<div class="flex items-center gap-2">
@if ($twoFactorEnabled)
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.recovery-codes' })">{{ __('auth.recovery_manage') }}</x-btn>
@endif
@if ($hasTotp)
<x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('settings.twofa_remove_totp') }}</x-btn>
@else
<x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate>
<x-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }}
</x-btn>
@endif
</div>
</div>
</x-panel>
<livewire:settings.webauthn-keys />
<div class="min-w-0">
@if ($tab === 'profile') <livewire:settings.profile />
@elseif ($tab === 'security') <livewire:settings.security />
@elseif ($tab === 'users') <livewire:settings.users />
@elseif ($tab === 'sessions') <livewire:settings.sessions />
@elseif ($tab === 'email') <livewire:settings.email />
@endif
</div>
</div>

View File

@ -0,0 +1,55 @@
@php
$field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-sans text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<div class="space-y-5">
<x-panel :title="__('settings.profile_title')" :subtitle="__('settings.profile_subtitle')">
<form wire:submit="updateProfile" class="space-y-4">
<div>
<label class="{{ $label }}">{{ __('settings.name') }}</label>
<input wire:model="name" type="text" class="{{ $field }}" />
@error('name') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('settings.email') }}</label>
<input wire:model="email" type="text" class="{{ $field }} font-mono" />
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="flex justify-end">
<x-btn variant="primary" type="submit" wire:target="updateProfile" wire:loading.attr="disabled">
<svg wire:loading wire:target="updateProfile" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('common.save') }}
</x-btn>
</div>
</form>
</x-panel>
<x-panel :title="__('settings.password_title')" :subtitle="__('settings.password_subtitle')">
<form wire:submit="updatePassword" class="space-y-4">
<div>
<label class="{{ $label }}">{{ __('settings.current_password') }}</label>
<input wire:model="current_password" type="password" autocomplete="current-password" class="{{ $field }} font-mono" />
@error('current_password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="{{ $label }}">{{ __('settings.new_password') }}</label>
<input wire:model="password" type="password" autocomplete="new-password" class="{{ $field }} font-mono" />
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('settings.repeat_password') }}</label>
<input wire:model="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }} font-mono" />
</div>
</div>
<div class="flex justify-end">
<x-btn variant="primary" type="submit" wire:target="updatePassword" wire:loading.attr="disabled">
<svg wire:loading wire:target="updatePassword" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('settings.change_password') }}
</x-btn>
</div>
</form>
</x-panel>
</div>

View File

@ -0,0 +1,34 @@
<div class="space-y-5">
<x-panel :title="__('settings.twofa_title')" :subtitle="__('settings.twofa_subtitle')">
@unless ($twoFactorEnabled)
<div class="mb-4 flex items-start gap-2.5 rounded-md border border-accent/25 bg-accent/10 px-4 py-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
<p class="text-sm text-ink-2">{{ __('settings.twofa_recommended') }}</p>
</div>
@endunless
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<x-status-dot :status="$hasTotp ? 'online' : 'offline'" />
<div>
<p class="text-sm text-ink">{{ $hasTotp ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}</p>
<p class="font-mono text-[11px] text-ink-3">{{ $hasTotp ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}</p>
</div>
</div>
<div class="flex items-center gap-2">
@if ($twoFactorEnabled)
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.recovery-codes' })">{{ __('auth.recovery_manage') }}</x-btn>
@endif
@if ($hasTotp)
<x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('settings.twofa_remove_totp') }}</x-btn>
@else
<x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate>
<x-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }}
</x-btn>
@endif
</div>
</div>
</x-panel>
<livewire:settings.webauthn-keys />
</div>

View File

@ -0,0 +1,5 @@
<div class="space-y-5">
<x-panel :title="__('settings.sessions_title')" :subtitle="__('settings.sessions_subtitle')">
<p class="font-mono text-[11px] text-ink-4">{{ __('settings.coming_soon') }}</p>
</x-panel>
</div>

View File

@ -0,0 +1,5 @@
<div class="space-y-5">
<x-panel :title="__('settings.users_title')" :subtitle="__('settings.users_subtitle')">
<p class="font-mono text-[11px] text-ink-4">{{ __('settings.coming_soon') }}</p>
</x-panel>
</div>

View File

@ -2,7 +2,7 @@
namespace Tests\Feature;
use App\Livewire\Settings\Index;
use App\Livewire\Settings\Security;
use App\Livewire\Settings\WebauthnKeys;
use App\Models\User;
use App\Models\WebauthnCredential;
@ -27,7 +27,7 @@ class SettingsFactorManagementTest extends TestCase
$user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]);
$user->replaceRecoveryCodes();
Livewire::actingAs($user)->test(Index::class)->call('disableTwoFactor');
Livewire::actingAs($user)->test(Security::class)->call('disableTwoFactor');
$fresh = $user->fresh();
$this->assertFalse($fresh->hasTotp());
@ -40,7 +40,7 @@ class SettingsFactorManagementTest extends TestCase
WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);
$user->replaceRecoveryCodes();
Livewire::actingAs($user->fresh())->test(Index::class)->call('disableTwoFactor');
Livewire::actingAs($user->fresh())->test(Security::class)->call('disableTwoFactor');
$fresh = $user->fresh();
$this->assertFalse($fresh->hasTotp());