Certified/resources/views/pages/shares/⚡index.blade.php
Joël van de Wouw ad2a78a989 Replace email-only sharing with secure share pages
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>
2026-07-12 15:38:43 +02:00

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>