Add join token TTL policy and Live Show link sharing
This commit is contained in:
@@ -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-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-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-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-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-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"}
|
{"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"}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
fotospiel-app-579
|
fotospiel-app-sju
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ class GuestPolicySettingsPage extends Page
|
|||||||
|
|
||||||
public int $join_token_download_decay_minutes = 1;
|
public int $join_token_download_decay_minutes = 1;
|
||||||
|
|
||||||
|
public int $join_token_ttl_hours = 168;
|
||||||
|
|
||||||
public int $share_link_ttl_hours = 48;
|
public int $share_link_ttl_hours = 48;
|
||||||
|
|
||||||
public ?int $guest_notification_ttl_hours = null;
|
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_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_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_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->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
|
||||||
$this->guest_notification_ttl_hours = $settings->guest_notification_ttl_hours;
|
$this->guest_notification_ttl_hours = $settings->guest_notification_ttl_hours;
|
||||||
}
|
}
|
||||||
@@ -130,6 +133,11 @@ class GuestPolicySettingsPage extends Page
|
|||||||
->columns(2),
|
->columns(2),
|
||||||
Section::make(__('admin.guest_policy.sections.retention'))
|
Section::make(__('admin.guest_policy.sections.retention'))
|
||||||
->schema([
|
->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')
|
Forms\Components\TextInput::make('share_link_ttl_hours')
|
||||||
->label(__('admin.guest_policy.fields.share_link_ttl_hours'))
|
->label(__('admin.guest_policy.fields.share_link_ttl_hours'))
|
||||||
->numeric()
|
->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_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_limit = (int) $this->join_token_download_limit;
|
||||||
$settings->join_token_download_decay_minutes = (int) $this->join_token_download_decay_minutes;
|
$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->share_link_ttl_hours = (int) $this->share_link_ttl_hours;
|
||||||
$settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours;
|
$settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours;
|
||||||
$settings->save();
|
$settings->save();
|
||||||
|
|||||||
135
app/Http/Controllers/Api/Tenant/LiveShowLinkController.php
Normal file
135
app/Http/Controllers/Api/Tenant/LiveShowLinkController.php
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Tenant;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Event;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
|
|
||||||
|
class LiveShowLinkController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request, Event $event): JsonResponse
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ class GuestPolicySetting extends Model
|
|||||||
'join_token_access_decay_minutes' => 'integer',
|
'join_token_access_decay_minutes' => 'integer',
|
||||||
'join_token_download_limit' => 'integer',
|
'join_token_download_limit' => 'integer',
|
||||||
'join_token_download_decay_minutes' => 'integer',
|
'join_token_download_decay_minutes' => 'integer',
|
||||||
|
'join_token_ttl_hours' => 'integer',
|
||||||
'share_link_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_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_limit' => (int) config('join_tokens.download_limit', 60),
|
||||||
'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
|
'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),
|
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),
|
||||||
'guest_notification_ttl_hours' => null,
|
'guest_notification_ttl_hours' => null,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
|
use App\Models\GuestPolicySetting;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -28,6 +29,12 @@ class EventJoinTokenService
|
|||||||
$payload['expires_at'] = $expiresAt instanceof Carbon
|
$payload['expires_at'] = $expiresAt instanceof Carbon
|
||||||
? $expiresAt
|
? $expiresAt
|
||||||
: Carbon::parse($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')) {
|
if ($createdBy = Arr::get($attributes, 'created_by')) {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('guest_policy_settings', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -72,6 +72,13 @@ export type LiveShowSettings = {
|
|||||||
background_mode?: 'blur_last' | 'gradient' | 'solid' | 'brand';
|
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 = {
|
export type TenantEvent = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string | Record<string, string>;
|
name: string | Record<string, string>;
|
||||||
@@ -1521,6 +1528,42 @@ export type GetLiveShowQueueOptions = {
|
|||||||
liveStatus?: LiveShowQueueStatus;
|
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<string, JsonValue>;
|
||||||
|
|
||||||
|
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<LiveShowLink> {
|
||||||
|
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<LiveShowLink> {
|
||||||
|
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(
|
export async function getEventPhotos(
|
||||||
slug: string,
|
slug: string,
|
||||||
options: GetEventPhotosOptions = {}
|
options: GetEventPhotosOptions = {}
|
||||||
|
|||||||
@@ -2183,6 +2183,27 @@
|
|||||||
"liveShowSettings": {
|
"liveShowSettings": {
|
||||||
"title": "Live-Show Einstellungen",
|
"title": "Live-Show Einstellungen",
|
||||||
"subtitle": "Tempo, Layout und Effekte für die Leinwand feinjustieren.",
|
"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.",
|
"loadFailed": "Live-Show-Einstellungen konnten nicht geladen werden.",
|
||||||
"save": "Einstellungen speichern",
|
"save": "Einstellungen speichern",
|
||||||
"saveSuccess": "Live-Show-Einstellungen gespeichert.",
|
"saveSuccess": "Live-Show-Einstellungen gespeichert.",
|
||||||
|
|||||||
@@ -2187,6 +2187,27 @@
|
|||||||
"liveShowSettings": {
|
"liveShowSettings": {
|
||||||
"title": "Live Show settings",
|
"title": "Live Show settings",
|
||||||
"subtitle": "Tune the playback, pacing, and effects shown on the screen.",
|
"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.",
|
"loadFailed": "Live Show settings could not be loaded.",
|
||||||
"save": "Save settings",
|
"save": "Save settings",
|
||||||
"saveSuccess": "Live Show settings updated.",
|
"saveSuccess": "Live Show settings updated.",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { getApiValidationMessage, isApiError } from '../lib/apiError';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||||
import { useAdminTheme } from './theme';
|
import { useAdminTheme } from './theme';
|
||||||
|
import { withAlpha } from './components/colors';
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,7 +36,7 @@ export default function MobileEventFormPage() {
|
|||||||
const isEdit = Boolean(slug);
|
const isEdit = Boolean(slug);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation(['management', 'common']);
|
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<FormState>({
|
const [form, setForm] = React.useState<FormState>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -224,10 +225,14 @@ export default function MobileEventFormPage() {
|
|||||||
|
|
||||||
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
|
<MobileField label={t('eventForm.fields.date.label', 'Date & time')}>
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<MobileInput
|
<NativeDateTimeInput
|
||||||
type="datetime-local"
|
|
||||||
value={form.date}
|
value={form.date}
|
||||||
onChange={(e) => 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 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<CalendarDays size={16} color={subtle} />
|
<CalendarDays size={16} color={subtle} />
|
||||||
@@ -443,6 +448,57 @@ function toDateTimeLocal(value?: string | null): string {
|
|||||||
return fallback.length >= 16 ? fallback.slice(0, 16) : '';
|
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 (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => 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 {
|
function resolveLocation(event: TenantEvent): string {
|
||||||
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
const settings = (event.settings ?? {}) as Record<string, unknown>;
|
||||||
const candidate =
|
const candidate =
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { HeaderActionButton, MobileShell } from './components/MobileShell';
|
import { HeaderActionButton, MobileShell } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
||||||
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
|
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 { isAuthError } from '../auth/tokens';
|
||||||
import { getApiErrorMessage } from '../lib/apiError';
|
import { getApiErrorMessage } from '../lib/apiError';
|
||||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||||
@@ -131,16 +132,34 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||||
const slug = slugParam ?? null;
|
const slug = slugParam ?? null;
|
||||||
const { t, i18n } = useTranslation('management');
|
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<TenantEvent | null>(null);
|
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||||
const [form, setForm] = React.useState<LiveShowFormState>(DEFAULT_LIVE_SHOW_SETTINGS);
|
const [form, setForm] = React.useState<LiveShowFormState>(DEFAULT_LIVE_SHOW_SETTINGS);
|
||||||
|
const [liveShowLink, setLiveShowLink] = React.useState<LiveShowLink | null>(null);
|
||||||
const [loading, setLoading] = React.useState(true);
|
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 [saving, setSaving] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||||
|
|
||||||
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
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 () => {
|
const load = React.useCallback(async () => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -149,6 +168,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
const data = await getEvent(slug);
|
const data = await getEvent(slug);
|
||||||
setEvent(data);
|
setEvent(data);
|
||||||
setForm(extractSettings(data));
|
setForm(extractSettings(data));
|
||||||
|
void loadLink();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setError(getApiErrorMessage(err, t('liveShowSettings.loadFailed', 'Live Show settings could not be loaded.')));
|
setError(getApiErrorMessage(err, t('liveShowSettings.loadFailed', 'Live Show settings could not be loaded.')));
|
||||||
@@ -156,7 +176,7 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [slug, t]);
|
}, [slug, t, loadLink]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void load();
|
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];
|
const presetMeta = EFFECT_PRESETS.find((preset) => preset.value === form.effect_preset) ?? EFFECT_PRESETS[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -246,6 +287,87 @@ export default function MobileEventLiveShowSettingsPage() {
|
|||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
|
<MobileCard space="$2">
|
||||||
|
<XStack alignItems="center" space="$2">
|
||||||
|
<Link2 size={18} color={text} />
|
||||||
|
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||||
|
{t('liveShowSettings.link.title', 'Live Show link')}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
<Text fontSize="$xs" color={muted}>
|
||||||
|
{t('liveShowSettings.link.subtitle', 'Open this link on a screen to run the Live Show.')}
|
||||||
|
</Text>
|
||||||
|
{linkLoading ? (
|
||||||
|
<SkeletonCard height={96} />
|
||||||
|
) : liveShowLink?.url ? (
|
||||||
|
<Text fontSize="$sm" color={textStrong} selectable>
|
||||||
|
{liveShowLink.url}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="$sm" color={muted}>
|
||||||
|
{t('liveShowSettings.link.empty', 'No Live Show link available.')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<XStack space="$2" marginTop="$2" alignItems="center" flexWrap="nowrap">
|
||||||
|
<IconAction
|
||||||
|
label={t('liveShowSettings.link.copy', 'Copy')}
|
||||||
|
disabled={!liveShowLink?.url}
|
||||||
|
onPress={() => liveShowLink?.url && copyToClipboard(liveShowLink.url, t)}
|
||||||
|
>
|
||||||
|
<Copy size={18} />
|
||||||
|
</IconAction>
|
||||||
|
<IconAction
|
||||||
|
label={t('liveShowSettings.link.share', 'Share')}
|
||||||
|
disabled={!liveShowLink?.url}
|
||||||
|
onPress={() => liveShowLink?.url && shareLink(liveShowLink.url, event, t)}
|
||||||
|
>
|
||||||
|
<Share2 size={18} />
|
||||||
|
</IconAction>
|
||||||
|
<IconAction
|
||||||
|
label={t('liveShowSettings.link.open', 'Open')}
|
||||||
|
disabled={!liveShowLink?.url}
|
||||||
|
onPress={() => liveShowLink?.url && openLink(liveShowLink.url)}
|
||||||
|
>
|
||||||
|
<ExternalLink size={18} />
|
||||||
|
</IconAction>
|
||||||
|
<IconAction
|
||||||
|
label={t('liveShowSettings.link.rotate', 'Rotate')}
|
||||||
|
disabled={linkBusy}
|
||||||
|
onPress={() => handleRotateLink()}
|
||||||
|
>
|
||||||
|
<RotateCcw size={18} />
|
||||||
|
</IconAction>
|
||||||
|
</XStack>
|
||||||
|
{liveShowLink?.qr_code_data_url ? (
|
||||||
|
<XStack space="$2" alignItems="center" marginTop="$2" flexWrap="wrap">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => 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' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={liveShowLink.qr_code_data_url}
|
||||||
|
alt={t('liveShowSettings.link.qrAlt', 'Live Show QR code')}
|
||||||
|
style={{ width: 140, height: 140, borderRadius: 12, border: `1px solid ${border}` }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
{liveShowLink ? (
|
||||||
|
<Text fontSize="$xs" color={muted} marginTop="$1.5">
|
||||||
|
{t('liveShowSettings.link.noExpiry', 'No expiry date set.')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{liveShowLink?.rotated_at ? (
|
||||||
|
<Text fontSize="$xs" color={muted} marginTop="$1.5">
|
||||||
|
{t('liveShowSettings.link.rotatedAt', 'Last rotated {{time}}', {
|
||||||
|
time: formatTimestamp(liveShowLink.rotated_at, locale),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</MobileCard>
|
||||||
|
|
||||||
<MobileCard space="$2">
|
<MobileCard space="$2">
|
||||||
<XStack alignItems="center" space="$2">
|
<XStack alignItems="center" space="$2">
|
||||||
<Settings size={18} color={text} />
|
<Settings size={18} color={text} />
|
||||||
@@ -456,6 +578,46 @@ function resolveName(name: TenantEvent['name']): string {
|
|||||||
return 'Event';
|
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<T extends string>(value: unknown, options: T[], fallback: T): T {
|
function resolveOption<T extends string>(value: unknown, options: T[], fallback: T): T {
|
||||||
if (typeof value !== 'string') return fallback;
|
if (typeof value !== 'string') return fallback;
|
||||||
return options.includes(value as T) ? (value as T) : fallback;
|
return options.includes(value as T) ? (value as T) : fallback;
|
||||||
@@ -517,3 +679,42 @@ function EffectSlider({
|
|||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Pressable
|
||||||
|
onPress={disabled ? undefined : onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: border,
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XStack alignItems="center" justifyContent="center">
|
||||||
|
{React.isValidElement(children) ? React.cloneElement(children, { color }) : children}
|
||||||
|
</XStack>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ vi.mock('../theme', () => ({
|
|||||||
muted: '#6b7280',
|
muted: '#6b7280',
|
||||||
subtle: '#94a3b8',
|
subtle: '#94a3b8',
|
||||||
danger: '#b91c1c',
|
danger: '#b91c1c',
|
||||||
|
border: '#e5e7eb',
|
||||||
|
surface: '#ffffff',
|
||||||
|
primary: '#ff5a5f',
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -235,11 +235,13 @@ return [
|
|||||||
'join_token_access_decay_minutes' => 'Join-Token Zugriff-Decay (Minuten)',
|
'join_token_access_decay_minutes' => 'Join-Token Zugriff-Decay (Minuten)',
|
||||||
'join_token_download_limit' => 'Join-Token Downloadlimit',
|
'join_token_download_limit' => 'Join-Token Downloadlimit',
|
||||||
'join_token_download_decay_minutes' => 'Join-Token Download-Decay (Minuten)',
|
'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)',
|
'share_link_ttl_hours' => 'Share-Link TTL (Stunden)',
|
||||||
'guest_notification_ttl_hours' => 'Gast-Notification TTL (Stunden)',
|
'guest_notification_ttl_hours' => 'Gast-Notification TTL (Stunden)',
|
||||||
],
|
],
|
||||||
'help' => [
|
'help' => [
|
||||||
'zero_disables' => '0 deaktiviert das Throttling.',
|
'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.',
|
'notification_ttl' => 'Leer lassen, um Benachrichtigungen ohne Ablauf zu speichern.',
|
||||||
],
|
],
|
||||||
'actions' => [
|
'actions' => [
|
||||||
|
|||||||
@@ -235,11 +235,13 @@ return [
|
|||||||
'join_token_access_decay_minutes' => 'Join token access decay (minutes)',
|
'join_token_access_decay_minutes' => 'Join token access decay (minutes)',
|
||||||
'join_token_download_limit' => 'Join token download limit',
|
'join_token_download_limit' => 'Join token download limit',
|
||||||
'join_token_download_decay_minutes' => 'Join token download decay (minutes)',
|
'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)',
|
'share_link_ttl_hours' => 'Share link TTL (hours)',
|
||||||
'guest_notification_ttl_hours' => 'Guest notification default TTL (hours)',
|
'guest_notification_ttl_hours' => 'Guest notification default TTL (hours)',
|
||||||
],
|
],
|
||||||
'help' => [
|
'help' => [
|
||||||
'zero_disables' => '0 disables throttling.',
|
'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.',
|
'notification_ttl' => 'Leave empty to keep notifications until explicitly expired.',
|
||||||
],
|
],
|
||||||
'actions' => [
|
'actions' => [
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
|
|||||||
use App\Http\Controllers\Api\Tenant\EventMemberController;
|
use App\Http\Controllers\Api\Tenant\EventMemberController;
|
||||||
use App\Http\Controllers\Api\Tenant\EventTypeController;
|
use App\Http\Controllers\Api\Tenant\EventTypeController;
|
||||||
use App\Http\Controllers\Api\Tenant\FontController;
|
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\LiveShowPhotoController;
|
||||||
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
use App\Http\Controllers\Api\Tenant\NotificationLogController;
|
||||||
use App\Http\Controllers\Api\Tenant\OnboardingController;
|
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::post('addons/checkout', [EventAddonController::class, 'checkout'])->name('tenant.events.addons.checkout');
|
||||||
|
|
||||||
Route::prefix('live-show')->group(function () {
|
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::get('photos', [LiveShowPhotoController::class, 'index'])->name('tenant.events.live-show.photos.index');
|
||||||
Route::post('photos/{photo}/approve', [LiveShowPhotoController::class, 'approve'])
|
Route::post('photos/{photo}/approve', [LiveShowPhotoController::class, 'approve'])
|
||||||
->name('tenant.events.live-show.photos.approve');
|
->name('tenant.events.live-show.photos.approve');
|
||||||
|
|||||||
31
tests/Feature/Tenant/EventJoinTokenTtlPolicyTest.php
Normal file
31
tests/Feature/Tenant/EventJoinTokenTtlPolicyTest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class EventJoinTokenTtlPolicyTest extends TenantTestCase
|
||||||
|
{
|
||||||
|
public function test_join_token_defaults_to_policy_ttl_when_expiry_missing(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-05 12:00:00'));
|
||||||
|
|
||||||
|
$event = Event::factory()
|
||||||
|
->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
60
tests/Feature/Tenant/LiveShowLinkControllerTest.php
Normal file
60
tests/Feature/Tenant/LiveShowLinkControllerTest.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
|
||||||
|
class LiveShowLinkControllerTest extends TenantTestCase
|
||||||
|
{
|
||||||
|
public function test_live_show_link_response_includes_qr_code_and_url(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()
|
||||||
|
->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user