From 88012c35bd0fc0572e9b9f9b935f7def909fd0dc Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 5 Jan 2026 21:11:36 +0100 Subject: [PATCH] Add join token TTL policy and Live Show link sharing --- .beads/issues.jsonl | 1 + .beads/last-touched | 2 +- .../Pages/GuestPolicySettingsPage.php | 9 + .../Api/Tenant/LiveShowLinkController.php | 135 +++++++++++ app/Models/GuestPolicySetting.php | 2 + app/Services/EventJoinTokenService.php | 7 + ...l_hours_to_guest_policy_settings_table.php | 30 +++ resources/js/admin/api.ts | 43 ++++ .../js/admin/i18n/locales/de/management.json | 21 ++ .../js/admin/i18n/locales/en/management.json | 21 ++ resources/js/admin/mobile/EventFormPage.tsx | 64 +++++- .../mobile/EventLiveShowSettingsPage.tsx | 209 +++++++++++++++++- .../mobile/__tests__/EventFormPage.test.tsx | 3 + resources/lang/de/admin.php | 2 + resources/lang/en/admin.php | 2 + routes/api.php | 3 + .../Tenant/EventJoinTokenTtlPolicyTest.php | 31 +++ .../Tenant/LiveShowLinkControllerTest.php | 60 +++++ 18 files changed, 636 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/Api/Tenant/LiveShowLinkController.php create mode 100644 database/migrations/2026_01_05_210417_add_join_token_ttl_hours_to_guest_policy_settings_table.php create mode 100644 tests/Feature/Tenant/EventJoinTokenTtlPolicyTest.php create mode 100644 tests/Feature/Tenant/LiveShowLinkControllerTest.php diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 69d6b98..d45a671 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -111,6 +111,7 @@ {"id":"fotospiel-app-qne","title":"Live Show: realtime delivery channel (WS/SSE) + fallback polling","acceptance_criteria":"- Public Live Show endpoints exist for state, updates, and SSE stream\\n- Updates endpoint supports cursor (after_approved_at + after_id)\\n- SSE emits photo.approved and ping, with settings updates when version changes\\n- Feature tests cover state, updates, invalid token","notes":"Added LiveShowController with public endpoints: /api/v1/live-show/{token} (state), /updates (polling), /stream (SSE). Provides live-show settings (defaults + event.settings.live_show merge), settings_version hash, ordered approved photo feed with cursor. SSE emits photo.approved, settings.updated, ping. Added routes in routes/api.php. Added Photo live_status default. Tests: tests/Feature/LiveShowRealtimeTest.php. Ran Pint + test.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:06.028871737+01:00","created_by":"soeren","updated_at":"2026-01-05T13:08:33.936740582+01:00","closed_at":"2026-01-05T13:08:33.936740582+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-qne","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:30.363982215+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-qtn","title":"Security review kickoff mitigations (CORS allowlist, headers, upload hardening, signed URLs)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:46.310873311+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:51.914359487+01:00","closed_at":"2026-01-01T16:09:51.914359487+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"} +{"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"} {"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"} diff --git a/.beads/last-touched b/.beads/last-touched index cabf891..6b98749 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -fotospiel-app-579 +fotospiel-app-sju diff --git a/app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php b/app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php index aae8252..7eb87d5 100644 --- a/app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php +++ b/app/Filament/SuperAdmin/Pages/GuestPolicySettingsPage.php @@ -53,6 +53,8 @@ class GuestPolicySettingsPage extends Page public int $join_token_download_decay_minutes = 1; + public int $join_token_ttl_hours = 168; + public int $share_link_ttl_hours = 48; public ?int $guest_notification_ttl_hours = null; @@ -71,6 +73,7 @@ class GuestPolicySettingsPage extends Page $this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1); $this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60); $this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1); + $this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168); $this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48); $this->guest_notification_ttl_hours = $settings->guest_notification_ttl_hours; } @@ -130,6 +133,11 @@ class GuestPolicySettingsPage extends Page ->columns(2), Section::make(__('admin.guest_policy.sections.retention')) ->schema([ + Forms\Components\TextInput::make('join_token_ttl_hours') + ->label(__('admin.guest_policy.fields.join_token_ttl_hours')) + ->numeric() + ->minValue(0) + ->helperText(__('admin.guest_policy.help.join_token_ttl')), Forms\Components\TextInput::make('share_link_ttl_hours') ->label(__('admin.guest_policy.fields.share_link_ttl_hours')) ->numeric() @@ -160,6 +168,7 @@ class GuestPolicySettingsPage extends Page $settings->join_token_access_decay_minutes = (int) $this->join_token_access_decay_minutes; $settings->join_token_download_limit = (int) $this->join_token_download_limit; $settings->join_token_download_decay_minutes = (int) $this->join_token_download_decay_minutes; + $settings->join_token_ttl_hours = (int) $this->join_token_ttl_hours; $settings->share_link_ttl_hours = (int) $this->share_link_ttl_hours; $settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours; $settings->save(); diff --git a/app/Http/Controllers/Api/Tenant/LiveShowLinkController.php b/app/Http/Controllers/Api/Tenant/LiveShowLinkController.php new file mode 100644 index 0000000..f8be83e --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/LiveShowLinkController.php @@ -0,0 +1,135 @@ +authorizeEvent($request, $event); + + $token = $event->ensureLiveShowToken(); + + return response()->json([ + 'data' => $this->buildPayload($event, $token), + ]); + } + + public function rotate(Request $request, Event $event): JsonResponse + { + $this->authorizeEvent($request, $event); + + $token = $event->rotateLiveShowToken(); + + return response()->json([ + 'data' => $this->buildPayload($event, $token), + ]); + } + + private function authorizeEvent(Request $request, Event $event): void + { + $tenantId = $request->attributes->get('tenant_id'); + + if ($event->tenant_id !== $tenantId) { + abort(404, 'Event not found'); + } + } + + private function buildPayload(Event $event, string $token): array + { + $url = $this->buildLiveShowUrl($event, $token); + + return [ + 'token' => $token, + 'url' => $url, + 'qr_code_data_url' => $this->buildQrCodeDataUrl($url), + 'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(), + ]; + } + + private function buildLiveShowUrl(Event $event, string $token): string + { + $baseUrl = $this->resolveBaseUrl($event); + + return rtrim($baseUrl, '/').'/show/'.$token; + } + + private function resolveBaseUrl(Event $event): string + { + $settings = is_array($event->settings) ? $event->settings : []; + $customDomain = $settings['custom_domain'] ?? null; + + if (is_string($customDomain) && $customDomain !== '') { + return sprintf('%s://%s', $this->resolveScheme(), $customDomain); + } + + $publicUrl = $settings['public_url'] ?? null; + + if (is_string($publicUrl) && $publicUrl !== '') { + $parsed = parse_url($publicUrl); + $host = is_array($parsed) ? ($parsed['host'] ?? null) : null; + + if (is_string($host) && $host !== '') { + $scheme = $parsed['scheme'] ?? $this->resolveScheme(); + $port = $parsed['port'] ?? null; + $base = $scheme.'://'.$host; + + if ($port) { + $base .= ':'.$port; + } + + return $base; + } + } + + return (string) config('app.url'); + } + + private function resolveScheme(): string + { + $appUrl = config('app.url'); + + if (is_string($appUrl)) { + $scheme = parse_url($appUrl, PHP_URL_SCHEME); + + if (is_string($scheme) && $scheme !== '') { + return $scheme; + } + } + + return 'https'; + } + + private function buildQrCodeDataUrl(string $url): ?string + { + if ($url === '') { + return null; + } + + try { + $png = QrCode::format('png') + ->size(360) + ->margin(1) + ->errorCorrection('M') + ->generate($url); + + $pngBinary = (string) $png; + + if ($pngBinary === '') { + return null; + } + + return 'data:image/png;base64,'.base64_encode($pngBinary); + } catch (\Throwable $exception) { + report($exception); + } + + return null; + } +} diff --git a/app/Models/GuestPolicySetting.php b/app/Models/GuestPolicySetting.php index f5a204a..92b1c8a 100644 --- a/app/Models/GuestPolicySetting.php +++ b/app/Models/GuestPolicySetting.php @@ -21,6 +21,7 @@ class GuestPolicySetting extends Model 'join_token_access_decay_minutes' => 'integer', 'join_token_download_limit' => 'integer', 'join_token_download_decay_minutes' => 'integer', + 'join_token_ttl_hours' => 'integer', 'share_link_ttl_hours' => 'integer', ]; @@ -44,6 +45,7 @@ class GuestPolicySetting extends Model 'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1), 'join_token_download_limit' => (int) config('join_tokens.download_limit', 60), 'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1), + 'join_token_ttl_hours' => 168, 'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48), 'guest_notification_ttl_hours' => null, ]; diff --git a/app/Services/EventJoinTokenService.php b/app/Services/EventJoinTokenService.php index 6175a06..294835b 100644 --- a/app/Services/EventJoinTokenService.php +++ b/app/Services/EventJoinTokenService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\Event; use App\Models\EventJoinToken; +use App\Models\GuestPolicySetting; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -28,6 +29,12 @@ class EventJoinTokenService $payload['expires_at'] = $expiresAt instanceof Carbon ? $expiresAt : Carbon::parse($expiresAt); + } else { + $ttlHours = (int) (GuestPolicySetting::current()->join_token_ttl_hours ?? 0); + + if ($ttlHours > 0) { + $payload['expires_at'] = now()->addHours($ttlHours); + } } if ($createdBy = Arr::get($attributes, 'created_by')) { diff --git a/database/migrations/2026_01_05_210417_add_join_token_ttl_hours_to_guest_policy_settings_table.php b/database/migrations/2026_01_05_210417_add_join_token_ttl_hours_to_guest_policy_settings_table.php new file mode 100644 index 0000000..732b8b0 --- /dev/null +++ b/database/migrations/2026_01_05_210417_add_join_token_ttl_hours_to_guest_policy_settings_table.php @@ -0,0 +1,30 @@ +unsignedInteger('join_token_ttl_hours') + ->default(168) + ->after('join_token_download_decay_minutes'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('guest_policy_settings', function (Blueprint $table) { + $table->dropColumn('join_token_ttl_hours'); + }); + } +}; diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 1ddf316..7c49fc9 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -72,6 +72,13 @@ export type LiveShowSettings = { background_mode?: 'blur_last' | 'gradient' | 'solid' | 'brand'; }; +export type LiveShowLink = { + token: string; + url: string; + qr_code_data_url: string | null; + rotated_at: string | null; +}; + export type TenantEvent = { id: number; name: string | Record; @@ -1521,6 +1528,42 @@ export type GetLiveShowQueueOptions = { liveStatus?: LiveShowQueueStatus; }; +function normalizeLiveShowLink(payload: JsonValue | LiveShowLink | null | undefined): LiveShowLink { + if (!payload || typeof payload !== 'object') { + return { + token: '', + url: '', + qr_code_data_url: null, + rotated_at: null, + }; + } + + const record = payload as Record; + + return { + token: typeof record.token === 'string' ? record.token : '', + url: typeof record.url === 'string' ? record.url : '', + qr_code_data_url: typeof record.qr_code_data_url === 'string' ? record.qr_code_data_url : null, + rotated_at: typeof record.rotated_at === 'string' ? record.rotated_at : null, + }; +} + +export async function getLiveShowLink(slug: string): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/link`); + const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to load live show link'); + + return normalizeLiveShowLink(data.data); +} + +export async function rotateLiveShowLink(slug: string): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/live-show/link/rotate`, { + method: 'POST', + }); + const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to rotate live show link'); + + return normalizeLiveShowLink(data.data); +} + export async function getEventPhotos( slug: string, options: GetEventPhotosOptions = {} diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 44aeb0b..948829f 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2183,6 +2183,27 @@ "liveShowSettings": { "title": "Live-Show Einstellungen", "subtitle": "Tempo, Layout und Effekte für die Leinwand feinjustieren.", + "link": { + "title": "Live-Show-Link", + "subtitle": "Öffne diesen Link auf einem Bildschirm, um die Live-Show zu starten.", + "empty": "Kein Live-Show-Link verfügbar.", + "copy": "Kopieren", + "share": "Teilen", + "open": "Öffnen", + "rotate": "Neu generieren", + "rotateConfirm": "Live-Show-Link neu generieren? Der aktuelle Link funktioniert dann nicht mehr.", + "rotateSuccess": "Live-Show-Link neu generiert.", + "rotateFailed": "Live-Show-Link konnte nicht neu generiert werden.", + "rotatedAt": "Zuletzt erneuert {{time}}", + "noExpiry": "Dauerhaft gültig, bis von Dir erneuert.", + "loadFailed": "Live-Show-Link konnte nicht geladen werden.", + "copySuccess": "Link kopiert", + "copyFailed": "Link konnte nicht kopiert werden", + "shareTitle": "Live-Show", + "shareText": "Live-Show-Link", + "qrAlt": "Live-Show-QR-Code", + "downloadQr": "QR herunterladen" + }, "loadFailed": "Live-Show-Einstellungen konnten nicht geladen werden.", "save": "Einstellungen speichern", "saveSuccess": "Live-Show-Einstellungen gespeichert.", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 9058831..f477bb1 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2187,6 +2187,27 @@ "liveShowSettings": { "title": "Live Show settings", "subtitle": "Tune the playback, pacing, and effects shown on the screen.", + "link": { + "title": "Live Show link", + "subtitle": "Open this link on a screen to run the Live Show.", + "empty": "No Live Show link available.", + "copy": "Copy", + "share": "Share", + "open": "Open", + "rotate": "Rotate", + "rotateConfirm": "Rotate the Live Show link? The current link will stop working.", + "rotateSuccess": "Live Show link rotated.", + "rotateFailed": "Live Show link could not be rotated.", + "rotatedAt": "Last rotated {{time}}", + "noExpiry": "No expiry date (valid until you rotate it).", + "loadFailed": "Live Show link could not be loaded.", + "copySuccess": "Link copied", + "copyFailed": "Link could not be copied", + "shareTitle": "Live Show", + "shareText": "Live Show link", + "qrAlt": "Live Show QR code", + "downloadQr": "Download QR" + }, "loadFailed": "Live Show settings could not be loaded.", "save": "Save settings", "saveSuccess": "Live Show settings updated.", diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index 588500c..51262ff 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -17,6 +17,7 @@ import { getApiValidationMessage, isApiError } from '../lib/apiError'; import toast from 'react-hot-toast'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; +import { withAlpha } from './components/colors'; type FormState = { name: string; @@ -35,7 +36,7 @@ export default function MobileEventFormPage() { const isEdit = Boolean(slug); const navigate = useNavigate(); const { t } = useTranslation(['management', 'common']); - const { text, muted, subtle, danger } = useAdminTheme(); + const { text, muted, subtle, danger, border, surface, primary } = useAdminTheme(); const [form, setForm] = React.useState({ name: '', @@ -224,10 +225,14 @@ export default function MobileEventFormPage() { - setForm((prev) => ({ ...prev, date: e.target.value }))} + onChange={(value) => setForm((prev) => ({ ...prev, date: value }))} + border={border} + surface={surface} + text={text} + primary={primary} + danger={danger} style={{ flex: 1 }} /> @@ -443,6 +448,57 @@ function toDateTimeLocal(value?: string | null): string { return fallback.length >= 16 ? fallback.slice(0, 16) : ''; } +function NativeDateTimeInput({ + value, + onChange, + border, + surface, + text, + primary, + danger, + hasError, + style, +}: { + value: string; + onChange: (value: string) => void; + border: string; + surface: string; + text: string; + primary: string; + danger: string; + hasError?: boolean; + style?: React.CSSProperties; +}) { + const [focused, setFocused] = React.useState(false); + const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18); + const borderColor = hasError ? danger : focused ? primary : border; + + return ( + onChange(event.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + style={{ + width: '100%', + height: 44, + padding: '0 12px', + borderRadius: 12, + borderWidth: 1, + borderStyle: 'solid', + borderColor, + backgroundColor: surface, + color: text, + fontSize: 14, + boxShadow: focused ? `0 0 0 3px ${ringColor}` : undefined, + outline: 'none', + ...style, + }} + /> + ); +} + function resolveLocation(event: TenantEvent): string { const settings = (event.settings ?? {}) as Record; const candidate = diff --git a/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx b/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx index 11a97fe..dae8676 100644 --- a/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx +++ b/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx @@ -1,14 +1,15 @@ import React from 'react'; import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { RefreshCcw, Settings } from 'lucide-react'; +import { Copy, ExternalLink, Link2, RefreshCcw, RotateCcw, Settings, Share2 } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; +import { Pressable } from '@tamagui/react-native-web-lite'; import toast from 'react-hot-toast'; import { HeaderActionButton, MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; import { MobileField, MobileInput, MobileSelect } from './components/FormControls'; -import { getEvent, updateEvent, LiveShowSettings, TenantEvent } from '../api'; +import { getEvent, getLiveShowLink, rotateLiveShowLink, updateEvent, LiveShowLink, LiveShowSettings, TenantEvent } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { formatEventDate, resolveEventDisplayName } from '../lib/events'; @@ -131,16 +132,34 @@ export default function MobileEventLiveShowSettingsPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); const slug = slugParam ?? null; const { t, i18n } = useTranslation('management'); - const { text, muted, danger, border, surface } = useAdminTheme(); + const { textStrong, text, muted, danger, border, surface } = useAdminTheme(); const [event, setEvent] = React.useState(null); const [form, setForm] = React.useState(DEFAULT_LIVE_SHOW_SETTINGS); + const [liveShowLink, setLiveShowLink] = React.useState(null); const [loading, setLoading] = React.useState(true); + const [linkLoading, setLinkLoading] = React.useState(false); + const [linkBusy, setLinkBusy] = React.useState(false); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; + const loadLink = React.useCallback(async () => { + if (!slug) return; + setLinkLoading(true); + try { + const link = await getLiveShowLink(slug); + setLiveShowLink(link); + } catch (err) { + if (!isAuthError(err)) { + toast.error(getApiErrorMessage(err, t('liveShowSettings.link.loadFailed', 'Live Show link could not be loaded.'))); + } + } finally { + setLinkLoading(false); + } + }, [slug, t]); + const load = React.useCallback(async () => { if (!slug) return; setLoading(true); @@ -149,6 +168,7 @@ export default function MobileEventLiveShowSettingsPage() { const data = await getEvent(slug); setEvent(data); setForm(extractSettings(data)); + void loadLink(); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, t('liveShowSettings.loadFailed', 'Live Show settings could not be loaded.'))); @@ -156,7 +176,7 @@ export default function MobileEventLiveShowSettingsPage() { } finally { setLoading(false); } - }, [slug, t]); + }, [slug, t, loadLink]); React.useEffect(() => { void load(); @@ -216,6 +236,27 @@ export default function MobileEventLiveShowSettingsPage() { } } + async function handleRotateLink() { + if (!slug || linkBusy) return; + + if (!window.confirm(t('liveShowSettings.link.rotateConfirm', 'Rotate the Live Show link? The current link will stop working.'))) { + return; + } + + setLinkBusy(true); + try { + const link = await rotateLiveShowLink(slug); + setLiveShowLink(link); + toast.success(t('liveShowSettings.link.rotateSuccess', 'Live Show link rotated.')); + } catch (err) { + if (!isAuthError(err)) { + toast.error(getApiErrorMessage(err, t('liveShowSettings.link.rotateFailed', 'Live Show link could not be rotated.'))); + } + } finally { + setLinkBusy(false); + } + } + const presetMeta = EFFECT_PRESETS.find((preset) => preset.value === form.effect_preset) ?? EFFECT_PRESETS[0]; return ( @@ -246,6 +287,87 @@ export default function MobileEventLiveShowSettingsPage() { ) : ( + + + + + {t('liveShowSettings.link.title', 'Live Show link')} + + + + {t('liveShowSettings.link.subtitle', 'Open this link on a screen to run the Live Show.')} + + {linkLoading ? ( + + ) : liveShowLink?.url ? ( + + {liveShowLink.url} + + ) : ( + + {t('liveShowSettings.link.empty', 'No Live Show link available.')} + + )} + + liveShowLink?.url && copyToClipboard(liveShowLink.url, t)} + > + + + liveShowLink?.url && shareLink(liveShowLink.url, event, t)} + > + + + liveShowLink?.url && openLink(liveShowLink.url)} + > + + + handleRotateLink()} + > + + + + {liveShowLink?.qr_code_data_url ? ( + + downloadQr(liveShowLink.qr_code_data_url, 'live-show-qr.png')} + title={t('liveShowSettings.link.downloadQr', 'Download QR')} + aria-label={t('liveShowSettings.link.downloadQr', 'Download QR')} + style={{ borderRadius: 12, cursor: 'pointer' }} + > + {t('liveShowSettings.link.qrAlt', + + + ) : null} + {liveShowLink ? ( + + {t('liveShowSettings.link.noExpiry', 'No expiry date set.')} + + ) : null} + {liveShowLink?.rotated_at ? ( + + {t('liveShowSettings.link.rotatedAt', 'Last rotated {{time}}', { + time: formatTimestamp(liveShowLink.rotated_at, locale), + })} + + ) : null} + + @@ -456,6 +578,46 @@ function resolveName(name: TenantEvent['name']): string { return 'Event'; } +function copyToClipboard(value: string, t: (key: string, fallback?: string) => string) { + navigator.clipboard + .writeText(value) + .then(() => toast.success(t('liveShowSettings.link.copySuccess', 'Link copied'))) + .catch(() => toast.error(t('liveShowSettings.link.copyFailed', 'Link could not be copied'))); +} + +async function shareLink(value: string, event: TenantEvent | null, t: (key: string, fallback?: string) => string) { + if (navigator.share) { + try { + await navigator.share({ + title: event ? resolveEventDisplayName(event) : t('liveShowSettings.link.shareTitle', 'Live Show'), + text: t('liveShowSettings.link.shareText', 'Live Show link'), + url: value, + }); + return; + } catch { + // ignore + } + } + copyToClipboard(value, t); +} + +function openLink(value: string) { + window.open(value, '_blank', 'noopener,noreferrer'); +} + +function downloadQr(dataUrl: string, filename: string) { + const link = document.createElement('a'); + link.href = dataUrl; + link.download = filename; + link.click(); +} + +function formatTimestamp(value: string, locale: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(locale, { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + function resolveOption(value: unknown, options: T[], fallback: T): T { if (typeof value !== 'string') return fallback; return options.includes(value as T) ? (value as T) : fallback; @@ -517,3 +679,42 @@ function EffectSlider({ ); } + +function IconAction({ + label, + onPress, + disabled, + children, +}: { + label: string; + onPress: () => void; + disabled?: boolean; + children: React.ReactNode; +}) { + const { surface, border, text, muted } = useAdminTheme(); + const color = disabled ? muted : text; + + return ( + + + {React.isValidElement(children) ? React.cloneElement(children, { color }) : children} + + + ); +} diff --git a/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx b/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx index 35e1cbf..6e3eed7 100644 --- a/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventFormPage.test.tsx @@ -75,6 +75,9 @@ vi.mock('../theme', () => ({ muted: '#6b7280', subtle: '#94a3b8', danger: '#b91c1c', + border: '#e5e7eb', + surface: '#ffffff', + primary: '#ff5a5f', }), })); diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index c244364..991478d 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -235,11 +235,13 @@ return [ 'join_token_access_decay_minutes' => 'Join-Token Zugriff-Decay (Minuten)', 'join_token_download_limit' => 'Join-Token Downloadlimit', 'join_token_download_decay_minutes' => 'Join-Token Download-Decay (Minuten)', + 'join_token_ttl_hours' => 'Join-Token Standard-TTL (Stunden)', 'share_link_ttl_hours' => 'Share-Link TTL (Stunden)', 'guest_notification_ttl_hours' => 'Gast-Notification TTL (Stunden)', ], 'help' => [ 'zero_disables' => '0 deaktiviert das Throttling.', + 'join_token_ttl' => '0 lässt Tokens gültig, bis sie widerrufen oder limitiert werden.', 'notification_ttl' => 'Leer lassen, um Benachrichtigungen ohne Ablauf zu speichern.', ], 'actions' => [ diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 9ac936c..3345808 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -235,11 +235,13 @@ return [ 'join_token_access_decay_minutes' => 'Join token access decay (minutes)', 'join_token_download_limit' => 'Join token download limit', 'join_token_download_decay_minutes' => 'Join token download decay (minutes)', + 'join_token_ttl_hours' => 'Join token default TTL (hours)', 'share_link_ttl_hours' => 'Share link TTL (hours)', 'guest_notification_ttl_hours' => 'Guest notification default TTL (hours)', ], 'help' => [ 'zero_disables' => '0 disables throttling.', + 'join_token_ttl' => '0 keeps tokens active until revoked or limited.', 'notification_ttl' => 'Leave empty to keep notifications until explicitly expired.', ], 'actions' => [ diff --git a/routes/api.php b/routes/api.php index 033b5b4..37ef775 100644 --- a/routes/api.php +++ b/routes/api.php @@ -20,6 +20,7 @@ use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController; use App\Http\Controllers\Api\Tenant\EventMemberController; use App\Http\Controllers\Api\Tenant\EventTypeController; use App\Http\Controllers\Api\Tenant\FontController; +use App\Http\Controllers\Api\Tenant\LiveShowLinkController; use App\Http\Controllers\Api\Tenant\LiveShowPhotoController; use App\Http\Controllers\Api\Tenant\NotificationLogController; use App\Http\Controllers\Api\Tenant\OnboardingController; @@ -202,6 +203,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('addons/checkout', [EventAddonController::class, 'checkout'])->name('tenant.events.addons.checkout'); Route::prefix('live-show')->group(function () { + Route::get('link', [LiveShowLinkController::class, 'show'])->name('tenant.events.live-show.link'); + Route::post('link/rotate', [LiveShowLinkController::class, 'rotate'])->name('tenant.events.live-show.link.rotate'); Route::get('photos', [LiveShowPhotoController::class, 'index'])->name('tenant.events.live-show.photos.index'); Route::post('photos/{photo}/approve', [LiveShowPhotoController::class, 'approve']) ->name('tenant.events.live-show.photos.approve'); diff --git a/tests/Feature/Tenant/EventJoinTokenTtlPolicyTest.php b/tests/Feature/Tenant/EventJoinTokenTtlPolicyTest.php new file mode 100644 index 0000000..bf3621b --- /dev/null +++ b/tests/Feature/Tenant/EventJoinTokenTtlPolicyTest.php @@ -0,0 +1,31 @@ +for($this->tenant) + ->create([ + 'name' => ['de' => 'Token TTL Test', 'en' => 'Token TTL Test'], + 'slug' => 'token-ttl-test', + ]); + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/join-tokens"); + + $response->assertCreated(); + + $expectedExpiry = now()->addDays(7)->toIso8601String(); + + $this->assertSame($expectedExpiry, $response->json('data.expires_at')); + + Carbon::setTestNow(); + } +} diff --git a/tests/Feature/Tenant/LiveShowLinkControllerTest.php b/tests/Feature/Tenant/LiveShowLinkControllerTest.php new file mode 100644 index 0000000..3e8478b --- /dev/null +++ b/tests/Feature/Tenant/LiveShowLinkControllerTest.php @@ -0,0 +1,60 @@ +for($this->tenant) + ->create([ + 'name' => ['de' => 'Live-Show Test', 'en' => 'Live Show Test'], + 'slug' => 'live-show-link-test', + ]); + + $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/live-show/link"); + + $response->assertOk(); + + $data = $response->json('data'); + + $this->assertIsArray($data); + $this->assertArrayHasKey('token', $data); + $this->assertArrayHasKey('url', $data); + $this->assertArrayHasKey('qr_code_data_url', $data); + $this->assertArrayHasKey('rotated_at', $data); + + $this->assertIsString($data['token']); + $this->assertIsString($data['url']); + $this->assertIsString($data['qr_code_data_url']); + $this->assertStringStartsWith('data:image/png;base64,', $data['qr_code_data_url']); + $this->assertNotNull($data['rotated_at']); + + $expectedBase = rtrim((string) config('app.url'), '/'); + $this->assertSame("{$expectedBase}/show/{$data['token']}", $data['url']); + } + + public function test_rotate_live_show_link_changes_token(): void + { + $event = Event::factory() + ->for($this->tenant) + ->create([ + 'name' => ['de' => 'Live-Show Rotation', 'en' => 'Live Show Rotation'], + 'slug' => 'live-show-rotate-test', + ]); + + $first = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/live-show/link"); + $first->assertOk(); + $firstToken = $first->json('data.token'); + + $rotated = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/live-show/link/rotate"); + $rotated->assertOk(); + + $rotatedToken = $rotated->json('data.token'); + $this->assertIsString($rotatedToken); + $this->assertNotSame($firstToken, $rotatedToken); + } +}