Compare commits

..

No commits in common. "83daaca38bb39b36c37e1a44ef042b3c7e5c0e9a" and "ac340d125c4123dbbfb9204a61ed10eb23819c14" have entirely different histories.

44 changed files with 356 additions and 1181 deletions

View file

@ -1,11 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "certified",
"runtimeExecutable": "php",
"runtimeArgs": ["/Users/joel/Development/Certified/artisan", "serve", "--port=8321"],
"port": 8321
}
]
}

View file

@ -13,7 +13,7 @@ enum ExpiryStatus: string
return match ($this) {
self::Expired => 'red',
self::ExpiringSoon => 'amber',
self::Valid => 'emerald',
self::Valid => 'green',
};
}

View file

@ -1,97 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Certificate;
use App\Models\Share;
use App\Services\CertificateZipper;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Public page where external recipients view a share and download its
* certificates. Authorization is the unguessable token in the URL (plus the
* optional share password); this runs unauthenticated, so the owner scope
* on Share/Certificate no-ops.
*/
class SharePageController extends Controller
{
public function show(Share $share): View|Response
{
if (! $share->isActive()) {
return response()->view('shared.expired', status: 410);
}
if (! $this->isUnlocked($share)) {
return view('shared.unlock', ['share' => $share]);
}
$share->recordAccess();
return view('shared.show', [
'share' => $share,
'certificates' => $share->certificates()->whereNotNull('file_path')->orderBy('title')->get(),
]);
}
public function unlock(Request $request, Share $share): RedirectResponse
{
abort_unless($share->isActive(), 410);
$validated = $request->validate(['password' => ['required', 'string']]);
if ($share->password === null || ! hash_equals($share->password, $validated['password'])) {
return back()->withErrors(['password' => __('The password is incorrect.')]);
}
$request->session()->put($this->sessionKey($share), true);
return redirect()->route('shared.show', $share->token);
}
public function download(Share $share, Certificate $certificate): StreamedResponse
{
$this->authorizeDownload($share);
abort_unless(filled($certificate->file_path) && Storage::disk('local')->exists($certificate->file_path), 404);
return Storage::disk('local')->download(
$certificate->file_path,
$certificate->title.'.'.pathinfo($certificate->file_path, PATHINFO_EXTENSION),
);
}
public function zip(Share $share): BinaryFileResponse
{
$this->authorizeDownload($share);
$certificates = $share->certificates()->whereNotNull('file_path')->get();
abort_if($certificates->isEmpty(), 404);
$zipPath = app(CertificateZipper::class)->create($certificates, $share->password);
return response()->download($zipPath, 'certificates.zip')->deleteFileAfterSend();
}
private function authorizeDownload(Share $share): void
{
abort_unless($share->isActive(), 410);
abort_unless($this->isUnlocked($share), 403);
}
private function isUnlocked(Share $share): bool
{
return $share->password === null || session()->get($this->sessionKey($share)) === true;
}
private function sessionKey(Share $share): string
{
return 'share-unlocked:'.$share->id;
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Models\Certificate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SharedCertificateController extends Controller
{
/**
* Stream a certificate to an external recipient via a temporary signed
* URL. The 'signed' middleware is the authorization here, so this runs
* unauthenticated (the global scope no-ops without a logged-in user).
*/
public function __invoke(Certificate $certificate): StreamedResponse
{
abort_unless(filled($certificate->file_path) && Storage::disk('local')->exists($certificate->file_path), 404);
return Storage::disk('local')->download(
$certificate->file_path,
$certificate->title.'.'.pathinfo($certificate->file_path, PATHINFO_EXTENSION),
);
}
}

View file

@ -8,16 +8,17 @@ use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ShareCreatedMail extends Mailable
class SharedCertificateLinksMail extends Mailable
{
use Queueable, SerializesModels;
/**
* @param array<int, array{title: string, url: string}> $links
*/
public function __construct(
public string $senderName,
public string $shareUrl,
public array $links,
public string $expiresAt,
public int $certificateCount,
public bool $hasPassword,
) {}
public function envelope(): Envelope
@ -30,13 +31,11 @@ class ShareCreatedMail extends Mailable
public function content(): Content
{
return new Content(
markdown: 'mail.share-created',
markdown: 'mail.shared-certificate-links',
with: [
'senderName' => $this->senderName,
'shareUrl' => $this->shareUrl,
'links' => $this->links,
'expiresAt' => $this->expiresAt,
'certificateCount' => $this->certificateCount,
'hasPassword' => $this->hasPassword,
],
);
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class SharedCertificatesMail extends Mailable
{
use Queueable, SerializesModels;
/**
* @param string $zipPath Absolute path to the password-protected archive.
*/
public function __construct(
public string $senderName,
public string $zipPath,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: __(':name shared certificates with you', ['name' => $this->senderName]),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.shared-certificates',
with: ['senderName' => $this->senderName],
);
}
/**
* @return array<int, Attachment>
*/
public function attachments(): array
{
return [
Attachment::fromPath($this->zipPath)
->as('certificates.zip')
->withMime('application/zip'),
];
}
}

View file

@ -1,72 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\OwnedByUser;
use App\Models\Scopes\OwnedByUserScope;
use Database\Factories\ShareFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $user_id
* @property string $token
* @property string|null $recipient_email
* @property string|null $password
* @property Carbon $expires_at
* @property Carbon|null $revoked_at
* @property int $access_count
* @property Carbon|null $last_accessed_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['token', 'recipient_email', 'password', 'expires_at', 'revoked_at'])]
#[ScopedBy(OwnedByUserScope::class)]
class Share extends Model
{
/** @use HasFactory<ShareFactory> */
use HasFactory, OwnedByUser;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
// Encrypted (not hashed) so the owner can re-read it and the ZIP
// can be AES-encrypted with the same password on download.
'password' => 'encrypted',
'expires_at' => 'datetime',
'revoked_at' => 'datetime',
'last_accessed_at' => 'datetime',
];
}
/**
* @return BelongsToMany<Certificate, $this>
*/
public function certificates(): BelongsToMany
{
return $this->belongsToMany(Certificate::class);
}
public function isActive(): bool
{
return $this->revoked_at === null && $this->expires_at->isFuture();
}
public function url(): string
{
return route('shared.show', $this->token);
}
public function recordAccess(): void
{
$this->increment('access_count', extra: ['last_accessed_at' => now()]);
}
}

View file

@ -12,14 +12,13 @@ use ZipArchive;
class CertificateZipper
{
/**
* Build a ZIP archive containing the given certificates' files. When a
* password is given the entries are AES-256 encrypted with it. Returns
* the absolute path to the created archive; the caller is responsible
* for deleting it once sent.
* Build a password-protected (AES-256) ZIP archive containing the given
* certificates' files. Returns the absolute path to the created archive;
* the caller is responsible for deleting it once sent.
*
* @param Collection<int, Certificate> $certificates
*/
public function create(Collection $certificates, ?string $password = null): string
public function create(Collection $certificates, string $password): string
{
$archivePath = tempnam(sys_get_temp_dir(), 'certs_').'.zip';
@ -29,10 +28,7 @@ class CertificateZipper
throw new RuntimeException('Unable to create ZIP archive.');
}
if ($password !== null) {
$zip->setPassword($password);
}
$usedNames = [];
foreach ($certificates as $certificate) {
@ -44,11 +40,8 @@ class CertificateZipper
$usedNames[] = $entryName;
$zip->addFromString($entryName, Storage::disk('local')->get($certificate->file_path));
if ($password !== null) {
$zip->setEncryptionName($entryName, ZipArchive::EM_AES_256);
}
}
if ($zip->count() === 0) {
$zip->close();

View file

@ -1,50 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\Share;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Share>
*/
class ShareFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'token' => Str::random(48),
'recipient_email' => null,
'password' => null,
'expires_at' => now()->addDays(7),
'revoked_at' => null,
];
}
public function expired(): static
{
return $this->state(fn (array $attributes) => [
'expires_at' => now()->subDay(),
]);
}
public function revoked(): static
{
return $this->state(fn (array $attributes) => [
'revoked_at' => now()->subHour(),
]);
}
public function passwordProtected(string $password = 'secret-password'): static
{
return $this->state(fn (array $attributes) => [
'password' => $password,
]);
}
}

