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>
194 lines
10 KiB
PHP
194 lines
10 KiB
PHP
<?php
|
|
|
|
use App\Models\Share;
|
|
use Flux\Flux;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Livewire\Attributes\Computed;
|
|
use Livewire\Attributes\Title;
|
|
use Livewire\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
new #[Title('Shares')] class extends Component {
|
|
use WithPagination;
|
|
|
|
public function revoke(int $shareId): void
|
|
{
|
|
$share = Share::findOrFail($shareId);
|
|
|
|
if ($share->revoked_at === null) {
|
|
$share->update(['revoked_at' => now()]);
|
|
}
|
|
|
|
Flux::modals()->close();
|
|
Flux::toast(variant: 'success', text: __('Share revoked. The link no longer works.'));
|
|
}
|
|
|
|
public function delete(int $shareId): void
|
|
{
|
|
Share::findOrFail($shareId)->delete();
|
|
|
|
Flux::modals()->close();
|
|
Flux::toast(variant: 'success', text: __('Share deleted.'));
|
|
}
|
|
|
|
/**
|
|
* @return LengthAwarePaginator<int, Share>
|
|
*/
|
|
#[Computed]
|
|
public function shares(): LengthAwarePaginator
|
|
{
|
|
return Share::query()
|
|
->with('certificates')
|
|
->latest()
|
|
->paginate(10);
|
|
}
|
|
}; ?>
|
|
|
|
<section class="flex h-full w-full flex-1 flex-col gap-6">
|
|
<div>
|
|
<flux:heading size="xl">{{ __('Shares') }}</flux:heading>
|
|
<flux:text class="mt-1">{{ __('Secure pages you have shared with external recipients.') }}</flux:text>
|
|
</div>
|
|
|
|
@if ($this->shares->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.share class="size-6" />
|
|
</span>
|
|
<flux:heading size="lg">{{ __('No shares yet') }}</flux:heading>
|
|
<flux:text>{{ __('Select certificates on the Certificates page and choose Share to create a secure page.') }}</flux:text>
|
|
<flux:button :href="route('certificates.index')" wire:navigate class="mt-2">
|
|
{{ __('Go to certificates') }}
|
|
</flux:button>
|
|
</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->shares">
|
|
<flux:table.columns>
|
|
<flux:table.column>{{ __('Certificates') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Recipient') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Expires') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Opened') }}</flux:table.column>
|
|
<flux:table.column>{{ __('Status') }}</flux:table.column>
|
|
<flux:table.column></flux:table.column>
|
|
</flux:table.columns>
|
|
|
|
<flux:table.rows>
|
|
@foreach ($this->shares as $share)
|
|
<flux:table.row :key="$share->id">
|
|
<flux:table.cell class="max-w-64 font-medium text-zinc-800 dark:text-white">
|
|
<span class="block truncate" title="{{ $share->certificates->pluck('title')->join(', ') }}">
|
|
{{ $share->certificates->pluck('title')->join(', ') ?: '—' }}
|
|
</span>
|
|
</flux:table.cell>
|
|
<flux:table.cell>{{ $share->recipient_email ?? '—' }}</flux:table.cell>
|
|
<flux:table.cell>{{ $share->expires_at->format('M j, Y H:i') }}</flux:table.cell>
|
|
<flux:table.cell>
|
|
{{ trans_choice(':count time|:count times', $share->access_count, ['count' => $share->access_count]) }}
|
|
</flux:table.cell>
|
|
<flux:table.cell>
|
|
@if ($share->revoked_at !== null)
|
|
<flux:badge size="sm" color="zinc">{{ __('Revoked') }}</flux:badge>
|
|
@elseif ($share->expires_at->isPast())
|
|
<flux:badge size="sm" color="red">{{ __('Expired') }}</flux:badge>
|
|
@else
|
|
<flux:badge size="sm" color="green">{{ __('Active') }}</flux:badge>
|
|
@endif
|
|
</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:modal.trigger :name="'share-details-'.$share->id">
|
|
<flux:menu.item icon="link">{{ __('Link & password') }}</flux:menu.item>
|
|
</flux:modal.trigger>
|
|
<flux:menu.item :href="$share->url()" target="_blank" icon="arrow-top-right-on-square">
|
|
{{ __('Open page') }}
|
|
</flux:menu.item>
|
|
@if ($share->isActive())
|
|
<flux:modal.trigger :name="'revoke-share-'.$share->id">
|
|
<flux:menu.item variant="danger" icon="no-symbol">{{ __('Revoke') }}</flux:menu.item>
|
|
</flux:modal.trigger>
|
|
@endif
|
|
<flux:modal.trigger :name="'delete-share-'.$share->id">
|
|
<flux:menu.item variant="danger" icon="trash">{{ __('Delete') }}</flux:menu.item>
|
|
</flux:modal.trigger>
|
|
</flux:menu>
|
|
</flux:dropdown>
|
|
|
|
<flux:modal :name="'share-details-'.$share->id" class="min-w-96">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">{{ __('Share details') }}</flux:heading>
|
|
<flux:text class="mt-2">
|
|
{{ __('Send the link and the password through different channels.') }}
|
|
</flux:text>
|
|
</div>
|
|
|
|
<flux:input readonly copyable value="{{ $share->url() }}" :label="__('Secure link')" />
|
|
|
|
@if ($share->password !== null)
|
|
<flux:input readonly copyable value="{{ $share->password }}" :label="__('Password')" class="font-mono" />
|
|
@else
|
|
<flux:text class="text-sm">{{ __('This share is not password protected.') }}</flux:text>
|
|
@endif
|
|
|
|
@if ($share->last_accessed_at !== null)
|
|
<flux:text class="text-sm">
|
|
{{ __('Last opened :date.', ['date' => $share->last_accessed_at->translatedFormat('F j, Y H:i')]) }}
|
|
</flux:text>
|
|
@endif
|
|
|
|
<div class="flex justify-end">
|
|
<flux:modal.close>
|
|
<flux:button variant="primary">{{ __('Done') }}</flux:button>
|
|
</flux:modal.close>
|
|
</div>
|
|
</div>
|
|
</flux:modal>
|
|
|
|
<flux:modal :name="'revoke-share-'.$share->id" class="min-w-88">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">{{ __('Revoke share?') }}</flux:heading>
|
|
<flux:text class="mt-2">
|
|
{{ __('The link stops working immediately. The recipient will no longer be able to view or download the certificates.') }}
|
|
</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="revoke({{ $share->id }})">
|
|
{{ __('Revoke') }}
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</flux:modal>
|
|
|
|
<flux:modal :name="'delete-share-'.$share->id" class="min-w-88">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">{{ __('Delete share?') }}</flux:heading>
|
|
<flux:text class="mt-2">
|
|
{{ __('The link stops working and the share disappears from this list. The certificates themselves are not deleted.') }}
|
|
</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({{ $share->id }})">
|
|
{{ __('Delete') }}
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</flux:modal>
|
|
</flux:table.cell>
|
|
</flux:table.row>
|
|
@endforeach
|
|
</flux:table.rows>
|
|
</flux:table>
|
|
</div>
|
|
@endif
|
|
</section>
|