*/ 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 $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 $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 */ #[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 */ #[Computed] public function categories(): Collection { return Category::orderBy('name')->get(); } }; ?>
{{ __('Certificates') }} {{ __('An overview of your certificates and their expiry status.') }}
{{ __('All categories') }} @foreach ($this->categories as $category) {{ $category->name }} @endforeach {{ __('Add certificate') }}
@if (count($selected) > 0) {{ trans_choice(':count certificate selected|:count certificates selected', count($selected), ['count' => count($selected)]) }} {{ __('Share') }} @endif @if ($this->certificates->isEmpty()) {{ __('No certificates found') }} {{ $categoryFilter !== '' ? __('No certificates in this category. Try another filter.') : __('Add your first certificate to get started.') }} @else {{ __('Title') }} {{ __('Category') }} {{ __('Expires') }} {{ __('Status') }} @foreach ($this->certificates as $certificate) @php($status = $certificate->expiryStatus(auth()->user()->reminder_months_before)) {{ $certificate->title }} {{ $certificate->category?->name ?? '—' }} {{ $certificate->expires_at->format('M j, Y') }} {{ $status->label() }} {{ __('Download') }} {{ __('Edit') }} {{ __('Delete') }}
{{ __('Delete certificate?') }} {{ __('":title" and its stored file will be permanently deleted.', ['title' => $certificate->title]) }}
{{ __('Cancel') }} {{ __('Delete') }}
@endforeach
@endif {{-- Share modal --}}
{{ __('Share certificates') }} {{ __('Send the selected certificates to an external recipient.') }}
{{ __('Cancel') }} {{ __('Send') }}
{{-- ZIP password reveal modal --}}
{{ __('Archive password') }} {{ __('The ZIP was emailed. Share this password with the recipient through a different channel (SMS or WhatsApp). It will not be shown again.') }}
@if ($sharePassword) @endif
{{ __('Done') }}