View file

@ -1,40 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('shares', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('token', 64)->unique();
$table->string('recipient_email')->nullable();
$table->text('password')->nullable();
$table->dateTime('expires_at');
$table->dateTime('revoked_at')->nullable();
$table->unsignedInteger('access_count')->default(0);
$table->dateTime('last_accessed_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'expires_at']);
});
Schema::create('certificate_share', function (Blueprint $table) {
$table->id();
$table->foreignId('certificate_id')->constrained()->cascadeOnDelete();
$table->foreignId('share_id')->constrained()->cascadeOnDelete();
$table->unique(['certificate_id', 'share_id']);
});
}
public function down(): void
{
Schema::dropIfExists('certificate_share');
Schema::dropIfExists('shares');
}
};

View file

@ -1,18 +1,13 @@
{
"\":name\" will be deleted. Certificates in this category are kept and become uncategorized.": "\":name\" wordt verwijderd. Certificaten in deze categorie blijven behouden en worden ongecategoriseerd.",
"\":title\" and its stored file will be permanently deleted.": "\":title\" en het bijbehorende bestand worden permanent verwijderd.",
"1 day": "1 dag",
"2FA recovery codes": "2FA-herstelcodes",
"30 days": "30 dagen",
"7 days": "7 dagen",
":count certificate selected|:count certificates selected": ":count certificaat geselecteerd|:count certificaten geselecteerd",
":count time|:count times": ":count keer|:count keer",
":name has shared :count certificate with you.|:name has shared :count certificates with you.": ":name heeft :count certificaat met je gedeeld.|:name heeft :count certificaten met je gedeeld.",
":name has shared certificates with you as a password-protected ZIP archive attached to this email.": ":name heeft certificaten met je gedeeld als een met wachtwoord beveiligd ZIP-bestand, bijgevoegd bij deze e-mail.",
":name has shared the following certificates with you.": ":name heeft de volgende certificaten met je gedeeld.",
":name shared certificates with you": ":name heeft certificaten met je gedeeld",
"A new verification link has been sent to the email address you provided during registration.": "Er is een nieuwe verificatielink verstuurd naar het e-mailadres dat je bij registratie hebt opgegeven.",
"A new verification link has been sent to your email address.": "Er is een nieuwe verificatielink naar je e-mailadres verstuurd.",
"A password is generated for you; share it with the recipient through a different channel (SMS or WhatsApp).": "Er wordt een wachtwoord voor je gegenereerd; deel dit met de ontvanger via een ander kanaal (sms of WhatsApp).",
"Active": "Actief",
"API key": "API-sleutel",
"Add": "Toevoegen",
"Add a passkey to sign in without a password": "Voeg een passkey toe om zonder wachtwoord in te loggen",
"Add certificate": "Certificaat toevoegen",
@ -23,9 +18,9 @@
"All categories": "Alle categorieën",
"Already have an account?": "Heb je al een account?",
"An overview of your certificates and their expiry status.": "Een overzicht van je certificaten en hun vervalstatus.",
"API key": "API-sleutel",
"Appearance": "Weergave",
"Appearance settings": "Weergave-instellingen",
"Archive password": "Archiefwachtwoord",
"Are you sure you want to delete your account?": "Weet je zeker dat je je account wilt verwijderen?",
"Are you sure you want to remove the passkey \":name\"? You will no longer be able to use it to sign in.": "Weet je zeker dat je de passkey \":name\" wilt verwijderen? Je kunt hem daarna niet meer gebruiken om in te loggen.",
"Authenticating...": "Bezig met authenticeren...",
@ -46,7 +41,6 @@
"Certificate updated.": "Certificaat bijgewerkt.",
"Certificates": "Certificaten",
"Certificates shared with you": "Certificaten met je gedeeld",
"Choose the language used throughout the application": "Kies de taal die in de hele applicatie wordt gebruikt",
"Click here to re-send the verification email.": "Klik hier om de verificatie-e-mail opnieuw te versturen.",
"Close": "Sluiten",
"Configure expiry reminders and document scanning": "Stel vervalherinneringen en documentscannen in",
@ -55,10 +49,8 @@
"Confirm with passkey": "Bevestig met passkey",
"Confirming...": "Bezig met bevestigen...",
"Continue": "Doorgaan",
"Create a secure page where the recipient can view and download the selected certificates.": "Maak een beveiligde pagina waar de ontvanger de geselecteerde certificaten kan bekijken en downloaden.",
"Create account": "Account aanmaken",
"Create an account": "Maak een account aan",
"Create share": "Link aanmaken",
"Create your first category to organize your certificates.": "Maak je eerste categorie aan om je certificaten te organiseren.",
"Current password": "Huidig wachtwoord",
"Dark": "Donker",
@ -66,7 +58,6 @@
"Delete account": "Account verwijderen",
"Delete category?": "Categorie verwijderen?",
"Delete certificate?": "Certificaat verwijderen?",
"Delete share?": "Gedeelde link verwijderen?",
"Delete your account and all of its resources": "Verwijder je account en alle bijbehorende gegevens",
"Disable 2FA": "2FA uitschakelen",
"Document preview": "Documentvoorbeeld",
@ -75,26 +66,19 @@
"Don't have an account?": "Nog geen account?",
"Done": "Klaar",
"Download": "Downloaden",
"Download all as ZIP": "Alles downloaden als ZIP",
"Download current file": "Huidig bestand downloaden",
"Dutch": "Nederlands",
"e.g. VCA, Red Cross": "bijv. VCA, Rode Kruis",
"e.g., MacBook Pro, iPhone": "bijv. MacBook Pro, iPhone",
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate codes above.": "Elke herstelcode kan één keer worden gebruikt om toegang te krijgen tot je account en wordt na gebruik verwijderd. Heb je meer nodig, klik dan hierboven op Codes opnieuw genereren.",
"Edit": "Bewerken",
"Edit certificate": "Certificaat bewerken",
"Email": "E-mail",
"Email address": "E-mailadres",
"Email password reset link": "Verstuur link om wachtwoord opnieuw in te stellen",
"Email the link to a recipient (optional)": "E-mail de link naar een ontvanger (optioneel)",
"Email verification": "E-mailverificatie",
"Enable 2FA": "2FA inschakelen",
"Enable two-factor authentication": "Tweestapsverificatie inschakelen",
"English": "Engels",
"Ensure your account is using a long, random password to stay secure": "Zorg dat je account een lang, willekeurig wachtwoord gebruikt om veilig te blijven",
"Enter the 6-digit code from your authenticator app.": "Voer de 6-cijferige code uit je authenticator-app in.",
"Enter the authentication code provided by your authenticator application.": "Voer de authenticatiecode in die door je authenticator-app wordt gegeven.",
"Enter the password you received from the sender to view the shared certificates.": "Voer het wachtwoord in dat je van de afzender hebt ontvangen om de gedeelde certificaten te bekijken.",
"Enter your API key": "Voer je API-sleutel in",
"Enter your details below to create your account": "Vul hieronder je gegevens in om je account aan te maken",
"Enter your email and password below to log in": "Vul hieronder je e-mailadres en wachtwoord in om in te loggen",
@ -103,35 +87,28 @@
"Expires": "Vervalt",
"Expiring soon": "Vervalt binnenkort",
"Expiry date": "Vervaldatum",
"For your security, the password is sent separately through another channel (for example SMS or WhatsApp). You will need it to open the archive.": "Voor je veiligheid wordt het wachtwoord apart via een ander kanaal verstuurd (bijvoorbeeld sms of WhatsApp). Je hebt het nodig om het archief te openen.",
"Forgot password": "Wachtwoord vergeten",
"Forgot your password?": "Wachtwoord vergeten?",
"Full name": "Volledige naam",
"Give this passkey a name to help you identify it later.": "Geef deze passkey een naam zodat je hem later herkent.",
"Go to certificates": "Naar certificaten",
"Hello :name,": "Hallo :name,",
"Here is the status of your certificates.": "Dit is de status van je certificaten.",
"Hide recovery codes": "Herstelcodes verbergen",
"If you did not expect this email, you can safely ignore it.": "Als je deze e-mail niet verwachtte, kun je hem veilig negeren.",
"Issue date": "Uitgiftedatum",
"Issuing authority": "Uitgevende instantie",
"Language": "Taal",
"Last opened :date.": "Laatst geopend op :date.",
"Last used :time": "Laatst gebruikt :time",
"Leave blank to keep the current key": "Laat leeg om de huidige sleutel te behouden",
"Leave empty to copy the link and send it yourself.": "Laat leeg om de link te kopiëren en zelf te versturen.",
"Light": "Licht",
"Link & password": "Link & wachtwoord",
"Link expires after": "Link verloopt na",
"Log in": "Inloggen",
"log in": "inloggen",
"Log in to your account": "Log in op je account",
"Log out": "Uitloggen",
"login using a recovery code": "inloggen met een herstelcode",
"login using an authentication code": "inloggen met een authenticatiecode",
"Manage": "Beheren",
"Manage your passkeys for passwordless sign-in": "Beheer je passkeys voor inloggen zonder wachtwoord",
"Manage your profile and account settings": "Beheer je profiel- en accountinstellingen",
"Manage your two-factor authentication settings": "Beheer je instellingen voor tweestapsverificatie",
"Method": "Methode",
"Name": "Naam",
"Needs attention": "Actie vereist",
"New category name": "Naam nieuwe categorie",
@ -142,27 +119,23 @@
"No certificates in this category. Try another filter.": "Geen certificaten in deze categorie. Probeer een ander filter.",
"No certificates yet": "Nog geen certificaten",
"No passkeys yet": "Nog geen passkeys",
"No shares yet": "Nog niets gedeeld",
"None (manual entry)": "Geen (handmatig invoeren)",
"Notes": "Notities",
"Nothing needs attention. All your certificates are up to date.": "Niets vereist actie. Al je certificaten zijn up-to-date.",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Zodra je account is verwijderd, worden alle bijbehorende gegevens permanent verwijderd. Voer je wachtwoord in om te bevestigen dat je je account definitief wilt verwijderen.",
"Open page": "Pagina openen",
"Opened": "Geopend",
"Optional notes about this certificate": "Optionele notities over dit certificaat",
"Optional. Leave empty to keep the current file.": "Optioneel. Laat leeg om het huidige bestand te behouden.",
"Or confirm with password": "Of bevestig met wachtwoord",
"Or continue with email": "Of ga verder met e-mail",
"or you can": "of je kunt",
"or, enter the code manually": "of voer de code handmatig in",
"Or, return to": "Of ga terug naar",
"Organize your certificates with personal categories.": "Organiseer je certificaten met persoonlijke categorieën.",
"PDF or image, up to 10 MB.": "PDF of afbeelding, tot 10 MB.",
"Passkey name": "Naam passkey",
"Passkeys": "Passkeys",
"Passkeys are not supported in this browser.": "Passkeys worden niet ondersteund in deze browser.",
"Password": "Wachtwoord",
"Password updated.": "Wachtwoord bijgewerkt.",
"PDF or image, up to 10 MB.": "PDF of afbeelding, tot 10 MB.",
"Password-protected ZIP attachment": "Met wachtwoord beveiligde ZIP-bijlage",
"Please confirm access to your account by entering one of your emergency recovery codes.": "Bevestig toegang tot je account door een van je noodherstelcodes in te voeren.",
"Please enter your new password below": "Voer hieronder je nieuwe wachtwoord in",
"Please renew it in good time to stay compliant.": "Verleng het op tijd om compliant te blijven.",
@ -170,9 +143,8 @@
"Profile": "Profiel",
"Profile settings": "Profielinstellingen",
"Profile updated.": "Profiel bijgewerkt.",
"Protect with a password": "Beveiligen met een wachtwoord",
"Recently added": "Recent toegevoegd",
"Recipient": "Ontvanger",
"Recipient email": "E-mailadres ontvanger",
"Recovery code": "Herstelcode",
"Recovery codes": "Herstelcodes",
"Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "Met herstelcodes krijg je weer toegang als je je 2FA-apparaat kwijtraakt. Bewaar ze in een veilige wachtwoordmanager.",
@ -187,50 +159,29 @@
"Replace document": "Document vervangen",
"Resend verification email": "Verificatie-e-mail opnieuw versturen",
"Reset password": "Wachtwoord opnieuw instellen",
"Revoke": "Intrekken",
"Revoke share?": "Gedeelde link intrekken?",
"Revoked": "Ingetrokken",
"Save": "Opslaan",
"Save certificate": "Certificaat opslaan",
"Save changes": "Wijzigingen opslaan",
"Scanning document for suggestions…": "Document wordt gescand voor suggesties…",
"Search": "Zoeken",
"Secure link": "Beveiligde link",
"Secure pages you have shared with external recipients.": "Beveiligde pagina's die je met externe ontvangers hebt gedeeld.",
"Secure link (valid 48 hours)": "Beveiligde link (48 uur geldig)",
"Secure links sent to :email.": "Beveiligde links verzonden naar :email.",
"Security": "Beveiliging",
"Security settings": "Beveiligingsinstellingen",
"Select a document to preview it here.": "Selecteer een document om het hier te bekijken.",
"Select certificates on the Certificates page and choose Share to create a secure page.": "Selecteer certificaten op de pagina Certificaten en kies Delen om een beveiligde pagina te maken.",
"Send the link and the password through different channels.": "Verstuur de link en het wachtwoord via verschillende kanalen.",
"Send": "Versturen",
"Send the selected certificates to an external recipient.": "Verstuur de geselecteerde certificaten naar een externe ontvanger.",
"Settings": "Instellingen",
"Share": "Delen",
"Share certificates": "Certificaten delen",
"Share created": "Link aangemaakt",
"Share deleted.": "Gedeelde link verwijderd.",
"Share details": "Details van gedeelde link",
"Share revoked. The link no longer works.": "Gedeelde link ingetrokken. Deze werkt niet meer.",
"Share this password with the recipient through a different channel (SMS or WhatsApp). You can look it up later on the Shares page.": "Deel dit wachtwoord met de ontvanger via een ander kanaal (sms of WhatsApp). Je kunt het later terugvinden op de pagina Gedeelde links.",
"Shared certificates": "Gedeelde certificaten",
"Shared securely via :app.": "Veilig gedeeld via :app.",
"Shares": "Gedeelde links",
"Sign in with a passkey": "Inloggen met een passkey",
"Sign up": "Registreren",
"System": "Systeem",
"Thanks": "Met vriendelijke groet",
"The link has expired or was revoked by the sender. Ask them to share the certificates again if you still need them.": "De link is verlopen of door de afzender ingetrokken. Vraag de afzender om de certificaten opnieuw te delen als je ze nog nodig hebt.",
"The link stops working and the share disappears from this list. The certificates themselves are not deleted.": "De link werkt niet meer en verdwijnt uit deze lijst. De certificaten zelf worden niet verwijderd.",
"The link stops working immediately. The recipient will no longer be able to view or download the certificates.": "De link werkt direct niet meer. De ontvanger kan de certificaten niet langer bekijken of downloaden.",
"The page is protected with a password. :name will share it with you through a separate channel.": "De pagina is beveiligd met een wachtwoord. :name deelt dit met je via een apart kanaal.",
"The password is incorrect.": "Het wachtwoord is onjuist.",
"The recipient can view and download the certificates on this page:": "De ontvanger kan de certificaten bekijken en downloaden op deze pagina:",
"The ZIP was emailed. Share this password with the recipient through a different channel (SMS or WhatsApp). It will not be shown again.": "De ZIP is verstuurd per e-mail. Deel dit wachtwoord met de ontvanger via een ander kanaal (sms of WhatsApp). Het wordt niet opnieuw getoond.",
"The selected certificates have no files to share.": "De geselecteerde certificaten hebben geen bestanden om te delen.",
"The ZIP archive is encrypted with the same password you used to open this page.": "Het ZIP-archief is versleuteld met hetzelfde wachtwoord waarmee je deze pagina hebt geopend.",
"There are no files available in this share anymore.": "Er zijn geen bestanden meer beschikbaar op deze pagina.",
"These links are valid until :date, after which they will stop working.": "Deze links zijn geldig tot :date, daarna werken ze niet meer.",
"This is a secure area of the application. Please confirm your password before continuing.": "Dit is een beveiligd gedeelte van de applicatie. Bevestig je wachtwoord voordat je verdergaat.",
"This page is available until :date, after which it will stop working.": "Deze pagina is beschikbaar tot :date, daarna werkt deze niet meer.",
"This share is no longer available": "Deze gedeelde pagina is niet meer beschikbaar",
"This share is not password protected.": "Deze gedeelde link is niet beveiligd met een wachtwoord.",
"This share is password protected": "Deze gedeelde pagina is beveiligd met een wachtwoord",
"Title": "Titel",
"To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.": "Scan de QR-code of voer de installatiesleutel in je authenticator-app in om tweestapsverificatie te voltooien.",
"Too many share attempts. Please try again later.": "Te veel deelpogingen. Probeer het later opnieuw.",
@ -239,7 +190,6 @@
"Two-factor authentication enabled": "Tweestapsverificatie ingeschakeld",
"Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.": "Tweestapsverificatie is nu ingeschakeld. Scan de QR-code of voer de installatiesleutel in je authenticator-app in.",
"Uncategorized": "Ongecategoriseerd",
"Unlock": "Ontgrendelen",
"Update password": "Wachtwoord bijwerken",
"Update the appearance settings for your account": "Werk de weergave-instellingen van je account bij",
"Update the details or replace the stored document.": "Werk de gegevens bij of vervang het opgeslagen document.",
@ -251,14 +201,24 @@
"Verify authentication code": "Authenticatiecode verifiëren",
"View all": "Alles bekijken",
"View recovery codes": "Herstelcodes bekijken",
"View shared certificates": "Bekijk gedeelde certificaten",
"View your certificates": "Bekijk je certificaten",
"Welcome": "Welkom",
"Welcome back, :name": "Welkom terug, :name",
"When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.": "Wanneer je tweestapsverificatie inschakelt, wordt tijdens het inloggen om een beveiligde pincode gevraagd. Deze pincode kun je ophalen uit een TOTP-ondersteunende app op je telefoon.",
"You can view and download them on a secure page:": "Je kunt ze bekijken en downloaden op een beveiligde pagina:",
"You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.": "Tijdens het inloggen wordt om een beveiligde, willekeurige pincode gevraagd, die je kunt ophalen uit de TOTP-ondersteunende app op je telefoon.",
"Your certificate \":title\" expires today.": "Je certificaat \":title\" verloopt vandaag.",
"Your certificate \":title\" will expire on :date.": "Je certificaat \":title\" verloopt op :date.",
"Your email address is unverified.": "Je e-mailadres is niet geverifieerd."
"Your email address is unverified.": "Je e-mailadres is niet geverifieerd.",
"e.g. VCA, Red Cross": "bijv. VCA, Rode Kruis",
"e.g., MacBook Pro, iPhone": "bijv. MacBook Pro, iPhone",
"log in": "inloggen",
"login using a recovery code": "inloggen met een herstelcode",
"login using an authentication code": "inloggen met een authenticatiecode",
"or you can": "of je kunt",
"or, enter the code manually": "of voer de code handmatig in",
":count certificate selected|:count certificates selected": ":count certificaat geselecteerd|:count certificaten geselecteerd",
"Language": "Taal",
"Choose the language used throughout the application": "Kies de taal die in de hele applicatie wordt gebruikt",
"English": "Engels",
"Dutch": "Nederlands"
}

