Viewing File: /usr/local/cpanel/base/frontend/meridian/_assets/react_shell_data.html.tt
[%
# Copyright 2026 WebPros International, LLC
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited.
%]
[%#
react_shell_data.html.tt
Reusable BLOCK that outputs the window.PAGE.shell object
with server-side data for the React AppShell.
Included by each app's index.html.tt when react_full_shell flag is ON.
This avoids duplicating the shell data injection across 60+ app templates.
%]
[%
USE Uapi;
USE NVData;
USE UIAnalytics;
# Resolve variables that master.html.tt sets AFTER the WRAPPER body runs.
# This INCLUDE is called from the WRAPPER body, so master.html.tt hasn't
# executed yet and those TT variables are not yet in stash. Use direct
# CPANEL accessors as fallbacks.
IF !cPanel_version; SET cPanel_version = CPANEL.version(); END;
IF !domain; SET domain = CPANEL.CPDATA.DNS; END;
# Apache plugin provides file_test(); load it so capabilities.wpx resolves.
USE Apache;
# ServerRoles + Services plugins back the role/service probes below.
USE ServerRoles;
USE Services;
# Pull Personalization/NVData values the shell needs at boot time so the
# pre-React bootstrap can apply theme/color-mode/layout before mount and
# hub modules can hydrate from window.PAGE.shell without a round-trip.
# The dashboard keys (suggested-action dismissals + survey state) are
# batched into the same Personalization/get call so the dashboard hub
# hydrates synchronously on first render without any extra round-trips.
# The satisfaction keys (pending + shown for each trigger) ride this same
# batched call so every satisfaction surface — dashboard, email, websites —
# hydrates from the seeded cache without its own Personalization/get on
# mount. Added to the existing batch, so no extra server round-trip.
# The "New in Meridian" markers (DUCKS-6712) ride the same batched call for
# the same reason: the shell decides whether to show the release-notes dialog
# on its first render, so both markers have to be there already or a user who
# dismissed it long ago sees it flash back in on every load. Two keys, one
# call — adding the second name to this list is free.
SET cp_personalization_data = Uapi.exec('Personalization', 'get', {
names = [
'cpanel_theme_preset',
'cpanel_color_mode',
'cpanel_layout_override',
'initial_theme',
'meridian_dashboard_suggested_actions_dismissed',
'meridian_survey_data',
'meridian_satisfaction_pending_onboarding_complete',
'meridian_satisfaction_shown_onboarding_complete',
'meridian_satisfaction_pending_first_email_created',
'meridian_satisfaction_shown_first_email_created',
'meridian_satisfaction_pending_sitejet_website_published',
'meridian_satisfaction_shown_sitejet_website_published',
'meridian_satisfaction_pending_first_wordpress_work',
'meridian_satisfaction_shown_first_wordpress_work',
'meridian-ai-drawer-open',
'meridian-ai-drawer-seen',
'meridian-ai-drawer-width',
'meridian_whats_new_shown',
'meridian_whats_new_release'
]
}).data.personalization;
SET cp_theme_preset_value = cp_personalization_data.cpanel_theme_preset.value;
SET cp_color_mode_value = cp_personalization_data.cpanel_color_mode.value;
SET cp_layout_override_value = cp_personalization_data.cpanel_layout_override.value;
# Standalone-include fallback only, mirroring the IF !cPanel_version /
# IF !cp_analytics_data pattern: master.html.tt pre-reads this in its parent
# scope BEFORE the INCLUDE (a SET here is localized and would never reach
# it), so on the master path this guard is a no-op. The key stays in the
# batch names list above (free — same single call) to feed this fallback.
# The `IF !` guard is safe here: both real values ("jupiter"/"meridian")
# are truthy, and an absent entry is ''/undef from either source.
IF !cp_initial_theme_value;
SET cp_initial_theme_value = cp_personalization_data.initial_theme.value;
END;
SET cp_dashboard_actions_dismissed_value = cp_personalization_data.meridian_dashboard_suggested_actions_dismissed.value;
SET cp_survey_data_value = cp_personalization_data.meridian_survey_data.value;
# Shell-wide bootstrap data: a single TT include is the only writer of
# window.PAGE in shell mode, so hubs always see a consistent payload and
# there is no shallow Object.assign clobber risk.
SET cp_base_path = cp_security_token _ breadcrumb_url _ '/frontend/' _ CPANEL.CPDATA.RS;
SET cp_account_locale = locale.get_user_locale();
SET cp_user_analytics_consent = NVData.get('analytics', undef, 0, 'cpanel');
# Mixpanel project token, resolved server-side so the browser bundle never
# hardcodes a token nor guesses its environment from the build mode. The
# dev-vs-prod choice mirrors Cpanel::Analytics::Mixpanel's _is_dev_environment
# and the legacy cp-ui-analytics web component: a sandbox, or a non-NAT host
# on a private IP, is development. Mixpanel project tokens are NOT secrets —
# they are designed to ship to the browser (see
# Cpanel::Analytics::Mixpanel::Constants). master.html.tt already builds
# cp_analytics_data before this INCLUDE, so the get_cpanel_analytics_data call
# only runs if react_shell_data was included standalone.
IF !cp_analytics_data;
SET cp_analytics_data = UIAnalytics.get_cpanel_analytics_data();
END;
# server_main_ip_is_private can be the STRING 'N/A' (truthy in TT2) when the
# upstream privacy check eval fails; compare to 1 explicitly so an
# indeterminate result is treated as NOT private (DEV), matching the
# authoritative Perl _is_dev_environment, instead of misclassifying a
# non-NAT PROD host as DEV and shipping the dev token.
# Shared dev/prod signal for the browser bundle (Mixpanel token below,
# Sentry environment further down) — not Mixpanel-specific.
SET cp_is_dev_environment = CPANEL.is_sandbox()
|| ( !cp_analytics_data.is_nat && cp_analytics_data.server_main_ip_is_private == 1 );
SET cp_mixpanel_token = cp_is_dev_environment
? 'c7c6f1b1bc8e7b3d8254ebe545861955'
: '2cca34424fe0e8ad6897d354b9591c45';
# The unified WebPros proxy routes by `/<product>/<environment>` and answers
# a bare host with 400 "Unexpected proxy URL path format" (DATA: "How to
# integrate with Mixpanel proxy server"; CPANEL-54053 fixed the same for
# the server-side sender). The base is the panel.ini/Leika-configurable
# `analytics.mixpanel.proxy_url` the Perl sender uses, so both planes move
# together; the env code mirrors Cpanel::Analytics::Mixpanel ('dev'|'prod').
SET cp_mixpanel_proxy_base = CPANEL.mixpanel_proxy_url || 'https://uat.webpros.com';
SET cp_mixpanel_api_host = cp_mixpanel_proxy_base _ '/cp/' _ (cp_is_dev_environment ? 'dev' : 'prod');
# Sentry runtime kill switch (CPANEL-54995). Sentry is otherwise always-on in
# every BUILT bundle — the only other gate is import.meta.env.DEV, which
# separates the Vite dev server from a build, not a sandbox from production —
# so without this a built QA/CI box has no way to stop transmitting short of a
# rebuild or a network block, and its events share quota, alert rules and
# release health with real traffic.
# OPT-OUT, not opt-in: absent file => enabled, so this changes nothing on any
# existing box. Deliberately a file probe rather than a Leika flag — no core
# registration needed, it mirrors CPANEL.is_sandbox()'s /var/cpanel/dev_sandbox
# idiom, and the TT2 template recomputes per request, so
# `touch /var/cpanel/disable_meridian_sentry` takes effect on the next page
# load with no rebuild and no cpsrvd restart.
SET cp_sentry_disabled = file_test('e', '/var/cpanel/disable_meridian_sentry');
# Three categorized bags on window.PAGE — kept separate so hubs and
# the typed contracts in @cpanel/module-contracts can distinguish:
# features — cPanel user privileges (Feature Manager, per-account)
# capabilities — server-install probes (per-server, RPM/upgrade-set)
# flags — admin-toggleable rollout / experiment switches
SET cp_features = Uapi.exec('Features', 'list_features').data;
# WebApp::has_feature — Node.js / generic web-app deploy availability.
# Folded in explicitly (rather than relying solely on list_features,
# which may already carry it) so the key is always present with a real
# boolean value. Fail-closed: wrapped in TRY/CATCH so a missing WebApp
# module (older builds) or any other eval failure leaves the key false
# instead of aborting the whole shell-data render.
SET cp_webapp_available = 0;
TRY;
SET cp_webapp_available = Uapi.exec('WebApp', 'has_feature').data.has_feature ? 1 : 0;
CATCH;
SET cp_webapp_available = 0;
END;
SET cp_features.webapp = cp_webapp_available;
SET cp_capabilities = {
wpx => file_test('e', '/usr/local/cpanel/Cpanel/API/WPX.pm') ? 1 : 0,
nova => file_test('e', '/usr/local/cpanel/Cpanel/API/Nova.pm') ? 1 : 0,
# Server enforces the Two-Factor Authentication security policy
# (SecurityPolicy::TwoFactorAuth). The 2FA UI is only meaningful when
# this is on AND the user holds the twofactorauth feature — mirrors
# Jupiter, which gates on $is_twofactorauth_sec_policy_enabled too.
twoFactorAuthPolicy => ExpVar.expand('$is_twofactorauth_sec_policy_enabled') ? 1 : 0,
roles => {
mailreceive => ServerRoles.is_role_enabled('MailReceive') ? 1 : 0,
mailsend => ServerRoles.is_role_enabled('MailSend') ? 1 : 0,
mysqlclient => ServerRoles.is_role_enabled('MySQLClient') ? 1 : 0,
webserver => ServerRoles.is_role_enabled('WebServer') ? 1 : 0
},
services => {
exim => Services.is_service_provided('exim') ? 1 : 0,
imap => Services.is_service_provided('imap') ? 1 : 0,
pop => Services.is_service_provided('pop') ? 1 : 0,
ftp => Services.is_service_provided('ftp') ? 1 : 0,
cpdavd => Services.is_service_provided('cpdavd') ? 1 : 0,
mysql => Services.is_service_provided('mysql') ? 1 : 0,
postgresql => Services.is_service_provided('postgresql') ? 1 : 0,
httpd => Services.is_service_provided('httpd') ? 1 : 0,
nginx => Services.is_service_provided('nginx') ? 1 : 0
}
};
# Package extensions — a per-account gating axis distinct from features
# (privileges) and capabilities (per-server probes). Read from the cpuser
# file's _PACKAGE_EXTENSIONS field (already loaded as CPANEL.CPDATA). We
# inject only EFFECTIVE extensions: `ai-app-builder` counts only when the
# extension is listed AND its tier (AI_APP_BUILDER_TIER) is non-blank. WHM
# writes both together, so a listed-but-tierless account is a
# misconfiguration — fail closed (treat as not entitled, so Nova stays
# hidden). A whitespace-only tier is treated as blank, so we match `\S`
# (has a non-whitespace char) rather than `.length` (true for spaces).
#
# _PACKAGE_EXTENSIONS starts with an underscore, which TT2 treats as a
# PRIVATE key and blocks from dot access — so read it via `.item(...)` (the
# same vmethod used for the hyphenated meridian-ai-drawer-* keys above),
# which bypasses the private-key rule without touching the global stash.
# The `.defined` guards keep .match/.length from warning on an absent field.
SET cp_ai_ext_listed = CPANEL.CPDATA.item('_PACKAGE_EXTENSIONS').defined
&& CPANEL.CPDATA.item('_PACKAGE_EXTENSIONS').match('(^|\s)ai-app-builder(\s|$)') ? 1 : 0;
SET cp_ai_tier_set = CPANEL.CPDATA.AI_APP_BUILDER_TIER.defined
&& CPANEL.CPDATA.AI_APP_BUILDER_TIER.match('\S') ? 1 : 0;
SET cp_package_extensions = [];
IF cp_ai_ext_listed && cp_ai_tier_set; CALL cp_package_extensions.push('ai-app-builder'); END;
SET cp_flags = {
react_meridian_dashboard_question_cards => CPANEL.CPCONF.react_meridian_dashboard_question_cards == '0' ? 0 : 1,
# Server-wide kill-switch for onboarding's temporary→real primary-domain
# auto-swap. Default-ON: only an explicit '0' in tweaksettings disables
# it (matches the TS `!== false` read in OnboardingHubV3).
react_meridian_onboarding_domain_swap => CPANEL.CPCONF.react_meridian_onboarding_domain_swap == '0' ? 0 : 1
};
# The page-meta merge in master.html.tt sets framework_js from the legacy
# stack (YUI / Angular); override it so Meridian events report the real
# framework and stay distinguishable from Jupiter's in Mixpanel.
# NOTE: on the React path master builds cp_analytics_data BEFORE this
# INCLUDE and passes it in by reference, so this nested mutation is visible
# in the parent scope. TT2 INCLUDE localizes top-level bindings — a fresh
# `SET cp_analytics_data = ...` here would NOT propagate to master — but it
# does not deep-copy nested structures, so mutating a key of the existing
# hashref reaches master's copy. master reads cp_analytics_data.UUID AFTER
# this INCLUDE and sees the same hashref; only framework_js is mutated, not
# UUID. (When react_shell_data is INCLUDEd standalone, the
# IF !cp_analytics_data fallback above builds it locally and there is no
# parent read.) The legacy <cp-ui-load-analytics> snapshot is built only on
# the non-React path, where react_shell_data is not included, so it never
# sees this mutation.
SET cp_analytics_data.framework_js = "React";
-%]
shell: {
variant: "cpanel",
theme: "meridian",
username: [% CPANEL.authuser.json() %],
version: [% cPanel_version ? cPanel_version.json() : 'null' %],
domain: [% domain ? domain.json() : 'null' %],
logoSrc: [% varcache.customizations.header_logo ? varcache.customizations.header_logo.json() : 'null' %],
logoAlt: [% varcache.customizations.logo_description_html ? varcache.customizations.logo_description_html.json() : 'null' %],
docLink: [% varcache.customizations.documentation_url ? varcache.customizations.documentation_url.json() : 'null' %],
helpLink: [% varcache.customizations.help_url ? varcache.customizations.help_url.json() : 'null' %],
appKey: [% APP_KEY ? APP_KEY.json() : 'null' %],
[%# Meridian's sidebar is registry-driven (HUB_MANIFESTS via _registry.js),
never TT2-injected, and the React side has no nav-item consumer at all.
Do not add one here — see docs/guides/extending-meridian.md. %]
themePreset: [% cp_theme_preset_value ? cp_theme_preset_value.json() : '"default"' %],
colorMode: [% cp_color_mode_value ? cp_color_mode_value.json() : 'null' %],
layoutOverride: [% cp_layout_override_value ? cp_layout_override_value.json() : 'null' %],
[%# The theme the account was provisioned on — THE single client-facing
field for it (there is deliberately no onboarding.initialTheme).
Write-once, except a one-way meridian→jupiter promotion when the
account first lands on Jupiter; never demoted. Every consumer goes
through getInitialThemeFromPage(), which owns the value semantics;
gates the "Switch Back to Jupiter" control (useBackSwitchBanner). %]
initialTheme: [% cp_initial_theme_value ? cp_initial_theme_value.json() : 'null' %]
},
ip_address: [% ExpVar.expand('$mainip') ? ExpVar.expand('$mainip').json() : 'null' %],
locale: {
code: [% locale_attributes.locale ? locale_attributes.locale.json() : '"en"' %],
direction: [% locale_attributes.direction ? locale_attributes.direction.json() : '"ltr"' %]
},
basePath: [% cp_base_path.json() %],
applications: [% varcache.available_applications.json() %],
securityToken: [% cp_security_token.json() %],
account_locale: [% cp_account_locale.json() %],
analytics_info: {
analytics: [% cp_user_analytics_consent.json() %],
mixpanelToken: [% cp_mixpanel_token.json() %],
[%# The SDK api_host: proxy base + `/cp/<dev|prod>`. Read via
getMixpanelApiHost(); the transport stays silent without it, exactly
like a missing token. %]
mixpanelApiHost: [% cp_mixpanel_api_host.json() %],
[%# Whether analytics consent must be collected, per the Leika flag
`global.analytics_consent.enable` (CPANEL-47776, DUCKS-6290). Read via
getAnalyticsConsentRequired() to gate the preferences consent UI: hidden
when false (the default — consent not required, all users tracked), shown
only when true. An undefined `is_user_analytics_required_by_leika` (e.g.
the standalone-include path where the ambient cPanel var is absent) is
falsy in TT2 and emits `false`, so the section stays hidden by default. %]
consentRequired: [% is_user_analytics_required_by_leika ? 'true' : 'false' %],
[%# The server's authoritative "may we track this user?" decision
(Cpanel::Analytics::can_track_user_analytics), mirroring Jupiter. It
already combines: the Leika consent-required flag (consent not required
=> track everyone), the WHM admin analytics master switch, and — when
consent IS required — this user's stored consent value. Meridian gates
initAnalytics() on this via getCanTrackUserAnalytics() so tracking
behaves identically to cPanel/Jupiter (DUCKS-6290 follow-up). Do NOT
gate tracking on `analytics` (the raw per-user consent) alone — that
suppresses events for un-prompted users even when consent isn't
required, which is the opposite of the intended default. %]
canTrackUserAnalytics: [% can_track_user_analytics ? 'true' : 'false' %],
[%# The WHM server-wide "Interface Analytics" master switch, standalone
(Cpanel::Analytics::UiIncludes::are_enabled, via the default TT var
CPANEL.is_server_analytics_enabled). Read via getAdminAnalyticsEnabled()
so Meridian can tell "admin globally disabled analytics" apart from "user
hasn't consented" — both otherwise collapse to canTrackUserAnalytics=false
(Story 5.8). Emit 'false' ONLY when the switch is explicitly disabled;
default to enabled ('true') when the var is undefined so an unresolved
accessor can never fabricate an admin-disabled state — matching
getAdminAnalyticsEnabled's absent-field default (safe direction on BOTH
sides). %]
adminAnalyticsEnabled: [% CPANEL.is_server_analytics_enabled.defined && !CPANEL.is_server_analytics_enabled ? 'false' : 'true' %],
[%# Full server/account/license/locale context — the same cpAnalyticsData
bundle Jupiter registers — so both themes' events share dimensions. Read
via getAnalyticsContextFromPage() and applied as super/group/profile
properties in MeridianShellProvider. Server metadata, not user PII.
NOTE: this bundle's server_main_ip is the SERVER/node public IP (Jupiter
parity, intentionally shipped — do NOT strip it), NOT the visitor's IP;
do not silently repurpose the key for a client/visitor address.
This is the JS output region, so any note here must be a TT2 comment,
never a bare hash-comment (which would print into the object literal and
break it). Keep TT2 delimiter tokens out of this comment body too: the
first closing delimiter ends the comment, so one embedded mid-sentence
would dump the rest of this prose straight into the JS. %]
context: [% cp_analytics_data.json() %]
},
[%# Sentry config (CPANEL-54995); read via isSentryProductionFromPage() and
isSentryEnabledFromPage(). `enabled` is an OPT-OUT kill switch: emit 'false'
only when /var/cpanel/disable_meridian_sentry exists, so a missing file — or an
older shell bundle that does not read the field at all — leaves monitoring on. %]
sentry: {
isProduction: [% cp_is_dev_environment ? 'false' : 'true' %],
enabled: [% cp_sentry_disabled ? 'false' : 'true' %]
},
features: [% cp_features.json() %],
capabilities: [% cp_capabilities.json() %],
packageExtensions: [% cp_package_extensions.json() %],
flags: [% cp_flags.json() %],
dashboard: {
suggestedActionsDismissed: [% cp_dashboard_actions_dismissed_value ? cp_dashboard_actions_dismissed_value.json() : 'null' %],
surveyData: [% cp_survey_data_value ? cp_survey_data_value.json() : 'null' %]
},
[%# Satisfaction NVData, keyed by the raw NVData key name so the boundary code
can seed PersonalizationService's cache directly with no field remap. The
React shell reads these via getSatisfactionInjected() and seeds the cache at
boot, so every satisfaction surface hydrates without a Personalization/get
on mount. pending values are JSON event blobs; shown markers are 'true'. %]
satisfaction: {
"meridian_satisfaction_pending_onboarding_complete": [% cp_personalization_data.meridian_satisfaction_pending_onboarding_complete.value ? cp_personalization_data.meridian_satisfaction_pending_onboarding_complete.value.json() : 'null' %],
"meridian_satisfaction_shown_onboarding_complete": [% cp_personalization_data.meridian_satisfaction_shown_onboarding_complete.value ? cp_personalization_data.meridian_satisfaction_shown_onboarding_complete.value.json() : 'null' %],
"meridian_satisfaction_pending_first_email_created": [% cp_personalization_data.meridian_satisfaction_pending_first_email_created.value ? cp_personalization_data.meridian_satisfaction_pending_first_email_created.value.json() : 'null' %],
"meridian_satisfaction_shown_first_email_created": [% cp_personalization_data.meridian_satisfaction_shown_first_email_created.value ? cp_personalization_data.meridian_satisfaction_shown_first_email_created.value.json() : 'null' %],
"meridian_satisfaction_pending_sitejet_website_published": [% cp_personalization_data.meridian_satisfaction_pending_sitejet_website_published.value ? cp_personalization_data.meridian_satisfaction_pending_sitejet_website_published.value.json() : 'null' %],
"meridian_satisfaction_shown_sitejet_website_published": [% cp_personalization_data.meridian_satisfaction_shown_sitejet_website_published.value ? cp_personalization_data.meridian_satisfaction_shown_sitejet_website_published.value.json() : 'null' %],
"meridian_satisfaction_pending_first_wordpress_work": [% cp_personalization_data.meridian_satisfaction_pending_first_wordpress_work.value ? cp_personalization_data.meridian_satisfaction_pending_first_wordpress_work.value.json() : 'null' %],
"meridian_satisfaction_shown_first_wordpress_work": [% cp_personalization_data.meridian_satisfaction_shown_first_wordpress_work.value ? cp_personalization_data.meridian_satisfaction_shown_first_wordpress_work.value.json() : 'null' %]
},
[%# AI assistant pane NVData (open/closed, nudge-seen, saved width), keyed by
the raw NVData key name for the same reason as the satisfaction bag above:
the React shell reads these via getAiAssistantInjected() and seeds
PersonalizationService's cache at boot (CPANEL-54358), so the pane opens at
its saved geometry on first render instead of flashing the defaults and
correcting itself when the background Personalization/get lands. The keys
contain hyphens, so TT item() access is required. %]
aiAssistant: {
"meridian-ai-drawer-open": [% cp_personalization_data.item('meridian-ai-drawer-open').value ? cp_personalization_data.item('meridian-ai-drawer-open').value.json() : 'null' %],
"meridian-ai-drawer-seen": [% cp_personalization_data.item('meridian-ai-drawer-seen').value ? cp_personalization_data.item('meridian-ai-drawer-seen').value.json() : 'null' %],
"meridian-ai-drawer-width": [% cp_personalization_data.item('meridian-ai-drawer-width').value ? cp_personalization_data.item('meridian-ai-drawer-width').value.json() : 'null' %]
},
[%# "New in Meridian" release-notes NVData (DUCKS-6712), keyed by the raw NVData
key name for the same reason as the two bags above: the React shell reads it
via getWhatsNewInjected() and seeds PersonalizationService's cache at boot, so
useWhatsNewDialog can decide show-vs-suppress synchronously on its first
render.
TWO keys, because the dialog is not one-time-only. Every row in the registry
carries the release that introduced it, and the dialog re-opens showing only
rows newer than what the user has acknowledged:
meridian_whats_new_release — the highest release acknowledged, a decimal
integer string. null means "nothing acknowledged".
meridian_whats_new_shown — the ISO timestamp of the last dismissal. Kept
for support ("when did they see it?") AND as the legacy migration
signal: it used to be the whole model, so a user carrying it with NO
release marker has already seen release 1 and must not be re-shown it.
Dropping this key from the bag re-shows the introductory dialog to the
entire installed base.
Both are written together on dismiss, and together they are the dialog's only
"already seen" input: DUCKS-7078 removed its two account-level gates
(onboarding status, and shell.initialTheme above — which used to suppress the
introductory release for born-on-Meridian accounts). Those accounts now get
the introduction, and write these markers on dismiss like everyone else. %]
whatsNew: {
"meridian_whats_new_shown": [% cp_personalization_data.meridian_whats_new_shown.value ? cp_personalization_data.meridian_whats_new_shown.value.json() : 'null' %],
"meridian_whats_new_release": [% cp_personalization_data.meridian_whats_new_release.value ? cp_personalization_data.meridian_whats_new_release.value.json() : 'null' %]
}
Back to Directory