Compare commits
3 commits
ac340d125c
...
83daaca38b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83daaca38b | ||
|
|
ad2a78a989 | ||
|
|
a3b3d7d029 |
44 changed files with 1181 additions and 356 deletions
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "certified",
|
||||
"runtimeExecutable": "php",
|
||||
"runtimeArgs": ["/Users/joel/Development/Certified/artisan", "serve", "--port=8321"],
|
||||
"port": 8321
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ enum ExpiryStatus: string
|
|||
return match ($this) {
|
||||
self::Expired => 'red',
|
||||
self::ExpiringSoon => 'amber',
|
||||
self::Valid => 'green',
|
||||
self::Valid => 'emerald',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
97
app/Http/Controllers/SharePageController.php
Normal file
97
app/Http/Controllers/SharePageController.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,17 +8,16 @@ use Illuminate\Mail\Mailables\Content;
|
|||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SharedCertificateLinksMail extends Mailable
|
||||
class ShareCreatedMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array<int, array{title: string, url: string}> $links
|
||||
*/
|
||||
public function __construct(
|
||||
public string $senderName,
|
||||
public array $links,
|
||||
public string $shareUrl,
|
||||
public string $expiresAt,
|
||||
public int $certificateCount,
|
||||
public bool $hasPassword,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
|
|
@ -31,11 +30,13 @@ class SharedCertificateLinksMail extends Mailable
|
|||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.shared-certificate-links',
|
||||
markdown: 'mail.share-created',
|
||||
with: [
|
||||
'senderName' => $this->senderName,
|
||||
'links' => $this->links,
|
||||
'shareUrl' => $this->shareUrl,
|
||||
'expiresAt' => $this->expiresAt,
|
||||
'certificateCount' => $this->certificateCount,
|
||||
'hasPassword' => $this->hasPassword,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<?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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
72
app/Models/Share.php
Normal file
72
app/Models/Share.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?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()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,13 +12,14 @@ use ZipArchive;
|
|||
class CertificateZipper
|
||||
{
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* @param Collection<int, Certificate> $certificates
|
||||
*/
|
||||
public function create(Collection $certificates, string $password): string
|
||||
public function create(Collection $certificates, ?string $password = null): string
|
||||
{
|
||||
$archivePath = tempnam(sys_get_temp_dir(), 'certs_').'.zip';
|
||||
|
||||
|
|
@ -28,7 +29,10 @@ class CertificateZipper
|
|||
throw new RuntimeException('Unable to create ZIP archive.');
|
||||
}
|
||||
|
||||
$zip->setPassword($password);
|
||||
if ($password !== null) {
|
||||
$zip->setPassword($password);
|
||||
}
|
||||
|
||||
$usedNames = [];
|
||||
|
||||
foreach ($certificates as $certificate) {
|
||||
|
|
@ -40,7 +44,10 @@ class CertificateZipper
|
|||
$usedNames[] = $entryName;
|
||||
|
||||
$zip->addFromString($entryName, Storage::disk('local')->get($certificate->file_path));
|
||||
$zip->setEncryptionName($entryName, ZipArchive::EM_AES_256);
|
||||
|
||||
if ($password !== null) {
|
||||
$zip->setEncryptionName($entryName, ZipArchive::EM_AES_256);
|
||||
}
|
||||
}
|
||||
|
||||
if ($zip->count() === 0) {
|
||||
|
|
|
|||
50
database/factories/ShareFactory.php
Normal file
50
database/factories/ShareFactory.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?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');
|
||||
}
|
||||
};
|
||||
96
lang/nl.json
96
lang/nl.json
|
|
@ -1,13 +1,18 @@
|
|||
{
|
||||
"\":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",
|
||||
":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.",
|
||||
"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 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.",
|
||||
"API key": "API-sleutel",
|
||||
"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",
|
||||
"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",
|
||||
|
|
@ -18,9 +23,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...",
|
||||
|
|
@ -41,6 +46,7 @@
|
|||
"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",
|
||||
|
|
@ -49,8 +55,10 @@
|
|||
"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",
|
||||
|
|
@ -58,6 +66,7 @@
|
|||
"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",
|
||||
|
|
@ -66,19 +75,26 @@
|
|||
"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",
|
||||
|
|
@ -87,28 +103,35 @@
|
|||
"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",
|
||||
|
|
@ -119,23 +142,27 @@
|
|||
"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.",
|
||||
"Password-protected ZIP attachment": "Met wachtwoord beveiligde ZIP-bijlage",
|
||||
"PDF or image, up to 10 MB.": "PDF of afbeelding, tot 10 MB.",
|
||||
"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.",
|
||||
|
|
@ -143,8 +170,9 @@
|
|||
"Profile": "Profiel",
|
||||
"Profile settings": "Profielinstellingen",
|
||||
"Profile updated.": "Profiel bijgewerkt.",
|
||||
"Protect with a password": "Beveiligen met een wachtwoord",
|
||||
"Recently added": "Recent toegevoegd",
|
||||
"Recipient email": "E-mailadres ontvanger",
|
||||
"Recipient": "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.",
|
||||
|
|
@ -159,29 +187,50 @@
|
|||
"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 (valid 48 hours)": "Beveiligde link (48 uur geldig)",
|
||||
"Secure links sent to :email.": "Beveiligde links verzonden naar :email.",
|
||||
"Secure link": "Beveiligde link",
|
||||
"Secure pages you have shared with external recipients.": "Beveiligde pagina's die je met externe ontvangers hebt gedeeld.",
|
||||
"Security": "Beveiliging",
|
||||
"Security settings": "Beveiligingsinstellingen",
|
||||
"Select a document to preview it here.": "Selecteer een document om het hier te bekijken.",
|
||||
"Send": "Versturen",
|
||||
"Send the selected certificates to an external recipient.": "Verstuur de geselecteerde certificaten naar een externe ontvanger.",
|
||||
"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.",
|
||||
"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 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 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 selected certificates have no files to share.": "De geselecteerde certificaten hebben geen bestanden om te delen.",
|
||||
"These links are valid until :date, after which they will stop working.": "Deze links zijn geldig tot :date, daarna werken ze niet meer.",
|
||||
"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.",
|
||||
"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.",
|
||||
|
|
@ -190,6 +239,7 @@
|
|||
"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.",
|
||||
|
|
@ -201,24 +251,14 @@
|
|||
"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.",
|
||||
"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"
|
||||
"Your email address is unverified.": "Je e-mailadres is niet geverifieerd."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: #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-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-accent: var(--color-neutral-800);
|
||||
--color-accent-content: var(--color-neutral-800);
|
||||
--color-accent: var(--color-emerald-600);
|
||||
--color-accent-content: var(--color-emerald-600);
|
||||
--color-accent-foreground: var(--color-white);
|
||||
|
||||
/* Marketing pages */
|
||||
|
|
@ -92,9 +92,9 @@
|
|||
|
||||
@layer theme {
|
||||
.dark {
|
||||
--color-accent: var(--color-white);
|
||||
--color-accent-content: var(--color-white);
|
||||
--color-accent-foreground: var(--color-neutral-800);
|
||||
--color-accent: var(--color-emerald-500);
|
||||
--color-accent-content: var(--color-emerald-400);
|
||||
--color-accent-foreground: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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-accent-foreground;
|
||||
@apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-white dark:ring-offset-slate-900;
|
||||
}
|
||||
|
||||
/* \[:where(&)\]:size-4 {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@
|
|||
])
|
||||
|
||||
@if($sidebar)
|
||||
<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" />
|
||||
<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" />
|
||||
</x-slot>
|
||||
</flux:sidebar.brand>
|
||||
@else
|
||||
<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" />
|
||||
<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" />
|
||||
</x-slot>
|
||||
</flux:brand>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
])
|
||||
|
||||
@if ($status)
|
||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
|
||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-emerald-600']) }}>
|
||||
{{ $status }}
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
4
resources/views/components/brand-icon.blade.php
Normal file
4
resources/views/components/brand-icon.blade.php
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{{-- 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>
|
||||
16
resources/views/components/brand-mark.blade.php
Normal file
16
resources/views/components/brand-mark.blade.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{{-- 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>
|
||||
|
|
@ -82,7 +82,7 @@
|
|||
</template>
|
||||
|
||||
<template x-if="supported && showForm">
|
||||
<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">
|
||||
<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">
|
||||
<flux:input
|
||||
label="{{ __('Passkey name') }}"
|
||||
x-model="name"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<x-layouts::app.sidebar :title="$title ?? null">
|
||||
<flux:main>
|
||||
<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>
|
||||
|
||||
{{ $slot }}
|
||||
</flux:main>
|
||||
</x-layouts::app.sidebar>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<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">
|
||||
<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">
|
||||
<flux:sidebar.header>
|
||||
<x-app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate />
|
||||
<flux:sidebar.collapse class="lg:hidden" />
|
||||
|
|
@ -21,20 +21,15 @@
|
|||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
|
||||
<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>
|
||||
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
<x-language-switcher />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</a>
|
||||
<div class="flex flex-col gap-6">
|
||||
<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">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
22
resources/views/mail/share-created.blade.php
Normal file
22
resources/views/mail/share-created.blade.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<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>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -3,11 +3,7 @@
|
|||
<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">
|
||||
<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>
|
||||
<x-brand-mark size="sm" />
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -14,11 +14,7 @@
|
|||
|
||||
{{-- Logo --}}
|
||||
<a href="{{ route('home') }}" class="group flex items-center gap-2.5" aria-label="Certified — home">
|
||||
<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>
|
||||
<x-brand-mark 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>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
</flux:text>
|
||||
|
||||
@if (session('status') == 'verification-link-sent')
|
||||
<flux:text class="text-center font-medium !dark:text-green-400 !text-green-600">
|
||||
<flux:text class="text-center font-medium !dark:text-emerald-400 !text-emerald-600">
|
||||
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
||||
</flux:text>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -63,12 +63,15 @@ new #[Title('Categories')] class extends Component {
|
|||
</form>
|
||||
|
||||
@if ($this->categories->isEmpty())
|
||||
<flux:card class="flex flex-col items-center gap-2 py-12 text-center">
|
||||
<flux:icon.tag class="size-8 text-zinc-400" />
|
||||
<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: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>
|
||||
|
|
@ -115,5 +118,6 @@ new #[Title('Categories')] class extends Component {
|
|||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -137,9 +137,14 @@ 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-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" />
|
||||
<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>
|
||||
<flux:text>{{ __('Select a document to preview it here.') }}</flux:text>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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">
|
||||
<flux:input wire:model="title" :label="__('Title')" required />
|
||||
|
||||
<flux:select wire:model="category_id" :label="__('Category')">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\SharedCertificateLinksMail;
|
||||
use App\Mail\SharedCertificatesMail;
|
||||
use App\Mail\ShareCreatedMail;
|
||||
use App\Models\Category;
|
||||
use App\Models\Certificate;
|
||||
use App\Services\CertificateZipper;
|
||||
use App\Models\Share;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
|
@ -12,7 +11,6 @@ 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;
|
||||
|
|
@ -40,9 +38,13 @@ new #[Title('Certificates')] class extends Component {
|
|||
|
||||
public string $shareEmail = '';
|
||||
|
||||
public string $shareMethod = 'link';
|
||||
public int $shareExpiresInDays = 7;
|
||||
|
||||
public ?string $sharePassword = null;
|
||||
public bool $sharePasswordProtect = true;
|
||||
|
||||
public ?string $createdShareUrl = null;
|
||||
|
||||
public ?string $createdSharePassword = null;
|
||||
|
||||
public function updatedCategoryFilter(): void
|
||||
{
|
||||
|
|
@ -82,23 +84,26 @@ new #[Title('Certificates')] class extends Component {
|
|||
public function share(): void
|
||||
{
|
||||
$validated = $this->validate([
|
||||
'shareEmail' => ['required', 'email'],
|
||||
'shareMethod' => ['required', Rule::in(['link', 'zip'])],
|
||||
'shareEmail' => ['nullable', 'email'],
|
||||
'shareExpiresInDays' => ['required', Rule::in([1, 7, 30])],
|
||||
'sharePasswordProtect' => ['boolean'],
|
||||
'selected' => ['required', 'array', 'min:1'],
|
||||
'selected.*' => ['integer'],
|
||||
]);
|
||||
|
||||
// Throttle to protect the mail server from being used as a relay.
|
||||
$key = 'share-certificates:'.Auth::id();
|
||||
// Throttle emails to protect the mail server from being used as a relay.
|
||||
if (filled($validated['shareEmail'])) {
|
||||
$key = 'share-certificates:'.Auth::id();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, maxAttempts: 10)) {
|
||||
$this->addError('shareEmail', __('Too many share attempts. Please try again later.'));
|
||||
if (RateLimiter::tooManyAttempts($key, maxAttempts: 10)) {
|
||||
$this->addError('shareEmail', __('Too many share attempts. Please try again later.'));
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, decaySeconds: 3600);
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, decaySeconds: 3600);
|
||||
|
||||
$certificates = Certificate::whereIn('id', $validated['selected'])
|
||||
->whereNotNull('file_path')
|
||||
->get();
|
||||
|
|
@ -109,56 +114,39 @@ new #[Title('Certificates')] class extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
$validated['shareMethod'] === 'zip'
|
||||
? $this->shareAsZip($certificates, $validated['shareEmail'])
|
||||
: $this->shareAsLinks($certificates, $validated['shareEmail']);
|
||||
}
|
||||
$password = $validated['sharePasswordProtect'] ? Str::password(12, symbols: false) : null;
|
||||
|
||||
/**
|
||||
* @param Collection<int, Certificate> $certificates
|
||||
*/
|
||||
private function shareAsLinks(Collection $certificates, string $email): void
|
||||
{
|
||||
$expiresAt = now()->addHours(48);
|
||||
$share = Share::create([
|
||||
'token' => Str::random(48),
|
||||
'recipient_email' => $validated['shareEmail'] ?: null,
|
||||
'password' => $password,
|
||||
'expires_at' => now()->addDays($validated['shareExpiresInDays']),
|
||||
]);
|
||||
|
||||
$links = $certificates->map(fn (Certificate $certificate) => [
|
||||
'title' => $certificate->title,
|
||||
'url' => UrlGenerator::temporarySignedRoute('shared.certificate', $expiresAt, ['certificate' => $certificate->id]),
|
||||
])->all();
|
||||
$share->certificates()->attach($certificates->modelKeys());
|
||||
|
||||
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(
|
||||
if (filled($validated['shareEmail'])) {
|
||||
Mail::to($validated['shareEmail'])->send(new ShareCreatedMail(
|
||||
senderName: Auth::user()->name,
|
||||
zipPath: $zipPath,
|
||||
shareUrl: $share->url(),
|
||||
expiresAt: $share->expires_at->toDayDateTimeString(),
|
||||
certificateCount: $certificates->count(),
|
||||
hasPassword: $password !== null,
|
||||
));
|
||||
} finally {
|
||||
@unlink($zipPath);
|
||||
}
|
||||
|
||||
// Show the password so the user can share it via a separate channel.
|
||||
$this->sharePassword = $password;
|
||||
// Show the link (and password) so the user can pass them on; the
|
||||
// password should travel through a separate channel than the link.
|
||||
$this->createdShareUrl = $share->url();
|
||||
$this->createdSharePassword = $password;
|
||||
$this->reset('selected', 'shareEmail');
|
||||
Flux::modal('share')->close();
|
||||
Flux::modal('share-password')->show();
|
||||
Flux::modal('share-created')->show();
|
||||
}
|
||||
|
||||
public function dismissCreatedShare(): void
|
||||
{
|
||||
$this->reset('createdShareUrl', 'createdSharePassword');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -209,7 +197,7 @@ new #[Title('Certificates')] class extends Component {
|
|||
</div>
|
||||
|
||||
@if (count($selected) > 0)
|
||||
<flux:card class="flex items-center justify-between gap-4 py-3">
|
||||
<flux:card class="flex items-center justify-between gap-4 rounded-2xl border-emerald-200 bg-emerald-50/60 py-3 dark:border-emerald-500/25 dark:bg-emerald-500/10">
|
||||
<flux:text>{{ trans_choice(':count certificate selected|:count certificates selected', count($selected), ['count' => count($selected)]) }}</flux:text>
|
||||
<flux:modal.trigger name="share">
|
||||
<flux:button icon="share" size="sm">{{ __('Share') }}</flux:button>
|
||||
|
|
@ -218,8 +206,10 @@ new #[Title('Certificates')] class extends Component {
|
|||
@endif
|
||||
|
||||
@if ($this->certificates->isEmpty())
|
||||
<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:card class="flex flex-col items-center gap-3 rounded-2xl py-12 text-center">
|
||||
<span class="flex size-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
|
||||
<flux:icon.shield-check class="size-6" />
|
||||
</span>
|
||||
<flux:heading size="lg">{{ __('No certificates found') }}</flux:heading>
|
||||
<flux:text>
|
||||
{{ $categoryFilter !== ''
|
||||
|
|
@ -228,6 +218,7 @@ 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>
|
||||
|
|
@ -302,6 +293,7 @@ new #[Title('Certificates')] class extends Component {
|
|||
@endforeach
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Share modal --}}
|
||||
|
|
@ -309,42 +301,63 @@ 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">{{ __('Send the selected certificates to an external recipient.') }}</flux:text>
|
||||
<flux:text class="mt-2">{{ __('Create a secure page where the recipient can view and download the selected certificates.') }}</flux:text>
|
||||
</div>
|
||||
|
||||
<flux:input type="email" wire:model="shareEmail" :label="__('Recipient email')" required />
|
||||
<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: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>
|
||||
<flux:checkbox
|
||||
wire:model="sharePasswordProtect"
|
||||
:label="__('Protect with a password')"
|
||||
:description="__('A password is generated for you; share it with the recipient through a different channel (SMS or WhatsApp).')"
|
||||
/>
|
||||
|
||||
<flux:input
|
||||
type="email"
|
||||
wire:model="shareEmail"
|
||||
:label="__('Email the link to a recipient (optional)')"
|
||||
:description="__('Leave empty to copy the link and send it yourself.')"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<flux:modal.close>
|
||||
<flux:button variant="ghost">{{ __('Cancel') }}</flux:button>
|
||||
</flux:modal.close>
|
||||
<flux:button type="submit" variant="primary">{{ __('Send') }}</flux:button>
|
||||
<flux:button type="submit" variant="primary">{{ __('Create share') }}</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</flux:modal>
|
||||
|
||||
{{-- ZIP password reveal modal --}}
|
||||
<flux:modal name="share-password" class="min-w-96">
|
||||
{{-- Share created modal --}}
|
||||
<flux:modal name="share-created" class="min-w-96">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Archive password') }}</flux:heading>
|
||||
<flux:heading size="lg">{{ __('Share created') }}</flux:heading>
|
||||
<flux:text class="mt-2">
|
||||
{{ __('The ZIP was emailed. Share this password with the recipient through a different channel (SMS or WhatsApp). It will not be shown again.') }}
|
||||
{{ __('The recipient can view and download the certificates on this page:') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
|
||||
@if ($sharePassword)
|
||||
<flux:input readonly copyable value="{{ $sharePassword }}" class="font-mono" />
|
||||
@if ($createdShareUrl)
|
||||
<flux:input readonly copyable value="{{ $createdShareUrl }}" :label="__('Secure link')" />
|
||||
@endif
|
||||
|
||||
@if ($createdSharePassword)
|
||||
<flux:input readonly copyable value="{{ $createdSharePassword }}" :label="__('Password')" class="font-mono" />
|
||||
<flux:text class="text-sm">
|
||||
{{ __('Share this password with the recipient through a different channel (SMS or WhatsApp). You can look it up later on the Shares page.') }}
|
||||
</flux:text>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end">
|
||||
<flux:modal.close>
|
||||
<flux:button variant="primary" wire:click="$set('sharePassword', null)">{{ __('Done') }}</flux:button>
|
||||
<flux:button variant="primary" wire:click="dismissCreatedShare">
|
||||
{{ __('Done') }}
|
||||
</flux:button>
|
||||
</flux:modal.close>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ new class extends Component {
|
|||
}; ?>
|
||||
|
||||
<div
|
||||
class="py-6 space-y-6 border shadow-sm rounded-xl border-zinc-200 dark:border-white/10"
|
||||
class="py-6 space-y-6 border shadow-sm rounded-2xl border-zinc-200 dark:border-white/10"
|
||||
wire:cloak
|
||||
x-data="{ showRecoveryCodes: false }"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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-green-400 !text-green-600">
|
||||
<flux:text class="mt-2 font-medium !dark:text-emerald-400 !text-emerald-600">
|
||||
{{ __('A new verification link has been sent to your email address.') }}
|
||||
</flux:text>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -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-lg border-zinc-200 dark:border-zinc-700 overflow-hidden">
|
||||
<div class="border rounded-2xl 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">
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ new class extends Component {
|
|||
<flux:icon.check
|
||||
x-show="copied"
|
||||
variant="solid"
|
||||
class="text-green-500"
|
||||
class="text-emerald-500"
|
||||
></flux:icon>
|
||||
</button>
|
||||
@endempty
|
||||
|
|
|
|||
194
resources/views/pages/shares/⚡index.blade.php
Normal file
194
resources/views/pages/shares/⚡index.blade.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?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>
|
||||
|
|
@ -87,8 +87,11 @@ new #[Title('Dashboard')] class extends Component {
|
|||
</div>
|
||||
|
||||
@if ($this->stats['total'] === 0)
|
||||
<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: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: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.') }}
|
||||
|
|
@ -102,18 +105,20 @@ 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'],
|
||||
['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'],
|
||||
['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'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@foreach ($tiles as $tile)
|
||||
<flux:card class="flex flex-col gap-2">
|
||||
<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">
|
||||
<div class="flex items-center justify-between">
|
||||
<flux:text class="text-sm">{{ $tile['label'] }}</flux:text>
|
||||
<flux:icon :name="$tile['icon']" class="size-5 text-zinc-400" />
|
||||
<span @class(['flex size-9 items-center justify-center rounded-xl', $tile['tile']])>
|
||||
<flux:icon :name="$tile['icon']" class="size-5" />
|
||||
</span>
|
||||
</div>
|
||||
<flux:heading size="xl" @class(['text-3xl font-semibold', $tile['class']])>{{ $tile['value'] }}</flux:heading>
|
||||
</flux:card>
|
||||
|
|
@ -122,7 +127,7 @@ new #[Title('Dashboard')] class extends Component {
|
|||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{{-- Needs attention --}}
|
||||
<flux:card class="lg:col-span-2">
|
||||
<flux:card class="rounded-2xl 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>
|
||||
|
|
@ -130,7 +135,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-green-500" />
|
||||
<flux:icon.check-badge class="size-8 text-emerald-500" />
|
||||
<flux:text>{{ __('Nothing needs attention. All your certificates are up to date.') }}</flux:text>
|
||||
</div>
|
||||
@else
|
||||
|
|
@ -161,7 +166,7 @@ new #[Title('Dashboard')] class extends Component {
|
|||
|
||||
<div class="flex flex-col gap-6">
|
||||
{{-- By category --}}
|
||||
<flux:card>
|
||||
<flux:card class="rounded-2xl">
|
||||
<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>
|
||||
|
|
@ -182,7 +187,7 @@ new #[Title('Dashboard')] class extends Component {
|
|||
</flux:card>
|
||||
|
||||
{{-- Recently added --}}
|
||||
<flux:card>
|
||||
<flux:card class="rounded-2xl">
|
||||
<flux:heading size="lg" class="mb-4">{{ __('Recently added') }}</flux:heading>
|
||||
<div class="flex flex-col gap-3">
|
||||
@foreach ($this->recent as $certificate)
|
||||
|
|
|
|||
13
resources/views/shared/expired.blade.php
Normal file
13
resources/views/shared/expired.blade.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
@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
|
||||
21
resources/views/shared/layout.blade.php
Normal file
21
resources/views/shared/layout.blade.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!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>
|
||||
63
resources/views/shared/show.blade.php
Normal file
63
resources/views/shared/show.blade.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
@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
|
||||
33
resources/views/shared/unlock.blade.php
Normal file
33
resources/views/shared/unlock.blade.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
@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
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
use App\Http\Controllers\CertificateDownloadController;
|
||||
use App\Http\Controllers\LanguageController;
|
||||
use App\Http\Controllers\MarketingController;
|
||||
use App\Http\Controllers\SharedCertificateController;
|
||||
use App\Http\Controllers\SharePageController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', [MarketingController::class, 'home'])->name('home');
|
||||
|
|
@ -22,11 +22,17 @@ 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 signed URL, not by login.
|
||||
Route::get('shared/certificates/{certificate}', SharedCertificateController::class)
|
||||
->middleware('signed')
|
||||
->name('shared.certificate');
|
||||
// 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');
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\SharedCertificateLinksMail;
|
||||
use App\Mail\SharedCertificatesMail;
|
||||
use App\Mail\ShareCreatedMail;
|
||||
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 () {
|
||||
|
|
@ -23,23 +22,7 @@ function sharableCertificate(User $user): Certificate
|
|||
return Certificate::factory()->for($user)->create(['file_path' => $path]);
|
||||
}
|
||||
|
||||
test('sharing by secure link emails the recipient', function () {
|
||||
$user = User::factory()->create();
|
||||
$certificate = sharableCertificate($user);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test('pages::certificates.index')
|
||||
->set('selected', [$certificate->id])
|
||||
->set('shareEmail', 'friend@example.com')
|
||||
->set('shareMethod', 'link')
|
||||
->call('share')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Mail::assertSent(SharedCertificateLinksMail::class, fn ($mail) => $mail->hasTo('friend@example.com'));
|
||||
});
|
||||
|
||||
test('sharing by zip emails an attachment and reveals a password', function () {
|
||||
test('creating a share stores it and reveals the link and password', function () {
|
||||
$user = User::factory()->create();
|
||||
$certificate = sharableCertificate($user);
|
||||
|
||||
|
|
@ -47,29 +30,81 @@ test('sharing by zip emails an attachment and reveals a password', function () {
|
|||
|
||||
$component = Livewire::test('pages::certificates.index')
|
||||
->set('selected', [$certificate->id])
|
||||
->set('shareEmail', 'friend@example.com')
|
||||
->set('shareMethod', 'zip')
|
||||
->set('shareExpiresInDays', 7)
|
||||
->set('sharePasswordProtect', true)
|
||||
->call('share')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($component->get('sharePassword'))->toHaveLength(12);
|
||||
$share = Share::first();
|
||||
|
||||
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(['shareEmail', 'selected']);
|
||||
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('sharing is rate limited', function () {
|
||||
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 () {
|
||||
$user = User::factory()->create();
|
||||
$certificate = sharableCertificate($user);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test('pages::certificates.index')
|
||||
->set('selected', [$certificate->id])
|
||||
->set('shareEmail', 'friend@example.com')
|
||||
->call('share')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Share::first()->recipient_email)->toBe('friend@example.com');
|
||||
|
||||
Mail::assertSent(ShareCreatedMail::class, fn ($mail) => $mail->hasTo('friend@example.com'));
|
||||
});
|
||||
|
||||
test('sharing requires a selection', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Livewire::test('pages::certificates.index')
|
||||
->set('selected', [])
|
||||
->call('share')
|
||||
->assertHasErrors(['selected']);
|
||||
|
||||
expect(Share::count())->toBe(0);
|
||||
});
|
||||
|
||||
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 () {
|
||||
$user = User::factory()->create();
|
||||
$certificate = sharableCertificate($user);
|
||||
|
||||
|
|
@ -87,19 +122,3 @@ test('sharing 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();
|
||||
});
|
||||
|
|
|
|||
64
tests/Feature/Shares/ShareManagementTest.php
Normal file
64
tests/Feature/Shares/ShareManagementTest.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?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'));
|
||||
});
|
||||
139
tests/Feature/Shares/SharePageTest.php
Normal file
139
tests/Feature/Shares/SharePageTest.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?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');
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue