im profil kann ein nutzer nun seine daten exportieren. man kann seinen account löschen. nach 2 jahren werden inaktive accounts gelöscht, 1 monat vorher wird eine email geschickt. Hilfetexte und Legal Pages in der Guest PWA korrigiert und vom layout her optimiert (dark mode).
This commit is contained in:
109
app/Console/Commands/ProcessTenantRetention.php
Normal file
109
app/Console/Commands/ProcessTenantRetention.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\AnonymizeAccount;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\InactiveTenantDeletionWarning;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class ProcessTenantRetention extends Command
|
||||
{
|
||||
protected $signature = 'tenants:retention-scan';
|
||||
|
||||
protected $description = 'Scans tenants for Inaktivität, verschickt Warnungen und startet Anonymisierungen.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$warningThreshold = now()->subMonths(23);
|
||||
$deletionThreshold = now()->subMonths(24);
|
||||
|
||||
Tenant::query()
|
||||
->whereNull('anonymized_at')
|
||||
->with(['user'])
|
||||
->withMax('events as last_event_activity', 'updated_at')
|
||||
->withMax('purchases as last_purchase_activity', 'purchased_at')
|
||||
->withMax('photos as last_photo_activity', 'created_at')
|
||||
->chunkById(100, function ($tenants) use ($warningThreshold, $deletionThreshold) {
|
||||
foreach ($tenants as $tenant) {
|
||||
$lastActivity = $this->determineLastActivity($tenant);
|
||||
|
||||
if (! $lastActivity) {
|
||||
$lastActivity = $tenant->created_at ?? now();
|
||||
}
|
||||
|
||||
if ($this->hasActiveSubscription($tenant)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($lastActivity->lte($deletionThreshold)) {
|
||||
$this->dispatchAnonymization($tenant);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($lastActivity->lte($warningThreshold) && $tenant->deletion_warning_sent_at === null) {
|
||||
$this->sendWarning($tenant, $lastActivity->copy()->addMonths(24));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(__('profile.retention.scan_complete'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function determineLastActivity(Tenant $tenant): ?Carbon
|
||||
{
|
||||
$candidates = collect([
|
||||
$tenant->last_activity_at,
|
||||
$tenant->last_event_activity ? Carbon::parse($tenant->last_event_activity) : null,
|
||||
$tenant->last_purchase_activity ? Carbon::parse($tenant->last_purchase_activity) : null,
|
||||
$tenant->last_photo_activity ? Carbon::parse($tenant->last_photo_activity) : null,
|
||||
])->filter();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
return $tenant->created_at ? $tenant->created_at->copy() : null;
|
||||
}
|
||||
|
||||
return $candidates->sort()->last();
|
||||
}
|
||||
|
||||
protected function hasActiveSubscription(Tenant $tenant): bool
|
||||
{
|
||||
return $tenant->tenantPackages()
|
||||
->where('active', true)
|
||||
->whereHas('package', fn ($query) => $query->where('type', 'reseller'))
|
||||
->exists();
|
||||
}
|
||||
|
||||
protected function sendWarning(Tenant $tenant, Carbon $plannedDeletion): void
|
||||
{
|
||||
$email = $tenant->contact_email
|
||||
?? $tenant->email
|
||||
?? $tenant->user?->email;
|
||||
|
||||
if (! $email) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::route('mail', $email)
|
||||
->notify(new InactiveTenantDeletionWarning($tenant, $plannedDeletion));
|
||||
|
||||
$tenant->forceFill([
|
||||
'deletion_warning_sent_at' => now(),
|
||||
'pending_deletion_at' => $plannedDeletion,
|
||||
])->save();
|
||||
}
|
||||
|
||||
protected function dispatchAnonymization(Tenant $tenant): void
|
||||
{
|
||||
if ($tenant->anonymized_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
AnonymizeAccount::dispatch($tenant->user?->id, $tenant->id);
|
||||
}
|
||||
}
|
||||
41
app/Console/Commands/PurgeExpiredDataExports.php
Normal file
41
app/Console/Commands/PurgeExpiredDataExports.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\DataExport;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PurgeExpiredDataExports extends Command
|
||||
{
|
||||
protected $signature = 'exports:purge';
|
||||
|
||||
protected $description = 'Delete abgelaufene Datenexporte und entfernen die entsprechenden Dateien.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$expired = DataExport::query()
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', now())
|
||||
->get();
|
||||
|
||||
if ($expired->isEmpty()) {
|
||||
$this->info(__('profile.export.purge.none'));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($expired as $export) {
|
||||
if ($export->path && Storage::disk('local')->exists($export->path)) {
|
||||
Storage::disk('local')->delete($export->path);
|
||||
}
|
||||
$export->delete();
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->info(trans_choice('profile.export.purge.deleted', $count, ['count' => $count]));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,35 @@ use App\Models\LegalPage;
|
||||
use App\Support\ApiError;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\Autolink\AutolinkExtension;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
|
||||
use League\CommonMark\Extension\Table\TableExtension;
|
||||
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LegalController extends BaseController
|
||||
{
|
||||
protected MarkdownConverter $markdown;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$environment = new Environment([
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
]);
|
||||
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
$environment->addExtension(new TableExtension());
|
||||
$environment->addExtension(new AutolinkExtension());
|
||||
$environment->addExtension(new StrikethroughExtension());
|
||||
$environment->addExtension(new TaskListExtension());
|
||||
|
||||
$this->markdown = new MarkdownConverter($environment);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $slug)
|
||||
{
|
||||
$locale = $request->query('lang', 'de');
|
||||
@@ -39,7 +64,7 @@ class LegalController extends BaseController
|
||||
}
|
||||
|
||||
$title = $page->title[$locale] ?? $page->title[$page->locale_fallback] ?? $page->title['de'] ?? $page->title['en'] ?? $page->slug;
|
||||
$body = $page->body_markdown[$locale] ?? $page->body_markdown[$page->locale_fallback] ?? reset($page->body_markdown);
|
||||
$body = $page->body_markdown[$locale] ?? $page->body_markdown[$page->locale_fallback] ?? reset($page->body_markdown) ?? '';
|
||||
|
||||
return response()->json([
|
||||
'slug' => $page->slug,
|
||||
@@ -48,6 +73,12 @@ class LegalController extends BaseController
|
||||
'locale' => $locale,
|
||||
'title' => $title,
|
||||
'body_markdown' => (string) $body,
|
||||
'body_html' => $this->convertMarkdownToHtml($body),
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
protected function convertMarkdownToHtml(string $markdown): string
|
||||
{
|
||||
return trim((string) $this->markdown->convert($markdown));
|
||||
}
|
||||
}
|
||||
|
||||
39
app/Http/Controllers/ProfileAccountController.php
Normal file
39
app/Http/Controllers/ProfileAccountController.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\AnonymizeAccount;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProfileAccountController extends Controller
|
||||
{
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$confirmationWord = Str::upper(__('profile.delete.confirmation_keyword'));
|
||||
|
||||
$request->validate([
|
||||
'confirmation' => [
|
||||
'required',
|
||||
function ($attribute, $value, $fail) use ($confirmationWord) {
|
||||
if (Str::upper(trim((string) $value)) !== $confirmationWord) {
|
||||
$fail(__('profile.delete.validation', ['word' => $confirmationWord]));
|
||||
}
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless($user, 403);
|
||||
|
||||
if ($user->tenant?->anonymized_at) {
|
||||
return back()->with('status', __('profile.delete.already'));
|
||||
}
|
||||
|
||||
AnonymizeAccount::dispatch($user->id);
|
||||
|
||||
return redirect()->route('profile.index')->with('status', __('profile.delete.started'));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DataExport;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
@@ -43,6 +44,36 @@ class ProfileController extends Controller
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$recentExports = $user->dataExports()
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn ($export) => [
|
||||
'id' => $export->id,
|
||||
'status' => $export->status,
|
||||
'size' => $export->size_bytes,
|
||||
'expires_at' => optional($export->expires_at)->toIso8601String(),
|
||||
'created_at' => optional($export->created_at)->toIso8601String(),
|
||||
'download_url' => $export->isReady() && ! $export->hasExpired()
|
||||
? route('profile.data-exports.download', $export)
|
||||
: null,
|
||||
'error_message' => $export->error_message,
|
||||
]);
|
||||
|
||||
$pendingExport = $user->dataExports()
|
||||
->whereIn('status', [
|
||||
DataExport::STATUS_PENDING,
|
||||
DataExport::STATUS_PROCESSING,
|
||||
])
|
||||
->exists();
|
||||
|
||||
$lastReadyExport = $user->dataExports()
|
||||
->where('status', DataExport::STATUS_READY)
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
$nextExportAt = $lastReadyExport?->created_at?->clone()->addDay();
|
||||
|
||||
return Inertia::render('Profile/Index', [
|
||||
'userData' => [
|
||||
'id' => $user->id,
|
||||
@@ -68,6 +99,11 @@ class ProfileController extends Controller
|
||||
] : null,
|
||||
] : null,
|
||||
'purchases' => $purchases,
|
||||
'dataExport' => [
|
||||
'exports' => $recentExports,
|
||||
'hasPending' => $pendingExport,
|
||||
'nextRequestAt' => $nextExportAt?->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
58
app/Http/Controllers/ProfileDataExportController.php
Normal file
58
app/Http/Controllers/ProfileDataExportController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\GenerateDataExport;
|
||||
use App\Models\DataExport;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ProfileDataExportController extends Controller
|
||||
{
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless($user, 403);
|
||||
|
||||
$hasRecentExport = $user->dataExports()
|
||||
->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING])
|
||||
->exists();
|
||||
|
||||
if ($hasRecentExport) {
|
||||
return back()->with('error', __('profile.export.messages.in_progress'));
|
||||
}
|
||||
|
||||
$recentReadyExport = $user->dataExports()
|
||||
->where('status', DataExport::STATUS_READY)
|
||||
->where('created_at', '>=', now()->subDay())
|
||||
->exists();
|
||||
|
||||
if ($recentReadyExport) {
|
||||
return back()->with('error', __('profile.export.messages.recent_ready'));
|
||||
}
|
||||
|
||||
$export = $user->dataExports()->create([
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'status' => DataExport::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
GenerateDataExport::dispatch($export->id);
|
||||
|
||||
return back()->with('status', __('profile.export.messages.started'));
|
||||
}
|
||||
|
||||
public function download(Request $request, DataExport $export)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless($user && $export->user_id === $user->id, 403);
|
||||
|
||||
if (! $export->isReady() || $export->hasExpired() || ! $export->path) {
|
||||
return back()->with('error', __('profile.export.messages.not_available'));
|
||||
}
|
||||
|
||||
return Storage::disk('local')->download($export->path, sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd')));
|
||||
}
|
||||
}
|
||||
68
app/Jobs/AnonymizeAccount.php
Normal file
68
app/Jobs/AnonymizeAccount.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Compliance\AccountAnonymizer;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class AnonymizeAccount implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(private readonly ?int $userId = null, private readonly ?int $tenantId = null)
|
||||
{
|
||||
if ($this->userId === null && $this->tenantId === null) {
|
||||
throw new \InvalidArgumentException('An anonymization job requires either a user or tenant id.');
|
||||
}
|
||||
|
||||
$this->onQueue('default');
|
||||
}
|
||||
|
||||
public function handle(AccountAnonymizer $anonymizer): void
|
||||
{
|
||||
if ($this->userId) {
|
||||
$user = User::with('tenant')->find($this->userId);
|
||||
|
||||
if (! $user || $user->tenant?->anonymized_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
$anonymizer->anonymize($user);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->tenantId) {
|
||||
$tenant = Tenant::with('user')->find($this->tenantId);
|
||||
|
||||
if (! $tenant || $tenant->anonymized_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($tenant->user) {
|
||||
$anonymizer->anonymize($tenant->user);
|
||||
} else {
|
||||
$anonymizer->anonymizeTenantOnly($tenant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function userId(): ?int
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function tenantId(): ?int
|
||||
{
|
||||
return $this->tenantId;
|
||||
}
|
||||
}
|
||||
224
app/Jobs/GenerateDataExport.php
Normal file
224
app/Jobs/GenerateDataExport.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\DataExport;
|
||||
use App\Models\Event;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use ZipArchive;
|
||||
|
||||
class GenerateDataExport implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(private readonly int $exportId)
|
||||
{
|
||||
$this->onQueue('default');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$export = DataExport::with(['user', 'tenant'])->find($this->exportId);
|
||||
|
||||
if (! $export) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $export->user) {
|
||||
$export->update([
|
||||
'status' => DataExport::STATUS_FAILED,
|
||||
'error_message' => 'User no longer exists.',
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$export->update(['status' => DataExport::STATUS_PROCESSING, 'error_message' => null]);
|
||||
|
||||
try {
|
||||
$payload = $this->buildPayload($export->user, $export->tenant);
|
||||
$zipPath = $this->writeArchive($export, $payload);
|
||||
$export->update([
|
||||
'status' => DataExport::STATUS_READY,
|
||||
'path' => $zipPath,
|
||||
'size_bytes' => Storage::disk('local')->size($zipPath),
|
||||
'expires_at' => now()->addDays(14),
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
$export->update([
|
||||
'status' => DataExport::STATUS_FAILED,
|
||||
'error_message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function buildPayload(User $user, ?Tenant $tenant): array
|
||||
{
|
||||
$profile = [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'username' => $user->username,
|
||||
'preferred_locale' => $user->preferred_locale,
|
||||
'created_at' => optional($user->created_at)->toIso8601String(),
|
||||
'email_verified_at' => optional($user->email_verified_at)->toIso8601String(),
|
||||
],
|
||||
];
|
||||
|
||||
if ($tenant) {
|
||||
$profile['tenant'] = [
|
||||
'id' => $tenant->id,
|
||||
'name' => $tenant->name,
|
||||
'slug' => $tenant->slug,
|
||||
'contact_email' => $tenant->contact_email,
|
||||
'contact_phone' => $tenant->contact_phone,
|
||||
'subscription_status' => $tenant->subscription_status,
|
||||
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toIso8601String(),
|
||||
'created_at' => optional($tenant->created_at)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
$events = $tenant
|
||||
? $this->collectEvents($tenant)
|
||||
: [];
|
||||
$invoices = $tenant
|
||||
? $this->collectInvoices($tenant)
|
||||
: [];
|
||||
|
||||
return [
|
||||
'profile' => $profile,
|
||||
'events' => $events,
|
||||
'invoices' => $invoices,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function collectEvents(Tenant $tenant): array
|
||||
{
|
||||
$events = Event::query()
|
||||
->withCount([
|
||||
'photos as photos_total',
|
||||
'photos as featured_photos_total' => fn ($query) => $query->where('is_featured', true),
|
||||
'joinTokens',
|
||||
'members',
|
||||
])
|
||||
->where('tenant_id', $tenant->id)
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
$likeCounts = DB::table('photo_likes')
|
||||
->join('photos', 'photo_likes.photo_id', '=', 'photos.id')
|
||||
->where('photos.tenant_id', $tenant->id)
|
||||
->groupBy('photos.event_id')
|
||||
->pluck(DB::raw('COUNT(*)'), 'photos.event_id');
|
||||
|
||||
return $events
|
||||
->map(function (Event $event) use ($likeCounts): array {
|
||||
$likes = (int) ($likeCounts[$event->id] ?? 0);
|
||||
|
||||
return [
|
||||
'id' => $event->id,
|
||||
'slug' => $event->slug,
|
||||
'status' => $event->status,
|
||||
'name' => $event->name,
|
||||
'location' => $event->location,
|
||||
'date' => optional($event->date)->toIso8601String(),
|
||||
'photos_total' => (int) ($event->photos_total ?? 0),
|
||||
'featured_photos_total' => (int) ($event->featured_photos_total ?? 0),
|
||||
'join_tokens_total' => (int) ($event->join_tokens_count ?? 0),
|
||||
'members_total' => (int) ($event->members_count ?? 0),
|
||||
'likes_total' => $likes,
|
||||
'created_at' => optional($event->created_at)->toIso8601String(),
|
||||
'updated_at' => optional($event->updated_at)->toIso8601String(),
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function collectInvoices(Tenant $tenant): array
|
||||
{
|
||||
return PackagePurchase::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->with('package')
|
||||
->latest('purchased_at')
|
||||
->get()
|
||||
->map(function (PackagePurchase $purchase): array {
|
||||
return [
|
||||
'id' => $purchase->id,
|
||||
'package' => $purchase->package?->getNameForLocale(app()->getLocale())
|
||||
?? $purchase->package?->name,
|
||||
'price' => $purchase->price !== null ? (float) $purchase->price : null,
|
||||
'currency' => 'EUR',
|
||||
'type' => $purchase->type,
|
||||
'provider' => $purchase->provider,
|
||||
'provider_id' => $purchase->provider_id,
|
||||
'purchased_at' => optional($purchase->purchased_at)->toIso8601String(),
|
||||
'refunded' => (bool) $purchase->refunded,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function writeArchive(DataExport $export, array $payload): string
|
||||
{
|
||||
$directory = 'exports/user-'.$export->user_id;
|
||||
Storage::disk('local')->makeDirectory($directory);
|
||||
$filename = sprintf('data-export-%s.zip', Str::uuid());
|
||||
$path = $directory.'/'.$filename;
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$fullPath = Storage::disk('local')->path($path);
|
||||
|
||||
if ($zip->open($fullPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Unable to create export archive.');
|
||||
}
|
||||
|
||||
$zip->addFromString('profile.json', json_encode($payload['profile'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$zip->addFromString('events.json', json_encode($payload['events'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$zip->addFromString('invoices.json', json_encode($payload['invoices'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$locale = $export->user?->preferred_locale ?? app()->getLocale();
|
||||
$readme = implode("\n", [
|
||||
__('profile.export.readme.title', [], $locale),
|
||||
'---------------------',
|
||||
__('profile.export.readme.description', [], $locale),
|
||||
__('profile.export.readme.generated', [
|
||||
'date' => now()->copy()->locale($locale)->translatedFormat('d. F Y H:i'),
|
||||
], $locale),
|
||||
__('profile.export.readme.expiry', [], $locale),
|
||||
]);
|
||||
$zip->addFromString('README.txt', $readme);
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
54
app/Models/DataExport.php
Normal file
54
app/Models/DataExport.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DataExport extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
|
||||
public const STATUS_READY = 'ready';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'tenant_id',
|
||||
'status',
|
||||
'path',
|
||||
'size_bytes',
|
||||
'expires_at',
|
||||
'error_message',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function isReady(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_READY;
|
||||
}
|
||||
|
||||
public function hasExpired(): bool
|
||||
{
|
||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||
}
|
||||
}
|
||||
@@ -156,4 +156,9 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
||||
{
|
||||
return $this->hasMany(EventMember::class);
|
||||
}
|
||||
|
||||
public function dataExports(): HasMany
|
||||
{
|
||||
return $this->hasMany(DataExport::class);
|
||||
}
|
||||
}
|
||||
|
||||
39
app/Notifications/InactiveTenantDeletionWarning.php
Normal file
39
app/Notifications/InactiveTenantDeletionWarning.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class InactiveTenantDeletionWarning extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(private readonly Tenant $tenant, private readonly Carbon $plannedDeletion)
|
||||
{
|
||||
$this->afterCommit();
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$locale = $this->tenant->user?->preferred_locale ?? app()->getLocale();
|
||||
$formattedDate = $this->plannedDeletion->copy()->locale($locale)->translatedFormat('d. F Y');
|
||||
|
||||
return (new MailMessage)
|
||||
->locale($locale)
|
||||
->subject(__('profile.retention.warning_subject', [], $locale))
|
||||
->line(__('profile.retention.line1', ['name' => $this->tenant->name], $locale))
|
||||
->line(__('profile.retention.line2', ['date' => $formattedDate], $locale))
|
||||
->line(__('profile.retention.line3', [], $locale))
|
||||
->action(__('profile.retention.action', [], $locale), url('/login'));
|
||||
}
|
||||
}
|
||||
151
app/Services/Compliance/AccountAnonymizer.php
Normal file
151
app/Services/Compliance/AccountAnonymizer.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Compliance;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Models\EventMediaAsset;
|
||||
use App\Models\EventMember;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AccountAnonymizer
|
||||
{
|
||||
public function anonymize(User $user): void
|
||||
{
|
||||
DB::transaction(function () use ($user) {
|
||||
$tenant = $user->tenant;
|
||||
|
||||
if ($tenant) {
|
||||
$this->purgeTenantMedia($tenant);
|
||||
$this->scrubTenantData($tenant);
|
||||
}
|
||||
|
||||
$this->scrubUser($user);
|
||||
});
|
||||
}
|
||||
|
||||
public function anonymizeTenantOnly(Tenant $tenant): void
|
||||
{
|
||||
DB::transaction(function () use ($tenant) {
|
||||
$this->purgeTenantMedia($tenant);
|
||||
$this->scrubTenantData($tenant);
|
||||
});
|
||||
}
|
||||
|
||||
protected function purgeTenantMedia(Tenant $tenant): void
|
||||
{
|
||||
EventMediaAsset::query()
|
||||
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenant->id))
|
||||
->chunkById(100, function ($assets) {
|
||||
foreach ($assets as $asset) {
|
||||
if ($asset->disk && $asset->path) {
|
||||
Storage::disk($asset->disk)->delete($asset->path);
|
||||
}
|
||||
|
||||
$asset->delete();
|
||||
}
|
||||
});
|
||||
|
||||
Photo::query()
|
||||
->where(function ($query) use ($tenant) {
|
||||
$query->where('tenant_id', $tenant->id)
|
||||
->orWhereHas('event', fn ($inner) => $inner->where('tenant_id', $tenant->id));
|
||||
})
|
||||
->chunkById(200, function ($photos) {
|
||||
foreach ($photos as $photo) {
|
||||
$photo->delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function scrubTenantData(Tenant $tenant): void
|
||||
{
|
||||
$eventIds = Event::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->pluck('id');
|
||||
|
||||
EventJoinToken::query()->whereIn('event_id', $eventIds)->delete();
|
||||
EventMember::query()->where('tenant_id', $tenant->id)->delete();
|
||||
|
||||
Event::query()
|
||||
->whereIn('id', $eventIds)
|
||||
->chunkById(100, function ($events) {
|
||||
foreach ($events as $event) {
|
||||
$event->forceFill([
|
||||
'name' => ['de' => 'Anonymisiertes Event', 'en' => 'Anonymized Event'],
|
||||
'description' => null,
|
||||
'location' => null,
|
||||
'slug' => 'anonymized-event-'.$event->id,
|
||||
'status' => 'archived',
|
||||
'photobooth_enabled' => false,
|
||||
'photobooth_path' => null,
|
||||
])->save();
|
||||
}
|
||||
});
|
||||
|
||||
PackagePurchase::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->update([
|
||||
'ip_address' => null,
|
||||
'user_agent' => null,
|
||||
]);
|
||||
|
||||
CheckoutSession::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->update([
|
||||
'tenant_id' => null,
|
||||
'user_id' => null,
|
||||
'provider_metadata' => [],
|
||||
]);
|
||||
|
||||
TenantPackage::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->update(['active' => false]);
|
||||
|
||||
$tenant->forceFill([
|
||||
'name' => 'Anonymisierter Tenant #'.$tenant->id,
|
||||
'slug' => 'anonymized-tenant-'.$tenant->id,
|
||||
'contact_name' => null,
|
||||
'contact_email' => null,
|
||||
'contact_phone' => null,
|
||||
'email' => null,
|
||||
'domain' => null,
|
||||
'custom_domain' => null,
|
||||
'user_id' => null,
|
||||
'anonymized_at' => now(),
|
||||
'pending_deletion_at' => null,
|
||||
'deletion_warning_sent_at' => null,
|
||||
'subscription_status' => 'deleted',
|
||||
'is_active' => 0,
|
||||
'is_suspended' => 1,
|
||||
])->save();
|
||||
}
|
||||
|
||||
protected function scrubUser(User $user): void
|
||||
{
|
||||
$placeholderEmail = sprintf('deleted+%s@fotospiel.app', $user->id);
|
||||
|
||||
$user->forceFill([
|
||||
'name' => 'Anonymisierter Benutzer',
|
||||
'email' => $placeholderEmail,
|
||||
'username' => null,
|
||||
'first_name' => null,
|
||||
'last_name' => null,
|
||||
'address' => null,
|
||||
'phone' => null,
|
||||
'password' => Str::random(40),
|
||||
'remember_token' => null,
|
||||
'pending_purchase' => false,
|
||||
'email_verified_at' => null,
|
||||
'role' => 'user',
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\Table\TableExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Finder\SplFileInfo;
|
||||
@@ -22,6 +23,7 @@ class HelpSyncService
|
||||
{
|
||||
$environment = new Environment;
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$environment->addExtension(new TableExtension);
|
||||
$this->converter = new MarkdownConverter($environment);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,15 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
\App\Console\Commands\MonitorStorageCommand::class,
|
||||
\App\Console\Commands\DispatchStorageArchiveCommand::class,
|
||||
\App\Console\Commands\CheckUploadQueuesCommand::class,
|
||||
\App\Console\Commands\PurgeExpiredDataExports::class,
|
||||
\App\Console\Commands\ProcessTenantRetention::class,
|
||||
])
|
||||
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule) {
|
||||
$schedule->command('package:check-status')->dailyAt('06:00');
|
||||
$schedule->command('photobooth:cleanup-expired')->hourly()->withoutOverlapping();
|
||||
$schedule->command('photobooth:ingest')->everyFiveMinutes()->withoutOverlapping();
|
||||
$schedule->command('exports:purge')->dailyAt('02:00');
|
||||
$schedule->command('tenants:retention-scan')->dailyAt('03:00');
|
||||
})
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->alias([
|
||||
|
||||
32
cron/checkout_reminders.sh
Normal file
32
cron/checkout_reminders.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Checkout reminder cron job
|
||||
# Run hourly to send abandoned checkout reminders
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$APP_DIR"
|
||||
|
||||
LOG_DIR="$APP_DIR/storage/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
LOG_FILE="$LOG_DIR/cron-checkout-reminders.log"
|
||||
LOCK_FILE="$LOG_DIR/checkout_reminders.lock"
|
||||
|
||||
exec 204>"$LOCK_FILE"
|
||||
if ! flock -n 204; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date --iso-8601=seconds
|
||||
}
|
||||
|
||||
echo "[$(timestamp)] Running checkout:send-reminders" >> "$LOG_FILE"
|
||||
if /usr/bin/env php artisan checkout:send-reminders --no-interaction --quiet >> "$LOG_FILE" 2>&1; then
|
||||
echo "[$(timestamp)] checkout:send-reminders completed" >> "$LOG_FILE"
|
||||
else
|
||||
status=$?
|
||||
echo "[$(timestamp)] checkout:send-reminders failed (exit $status)" >> "$LOG_FILE"
|
||||
exit $status
|
||||
fi
|
||||
32
cron/scheduler.sh
Normal file
32
cron/scheduler.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Laravel scheduler runner
|
||||
# Add to crontab: * * * * * /path/to/repo/cron/scheduler.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$APP_DIR"
|
||||
|
||||
LOG_DIR="$APP_DIR/storage/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
LOG_FILE="$LOG_DIR/cron-scheduler.log"
|
||||
LOCK_FILE="$LOG_DIR/scheduler.lock"
|
||||
|
||||
exec 203>"$LOCK_FILE"
|
||||
if ! flock -n 203; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date --iso-8601=seconds
|
||||
}
|
||||
|
||||
echo "[$(timestamp)] Running php artisan schedule:run" >> "$LOG_FILE"
|
||||
if /usr/bin/env php artisan schedule:run --no-interaction >> "$LOG_FILE" 2>&1; then
|
||||
echo "[$(timestamp)] schedule:run completed" >> "$LOG_FILE"
|
||||
else
|
||||
status=$?
|
||||
echo "[$(timestamp)] schedule:run failed (exit $status)" >> "$LOG_FILE"
|
||||
exit $status
|
||||
fi
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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('data_exports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('status', 32)->default('pending');
|
||||
$table->string('path')->nullable();
|
||||
$table->unsignedBigInteger('size_bytes')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status']);
|
||||
$table->index(['expires_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('data_exports');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?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::table('tenants', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('tenants', 'anonymized_at')) {
|
||||
$table->timestamp('anonymized_at')->nullable()->after('last_activity_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('tenants', 'pending_deletion_at')) {
|
||||
$table->timestamp('pending_deletion_at')->nullable()->after('anonymized_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('tenants', 'deletion_warning_sent_at')) {
|
||||
$table->timestamp('deletion_warning_sent_at')->nullable()->after('pending_deletion_at');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (app()->environment('local', 'testing')) {
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('tenants', 'deletion_warning_sent_at')) {
|
||||
$table->dropColumn('deletion_warning_sent_at');
|
||||
}
|
||||
if (Schema::hasColumn('tenants', 'pending_deletion_at')) {
|
||||
$table->dropColumn('pending_deletion_at');
|
||||
}
|
||||
if (Schema::hasColumn('tenants', 'anonymized_at')) {
|
||||
$table->dropColumn('anonymized_at');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
66
lang/de/profile.php
Normal file
66
lang/de/profile.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'export' => [
|
||||
'title' => 'Datenexport',
|
||||
'description' => 'Fordere eine Kopie deiner Profildaten inklusive Events und Rechnungen an. Der Link bleibt 14 Tage gültig.',
|
||||
'empty' => 'Bisher wurde kein Export erstellt. Du kannst jederzeit einen neuen Export anfordern.',
|
||||
'table' => [
|
||||
'status' => 'Status',
|
||||
'created' => 'Erstellt',
|
||||
'expires' => 'Ablauf',
|
||||
'size' => 'Größe',
|
||||
'action' => 'Aktion',
|
||||
],
|
||||
'status' => [
|
||||
'pending' => 'Geplant',
|
||||
'processing' => 'In Arbeit',
|
||||
'ready' => 'Bereit',
|
||||
'failed' => 'Fehlgeschlagen',
|
||||
],
|
||||
'pending_alert_title' => 'Export wird vorbereitet',
|
||||
'pending_alert_body' => 'Wir stellen deine Daten zusammen. Du erhältst eine Benachrichtigung, sobald der Download bereitsteht.',
|
||||
'cooldown' => 'Ein neuer Export ist ab dem :date verfügbar.',
|
||||
'button' => 'Datenexport anfordern',
|
||||
'download' => 'Download',
|
||||
'failed_reason' => 'Fehler: :reason',
|
||||
'messages' => [
|
||||
'in_progress' => 'Ein Datenexport ist bereits in Arbeit.',
|
||||
'recent_ready' => 'Du kannst nur alle 24 Stunden einen neuen Export anfordern.',
|
||||
'started' => 'Wir bereiten deinen Datenexport vor. Du erhältst eine Benachrichtigung, sobald er bereitsteht.',
|
||||
'not_available' => 'Dieser Export ist nicht mehr verfügbar.',
|
||||
],
|
||||
'purge' => [
|
||||
'none' => 'Keine abgelaufenen Datenexporte gefunden.',
|
||||
'deleted' => '{1} :count Datenexport gelöscht.|[2,*] :count Datenexporte gelöscht.',
|
||||
],
|
||||
'readme' => [
|
||||
'title' => 'Fotospiel Datenexport',
|
||||
'description' => 'Dieses Archiv enthält persönliche Daten aus deinem Konto (Profil, Events, Rechnungen).',
|
||||
'generated' => 'Erstellt am: :date',
|
||||
'expiry' => 'Der Download-Link ist 14 Tage gültig.',
|
||||
],
|
||||
],
|
||||
'delete' => [
|
||||
'title' => 'Konto anonymisieren & löschen',
|
||||
'description' => 'Entfernt alle Fotos, anonymisiert Events und sperrt den Login unwiderruflich. So erfüllst du das Recht auf Vergessenwerden.',
|
||||
'warning_title' => 'Wichtiger Hinweis',
|
||||
'warning_body' => 'Alle Medien und Photobooth-Uploads werden dauerhaft gelöscht. Statistiken bleiben lediglich anonymisiert erhalten.',
|
||||
'confirmation_label' => 'Bestätigung',
|
||||
'confirmation_placeholder' => 'LÖSCHEN',
|
||||
'confirmation_hint' => 'Bitte tippe LÖSCHEN ein, um die Anonymisierung zu bestätigen.',
|
||||
'confirmation_keyword' => 'LÖSCHEN',
|
||||
'button' => 'Konto anonymisieren',
|
||||
'validation' => 'Bitte gib ":word" ein, um die Anonymisierung zu bestätigen.',
|
||||
'started' => 'Wir führen die Anonymisierung deines Kontos jetzt durch. Du wirst nach Abschluss informiert.',
|
||||
'already' => 'Dein Konto wurde bereits anonymisiert.',
|
||||
],
|
||||
'retention' => [
|
||||
'warning_subject' => 'Dein Fotospiel-Konto wird gelöscht',
|
||||
'line1' => 'Wir haben in den letzten 24 Monaten keine Aktivität für dein Konto „:name“ festgestellt.',
|
||||
'line2' => 'Wenn du das Konto weiter nutzen möchtest, logge dich bitte vor dem :date ein oder kontaktiere uns.',
|
||||
'line3' => 'Nach Ablauf der Frist werden alle Fotos dauerhaft gelöscht und das Konto anonymisiert.',
|
||||
'action' => 'Jetzt anmelden',
|
||||
'scan_complete' => 'Retention-Scan abgeschlossen.',
|
||||
],
|
||||
];
|
||||
66
lang/en/profile.php
Normal file
66
lang/en/profile.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'export' => [
|
||||
'title' => 'Data export',
|
||||
'description' => 'Request a copy of your profile data including events and invoices. The link remains valid for 14 days.',
|
||||
'empty' => 'No export has been created yet. You can request one at any time.',
|
||||
'table' => [
|
||||
'status' => 'Status',
|
||||
'created' => 'Created',
|
||||
'expires' => 'Expires',
|
||||
'size' => 'Size',
|
||||
'action' => 'Action',
|
||||
],
|
||||
'status' => [
|
||||
'pending' => 'Scheduled',
|
||||
'processing' => 'Processing',
|
||||
'ready' => 'Ready',
|
||||
'failed' => 'Failed',
|
||||
],
|
||||
'pending_alert_title' => 'Export in progress',
|
||||
'pending_alert_body' => 'We are preparing your data. You will be notified as soon as the download is ready.',
|
||||
'cooldown' => 'A new export is available on :date.',
|
||||
'button' => 'Request data export',
|
||||
'download' => 'Download',
|
||||
'failed_reason' => 'Error: :reason',
|
||||
'messages' => [
|
||||
'in_progress' => 'A data export is already in progress.',
|
||||
'recent_ready' => 'You can request a new export only once every 24 hours.',
|
||||
'started' => 'We are preparing your data export. You will be notified once it is ready.',
|
||||
'not_available' => 'This export is no longer available.',
|
||||
],
|
||||
'purge' => [
|
||||
'none' => 'No expired data exports found.',
|
||||
'deleted' => '{1} :count data export deleted.|[2,*] :count data exports deleted.',
|
||||
],
|
||||
'readme' => [
|
||||
'title' => 'Fotospiel Data Export',
|
||||
'description' => 'This archive contains personal data from your account (profile, events, invoices).',
|
||||
'generated' => 'Generated on: :date',
|
||||
'expiry' => 'The download link stays valid for 14 days.',
|
||||
],
|
||||
],
|
||||
'delete' => [
|
||||
'title' => 'Anonymise & delete account',
|
||||
'description' => 'Removes all photos, anonymises events, and permanently blocks login. This fulfils the right to be forgotten.',
|
||||
'warning_title' => 'Important notice',
|
||||
'warning_body' => 'All media and photobooth uploads will be permanently deleted. Statistics remain available only in anonymised form.',
|
||||
'confirmation_label' => 'Confirmation',
|
||||
'confirmation_placeholder' => 'DELETE',
|
||||
'confirmation_hint' => 'Please type DELETE to confirm the anonymisation.',
|
||||
'confirmation_keyword' => 'DELETE',
|
||||
'button' => 'Anonymise account',
|
||||
'validation' => 'Please enter ":word" to confirm the anonymisation.',
|
||||
'started' => 'We are anonymising your account now. You will be notified once the process is finished.',
|
||||
'already' => 'Your account has already been anonymised.',
|
||||
],
|
||||
'retention' => [
|
||||
'warning_subject' => 'Your Fotospiel account will be deleted',
|
||||
'line1' => 'We have not detected any activity for your account “:name” during the past 24 months.',
|
||||
'line2' => 'If you want to keep it, please sign in before :date or contact us.',
|
||||
'line3' => 'After that deadline all photos will be permanently deleted and the account will be anonymised.',
|
||||
'action' => 'Sign in now',
|
||||
'scan_complete' => 'Retention scan completed.',
|
||||
],
|
||||
];
|
||||
36
public/lang/de/profile.json
Normal file
36
public/lang/de/profile.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"export": {
|
||||
"title": "Datenexport",
|
||||
"description": "Fordere eine Kopie deiner Profildaten inklusive Events und Rechnungen an. Der Link bleibt 14 Tage gültig.",
|
||||
"empty": "Bisher wurde kein Export erstellt. Du kannst jederzeit einen neuen Export anfordern.",
|
||||
"table": {
|
||||
"status": "Status",
|
||||
"created": "Erstellt",
|
||||
"expires": "Ablauf",
|
||||
"size": "Größe",
|
||||
"action": "Aktion"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Geplant",
|
||||
"processing": "In Arbeit",
|
||||
"ready": "Bereit",
|
||||
"failed": "Fehlgeschlagen"
|
||||
},
|
||||
"pending_alert_title": "Export wird vorbereitet",
|
||||
"pending_alert_body": "Wir stellen deine Daten zusammen. Du erhältst eine Benachrichtigung, sobald der Download bereitsteht.",
|
||||
"cooldown": "Ein neuer Export ist ab dem {{date}} verfügbar.",
|
||||
"button": "Datenexport anfordern",
|
||||
"download": "Download",
|
||||
"failed_reason": "Fehler: {{reason}}"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Konto anonymisieren & löschen",
|
||||
"description": "Entfernt alle Fotos, anonymisiert Events und sperrt den Login unwiderruflich. So erfüllst du das Recht auf Vergessenwerden.",
|
||||
"warning_title": "Wichtiger Hinweis",
|
||||
"warning_body": "Alle Medien und Photobooth-Uploads werden dauerhaft gelöscht. Statistiken bleiben lediglich anonymisiert erhalten.",
|
||||
"confirmation_label": "Bestätigung",
|
||||
"confirmation_placeholder": "LÖSCHEN",
|
||||
"confirmation_hint": "Bitte tippe LÖSCHEN ein, um die Anonymisierung zu bestätigen.",
|
||||
"button": "Konto anonymisieren"
|
||||
}
|
||||
}
|
||||
36
public/lang/en/profile.json
Normal file
36
public/lang/en/profile.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"export": {
|
||||
"title": "Data export",
|
||||
"description": "Request a copy of your profile data including events and invoices. The link remains valid for 14 days.",
|
||||
"empty": "No export has been created yet. You can request one at any time.",
|
||||
"table": {
|
||||
"status": "Status",
|
||||
"created": "Created",
|
||||
"expires": "Expires",
|
||||
"size": "Size",
|
||||
"action": "Action"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Scheduled",
|
||||
"processing": "Processing",
|
||||
"ready": "Ready",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"pending_alert_title": "Export in progress",
|
||||
"pending_alert_body": "We are preparing your data. You will be notified as soon as the download is ready.",
|
||||
"cooldown": "A new export is available on {{date}}.",
|
||||
"button": "Request data export",
|
||||
"download": "Download",
|
||||
"failed_reason": "Error: {{reason}}"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Anonymise & delete account",
|
||||
"description": "Removes all photos, anonymises events, and permanently blocks login. This fulfils the right to be forgotten.",
|
||||
"warning_title": "Important notice",
|
||||
"warning_body": "All media and photobooth uploads will be permanently deleted. Statistics remain available only in anonymised form.",
|
||||
"confirmation_label": "Confirmation",
|
||||
"confirmation_placeholder": "DELETE",
|
||||
"confirmation_hint": "Please type DELETE to confirm the anonymisation.",
|
||||
"button": "Anonymise account"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from './i18n';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { ConsentProvider } from './contexts/consent';
|
||||
import CookieBanner from '@/components/consent/CookieBanner';
|
||||
import React from 'react';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
@@ -45,6 +46,7 @@ createInertiaApp({
|
||||
<ConsentProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<App {...props} />
|
||||
<CookieBanner />
|
||||
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
|
||||
</I18nextProvider>
|
||||
</ConsentProvider>
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
markdown: string;
|
||||
markdown?: string;
|
||||
html?: string;
|
||||
};
|
||||
|
||||
export function LegalMarkdown({ markdown }: Props) {
|
||||
const html = React.useMemo(() => {
|
||||
let safe = markdown
|
||||
export function LegalMarkdown({ markdown = '', html }: Props) {
|
||||
const derived = React.useMemo(() => {
|
||||
if (html && html.trim().length > 0) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const escaped = markdown
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
safe = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
safe = safe.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||||
safe = safe.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
safe = safe
|
||||
|
||||
return escaped
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => `<p>${block.replace(/\n/g, '<br/>')}</p>`)
|
||||
.join('\n');
|
||||
return safe;
|
||||
}, [markdown]);
|
||||
}, [markdown, html]);
|
||||
|
||||
return <div className="prose prose-sm dark:prose-invert" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: derived }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,10 +36,10 @@ type ViewState =
|
||||
};
|
||||
|
||||
type LegalDocumentState =
|
||||
| { phase: 'idle'; title: string; body: string }
|
||||
| { phase: 'loading'; title: string; body: string }
|
||||
| { phase: 'ready'; title: string; body: string }
|
||||
| { phase: 'error'; title: string; body: string };
|
||||
| { phase: 'idle'; title: string; markdown: string; html: string }
|
||||
| { phase: 'loading'; title: string; markdown: string; html: string }
|
||||
| { phase: 'ready'; title: string; markdown: string; html: string }
|
||||
| { phase: 'error'; title: string; markdown: string; html: string };
|
||||
|
||||
type NameStatus = 'idle' | 'saved';
|
||||
|
||||
@@ -223,10 +223,10 @@ function LegalView({
|
||||
<CardHeader>
|
||||
<CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<LegalMarkdown markdown={document.body} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardContent className="prose prose-sm max-w-none dark:prose-invert [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground">
|
||||
<LegalMarkdown markdown={document.markdown} html={document.html} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -418,17 +418,18 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
|
||||
const [state, setState] = React.useState<LegalDocumentState>({
|
||||
phase: 'idle',
|
||||
title: '',
|
||||
body: '',
|
||||
markdown: '',
|
||||
html: '',
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
setState({ phase: 'idle', title: '', body: '' });
|
||||
setState({ phase: 'idle', title: '', markdown: '', html: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setState({ phase: 'loading', title: '', body: '' });
|
||||
setState({ phase: 'loading', title: '', markdown: '', html: '' });
|
||||
|
||||
const langParam = encodeURIComponent(locale);
|
||||
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, {
|
||||
@@ -443,7 +444,8 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
|
||||
setState({
|
||||
phase: 'ready',
|
||||
title: payload.title ?? '',
|
||||
body: payload.body_markdown ?? '',
|
||||
markdown: payload.body_markdown ?? '',
|
||||
html: payload.body_html ?? '',
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -451,7 +453,7 @@ function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumen
|
||||
return;
|
||||
}
|
||||
console.error('Failed to load legal page', error);
|
||||
setState({ phase: 'error', title: '', body: '' });
|
||||
setState({ phase: 'error', title: '', markdown: '', html: '' });
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
|
||||
@@ -2,20 +2,48 @@ import React from 'react';
|
||||
import { translate, DEFAULT_LOCALE, type LocaleCode } from './messages';
|
||||
import { useLocale } from './LocaleContext';
|
||||
|
||||
export type TranslateFn = (key: string, fallback?: string) => string;
|
||||
type ReplacementValues = Record<string, string | number>;
|
||||
|
||||
export type TranslateFn = {
|
||||
(key: string): string;
|
||||
(key: string, fallback: string): string;
|
||||
(key: string, replacements: ReplacementValues): string;
|
||||
(key: string, replacements: ReplacementValues, fallback: string): string;
|
||||
};
|
||||
|
||||
function resolveTranslation(locale: LocaleCode, key: string, fallback?: string): string {
|
||||
return translate(locale, key) ?? translate(DEFAULT_LOCALE, key) ?? fallback ?? key;
|
||||
}
|
||||
|
||||
function applyReplacements(value: string, replacements?: ReplacementValues): string {
|
||||
if (!replacements) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.entries(replacements).reduce((acc, [token, replacement]) => {
|
||||
const pattern = new RegExp(`\\{${token}\\}`, 'g');
|
||||
return acc.replace(pattern, String(replacement));
|
||||
}, value);
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const { locale } = useLocale();
|
||||
|
||||
const t = React.useCallback<TranslateFn>(
|
||||
(key, fallback) => resolveTranslation(locale, key, fallback),
|
||||
[locale],
|
||||
);
|
||||
const t = React.useCallback<TranslateFn>((key: string, arg2?: ReplacementValues | string, arg3?: string) => {
|
||||
let replacements: ReplacementValues | undefined;
|
||||
let fallback: string | undefined;
|
||||
|
||||
if (typeof arg2 === 'string' || arg2 === undefined) {
|
||||
fallback = arg2 ?? arg3;
|
||||
} else {
|
||||
replacements = arg2;
|
||||
fallback = arg3;
|
||||
}
|
||||
|
||||
const raw = resolveTranslation(locale, key, fallback);
|
||||
|
||||
return applyReplacements(raw, replacements);
|
||||
}, [locale]);
|
||||
|
||||
return React.useMemo(() => ({ t, locale }), [t, locale]);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,16 +73,18 @@ export default function HelpArticlePage() {
|
||||
{article.updated_at && (
|
||||
<div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div>
|
||||
)}
|
||||
{servedFromCache && (
|
||||
<Badge variant="secondary" className="bg-amber-200/70 text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">
|
||||
{t('help.center.offlineBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={basePath}>
|
||||
← {t('help.article.back')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert [&_table]:w-full [&_table]:text-sm [&_:where(p,ul,ol,li)]:text-foreground [&_:where(h1,h2,h3,h4,h5,h6)]:text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: article.body_html ?? article.body_markdown ?? '' }}
|
||||
/>
|
||||
{article.related && article.related.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-base font-semibold text-foreground">{t('help.article.relatedTitle')}</h3>
|
||||
@@ -95,7 +97,7 @@ export default function HelpArticlePage() {
|
||||
asChild
|
||||
>
|
||||
<Link to={`${basePath}/${encodeURIComponent(rel.slug)}`}>
|
||||
{rel.slug}
|
||||
{rel.title ?? rel.slug}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
@@ -8,6 +8,7 @@ export default function LegalPage() {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [body, setBody] = React.useState('');
|
||||
const [html, setHtml] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!page) {
|
||||
@@ -29,11 +30,13 @@ export default function LegalPage() {
|
||||
const data = await res.json();
|
||||
setTitle(data.title || '');
|
||||
setBody(data.body_markdown || '');
|
||||
setHtml(data.body_html || '');
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error('Failed to load legal page', error);
|
||||
setTitle('');
|
||||
setBody('');
|
||||
setHtml('');
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -50,7 +53,7 @@ export default function LegalPage() {
|
||||
|
||||
return (
|
||||
<Page title={title || fallbackTitle}>
|
||||
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />}
|
||||
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} html={html} />}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export type HelpArticleSummary = {
|
||||
last_reviewed_at?: string;
|
||||
owner?: string;
|
||||
updated_at?: string;
|
||||
related?: Array<{ slug: string }>;
|
||||
related?: Array<{ slug: string; title?: string }>;
|
||||
};
|
||||
|
||||
export type HelpArticleDetail = HelpArticleSummary & {
|
||||
|
||||
@@ -16,7 +16,7 @@ i18n
|
||||
backend: {
|
||||
loadPath: '/lang/{{lng}}/{{ns}}.json',
|
||||
},
|
||||
ns: ['marketing', 'auth'],
|
||||
ns: ['marketing', 'auth', 'profile', 'common', 'legal'],
|
||||
defaultNS: 'marketing',
|
||||
supportedLngs: ['de', 'en'],
|
||||
detection: {
|
||||
@@ -26,4 +26,4 @@ i18n
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
export default i18n;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MatomoTracker, { MatomoConfig } from '@/components/analytics/MatomoTracker';
|
||||
import CookieBanner from '@/components/consent/CookieBanner';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import Footer from '@/layouts/app/Footer';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
@@ -165,7 +164,6 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
|
||||
))}
|
||||
</Head>
|
||||
<MatomoTracker config={analytics?.matomo} />
|
||||
<CookieBanner />
|
||||
<div className="min-h-screen bg-white">
|
||||
<header className="sticky top-0 z-40 border-b border-gray-200/60 bg-white/95 backdrop-blur">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-4">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Head, Form, Link, usePage } from '@inertiajs/react';
|
||||
import { Head, Form, Link, router, useForm, usePage } from '@inertiajs/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarClock, CheckCircle2, MailWarning, ReceiptText } from 'lucide-react';
|
||||
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
@@ -49,6 +50,19 @@ type ProfilePageProps = {
|
||||
type: string | null;
|
||||
provider: string | null;
|
||||
}>;
|
||||
dataExport: {
|
||||
exports: Array<{
|
||||
id: number;
|
||||
status: string;
|
||||
size: number | null;
|
||||
created_at: string | null;
|
||||
expires_at: string | null;
|
||||
download_url: string | null;
|
||||
error_message: string | null;
|
||||
}>;
|
||||
hasPending: boolean;
|
||||
nextRequestAt?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
@@ -59,7 +73,8 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
];
|
||||
|
||||
export default function ProfileIndex() {
|
||||
const { userData, tenant, purchases, supportedLocales, locale } = usePage<SharedData & ProfilePageProps>().props;
|
||||
const { userData, tenant, purchases, supportedLocales, locale, dataExport } = usePage<SharedData & ProfilePageProps>().props;
|
||||
const { t } = useTranslation('profile');
|
||||
|
||||
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(locale ?? 'de-DE', {
|
||||
day: '2-digit',
|
||||
@@ -73,6 +88,44 @@ export default function ProfileIndex() {
|
||||
maximumFractionDigits: 2,
|
||||
}), [locale]);
|
||||
|
||||
const byteFormatter = useMemo(() => new Intl.NumberFormat(locale ?? 'de-DE', {
|
||||
maximumFractionDigits: 1,
|
||||
}), [locale]);
|
||||
|
||||
const dataExportInfo = dataExport ?? { exports: [], hasPending: false, nextRequestAt: null };
|
||||
const nextRequestDeadline = dataExportInfo.nextRequestAt ? new Date(dataExportInfo.nextRequestAt) : null;
|
||||
const canRequestExport = !dataExportInfo.hasPending && (!nextRequestDeadline || nextRequestDeadline <= new Date());
|
||||
|
||||
const handleExportRequest = () => {
|
||||
router.post('/profile/data-exports');
|
||||
};
|
||||
|
||||
const deleteForm = useForm({ confirmation: '' });
|
||||
|
||||
const handleDeleteSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
deleteForm.delete('/profile/account');
|
||||
};
|
||||
|
||||
const formatExportStatus = (status: string) => t(`export.status.${status}`, status);
|
||||
|
||||
const formatBytes = (input: number | null) => {
|
||||
if (!input) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = input;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${byteFormatter.format(value)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const registrationDate = useMemo(() => {
|
||||
if (!userData.emailVerifiedAt) {
|
||||
return null;
|
||||
@@ -229,6 +282,108 @@ export default function ProfileIndex() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card className="border border-muted-foreground/30 bg-white/95 dark:bg-gray-950/50">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('export.title')}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{t('export.description')}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{dataExportInfo.exports.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('export.empty')}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('export.table.status')}</TableHead>
|
||||
<TableHead>{t('export.table.created')}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t('export.table.expires')}</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">{t('export.table.size')}</TableHead>
|
||||
<TableHead className="text-right">{t('export.table.action')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dataExportInfo.exports.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{formatExportStatus(item.status)}</TableCell>
|
||||
<TableCell>{formatDate(item.created_at)}</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{formatDate(item.expires_at)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">{formatBytes(item.size)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.download_url && item.status === 'ready' ? (
|
||||
<a
|
||||
href={item.download_url}
|
||||
className="text-sm font-semibold text-pink-600 transition hover:text-pink-700"
|
||||
>
|
||||
{t('export.download')}
|
||||
</a>
|
||||
) : item.status === 'failed' && item.error_message ? (
|
||||
<span className="text-xs text-destructive">{t('export.failed_reason', { reason: item.error_message })}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{dataExportInfo.hasPending && (
|
||||
<Alert variant="default" className="border-dashed border-muted-foreground/30 bg-muted/20">
|
||||
<AlertTitle>{t('export.pending_alert_title')}</AlertTitle>
|
||||
<AlertDescription>{t('export.pending_alert_body')}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!canRequestExport && nextRequestDeadline && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('export.cooldown', {
|
||||
date: nextRequestDeadline.toLocaleDateString(locale ?? 'de-DE'),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button onClick={handleExportRequest} disabled={!canRequestExport}>
|
||||
{t('export.button')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-red-200 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('delete.title')}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{t('delete.description')}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('delete.warning_title')}</AlertTitle>
|
||||
<AlertDescription>{t('delete.warning_body')}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={handleDeleteSubmit} className="space-y-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="confirmation">{t('delete.confirmation_label')}</Label>
|
||||
<Input
|
||||
id="confirmation"
|
||||
name="confirmation"
|
||||
value={deleteForm.data.confirmation}
|
||||
onChange={(event) => deleteForm.setData('confirmation', event.target.value.toUpperCase())}
|
||||
placeholder={t('delete.confirmation_placeholder')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('delete.confirmation_hint')}</p>
|
||||
<InputError message={deleteForm.errors.confirmation} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="destructive" className="w-full" disabled={deleteForm.processing}>
|
||||
{t('delete.button')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\EventPublicController;
|
||||
use App\Http\Controllers\Api\LegalController;
|
||||
use App\Http\Controllers\Api\HelpController;
|
||||
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
||||
use App\Http\Controllers\Api\PackageController;
|
||||
@@ -61,6 +62,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::middleware('throttle:100,1')->group(function () {
|
||||
Route::get('/help', [HelpController::class, 'index'])->name('help.index');
|
||||
Route::get('/help/{slug}', [HelpController::class, 'show'])->name('help.show');
|
||||
Route::get('/legal/{slug}', [LegalController::class, 'show'])->name('legal.show');
|
||||
|
||||
Route::get('/events/{token}', [EventPublicController::class, 'event'])->name('events.show');
|
||||
Route::get('/events/{token}/stats', [EventPublicController::class, 'stats'])->name('events.stats');
|
||||
|
||||
@@ -10,7 +10,9 @@ use App\Http\Controllers\LocaleController;
|
||||
use App\Http\Controllers\MarketingController;
|
||||
use App\Http\Controllers\PaddleCheckoutController;
|
||||
use App\Http\Controllers\PaddleWebhookController;
|
||||
use App\Http\Controllers\ProfileAccountController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\ProfileDataExportController;
|
||||
use App\Http\Controllers\Tenant\EventPhotoArchiveController;
|
||||
use App\Http\Controllers\TenantAdminAuthController;
|
||||
use App\Http\Controllers\TenantAdminGoogleController;
|
||||
@@ -242,6 +244,12 @@ Route::get('/dashboard', DashboardController::class)
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/profile', [ProfileController::class, 'index'])
|
||||
->name('profile.index');
|
||||
Route::post('/profile/data-exports', [ProfileDataExportController::class, 'store'])
|
||||
->name('profile.data-exports.store');
|
||||
Route::get('/profile/data-exports/{export}', [ProfileDataExportController::class, 'download'])
|
||||
->name('profile.data-exports.download');
|
||||
Route::delete('/profile/account', [ProfileAccountController::class, 'destroy'])
|
||||
->name('profile.account.destroy');
|
||||
});
|
||||
Route::prefix('event-admin')->group(function () {
|
||||
$renderAdmin = fn () => view('admin');
|
||||
|
||||
51
tests/Feature/ProfileAccountDeletionTest.php
Normal file
51
tests/Feature/ProfileAccountDeletionTest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\AnonymizeAccount;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProfileAccountDeletionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_confirmation_is_required(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->delete('/profile/account', [
|
||||
'confirmation' => 'WRONG',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('confirmation');
|
||||
Queue::assertNothingPushed();
|
||||
}
|
||||
|
||||
public function test_account_deletion_dispatches_job(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$keyword = Str::upper(__('profile.delete.confirmation_keyword'));
|
||||
|
||||
$response = $this->actingAs($user)->delete('/profile/account', [
|
||||
'confirmation' => $keyword,
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/profile');
|
||||
|
||||
Queue::assertPushed(AnonymizeAccount::class, function (AnonymizeAccount $job) use ($user) {
|
||||
return $job->userId() === $user->id;
|
||||
});
|
||||
}
|
||||
}
|
||||
55
tests/Feature/ProfileDataExportTest.php
Normal file
55
tests/Feature/ProfileDataExportTest.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\GenerateDataExport;
|
||||
use App\Models\DataExport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProfileDataExportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_can_request_data_export(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->post('/profile/data-exports');
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertDatabaseCount('data_exports', 1);
|
||||
Queue::assertPushed(GenerateDataExport::class);
|
||||
}
|
||||
|
||||
public function test_ready_export_can_be_downloaded(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
Storage::disk('local')->put('exports/demo.zip', 'demo-content');
|
||||
|
||||
$export = DataExport::create([
|
||||
'user_id' => $user->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'status' => DataExport::STATUS_READY,
|
||||
'path' => 'exports/demo.zip',
|
||||
'size_bytes' => 123,
|
||||
'expires_at' => now()->addDay(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('profile.data-exports.download', $export));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertHeader('content-disposition');
|
||||
}
|
||||
}
|
||||
85
tests/Feature/TenantRetentionCommandTest.php
Normal file
85
tests/Feature/TenantRetentionCommandTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\AnonymizeAccount;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
use App\Notifications\InactiveTenantDeletionWarning;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TenantRetentionCommandTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_inactive_tenant_gets_anonymized(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'last_activity_at' => now()->subMonths(25),
|
||||
]);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$tenant->user()->associate($user)->save();
|
||||
|
||||
$this->artisan('tenants:retention-scan')->assertExitCode(0);
|
||||
Queue::assertPushed(AnonymizeAccount::class, function (AnonymizeAccount $job) use ($tenant) {
|
||||
return $job->tenantId() === $tenant->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_warning_is_sent_one_month_before(): void
|
||||
{
|
||||
Queue::fake();
|
||||
Notification::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'last_activity_at' => now()->subMonths(23)->subWeek(),
|
||||
'contact_email' => 'owner@example.com',
|
||||
]);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$tenant->user()->associate($user)->save();
|
||||
|
||||
$this->artisan('tenants:retention-scan')->assertExitCode(0);
|
||||
|
||||
Notification::assertSentOnDemand(
|
||||
InactiveTenantDeletionWarning::class,
|
||||
function (InactiveTenantDeletionWarning $notification, array $channels, $notifiable) {
|
||||
return in_array('mail', $channels, true);
|
||||
}
|
||||
);
|
||||
|
||||
$this->assertNotNull($tenant->fresh()->deletion_warning_sent_at);
|
||||
}
|
||||
|
||||
public function test_active_subscription_is_whitelisted(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create([
|
||||
'last_activity_at' => now()->subMonths(25),
|
||||
]);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$tenant->user()->associate($user)->save();
|
||||
|
||||
$package = Package::factory()->create(['type' => 'reseller']);
|
||||
TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'price' => 99,
|
||||
'purchased_at' => now()->subMonth(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'used_events' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->artisan('tenants:retention-scan')->assertExitCode(0);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user