Certified/resources/views/pages/certificates/⚡index.blade.php
Joël van de Wouw ad2a78a989 Replace email-only sharing with secure share pages
Sharing certificates now creates a Share: a public page behind an
unguessable token where the recipient views and downloads the selected
certificates individually or as a ZIP, instead of receiving links or
attachments by email.

- Share model + certificate_share pivot; expiration (1/7/30 days),
  optional password (encrypted so the owner can re-view it), revocation,
  and open tracking
- Public routes under shared/{token}: password unlock gate (throttled,
  session-scoped), per-certificate download, on-the-fly ZIP that is
  AES-256 encrypted when the share has a password, friendly 410 page
  for expired/revoked links
- Share modal on the certificates index now creates the page and
  reveals a copyable link + password; optional transactional email
  (ShareCreatedMail) still sends the link to a recipient
- New Shares page in the sidebar to copy links, look up passwords,
  revoke, and delete shares
- Old signed-URL controller and both attachment/link mailables removed;
  Dutch translations updated; Pest coverage for the full lifecycle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:38:43 +02:00

365 lines
15 KiB
PHP

<?php
use App\Mail\ShareCreatedMail;
use App\Models\Category;
use App\Models\Certificate;
use App\Models\Share;
use Flux\Flux;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new #[Title('Certificates')] class extends Component {
use WithPagination;
private const SORTABLE_COLUMNS = ['title', 'expires_at'];
#[Url(as: 'category')]
public string $categoryFilter = '';
#[Url(as: 'sort')]
public string $sortBy = 'expires_at';
#[Url(as: 'direction')]
public string $sortDirection = 'asc';
/** @var array<int, int> */
public array $selected = [];
public string $shareEmail = '';
public int $shareExpiresInDays = 7;
public bool $sharePasswordProtect = true;
public ?string $createdShareUrl = null;
public ?string $createdSharePassword = null;
public function updatedCategoryFilter(): void
{
$this->resetPage();
}
public function sort(string $column): void
{
if (! in_array($column, self::SORTABLE_COLUMNS, true)) {
return;
}
if ($this->sortBy === $column) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDirection = 'asc';
}
$this->resetPage();
}
public function delete(int $certificateId): void
{
$certificate = Certificate::findOrFail($certificateId);
if (filled($certificate->file_path)) {
Storage::disk('local')->delete($certificate->file_path);
}
$certificate->delete();
Flux::modals()->close();
Flux::toast(variant: 'success', text: __('Certificate deleted.'));
}
public function share(): void
{
$validated = $this->validate([
'shareEmail' => ['nullable', 'email'],
'shareExpiresInDays' => ['required', Rule::in([1, 7, 30])],
'sharePasswordProtect' => ['boolean'],
'selected' => ['required', 'array', 'min:1'],
'selected.*' => ['integer'],
]);
// Throttle emails to protect the mail server from being used as a relay.
if (filled($validated['shareEmail'])) {
$key = 'share-certificates:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, maxAttempts: 10)) {
$this->addError('shareEmail', __('Too many share attempts. Please try again later.'));
return;
}
RateLimiter::hit($key, decaySeconds: 3600);
}
$certificates = Certificate::whereIn('id', $validated['selected'])
->whereNotNull('file_path')
->get();
if ($certificates->isEmpty()) {
$this->addError('selected', __('The selected certificates have no files to share.'));
return;
}
$password = $validated['sharePasswordProtect'] ? Str::password(12, symbols: false) : null;
$share = Share::create([
'token' => Str::random(48),
'recipient_email' => $validated['shareEmail'] ?: null,
'password' => $password,
'expires_at' => now()->addDays($validated['shareExpiresInDays']),
]);
$share->certificates()->attach($certificates->modelKeys());
if (filled($validated['shareEmail'])) {
Mail::to($validated['shareEmail'])->send(new ShareCreatedMail(
senderName: Auth::user()->name,
shareUrl: $share->url(),
expiresAt: $share->expires_at->toDayDateTimeString(),
certificateCount: $certificates->count(),
hasPassword: $password !== null,
));
}
// Show the link (and password) so the user can pass them on; the
// password should travel through a separate channel than the link.
$this->createdShareUrl = $share->url();
$this->createdSharePassword = $password;
$this->reset('selected', 'shareEmail');
Flux::modal('share')->close();
Flux::modal('share-created')->show();
}
public function dismissCreatedShare(): void
{
$this->reset('createdShareUrl', 'createdSharePassword');
}
/**
* @return LengthAwarePaginator<int, Certificate>
*/
#[Computed]
public function certificates(): LengthAwarePaginator
{
$sortBy = in_array($this->sortBy, self::SORTABLE_COLUMNS, true) ? $this->sortBy : 'expires_at';
$sortDirection = $this->sortDirection === 'desc' ? 'desc' : 'asc';
return Certificate::query()
->with('category')
->when($this->categoryFilter !== '', fn ($query) => $query->where('category_id', $this->categoryFilter))
->orderBy($sortBy, $sortDirection)
->paginate(10);
}
/**
* @return Collection<int, Category>
*/
#[Computed]
public function categories(): Collection
{
return Category::orderBy('name')->get();
}
}; ?>
<section class="flex h-full w-full flex-1 flex-col gap-6">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<flux:heading size="xl">{{ __('Certificates') }}</flux:heading>
<flux:text class="mt-1">{{ __('An overview of your certificates and their expiry status.') }}</flux:text>
</div>
<div class="flex items-end gap-3">
<flux:select wire:model.live="categoryFilter" :label="__('Category')" class="min-w-48">
<flux:select.option value="">{{ __('All categories') }}</flux:select.option>
@foreach ($this->categories as $category)
<flux:select.option value="{{ $category->id }}">{{ $category->name }}</flux:select.option>
@endforeach
</flux:select>
<flux:button :href="route('certificates.create')" wire:navigate icon="plus" variant="primary">
{{ __('Add certificate') }}
</flux:button>
</div>
</div>
@if (count($selected) > 0)
<flux:card class="flex items-center justify-between gap-4 rounded-2xl border-emerald-200 bg-emerald-50/60 py-3 dark:border-emerald-500/25 dark:bg-emerald-500/10">
<flux:text>{{ trans_choice(':count certificate selected|:count certificates selected', count($selected), ['count' => count($selected)]) }}</flux:text>
<flux:modal.trigger name="share">
<flux:button icon="share" size="sm">{{ __('Share') }}</flux:button>
</flux:modal.trigger>
</flux:card>
@endif
@if ($this->certificates->isEmpty())
<flux:card class="flex flex-col items-center gap-3 rounded-2xl py-12 text-center">
<span class="flex size-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
<flux:icon.shield-check class="size-6" />
</span>
<flux:heading size="lg">{{ __('No certificates found') }}</flux:heading>
<flux:text>
{{ $categoryFilter !== ''
? __('No certificates in this category. Try another filter.')
: __('Add your first certificate to get started.') }}
</flux:text>
</flux:card>
@else
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white px-4 shadow-sm sm:px-6 dark:border-zinc-800 dark:bg-zinc-900">
<flux:table :paginate="$this->certificates">
<flux:table.columns>
<flux:table.column></flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'title'"
:direction="$sortDirection"
wire:click="sort('title')"
>{{ __('Title') }}</flux:table.column>
<flux:table.column>{{ __('Category') }}</flux:table.column>
<flux:table.column
sortable
:sorted="$sortBy === 'expires_at'"
:direction="$sortDirection"
wire:click="sort('expires_at')"
>{{ __('Expires') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column></flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($this->certificates as $certificate)
@php($status = $certificate->expiryStatus(auth()->user()->reminder_months_before))
<flux:table.row :key="$certificate->id">
<flux:table.cell>
<flux:checkbox wire:model.live="selected" value="{{ $certificate->id }}" />
</flux:table.cell>
<flux:table.cell class="font-medium text-zinc-800 dark:text-white">
{{ $certificate->title }}
</flux:table.cell>
<flux:table.cell>{{ $certificate->category?->name ?? '—' }}</flux:table.cell>
<flux:table.cell>{{ $certificate->expires_at->format('M j, Y') }}</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" :color="$status->color()">{{ $status->label() }}</flux:badge>
</flux:table.cell>
<flux:table.cell align="end">
<flux:dropdown>
<flux:button variant="ghost" size="sm" icon="ellipsis-horizontal" inset="top bottom" />
<flux:menu>
<flux:menu.item :href="route('certificates.download', $certificate)" icon="arrow-down-tray">
{{ __('Download') }}
</flux:menu.item>
<flux:menu.item :href="route('certificates.edit', $certificate)" wire:navigate icon="pencil-square">
{{ __('Edit') }}
</flux:menu.item>
<flux:modal.trigger :name="'delete-certificate-'.$certificate->id">
<flux:menu.item variant="danger" icon="trash">{{ __('Delete') }}</flux:menu.item>
</flux:modal.trigger>
</flux:menu>
</flux:dropdown>
<flux:modal :name="'delete-certificate-'.$certificate->id" class="min-w-88">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Delete certificate?') }}</flux:heading>
<flux:text class="mt-2">
{{ __('":title" and its stored file will be permanently deleted.', ['title' => $certificate->title]) }}
</flux:text>
</div>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="ghost">{{ __('Cancel') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="delete({{ $certificate->id }})">
{{ __('Delete') }}
</flux:button>
</div>
</div>
</flux:modal>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
</div>
@endif
{{-- Share modal --}}
<flux:modal name="share" class="min-w-96">
<form wire:submit="share" class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Share certificates') }}</flux:heading>
<flux:text class="mt-2">{{ __('Create a secure page where the recipient can view and download the selected certificates.') }}</flux:text>
</div>
<flux:select wire:model="shareExpiresInDays" :label="__('Link expires after')">
<flux:select.option value="1">{{ __('1 day') }}</flux:select.option>
<flux:select.option value="7">{{ __('7 days') }}</flux:select.option>
<flux:select.option value="30">{{ __('30 days') }}</flux:select.option>
</flux:select>
<flux:checkbox
wire:model="sharePasswordProtect"
:label="__('Protect with a password')"
:description="__('A password is generated for you; share it with the recipient through a different channel (SMS or WhatsApp).')"
/>
<flux:input
type="email"
wire:model="shareEmail"
:label="__('Email the link to a recipient (optional)')"
:description="__('Leave empty to copy the link and send it yourself.')"
/>
<div class="flex justify-end gap-2">
<flux:modal.close>
<flux:button variant="ghost">{{ __('Cancel') }}</flux:button>
</flux:modal.close>
<flux:button type="submit" variant="primary">{{ __('Create share') }}</flux:button>
</div>
</form>
</flux:modal>
{{-- Share created modal --}}
<flux:modal name="share-created" class="min-w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Share created') }}</flux:heading>
<flux:text class="mt-2">
{{ __('The recipient can view and download the certificates on this page:') }}
</flux:text>
</div>
@if ($createdShareUrl)
<flux:input readonly copyable value="{{ $createdShareUrl }}" :label="__('Secure link')" />
@endif
@if ($createdSharePassword)
<flux:input readonly copyable value="{{ $createdSharePassword }}" :label="__('Password')" class="font-mono" />
<flux:text class="text-sm">
{{ __('Share this password with the recipient through a different channel (SMS or WhatsApp). You can look it up later on the Shares page.') }}
</flux:text>
@endif
<div class="flex justify-end">
<flux:modal.close>
<flux:button variant="primary" wire:click="dismissCreatedShare">
{{ __('Done') }}
</flux:button>
</flux:modal.close>
</div>
</div>
</flux:modal>
</section>