Files
SealShare/resources/views/pages/settings/⚡two-factor.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 5853f1f7d6 Give every page the share pages' layout, at one width
The pages were built five ways: two layouts, seven widths from 28 to
64rem and four heading styles. Every page now looks like the share
pages: a centred heading over one 40rem column of outlined cards, with
the floating toolbar below.

- <x-page> (components/page.blade.php) is the root of every page. It
  draws the h1 and its line (`brand` takes the site's logo, title and
  description from Admin settings), an optional `mark` and `navigation`
  slot, then the content. It has no width prop: every page is the same
  <x-pane width="narrow">.
- The sign-in, password reset, confirm, verify email, two-factor
  challenge, setup and system password pages move onto layouts/app with
  the brand heading and their form in a card titled with the task.
  layouts/auth, auth-header and the settings heading partial are gone,
  and so is the per-page width CSS.
- Settings put their section nav under the heading; the admin pages get
  a description line each. FileUploader and ShareDownload no longer pass
  the branding to their views.
- The admin dashboard's table needed about 49rem, so its shares are a
  list: created above the token, which opens the share, then files,
  size, downloads and expiry on two lines that wrap instead of clipping,
  and one delete button. A "Sort by" select replaces the column headers
  (newest, oldest, expiring soonest with never-expiring last, largest,
  most downloads, most files) and resets the page. The stats stay two
  by two. table.css and sort-header.css are no longer imported.
- Branding hints in Admin settings name every page the title shows on.
- Tests: PageTemplateTest renders every page once and checks one page
  template, one h1 and the width, and the brand heading with its
  fallbacks. FrameTest measures the page column instead of the auth
  card and the 64rem main; dashboard tests follow the list and the sort
  select, including expiry order. .ai/rules/views.md records <x-page>,
  the CHANGELOG notes the change and the website screenshots are
  regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:42:04 +02:00

268 lines
8.8 KiB
PHP

<?php
use App\Services\QrCodeService;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Symfony\Component\HttpFoundation\Response;
new class extends Component {
#[Locked]
public bool $twoFactorEnabled;
#[Locked]
public bool $requiresConfirmation;
#[Locked]
public string $qrCodeSvg = '';
#[Locked]
public string $manualSetupKey = '';
public bool $showModal = false;
public bool $showVerificationStep = false;
#[Validate('required|string|size:6', onUpdate: false)]
public string $code = '';
/**
* Mount the component.
*/
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
abort_unless(Features::enabled(Features::twoFactorAuthentication()), Response::HTTP_FORBIDDEN);
if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) {
$disableTwoFactorAuthentication(auth()->user());
}
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
$this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
/**
* Enable two-factor authentication for the user.
*/
public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void
{
$enableTwoFactorAuthentication(auth()->user());
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
$this->loadSetupData();
$this->showModal = true;
}
/**
* Load the two-factor authentication setup data for the user.
*/
private function loadSetupData(): void
{
$user = auth()->user();
try {
$this->qrCodeSvg = app(QrCodeService::class)->svg($user?->twoFactorQrCodeUrl());
$this->manualSetupKey = decrypt($user->two_factor_secret);
} catch (Exception) {
$this->addError('setupData', 'Failed to fetch setup data.');
$this->reset('qrCodeSvg', 'manualSetupKey');
}
}
/**
* Show the two-factor verification step if necessary.
*/
public function showVerificationIfNecessary(): void
{
if ($this->requiresConfirmation) {
$this->showVerificationStep = true;
$this->resetErrorBag();
return;
}
$this->closeModal();
}
/**
* Confirm two-factor authentication for the user.
*/
public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void
{
$this->validate();
$confirmTwoFactorAuthentication(auth()->user(), $this->code);
$this->closeModal();
$this->twoFactorEnabled = true;
}
/**
* Reset two-factor verification state.
*/
public function resetVerification(): void
{
$this->reset('code', 'showVerificationStep');
$this->resetErrorBag();
}
/**
* Disable two-factor authentication for the user.
*/
public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$disableTwoFactorAuthentication(auth()->user());
$this->twoFactorEnabled = false;
}
/**
* Close the two-factor authentication modal.
*/
public function closeModal(): void
{
$this->reset(
'code',
'manualSetupKey',
'qrCodeSvg',
'showModal',
'showVerificationStep',
);
$this->resetErrorBag();
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
}
/**
* Get the current modal configuration state.
*/
public function getModalConfigProperty(): array
{
if ($this->twoFactorEnabled) {
return [
'title' => __('Two-Factor Authentication Enabled'),
'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Close'),
];
}
if ($this->showVerificationStep) {
return [
'title' => __('Verify Authentication Code'),
'description' => __('Enter the 6-digit code from your authenticator app.'),
'buttonText' => __('Continue'),
];
}
return [
'title' => __('Enable Two-Factor Authentication'),
'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Continue'),
];
}
} ?>
<x-pages::settings.layout
:heading="__('Two Factor Authentication')"
:subheading="__('Manage your two-factor authentication settings')"
>
<x-stack gap="space300" wire:cloak>
@if ($twoFactorEnabled)
<x-stack gap="space200" align="start">
<x-badge :value="__('Enabled')" tonal color="success" />
<p class="md-type-body-md md-ink-variant">
{{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
</p>
<x-button :label="__('Disable 2FA')" icon="remove_moderator" danger wire:click="disable" />
</x-stack>
@else
<x-stack gap="space200" align="start">
<x-badge :value="__('Disabled')" tonal color="error" />
<p class="md-type-body-md md-ink-variant">
{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
</p>
<x-button :label="__('Enable 2FA')" icon="shield_lock" variant="filled" wire:click="enable" />
</x-stack>
@endif
</x-stack>
{{-- The recovery codes are their own subject, so they are their own card under this one. --}}
<x-slot:after>
@if ($twoFactorEnabled)
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
@endif
<x-modal wire:model="showModal" :title="$this->modalConfig['title']" :subtitle="$this->modalConfig['description']" fullscreen>
@if ($showVerificationStep)
<x-input
name="code"
wire:model="code"
:label="__('Code')"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
mono
autofocus
/>
<x-slot:actions>
<x-button :label="__('Back')" wire:click="resetVerification" />
<x-button :label="__('Confirm')" variant="filled" wire:click="confirmTwoFactor" x-bind:disabled="$wire.code.length < 6" />
</x-slot:actions>
@else
<x-stack gap="space300">
@error('setupData')
<x-alert color="error">{{ $message }}</x-alert>
@enderror
{{-- The QR code keeps a white ground in both themes: scanners read dark on light. --}}
<x-row justify="center">
<div class="settings-two-factor-qr">
@empty($qrCodeSvg)
<x-loading :label="__('Loading')" />
@else
{!! $qrCodeSvg !!}
@endempty
</div>
</x-row>
<x-stack gap="space200">
<p class="md-type-label-lg md-ink-variant md-text-center">{{ __('or, enter the code manually') }}</p>
<x-input :label="__('Setup key')" :value="$manualSetupKey" readonly copyable mono />
</x-stack>
</x-stack>
<x-slot:actions>
<x-button
:disabled="$errors->has('setupData')"
:label="$this->modalConfig['buttonText']"
variant="filled"
wire:click="showVerificationIfNecessary"
/>
</x-slot:actions>
@endif
</x-modal>
</x-slot:after>
</x-pages::settings.layout>