kamerazugriff wieder möglich + anleitung zum erlauben des kamerazugriffs
This commit is contained in:
@@ -13,11 +13,15 @@ class ResponseSecurityHeaders
|
|||||||
/** @var Response $response */
|
/** @var Response $response */
|
||||||
$response = $next($request);
|
$response = $next($request);
|
||||||
|
|
||||||
|
$permissionsPolicy = $request->is('event', 'e/*', 'g/*', 'share/*')
|
||||||
|
? 'camera=(self), microphone=(), geolocation=()'
|
||||||
|
: 'camera=(), microphone=(), geolocation=()';
|
||||||
|
|
||||||
$headers = [
|
$headers = [
|
||||||
'Referrer-Policy' => 'strict-origin-when-cross-origin',
|
'Referrer-Policy' => 'strict-origin-when-cross-origin',
|
||||||
'X-Content-Type-Options' => 'nosniff',
|
'X-Content-Type-Options' => 'nosniff',
|
||||||
'X-Frame-Options' => 'SAMEORIGIN',
|
'X-Frame-Options' => 'SAMEORIGIN',
|
||||||
'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
|
'Permissions-Policy' => $permissionsPolicy,
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($headers as $name => $value) {
|
foreach ($headers as $name => $value) {
|
||||||
|
|||||||
@@ -1,373 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Seeders;
|
|
||||||
|
|
||||||
use App\Models\Event;
|
|
||||||
use App\Models\EventPackage;
|
|
||||||
use App\Models\EventType;
|
|
||||||
use App\Models\Package;
|
|
||||||
use App\Models\PackagePurchase;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantPackage;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class DemoLifecycleSeeder extends Seeder
|
|
||||||
{
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
[$standard, $premium, $reseller] = $this->ensurePackages();
|
|
||||||
[$weddingType, $corporateType] = $this->ensureEventTypes();
|
|
||||||
|
|
||||||
$this->seedOnboardingTenant();
|
|
||||||
$this->seedActiveTenant($standard, $premium, $weddingType, $corporateType);
|
|
||||||
$this->seedResellerTenant($reseller, $standard, $weddingType);
|
|
||||||
$this->seedDormantTenant();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensurePackages(): array
|
|
||||||
{
|
|
||||||
$standard = Package::firstOrCreate(
|
|
||||||
['slug' => 'standard'],
|
|
||||||
[
|
|
||||||
'type' => 'endcustomer',
|
|
||||||
'name' => 'Standard',
|
|
||||||
'name_translations' => ['de' => 'Standard', 'en' => 'Standard'],
|
|
||||||
'price' => 79,
|
|
||||||
'max_photos' => 1500,
|
|
||||||
'max_guests' => 400,
|
|
||||||
'gallery_days' => 60,
|
|
||||||
'watermark_allowed' => false,
|
|
||||||
'branding_allowed' => false,
|
|
||||||
'features' => [
|
|
||||||
'basic_uploads' => true,
|
|
||||||
'unlimited_sharing' => true,
|
|
||||||
'custom_tasks' => true,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$premium = Package::firstOrCreate(
|
|
||||||
['slug' => 'premium'],
|
|
||||||
[
|
|
||||||
'type' => 'endcustomer',
|
|
||||||
'name' => 'Premium',
|
|
||||||
'name_translations' => ['de' => 'Premium', 'en' => 'Premium'],
|
|
||||||
'price' => 149,
|
|
||||||
'max_photos' => 5000,
|
|
||||||
'max_guests' => 1000,
|
|
||||||
'gallery_days' => 180,
|
|
||||||
'watermark_allowed' => false,
|
|
||||||
'branding_allowed' => true,
|
|
||||||
'features' => [
|
|
||||||
'basic_uploads' => true,
|
|
||||||
'unlimited_sharing' => true,
|
|
||||||
'custom_branding' => true,
|
|
||||||
'custom_tasks' => true,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$reseller = Package::firstOrCreate(
|
|
||||||
['slug' => 'studio-annual'],
|
|
||||||
[
|
|
||||||
'type' => 'reseller',
|
|
||||||
'name' => 'Studio Jahrespaket',
|
|
||||||
'name_translations' => ['de' => 'Studio Jahrespaket', 'en' => 'Studio Annual'],
|
|
||||||
'price' => 1299,
|
|
||||||
'max_events_per_year' => 24,
|
|
||||||
'watermark_allowed' => false,
|
|
||||||
'branding_allowed' => true,
|
|
||||||
'features' => [
|
|
||||||
'custom_branding' => true,
|
|
||||||
'unlimited_sharing' => true,
|
|
||||||
'basic_uploads' => true,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return [$standard, $premium, $reseller];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensureEventTypes(): array
|
|
||||||
{
|
|
||||||
$weddingType = EventType::firstOrCreate(
|
|
||||||
['slug' => 'wedding'],
|
|
||||||
[
|
|
||||||
'name' => 'Wedding',
|
|
||||||
'name_translations' => ['de' => 'Hochzeit', 'en' => 'Wedding'],
|
|
||||||
'icon' => 'heart',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$corporateType = EventType::firstOrCreate(
|
|
||||||
['slug' => 'corporate'],
|
|
||||||
[
|
|
||||||
'name' => 'Corporate Event',
|
|
||||||
'name_translations' => ['de' => 'Firmen-Event', 'en' => 'Corporate'],
|
|
||||||
'icon' => 'presentation-chart',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return [$weddingType, $corporateType];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function seedOnboardingTenant(): void
|
|
||||||
{
|
|
||||||
$tenant = $this->createTenant('storycraft-weddings', [
|
|
||||||
'name' => 'Storycraft Weddings',
|
|
||||||
'contact_email' => 'hello@storycraft-weddings.demo',
|
|
||||||
'subscription_tier' => 'free',
|
|
||||||
'subscription_status' => 'free',
|
|
||||||
'subscription_expires_at' => null,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->createTenantAdmin($tenant, 'storycraft-owner@demo.fotospiel');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function seedActiveTenant(Package $standard, Package $premium, EventType $weddingType, EventType $corporateType): void
|
|
||||||
{
|
|
||||||
$tenant = $this->createTenant('lumen-moments', [
|
|
||||||
'name' => 'Lumen Moments',
|
|
||||||
'contact_email' => 'hello@lumen-moments.demo',
|
|
||||||
'subscription_tier' => 'starter',
|
|
||||||
'subscription_status' => 'active',
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->createTenantAdmin($tenant, 'hello@lumen-moments.demo');
|
|
||||||
|
|
||||||
$purchase = PackagePurchase::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $premium->id,
|
|
||||||
'provider' => 'stripe',
|
|
||||||
'provider_id' => 'stripe_demo_pi',
|
|
||||||
'price' => $premium->price,
|
|
||||||
'type' => 'endcustomer_event',
|
|
||||||
'purchased_at' => Carbon::now()->subDays(3),
|
|
||||||
'metadata' => ['demo' => true],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$publishedEvent = $this->createEventWithPackage(
|
|
||||||
tenant: $tenant,
|
|
||||||
package: $premium,
|
|
||||||
eventType: $weddingType,
|
|
||||||
attributes: [
|
|
||||||
'name' => ['de' => 'Sommerhochzeit Lea & Tim', 'en' => 'Summer Wedding Lea & Tim'],
|
|
||||||
'slug' => 'summer-wedding-lea-tim',
|
|
||||||
'status' => 'published',
|
|
||||||
'is_active' => true,
|
|
||||||
'date' => Carbon::now()->addWeeks(4),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$draftEvent = $this->createEventWithPackage(
|
|
||||||
tenant: $tenant,
|
|
||||||
package: $standard,
|
|
||||||
eventType: $corporateType,
|
|
||||||
attributes: [
|
|
||||||
'name' => ['de' => 'Startup Social 2025', 'en' => 'Startup Social 2025'],
|
|
||||||
'slug' => 'startup-social-2025',
|
|
||||||
'status' => 'draft',
|
|
||||||
'is_active' => false,
|
|
||||||
'date' => Carbon::now()->addWeeks(12),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$purchase->update(['event_id' => $publishedEvent->id]);
|
|
||||||
|
|
||||||
PackagePurchase::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'event_id' => $draftEvent->id,
|
|
||||||
'package_id' => $standard->id,
|
|
||||||
'provider' => 'paypal',
|
|
||||||
'provider_id' => 'paypal_demo_capture',
|
|
||||||
'price' => $standard->price,
|
|
||||||
'type' => 'endcustomer_event',
|
|
||||||
'purchased_at' => Carbon::now()->subDays(1),
|
|
||||||
'metadata' => ['demo' => true],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function seedResellerTenant(Package $reseller, Package $standard, EventType $weddingType): void
|
|
||||||
{
|
|
||||||
$tenant = $this->createTenant('viewfinder-studios', [
|
|
||||||
'name' => 'Viewfinder Studios',
|
|
||||||
'contact_email' => 'team@viewfinder.demo',
|
|
||||||
'subscription_tier' => 'reseller',
|
|
||||||
'subscription_status' => 'active',
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->createTenantAdmin($tenant, 'team@viewfinder.demo');
|
|
||||||
|
|
||||||
$tenantPackage = TenantPackage::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $reseller->id,
|
|
||||||
'price' => $reseller->price,
|
|
||||||
'purchased_at' => Carbon::now()->subMonths(2),
|
|
||||||
'expires_at' => Carbon::now()->addMonths(10),
|
|
||||||
'used_events' => 6,
|
|
||||||
'active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
PackagePurchase::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $reseller->id,
|
|
||||||
'provider' => 'stripe',
|
|
||||||
'provider_id' => 'stripe_demo_subscription',
|
|
||||||
'price' => $reseller->price,
|
|
||||||
'type' => 'reseller_subscription',
|
|
||||||
'purchased_at' => $tenantPackage->purchased_at,
|
|
||||||
'metadata' => ['demo' => true, 'plan' => 'studio-annual'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create a mix of events representing allowance consumption.
|
|
||||||
$statuses = ['published', 'published', 'draft', 'archived'];
|
|
||||||
|
|
||||||
foreach ($statuses as $index => $status) {
|
|
||||||
$event = $this->createEventWithPackage(
|
|
||||||
tenant: $tenant,
|
|
||||||
package: $standard,
|
|
||||||
eventType: $weddingType,
|
|
||||||
attributes: [
|
|
||||||
'name' => ['de' => 'Studio Event #'.($index + 1), 'en' => 'Studio Event #'.($index + 1)],
|
|
||||||
'slug' => 'studio-event-'.($index + 1),
|
|
||||||
'status' => $status,
|
|
||||||
'is_active' => $status === 'published',
|
|
||||||
'date' => $status === 'archived'
|
|
||||||
? Carbon::now()->subMonths(3)
|
|
||||||
: Carbon::now()->addWeeks($index * 3 + 1),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
PackagePurchase::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'event_id' => $event->id,
|
|
||||||
'package_id' => $standard->id,
|
|
||||||
'provider' => 'manual',
|
|
||||||
'provider_id' => 'reseller_allowance',
|
|
||||||
'price' => 0,
|
|
||||||
'type' => 'endcustomer_event',
|
|
||||||
'purchased_at' => Carbon::now()->subDays(10 - $index),
|
|
||||||
'metadata' => ['allowance' => true],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function seedDormantTenant(): void
|
|
||||||
{
|
|
||||||
$tenant = $this->createTenant('pixel-and-co', [
|
|
||||||
'name' => 'Pixel & Co',
|
|
||||||
'contact_email' => 'support@pixelco.demo',
|
|
||||||
'subscription_status' => 'expired',
|
|
||||||
'subscription_tier' => 'free',
|
|
||||||
'subscription_expires_at' => Carbon::now()->subMonths(2),
|
|
||||||
'is_active' => false,
|
|
||||||
'is_suspended' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->createTenantAdmin($tenant, 'support@pixelco.demo', role: 'member');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createTenantAdmin(Tenant $tenant, string $email, string $role = 'tenant_admin'): User
|
|
||||||
{
|
|
||||||
return User::updateOrCreate(
|
|
||||||
['email' => $email],
|
|
||||||
[
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'role' => $role,
|
|
||||||
'password' => Hash::make('Demo1234!'),
|
|
||||||
'first_name' => Str::headline(Str::before($tenant->slug, '-')),
|
|
||||||
'last_name' => 'Team',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createEventWithPackage(
|
|
||||||
Tenant $tenant,
|
|
||||||
Package $package,
|
|
||||||
EventType $eventType,
|
|
||||||
array $attributes
|
|
||||||
): Event {
|
|
||||||
$payload = array_merge([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'event_type_id' => $eventType->id,
|
|
||||||
'settings' => [
|
|
||||||
'features' => [
|
|
||||||
'photo_likes_enabled' => true,
|
|
||||||
'event_checklist' => true,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], $attributes);
|
|
||||||
|
|
||||||
$event = Event::updateOrCreate(['slug' => $attributes['slug']], $payload);
|
|
||||||
|
|
||||||
EventPackage::updateOrCreate(
|
|
||||||
[
|
|
||||||
'event_id' => $event->id,
|
|
||||||
'package_id' => $package->id,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'purchased_price' => $package->price,
|
|
||||||
'purchased_at' => Carbon::now()->subDays(2),
|
|
||||||
'used_photos' => 0,
|
|
||||||
'used_guests' => 0,
|
|
||||||
'gallery_expires_at' => Carbon::now()->addDays($package->gallery_days ?? 30),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return $event;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createTenant(string $slug, array $overrides = []): Tenant
|
|
||||||
{
|
|
||||||
$email = $overrides['contact_email'] ?? $slug.'@demo.fotospiel';
|
|
||||||
|
|
||||||
$defaults = [
|
|
||||||
'name' => Str::headline(str_replace('-', ' ', $slug)),
|
|
||||||
'slug' => $slug,
|
|
||||||
'contact_email' => $email,
|
|
||||||
'event_credits_balance' => 0,
|
|
||||||
'subscription_tier' => 'free',
|
|
||||||
'subscription_status' => 'free',
|
|
||||||
'subscription_expires_at' => Carbon::now()->addMonths(6),
|
|
||||||
'is_active' => true,
|
|
||||||
'is_suspended' => false,
|
|
||||||
'settings_updated_at' => Carbon::now(),
|
|
||||||
'settings' => $this->defaultSettings($email),
|
|
||||||
];
|
|
||||||
|
|
||||||
$attributes = array_merge($defaults, $overrides);
|
|
||||||
|
|
||||||
$tenant = Tenant::updateOrCreate(['slug' => $slug], $attributes);
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function defaultSettings(string $contactEmail): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'branding' => [
|
|
||||||
'logo_url' => null,
|
|
||||||
'primary_color' => '#3B82F6',
|
|
||||||
'secondary_color' => '#1F2937',
|
|
||||||
'font_family' => 'Inter, sans-serif',
|
|
||||||
],
|
|
||||||
'features' => [
|
|
||||||
'photo_likes_enabled' => true,
|
|
||||||
'event_checklist' => true,
|
|
||||||
'custom_domain' => false,
|
|
||||||
'advanced_analytics' => false,
|
|
||||||
],
|
|
||||||
'custom_domain' => null,
|
|
||||||
'contact_email' => $contactEmail,
|
|
||||||
'event_default_type' => 'general',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -445,17 +445,24 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
message: 'Dein Gerät unterstützt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
|
message: 'Dein Gerät unterstützt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
|
||||||
openGallery: 'Foto aus Galerie wählen',
|
openGallery: 'Foto aus Galerie wählen',
|
||||||
},
|
},
|
||||||
|
cameraBlocked: {
|
||||||
|
title: 'Kamera durch Sicherheitsrichtlinie blockiert',
|
||||||
|
message: 'Die Kamera ist durch die Sicherheitsrichtlinie dieser Seite blockiert. Öffne den Event-Link im Browser oder lade ein Foto aus der Galerie hoch.',
|
||||||
|
hint: 'Tipp: Wenn du in einer In-App-Ansicht bist, öffne den Link in Safari/Chrome und lade die Seite neu.',
|
||||||
|
},
|
||||||
cameraDenied: {
|
cameraDenied: {
|
||||||
title: 'Kamera-Zugriff verweigert',
|
title: 'Kamera-Zugriff verweigert',
|
||||||
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu können.',
|
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu können.',
|
||||||
reopenPrompt: 'Systemdialog erneut öffnen',
|
reopenPrompt: 'Systemdialog erneut öffnen',
|
||||||
chooseFile: 'Foto aus Galerie wählen',
|
chooseFile: 'Foto aus Galerie wählen',
|
||||||
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder wähle alternativ ein Foto aus deiner Galerie.',
|
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder wähle alternativ ein Foto aus deiner Galerie.',
|
||||||
|
hint: 'Tipp: Prüfe in den Browser-Einstellungen, ob Kamera-Zugriff erlaubt ist, und lade die Seite neu.',
|
||||||
},
|
},
|
||||||
cameraError: {
|
cameraError: {
|
||||||
title: 'Kamera konnte nicht gestartet werden',
|
title: 'Kamera konnte nicht gestartet werden',
|
||||||
explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Prüfe die Berechtigungen oder starte dein Gerät neu.',
|
explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Prüfe die Berechtigungen oder starte dein Gerät neu.',
|
||||||
tryAgain: 'Nochmals versuchen',
|
tryAgain: 'Nochmals versuchen',
|
||||||
|
hint: 'Tipp: Schließe andere Apps mit Kamerazugriff und versuche es erneut.',
|
||||||
},
|
},
|
||||||
readyOverlay: {
|
readyOverlay: {
|
||||||
title: 'Kamera bereit',
|
title: 'Kamera bereit',
|
||||||
@@ -591,6 +598,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
buttons: {
|
buttons: {
|
||||||
startCamera: 'Kamera starten',
|
startCamera: 'Kamera starten',
|
||||||
tryAgain: 'Erneut versuchen',
|
tryAgain: 'Erneut versuchen',
|
||||||
|
recheckCamera: 'Zugriff erneut prüfen',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
@@ -1100,17 +1108,24 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
message: 'Your device does not support live camera preview in this browser. You can upload photos from your gallery instead.',
|
message: 'Your device does not support live camera preview in this browser. You can upload photos from your gallery instead.',
|
||||||
openGallery: 'Choose photo from gallery',
|
openGallery: 'Choose photo from gallery',
|
||||||
},
|
},
|
||||||
|
cameraBlocked: {
|
||||||
|
title: 'Camera blocked by security policy',
|
||||||
|
message: 'Camera access is blocked by the site security policy. Open the event link in your browser or upload a photo from your gallery.',
|
||||||
|
hint: 'Tip: If you are in an in-app browser, open the link in Safari/Chrome and reload the page.',
|
||||||
|
},
|
||||||
cameraDenied: {
|
cameraDenied: {
|
||||||
title: 'Camera access denied',
|
title: 'Camera access denied',
|
||||||
explanation: 'Allow camera access to capture photos.',
|
explanation: 'Allow camera access to capture photos.',
|
||||||
reopenPrompt: 'Open system dialog again',
|
reopenPrompt: 'Open system dialog again',
|
||||||
chooseFile: 'Choose photo from gallery',
|
chooseFile: 'Choose photo from gallery',
|
||||||
prompt: 'We need access to your camera. Allow the request or pick a photo from your gallery.',
|
prompt: 'We need access to your camera. Allow the request or pick a photo from your gallery.',
|
||||||
|
hint: 'Tip: Check your browser settings for camera permissions and reload the page.',
|
||||||
},
|
},
|
||||||
cameraError: {
|
cameraError: {
|
||||||
title: 'Camera could not be started',
|
title: 'Camera could not be started',
|
||||||
explanation: 'We could not connect to the camera. Check permissions or restart your device.',
|
explanation: 'We could not connect to the camera. Check permissions or restart your device.',
|
||||||
tryAgain: 'Try again',
|
tryAgain: 'Try again',
|
||||||
|
hint: 'Tip: Close other apps that might be using the camera and try again.',
|
||||||
},
|
},
|
||||||
readyOverlay: {
|
readyOverlay: {
|
||||||
title: 'Camera ready',
|
title: 'Camera ready',
|
||||||
@@ -1246,6 +1261,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
|||||||
buttons: {
|
buttons: {
|
||||||
startCamera: 'Start camera',
|
startCamera: 'Start camera',
|
||||||
tryAgain: 'Try again',
|
tryAgain: 'Try again',
|
||||||
|
recheckCamera: 'Recheck access',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ interface Task {
|
|||||||
difficulty?: 'easy' | 'medium' | 'hard';
|
difficulty?: 'easy' | 'medium' | 'hard';
|
||||||
}
|
}
|
||||||
|
|
||||||
type PermissionState = 'idle' | 'prompt' | 'granted' | 'denied' | 'error' | 'unsupported';
|
type PermissionState = 'idle' | 'prompt' | 'granted' | 'denied' | 'error' | 'unsupported' | 'blocked';
|
||||||
type CameraMode = 'preview' | 'countdown' | 'review' | 'uploading';
|
type CameraMode = 'preview' | 'countdown' | 'review' | 'uploading';
|
||||||
|
|
||||||
type CameraPreferences = {
|
type CameraPreferences = {
|
||||||
@@ -85,6 +85,21 @@ function getErrorName(error: unknown): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCameraBlockedByPolicy(): boolean {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = (document as { permissionsPolicy?: { allowsFeature?: (feature: string) => boolean } })
|
||||||
|
.permissionsPolicy;
|
||||||
|
|
||||||
|
if (!policy?.allowsFeature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !policy.allowsFeature('camera');
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_PREFS: CameraPreferences = {
|
const DEFAULT_PREFS: CameraPreferences = {
|
||||||
facingMode: 'environment',
|
facingMode: 'environment',
|
||||||
countdownSeconds: 3,
|
countdownSeconds: 3,
|
||||||
@@ -449,6 +464,12 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
if (mode === 'uploading') return;
|
if (mode === 'uploading') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isCameraBlockedByPolicy()) {
|
||||||
|
setPermissionState('blocked');
|
||||||
|
setPermissionMessage(t('upload.cameraBlocked.message'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setPermissionState('prompt');
|
setPermissionState('prompt');
|
||||||
setPermissionMessage(null);
|
setPermissionMessage(null);
|
||||||
|
|
||||||
@@ -475,6 +496,18 @@ const [canUpload, setCanUpload] = useState(true);
|
|||||||
}
|
}
|
||||||
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, t]);
|
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, t]);
|
||||||
|
|
||||||
|
const handleRecheckCamera = useCallback(() => {
|
||||||
|
if (isCameraBlockedByPolicy()) {
|
||||||
|
setPermissionState('blocked');
|
||||||
|
setPermissionMessage(t('upload.cameraBlocked.message'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPermissionState('idle');
|
||||||
|
setPermissionMessage(null);
|
||||||
|
void startCamera();
|
||||||
|
}, [startCamera, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadingTask) return;
|
if (loadingTask) return;
|
||||||
startCamera();
|
startCamera();
|
||||||
@@ -1078,6 +1111,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
denied: t('upload.cameraDenied.title'),
|
denied: t('upload.cameraDenied.title'),
|
||||||
error: t('upload.cameraError.title'),
|
error: t('upload.cameraError.title'),
|
||||||
unsupported: t('upload.cameraUnsupported.title'),
|
unsupported: t('upload.cameraUnsupported.title'),
|
||||||
|
blocked: t('upload.cameraBlocked.title'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const fallbackMessages: Record<PermissionState, string> = {
|
const fallbackMessages: Record<PermissionState, string> = {
|
||||||
@@ -1087,11 +1121,20 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
denied: t('upload.cameraDenied.explanation'),
|
denied: t('upload.cameraDenied.explanation'),
|
||||||
error: t('upload.cameraError.explanation'),
|
error: t('upload.cameraError.explanation'),
|
||||||
unsupported: t('upload.cameraUnsupported.message'),
|
unsupported: t('upload.cameraUnsupported.message'),
|
||||||
|
blocked: t('upload.cameraBlocked.message'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = titles[permissionState];
|
const title = titles[permissionState];
|
||||||
const description = permissionMessage ?? fallbackMessages[permissionState];
|
const description = permissionMessage ?? fallbackMessages[permissionState];
|
||||||
const canRetryCamera = permissionState !== 'unsupported';
|
const canRetryCamera = permissionState !== 'unsupported' && permissionState !== 'blocked';
|
||||||
|
const canRecheckCamera = permissionState === 'blocked';
|
||||||
|
const helpText = permissionState === 'blocked'
|
||||||
|
? t('upload.cameraBlocked.hint')
|
||||||
|
: permissionState === 'denied'
|
||||||
|
? t('upload.cameraDenied.hint')
|
||||||
|
: permissionState === 'error'
|
||||||
|
? t('upload.cameraError.hint')
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1107,6 +1150,9 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
<p className="text-xs text-slate-600 dark:text-white/70">{description}</p>
|
<p className="text-xs text-slate-600 dark:text-white/70">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{helpText ? (
|
||||||
|
<p className="mt-3 text-xs text-slate-600 dark:text-white/70">{helpText}</p>
|
||||||
|
) : null}
|
||||||
<div className="mt-4 flex flex-wrap gap-3">
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
{canRetryCamera && (
|
{canRetryCamera && (
|
||||||
<Button
|
<Button
|
||||||
@@ -1117,6 +1163,17 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
{t('upload.buttons.startCamera')}
|
{t('upload.buttons.startCamera')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canRecheckCamera && (
|
||||||
|
<Button
|
||||||
|
onClick={handleRecheckCamera}
|
||||||
|
size="sm"
|
||||||
|
style={buttonStyle === 'outline'
|
||||||
|
? { borderRadius: radius, background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` }
|
||||||
|
: { borderRadius: radius }}
|
||||||
|
>
|
||||||
|
{t('upload.buttons.recheckCamera')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1194,11 +1251,19 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
<span>
|
<span>
|
||||||
{permissionState === 'unsupported'
|
{permissionState === 'unsupported'
|
||||||
? t('upload.cameraUnsupported.title')
|
? t('upload.cameraUnsupported.title')
|
||||||
|
: permissionState === 'blocked'
|
||||||
|
? t('upload.cameraBlocked.title')
|
||||||
: t('upload.cameraDenied.title')}
|
: t('upload.cameraDenied.title')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{permissionState !== 'granted' && (
|
||||||
|
<div className="absolute inset-x-4 top-16 z-30 sm:top-20">
|
||||||
|
{renderPermissionNotice()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === 'countdown' && (
|
{mode === 'countdown' && (
|
||||||
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center bg-black/60 text-white">
|
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center bg-black/60 text-white">
|
||||||
<div className="text-6xl font-bold">{countdownValue}</div>
|
<div className="text-6xl font-bold">{countdownValue}</div>
|
||||||
@@ -1449,7 +1514,6 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{permissionState !== 'granted' && renderPermissionNotice()}
|
|
||||||
{renderPrimer()}
|
{renderPrimer()}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -100,4 +100,29 @@ class SecurityHeadersTest extends TestCase
|
|||||||
app()->detectEnvironment(fn () => $originalEnv);
|
app()->detectEnvironment(fn () => $originalEnv);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_guest_pwa_allows_camera_via_permissions_policy(): void
|
||||||
|
{
|
||||||
|
$originalEnv = app()->environment();
|
||||||
|
app()->detectEnvironment(fn () => 'production');
|
||||||
|
config([
|
||||||
|
'app.debug' => false,
|
||||||
|
'app.url' => 'https://test-y0k0.fotospiel.app',
|
||||||
|
'security_headers.force_hsts' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/e/test-token', fn () => response('ok'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->withServerVariables([
|
||||||
|
'HTTPS' => 'on',
|
||||||
|
'HTTP_ORIGIN' => 'https://test-y0k0.fotospiel.app',
|
||||||
|
])->get('/e/test-token');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertHeader('Permissions-Policy', 'camera=(self), microphone=(), geolocation=()');
|
||||||
|
} finally {
|
||||||
|
app()->detectEnvironment(fn () => $originalEnv);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user