__( 'General', 'equasign' ), 'security' => __( 'Encryption key', 'equasign' ), 'otp' => __( 'OTP & Brevo', 'equasign' ), 'type' => __( 'Document types', 'equasign' ), 'model' => __( 'Models', 'equasign' ), 'content' => __( 'EquaSuite content', 'equasign' ), ); $views = array( 'general' => 'settings-general.php', 'security' => 'settings-security.php', 'otp' => 'settings-otp.php', 'type' => 'settings-type.php', 'model' => 'settings-model.php', 'content' => 'settings-content.php', ); // phpcs:ignore WordPress.Security.NonceVerification $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'general'; if ( ! isset( $tabs[ $tab ] ) ) { $tab = 'general'; } echo '
'; self::header(); if ( self::skin_enabled() ) { echo '

' . esc_html__( 'Settings', 'equasign' ) . '

'; echo ''; } else { echo '

' . esc_html__( 'Settings', 'equasign' ) . '

'; echo ''; } $view = EQUASIGN_DIR . 'admin/views/' . $views[ $tab ]; if ( is_readable( $view ) ) { require $view; } else { echo '

' . 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' ) . '

'; } $notice = get_transient( 'equasign_notice_' . get_current_user_id() ); if ( $notice && is_array( $notice ) ) { delete_transient( 'equasign_notice_' . get_current_user_id() ); $class = ( 'error' === $notice['type'] ) ? 'notice-error' : 'notice-success'; echo '

' . esc_html( $notice['message'] ) . '

'; } } /* --------------------------------------------------------------------- */ /* 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 ''; wp_nonce_field( 'equasign_probar_dismiss' ); echo ''; 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 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 // '; echo wp_nonce_field( 'equasign_kek_backup', '_wpnonce', true, false ); echo ' '; echo '
'; echo ''; echo wp_nonce_field( 'equasign_kek_snooze_backup', '_wpnonce', true, false ); echo '
'; echo '

'; } /** * Standalone, plugin-styled interstitial page (used when wp-config.php is not * writable, and for the overwrite confirmation). Emits its own minimal document * so it works from admin-post.php; the body is caller-escaped. * * @param string $title Page title. * @param string $body Pre-escaped HTML body. * @return void */ private static function render_kek_standalone( $title, $body ) { nocache_headers(); header( 'Content-Type: text/html; charset=utf-8' ); // Standalone page emitted straight from admin-post.php (no admin_head/admin_footer fires), // so the stylesheet is registered + enqueued and printed with wp_print_styles(), and the // page-specific tweaks are attached with wp_add_inline_style() — no hard-coded /