Adjust watermark permissions and transparency
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-19 13:45:43 +01:00
parent fbff2afa3e
commit d4ab9a3a20
15 changed files with 325 additions and 54 deletions

View File

@@ -143,7 +143,7 @@ class PackageResource extends Resource
->nullable() ->nullable()
->visible(fn ($get) => $get('type') === 'reseller'), ->visible(fn ($get) => $get('type') === 'reseller'),
Toggle::make('watermark_allowed') Toggle::make('watermark_allowed')
->label('Wasserzeichen erlaubt') ->label('Eigenes Wasserzeichen erlaubt')
->default(true), ->default(true),
Toggle::make('branding_allowed') Toggle::make('branding_allowed')
->label('Eigenes Branding erlaubt') ->label('Eigenes Branding erlaubt')

View File

@@ -27,7 +27,7 @@ class WatermarkSettingsPage extends Page
return __('admin.nav.branding'); return __('admin.nav.branding');
} }
public ?string $asset = null; public $asset = [];
public string $position = 'bottom-right'; public string $position = 'bottom-right';
@@ -46,7 +46,7 @@ class WatermarkSettingsPage extends Page
$settings = WatermarkSetting::query()->first(); $settings = WatermarkSetting::query()->first();
if ($settings) { if ($settings) {
$this->asset = $settings->asset; $this->asset = $settings->asset ? [$settings->asset] : [];
$this->position = $settings->position; $this->position = $settings->position;
$this->opacity = (float) $settings->opacity; $this->opacity = (float) $settings->opacity;
$this->scale = (float) $settings->scale; $this->scale = (float) $settings->scale;
@@ -119,8 +119,14 @@ class WatermarkSettingsPage extends Page
{ {
$this->validate(); $this->validate();
$state = $this->form->getState();
$asset = $state['asset'] ?? $this->asset;
if (is_array($asset)) {
$asset = $asset[0] ?? null;
}
$settings = WatermarkSetting::query()->firstOrNew([]); $settings = WatermarkSetting::query()->firstOrNew([]);
$settings->asset = $this->asset; $settings->asset = $asset;
$settings->position = $this->position; $settings->position = $this->position;
$settings->opacity = $this->opacity; $settings->opacity = $this->opacity;
$settings->scale = $this->scale; $settings->scale = $this->scale;

View File

@@ -20,6 +20,7 @@ use App\Models\User;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\TenantMemberPermissions; use App\Support\TenantMemberPermissions;
use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -414,6 +415,7 @@ class EventController extends Controller
$package = $event->eventPackage?->package; $package = $event->eventPackage?->package;
$brandingAllowed = optional($package)->branding_allowed !== false; $brandingAllowed = optional($package)->branding_allowed !== false;
$watermarkAllowed = optional($package)->watermark_allowed !== false; $watermarkAllowed = optional($package)->watermark_allowed !== false;
$watermarkRemovalAllowed = WatermarkConfigResolver::determineRemovalAllowed($event);
if (isset($validated['settings']) && is_array($validated['settings'])) { if (isset($validated['settings']) && is_array($validated['settings'])) {
$validated['settings'] = array_merge($event->settings ?? [], $validated['settings']); $validated['settings'] = array_merge($event->settings ?? [], $validated['settings']);
@@ -423,6 +425,7 @@ class EventController extends Controller
$validated['settings']['branding_allowed'] = $brandingAllowed; $validated['settings']['branding_allowed'] = $brandingAllowed;
$validated['settings']['watermark_allowed'] = $watermarkAllowed; $validated['settings']['watermark_allowed'] = $watermarkAllowed;
$validated['settings']['watermark_removal_allowed'] = $watermarkRemovalAllowed;
$settings = $validated['settings']; $settings = $validated['settings'];
$branding = Arr::get($settings, 'branding', []); $branding = Arr::get($settings, 'branding', []);
@@ -435,20 +438,19 @@ class EventController extends Controller
if (is_array($watermark)) { if (is_array($watermark)) {
$mode = $watermark['mode'] ?? 'base'; $mode = $watermark['mode'] ?? 'base';
$policy = $watermarkAllowed ? 'basic' : 'none';
if (! $watermarkAllowed) { if (! $watermarkAllowed) {
$mode = 'off'; $mode = 'base';
} elseif (! $brandingAllowed) { } elseif (! $brandingAllowed) {
$mode = 'base'; $mode = 'base';
} elseif ($mode === 'off' && $policy === 'basic') { } elseif ($mode === 'off' && ! $watermarkRemovalAllowed) {
$mode = 'base'; $mode = 'base';
} }
$assetPath = $watermark['asset'] ?? null; $assetPath = $watermark['asset'] ?? null;
$assetDataUrl = $watermark['asset_data_url'] ?? null; $assetDataUrl = $watermark['asset_data_url'] ?? null;
if (! $watermarkAllowed) { if (! $watermarkAllowed || $mode === 'off') {
$assetPath = null; $assetPath = null;
} }

View File

@@ -2,12 +2,15 @@
namespace App\Http\Resources\Tenant; namespace App\Http\Resources\Tenant;
use App\Models\WatermarkSetting;
use App\Services\Packages\PackageLimitEvaluator; use App\Services\Packages\PackageLimitEvaluator;
use App\Support\TenantMemberPermissions; use App\Support\TenantMemberPermissions;
use App\Support\WatermarkConfigResolver;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue; use Illuminate\Http\Resources\MissingValue;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use function app; use function app;
@@ -47,6 +50,8 @@ class EventResource extends JsonResource
$limitEvaluator = app()->make(PackageLimitEvaluator::class); $limitEvaluator = app()->make(PackageLimitEvaluator::class);
} }
$settings['watermark_removal_allowed'] = WatermarkConfigResolver::determineRemovalAllowed($this->resource);
return [ return [
'id' => $this->id, 'id' => $this->id,
'name' => $this->name, 'name' => $this->name,
@@ -108,6 +113,27 @@ class EventResource extends JsonResource
$watermark = Arr::get($settings, 'watermark'); $watermark = Arr::get($settings, 'watermark');
$base = config('watermark.base', []); $base = config('watermark.base', []);
$base = is_array($base) ? $base : []; $base = is_array($base) ? $base : [];
$baseSetting = null;
if (class_exists(WatermarkSetting::class) && Schema::hasTable('watermark_settings')) {
try {
$baseSetting = WatermarkSetting::query()->first();
} catch (\Throwable) {
$baseSetting = null;
}
}
if ($baseSetting) {
$base = array_merge($base, array_filter([
'asset' => $baseSetting->asset,
'position' => $baseSetting->position,
'opacity' => $baseSetting->opacity,
'scale' => $baseSetting->scale,
'padding' => $baseSetting->padding,
'offset_x' => $baseSetting->offset_x,
'offset_y' => $baseSetting->offset_y,
], static fn ($value) => $value !== null));
}
if (! is_array($watermark)) { if (! is_array($watermark)) {
$watermark = []; $watermark = [];

View File

@@ -13,7 +13,8 @@ trait PresentsPackages
$packageArray = $package->toArray(); $packageArray = $package->toArray();
$features = $packageArray['features'] ?? []; $features = $packageArray['features'] ?? [];
$features = $this->normaliseFeatures($features); $features = $this->normaliseFeatures($features);
$watermarkPolicy = $package->watermark_allowed === false ? 'none' : 'basic'; $watermarkRemovalAllowed = in_array('no_watermark', $features, true) || in_array('watermark_removal', $features, true);
$watermarkPolicy = $watermarkRemovalAllowed ? 'none' : 'basic';
$locale = app()->getLocale(); $locale = app()->getLocale();
$name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale); $name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale);

View File

@@ -178,10 +178,13 @@ class ImageHelper
$y = max(0, min($srcH - $targetH, $y + $offsetY)); $y = max(0, min($srcH - $targetH, $y + $offsetY));
$opacity = max(0.0, min(1.0, (float) ($config['opacity'] ?? 0.25))); $opacity = max(0.0, min(1.0, (float) ($config['opacity'] ?? 0.25)));
$mergeOpacity = (int) round($opacity * 100); // imagecopymerge uses 0-100
if ($opacity < 1.0) {
self::applyOpacity($resized, $opacity);
}
imagealphablending($src, true); imagealphablending($src, true);
imagecopymerge($src, $resized, $x, $y, 0, 0, $targetW, $targetH, $mergeOpacity); imagecopy($src, $resized, $x, $y, 0, 0, $targetW, $targetH);
imagedestroy($resized); imagedestroy($resized);
// Overwrite original (respect mime: always JPEG for compatibility) // Overwrite original (respect mime: always JPEG for compatibility)
@@ -210,4 +213,34 @@ class ImageHelper
return $applied ? $destPath : null; return $applied ? $destPath : null;
} }
/**
* @param \GdImage|resource $image
*/
private static function applyOpacity($image, float $opacity): void
{
$width = imagesx($image);
$height = imagesy($image);
if ($width <= 0 || $height <= 0) {
return;
}
imagealphablending($image, false);
imagesavealpha($image, true);
for ($x = 0; $x < $width; $x++) {
for ($y = 0; $y < $height; $y++) {
$rgba = imagecolorat($image, $x, $y);
$alpha = ($rgba >> 24) & 0x7F;
$red = ($rgba >> 16) & 0xFF;
$green = ($rgba >> 8) & 0xFF;
$blue = $rgba & 0xFF;
$adjustedAlpha = (int) round(127 - (127 - $alpha) * $opacity);
$color = imagecolorallocatealpha($image, $red, $green, $blue, max(0, min(127, $adjustedAlpha)));
imagesetpixel($image, $x, $y, $color);
}
}
}
} }

View File

@@ -3,6 +3,7 @@
namespace App\Support; namespace App\Support;
use App\Models\Event; use App\Models\Event;
use App\Models\Package;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
class WatermarkConfigResolver class WatermarkConfigResolver
@@ -29,7 +30,21 @@ class WatermarkConfigResolver
{ {
$event->loadMissing('eventPackage.package'); $event->loadMissing('eventPackage.package');
return $event->eventPackage?->package?->watermark_allowed === false ? 'none' : 'basic'; return self::determineRemovalAllowed($event) ? 'none' : 'basic';
}
public static function determineRemovalAllowed(Event $event): bool
{
$event->loadMissing('eventPackage.package', 'eventPackages.package');
$package = $event->eventPackage?->package;
if (! $package && $event->relationLoaded('eventPackages')) {
$package = $event->eventPackages->first()?->package;
}
return self::packageHasFeature($package, 'no_watermark')
|| self::packageHasFeature($package, 'watermark_removal');
} }
/** /**
@@ -39,13 +54,6 @@ class WatermarkConfigResolver
{ {
$policy = self::determinePolicy($event); $policy = self::determinePolicy($event);
if ($policy === 'none') {
return [
'type' => 'none',
'policy' => $policy,
];
}
$baseSetting = null; $baseSetting = null;
if (class_exists(\App\Models\WatermarkSetting::class) && \Illuminate\Support\Facades\Schema::hasTable('watermark_settings')) { if (class_exists(\App\Models\WatermarkSetting::class) && \Illuminate\Support\Facades\Schema::hasTable('watermark_settings')) {
@@ -65,8 +73,15 @@ class WatermarkConfigResolver
'offset_y' => $baseSetting?->offset_y ?? config('watermark.base.offset_y', 0), 'offset_y' => $baseSetting?->offset_y ?? config('watermark.base.offset_y', 0),
]; ];
$event->loadMissing('eventPackage.package', 'tenant'); $event->loadMissing('eventPackage.package', 'eventPackages.package', 'tenant');
$package = $event->eventPackage?->package;
if (! $package && $event->relationLoaded('eventPackages')) {
$package = $event->eventPackages->first()?->package;
}
$brandingAllowed = self::determineBrandingAllowed($event); $brandingAllowed = self::determineBrandingAllowed($event);
$watermarkAllowed = $package?->watermark_allowed !== false;
$removalAllowed = self::determineRemovalAllowed($event);
$eventWatermark = Arr::get($event->settings, 'watermark', []); $eventWatermark = Arr::get($event->settings, 'watermark', []);
$tenantWatermark = Arr::get($event->tenant?->settings, 'watermark', []); $tenantWatermark = Arr::get($event->tenant?->settings, 'watermark', []);
$serveOriginals = (bool) Arr::get($event->settings, 'watermark_serve_originals', false); $serveOriginals = (bool) Arr::get($event->settings, 'watermark_serve_originals', false);
@@ -75,7 +90,11 @@ class WatermarkConfigResolver
? ($eventWatermark['mode'] ?? $tenantWatermark['mode'] ?? 'base') ? ($eventWatermark['mode'] ?? $tenantWatermark['mode'] ?? 'base')
: 'base'; : 'base';
if ($mode === 'off' && $policy === 'basic') { if ($mode === 'custom' && (! $brandingAllowed || ! $watermarkAllowed)) {
$mode = 'base';
}
if ($mode === 'off' && ! $removalAllowed) {
$mode = 'base'; $mode = 'base';
} }
@@ -111,4 +130,30 @@ class WatermarkConfigResolver
'serve_originals' => $serveOriginals, 'serve_originals' => $serveOriginals,
]; ];
} }
private static function packageHasFeature(?Package $package, string $feature): bool
{
if (! $package) {
return false;
}
$features = $package->features ?? [];
if (is_string($features)) {
$decoded = json_decode($features, true);
if (json_last_error() === JSON_ERROR_NONE) {
$features = $decoded;
}
}
if (! is_array($features)) {
return false;
}
if (array_is_list($features)) {
return in_array($feature, $features, true);
}
return isset($features[$feature]) && (bool) $features[$feature];
}
} }

View File

@@ -29,7 +29,7 @@ class PackageSeeder extends Seeder
'gallery_days' => 180, 'gallery_days' => 180,
'max_tasks' => 30, 'max_tasks' => 30,
'max_events_per_year' => 1, 'max_events_per_year' => 1,
'watermark_allowed' => true, 'watermark_allowed' => false,
'branding_allowed' => false, 'branding_allowed' => false,
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'], 'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej', 'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
@@ -64,7 +64,7 @@ TEXT,
'max_tasks' => 100, 'max_tasks' => 100,
'watermark_allowed' => true, 'watermark_allowed' => true,
'branding_allowed' => true, 'branding_allowed' => true,
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow'], 'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'no_watermark'],
'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j', 'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j',
'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc', 'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc',
'description' => <<<'TEXT' 'description' => <<<'TEXT'
@@ -95,7 +95,7 @@ TEXT,
'max_guests' => null, 'max_guests' => null,
'gallery_days' => 730, 'gallery_days' => 730,
'max_tasks' => 200, 'max_tasks' => 200,
'watermark_allowed' => false, 'watermark_allowed' => true,
'branding_allowed' => true, 'branding_allowed' => true,
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'], 'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'],
'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s', 'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s',
@@ -197,7 +197,7 @@ TEXT,
'max_guests' => null, 'max_guests' => null,
'gallery_days' => null, 'gallery_days' => null,
'max_tasks' => null, 'max_tasks' => null,
'watermark_allowed' => false, 'watermark_allowed' => true,
'branding_allowed' => true, 'branding_allowed' => true,
'max_events_per_year' => 35, 'max_events_per_year' => 35,
'expires_after' => null, 'expires_after' => null,
@@ -231,7 +231,7 @@ TEXT,
'max_guests' => null, 'max_guests' => null,
'gallery_days' => null, 'gallery_days' => null,
'max_tasks' => null, 'max_tasks' => null,
'watermark_allowed' => false, 'watermark_allowed' => true,
'branding_allowed' => true, 'branding_allowed' => true,
'max_events_per_year' => 5, 'max_events_per_year' => 5,
'expires_after' => null, 'expires_after' => null,

View File

@@ -103,6 +103,7 @@ export type TenantEvent = {
live_show?: LiveShowSettings; live_show?: LiveShowSettings;
watermark?: WatermarkSettings; watermark?: WatermarkSettings;
watermark_allowed?: boolean | null; watermark_allowed?: boolean | null;
watermark_removal_allowed?: boolean | null;
watermark_serve_originals?: boolean | null; watermark_serve_originals?: boolean | null;
}; };
package?: { package?: {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { isBrandingAllowed, isWatermarkAllowed } from '../events'; import { isBrandingAllowed, isWatermarkAllowed, isWatermarkRemovalAllowed } from '../events';
describe('event branding access helpers', () => { describe('event branding access helpers', () => {
it('respects package-level disallow', () => { it('respects package-level disallow', () => {
@@ -25,5 +25,14 @@ describe('event branding access helpers', () => {
it('defaults to allow when nothing is set', () => { it('defaults to allow when nothing is set', () => {
expect(isBrandingAllowed({} as any)).toBe(true); expect(isBrandingAllowed({} as any)).toBe(true);
expect(isWatermarkAllowed({} as any)).toBe(true); expect(isWatermarkAllowed({} as any)).toBe(true);
expect(isWatermarkRemovalAllowed({} as any)).toBe(false);
});
it('uses removal flag from settings', () => {
const event = {
settings: { watermark_removal_allowed: true },
};
expect(isWatermarkRemovalAllowed(event as any)).toBe(true);
}); });
}); });

View File

@@ -116,6 +116,15 @@ export function isWatermarkAllowed(event?: TenantEvent | null): boolean {
return true; return true;
} }
export function isWatermarkRemovalAllowed(event?: TenantEvent | null): boolean {
if (!event) return false;
const settings = (event.settings ?? {}) as Record<string, unknown>;
if (typeof settings.watermark_removal_allowed === 'boolean') {
return settings.watermark_removal_allowed;
}
return false;
}
export function formatEventStatusLabel( export function formatEventStatusLabel(
status: TenantEvent['status'] | null, status: TenantEvent['status'] | null,
t: (key: string, options?: Record<string, unknown>) => string, t: (key: string, options?: Record<string, unknown>) => string,

View File

@@ -12,7 +12,7 @@ import { MobileColorInput, MobileField, MobileFileInput, MobileInput, MobileSele
import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api'; import { TenantEvent, getEvent, updateEvent, getTenantFonts, getTenantSettings, TenantFont, WatermarkSettings, trackOnboarding } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { ApiError, getApiErrorMessage } from '../lib/apiError'; import { ApiError, getApiErrorMessage } from '../lib/apiError';
import { isBrandingAllowed, isWatermarkAllowed } from '../lib/events'; import { isBrandingAllowed, isWatermarkAllowed, isWatermarkRemovalAllowed } from '../lib/events';
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
@@ -188,10 +188,24 @@ export default function MobileBrandingPage() {
const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : ''; const previewLogoValue = previewForm.logoMode === 'emoticon' ? previewForm.logoValue : '';
const previewInitials = getInitials(previewTitle); const previewInitials = getInitials(previewTitle);
const watermarkAllowed = isWatermarkAllowed(event ?? null); const watermarkAllowed = isWatermarkAllowed(event ?? null);
const watermarkRemovalAllowed = isWatermarkRemovalAllowed(event ?? null);
const brandingAllowed = isBrandingAllowed(event ?? null); const brandingAllowed = isBrandingAllowed(event ?? null);
const customWatermarkAllowed = watermarkAllowed && brandingAllowed;
const watermarkLocked = watermarkAllowed && !brandingAllowed; const watermarkLocked = watermarkAllowed && !brandingAllowed;
const brandingDisabled = !brandingAllowed || form.useDefaultBranding; const brandingDisabled = !brandingAllowed || form.useDefaultBranding;
React.useEffect(() => {
setWatermarkForm((prev) => {
if (prev.mode === 'custom' && !customWatermarkAllowed) {
return { ...prev, mode: 'base' };
}
if (prev.mode === 'off' && !watermarkRemovalAllowed) {
return { ...prev, mode: 'base' };
}
return prev;
});
}, [customWatermarkAllowed, watermarkRemovalAllowed]);
async function handleSave() { async function handleSave() {
if (!event?.slug) return; if (!event?.slug) return;
setSaving(true); setSaving(true);
@@ -271,7 +285,12 @@ export default function MobileBrandingPage() {
size: form.logoSize, size: form.logoSize,
}, },
}; };
const watermarkPayload = buildWatermarkPayload(watermarkForm, watermarkAllowed, brandingAllowed); const watermarkPayload = buildWatermarkPayload(
watermarkForm,
watermarkAllowed,
brandingAllowed,
watermarkRemovalAllowed
);
if (watermarkPayload) { if (watermarkPayload) {
settings.watermark = watermarkPayload; settings.watermark = watermarkPayload;
} }
@@ -307,10 +326,14 @@ export default function MobileBrandingPage() {
} }
function renderWatermarkTab() { function renderWatermarkTab() {
const policyLabel = watermarkAllowed ? 'basic' : 'none'; const controlsLocked = watermarkLocked;
const disabled = !watermarkAllowed;
const controlsLocked = watermarkLocked || disabled;
const mode = controlsLocked ? 'base' : watermarkForm.mode; const mode = controlsLocked ? 'base' : watermarkForm.mode;
const resolvedMode = mode === 'custom' && !customWatermarkAllowed
? 'base'
: mode === 'off' && !watermarkRemovalAllowed
? 'base'
: mode;
const customizationDisabled = controlsLocked || resolvedMode !== 'custom';
return ( return (
<> <>
@@ -325,12 +348,12 @@ export default function MobileBrandingPage() {
padding={watermarkForm.padding} padding={watermarkForm.padding}
offsetX={watermarkForm.offsetX} offsetX={watermarkForm.offsetX}
offsetY={watermarkForm.offsetY} offsetY={watermarkForm.offsetY}
previewUrl={mode === 'off' ? '' : watermarkForm.assetDataUrl || watermarkForm.assetPreviewUrl} previewUrl={resolvedMode === 'off' ? '' : watermarkForm.assetDataUrl || watermarkForm.assetPreviewUrl}
previewAlt={t('events.watermark.previewAlt', 'Watermark preview')} previewAlt={t('events.watermark.previewAlt', 'Watermark preview')}
/> />
</MobileCard> </MobileCard>
{disabled ? ( {!watermarkAllowed ? (
<UpgradeCard <UpgradeCard
title={t('events.watermark.lockedTitle', 'Unlock watermarks')} title={t('events.watermark.lockedTitle', 'Unlock watermarks')}
body={t('events.watermark.lockedBody', 'Custom watermarks are available with the Premium package.')} body={t('events.watermark.lockedBody', 'Custom watermarks are available with the Premium package.')}
@@ -353,7 +376,7 @@ export default function MobileBrandingPage() {
<MobileField label={t('events.watermark.mode', 'Modus')}> <MobileField label={t('events.watermark.mode', 'Modus')}>
<MobileSelect <MobileSelect
value={mode} value={resolvedMode}
disabled={controlsLocked} disabled={controlsLocked}
onChange={(event) => { onChange={(event) => {
const value = event.target.value; const value = event.target.value;
@@ -364,16 +387,16 @@ export default function MobileBrandingPage() {
}} }}
> >
<option value="base">{t('events.watermark.modeBase', 'Basis')}</option> <option value="base">{t('events.watermark.modeBase', 'Basis')}</option>
<option value="custom" disabled={watermarkLocked}> <option value="custom" disabled={!customWatermarkAllowed}>
{t('events.watermark.modeCustom', 'Eigenes Wasserzeichen')} {t('events.watermark.modeCustom', 'Eigenes Wasserzeichen')}
</option> </option>
<option value="off" disabled={policyLabel === 'basic'}> <option value="off" disabled={!watermarkRemovalAllowed}>
{t('events.watermark.modeOff', 'Deaktiviert')} {t('events.watermark.modeOff', 'Deaktiviert')}
</option> </option>
</MobileSelect> </MobileSelect>
</MobileField> </MobileField>
{mode === 'custom' && !controlsLocked ? ( {resolvedMode === 'custom' && !controlsLocked ? (
<YStack space="$2"> <YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color={textStrong}> <Text fontSize="$sm" fontWeight="700" color={textStrong}>
{t('events.watermark.upload', 'Wasserzeichen hochladen')} {t('events.watermark.upload', 'Wasserzeichen hochladen')}
@@ -452,7 +475,7 @@ export default function MobileBrandingPage() {
<PositionGrid <PositionGrid
value={watermarkForm.position} value={watermarkForm.position}
onChange={(next) => setWatermarkForm((prev) => ({ ...prev, position: next }))} onChange={(next) => setWatermarkForm((prev) => ({ ...prev, position: next }))}
disabled={controlsLocked} disabled={customizationDisabled}
/> />
<LabeledSlider <LabeledSlider
label={t('events.watermark.size', 'Größe')} label={t('events.watermark.size', 'Größe')}
@@ -461,7 +484,7 @@ export default function MobileBrandingPage() {
max={60} max={60}
step={1} step={1}
onChange={(value) => setWatermarkForm((prev) => ({ ...prev, scale: value / 100 }))} onChange={(value) => setWatermarkForm((prev) => ({ ...prev, scale: value / 100 }))}
disabled={controlsLocked} disabled={customizationDisabled}
/> />
<LabeledSlider <LabeledSlider
label={t('events.watermark.opacity', 'Transparenz')} label={t('events.watermark.opacity', 'Transparenz')}
@@ -470,7 +493,7 @@ export default function MobileBrandingPage() {
max={80} max={80}
step={5} step={5}
onChange={(value) => setWatermarkForm((prev) => ({ ...prev, opacity: value / 100 }))} onChange={(value) => setWatermarkForm((prev) => ({ ...prev, opacity: value / 100 }))}
disabled={controlsLocked} disabled={customizationDisabled}
/> />
<LabeledSlider <LabeledSlider
label={t('events.watermark.padding', 'Abstand zum Rand')} label={t('events.watermark.padding', 'Abstand zum Rand')}
@@ -479,7 +502,7 @@ export default function MobileBrandingPage() {
max={80} max={80}
step={2} step={2}
onChange={(value) => setWatermarkForm((prev) => ({ ...prev, padding: value }))} onChange={(value) => setWatermarkForm((prev) => ({ ...prev, padding: value }))}
disabled={controlsLocked} disabled={customizationDisabled}
/> />
<LabeledSlider <LabeledSlider
label={t('events.watermark.offset', 'Feinjustierung')} label={t('events.watermark.offset', 'Feinjustierung')}
@@ -488,7 +511,7 @@ export default function MobileBrandingPage() {
max={30} max={30}
step={1} step={1}
onChange={(value) => setWatermarkForm((prev) => ({ ...prev, offsetX: value }))} onChange={(value) => setWatermarkForm((prev) => ({ ...prev, offsetX: value }))}
disabled={controlsLocked} disabled={customizationDisabled}
suffix={t('events.watermark.offsetX', 'X-Achse')} suffix={t('events.watermark.offsetX', 'X-Achse')}
/> />
<LabeledSlider <LabeledSlider
@@ -498,7 +521,7 @@ export default function MobileBrandingPage() {
max={30} max={30}
step={1} step={1}
onChange={(value) => setWatermarkForm((prev) => ({ ...prev, offsetY: value }))} onChange={(value) => setWatermarkForm((prev) => ({ ...prev, offsetY: value }))}
disabled={controlsLocked} disabled={customizationDisabled}
/> />
</MobileCard> </MobileCard>
</> </>
@@ -1095,15 +1118,19 @@ function extractWatermark(event: TenantEvent): WatermarkForm {
function buildWatermarkPayload( function buildWatermarkPayload(
form: WatermarkForm, form: WatermarkForm,
watermarkAllowed: boolean, watermarkAllowed: boolean,
brandingAllowed: boolean brandingAllowed: boolean,
removalAllowed: boolean
): WatermarkSettings | null { ): WatermarkSettings | null {
if (!watermarkAllowed) { const customAllowed = watermarkAllowed && brandingAllowed;
return { mode: 'off' }; let mode = form.mode;
if (mode === 'custom' && !customAllowed) {
mode = 'base';
} }
const policy = watermarkAllowed ? 'basic' : 'none'; if (mode === 'off' && !removalAllowed) {
const desiredMode = brandingAllowed ? form.mode : 'base'; mode = 'base';
const mode = desiredMode === 'off' && policy === 'basic' ? 'base' : desiredMode; }
const payload: WatermarkSettings = { const payload: WatermarkSettings = {
mode, mode,
@@ -1115,7 +1142,7 @@ function buildWatermarkPayload(
offset_y: Math.max(-500, Math.min(500, Math.round(form.offsetY))), offset_y: Math.max(-500, Math.min(500, Math.round(form.offsetY))),
}; };
if (mode === 'custom' && brandingAllowed) { if (mode === 'custom' && customAllowed) {
if (form.assetDataUrl) { if (form.assetDataUrl) {
payload.asset_data_url = form.assetDataUrl; payload.asset_data_url = form.assetDataUrl;
} }

View File

@@ -5,6 +5,7 @@
use Illuminate\View\ComponentAttributeBag; use Illuminate\View\ComponentAttributeBag;
$stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column); $stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column);
$assetPath = is_array($asset ?? null) ? ($asset[0] ?? null) : $asset;
@endphp @endphp
<x-filament::page> <x-filament::page>
@@ -17,9 +18,9 @@
</div> </div>
</x-filament::section> </x-filament::section>
@if($asset) @if($assetPath)
<x-filament::section heading="Aktuelles Basis-Wasserzeichen" :icon="Heroicon::Photo"> <x-filament::section heading="Aktuelles Basis-Wasserzeichen" :icon="Heroicon::Photo">
<img src="{{ Storage::disk('public')->url($asset) }}" alt="Watermark" /> <img src="{{ Storage::disk('public')->url($assetPath) }}" alt="Watermark" />
</x-filament::section> </x-filament::section>
@endif @endif
</x-filament::page> </x-filament::page>

View File

@@ -8,6 +8,7 @@ use App\Models\EventType;
use App\Models\Package; use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Models\WatermarkSetting;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -369,7 +370,14 @@ class EventControllerTest extends TenantTestCase
{ {
Storage::fake('public'); Storage::fake('public');
config(['watermark.base.asset' => 'branding/watermarks/base-watermark.png']); $setting = WatermarkSetting::query()->create([
'asset' => 'branding/watermarks/base-watermark.png',
'position' => 'bottom-right',
'opacity' => 0.25,
'scale' => 0.2,
'padding' => 16,
]);
Storage::disk('public')->put('branding/watermarks/base-watermark.png', 'asset'); Storage::disk('public')->put('branding/watermarks/base-watermark.png', 'asset');
$eventType = EventType::factory()->create(); $eventType = EventType::factory()->create();
@@ -389,6 +397,83 @@ class EventControllerTest extends TenantTestCase
$this->assertNotSame('', $url); $this->assertNotSame('', $url);
$this->assertStringContainsString('/api/v1/branding/asset/branding/watermarks/base-watermark.png', $url); $this->assertStringContainsString('/api/v1/branding/asset/branding/watermarks/base-watermark.png', $url);
$this->assertStringContainsString('signature=', $url); $this->assertStringContainsString('signature=', $url);
$this->assertSame($setting->asset, $response->json('data.settings.watermark.asset'));
}
public function test_update_event_allows_disabling_watermark_when_removal_is_enabled(): void
{
$package = Package::factory()->create([
'watermark_allowed' => true,
'branding_allowed' => true,
'features' => ['no_watermark'],
]);
$eventType = EventType::factory()->create();
$event = Event::factory()->for($this->tenant)->create([
'event_type_id' => $eventType->id,
'name' => 'Removal Allowed Event',
'slug' => 'removal-allowed',
'date' => now()->addDays(2),
]);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price ?? 0,
'used_photos' => 0,
]);
$response = $this->authenticatedRequest('PUT', "/api/v1/tenant/events/{$event->slug}", [
'settings' => [
'watermark' => [
'mode' => 'off',
],
],
]);
$response->assertOk();
$event->refresh();
$this->assertSame('off', data_get($event->settings, 'watermark.mode'));
$this->assertTrue((bool) data_get($event->settings, 'watermark_removal_allowed'));
}
public function test_update_event_forces_base_watermark_when_removal_is_disabled(): void
{
$package = Package::factory()->create([
'watermark_allowed' => true,
'branding_allowed' => true,
'features' => [],
]);
$eventType = EventType::factory()->create();
$event = Event::factory()->for($this->tenant)->create([
'event_type_id' => $eventType->id,
'name' => 'Removal Disabled Event',
'slug' => 'removal-disabled',
'date' => now()->addDays(2),
]);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price ?? 0,
'used_photos' => 0,
]);
$response = $this->authenticatedRequest('PUT', "/api/v1/tenant/events/{$event->slug}", [
'settings' => [
'watermark' => [
'mode' => 'off',
],
],
]);
$response->assertOk();
$event->refresh();
$this->assertSame('base', data_get($event->settings, 'watermark.mode'));
$this->assertFalse((bool) data_get($event->settings, 'watermark_removal_allowed'));
} }
public function test_update_event_uploads_branding_logo_data_url(): void public function test_update_event_uploads_branding_logo_data_url(): void

View File

@@ -4,11 +4,15 @@ namespace Tests\Feature;
use App\Filament\Resources\EventResource\Pages\ManageWatermark; use App\Filament\Resources\EventResource\Pages\ManageWatermark;
use App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage; use App\Filament\SuperAdmin\Pages\GuestPolicySettingsPage;
use App\Filament\SuperAdmin\Pages\WatermarkSettingsPage;
use App\Models\Event; use App\Models\Event;
use App\Models\SuperAdminActionLog; use App\Models\SuperAdminActionLog;
use App\Models\User; use App\Models\User;
use App\Models\WatermarkSetting;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire; use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
@@ -49,6 +53,28 @@ class SuperAdminAuditLogSettingsTest extends TestCase
->exists()); ->exists());
} }
public function test_base_watermark_settings_save_creates_audit_log(): void
{
Storage::fake('public');
$user = User::factory()->create(['role' => 'super_admin']);
$this->bootSuperAdminPanel($user);
$file = UploadedFile::fake()->image('watermark.png', 120, 120);
Livewire::test(WatermarkSettingsPage::class)
->set('asset', [$file])
->call('save');
$settings = WatermarkSetting::query()->first();
$this->assertNotNull($settings);
$this->assertNotNull($settings->asset);
Storage::disk('public')->assertExists($settings->asset);
}
private function bootSuperAdminPanel(User $user): void private function bootSuperAdminPanel(User $user): void
{ {
$panel = Filament::getPanel('superadmin'); $panel = Filament::getPanel('superadmin');