Certified/resources/views/pages/certificates/⚡index.blade.php
Joël van de Wouw ac340d125c
Some checks failed
linter / quality (push) Failing after 13s
tests / ci (8.3) (push) Failing after 2s
tests / ci (8.4) (push) Failing after 2s
tests / ci (8.5) (push) Failing after 3s
Initial commit
Certificate manager built on Laravel 13, Livewire 4, and Flux.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 14:44:58 +02:00

352 lines
14 KiB
PHP

<?php
use App\Mail\SharedCertificateLinksMail;
use App\Mail\SharedCertificatesMail;
use App\Models\Category;
use App\Models\Certificate;
use App\Services\CertificateZipper;
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\Facades\URL as UrlGenerator;
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 string $shareMethod = 'link';
public ?string $sharePassword = 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' => ['required', 'email'],
'shareMethod' => ['required', Rule::in(['link', 'zip'])],
'selected' => ['required', 'array', 'min:1'],
'selected.*' => ['integer'],
]);
// Throttle to protect the mail server from being used as a relay.
$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;
}
$validated['shareMethod'] === 'zip'
? $this->shareAsZip($certificates, $validated['shareEmail'])
: $this->shareAsLinks($certificates, $validated['shareEmail']);
}
/**
* @param Collection<int, Certificate> $certificates
*/
private function shareAsLinks(Collection $certificates, string $email): void
{
$expiresAt = now()->addHours(48);
$links = $certificates->map(fn (Certificate $certificate) => [
'title' => $certificate->title,
'url' => UrlGenerator::temporarySignedRoute('shared.certificate', $expiresAt, ['certificate' => $certificate->id]),
])->all();
Mail::to($email)->send(new SharedCertificateLinksMail(
senderName: Auth::user()->name,
links: $links,
expiresAt: $expiresAt->toDayDateTimeString(),
));
Flux::modals()->close();
Flux::toast(variant: 'success', text: __('Secure links sent to :email.', ['email' => $email]));
$this->reset('selected', 'shareEmail');
}
/**
* @param Collection<int, Certificate> $certificates
*/
private function shareAsZip(Collection $certificates, string $email): void
{
$password = Str::password(12, symbols: false);
$zipPath = app(CertificateZipper::class)->create($certificates, $password);
try {
Mail::to($email)->send(new SharedCertificatesMail(
senderName: Auth::user()->name,
zipPath: $zipPath,
));
} finally {
@unlink($zipPath);
}
// Show the password so the user can share it via a separate channel.
$this->sharePassword = $password;
$this->reset('selected', 'shareEmail');
Flux::modal('share')->close();
Flux::modal('share-password')->show();
}
/**
* @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 py-3">
<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-2 py-12 text-center">
<flux:icon.shield-check class="size-8 text-zinc-400" />
<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
<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>
@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">{{ __('Send the selected certificates to an external recipient.') }}</flux:text>
</div>
<flux:input type="email" wire:model="shareEmail" :label="__('Recipient email')" required />
<flux:radio.group wire:model="shareMethod" :label="__('Method')">
<flux:radio value="link" :label="__('Secure link (valid 48 hours)')" />
<flux:radio value="zip" :label="__('Password-protected ZIP attachment')" />
</flux:radio.group>
<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">{{ __('Send') }}</flux:button>
</div>
</form>
</flux:modal>
{{-- ZIP password reveal modal --}}
<flux:modal name="share-password" class="min-w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Archive password') }}</flux:heading>
<flux:text class="mt-2">
{{ __('The ZIP was emailed. Share this password with the recipient through a different channel (SMS or WhatsApp). It will not be shown again.') }}
</flux:text>
</div>
@if ($sharePassword)
<flux:input readonly copyable value="{{ $sharePassword }}" class="font-mono" />
@endif
<div class="flex justify-end">
<flux:modal.close>
<flux:button variant="primary" wire:click="$set('sharePassword', null)">{{ __('Done') }}</flux:button>
</flux:modal.close>
</div>
</div>
</flux:modal>
</section>