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>
268 lines
8.8 KiB
PHP
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>
|