View file

@ -11,20 +11,20 @@
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--color-zinc-50: #f8fafc;
--color-zinc-100: #f1f5f9;
--color-zinc-200: #e2e8f0;
--color-zinc-300: #cbd5e1;
--color-zinc-400: #94a3b8;
--color-zinc-500: #64748b;
--color-zinc-600: #475569;
--color-zinc-700: #334155;
--color-zinc-800: #1e293b;
--color-zinc-900: #0f172a;
--color-zinc-950: #020617;
--color-zinc-50: #fafafa;
--color-zinc-100: #f5f5f5;
--color-zinc-200: #e5e5e5;
--color-zinc-300: #d4d4d4;
--color-zinc-400: #a3a3a3;
--color-zinc-500: #737373;
--color-zinc-600: #525252;
--color-zinc-700: #404040;
--color-zinc-800: #262626;
--color-zinc-900: #171717;
--color-zinc-950: #0a0a0a;
--color-accent: var(--color-emerald-600);
--color-accent-content: var(--color-emerald-600);
--color-accent: var(--color-neutral-800);
--color-accent-content: var(--color-neutral-800);
--color-accent-foreground: var(--color-white);
/* Marketing pages */
@ -92,9 +92,9 @@
@layer theme {
.dark {
--color-accent: var(--color-emerald-500);
--color-accent-content: var(--color-emerald-400);
--color-accent-foreground: var(--color-white);
--color-accent: var(--color-white);
--color-accent-content: var(--color-white);
--color-accent-foreground: var(--color-neutral-800);
}
}
@ -120,7 +120,7 @@
input:focus[data-flux-control],
textarea:focus[data-flux-control],
select:focus[data-flux-control] {
@apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-white dark:ring-offset-slate-900;
@apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-accent-foreground;
}
/* \[:where(&)\]:size-4 {

View file

@ -3,15 +3,15 @@
])
@if($sidebar)
<flux:sidebar.brand name="Certified" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600">
<x-brand-icon class="size-4.5 text-white" />
<flux:sidebar.brand name="Laravel Starter Kit" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:sidebar.brand>
@else
<flux:brand name="Certified" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600">
<x-brand-icon class="size-4.5 text-white" />
<flux:brand name="Laravel Starter Kit" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:brand>
@endif

View file

@ -3,7 +3,7 @@
])
@if ($status)
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-emerald-600']) }}>
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
{{ $status }}
</div>
@endif

View file

@ -1,4 +0,0 @@
{{-- Certified shield-check mark --}}
<svg {{ $attributes }} fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>

View file

@ -1,16 +0,0 @@
{{-- Certified gradient logo tile --}}
@props([
'size' => 'md', // sm | md | lg
])
@php
[$tile, $icon] = match ($size) {
'sm' => ['size-8 rounded-lg', 'size-4.5'],
'lg' => ['size-12 rounded-2xl shadow-lg shadow-emerald-500/25', 'size-7'],
default => ['size-9 rounded-xl shadow-lg shadow-emerald-500/25', 'size-5'],
};
@endphp
<span {{ $attributes->class(['flex items-center justify-center bg-gradient-to-br from-emerald-500 to-teal-600', $tile]) }}>
<x-brand-icon class="{{ $icon }} text-white" />
</span>

View file

@ -82,7 +82,7 @@
</template>
<template x-if="supported && showForm">
<div class="space-y-4 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 p-4">
<div class="space-y-4 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 p-4">
<flux:input
label="{{ __('Passkey name') }}"
x-model="name"

View file

@ -1,8 +1,5 @@
<x-layouts::app.sidebar :title="$title ?? null">
<flux:main class="relative isolate">
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[24rem] bg-grid-slate opacity-60 [mask-image:radial-gradient(ellipse_60%_40%_at_50%_0%,black,transparent)]"></div>
<div aria-hidden="true" class="pointer-events-none absolute -top-24 right-[10%] -z-10 h-64 w-96 rounded-full bg-gradient-to-br from-emerald-400/15 via-teal-400/10 to-transparent blur-3xl"></div>
<flux:main>
{{ $slot }}
</flux:main>
</x-layouts::app.sidebar>

View file

@ -1,10 +1,10 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white antialiased selection:bg-emerald-200 selection:text-emerald-950 dark:bg-zinc-950 dark:selection:bg-emerald-900 dark:selection:text-emerald-100">
<flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
<body class="min-h-screen bg-white dark:bg-zinc-800">
<flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.header>
<x-app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate />
<flux:sidebar.collapse class="lg:hidden" />
@ -21,15 +21,20 @@
<flux:sidebar.item icon="tag" :href="route('categories.index')" :current="request()->routeIs('categories.index')" wire:navigate>
{{ __('Categories') }}
</flux:sidebar.item>
<flux:sidebar.item icon="share" :href="route('shares.index')" :current="request()->routeIs('shares.index')" wire:navigate>
{{ __('Shares') }}
</flux:sidebar.item>
</flux:sidebar.group>
</flux:sidebar.nav>
<flux:spacer />
<flux:sidebar.nav>
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
{{ __('Repository') }}
</flux:sidebar.item>
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
{{ __('Documentation') }}
</flux:sidebar.item>
<x-language-switcher sidebar />
</flux:sidebar.nav>

View file

@ -1,23 +1,22 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-slate-50 antialiased selection:bg-emerald-200 selection:text-emerald-950 dark:bg-slate-950 dark:selection:bg-emerald-900 dark:selection:text-emerald-100">
<div aria-hidden="true" class="pointer-events-none fixed inset-x-0 top-0 h-[32rem] bg-grid-slate [mask-image:radial-gradient(ellipse_70%_60%_at_50%_0%,black,transparent)]"></div>
<div aria-hidden="true" class="pointer-events-none fixed -top-32 left-1/2 h-80 w-[36rem] -translate-x-1/2 rounded-full bg-gradient-to-br from-emerald-400/20 via-teal-400/10 to-transparent blur-3xl"></div>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="absolute top-4 right-4 z-10">
<x-language-switcher />
</div>
<div class="relative flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6">
<a href="{{ route('home') }}" class="group flex flex-col items-center gap-3 font-medium" wire:navigate>
<x-brand-mark size="lg" class="transition-transform duration-300 group-hover:scale-105 group-hover:-rotate-3" />
<span class="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">Certified</span>
<div class="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-2">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
<span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span>
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
</a>
<div class="flex flex-col gap-6 rounded-2xl border border-slate-200 bg-white p-8 shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900">
<div class="flex flex-col gap-6">
{{ $slot }}
</div>
</div>

View file

@ -1,22 +0,0 @@
<x-mail::message>
# {{ __('Certificates shared with you') }}
{{ trans_choice(':name has shared :count certificate with you.|:name has shared :count certificates with you.', $certificateCount, ['name' => $senderName, 'count' => $certificateCount]) }}
{{ __('You can view and download them on a secure page:') }}
<x-mail::button :url="$shareUrl">
{{ __('View shared certificates') }}
</x-mail::button>
@if ($hasPassword)
{{ __('The page is protected with a password. :name will share it with you through a separate channel.', ['name' => $senderName]) }}
@endif
{{ __('This page is available until :date, after which it will stop working.', ['date' => $expiresAt]) }}
{{ __('If you did not expect this email, you can safely ignore it.') }}
{{ __('Thanks') }},<br>
{{ config('app.name') }}
</x-mail::message>

View file

@ -0,0 +1,18 @@
<x-mail::message>
# {{ __('Certificates shared with you') }}
{{ __(':name has shared the following certificates with you.', ['name' => $senderName]) }}
@foreach ($links as $link)
<x-mail::button :url="$link['url']">
{{ $link['title'] }}
</x-mail::button>
@endforeach
{{ __('These links are valid until :date, after which they will stop working.', ['date' => $expiresAt]) }}
{{ __('If you did not expect this email, you can safely ignore it.') }}
{{ __('Thanks') }},<br>
{{ config('app.name') }}
</x-mail::message>

View file

@ -0,0 +1,12 @@
<x-mail::message>
# {{ __('Certificates shared with you') }}
{{ __(':name has shared certificates with you as a password-protected ZIP archive attached to this email.', ['name' => $senderName]) }}
{{ __('For your security, the password is sent separately through another channel (for example SMS or WhatsApp). You will need it to open the archive.') }}
{{ __('If you did not expect this email, you can safely ignore it.') }}
{{ __('Thanks') }},<br>
{{ config('app.name') }}
</x-mail::message>

View file

@ -3,7 +3,11 @@
<div class="grid gap-10 md:grid-cols-4">
<div class="md:col-span-2">
<a href="{{ route('home') }}" class="flex items-center gap-2.5">
<x-brand-mark size="sm" />
<span class="flex size-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600">
<svg class="size-4.5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
</span>
<span class="text-base font-semibold tracking-tight text-slate-900 dark:text-white">Certified</span>
</a>
<p class="mt-4 max-w-sm text-sm leading-relaxed text-slate-500 dark:text-slate-400">

View file

@ -14,7 +14,11 @@
{{-- Logo --}}
<a href="{{ route('home') }}" class="group flex items-center gap-2.5" aria-label="Certified — home">
<x-brand-mark class="transition-transform duration-300 group-hover:scale-105 group-hover:-rotate-3" />
<span class="flex size-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 shadow-lg shadow-emerald-500/25 transition-transform duration-300 group-hover:scale-105 group-hover:-rotate-3">
<svg class="size-5 text-white" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
</span>
<span class="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">Certified</span>
</a>

View file

@ -5,7 +5,7 @@
</flux:text>
@if (session('status') == 'verification-link-sent')
<flux:text class="text-center font-medium !dark:text-emerald-400 !text-emerald-600">
<flux:text class="text-center font-medium !dark:text-green-400 !text-green-600">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</flux:text>
@endif

View file

@ -63,15 +63,12 @@ new #[Title('Categories')] class extends Component {
</form>
@if ($this->categories->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.tag class="size-6" />
</span>
<flux:card class="flex flex-col items-center gap-2 py-12 text-center">
<flux:icon.tag class="size-8 text-zinc-400" />
<flux:heading size="lg">{{ __('No categories yet') }}</flux:heading>
<flux:text>{{ __('Create your first category to organize your certificates.') }}</flux:text>
</flux:card>
@else
<div class="max-w-3xl 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>
<flux:table.columns>
<flux:table.column>{{ __('Name') }}</flux:table.column>
@ -118,6 +115,5 @@ new #[Title('Categories')] class extends Component {
@endforeach
</flux:table.rows>
</flux:table>
</div>
@endif
</section>

View file

@ -137,14 +137,9 @@ new #[Title('Add certificate')] class extends Component {
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2 lg:items-start">
{{-- Left: live document preview (client-side, before upload completes) --}}
<div class="lg:sticky lg:top-6">
<div
class="relative flex h-[60vh] items-center justify-center overflow-hidden rounded-2xl border bg-zinc-50 transition-colors duration-200 lg:h-[calc(100vh-9rem)] dark:bg-zinc-800/40"
:class="previewUrl ? 'border-zinc-200 dark:border-zinc-700' : 'border-2 border-dashed border-emerald-400/60 dark:border-emerald-500/40'"
>
<div x-show="! previewUrl" class="flex flex-col items-center gap-3 px-6 text-center text-zinc-400">
<span class="flex size-12 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
<flux:icon.document-arrow-up class="size-6" />
</span>
<div class="relative flex h-[60vh] items-center justify-center overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 lg:h-[calc(100vh-9rem)] dark:border-zinc-700 dark:bg-zinc-800/40">
<div x-show="! previewUrl" class="flex flex-col items-center gap-2 px-6 text-center text-zinc-400">
<flux:icon.document-arrow-up class="size-10" />
<flux:text>{{ __('Select a document to preview it here.') }}</flux:text>
</div>

View file

@ -102,7 +102,7 @@ new #[Title('Edit certificate')] class extends Component {
<flux:text class="mt-1">{{ __('Update the details or replace the stored document.') }}</flux:text>
</div>
<form wire:submit="save" class="space-y-6 rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm sm:p-8 dark:border-zinc-800 dark:bg-zinc-900">
<form wire:submit="save" class="space-y-6">
<flux:input wire:model="title" :label="__('Title')" required />
<flux:select wire:model="category_id" :label="__('Category')">

View file

@ -1,9 +1,10 @@
<?php
use App\Mail\ShareCreatedMail;
use App\Mail\SharedCertificateLinksMail;
use App\Mail\SharedCertificatesMail;
use App\Models\Category;
use App\Models\Certificate;
use App\Models\Share;
use App\Services\CertificateZipper;
use Flux\Flux;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
@ -11,6 +12,7 @@ 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;
@ -38,13 +40,9 @@ new #[Title('Certificates')] class extends Component {
public string $shareEmail = '';
public int $shareExpiresInDays = 7;
public string $shareMethod = 'link';
public bool $sharePasswordProtect = true;
public ?string $createdShareUrl = null;
public ?string $createdSharePassword = null;
public ?string $sharePassword = null;
public function updatedCategoryFilter(): void
{
@ -84,15 +82,13 @@ new #[Title('Certificates')] class extends Component {
public function share(): void
{
$validated = $this->validate([
'shareEmail' => ['nullable', 'email'],
'shareExpiresInDays' => ['required', Rule::in([1, 7, 30])],
'sharePasswordProtect' => ['boolean'],
'shareEmail' => ['required', 'email'],
'shareMethod' => ['required', Rule::in(['link', 'zip'])],
'selected' => ['required', 'array', 'min:1'],
'selected.*' => ['integer'],
]);
// Throttle emails to protect the mail server from being used as a relay.
if (filled($validated['shareEmail'])) {
// Throttle to protect the mail server from being used as a relay.
$key = 'share-certificates:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, maxAttempts: 10)) {
@ -102,7 +98,6 @@ new #[Title('Certificates')] class extends Component {
}
RateLimiter::hit($key, decaySeconds: 3600);
}
$certificates = Certificate::whereIn('id', $validated['selected'])
->whereNotNull('file_path')
@ -114,39 +109,56 @@ new #[Title('Certificates')] class extends Component {
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,
));
$validated['shareMethod'] === 'zip'
? $this->shareAsZip($certificates, $validated['shareEmail'])
: $this->shareAsLinks($certificates, $validated['shareEmail']);
}
// 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;
/**
* @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-created')->show();
}
public function dismissCreatedShare(): void
{
$this->reset('createdShareUrl', 'createdSharePassword');
Flux::modal('share-password')->show();
}
/**
@ -197,7 +209,7 @@ new #[Title('Certificates')] class extends Component {
</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: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>
@ -206,10 +218,8 @@ new #[Title('Certificates')] class extends Component {
@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: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 !== ''
@ -218,7 +228,6 @@ new #[Title('Certificates')] class extends Component {
</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>
@ -293,7 +302,6 @@ new #[Title('Certificates')] class extends Component {
@endforeach
</flux:table.rows>
</flux:table>
</div>
@endif
{{-- Share modal --}}
@ -301,63 +309,42 @@ new #[Title('Certificates')] class extends Component {
<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>
<flux:text class="mt-2">{{ __('Send the selected certificates to an external recipient.') }}</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:input type="email" wire:model="shareEmail" :label="__('Recipient email')" required />
<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.')"
/>
<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">{{ __('Create share') }}</flux:button>
<flux:button type="submit" variant="primary">{{ __('Send') }}</flux:button>
</div>
</form>
</flux:modal>
{{-- Share created modal --}}
<flux:modal name="share-created" class="min-w-96">
{{-- ZIP password reveal modal --}}
<flux:modal name="share-password" class="min-w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Share created') }}</flux:heading>
<flux:heading size="lg">{{ __('Archive password') }}</flux:heading>
<flux:text class="mt-2">
{{ __('The recipient can view and download the certificates on this page:') }}
{{ __('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 ($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>
@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="dismissCreatedShare">
{{ __('Done') }}
</flux:button>
<flux:button variant="primary" wire:click="$set('sharePassword', null)">{{ __('Done') }}</flux:button>
</flux:modal.close>
</div>
</div>

View file

@ -46,7 +46,7 @@ new class extends Component {
}; ?>
<div
class="py-6 space-y-6 border shadow-sm rounded-2xl border-zinc-200 dark:border-white/10"
class="py-6 space-y-6 border shadow-sm rounded-xl border-zinc-200 dark:border-white/10"
wire:cloak
x-data="{ showRecoveryCodes: false }"
>

View file

@ -99,7 +99,7 @@ new #[Title('Profile settings')] class extends Component {
</flux:text>
@if (session('status') === 'verification-link-sent')
<flux:text class="mt-2 font-medium !dark:text-emerald-400 !text-emerald-600">
<flux:text class="mt-2 font-medium !dark:text-green-400 !text-green-600">
{{ __('A new verification link has been sent to your email address.') }}
</flux:text>
@endif

View file

@ -258,7 +258,7 @@ new #[Title('Security settings')] class extends Component {
<flux:subheading>{{ __('Manage your passkeys for passwordless sign-in') }}</flux:subheading>
<div class="mt-6 flex flex-col w-full mx-auto space-y-6 text-sm" wire:cloak>
<div class="border rounded-2xl border-zinc-200 dark:border-zinc-700 overflow-hidden">
<div class="border rounded-lg border-zinc-200 dark:border-zinc-700 overflow-hidden">
@forelse ($passkeys as $passkey)
<div class="flex items-center justify-between p-4 {{ ! $loop->last ? 'border-b border-zinc-200 dark:border-zinc-700' : '' }}">
<div class="flex items-center gap-4">

View file

@ -298,7 +298,7 @@ new class extends Component {
<flux:icon.check
x-show="copied"
variant="solid"
class="text-emerald-500"
class="text-green-500"
></flux:icon>
</button>
@endempty

View file

@ -1,194 +0,0 @@
<?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>

View file

@ -87,11 +87,8 @@ new #[Title('Dashboard')] class extends Component {
</div>
@if ($this->stats['total'] === 0)
<flux:card class="flex flex-col items-center gap-3 rounded-2xl py-16 text-center">
<span class="inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-1.5 text-sm font-medium text-emerald-700 dark:border-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400">
{{ __('Get started') }}
</span>
<x-brand-mark size="lg" class="mt-2" />
<flux:card class="flex flex-col items-center gap-3 py-16 text-center">
<flux:icon.shield-check class="size-10 text-zinc-400" />
<flux:heading size="lg">{{ __('No certificates yet') }}</flux:heading>
<flux:text class="max-w-sm">
{{ __('Upload your first certificate to start tracking issue dates, expiry, and reminders.') }}
@ -105,20 +102,18 @@ new #[Title('Dashboard')] class extends Component {
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
@php
$tiles = [
['label' => __('Total'), 'value' => $this->stats['total'], 'icon' => 'rectangle-stack', 'class' => 'text-zinc-800 dark:text-white', 'tile' => 'bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-md shadow-emerald-500/25'],
['label' => __('Valid'), 'value' => $this->stats['valid'], 'icon' => 'check-badge', 'class' => 'text-emerald-600 dark:text-emerald-400', 'tile' => 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400'],
['label' => __('Expiring soon'), 'value' => $this->stats['expiringSoon'], 'icon' => 'clock', 'class' => 'text-amber-600 dark:text-amber-400', 'tile' => 'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400'],
['label' => __('Expired'), 'value' => $this->stats['expired'], 'icon' => 'exclamation-triangle', 'class' => 'text-red-600 dark:text-red-400', 'tile' => 'bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-400'],
['label' => __('Total'), 'value' => $this->stats['total'], 'icon' => 'rectangle-stack', 'class' => 'text-zinc-800 dark:text-white'],
['label' => __('Valid'), 'value' => $this->stats['valid'], 'icon' => 'check-badge', 'class' => 'text-green-600 dark:text-green-400'],
['label' => __('Expiring soon'), 'value' => $this->stats['expiringSoon'], 'icon' => 'clock', 'class' => 'text-amber-600 dark:text-amber-400'],
['label' => __('Expired'), 'value' => $this->stats['expired'], 'icon' => 'exclamation-triangle', 'class' => 'text-red-600 dark:text-red-400'],
];
@endphp
@foreach ($tiles as $tile)
<flux:card class="flex flex-col gap-2 rounded-2xl transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-emerald-500/10 dark:hover:shadow-black/30">
<flux:card class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<flux:text class="text-sm">{{ $tile['label'] }}</flux:text>
<span @class(['flex size-9 items-center justify-center rounded-xl', $tile['tile']])>
<flux:icon :name="$tile['icon']" class="size-5" />
</span>
<flux:icon :name="$tile['icon']" class="size-5 text-zinc-400" />
</div>
<flux:heading size="xl" @class(['text-3xl font-semibold', $tile['class']])>{{ $tile['value'] }}</flux:heading>
</flux:card>
@ -127,7 +122,7 @@ new #[Title('Dashboard')] class extends Component {
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
{{-- Needs attention --}}
<flux:card class="rounded-2xl lg:col-span-2">
<flux:card class="lg:col-span-2">
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ __('Needs attention') }}</flux:heading>
<flux:link :href="route('certificates.index')" wire:navigate variant="subtle">{{ __('View all') }}</flux:link>
@ -135,7 +130,7 @@ new #[Title('Dashboard')] class extends Component {
@if ($this->attention->isEmpty())
<div class="flex flex-col items-center gap-2 py-8 text-center">
<flux:icon.check-badge class="size-8 text-emerald-500" />
<flux:icon.check-badge class="size-8 text-green-500" />
<flux:text>{{ __('Nothing needs attention. All your certificates are up to date.') }}</flux:text>
</div>
@else
@ -166,7 +161,7 @@ new #[Title('Dashboard')] class extends Component {
<div class="flex flex-col gap-6">
{{-- By category --}}
<flux:card class="rounded-2xl">
<flux:card>
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ __('By category') }}</flux:heading>
<flux:link :href="route('categories.index')" wire:navigate variant="subtle">{{ __('Manage') }}</flux:link>
@ -187,7 +182,7 @@ new #[Title('Dashboard')] class extends Component {
</flux:card>
{{-- Recently added --}}
<flux:card class="rounded-2xl">
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Recently added') }}</flux:heading>
<div class="flex flex-col gap-3">
@foreach ($this->recent as $certificate)

View file

@ -1,13 +0,0 @@
@extends('shared.layout')
@section('content')
<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-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
<flux:icon.clock class="size-6" />
</span>
<flux:heading size="lg">{{ __('This share is no longer available') }}</flux:heading>
<flux:text class="max-w-sm">
{{ __('The link has expired or was revoked by the sender. Ask them to share the certificates again if you still need them.') }}
</flux:text>
</flux:card>
@endsection

View file

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head', ['title' => $title ?? __('Shared certificates')])
</head>
<body class="min-h-screen bg-zinc-50 antialiased selection:bg-emerald-200 selection:text-emerald-950 dark:bg-zinc-950 dark:selection:bg-emerald-900 dark:selection:text-emerald-100">
<div class="mx-auto flex min-h-screen w-full max-w-2xl flex-col gap-8 px-4 py-10 sm:py-16">
<div class="flex justify-center">
<x-app-logo href="{{ route('home') }}" />
</div>
@yield('content')
<flux:text class="text-center text-xs">
{{ __('Shared securely via :app.', ['app' => config('app.name')]) }}
</flux:text>
</div>
@fluxScripts
</body>
</html>

View file

@ -1,63 +0,0 @@
@extends('shared.layout')
@section('content')
<flux:card class="space-y-6 rounded-2xl">
<div>
<flux:heading size="xl">{{ __('Certificates shared with you') }}</flux:heading>
<flux:text class="mt-2">
{{ trans_choice(':name has shared :count certificate with you.|:name has shared :count certificates with you.', $certificates->count(), ['name' => $share->user->name, 'count' => $certificates->count()]) }}
</flux:text>
</div>
<div class="divide-y divide-zinc-200 rounded-xl border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
@forelse ($certificates as $certificate)
<div class="flex items-center justify-between gap-4 p-4">
<div class="flex min-w-0 items-center gap-3">
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
<flux:icon.shield-check class="size-5" />
</span>
<div class="min-w-0">
<flux:heading class="truncate">{{ $certificate->title }}</flux:heading>
@if ($certificate->issuer)
<flux:text class="truncate text-sm">{{ $certificate->issuer }}</flux:text>
@endif
</div>
</div>
<flux:button
:href="route('shared.certificate', ['share' => $share->token, 'certificate' => $certificate->id])"
icon="arrow-down-tray"
size="sm"
>
{{ __('Download') }}
</flux:button>
</div>
@empty
<div class="p-4">
<flux:text>{{ __('There are no files available in this share anymore.') }}</flux:text>
</div>
@endforelse
</div>
@if ($certificates->isNotEmpty())
<div class="space-y-2">
<flux:button
:href="route('shared.zip', $share->token)"
icon="archive-box-arrow-down"
variant="primary"
class="w-full"
>
{{ __('Download all as ZIP') }}
</flux:button>
@if ($share->password !== null)
<flux:text class="text-center text-sm">
{{ __('The ZIP archive is encrypted with the same password you used to open this page.') }}
</flux:text>
@endif
</div>
@endif
<flux:text class="text-sm">
{{ __('This page is available until :date, after which it will stop working.', ['date' => $share->expires_at->translatedFormat('F j, Y H:i')]) }}
</flux:text>
</flux:card>
@endsection

View file

@ -1,33 +0,0 @@
@extends('shared.layout')
@section('content')
<flux:card class="space-y-6 rounded-2xl">
<div>
<flux:heading size="xl">{{ __('This share is password protected') }}</flux:heading>
<flux:text class="mt-2">
{{ __('Enter the password you received from the sender to view the shared certificates.') }}
</flux:text>
</div>
<form method="POST" action="{{ route('shared.unlock', $share->token) }}" class="space-y-6">
@csrf
<flux:input
type="password"
name="password"
:label="__('Password')"
required
autofocus
viewable
/>
@error('password')
<flux:text class="text-sm text-red-600 dark:text-red-400">{{ $message }}</flux:text>
@enderror
<flux:button type="submit" variant="primary" class="w-full" icon="lock-open">
{{ __('Unlock') }}
</flux:button>
</form>
</flux:card>
@endsection

View file

@ -3,7 +3,7 @@
use App\Http\Controllers\CertificateDownloadController;
use App\Http\Controllers\LanguageController;
use App\Http\Controllers\MarketingController;
use App\Http\Controllers\SharePageController;
use App\Http\Controllers\SharedCertificateController;
use Illuminate\Support\Facades\Route;
Route::get('/', [MarketingController::class, 'home'])->name('home');
@ -22,17 +22,11 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('certificates/{certificate}/download', CertificateDownloadController::class)->name('certificates.download');
Route::livewire('categories', 'pages::categories.index')->name('categories.index');
Route::livewire('shares', 'pages::shares.index')->name('shares.index');
});
// External recipients: authorized by the unguessable share token (and the
// optional share password), not by login.
Route::prefix('shared/{share:token}')->name('shared.')->scopeBindings()->group(function () {
Route::get('/', [SharePageController::class, 'show'])->name('show');
Route::post('unlock', [SharePageController::class, 'unlock'])->middleware('throttle:10,1')->name('unlock');
Route::get('zip', [SharePageController::class, 'zip'])->name('zip');
Route::get('certificates/{certificate}', [SharePageController::class, 'download'])->name('certificate');
});
// External recipients: authorized by the signed URL, not by login.
Route::get('shared/certificates/{certificate}', SharedCertificateController::class)
->middleware('signed')
->name('shared.certificate');
require __DIR__.'/settings.php';

View file

@ -1,12 +1,13 @@
<?php
use App\Mail\ShareCreatedMail;
use App\Mail\SharedCertificateLinksMail;
use App\Mail\SharedCertificatesMail;
use App\Models\Certificate;
use App\Models\Share;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Livewire\Livewire;
beforeEach(function () {
@ -22,47 +23,7 @@ function sharableCertificate(User $user): Certificate
return Certificate::factory()->for($user)->create(['file_path' => $path]);
}
test('creating a share stores it and reveals the link and password', function () {
$user = User::factory()->create();
$certificate = sharableCertificate($user);
$this->actingAs($user);
$component = Livewire::test('pages::certificates.index')
->set('selected', [$certificate->id])
->set('shareExpiresInDays', 7)
->set('sharePasswordProtect', true)
->call('share')
->assertHasNoErrors();
$share = Share::first();
expect($share)->not->toBeNull()
->and($share->certificates()->count())->toBe(1)
->and($share->password)->toHaveLength(12)
->and($share->expires_at->isSameDay(now()->addDays(7)))->toBeTrue()
->and($component->get('createdShareUrl'))->toBe($share->url())
->and($component->get('createdSharePassword'))->toBe($share->password);
Mail::assertNothingSent();
});
test('a share without password protection stores no password', function () {
$user = User::factory()->create();
$certificate = sharableCertificate($user);
$this->actingAs($user);
Livewire::test('pages::certificates.index')
->set('selected', [$certificate->id])
->set('sharePasswordProtect', false)
->call('share')
->assertHasNoErrors();
expect(Share::first()->password)->toBeNull();
});
test('providing a recipient email sends the share link by mail', function () {
test('sharing by secure link emails the recipient', function () {
$user = User::factory()->create();
$certificate = sharableCertificate($user);
@ -71,40 +32,44 @@ test('providing a recipient email sends the share link by mail', function () {
Livewire::test('pages::certificates.index')
->set('selected', [$certificate->id])
->set('shareEmail', 'friend@example.com')
->set('shareMethod', 'link')
->call('share')
->assertHasNoErrors();
expect(Share::first()->recipient_email)->toBe('friend@example.com');
Mail::assertSent(ShareCreatedMail::class, fn ($mail) => $mail->hasTo('friend@example.com'));
Mail::assertSent(SharedCertificateLinksMail::class, fn ($mail) => $mail->hasTo('friend@example.com'));
});
test('sharing requires a selection', function () {
test('sharing by zip emails an attachment and reveals a password', function () {
$user = User::factory()->create();
$certificate = sharableCertificate($user);
$this->actingAs($user);
$component = Livewire::test('pages::certificates.index')
->set('selected', [$certificate->id])
->set('shareEmail', 'friend@example.com')
->set('shareMethod', 'zip')
->call('share')
->assertHasNoErrors();
expect($component->get('sharePassword'))->toHaveLength(12);
Mail::assertSent(SharedCertificatesMail::class, fn ($mail) => $mail->hasTo('friend@example.com'));
});
test('sharing requires a recipient and a selection', function () {
$this->actingAs(User::factory()->create());
Livewire::test('pages::certificates.index')
->set('shareEmail', '')
->set('selected', [])
->call('share')
->assertHasErrors(['selected']);
->assertHasErrors(['shareEmail', 'selected']);
expect(Share::count())->toBe(0);
Mail::assertNothingSent();
});
test('certificates from another user cannot be shared', function () {
$owner = User::factory()->create();
$certificate = sharableCertificate($owner);
$this->actingAs(User::factory()->create());
Livewire::test('pages::certificates.index')
->set('selected', [$certificate->id])
->call('share')
->assertHasErrors(['selected']);
expect(Share::withoutGlobalScopes()->count())->toBe(0);
});
test('emailing shares is rate limited', function () {
test('sharing is rate limited', function () {
$user = User::factory()->create();
$certificate = sharableCertificate($user);
@ -122,3 +87,19 @@ test('emailing shares is rate limited', function () {
Mail::assertNothingSent();
});
test('a valid signed link streams the file to an unauthenticated recipient', function () {
$user = User::factory()->create();
$certificate = sharableCertificate($user);
$url = URL::temporarySignedRoute('shared.certificate', now()->addHours(48), ['certificate' => $certificate->id]);
$this->get($url)->assertOk();
});
test('a tampered signed link is rejected', function () {
$user = User::factory()->create();
$certificate = sharableCertificate($user);
$this->get(route('shared.certificate', $certificate))->assertForbidden();
});

View file

@ -1,64 +0,0 @@
<?php
use App\Models\Share;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Livewire\Livewire;
test('the shares page lists the owner shares', function () {
$user = User::factory()->create();
$share = Share::factory()->for($user)->create(['recipient_email' => 'client@example.com']);
$this->actingAs($user)
->get(route('shares.index'))
->assertOk()
->assertSee('client@example.com');
});
test('shares from other users are hidden', function () {
Share::factory()->create(['recipient_email' => 'client@example.com']);
$this->actingAs(User::factory()->create())
->get(route('shares.index'))
->assertOk()
->assertDontSee('client@example.com');
});
test('a share can be revoked', function () {
$user = User::factory()->create();
$share = Share::factory()->for($user)->create();
$this->actingAs($user);
Livewire::test('pages::shares.index')
->call('revoke', $share->id);
expect($share->refresh()->revoked_at)->not->toBeNull();
});
test('a share can be deleted', function () {
$user = User::factory()->create();
$share = Share::factory()->for($user)->create();
$this->actingAs($user);
Livewire::test('pages::shares.index')
->call('delete', $share->id);
expect(Share::withoutGlobalScopes()->count())->toBe(0);
});
test('another user cannot revoke a share they do not own', function () {
$share = Share::factory()->create();
$this->actingAs(User::factory()->create());
expect(fn () => Livewire::test('pages::shares.index')->call('revoke', $share->id))
->toThrow(ModelNotFoundException::class);
expect($share->refresh()->revoked_at)->toBeNull();
});
test('guests are redirected to login', function () {
$this->get(route('shares.index'))->assertRedirect(route('login'));
});

View file

@ -1,139 +0,0 @@
<?php
use App\Models\Certificate;
use App\Models\Share;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('local');
});
function shareWithCertificate(array $shareState = []): Share
{
$user = User::factory()->create();
$path = 'certificates/'.$user->id.'/'.uniqid().'.pdf';
Storage::disk('local')->put($path, 'file-contents');
$certificate = Certificate::factory()->for($user)->create([
'title' => 'Forklift License',
'file_path' => $path,
]);
$share = Share::factory()->for($user)->state($shareState)->create();
$share->certificates()->attach($certificate);
return $share;
}
test('an active share page lists the certificates for an unauthenticated visitor', function () {
$share = shareWithCertificate();
$this->get(route('shared.show', $share->token))
->assertOk()
->assertSee('Forklift License')
->assertSee(__('Download all as ZIP'));
});
test('opening the page is recorded on the share', function () {
$share = shareWithCertificate();
$this->get(route('shared.show', $share->token))->assertOk();
$this->get(route('shared.show', $share->token))->assertOk();
$share->refresh();
expect($share->access_count)->toBe(2)
->and($share->last_accessed_at)->not->toBeNull();
});
test('an unknown token is not found', function () {
$this->get(route('shared.show', 'nonexistent-token'))->assertNotFound();
});
test('an expired share renders the gone page', function () {
$share = shareWithCertificate(['expires_at' => now()->subDay()]);
$this->get(route('shared.show', $share->token))
->assertGone()
->assertSee(__('This share is no longer available'));
});
test('a revoked share renders the gone page', function () {
$share = shareWithCertificate(['revoked_at' => now()->subHour()]);
$this->get(route('shared.show', $share->token))->assertGone();
});
test('a password protected share asks for the password first', function () {
$share = shareWithCertificate(['password' => 'top-secret-123']);
$this->get(route('shared.show', $share->token))
->assertOk()
->assertSee(__('This share is password protected'))
->assertDontSee('Forklift License');
});
test('the correct password unlocks the share for the session', function () {
$share = shareWithCertificate(['password' => 'top-secret-123']);
$this->post(route('shared.unlock', $share->token), ['password' => 'top-secret-123'])
->assertRedirect(route('shared.show', $share->token));
$this->get(route('shared.show', $share->token))
->assertOk()
->assertSee('Forklift License');
});
test('a wrong password does not unlock the share', function () {
$share = shareWithCertificate(['password' => 'top-secret-123']);
$this->post(route('shared.unlock', $share->token), ['password' => 'wrong'])
->assertSessionHasErrors('password');
$this->get(route('shared.show', $share->token))->assertDontSee('Forklift License');
});
test('a certificate in the share can be downloaded', function () {
$share = shareWithCertificate();
$certificate = $share->certificates->first();
$this->get(route('shared.certificate', ['share' => $share->token, 'certificate' => $certificate->id]))
->assertOk()
->assertDownload('Forklift License.pdf');
});
test('a certificate outside the share cannot be downloaded through it', function () {
$share = shareWithCertificate();
$other = Certificate::factory()->create(['file_path' => 'certificates/other.pdf']);
$this->get(route('shared.certificate', ['share' => $share->token, 'certificate' => $other->id]))
->assertNotFound();
});
test('downloads are blocked while a password protected share is locked', function () {
$share = shareWithCertificate(['password' => 'top-secret-123']);
$certificate = $share->certificates->first();
$this->get(route('shared.certificate', ['share' => $share->token, 'certificate' => $certificate->id]))
->assertForbidden();
$this->get(route('shared.zip', $share->token))->assertForbidden();
});
test('downloads are blocked once the share expires', function () {
$share = shareWithCertificate(['expires_at' => now()->subDay()]);
$certificate = $share->certificates->first();
$this->get(route('shared.certificate', ['share' => $share->token, 'certificate' => $certificate->id]))
->assertGone();
});
test('the zip download returns an archive', function () {
$share = shareWithCertificate();
$this->get(route('shared.zip', $share->token))
->assertOk()
->assertDownload('certificates.zip');
});