publishableKey() && '' !== $this->secretKey(); } /** * Which student registration mode is active: `invite` (default) — only a * valid invite token grants the registration form — or `self_approval` — * anyone may sign up, confirm their email, and await studio approval. */ public function registrationMode(): string { return self::MODE_SELF_APPROVAL === get_option( self::OPT_REGISTRATION_MODE, self::MODE_INVITE ) ? self::MODE_SELF_APPROVAL : self::MODE_INVITE; } /** * Whether anyone may self-register (the `self_approval` mode). */ public function openRegistrationEnabled(): bool { return self::MODE_SELF_APPROVAL === $this->registrationMode(); } public function renderPage(): void { if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) { wp_die( esc_html__( 'You do not have permission to manage billing settings.', 'unsupervised-schedular' ) ); } if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_settings_action' ) ) { $this->save(); } $publishableKey = $this->publishableKey(); // Secrets are write-only in the UI: never echo a stored secret back into the // page. We only surface whether one is set so the field can be left blank to // keep the existing value. $secretKeySet = '' !== $this->secretKey(); $webhookSecretSet = '' !== $this->webhookSecret(); $webhookUrl = rest_url( 'us-scheduler/v1/payments/webhook' ); $mode = $this->mode(); $currency = $this->currency(); $etransferEmail = $this->etransferEmail(); $hstRate = $this->hstRate(); $stripeConfigured = $this->isStripeConfigured(); $openRegistration = $this->openRegistrationEnabled(); // Stored in hours, surfaced to the admin in whole days. $cancellationCutoffDays = (int) round( $this->cancellationCutoffHours() / 24 ); include USC_PLUGIN_DIR . 'templates/admin/settings.php'; } private function save(): void { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing $mode = sanitize_key( Val::string( wp_unslash( $_POST['mode'] ?? 'test' ) ) ); update_option( self::OPT_PUBLISHABLE, sanitize_text_field( Val::string( wp_unslash( $_POST['publishable_key'] ?? '' ) ) ) ); // Secret fields are write-only: a blank submission keeps the stored secret, // so an admin saving other settings never wipes the keys. $secretKey = sanitize_text_field( Val::string( wp_unslash( $_POST['secret_key'] ?? '' ) ) ); if ( '' !== $secretKey ) { update_option( self::OPT_SECRET, $secretKey ); } $webhookSecret = sanitize_text_field( Val::string( wp_unslash( $_POST['webhook_secret'] ?? '' ) ) ); if ( '' !== $webhookSecret ) { update_option( self::OPT_WEBHOOK_SECRET, $webhookSecret ); } update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' ); update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( Val::string( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) ) ); update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion. $hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0; update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) ); // The cutoff is entered in whole days but stored in hours. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::int() coerces to int; slashes cannot survive numeric coercion. $cutoffDays = isset( $_POST['cancellation_cutoff_days'] ) ? max( 0, Val::int( $_POST['cancellation_cutoff_days'] ) ) : 0; update_option( self::OPT_CANCELLATION_CUTOFF_HOURS, $cutoffDays * 24 ); $this->applyRegistrationMode( isset( $_POST['open_registration'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing } /** * Enable or disable open (self-approval) registration, mirroring the change * into the two core WordPress options it depends on. * * Enabling snapshots the current `users_can_register` and `default_role`, * then turns registration on and makes Student the default new-user role. * Disabling restores that snapshot, so this toggle never permanently * overwrites a site's own membership settings. Only transitions act, so * saving unrelated settings leaves the core options untouched. */ private function applyRegistrationMode( bool $enable ): void { $currentlyOpen = $this->openRegistrationEnabled(); if ( $enable && ! $currentlyOpen ) { update_option( self::OPT_PREV_USERS_CAN_REGISTER, get_option( 'users_can_register' ) ? '1' : '0' ); update_option( self::OPT_PREV_DEFAULT_ROLE, Val::string( get_option( 'default_role', 'subscriber' ) ) ); update_option( 'users_can_register', '1' ); update_option( 'default_role', RoleManager::STUDENT ); update_option( self::OPT_REGISTRATION_MODE, self::MODE_SELF_APPROVAL ); return; } if ( ! $enable && $currentlyOpen ) { $prevCanRegister = '1' === Val::string( get_option( self::OPT_PREV_USERS_CAN_REGISTER, '0' ) ); $prevRole = Val::string( get_option( self::OPT_PREV_DEFAULT_ROLE, 'subscriber' ) ); update_option( 'users_can_register', $prevCanRegister ? '1' : '0' ); update_option( 'default_role', '' !== $prevRole ? $prevRole : 'subscriber' ); delete_option( self::OPT_PREV_USERS_CAN_REGISTER ); delete_option( self::OPT_PREV_DEFAULT_ROLE ); update_option( self::OPT_REGISTRATION_MODE, self::MODE_INVITE ); } } }