396 lines
17 KiB
PHP
396 lines
17 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();
|
|
}
|
|
|
|
#[Computed]
|
|
public function certificateLimit(): ?int
|
|
{
|
|
return Auth::user()->plan()->certificateLimit();
|
|
}
|
|
|
|
#[Computed]
|
|
public function certificateCount(): int
|
|
{
|
|
return Certificate::count();
|
|
}
|
|
}; ?>
|
|
|
|
<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.') }}
|
|
@if ($this->certificateLimit !== null)
|
|
· {{ __(':used of :limit certificates used', ['used' => $this->certificateCount, 'limit' => $this->certificateLimit]) }}
|
|
@endif
|
|
</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>
|
|
|
|
@if ($this->certificateLimit !== null && $this->certificateCount >= $this->certificateLimit)
|
|
<flux:tooltip :content="__('Upgrade your plan to add more certificates.')" position="bottom">
|
|
<flux:button :href="route('marketing.pricing')" wire:navigate icon="lock-closed" variant="primary">
|
|
{{ __('Add certificate') }}
|
|
</flux:button>
|
|
</flux:tooltip>
|
|
@else
|
|
<flux:button :href="route('certificates.create')" wire:navigate icon="plus" variant="primary">
|
|
{{ __('Add certificate') }}
|
|
</flux:button>
|
|
@endif
|
|
</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>{{ __('Certificate number') }}</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"
|
|
x-on:click="Livewire.navigate('{{ route('certificates.show', $certificate) }}')"
|
|
class="cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
|
|
>
|
|
<flux:table.cell @click="event.stopPropagation()">
|
|
<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->certificate_number ?? '—' }}</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" @click="event.stopPropagation()">
|
|
<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>
|