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>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 21:42:04 +02:00
co-authored by Claude Opus 5
parent 40e35bab0e
commit 5853f1f7d6
77 changed files with 1234 additions and 1243 deletions
@@ -1,9 +0,0 @@
@props([
'title',
'description',
])
<x-stack gap="space50" class="md-text-center">
<h1 class="md-type-headline-sm">{{ $title }}</h1>
<p class="md-type-body-md md-ink-variant">{{ $description }}</p>
</x-stack>
+57
View File
@@ -0,0 +1,57 @@
{{-- Every page in SealShare: a centred header over one centred column of cards, the share pages'
shape carried to every other page, sign-in included.
<x-page :title="__('Settings')" :description="__('…')">
<x-slot:navigation><x-section-nav :items="$items" /></x-slot:navigation>
<x-card variant="outlined" heading="h2" ></x-card>
</x-page>
`brand` heads the page with the site's own logo, title and description from Admin settings
instead of `title` and `description`, falling back to the app's name and SealShare's line.
`mark` is a visual above the title (share created's check). Every page is the same 40rem
column (`<x-pane width="narrow">`, M3's cap on a text field), so there is no width prop: content
that needs more room is rearranged to fit, as the admin dashboard's shares became a list. No
page sets a width or a heading of its own. The page's content stacks 24px apart under the
header and the optional `navigation`. --}}
@props([
'title' => null,
'description' => null,
'brand' => false,
])
@php
$logo = null;
if ($brand) {
$title = \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare');
$description = \App\Models\Setting::get('site_description') ?: __('Share your files safely and securely');
$logo = \App\Models\Setting::get('site_logo');
}
@endphp
<x-pane width="narrow" data-test="page" {{ $attributes }}>
<x-stack gap="space400">
<x-stack as="header" align="center" gap="space200">
@if ($logo)
<img src="{{ Storage::disk('public')->url($logo) }}" alt="{{ $title }}" class="page-logo" data-test="page-logo" />
@endif
{{ $mark ?? '' }}
<x-stack align="center" gap="space100">
<h1 class="md-type-headline-lg md-text-center">{{ $title }}</h1>
@if (filled($description))
<p class="md-type-body-lg md-ink-variant md-text-center">{{ $description }}</p>
@endif
</x-stack>
</x-stack>
{{ $navigation ?? '' }}
<x-stack gap="space300">
{{ $slot }}
</x-stack>
</x-stack>
</x-pane>
-19
View File
@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body>
<main class="auth-main">
<x-surface level="surface-container-low" padding="space300" corner="xl" class="auth-card">
<x-stack gap="space300">
{{ $slot }}
</x-stack>
</x-surface>
</main>
@include('partials.toolbar')
<x-toast />
</body>
</html>
@@ -1,7 +1,5 @@
<x-stack gap="space300">
<h1 class="md-type-headline-md">{{ __('Admin Dashboard') }}</h1>
<x-grid :columns="['compact' => 2, 'expanded' => 4]" gap="space200">
<x-page :title="__('Admin Dashboard')" :description="__('Shares, files and storage at a glance')">
<x-grid :columns="2" gap="space200">
<x-stat :title="__('Total Shares')" :value="$totalShares" icon="link" />
<x-stat :title="__('Active Shares')" :value="$activeShares" icon="schedule" />
<x-stat :title="__('Total Files')" :value="$totalFiles" icon="description" />
@@ -10,49 +8,55 @@
</x-stat>
</x-grid>
{{-- The shares as a list, not a table: a table's columns need more than the page's 40rem, and
every page keeps that one width. The sort is a select above the list instead of column headers. --}}
<x-card :title="__('All Shares')" heading="h2" variant="outlined">
<x-stack gap="space200">
@if ($shares->total() === 0)
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
@else
<div class="admin-shares-table-scroll">
<x-table>
<thead>
<tr>
<x-sort-header column="token" :sort-by="$sortBy">{{ __('Token') }}</x-sort-header>
<x-sort-header column="files_count" :sort-by="$sortBy" class="md-text-end">{{ __('Files') }}</x-sort-header>
<x-sort-header column="total_size" :sort-by="$sortBy" class="md-text-end">{{ __('Size') }}</x-sort-header>
<x-sort-header column="download_count" :sort-by="$sortBy" class="md-text-end">{{ __('Downloads') }}</x-sort-header>
<x-sort-header column="expires_at" :sort-by="$sortBy">{{ __('Expires') }}</x-sort-header>
<x-sort-header column="created_at" :sort-by="$sortBy">{{ __('Created') }}</x-sort-header>
<th><span class="md-visually-hidden">{{ __('Actions') }}</span></th>
</tr>
</thead>
<tbody>
@foreach ($shares as $share)
<tr wire:key="share-{{ $share->id }}">
<td><code>{{ $share->token }}</code></td>
<td class="md-text-end md-tabular">{{ $share->files_count }}</td>
<td class="md-text-end md-tabular md-nowrap">{{ Number::fileSize($share->total_size) }}</td>
<td class="md-text-end md-tabular">{{ $share->download_count }}</td>
<td class="md-nowrap">
@if ($share->expires_at)
<span @class(['md-ink-error' => $share->isExpired()])>{{ $share->expires_at->diffForHumans() }}</span>
@else
<span class="md-ink-variant">{{ __('Never') }}</span>
@endif
</td>
<td class="md-nowrap">{{ $share->created_at->diffForHumans() }}</td>
<td class="md-text-end md-nowrap">
<x-button icon="open_in_new" :tooltip="__('Open')" :link="route('share.download', $share)" external />
<x-button icon="delete" :tooltip="__('Delete')" color="error" wire:click="$set('deletingShareId', {{ $share->id }})" data-test="delete-share-{{ $share->id }}" />
</td>
</tr>
@endforeach
</tbody>
</x-table>
<div class="admin-shares-sort">
<x-select
wire:model.live="sort"
:label="__('Sort by')"
:options="[
['id' => 'newest', 'name' => __('Newest first')],
['id' => 'oldest', 'name' => __('Oldest first')],
['id' => 'expiring', 'name' => __('Expiring soonest')],
['id' => 'largest', 'name' => __('Largest')],
['id' => 'most-downloaded', 'name' => __('Most downloads')],
['id' => 'most-files', 'name' => __('Most files')],
]"
data-test="shares-sort"
/>
</div>
{{-- Each share fits the column on a phone: the token opens it, so delete is the one button;
the details are two short lines that never clip (admin-shares, app.css). --}}
<x-list dividers :label="__('All Shares')" class="admin-shares">
@foreach ($shares as $share)
<x-list-item :overline="__('Created :time', ['time' => $share->created_at->diffForHumans()])" wire:key="share-{{ $share->id }}" data-test="share-row">
<a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a>
<x-slot:description>
<span class="admin-share-detail md-tabular">{{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ trans_choice(':count download|:count downloads', $share->download_count) }}</span>
@if (! $share->expires_at)
<span class="admin-share-detail">{{ __('Never expires') }}</span>
@elseif ($share->isExpired())
<span class="admin-share-detail md-ink-error">{{ __('Expired :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
@else
<span class="admin-share-detail">{{ __('Expires :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
@endif
</x-slot:description>
<x-slot:end>
<x-button icon="delete" :tooltip="__('Delete')" color="error" wire:click="$set('deletingShareId', {{ $share->id }})" data-test="delete-share-{{ $share->id }}" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
{{ $shares->links() }}
@endif
</x-stack>
@@ -66,4 +70,4 @@
<x-button :label="__('Delete')" danger x-on:click="$wire.deleteShare($wire.deletingShareId)" data-test="confirm-delete-share" />
</x-slot:actions>
</x-modal>
</x-stack>
</x-page>
@@ -1,227 +1,223 @@
<x-pane class="admin-settings">
<x-stack gap="space300">
<h1 class="md-type-headline-md">{{ __('System Settings') }}</h1>
<x-page :title="__('System Settings')" :description="__('How the site looks and what uploaders may do')">
<x-form wire:submit="saveSettings">
<x-card :title="__('Colour profile')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-scheme-picker wire:model="colorProfile" :hint="__('Choosing one previews it here. After saving, every page, mail and error page uses it.')" data-test="color-profile" />
</x-stack>
</x-card>
<x-card :title="__('Branding')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-input full wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload, download and sign-in pages.')" />
<x-textarea full wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload, download and sign-in pages.')" rows="3" />
<x-form wire:submit="saveSettings">
<x-card :title="__('Colour profile')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-scheme-picker wire:model="colorProfile" :hint="__('Choosing one previews it here. After saving, every page, mail and error page uses it.')" data-test="color-profile" />
</x-stack>
</x-card>
@if ($currentLogo)
<x-row gap="space200" wrap>
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="admin-settings-logo" />
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
</x-row>
@endif
<x-card :title="__('Branding')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-input full wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload page.')" />
<x-file full wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
<x-textarea full wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload page.')" rows="3" />
<x-stack gap="space200">
@if ($currentLogo)
<x-row gap="space200" wrap>
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="admin-settings-logo" />
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
</x-row>
@endif
<x-file full wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
@if ($siteLogo && is_object($siteLogo))
@if (str_contains($siteLogo->getMimeType(), 'svg'))
<p class="md-type-body-md md-ink-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
@else
<x-stack gap="space50">
<p class="md-type-label-lg md-ink-variant">{{ __('Preview:') }}</p>
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="admin-settings-logo" />
</x-stack>
@endif
@endif
</x-stack>
</x-stack>
</x-card>
<x-card :title="__('Upload Protection')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-stack gap="space100">
<x-password full
wire:model="systemPassword"
:label="__('System Upload Password')"
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
autocomplete="new-password"
/>
@if ($hasSystemPassword)
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
@endif
</x-stack>
</x-stack>
</x-card>
{{-- How the upload page offers random share passwords (App\Services\PasswordGeneratorService).
The example is drawn from the form as it stands, before saving. --}}
<x-card :title="__('Share Passwords')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-group
wire:model.live="passwordGeneratorMode"
:label="__('Password generator')"
:hint="match ($passwordGeneratorMode) {
'off' => __('Uploaders type a password themselves.'),
'prefill' => __('A random password is filled in as soon as Password protect is switched on. Generate draws a new one.'),
default => __('A Generate button under the password field fills in a random password.'),
}"
:options="[
['id' => 'off', 'name' => __('Off')],
['id' => 'button', 'name' => __('On request')],
['id' => 'prefill', 'name' => __('Prefilled')],
]"
/>
@if ($passwordGeneratorMode !== 'off')
<x-group
wire:model.live="passwordGeneratorType"
:label="__('Kind')"
:hint="$passwordGeneratorType === 'passphrase' ? __('Random words, easy to read out or type on a phone.') : __('Random characters, the most secure for their length.')"
:options="[
['id' => 'characters', 'name' => __('Characters')],
['id' => 'passphrase', 'name' => __('Passphrase')],
]"
/>
@if ($passwordGeneratorType === 'passphrase')
<x-input full
wire:model.live.blur="passphraseWords"
:label="__('Words')"
type="number"
:min="\App\Services\PasswordGeneratorService::MIN_WORDS"
:max="\App\Services\PasswordGeneratorService::MAX_WORDS"
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_WORDS, 'max' => \App\Services\PasswordGeneratorService::MAX_WORDS])"
/>
<x-select full
wire:model.live="passphraseSeparator"
:label="__('Separator')"
:options="[
['id' => 'hyphen', 'name' => __('Hyphen (-)')],
['id' => 'dot', 'name' => __('Dot (.)')],
['id' => 'underscore', 'name' => __('Underscore (_)')],
['id' => 'space', 'name' => __('Space')],
]"
/>
@if ($siteLogo && is_object($siteLogo))
@if (str_contains($siteLogo->getMimeType(), 'svg'))
<p class="md-type-body-md md-ink-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
@else
<x-input full
wire:model.live.blur="passwordLength"
:label="__('Length')"
type="number"
:min="\App\Services\PasswordGeneratorService::MIN_LENGTH"
:max="\App\Services\PasswordGeneratorService::MAX_LENGTH"
:suffix="__('characters')"
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_LENGTH, 'max' => \App\Services\PasswordGeneratorService::MAX_LENGTH])"
/>
<x-group
multiple
wire:model.live="passwordCharacterSets"
:label="__('Include')"
:hint="__('Uppercase letters, lowercase letters, numbers and symbols.')"
:options="[
['id' => 'uppercase', 'name' => 'AZ'],
['id' => 'lowercase', 'name' => 'az'],
['id' => 'numbers', 'name' => '09'],
['id' => 'symbols', 'name' => '#$%'],
]"
/>
<x-checkbox
wire:model.live="passwordAvoidAmbiguous"
:label="__('Avoid look-alike characters')"
:hint="__('Leaves out 0, O, 1, l and I.')"
/>
@endif
@if ($passwordExample)
<x-input full
:label="__('Example')"
:value="$passwordExample"
:hint="__('About :bits bits of entropy.', ['bits' => $passwordEntropy])"
readonly
mono
data-test="password-example"
/>
<x-stack gap="space50">
<p class="md-type-label-lg md-ink-variant">{{ __('Preview:') }}</p>
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="admin-settings-logo" />
</x-stack>
@endif
@endif
</x-stack>
</x-card>
</x-stack>
</x-card>
<x-card :title="__('Upload Limits')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-toggle
wire:model.live="allowNeverExpire"
:label="__('Allow shares to never expire')"
:hint="__('When disabled, users must select an expiration time.')"
right
<x-card :title="__('Upload Protection')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-stack gap="space100">
<x-password full
wire:model="systemPassword"
:label="__('System Upload Password')"
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
autocomplete="new-password"
/>
<x-select full
wire:model="defaultExpiration"
:label="__('Default Expiration')"
:placeholder="$allowNeverExpire ? __('None') : null"
@if ($hasSystemPassword)
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
@endif
</x-stack>
</x-stack>
</x-card>
{{-- How the upload page offers random share passwords (App\Services\PasswordGeneratorService).
The example is drawn from the form as it stands, before saving. --}}
<x-card :title="__('Share Passwords')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-group
wire:model.live="passwordGeneratorMode"
:label="__('Password generator')"
:hint="match ($passwordGeneratorMode) {
'off' => __('Uploaders type a password themselves.'),
'prefill' => __('A random password is filled in as soon as Password protect is switched on. Generate draws a new one.'),
default => __('A Generate button under the password field fills in a random password.'),
}"
:options="[
['id' => 'off', 'name' => __('Off')],
['id' => 'button', 'name' => __('On request')],
['id' => 'prefill', 'name' => __('Prefilled')],
]"
/>
@if ($passwordGeneratorMode !== 'off')
<x-group
wire:model.live="passwordGeneratorType"
:label="__('Kind')"
:hint="$passwordGeneratorType === 'passphrase' ? __('Random words, easy to read out or type on a phone.') : __('Random characters, the most secure for their length.')"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
['id' => '24h', 'name' => __('24 Hours')],
['id' => '48h', 'name' => __('48 Hours')],
['id' => '7d', 'name' => __('7 Days')],
['id' => '14d', 'name' => __('14 Days')],
['id' => '30d', 'name' => __('30 Days')],
['id' => 'characters', 'name' => __('Characters')],
['id' => 'passphrase', 'name' => __('Passphrase')],
]"
/>
<x-input full
wire:model="maxFileSize"
:label="__('Max file size (MB)')"
type="number"
min="1"
suffix="MB"
/>
@if ($passwordGeneratorType === 'passphrase')
<x-input full
wire:model.live.blur="passphraseWords"
:label="__('Words')"
type="number"
:min="\App\Services\PasswordGeneratorService::MIN_WORDS"
:max="\App\Services\PasswordGeneratorService::MAX_WORDS"
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_WORDS, 'max' => \App\Services\PasswordGeneratorService::MAX_WORDS])"
/>
<x-input full wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
<x-select full
wire:model.live="passphraseSeparator"
:label="__('Separator')"
:options="[
['id' => 'hyphen', 'name' => __('Hyphen (-)')],
['id' => 'dot', 'name' => __('Dot (.)')],
['id' => 'underscore', 'name' => __('Underscore (_)')],
['id' => 'space', 'name' => __('Space')],
]"
/>
@else
<x-input full
wire:model.live.blur="passwordLength"
:label="__('Length')"
type="number"
:min="\App\Services\PasswordGeneratorService::MIN_LENGTH"
:max="\App\Services\PasswordGeneratorService::MAX_LENGTH"
:suffix="__('characters')"
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_LENGTH, 'max' => \App\Services\PasswordGeneratorService::MAX_LENGTH])"
/>
<x-input full wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
</x-stack>
</x-card>
<x-group
multiple
wire:model.live="passwordCharacterSets"
:label="__('Include')"
:hint="__('Uppercase letters, lowercase letters, numbers and symbols.')"
:options="[
['id' => 'uppercase', 'name' => 'AZ'],
['id' => 'lowercase', 'name' => 'az'],
['id' => 'numbers', 'name' => '09'],
['id' => 'symbols', 'name' => '#$%'],
]"
/>
<x-card :title="__('Storage')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-input full
wire:model="maxStorageQuota"
:label="__('Max storage quota (GB)')"
type="number"
min="1"
suffix="GB"
:hint="__('When reached, new uploads are blocked.')"
/>
</x-stack>
</x-card>
<x-checkbox
wire:model.live="passwordAvoidAmbiguous"
:label="__('Avoid look-alike characters')"
:hint="__('Leaves out 0, O, 1, l and I.')"
/>
@endif
<x-slot:actions>
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" data-test="save-settings" />
</x-slot:actions>
</x-form>
@if ($passwordExample)
<x-input full
:label="__('Example')"
:value="$passwordExample"
:hint="__('About :bits bits of entropy.', ['bits' => $passwordEntropy])"
readonly
mono
data-test="password-example"
/>
@endif
@endif
</x-stack>
</x-card>
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
{{ __('The upload and download pages show the default mark again.') }}
<x-card :title="__('Upload Limits')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-toggle
wire:model.live="allowNeverExpire"
:label="__('Allow shares to never expire')"
:hint="__('When disabled, users must select an expiration time.')"
right
/>
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button :label="__('Remove')" danger wire:click="removeLogo" data-test="confirm-remove-logo" />
</x-slot:actions>
</x-modal>
<x-select full
wire:model="defaultExpiration"
:label="__('Default Expiration')"
:placeholder="$allowNeverExpire ? __('None') : null"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
['id' => '24h', 'name' => __('24 Hours')],
['id' => '48h', 'name' => __('48 Hours')],
['id' => '7d', 'name' => __('7 Days')],
['id' => '14d', 'name' => __('14 Days')],
['id' => '30d', 'name' => __('30 Days')],
]"
/>
<x-modal wire:model="confirmingPasswordRemoval" :title="__('Remove the system password?')" icon="lock_open">
{{ __('Anyone who can reach the upload page can upload files again.') }}
<x-input full
wire:model="maxFileSize"
:label="__('Max file size (MB)')"
type="number"
min="1"
suffix="MB"
/>
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button :label="__('Remove')" danger wire:click="clearSystemPassword" data-test="confirm-clear-system-password" />
</x-slot:actions>
</x-modal>
</x-stack>
</x-pane>
<x-input full wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
<x-input full wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
</x-stack>
</x-card>
<x-card :title="__('Storage')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-input full
wire:model="maxStorageQuota"
:label="__('Max storage quota (GB)')"
type="number"
min="1"
suffix="GB"
:hint="__('When reached, new uploads are blocked.')"
/>
</x-stack>
</x-card>
<x-slot:actions>
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" data-test="save-settings" />
</x-slot:actions>
</x-form>
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
{{ __('The upload, download and sign-in pages show only the site title.') }}
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button :label="__('Remove')" danger wire:click="removeLogo" data-test="confirm-remove-logo" />
</x-slot:actions>
</x-modal>
<x-modal wire:model="confirmingPasswordRemoval" :title="__('Remove the system password?')" icon="lock_open">
{{ __('Anyone who can reach the upload page can upload files again.') }}
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button :label="__('Remove')" danger wire:click="clearSystemPassword" data-test="confirm-clear-system-password" />
</x-slot:actions>
</x-modal>
</x-page>
+156 -170
View File
@@ -1,178 +1,164 @@
<x-pane class="upload-column">
<x-stack gap="space400">
<x-stack align="center" gap="space200">
@if ($siteLogo)
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="upload-site-logo" />
<x-page brand>
{{-- Files this page already uploaded count towards the quota: they can still become a share. --}}
@if ($isStorageFull && $pendingFiles->isEmpty())
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
@else
<x-form
wire:submit="createShare"
x-data="shareUploader({
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
messages: {{ \Illuminate\Support\Js::from([
'queued' => __('Waiting'),
'uploaded' => __('Uploaded'),
'failed' => __('Upload failed'),
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
]) }},
})"
x-on:beforeunload.window="warnBeforeLeaving($event)"
>
{{-- WebCrypto, which encrypts the files in the browser, only exists on HTTPS (or localhost). --}}
<div x-show="! secure" x-cloak data-test="insecure-context">
<x-alert color="warning" :title="__('Uploads need a secure connection (HTTPS).')" :description="__('Ask the administrator to serve this site over HTTPS.')" />
</div>
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
<div
class="upload-drop-zone"
x-bind:data-dragging="dragging ? 'true' : 'false'"
x-bind:aria-disabled="secure ? 'false' : 'true'"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="handleDrop($event)"
data-test="drop-zone"
>
<x-stack align="center" gap="space200">
<div class="upload-drop-shapes">
<x-shape name="cookie-9" class="upload-drop-shape upload-drop-shape--idle" />
<x-shape name="soft-burst" class="upload-drop-shape upload-drop-shape--burst" data-test="drop-zone-burst" />
<x-icon name="upload" size="48" class="upload-drop-icon" />
</div>
<x-stack align="center" gap="space50">
<p class="md-type-title-md md-text-center">{{ __('Drag & drop files or folders here') }}</p>
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
</x-stack>
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}}
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
</x-stack>
</div>
{{-- Upload progress, over every file still to send --}}
<div x-show="busy" x-cloak data-test="upload-progress">
<x-stack gap="space100">
<x-row justify="between">
<span class="md-type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
<x-button :label="__('Cancel')" size="xs" x-on:click="cancel()" />
</x-row>
<x-progress bind="progress" wavy :label="__('Uploading')" />
</x-stack>
</div>
@error('files')
<x-alert color="error">{{ $message }}</x-alert>
@enderror
{{-- Selected files --}}
@if ($pendingFiles->isNotEmpty())
<x-stack gap="space100">
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ $pendingFiles->count() }})</h2>
<div class="upload-file-list">
<x-list segmented :label="__('Selected Files')">
@foreach ($pendingFiles as $file)
<x-list-item
:title="$file->relative_path ?? $file->original_name"
icon="description"
wire:key="selected-file-{{ $file->id }}"
data-test="selected-file"
>
<x-slot:description>
<span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span>
· <span class="md-tabular" x-text="statusOf({{ $file->id }}, {{ $file->completed_at ? 'true' : 'false' }})" data-test="file-status"></span>
</x-slot:description>
<x-slot:end>
<span x-show="uploads[{{ $file->id }}]?.state === 'failed'" x-cloak>
<x-button icon="refresh" :aria-label="__('Retry')" x-on:click="retry({{ $file->id }})" />
</span>
<x-button icon="close" :aria-label="__('Remove')" x-on:click="remove({{ $file->id }})" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
</div>
</x-stack>
@endif
<x-stack align="center" gap="space100">
<h1 class="md-type-headline-lg md-text-center">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
{{-- Options --}}
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
<p class="md-type-body-lg md-ink-variant md-text-center">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</x-stack>
</x-stack>
@if ($usePassword)
<x-stack gap="space100">
<x-password full wire:model="password" :label="__('Password')" autocomplete="new-password" />
{{-- Files this page already uploaded count towards the quota: they can still become a share. --}}
@if ($isStorageFull && $pendingFiles->isEmpty())
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
@else
<x-form
wire:submit="createShare"
x-data="shareUploader({
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
messages: {{ \Illuminate\Support\Js::from([
'queued' => __('Waiting'),
'uploaded' => __('Uploaded'),
'failed' => __('Upload failed'),
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
]) }},
})"
x-on:beforeunload.window="warnBeforeLeaving($event)"
>
{{-- WebCrypto, which encrypts the files in the browser, only exists on HTTPS (or localhost). --}}
<div x-show="! secure" x-cloak data-test="insecure-context">
<x-alert color="warning" :title="__('Uploads need a secure connection (HTTPS).')" :description="__('Ask the administrator to serve this site over HTTPS.')" />
</div>
{{-- Generate draws one as Admin settings say (App\Services\PasswordGeneratorService); Copy takes
whatever is in the field, typed or generated, with the snackbar a copyable field shows. --}}
<x-row gap="space100" wrap>
@if ($passwordGeneratorMode !== 'off')
<x-button :label="__('Generate')" icon="password" variant="tonal" wire:click="generatePassword" spinner="generatePassword" data-test="generate-password" />
@endif
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
<div
class="upload-drop-zone"
x-bind:data-dragging="dragging ? 'true' : 'false'"
x-bind:aria-disabled="secure ? 'false' : 'true'"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="handleDrop($event)"
data-test="drop-zone"
>
<x-stack align="center" gap="space200">
<div class="upload-drop-shapes">
<x-shape name="cookie-9" class="upload-drop-shape upload-drop-shape--idle" />
<x-shape name="soft-burst" class="upload-drop-shape upload-drop-shape--burst" data-test="drop-zone-burst" />
<x-icon name="upload" size="48" class="upload-drop-icon" />
</div>
<x-stack align="center" gap="space50">
<p class="md-type-title-md md-text-center">{{ __('Drag & drop files or folders here') }}</p>
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
<x-button
:label="__('Copy')"
icon="content_copy"
variant="tonal"
x-on:click="navigator.clipboard.writeText($wire.password).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
x-bind:disabled="! $wire.password"
data-test="copy-password"
/>
</x-row>
</x-stack>
@endif
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}}
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
</x-stack>
</div>
{{-- Upload progress, over every file still to send --}}
<div x-show="busy" x-cloak data-test="upload-progress">
<x-stack gap="space100">
<x-row justify="between">
<span class="md-type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
<x-button :label="__('Cancel')" size="xs" x-on:click="cancel()" />
</x-row>
<x-progress bind="progress" wavy :label="__('Uploading')" />
</x-stack>
</div>
@error('files')
<x-alert color="error">{{ $message }}</x-alert>
@enderror
{{-- Selected files --}}
@if ($pendingFiles->isNotEmpty())
<x-stack gap="space100">
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ $pendingFiles->count() }})</h2>
<div class="upload-file-list">
<x-list segmented :label="__('Selected Files')">
@foreach ($pendingFiles as $file)
<x-list-item
:title="$file->relative_path ?? $file->original_name"
icon="description"
wire:key="selected-file-{{ $file->id }}"
data-test="selected-file"
>
<x-slot:description>
<span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span>
· <span class="md-tabular" x-text="statusOf({{ $file->id }}, {{ $file->completed_at ? 'true' : 'false' }})" data-test="file-status"></span>
</x-slot:description>
<x-slot:end>
<span x-show="uploads[{{ $file->id }}]?.state === 'failed'" x-cloak>
<x-button icon="refresh" :aria-label="__('Retry')" x-on:click="retry({{ $file->id }})" />
</span>
<x-button icon="close" :aria-label="__('Remove')" x-on:click="remove({{ $file->id }})" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
</div>
</x-stack>
@endif
{{-- Options --}}
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
@if ($usePassword)
<x-stack gap="space100">
<x-password full wire:model="password" :label="__('Password')" autocomplete="new-password" />
{{-- Generate draws one as Admin settings say (App\Services\PasswordGeneratorService); Copy takes
whatever is in the field, typed or generated, with the snackbar a copyable field shows. --}}
<x-row gap="space100" wrap>
@if ($passwordGeneratorMode !== 'off')
<x-button :label="__('Generate')" icon="password" variant="tonal" wire:click="generatePassword" spinner="generatePassword" data-test="generate-password" />
@endif
<x-button
:label="__('Copy')"
icon="content_copy"
variant="tonal"
x-on:click="navigator.clipboard.writeText($wire.password).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
x-bind:disabled="! $wire.password"
data-test="copy-password"
/>
</x-row>
</x-stack>
@endif
<x-select full
wire:model="expiration"
:label="__('Expiration')"
:placeholder="$allowNeverExpire ? __('Never') : null"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
['id' => '24h', 'name' => __('24 Hours')],
['id' => '48h', 'name' => __('48 Hours')],
['id' => '7d', 'name' => __('7 Days')],
['id' => '14d', 'name' => __('14 Days')],
['id' => '30d', 'name' => __('30 Days')],
]"
/>
<x-input full
wire:model="maxDownloads"
:label="__('Max downloads')"
type="number"
min="1"
:placeholder="__('Unlimited')"
/>
</x-stack>
</x-card>
<x-slot:actions>
<x-button
type="submit"
:label="__('Create Share Link')"
variant="filled"
size="md"
icon="link"
spinner="createShare"
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }}"
data-test="create-share"
<x-select full
wire:model="expiration"
:label="__('Expiration')"
:placeholder="$allowNeverExpire ? __('Never') : null"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
['id' => '24h', 'name' => __('24 Hours')],
['id' => '48h', 'name' => __('48 Hours')],
['id' => '7d', 'name' => __('7 Days')],
['id' => '14d', 'name' => __('14 Days')],
['id' => '30d', 'name' => __('30 Days')],
]"
/>
</x-slot:actions>
</x-form>
@endif
</x-stack>
</x-pane>
<x-input full
wire:model="maxDownloads"
:label="__('Max downloads')"
type="number"
min="1"
:placeholder="__('Unlimited')"
/>
</x-stack>
</x-card>
<x-slot:actions>
<x-button
type="submit"
:label="__('Create Share Link')"
variant="filled"
size="md"
icon="link"
spinner="createShare"
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }}"
data-test="create-share"
/>
</x-slot:actions>
</x-form>
@endif
</x-page>
+38 -38
View File
@@ -1,42 +1,42 @@
<x-stack gap="space300">
<x-auth-header :title="__('Setup SealShare')" :description="__('Create your admin account to get started')" />
<x-page brand>
<x-card :title="__('Set up SealShare')" :subtitle="__('Create your admin account to get started')" heading="h2" variant="outlined">
<x-form wire:submit="createAdmin">
<x-input
wire:model="name"
:label="__('Name')"
type="text"
required
autofocus
:placeholder="__('Admin name')"
icon="person"
/>
<x-form wire:submit="createAdmin">
<x-input
wire:model="name"
:label="__('Name')"
type="text"
required
autofocus
:placeholder="__('Admin name')"
icon="person"
/>
<x-input
wire:model="email"
:label="__('Email address')"
type="email"
required
placeholder="admin@example.com"
icon="mail"
/>
<x-input
wire:model="email"
:label="__('Email address')"
type="email"
required
placeholder="admin@example.com"
icon="mail"
/>
<x-password
wire:model="password"
:label="__('Password')"
required
:placeholder="__('Password')"
/>
<x-password
wire:model="password"
:label="__('Password')"
required
:placeholder="__('Password')"
/>
<x-password
wire:model="password_confirmation"
:label="__('Confirm password')"
required
:placeholder="__('Confirm password')"
/>
<x-password
wire:model="password_confirmation"
:label="__('Confirm password')"
required
:placeholder="__('Confirm password')"
/>
<x-slot:actions>
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" spinner="createAdmin" />
</x-slot:actions>
</x-form>
</x-stack>
<x-slot:actions>
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" spinner="createAdmin" />
</x-slot:actions>
</x-form>
</x-card>
</x-page>
@@ -1,97 +1,90 @@
<x-pane class="share-column">
<x-stack gap="space400">
<x-stack align="center" gap="space200">
{{-- The link is ready: a check on an Expressive shape that settles in (share-ready, app.css). --}}
<div class="share-check">
<x-shape name="soft-burst" class="share-check-shape" />
<x-icon name="check" size="48" class="share-check-icon" />
</div>
<x-page :title="__('Share Created!')" :description="__('Your files are ready to share')">
{{-- The link is ready: a check on an Expressive shape that settles in (share-ready, app.css). --}}
<x-slot:mark>
<div class="share-check">
<x-shape name="soft-burst" class="share-check-shape" />
<x-icon name="check" size="48" class="share-check-icon" />
</div>
</x-slot:mark>
<x-stack align="center" gap="space50">
<h1 class="md-type-headline-md md-text-center">{{ __('Share Created!') }}</h1>
<p class="md-type-body-lg md-ink-variant md-text-center">{{ __('Your files are ready to share') }}</p>
</x-stack>
</x-stack>
<x-stack gap="space200">
{{-- Besides the link: a QR code in a dialog, saved as a PNG in the browser, and the device's
share sheet where there is one (resources/js/share-created.js). Both carry the link only. --}}
<x-stack
gap="space100"
x-data="shareActions({
url: {{ \Illuminate\Support\Js::from($shareUrl) }},
title: {{ \Illuminate\Support\Js::from($siteTitle) }},
filename: {{ \Illuminate\Support\Js::from('share-'.$share->token.'.png') }},
messages: {{ \Illuminate\Support\Js::from(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) }},
})"
data-test="share-actions"
>
<x-input
:label="__('Share Link')"
:value="$shareUrl"
readonly
copyable
mono
icon="link"
data-test="share-link"
/>
<x-stack gap="space200">
{{-- Besides the link: a QR code in a dialog, saved as a PNG in the browser, and the device's
share sheet where there is one (resources/js/share-created.js). Both carry the link only. --}}
<x-stack
gap="space100"
x-data="shareActions({
url: {{ \Illuminate\Support\Js::from($shareUrl) }},
title: {{ \Illuminate\Support\Js::from($siteTitle) }},
filename: {{ \Illuminate\Support\Js::from('share-'.$share->token.'.png') }},
messages: {{ \Illuminate\Support\Js::from(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) }},
})"
data-test="share-actions"
>
{{-- Only on the visit the upload redirects to: the password is flashed once (FileUploader::createShare).
Masked, with the link's copy button at its end, so it is copied without reaching the screen. --}}
@if ($password)
<x-input
:label="__('Share Link')"
:value="$shareUrl"
type="password"
:label="__('Password')"
:value="$password"
:hint="__('Available only this once. Send it separately from the link.')"
readonly
copyable
mono
icon="link"
data-test="share-link"
icon="key"
autocomplete="off"
data-test="share-password"
/>
{{-- Only on the visit the upload redirects to: the password is flashed once (FileUploader::createShare).
Masked, with the link's copy button at its end, so it is copied without reaching the screen. --}}
@if ($password)
<x-input
type="password"
:label="__('Password')"
:value="$password"
:hint="__('Available only this once. Send it separately from the link.')"
readonly
copyable
icon="key"
autocomplete="off"
data-test="share-password"
/>
@endif
<x-row gap="space100" wrap>
<x-button :label="__('Show QR code')" icon="qr_code_2" variant="tonal" x-on:click="open = true" data-test="show-qr-code" />
<span x-show="canShare" x-cloak>
<x-button :label="__('Share…')" icon="share" variant="tonal" x-on:click="share()" data-test="share-sheet" />
</span>
</x-row>
<x-modal fullscreen :title="__('Scan to open the share')" data-test="qr-code-dialog">
<x-stack gap="space200">
{{-- The quiet zone is baked into the SVG (App\Services\QrCodeService), white in
either theme so a scanner keeps its contrast; the container adds no colour. --}}
<div data-qr-code class="share-qr">{!! $qrCodeSvg !!}</div>
@if ($share->isPasswordProtected())
<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />
@endif
</x-stack>
<x-slot:actions>
<x-button :label="__('Close')" x-on:click="close()" />
<x-button :label="__('Download')" icon="download" variant="tonal" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />
</x-slot:actions>
</x-modal>
</x-stack>
<x-grid :columns="2" gap="space200">
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
<x-stat :title="__('Total Size')" :value="Number::fileSize($share->total_size)" icon="hard_drive" />
<x-stat :title="__('Expires')" :value="$share->expires_at ? $share->expires_at->diffForHumans() : __('Never')" icon="schedule" />
<x-stat :title="__('Max Downloads')" :value="$share->max_downloads ?? __('Unlimited')" icon="download" />
</x-grid>
@if ($share->isPasswordProtected())
<x-alert color="info" icon="lock" :title="__('This share is password protected')" />
@endif
<x-row justify="end">
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
<x-row gap="space100" wrap>
<x-button :label="__('Show QR code')" icon="qr_code_2" variant="tonal" x-on:click="open = true" data-test="show-qr-code" />
<span x-show="canShare" x-cloak>
<x-button :label="__('Share…')" icon="share" variant="tonal" x-on:click="share()" data-test="share-sheet" />
</span>
</x-row>
<x-modal fullscreen :title="__('Scan to open the share')" data-test="qr-code-dialog">
<x-stack gap="space200">
{{-- The quiet zone is baked into the SVG (App\Services\QrCodeService), white in
either theme so a scanner keeps its contrast; the container adds no colour. --}}
<div data-qr-code class="share-qr">{!! $qrCodeSvg !!}</div>
@if ($share->isPasswordProtected())
<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />
@endif
</x-stack>
<x-slot:actions>
<x-button :label="__('Close')" x-on:click="close()" />
<x-button :label="__('Download')" icon="download" variant="tonal" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />
</x-slot:actions>
</x-modal>
</x-stack>
<x-grid :columns="2" gap="space200">
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
<x-stat :title="__('Total Size')" :value="Number::fileSize($share->total_size)" icon="hard_drive" />
<x-stat :title="__('Expires')" :value="$share->expires_at ? $share->expires_at->diffForHumans() : __('Never')" icon="schedule" />
<x-stat :title="__('Max Downloads')" :value="$share->max_downloads ?? __('Unlimited')" icon="download" />
</x-grid>
@if ($share->isPasswordProtected())
<x-alert color="info" icon="lock" :title="__('This share is password protected')" />
@endif
<x-row justify="end">
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
</x-row>
</x-stack>
</x-pane>
</x-page>
@@ -1,71 +1,58 @@
{{-- The page a recipient opens. No anchored components (menus, tooltips) on it: it has to work on
iOS before Safari 18.4, which cannot position them. --}}
<x-pane class="download-column">
<x-stack gap="space400">
<x-stack align="center" gap="space200">
@if ($siteLogo)
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="download-site-logo" />
@endif
<x-page brand>
{{-- Each state is one card under the page's h1: the card holds everything the recipient acts
on, and it is the shape SealShare has always shown them. --}}
@if (! $authenticated)
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" heading="h2" variant="outlined">
<x-form wire:submit="verifyPassword">
<x-password
full
wire:model="password"
:label="__('Password')"
required
autocomplete="off"
:placeholder="__('Enter share password')"
/>
<x-stack align="center" gap="space100">
<h1 class="md-type-headline-lg md-text-center">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<p class="md-type-body-lg md-ink-variant md-text-center">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</x-stack>
</x-stack>
<x-slot:actions>
<x-button type="submit" :label="__('Unlock')" variant="filled" icon="lock_open" spinner="verifyPassword" />
</x-slot:actions>
</x-form>
</x-card>
@else
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-stack gap="space100">
<x-list :label="__('Shared Files')">
@foreach ($share->files as $file)
<x-list-item
:title="$file->relative_path ?: $file->original_name"
icon="description"
wire:key="file-{{ $file->id }}"
>
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
<x-slot:end>
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
{{-- Each state is one card under the page's h1: the card holds everything the recipient acts
on, and it is the shape SealShare has always shown them. --}}
@if (! $authenticated)
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" heading="h2" variant="outlined">
<x-form wire:submit="verifyPassword">
<x-password
full
wire:model="password"
:label="__('Password')"
required
autocomplete="off"
:placeholder="__('Enter share password')"
/>
<x-slot:actions>
<x-button type="submit" :label="__('Unlock')" variant="filled" icon="lock_open" spinner="verifyPassword" />
</x-slot:actions>
</x-form>
</x-card>
@else
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-stack gap="space100">
<x-list :label="__('Shared Files')">
@foreach ($share->files as $file)
<x-list-item
:title="$file->relative_path ?: $file->original_name"
icon="description"
wire:key="file-{{ $file->id }}"
>
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
<x-slot:end>
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
@if ($share->expires_at)
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
@endif
</x-stack>
<x-row justify="end">
@if ($share->files->count() > 1)
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate />
@else
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate />
@endif
</x-row>
@if ($share->expires_at)
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
@endif
</x-stack>
</x-card>
@endif
</x-stack>
</x-pane>
<x-row justify="end">
@if ($share->files->count() > 1)
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate />
@else
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate />
@endif
</x-row>
</x-stack>
</x-card>
@endif
</x-page>
@@ -1,16 +1,16 @@
<x-stack gap="space300">
<x-auth-header :title="__('System Password Required')" :description="__('Enter the system password to access the upload page')" />
<x-page brand>
<x-card :title="__('System password required')" :subtitle="__('Enter the system password to access the upload page')" heading="h2" variant="outlined">
<x-form wire:submit="verify">
<x-password
wire:model="password"
:label="__('Password')"
required
:placeholder="__('System password')"
/>
<x-form wire:submit="verify">
<x-password
wire:model="password"
:label="__('Password')"
required
:placeholder="__('System password')"
/>
<x-slot:actions>
<x-button type="submit" :label="__('Continue')" variant="filled" spinner="verify" />
</x-slot:actions>
</x-form>
</x-stack>
<x-slot:actions>
<x-button type="submit" :label="__('Continue')" variant="filled" spinner="verify" />
</x-slot:actions>
</x-form>
</x-card>
</x-page>
@@ -1,24 +1,30 @@
<x-layouts::auth :title="__('Confirm password')">
<x-auth-header
:title="__('Confirm password')"
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
/>
<x-layouts::app :title="__('Confirm password')">
<x-page brand>
<x-card
:title="__('Confirm password')"
:subtitle="__('This is a secure area of the application. Please confirm your password before continuing.')"
heading="h2"
variant="outlined"
>
<x-stack gap="space300">
<x-auth-session-status :status="session('status')" />
<x-auth-session-status :status="session('status')" />
<x-form method="POST" action="{{ route('password.confirm.store') }}">
@csrf
<x-form method="POST" action="{{ route('password.confirm.store') }}">
@csrf
<x-password
name="password"
:label="__('Password')"
required
autofocus
autocomplete="current-password"
/>
<x-password
name="password"
:label="__('Password')"
required
autofocus
autocomplete="current-password"
/>
<x-slot:actions>
<x-button type="submit" :label="__('Confirm')" variant="filled" data-test="confirm-password-button" />
</x-slot:actions>
</x-form>
</x-layouts::auth>
<x-slot:actions>
<x-button type="submit" :label="__('Confirm')" variant="filled" data-test="confirm-password-button" />
</x-slot:actions>
</x-form>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>
@@ -1,29 +1,33 @@
<x-layouts::auth :title="__('Forgot password')">
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
<x-layouts::app :title="__('Forgot password')">
<x-page brand>
<x-card :title="__('Forgot password')" :subtitle="__('Enter your email to receive a password reset link')" heading="h2" variant="outlined">
<x-stack gap="space300">
<x-auth-session-status :status="session('status')" />
<x-auth-session-status :status="session('status')" />
<x-form method="POST" action="{{ route('password.email') }}">
@csrf
<x-form method="POST" action="{{ route('password.email') }}">
@csrf
<x-input
name="email"
:label="__('Email Address')"
:value="old('email')"
type="email"
required
autofocus
placeholder="email@example.com"
icon="mail"
/>
<x-input
name="email"
:label="__('Email Address')"
:value="old('email')"
type="email"
required
autofocus
placeholder="email@example.com"
icon="mail"
/>
<x-slot:actions>
<x-button type="submit" :label="__('Email password reset link')" variant="filled" data-test="email-password-reset-link-button" />
</x-slot:actions>
</x-form>
<x-slot:actions>
<x-button type="submit" :label="__('Email password reset link')" variant="filled" data-test="email-password-reset-link-button" />
</x-slot:actions>
</x-form>
<p class="md-type-body-md md-ink-variant md-text-center">
{{ __('Or, return to') }}
<a href="{{ route('login') }}" class="md-link" wire:navigate>{{ __('log in') }}</a>
</p>
</x-layouts::auth>
<p class="md-type-body-md md-ink-variant md-text-center">
{{ __('Or, return to') }}
<a href="{{ route('login') }}" class="md-link" wire:navigate>{{ __('log in') }}</a>
</p>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>
+42 -38
View File
@@ -1,44 +1,48 @@
<x-layouts::auth :title="__('Log in')">
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
<x-layouts::app :title="__('Log in')">
<x-page brand>
<x-card :title="__('Log in')" :subtitle="__('Enter your email and password below to log in')" heading="h2" variant="outlined">
<x-stack gap="space300">
<x-auth-session-status :status="session('status')" />
<x-auth-session-status :status="session('status')" />
<x-form method="POST" action="{{ route('login.store') }}">
@csrf
<x-form method="POST" action="{{ route('login.store') }}">
@csrf
<x-input
name="email"
:label="__('Email address')"
:value="old('email')"
type="email"
required
autofocus
autocomplete="email"
placeholder="email@example.com"
icon="mail"
/>
<x-input
name="email"
:label="__('Email address')"
:value="old('email')"
type="email"
required
autofocus
autocomplete="email"
placeholder="email@example.com"
icon="mail"
/>
<x-stack gap="space50">
<x-password
name="password"
:label="__('Password')"
required
autocomplete="current-password"
/>
<x-stack gap="space50">
<x-password
name="password"
:label="__('Password')"
required
autocomplete="current-password"
/>
@if (Route::has('password.request'))
<x-row justify="end">
<a class="md-link md-type-label-lg" href="{{ route('password.request') }}" wire:navigate>
{{ __('Forgot your password?') }}
</a>
</x-row>
@endif
</x-stack>
@if (Route::has('password.request'))
<x-row justify="end">
<a class="md-link md-type-label-lg" href="{{ route('password.request') }}" wire:navigate>
{{ __('Forgot your password?') }}
</a>
</x-row>
@endif
</x-stack>
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
<x-slot:actions>
<x-button type="submit" :label="__('Log in')" variant="filled" data-test="login-button" />
</x-slot:actions>
</x-form>
</x-layouts::auth>
<x-slot:actions>
<x-button type="submit" :label="__('Log in')" variant="filled" data-test="login-button" />
</x-slot:actions>
</x-form>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>
@@ -1,38 +1,42 @@
<x-layouts::auth :title="__('Reset password')">
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
<x-layouts::app :title="__('Reset password')">
<x-page brand>
<x-card :title="__('Reset password')" :subtitle="__('Please enter your new password below')" heading="h2" variant="outlined">
<x-stack gap="space300">
<x-auth-session-status :status="session('status')" />
<x-auth-session-status :status="session('status')" />
<x-form method="POST" action="{{ route('password.update') }}">
@csrf
<input type="hidden" name="token" value="{{ request()->route('token') }}">
<x-form method="POST" action="{{ route('password.update') }}">
@csrf
<input type="hidden" name="token" value="{{ request()->route('token') }}">
<x-input
name="email"
:value="old('email', request('email'))"
:label="__('Email')"
type="email"
required
autocomplete="email"
icon="mail"
/>
<x-input
name="email"
:value="old('email', request('email'))"
:label="__('Email')"
type="email"
required
autocomplete="email"
icon="mail"
/>
<x-password
name="password"
:label="__('Password')"
required
autocomplete="new-password"
/>
<x-password
name="password"
:label="__('Password')"
required
autocomplete="new-password"
/>
<x-password
name="password_confirmation"
:label="__('Confirm password')"
required
autocomplete="new-password"
/>
<x-password
name="password_confirmation"
:label="__('Confirm password')"
required
autocomplete="new-password"
/>
<x-slot:actions>
<x-button type="submit" :label="__('Reset password')" variant="filled" data-test="reset-password-button" />
</x-slot:actions>
</x-form>
</x-layouts::auth>
<x-slot:actions>
<x-button type="submit" :label="__('Reset password')" variant="filled" data-test="reset-password-button" />
</x-slot:actions>
</x-form>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>
@@ -1,67 +1,65 @@
<x-layouts::auth :title="__('Two-factor authentication')">
<x-stack
gap="space300"
x-data="{
showRecoveryInput: {{ \Illuminate\Support\Js::from($errors->has('recovery_code')) }},
toggleInput() {
this.showRecoveryInput = ! this.showRecoveryInput;
$nextTick(() => {
requestAnimationFrame(() => {
(this.showRecoveryInput ? $refs.recovery : $refs.code)?.querySelector('input')?.focus();
});
});
},
}"
>
<div x-show="! showRecoveryInput">
<x-auth-header
:title="__('Authentication Code')"
:description="__('Enter the authentication code provided by your authenticator application.')"
/>
</div>
<x-layouts::app :title="__('Two-factor authentication')">
<x-page brand>
<x-card :title="__('Two-factor authentication')" heading="h2" variant="outlined">
<x-stack
gap="space300"
x-data="{
showRecoveryInput: {{ \Illuminate\Support\Js::from($errors->has('recovery_code')) }},
toggleInput() {
this.showRecoveryInput = ! this.showRecoveryInput;
$nextTick(() => {
requestAnimationFrame(() => {
(this.showRecoveryInput ? $refs.recovery : $refs.code)?.querySelector('input')?.focus();
});
});
},
}"
>
<p class="md-type-body-md md-ink-variant" x-show="! showRecoveryInput">
{{ __('Enter the authentication code provided by your authenticator application.') }}
</p>
<div x-show="showRecoveryInput" x-cloak>
<x-auth-header
:title="__('Recovery Code')"
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
/>
</div>
<p class="md-type-body-md md-ink-variant" x-show="showRecoveryInput" x-cloak>
{{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }}
</p>
<x-form method="POST" action="{{ route('two-factor.login.store') }}">
@csrf
<x-form method="POST" action="{{ route('two-factor.login.store') }}">
@csrf
<div x-ref="code" x-show="! showRecoveryInput">
<x-input
name="code"
:label="__('Code')"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
mono
autofocus
x-bind:disabled="showRecoveryInput"
/>
</div>
<div x-ref="code" x-show="! showRecoveryInput">
<x-input
name="code"
:label="__('Code')"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
mono
autofocus
x-bind:disabled="showRecoveryInput"
/>
</div>
<div x-ref="recovery" x-show="showRecoveryInput" x-cloak>
<x-input
name="recovery_code"
:label="__('Recovery code')"
autocomplete="one-time-code"
mono
x-bind:disabled="! showRecoveryInput"
/>
</div>
<div x-ref="recovery" x-show="showRecoveryInput" x-cloak>
<x-input
name="recovery_code"
:label="__('Recovery code')"
autocomplete="one-time-code"
mono
x-bind:disabled="! showRecoveryInput"
/>
</div>
<x-slot:actions>
<x-button type="submit" :label="__('Continue')" variant="filled" />
</x-slot:actions>
</x-form>
<x-slot:actions>
<x-button type="submit" :label="__('Continue')" variant="filled" />
</x-slot:actions>
</x-form>
<p class="md-type-body-md md-ink-variant md-text-center">
{{ __('or you can') }}
<button type="button" class="md-link" x-show="! showRecoveryInput" x-on:click="toggleInput()">{{ __('login using a recovery code') }}</button>
<button type="button" class="md-link" x-show="showRecoveryInput" x-cloak x-on:click="toggleInput()">{{ __('login using an authentication code') }}</button>
</p>
</x-stack>
</x-layouts::auth>
<p class="md-type-body-md md-ink-variant md-text-center">
{{ __('or you can') }}
<button type="button" class="md-link" x-show="! showRecoveryInput" x-on:click="toggleInput()">{{ __('login using a recovery code') }}</button>
<button type="button" class="md-link" x-show="showRecoveryInput" x-cloak x-on:click="toggleInput()">{{ __('login using an authentication code') }}</button>
</p>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>
@@ -1,28 +1,34 @@
<x-layouts::auth :title="__('Verify email')">
<x-auth-header
:title="__('Verify your email')"
:description="__('Please verify your email address by clicking on the link we just emailed to you.')"
/>
<x-layouts::app :title="__('Verify email')">
<x-page brand>
<x-card
:title="__('Verify your email')"
:subtitle="__('Please verify your email address by clicking on the link we just emailed to you.')"
heading="h2"
variant="outlined"
>
<x-stack gap="space300">
@if (session('status') == 'verification-link-sent')
<x-alert color="success">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</x-alert>
@endif
@if (session('status') == 'verification-link-sent')
<x-alert color="success">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</x-alert>
@endif
<x-stack gap="space100">
<x-form method="POST" action="{{ route('verification.send') }}">
@csrf
<x-slot:actions>
<x-button type="submit" :label="__('Resend verification email')" variant="filled" />
</x-slot:actions>
</x-form>
<x-stack gap="space100">
<x-form method="POST" action="{{ route('verification.send') }}">
@csrf
<x-slot:actions>
<x-button type="submit" :label="__('Resend verification email')" variant="filled" />
</x-slot:actions>
</x-form>
{{-- Log out posts elsewhere, so it is a form of its own; it sits under Resend at the same end
edge, the page's two actions end-aligned below its content. --}}
<x-row as="form" justify="end" method="POST" action="{{ route('logout') }}">
@csrf
<x-button type="submit" :label="__('Log out')" data-test="logout-button" />
</x-row>
</x-stack>
</x-layouts::auth>
{{-- Log out posts elsewhere, so it is a form of its own; it sits under Resend at the same end
edge, the card's two actions end-aligned below its content. --}}
<x-row as="form" justify="end" method="POST" action="{{ route('logout') }}">
@csrf
<x-button type="submit" :label="__('Log out')" data-test="logout-button" />
</x-row>
</x-stack>
</x-stack>
</x-card>
</x-page>
</x-layouts::app>
+10 -10
View File
@@ -11,17 +11,17 @@
$items[] = ['title' => __('Appearance'), 'icon' => 'contrast', 'url' => route('appearance.edit'), 'active' => request()->routeIs('appearance.edit')];
@endphp
<x-stack gap="space400">
<x-section-nav :items="$items" :label="__('Settings')" />
<x-page :title="__('Settings')" :description="__('Manage your profile and account settings')">
<x-slot:navigation>
<x-section-nav :items="$items" :label="__('Settings')" />
</x-slot:navigation>
{{-- Every settings page is a card headed by its own title, as the admin's settings are. A page
with a section that stands apart from that one subject deleting the account, the recovery
codes puts it in `after`, where it becomes a card of its own beside this one. --}}
<x-stack gap="space300" class="settings-column">
<x-card :title="$heading ?? ''" :subtitle="$subheading ?? ''" heading="h2" variant="outlined">
{{ $slot }}
</x-card>
codes puts it in `after`, where it becomes a card of its own under this one. --}}
<x-card :title="$heading ?? ''" :subtitle="$subheading ?? ''" heading="h2" variant="outlined">
{{ $slot }}
</x-card>
{{ $after ?? '' }}
</x-stack>
</x-stack>
{{ $after ?? '' }}
</x-page>
@@ -6,10 +6,6 @@ new class extends Component {
//
}; ?>
<section>
@include('partials.settings-heading')
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
<x-theme-toggle mode="picker" class="settings-appearance-picker" data-test="appearance-picker" />
</x-pages::settings.layout>
</section>
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
<x-theme-toggle mode="picker" class="settings-appearance-picker" data-test="appearance-picker" />
</x-pages::settings.layout>
@@ -43,18 +43,14 @@ new class extends Component {
}
}; ?>
<section>
@include('partials.settings-heading')
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
<x-form method="POST" wire:submit="updatePassword">
<x-password full wire:model="current_password" :label="__('Current password')" required autocomplete="current-password" />
<x-password full wire:model="password" :label="__('New password')" required autocomplete="new-password" />
<x-password full wire:model="password_confirmation" :label="__('Confirm Password')" required autocomplete="new-password" />
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
<x-form method="POST" wire:submit="updatePassword">
<x-password full wire:model="current_password" :label="__('Current password')" required autocomplete="current-password" />
<x-password full wire:model="password" :label="__('New password')" required autocomplete="new-password" />
<x-password full wire:model="password_confirmation" :label="__('Confirm Password')" required autocomplete="new-password" />
<x-slot:actions>
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updatePassword" data-test="update-password-button" />
</x-slot:actions>
</x-form>
</x-pages::settings.layout>
</section>
<x-slot:actions>
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updatePassword" data-test="update-password-button" />
</x-slot:actions>
</x-form>
</x-pages::settings.layout>
@@ -80,40 +80,36 @@ new class extends Component {
}
}; ?>
<section>
@include('partials.settings-heading')
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
<x-form wire:submit="updateProfileInformation">
<x-input full wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
<x-form wire:submit="updateProfileInformation">
<x-input full wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
<x-stack gap="space100">
<x-input full wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
<x-stack gap="space100">
<x-input full wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
@if ($this->hasUnverifiedEmail)
<p class="md-type-body-md md-ink-variant">
{{ __('Your email address is unverified.') }}
@if ($this->hasUnverifiedEmail)
<p class="md-type-body-md md-ink-variant">
{{ __('Your email address is unverified.') }}
<button type="button" class="md-link" wire:click.prevent="resendVerificationNotification">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
<button type="button" class="md-link" wire:click.prevent="resendVerificationNotification">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
@if (session('status') === 'verification-link-sent')
<x-alert color="success">{{ __('A new verification link has been sent to your email address.') }}</x-alert>
@endif
@if (session('status') === 'verification-link-sent')
<x-alert color="success">{{ __('A new verification link has been sent to your email address.') }}</x-alert>
@endif
</x-stack>
<x-slot:actions>
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" />
</x-slot:actions>
</x-form>
<x-slot:after>
@if ($this->showDeleteUser)
<livewire:pages::settings.delete-user-form />
@endif
</x-slot:after>
</x-pages::settings.layout>
</section>
</x-stack>
<x-slot:actions>
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" />
</x-slot:actions>
</x-form>
<x-slot:after>
@if ($this->showDeleteUser)
<livewire:pages::settings.delete-user-form />
@endif
</x-slot:after>
</x-pages::settings.layout>
@@ -178,94 +178,90 @@ new class extends Component {
}
} ?>
<section>
@include('partials.settings-heading')
<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" />
<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>
<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 beside this one. --}}
<x-slot:after>
@if ($twoFactorEnabled)
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
@endif
</x-slot:after>
</x-pages::settings.layout>
<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-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" />
<x-slot:actions>
<x-button
:disabled="$errors->has('setupData')"
:label="$this->modalConfig['buttonText']"
variant="filled"
wire:click="showVerificationIfNecessary"
/>
</x-slot:actions>
<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-modal>
</section>
</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>
@@ -1,4 +0,0 @@
<x-stack gap="space50" class="settings-heading">
<h1 class="md-type-headline-md">{{ __('Settings') }}</h1>
<p class="md-type-body-md md-ink-variant">{{ __('Manage your profile and account settings') }}</p>
</x-stack>