Certificate manager built on Laravel 13, Livewire 4, and Flux. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
352 lines
14 KiB
PHP
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>
|