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
parent
5eb3deff0b
commit
5f6ad27329
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Email extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.email');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Sessions extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.sessions');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Users extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.users');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue