' . esc_html__( 'This section is not available yet (documented in a later release).', 'equasign' ) . '
';
}
echo '
';
}
/**
* Help page — the container is shipped now (P4); the section prose is filled
* in a later release (P6). Delegates to a dedicated view when present; falls
* back to a self-contained, sectioned skeleton so the Help entry is never a
* fatal. Both share help_sections() so the section set stays single-sourced.
*
* @return void
*/
public static function page_help() {
self::guard();
if ( self::onboarding_gate() ) {
return;
}
$view = EQUASIGN_DIR . 'admin/views/help.php';
if ( is_readable( $view ) ) {
require $view;
return;
}
echo '
';
self::header();
echo '
' . esc_html__( 'How to use EquaSign', 'equasign' ) . '
';
foreach ( self::help_sections() as $title ) {
echo '
' . esc_html( $title ) . '
';
echo '
' . esc_html__( '(documented in a later release)', 'equasign' ) . '
';
}
echo '
';
}
/**
* Ordered help section titles, shared by the Help view and its fallback. The
* prose under each is authored in a later release; the container ships now.
*
* @return array
*/
public static function help_sections() {
// Kept in sync with the accordion summaries in admin/views/help.php (identical
// strings → shared translations). This list is only the fallback when that
// view is unreadable; the view is the real, richer Help page.
$sections = array(
__( 'Responsibilities & limits', 'equasign' ),
__( 'Getting started — create a signing task', 'equasign' ),
__( 'Document types, models, and languages', 'equasign' ),
__( 'Channels and one-time codes (OTP)', 'equasign' ),
__( 'The signer’s journey', 'equasign' ),
__( 'Proof dossier and disputes', 'equasign' ),
__( 'Verifying a signature (SIGNATURE.md)', 'equasign' ),
// Free-vs-Pro comparison title (degraded-mode fallback only; the real Help view
// is help.php). Neutral label in every build.
__( 'Free vs Pro — what each plan does', 'equasign' ),
);
$sections[] = __( 'Proof event journal, IP address and timestamps', 'equasign' );
$sections[] = __( 'Encryption key (EQUASIGN_KEK): backup and restore', 'equasign' );
return $sections;
}
/* --------------------------------------------------------------------- */
/* Status notices. */
/* --------------------------------------------------------------------- */
/**
* Displays the banners (missing vault, armed mock) + transient notices.
*
* @return void
*/
public static function notices() {
if ( ! current_user_can( self::CAP ) ) {
return;
}
if ( ! EquaSign_Crypto::kek_present() ) {
echo '
EquaSign: '
. esc_html__( 'The master key EQUASIGN_KEK is not defined in wp-config.php. Signing is blocked until it is set. Never lose or change it after the first encryption.', 'equasign' )
. ' ' . esc_html__( 'Set it up now', 'equasign' ) . ''
. '
';
} elseif ( class_exists( 'EquaSign_Seal' ) && EquaSign_Seal::unreadable() ) {
// KEK present but the stored sealing key no longer decrypts under it — explicit,
// actionable, instead of a silent re-mint or unsigned dossiers.
echo '
EquaSign: '
. esc_html__( 'The sealing key cannot be read — the master key EQUASIGN_KEK has changed or is missing (this can happen after a reinstall or migration). Signing is blocked to avoid producing an unsigned proof. Restore the original EQUASIGN_KEK in wp-config.php to resume; already-sealed dossiers stay verifiable via their embedded key.', 'equasign' )
. '
';
}
// One-time reminder after a KEK was auto-written at activation: back it up.
if ( EquaSign_Crypto::kek_present() && get_option( 'equasign_kek_autogen_notice' ) ) {
delete_option( 'equasign_kek_autogen_notice' );
echo '
EquaSign: '
. esc_html__( 'An encryption key was generated automatically and written to wp-config.php. Download your backup now and store it off-site — its loss makes sealed dossiers unreadable.', 'equasign' )
. ' ' . esc_html__( 'Back up my key', 'equasign' ) . ''
. '
';
}
// E-1: no e-mail transport configured → EquaSign cannot send the OTP codes a
// signature requires. Warn the operator UP FRONT (not at the first send failure).
if ( class_exists( 'EquaSign_Brevo' ) && ! EquaSign_Brevo::email_transport_available() ) {
echo '
EquaSign: '
. esc_html__( 'No e-mail transport is configured, so EquaSign cannot send the one-time codes required to sign a document. For good deliverability we recommend the Brevo plugin (set up its account and sender domain); alternatively, enable the wp_mail fallback in Settings › OTP, which sends through your site\'s own mail transport (an SMTP plugin, for reliable delivery).', 'equasign' )
. ' ' . esc_html__( 'Configure e-mail', 'equasign' ) . ''
. '
';
}
// §2: native ext-sodium absent → the seal and encryption still work (via WordPress's
// pure-PHP sodium_compat), but in-memory wiping of keys (defence in depth) is inactive.
// A recommendation, not an error — measured tone.
if ( class_exists( 'EquaSign_Crypto' ) && EquaSign_Crypto::kek_present() && ! EquaSign_Crypto::secure_wipe_available() ) {
echo '
EquaSign: '
. esc_html__( 'EquaSign works normally and your dossiers are sealed correctly, but the native "sodium" PHP extension is not installed on this server, so in-memory wiping of encryption keys (a defence-in-depth measure) is not active. For optimal security, ask your host to enable the PHP "sodium" extension.', 'equasign' )
. '
';
}
}
/* --------------------------------------------------------------------- */
/* Incitation (welcome banner + Pro bar). */
/* --------------------------------------------------------------------- */
/**
* Renders the incitation at the top of EVERY EquaSign admin screen: first the
* local welcome banner (EquaSuite content opt-in), then the discreet "Upgrade
* to Pro" bar. This is the single shared render point for all EquaSign admin
* views (Tasks list/detail, New task, Settings, and the EquaSuite showcase);
* it is hooked on admin_notices and scoped to EquaSign screens so no view file
* has to opt in individually.
*
* The welcome banner sits ABOVE the Pro bar. Both are optional and self-guarded:
* the welcome banner is delegated to the content prise (a no-op when that class
* is absent), and the Pro bar stays silent when the licence prise is not loaded.
*
* @return void
*/
public static function incite() {
if ( ! current_user_can( self::CAP ) ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || false === strpos( (string) $screen->id, 'equasign' ) ) {
return; // Not an EquaSign screen → nothing to show.
}
if ( ! self::terms_accepted() ) {
return; // Onboarding gate is showing — no banner over it.
}
// Key preservation is the priority: the backup reminder sits above all else.
self::kek_backup_reminder();
// Admin banner slot (delegated to the content prise; guarded): OFF → local
// welcome banner, ON → the EquaSuite server banner. Must call admin_banner()
// (not welcome_banner() directly), otherwise the server banner is never
// rendered once the opt-in is ON (welcome_banner early-returns in that state).
if ( class_exists( 'EquaSign_Content' ) ) {
EquaSign_Content::admin_banner();
}
// Then the discreet Pro upsell, below the welcome banner.
self::pro_bar();
}
/**
* Thin, discreet "Upgrade to Pro" bar shown at the top of EVERY EquaSign admin
* screen (rendered from incite(), above the page content). Non-Pro only: hidden
* when the licence tier is 'pro'. Read-only, no network at render; when the tier
* is indeterminate (degraded) it is treated as non-Pro (fail-closed → shown).
*
* @return void
*/
public static function pro_bar() {
// The upsell bar is hidden for a licensed instance. No network at render;
// fail-closed (indeterminate tier → treated as non-Pro → shown).
if ( EquaSign_Policy::is_pro() ) {
return; // Licensed: no banner (and every badge is gone too).
}
// Guideline 11: this build's upsell bar is per-user dismissible (hidden for a while).
if ( 'pro' !== EQUASIGN_BUILD && (int) get_user_meta( get_current_user_id(), 'equasign_probar_hidden_until', true ) > time() ) {
return;
}
// Single source for the CTA label + URL (C4). The bar only adds the lead line on top.
$cta = EquaSign_Policy::upgrade_cta();
$label = (string) apply_filters( 'equasign_pro_cta_label', $cta['label'] );
$url = $cta['url'];
// Free build (WP.org).
$lead = (string) apply_filters( 'equasign_probar_lead', __( "You're on the Free plan. Need SMS one-time codes, customizable document types, or multiple consent texts?", 'equasign' ) );
// E2: internal target (the in-admin installer) opens in the SAME tab; only the
// external purchase page opens in a new tab.
$tgt = ! empty( $cta['external'] ) ? ' target="_blank" rel="noopener noreferrer"' : '';
echo '
';
echo '' . esc_html( $lead ) . ' ';
echo '' . esc_html( $label ) . ''; // phpcs:ignore WordPress.Security.EscapeOutput -- $tgt is a static, developer-controlled attribute fragment (target/rel) selected by a boolean; url and label are escaped above.
if ( 'pro' !== EQUASIGN_BUILD ) {
// Guideline 11: a no-JS, nonce-protected dismiss control on the Free upsell bar.
echo ' ';
}
echo '
';
}
/**
* Dismisses the Free "Upgrade to Pro" bar for the current user (7 days). Guideline 11: the
* upsell bar must be dismissible. No-JS, nonce + capability protected; PRG redirect back.
*
* @return void
*/
public static function handle_probar_dismiss() {
check_admin_referer( 'equasign_probar_dismiss' );
if ( current_user_can( self::CAP ) ) {
update_user_meta( get_current_user_id(), 'equasign_probar_hidden_until', time() + 7 * DAY_IN_SECONDS );
}
$equasign_back = wp_get_referer();
wp_safe_redirect( $equasign_back ? $equasign_back : admin_url( 'admin.php?page=equasign' ) );
exit;
}
/**
* Loads the admin assets on EquaSign pages.
*
* @param string $hook Current page.
* @return void
*/
public static function assets( $hook ) {
if ( false === strpos( $hook, 'equasign' ) ) {
return;
}
wp_enqueue_style( 'equasign-admin', EQUASIGN_URL . 'admin/css/admin.css', array(), EQUASIGN_VERSION );
// Brand design-system overlay, loaded after the base sheet so it wins.
// Skipping it is a complete, safe fallback to the plain WordPress look.
if ( self::skin_enabled() ) {
wp_enqueue_style( 'equasign-admin-ds', EQUASIGN_URL . 'admin/css/admin-ds.css', array( 'equasign-admin' ), EQUASIGN_VERSION );
}
wp_enqueue_script( 'equasign-admin', EQUASIGN_URL . 'admin/js/admin.js', array(), EQUASIGN_VERSION, true );
}
/* --------------------------------------------------------------------- */
/* Brand skin (design system). */
/* --------------------------------------------------------------------- */
/**
* Is the brand design-system skin active? True unless the site defines
* EQUASIGN_ADMIN_CLASSIC (truthy) or a filter opts out. When false the admin
* falls back to the plain WordPress look (no overlay sheet, plain header):
* the design is never a dependency of any feature.
*
* @return bool
*/
public static function skin_enabled() {
$enabled = ! ( defined( 'EQUASIGN_ADMIN_CLASSIC' ) && EQUASIGN_ADMIN_CLASSIC );
return (bool) apply_filters( 'equasign_admin_skin', $enabled );
}
/**
* Pre-paint JS flag on (equasign-js), printed in the head BEFORE the body
* renders, only on EquaSign screens with the skin on. Design-system rules that
* are gated on this class collapse at first paint (no flash); without JS the
* class is absent and every block stays expanded and accessible.
*
* @return void
*/
public static function head_flag() {
if ( ! self::skin_enabled() ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || false === strpos( (string) $screen->id, 'equasign' ) ) {
return;
}
wp_print_inline_script_tag( "document.documentElement.className += ' equasign-js';" ); // Native emitter (not a raw tag).
}
/**
* Common brand header shown at the top of every EquaSign admin page. With the
* skin active it renders the design-system header (embedded mono logotype +
* "Electronic signature" subtitle); otherwise it keeps a plain brand band. The
* incitation (welcome banner + Pro bar) is rendered separately by incite() on
* admin_notices, so it is never duplicated here.
*
* @return void
*/
public static function header() {
$icon = 'data:image/svg+xml;base64,' . base64_encode( self::icon_svg( '#0C3A66' ) );
if ( ! self::skin_enabled() ) {
echo '
EquaSign
';
return;
}
echo '';
echo '';
echo 'EquaSign';
echo '';
echo '' . esc_html__( 'Electronic signature', 'equasign' ) . '';
echo '';
}
/**
* Monochrome menu icon (currentColor SVG) as a base64 data URI, so the admin
* menu color scheme can repaint it. Self-contained: the mark is embedded, with
* no dependency on any file outside the plugin.
*
* @return string
*/
public static function menu_icon() {
return 'data:image/svg+xml;base64,' . base64_encode( self::icon_svg( 'currentColor' ) );
}
/**
* The EquaSign outline mark as an inline SVG string, painted in a single
* color. Used both for the menu icon (currentColor) and the header (brand
* navy). Kept inline so the icon works regardless of external asset folders.
*
* @param string $color A CSS color literal or "currentColor".
* @return string
*/
private static function icon_svg( $color ) {
return '';
}
/* --------------------------------------------------------------------- */
/* admin-post handlers. */
/* --------------------------------------------------------------------- */
/**
* Creates a task from the admin form.
*
* @return void
*/
public static function handle_create() {
self::guard();
check_admin_referer( 'equasign_create' );
$documents = array();
if ( ! empty( $_FILES['equasign_pdf'] ) && is_array( $_FILES['equasign_pdf']['name'] ) ) {
$labels = isset( $_POST['equasign_doc_label'] ) ? (array) wp_unslash( $_POST['equasign_doc_label'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- each element sanitized via sanitize_text_field() at point of use below.
$c_title = isset( $_POST['equasign_checkbox_title'] ) ? (array) wp_unslash( $_POST['equasign_checkbox_title'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- each element sanitized in build_checkboxes() (sanitize_text_field).
$c_content = isset( $_POST['equasign_checkbox_content'] ) ? (array) wp_unslash( $_POST['equasign_checkbox_content'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- each element sanitized in build_checkboxes() (sanitize_text_field).
$c_required = isset( $_POST['equasign_checkbox_required'] ) ? (array) wp_unslash( $_POST['equasign_checkbox_required'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- read only via empty() as a boolean flag in build_checkboxes().
foreach ( $_FILES['equasign_pdf']['name'] as $i => $file_name ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- file name only checked with empty() and sanitized via sanitize_file_name() at point of use below.
if ( empty( $file_name ) || ! empty( $_FILES['equasign_pdf']['error'][ $i ] ) ) {
continue;
}
if ( ! is_uploaded_file( $_FILES['equasign_pdf']['tmp_name'][ $i ] ) ) { // reject spoofed / non-upload paths (arbitrary-file-read guard)
continue;
}
$tmp = $_FILES['equasign_pdf']['tmp_name'][ $i ]; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- validated by is_uploaded_file() above
$content = file_get_contents( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions
if ( false === $content ) {
continue;
}
// No signature_zone: the document inherits the mask of the chosen type.
$documents[] = array(
'name' => isset( $labels[ $i ] ) ? sanitize_text_field( $labels[ $i ] ) : sanitize_file_name( $file_name ),
'pdf_base64' => base64_encode( $content ),
'checkboxes' => self::build_checkboxes(
isset( $c_title[ $i ] ) ? (array) $c_title[ $i ] : array(),
isset( $c_content[ $i ] ) ? (array) $c_content[ $i ] : array(),
isset( $c_required[ $i ] ) ? (array) $c_required[ $i ] : array()
),
);
}
}
$payload = array(
'external_reference' => isset( $_POST['equasign_external_reference'] ) ? sanitize_text_field( wp_unslash( $_POST['equasign_external_reference'] ) ) : '',
'caller' => 'admin',
'issue_immediately' => ! empty( $_POST['equasign_emit'] ),
'type' => isset( $_POST['equasign_type'] ) ? sanitize_key( wp_unslash( $_POST['equasign_type'] ) ) : 'default',
'signer' => array(
'name' => isset( $_POST['equasign_name'] ) ? sanitize_text_field( wp_unslash( $_POST['equasign_name'] ) ) : '',
'email' => isset( $_POST['equasign_email'] ) ? sanitize_email( wp_unslash( $_POST['equasign_email'] ) ) : '',
'phone' => isset( $_POST['equasign_phone'] ) ? sanitize_text_field( wp_unslash( $_POST['equasign_phone'] ) ) : '',
),
'documents' => $documents,
);
// Invitation channel: "default" leaves the send channel OUT so the REST layer
// applies the document type's default; the explicit choices override it.
$payload = array_merge( $payload, equasign_invite_fields( isset( $_POST['equasign_invite'] ) ? sanitize_key( wp_unslash( $_POST['equasign_invite'] ) ) : 'default' ) );
$res = EquaSign_Admin_Client::create( $payload );
if ( 201 === $res['code'] && ! empty( $res['body']['opaque_reference'] ) ) {
self::notice( 'success', __( 'Signing task created.', 'equasign' ) );
self::redirect( admin_url( 'admin.php?page=equasign&ref=' . rawurlencode( $res['body']['opaque_reference'] ) ) );
}
self::notice( 'error', self::error_message( $res, __( 'Could not create the task.', 'equasign' ) ) );
self::redirect( admin_url( 'admin.php?page=equasign-new' ) );
}
/**
* Revocation / renewal from the detail view.
*
* @return void
*/
public static function handle_cycle() {
self::guard();
check_admin_referer( 'equasign_cycle' );
$ref = isset( $_POST['ref'] ) ? sanitize_text_field( wp_unslash( $_POST['ref'] ) ) : '';
$action = isset( $_POST['cycle_action'] ) ? sanitize_key( wp_unslash( $_POST['cycle_action'] ) ) : '';
$res = EquaSign_Admin_Client::cycle( $ref, $action );
if ( 200 === $res['code'] ) {
self::notice( 'success', __( 'Task updated.', 'equasign' ) );
} else {
self::notice( 'error', self::error_message( $res, __( 'Operation failed.', 'equasign' ) ) );
}
self::redirect( admin_url( 'admin.php?page=equasign&ref=' . rawurlencode( $ref ) ) );
}
/**
* Sends the invitation ("Send invitation" button). Retrieves the link via
* REST (fetch), then sends it through Brevo. Human caller only.
*
* @return void
*/
public static function handle_invitation() {
self::guard();
check_admin_referer( 'equasign_invitation' );
$ref = isset( $_POST['ref'] ) ? sanitize_text_field( wp_unslash( $_POST['ref'] ) ) : '';
$channel = 'email';
$res = EquaSign_Admin_Client::fetch( $ref );
if ( 200 !== $res['code'] || empty( $res['body']['signing_link'] ) ) {
self::notice( 'error', __( 'No active link to send. Emit the link first.', 'equasign' ) );
self::redirect( admin_url( 'admin.php?page=equasign&ref=' . rawurlencode( $ref ) ) );
}
// Internal (read-only) lookup for the signer's contact details.
$task = EquaSign_Repo::task_by_reference( $ref );
if ( ! $task ) {
self::notice( 'error', __( 'Task not found.', 'equasign' ) );
self::redirect( admin_url( 'admin.php?page=equasign' ) );
}
// Availability of the requested channel.
if ( 'email' === $channel && empty( $task->signer_email ) ) {
self::notice( 'error', __( 'Email channel unavailable (no email address).', 'equasign' ) );
self::redirect( admin_url( 'admin.php?page=equasign&ref=' . rawurlencode( $ref ) ) );
}
$result = EquaSign_Brevo::send_invitation( $task, $res['body']['signing_link'], $channel );
if ( is_wp_error( $result ) ) {
self::notice( 'error', $result->get_error_message() );
} else {
/* translators: %s: channel (email). */
self::notice( 'success', sprintf( __( 'Invitation sent by %s.', 'equasign' ), $channel ) );
// Operator resend: tagged with source + operator identity so it is
// distinguishable from the automatic create-time send in the journal.
EquaSign_Repo::log_event( (int) $task->id, 'invitation_sent', array( 'channel' => $channel, 'source' => 'operator', 'actor' => self::current_actor() ) );
}
self::redirect( admin_url( 'admin.php?page=equasign&ref=' . rawurlencode( $ref ) ) );
}
/**
* Saves the "General" tab: link validity, max PDF size, display timezone, and
* the retention/purge policy (per-state delays, sealed-task mode, uninstall).
* Only these options are touched, so switching tabs never resets the rest.
*
* @return void
*/
public static function handle_settings_general() {
self::guard();
check_admin_referer( 'equasign_settings_general' );
update_option( 'equasign_expiration_days', max( 1, (int) wp_unslash( $_POST['expiration_days'] ?? 14 ) ) );
update_option( 'equasign_pdf_max_bytes', max( 1, (int) wp_unslash( $_POST['pdf_max_mb'] ?? 10 ) ) * 1024 * 1024 );
update_option( 'equasign_display_timezone', ( 'utc' === sanitize_text_field( wp_unslash( $_POST['display_timezone'] ?? 'wp' ) ) ) ? 'utc' : 'wp' );
if ( isset( $_POST['trusted_proxies'] ) ) {
update_option( 'equasign_trusted_proxies', equasign_sanitize_proxy_list( wp_unslash( $_POST['trusted_proxies'] ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitized by equasign_sanitize_proxy_list (IP/CIDR allowlist).
}
update_option( 'equasign_purge_on_uninstall', ! empty( $_POST['purge_on_uninstall'] ) ? 1 : 0 );
// Retention setting. sanitize_text_field() runs first — it strips tags, collapses and
// trims whitespace, then removes percent-encoded octets, but preserves case — after which
// the sanitized value is validated against the exact whitelist ('partial' | 'full'), with
// any other value falling back to 'partial' (conservative: keep the signed record).
// The whitelist therefore judges the SANITIZED value, so a forged POST that sanitizes
// down to 'full' (e.g. "full") does reach 'full'; that is unreachable from the
//