Repository: openemr/openemr
Pull request #8088: Feature add persist token portal login and Whereto redirect
Author: login: sjpadgett
Created: 2025-03-08T03:38:12Z | Merged: 2025-03-16T20:45:57Z

add new session var to track URL landOn query for redirect.

Fixes #8079 

#### Changes proposed in this pull request:
- Allow a menu redirect to various cards in portal from login URL.
- Implement login token create option from portal credential dashboard
- Implement ability for user to create onetime token to send to patient for logging straight to Payments to pay balances.

#### Does your code include anything generated by an AI Engine? No


Linked issue #8079: feat: pt pay invoice via onetime token
would be great if a person could pay their invoice online without having to create a portal account
bonus would be to do so using sphere ecommerce credentials







<!-- Love openemr? Please consider supporting our project:
👉  https://github.com/sponsors/openemr -->

Linked issue comments: user: login: sjpadgett
created_at: 2025-03-05T15:20:06Z
body: @stephenwaite Have you tried using Front Payments? I added credit card support a while back and Brady added Sphere to this feature I believe.
author_association: MEMBER
user: login: stephenwaite
created_at: 2025-03-05T15:46:20Z
body: hi @sjpadgett , this would be for a patient that received the bill by email or message.
author_association: MEMBER
user: login: sjpadgett
created_at: 2025-03-05T15:54:05Z
body: Ahh Okay, Good idea. I need to go ahead and finish the ability to send a onetime from Dashboard Portal manage. Will look at today sometime....
author_association: MEMBER
user: login: adunsulag
created_at: 2025-03-05T18:54:19Z
body: @stephenwaite is this something you want in 7.0.3? Or hold off to next patch?
author_association: MEMBER
user: login: sjpadgett
created_at: 2025-03-05T19:07:47Z
body: Yep trying for 7.0.3. I have at least a week. If I don't make it I'll reset milestone.
author_association: MEMBER
user: login: sjpadgett
created_at: 2025-03-05T23:03:31Z
body: @juggernautsei note that I will be adding the whereto to api query and onetime tokens. I noted your request on the forum a while back.
author_association: MEMBER
user: login: sjpadgett
created_at: 2025-03-08T04:14:32Z
body: I may take a look at generating an invoice to companion payment form so patient can download as receipt
author_association: MEMBER
user: login: juggernautsei
created_at: 2025-03-08T11:42:12Z
body: @sjpadgett the patient check-in module that I have been building is working well with the onetime token and redirect into the portal. The whereto guides the patient to the payment card like what is being discussed here. 
author_association: MEMBER
user: login: stephenwaite
created_at: 2025-03-08T13:46:38Z
body: thanks Jerry, how about when the invoices are batched in Fees->Posting Payments and a bunch are selected to be emailed then it uses the one-time token?
author_association: MEMBER
user: login: stephenwaite
created_at: 2025-03-08T13:48:37Z
body: was thinking about creating a twig template of an invoice based off of [custom invoice PR](https://github.com/openemr/openemr/pull/7861)
author_association: MEMBER

Diff:
diff --git a/interface/modules/custom_modules/oe-module-faxsms/library/api_onetime.php b/interface/modules/custom_modules/oe-module-faxsms/library/api_onetime.php
index ac39ca9ab72..c89f28b2a16 100644
--- a/interface/modules/custom_modules/oe-module-faxsms/library/api_onetime.php
+++ b/interface/modules/custom_modules/oe-module-faxsms/library/api_onetime.php
@@ -6,22 +6,74 @@
  * @package   OpenEMR
  * @link      http://www.open-emr.org
  * @author    Jerry Padgett <sjpadgett@gmail.com>
- * @copyright Copyright (c) 2023 Jerry Padgett <sjpadgett@gmail.com>
+ * @copyright Copyright (c)2023-2025 Jerry Padgett <sjpadgett@gmail.com>
  * @license   https://github.com/openemr/openemr/blob/master/LICENSE GNU General public License 3
  */
 
 require_once(__DIR__ . "/../../../../globals.php");
 
 use OpenEMR\Common\Csrf\CsrfUtils;
+use OpenEMR\Events\Messaging\SendNotificationEvent;
 use OpenEMR\Services\PatientPortalService;
 
-if (!CsrfUtils::verifyCsrfToken($_POST["csrf_token_form"], 'contact-form')) {
+if (!CsrfUtils::verifyCsrfToken($_REQUEST["csrf_token_form"], 'contact-form')) {
     CsrfUtils::csrfNotVerified();
 }
 
 if (isset($_REQUEST['sendOneTime'])) {
     try {
-        $rtn = doOnetimeDocumentRequest();
+        doOnetimeDocumentRequest();
+    } catch (Exception $e) {
+        die($e->getMessage());
+    }
+}
+
+if (isset($_REQUEST['sendInvoiceOneTime'])) {
+    try {
+        doOnetimeInvoiceRequest();
+    } catch (Exception $e) {
+        die($e->getMessage());
+    }
+}
+
+/**
+ * @throws Exception
+ */
+function doOnetimeInvoiceRequest(): void
+{
+    $service = new PatientPortalService();
+    // auto allow if a portal user else must be an admin
+    if (!$service::isPortalUser()) {
+        // default is admin documents
+        if (!$service::verifyAcl()) {
+            throw new Exception(xlt("Error! Not authorised. You must be an authorised portal user or admin."));
+        }
+    }
+    $ot_pid = $_REQUEST['pid'] ?? 0;
+    if (!empty($ot_pid)) {
+        $patient = $service->getPatientDetails($ot_pid);
+    } else {
+        throw new Exception(xlt("Error! Missing patient id."));
+    }
+    $message = "Dear " . $patient['fname'] . ' ' . $patient['lname'] . ",\n";
+    $message .= xlt("Please review your current invoice by clinking the link to automatically redirect to your billing account portal. Use this PIN to complete authorization");
+    $data = [
+        'pid' => $ot_pid,
+        'expiry_interval' => "P14D",
+        'text_message' => $message,
+        'html_message' => "",
+        'redirect_url' => $GLOBALS['web_root'] . "/portal/home.php?site=" . urlencode($_SESSION['site_id']) . "&landOn=MakePayment",
+        'phone' => $patient['phone'] ?? '',
+        'email' => $patient['email'] ?? '',
+        'actions' => [
+            'enforce_onetime_use' => true,
+            'enforce_auth_pin' => true,
+            'extend_portal_visit' => false,
+        ]
+    ];
+    try {
+        $rtn = $GLOBALS["kernel"]->getEventDispatcher()
+            ->dispatch(new SendNotificationEvent($data['pid'], $data, 'email'), SendNotificationEvent::SEND_NOTIFICATION_SERVICE_UNIVERSAL_ONETIME);
     } catch (Exception $e) {
         die($e->getMessage());
     }
@@ -30,7 +82,7 @@
 /**
  * @throws Exception
  */
-function doOnetimeDocumentRequest()
+function doOnetimeDocumentRequest(): void
 {
     $service = new PatientPortalService();
     // auto allow if a portal user else must be an admin
diff --git a/interface/modules/custom_modules/oe-module-faxsms/src/Events/NotificationEventListener.php b/interface/modules/custom_modules/oe-module-faxsms/src/Events/NotificationEventListener.php
index 9dbbcf82bc4..fa28d39916e 100644
--- a/interface/modules/custom_modules/oe-module-faxsms/src/Events/NotificationEventListener.php
+++ b/interface/modules/custom_modules/oe-module-faxsms/src/Events/NotificationEventListener.php
@@ -17,6 +17,7 @@
 use OpenEMR\Common\Logging\SystemLogger;
 use OpenEMR\Events\Messaging\SendNotificationEvent;
 use OpenEMR\Modules\FaxSMS\Controller\AppDispatch;
+use PHPMailer\PHPMailer\Exception;
 use Symfony\Component\EventDispatcher\EventDispatcher;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
@@ -44,6 +45,7 @@ public static function getSubscribedEvents(): array
         return [
             SendNotificationEvent::SEND_NOTIFICATION_BY_SERVICE => 'onNotifyEvent',
             SendNotificationEvent::SEND_NOTIFICATION_SERVICE_ONETIME => 'onNotifyDocumentRenderOneTime',
+            SendNotificationEvent::SEND_NOTIFICATION_SERVICE_UNIVERSAL_ONETIME => 'onNotifyPortalPaymentOneTime',
         ];
     }
 
@@ -55,6 +57,7 @@ public function subscribeToEvents(EventDispatcher $eventDispatcher)
     {
         $eventDispatcher->addListener('sendNotification.send', [$this, 'onNotifySendEvent']);
         $eventDispatcher->addListener('sendNotification.service.onetime', [$this, 'onNotifyDocumentRenderOneTime']);
+        $eventDispatcher->addListener('sendNotification.service.universal.onetime', [$this, 'onNotifyUniversalOneTime']);
         $eventDispatcher->addListener(SendNotificationEvent::ACTIONS_RENDER_NOTIFICATION_POST, [$this, 'notificationButton']);
         $eventDispatcher->addListener(SendNotificationEvent::JAVASCRIPT_READY_NOTIFICATION_POST, [$this, 'notificationDialogFunction']);
     }
@@ -66,7 +69,7 @@ public function subscribeToEvents(EventDispatcher $eventDispatcher)
      * @return string
      * @throws \Exception
      */
-    public function onNotifyDocumentRenderOneTime(SendNotificationEvent $event)
+    public function onNotifyDocumentRenderOneTime(SendNotificationEvent $event): string
     {
         $status = 'Starting request.' . ' ';
         $site_id = ($_SESSION['site_id'] ?? null) ?: 'default';
@@ -105,7 +108,7 @@ public function onNotifyDocumentRenderOneTime(SendNotificationEvent $event)
         }
 
         if ($patient['hipaa_allowsms'] == 'YES' && $includeSMS) {
-            $status .= "Sending SMS to " .  text($recipientPhone) . ': ';
+            $status .= "Sending SMS to " . text($recipientPhone) . ': ';
             $clientApp = AppDispatch::getApiService('sms');
             $status_api = $clientApp->sendSMS(
                 $recipientPhone,
@@ -129,6 +132,111 @@ public function onNotifyDocumentRenderOneTime(SendNotificationEvent $event)
             $status .= text($this->emailNotification($recipientEmail, $html_message));
         }
         $status .= "\n";
+        echo(nl2br($status)); //preserve html for alert status
+        return 'okay';
+    }
+
+    /**
+     * Send a token for universal onetime/token.
+     * Example: Payment link.
+     * $data = [
+     *   'pid' => $e_pid,
+     *   'expiry_interval' => "P2D", // valid for 2 days.
+     *   'text_message' => "Please make a payment for your appointment.",
+     *   'html_message' => "",
+     *   'redirect_url' => $GLOBALS['web_root'] . "/portal/home.php?site=" . urlencode($_SESSION['site_id']) . "&landOn=MakePayment",
+     *   'actions' => [
+     *      'enforce_onetime_use' => true,
+     *      'enforce_auth_pin' => true,
+     *     ]
+     * ];
+     * // Dispatch the event. In this case, the onetime is created and emailed to the recipient.
+     * $GLOBALS["kernel"]->getEventDispatcher()->dispatch(new SendNotificationEvent($e_pid, $data), SendNotificationEvent::SEND_NOTIFICATION_SERVICE_UNIVERSAL_ONETIME);
+     *
+     * @param SendNotificationEvent $event
+     * @return string
+     * @throws \Exception
+     */
+    public function onNotifyUniversalOneTime(SendNotificationEvent $event): string
+    {
+        // TODO: Move Implement onNotifyUniversalOneTime() method
+        $status = 'Starting request.' . ' ';
+        $site_id = ($_SESSION['site_id'] ?? null) ?: 'default';
+        $pid = $event->getPid();
+        $defaultUrl = $GLOBALS['web_root'] . "/portal/home.php?site=" . urlencode($site_id) . "&landOn=MakePayment";
+        $redirectURL = $data['redirect_url'] ?? $defaultUrl;
+        $data = $event->getEventData() ?? [];
+        $patient = $event->fetchPatientDetails($pid);
+
+        $recipientEmail = $data['email'] ?? $patient['email'];
+        $recipientPhone = $patient['phone'];
+        $sendMethod = $event->getSendNotificationMethod();
+        $includeSMS = ($sendMethod == 'sms' || $sendMethod == 'both') && $this->isSmsEnabled;
+        $includeEmail = $sendMethod == 'email' || $sendMethod == 'both';
+        // default actions.
+        $actionDefaults = [
+            'enforce_onetime_use' => false, // Enforces the onetime token to be used only once.
+            'extend_portal_visit' => true, // Extends the portal visit by not forcing logout redirect.
+            'enforce_auth_pin' => false, // Requires the pin to be entered.
+            'max_access_count' => 0, // 0 = unlimited.
+        ];
+        $actions = array_merge($actionDefaults, $data['actions'] ?? []); // from event data.
+
+        $text_message = $data['text_message'] ?? xl("Click link to run application.");
+        $html_message = $data['html_message'] ?? '';
+
+        $parameters = [
+            'pid' => $pid,
+            'redirect_link' => $redirectURL,
+            'email' => '',
+            'expiry_interval' => $data['expiry_interval'] ?? 'PT60M',
+            'actions' => $actions,
+        ];
+        // get token.
+        $service = new OneTimeAuth();
+        $oneTime = $service->createPortalOneTime($parameters); // create the token.
+
+        if (!isset($oneTime['encoded_link'])) {
+            (new SystemLogger())->errorLogCaller("Failed to generate encoded_link with onetime service");
+            return 'Failed! Redirect link.';
+        }
+
+        $status .= "Send Method: $sendMethod\n";
+
+        $canned = "\n\n" . xlt("PIN") . ": " . $oneTime['pin'] ?? '';
+        $canned .= "\n" . xlt("Link") . ": " . $oneTime['encoded_link'];
+        $canned .= "\n" . xlt("If you are not automatically redirected after clicking, please copy and then paste the link into your browser's address bar.");
+        $canned .= "\n" . xlt("Thank you for your attention.");
+        $text_message .= $canned;
+        if (empty($html_message)) {
+            $html_message = "<html><body><div class='wrapper'><p>" . nl2br($text_message) . "</p></div></body></html>";
+        }
+
+        if ($patient['hipaa_allowsms'] == 'YES' && $includeSMS) {
+            $status .= "Sending SMS to " . text($recipientPhone) . ': ';
+            $clientApp = AppDispatch::getApiService('sms');
+            $status_api = $clientApp->sendSMS(
+                $recipientPhone,
+                "",
+                $text_message,
+                $recipientPhone
+            );
+            if ($status_api !== true) {
+                $status .= text($status_api);
+            } else {
+                $status .= xlt("Message sent.");
+            }
+        }
+        $status .= "\n";
+        if (
+            !empty($recipientEmail)
+            && ($includeEmail)
+            && ($patient['hipaa_allowemail'] == 'YES')
+        ) {
+            $status .= "Sending email to " . text($recipientEmail) . ': ';
+            $status .= text($this->emailNotification($recipientEmail, $html_message)); // TODO use mail client
+        }
+        $status .= "\n";
         echo (nl2br($status)); //preserve html for alert status
         return 'okay';
     }
@@ -169,11 +277,7 @@ public function onNotifySendEvent(SendNotificationEvent $event): string
             }
         }
 
-        if (
-            !empty($patient['email'])
-            && ($data['include_email'] ?? false)
-            && ($patient['hipaa_allowemail'] == 'YES')
-        ) {
+        if (!empty($patient['email']) && ($data['include_email'] ?? false) && ($patient['hipaa_allowemail'] == 'YES')) {
             $status .= $this->emailNotification($patient['email'], $message);
         }
         return $status;
@@ -187,33 +291,43 @@ public function onNotifySendEvent(SendNotificationEvent $event): string
      */
     public function emailNotification($email, $content, $file = null): string
     {
-        $from_name = ($user['fname'] ?? '') . ' ' . ($user['lname'] ?? '');
-        $mail = new MyMailer(true);
-        $smtpEnabled = $mail::isConfigured();
-        if (!$smtpEnabled) {
-            return 'Error: ' . xlt("Mail was not sent. A SMTP client is not set up in Config Notifications!.");
-        }
-        $isHtml = (stripos($content, '<html') !== false) || (stripos($content, '<body') !== false);
-        if (!$isHtml) {
-            $html = "<html><body><div class='wrapper'>" . nl2br($content) . "</div></body></html>";
-        } else {
-            $html = $content;
-        }
-        $from_name = text($from_name);
-        $from = $GLOBALS["practice_return_email_path"];
-        $mail->AddReplyTo($from, $from_name);
-        $mail->SetFrom($from, $from);
-        $to = $email;
-        $to_name = $email;
-        $mail->AddAddress($to, $to_name);
-        $subject = xl("Your clinic asks for your attention.");
-        $mail->Subject = $subject;
-        $mail->MsgHTML($html);
-        $mail->IsHTML(true);
-        if (!empty($file)) {
-            $mail->AddAttachment($file);
+        $send = '';
+        try {
+            $from_name = ($user['fname'] ?? '') . ' ' . ($user['lname'] ?? '');
+            $mail = new MyMailer(true);
+            $smtpEnabled = $mail::isConfigured();
+            if (!$smtpEnabled) {
+                return 'Error: ' . xlt("Mail was not sent. A SMTP client is not set up in Config Notifications!.");
+            }
+            $isHtml = (stripos($content, '<html') !== false) || (stripos($content, '<body') !== false);
+            if (!$isHtml) {
+                $html = "<html><body><div class='wrapper'>" . nl2br($content) . "</div></body></html>";
+            } else {
+                $html = $content;
+            }
+            $from_name = text($from_name);
+            $from = $GLOBALS["practice_return_email_path"];
+            $mail->addReplyTo($from, $from_name);
+            $mail->setFrom($from, $from);
+            $to = $email;
+            $to_name = $email;
+            $mail->addAddress($to, $to_name);
+            $subject = xl("Your clinic asks for your attention.");
+            $mail->Subject = $subject;
+            $mail->msgHTML($html);
+            $mail->isHTML(true);
+            if (!empty($file)) {
+                $mail->addAttachment($file);
+            }
+            $send = $mail->Send();
+            $mail->smtpClose();
+            if (!$send) {
+                error_log("Failed to send email: " . $mail->ErrorInfo);
+            }
+        } catch (Exception $e) {
+            error_log("Failed to send email: " . $e->getMessage());
         }
-        return $mail->Send();
+        return $send ? xlt("Email sent.") : xlt("Email failed to send.");
     }
 
     /**
@@ -238,6 +352,21 @@ public function notificationButton(SendNotificationEvent $notificationEvent): vo
                 );" value="true"><?php echo $buttonName; ?></button>
     <?php }
 
+    /**
+     * Available button to use.
+     * @param SendNotificationEvent $notificationEvent
+     * @return void
+     */
+    public function universalSubmitButton(SendNotificationEvent $notificationEvent): void
+    {
+        $e_pid = $notificationEvent->getPid();
+        $p_data = $notificationEvent->fetchPatientDetails($e_pid);
+        $eData = $notificationEvent->getEventData();
+        $buttonName = $eData['button_name'] ?? xlt('Submit');
+        ?>
+        <button type="button" class="btn btn-success btn-sm btn-send-msg" onclick="sendUniversalNotification(<?php echo attr_js($e_pid) ?>)"><?php echo $buttonName; ?></button>
+    <?php }
+
     /**
      * @param SendNotificationEvent $event
      * @return void
@@ -270,17 +399,16 @@ public function notificationDialogFunction(SendNotificationEvent $event): void
         $modal_size_height = $e['modal_size_height'] ?? '';
         ?>
         function sendNotification(pid, docName, docId, details) {
-            let btnClose = <?php echo xlj("Cancel"); ?>;
-            let title = <?php echo xlj("Send Message"); ?>;
-            let url = top.webroot_url + '<?php echo $url_part; ?>' + encodeURIComponent(pid) +
-                '&title=' + encodeURIComponent(docName) + '&template_id=' + encodeURIComponent(docId) +
-                '&details=' + encodeURIComponent(details);
-            dlgopen(url, '', '<?php echo attr($modal); ?>', '<?php echo attr($modal_height); ?>', '', title, {
-                buttons: [{text: btnClose, close: true, style: 'secondary'}],
-                sizeHeight: '<?php echo attr($modal_size_height); ?>',
-                allowDrag: true,
-                allowResize: true,
-            });
+        let btnClose = <?php echo xlj("Cancel"); ?>;
+        let title = <?php echo xlj("Send Message"); ?>;
+        let url = top.webroot_url + '<?php echo $url_part; ?>' + encodeURIComponent(pid) + '&title=' + encodeURIComponent(docName) +
+        '&template_id=' + encodeURIComponent(docId) + '&details=' + encodeURIComponent(details);
+        dlgopen(url, '', '<?php echo attr($modal); ?>', '<?php echo attr($modal_height); ?>', '', title, {
+        buttons: [{text: btnClose, close: true, style: 'secondary'}],
+        sizeHeight: '<?php echo attr($modal_size_height); ?>',
+        allowDrag: true,
+        allowResize: true,
+        });
         }
     <?php }
 }
diff --git a/portal/home.php b/portal/home.php
index 11a1c92ca75..7d508c50632 100644
--- a/portal/home.php
+++ b/portal/home.php
@@ -45,12 +45,37 @@
     $_SESSION['portal_init'] = true;
 }
 
-$logoService = new LogoService();
+// Example https://localhost/openemr/portal/index.php?site=default&landOn=BillingSummary
+// landOn query is used to redirect to a specific section of the portal.
+$landOnHref = [
+    'ClinicalDocuments' => '#onsitedocuments',
+    'Appointments' => '#appointmentcard',
+    'MakePayment' => '#paymentcard',
+    'SecureMessaging' => '#secure-msgs-card',
+    'HealthSnapshot' => '#lists',
+    'Profile' => '#profilecard',
+    'BillingSummary' => '#ledgercard',
+    'MedicalReports' => '#reports-list-card',
+    'PROAssessment' => '#procard',
+    'Settings' => '#settings-card',
+    'Help' => '#help-card',
+    'Logout' => '#logout.php'
+];
+// redirect using the interface query landOn or last page visited
+// TODO sjp - qualify if redirect feature is enabled!
+$whereto = $_SESSION['whereto'] ?? null;
+// set the landOn session variable to the redirected card.
+$landWhere = $_SESSION['landOn'] = $_REQUEST['landOn'] ?? null;
+// Set the landOn href query from lookup.
+$where = $landOnHref[$landWhere] ?? null;
+if (!empty($where)) {
+    $_SESSION['whereto'] = $where;
+}
 
+$logoService = new LogoService();
 
 // Get language definitions for js
 $language_defs = TranslationService::getLanguageDefinitionsForSession();
-$whereto = $_SESSION['whereto'] ?? null;
 
 $user = $_SESSION['sessionUser'] ?? 'portal user';
 $result = getPatientData($pid);
@@ -323,7 +348,7 @@ function buildNav($newcnt, $pid, $result): array
     $filteredEvent = $GLOBALS['kernel']->getEventDispatcher()->dispatch($patientReportEvent, PatientReportFilterEvent::FILTER_PORTAL_HEALTHSNAPSHOT_TWIG_DATA);
     $data = [
         'user' => $user,
-        'whereto' => $_SESSION['whereto'] ?? null ?: ($whereto ?? '#quickstart-card'),
+        'whereto' => ($_SESSION['whereto'] ?? null) ?: ($whereto ?? '#quickstart-card'),
         'result' => $result,
         'msgs' => $msgs,
         'msgcnt' => $msgcnt,
@@ -366,6 +391,7 @@ function buildNav($newcnt, $pid, $result): array
         'dateDisplayFormat' => $GLOBALS['date_display_format'],
         'timezone' => $GLOBALS['gbl_time_zone'] ?? '',
         'assetVersion' => $GLOBALS['v_js_includes'],
+        'extendVisit' => $_SESSION['portal_visit_extended'] ?? 1,
         'eventNames' => [
             'sectionRenderPost' => RenderEvent::EVENT_SECTION_RENDER_POST,
             'scriptsRenderPre' => RenderEvent::EVENT_SCRIPTS_RENDER_PRE,
diff --git a/portal/index.php b/portal/index.php
index bd9a9a70820..01a3dbef503 100644
--- a/portal/index.php
+++ b/portal/index.php
@@ -10,7 +10,7 @@
  * @author    Brady Miller <brady.g.miller@gmail.com>
  * @author    Tyler Wrenn <tyler@tylerwrenn.com>
  * @copyright Copyright (c) 2011 Cassian LUP <cassi.lup@gmail.com>
- * @copyright Copyright (c) 2016-2024 Jerry Padgett <sjpadgett@gmail.com>
+ * @copyright Copyright (c) 2016-2025 Jerry Padgett <sjpadgett@gmail.com>
  * @copyright Copyright (c) 2019 Brady Miller <brady.g.miller@gmail.com>
  * @copyright Copyright (c) 2020 Tyler Wrenn <tyler@tylerwrenn.com>
  * @license   https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
@@ -48,16 +48,11 @@
 use OpenEMR\Services\LogoService;
 
 //For redirect if the site on session does not match
-$landingpage = "index.php?site=" . urlencode($_SESSION['site_id']);
+$landingpage = $GLOBALS['web_root'] . "/portal/index.php?site=" . urlencode($_SESSION['site_id']);
 $logoService = new LogoService();
 $logoSrc = $logoService->getLogo("portal/login/primary");
 $logo2ndSrc = $logoService->getLogo("portal/login/secondary"); /*rm - add secondary logo */
 
-// allow both get and post redirect params here... everything will be sanitized in get_patient_info.php before we
-// actually do anything with the redirect
-// this value should already be url encoded.
-$redirectUrl = $_REQUEST['redirect'] ?? '';
-
 //exit if portal is turned off
 if (!(isset($GLOBALS['portal_onsite_two_enable'])) || !($GLOBALS['portal_onsite_two_enable'])) {
     echo xlt('Patient Portal is turned off');
@@ -68,6 +63,25 @@
     unset($_GET['woops']);
     unset($_SESSION['password_update']);
 }
+/*
+    The below will test and set the where to session variable when redirecting from the login page.
+    First unset the where to session variable in case it is wrongly used.
+*/
+unset($_REQUEST['whereto']);
+unset($_GET['whereto']);
+// set the where to session variable to the page from previous session.
+$whereto = $_SESSION['whereto'] ?? null;
+// set the landOn session variable to the redirect page after successfully login.
+$_SESSION['landOn'] = $_GET['landOn'] ?? null;
+// unset the landOn super.
+unset($_REQUEST['landOn']);
+unset($_GET['landOn']);
+/*
+ allow both get and post redirect params here... everything will be sanitized in get_patient_info.php before we
+ actually do anything with the redirect
+ this value should already be url encoded.
+*/
+$redirectUrl = $_REQUEST['redirect'] ?? '';
 
 /*
  * Patient for onetime is verified when token redirect is decoded.
@@ -76,11 +90,15 @@
  * and compared to portal credential account id lookup.
  * */
 if (!empty($_REQUEST['service_auth'] ?? null)) {
+    $oneTime = new OneTimeAuth();
     if (!empty($_GET['service_auth'] ?? null)) {
         // we have to setup the csrf key to prevent CSRF Login attacks
         // we also implement this mechanism in order to handle Same-Site cookie blocking when being referred by
         // an external site domain.  We used to auto process via GET but now we submit via the POST in order to make it
         // a same site cookie origin request. This is a workaround for the Same-Site cookie blocking.
+        $token = $_GET['service_auth'];
+        $ot = $oneTime->decodePortalOneTime($token, null, false);
+        $pin_required = $ot['actions']['enforce_auth_pin'] ? 1 : 0;
         CsrfUtils::setupCsrfKey();
         $twig = new TwigContainer(null, $GLOBALS['kernel']);
         echo $twig->getTwig()->render('portal/login/autologin.html.twig', [
@@ -89,7 +107,8 @@
             'target' => $_GET['target'] ?? null,
             'csrf_token' => CsrfUtils::collectCsrfToken('autologin'),
             'pagetitle' => xl("OpenEMR Patient Portal"),
-            'images_static_relative' => $GLOBALS['images_static_relative'] ?? ''
+            'images_static_relative' => $GLOBALS['images_static_relative'] ?? '',
+            'pin_required' => $pin_required,
         ]);
         exit;
     } elseif (!empty($_POST['service_auth'] ?? null)) {
@@ -100,7 +119,6 @@
             if (!CsrfUtils::verifyCsrfToken($csrfToken, 'autologin')) {
                 throw new OneTimeAuthException('Invalid CSRF token');
             }
-            $oneTime = new OneTimeAuth();
             $auth = $oneTime->processOnetime($token, $redirect_token);
             $logit->portalLog('onetime login attempt', $auth['pid'], 'patient logged in and redirecting', '', '1');
             exit();
diff --git a/sql/7_0_2-to-7_0_3_upgrade.sql b/sql/7_0_2-to-7_0_3_upgrade.sql
index d2f65609264..56eb5a7c2f8 100644
--- a/sql/7_0_2-to-7_0_3_upgrade.sql
+++ b/sql/7_0_2-to-7_0_3_upgrade.sql
@@ -334,3 +334,15 @@ ALTER TABLE sct2_description
     ADD INDEX idx_active_term (active, term),
     ADD FULLTEXT INDEX ft_term (term);
 #EndIf
+
+#IfMissingColumn onetime_auth scope
+ALTER TABLE `onetime_auth` ADD `scope` tinytext COMMENT 'context scope for this token';
+#EndIf
+
+#IfMissingColumn onetime_auth profile
+ALTER TABLE `onetime_auth` ADD `profile` tinytext COMMENT 'scopes profile for this token';
+#EndIf
+
+#IfMissingColumn onetime_auth onetime_actions
+ALTER TABLE `onetime_auth` ADD `onetime_actions` text COMMENT 'JSON array of actions that can be performed with this token';
+#EndIf
diff --git a/src/Common/Auth/OneTimeAuth.php b/src/Common/Auth/OneTimeAuth.php
index 725dac57202..2657cee3b78 100644
--- a/src/Common/Auth/OneTimeAuth.php
+++ b/src/Common/Auth/OneTimeAuth.php
@@ -31,14 +31,16 @@ class OneTimeAuth
 {
     private $scope;
     private $context;
+    private $profile;
     private $cryptoGen;
     private $systemLogger;
 
-    public function __construct($context = 'portal', $scope = 'redirect')
+    public function __construct($context = 'portal', $scope = 'redirect', $profile = 'default')
     {
         // scope = portal/service tasks (reset, register). context = portal, patient etc.
-        $this->scope = $scope;
         $this->context = $context;
+        $this->scope = $scope;
+        $this->profile = $profile;
         $this->cryptoGen = new CryptoGen();
         $this->systemLogger = new SystemLogger();
     }
@@ -51,12 +53,20 @@ public function __construct($context = 'portal', $scope = 'redirect')
      *
      *   $p[
      *   'pid' => '', // required for most onetime auth
-     *   'target_link' => '', // Onetime endpoint
-     *   'redirect_link' => '', // Where to redirect the user after auth
+     *   'target_link' => '', // Onetime endpoint.
+     *   'redirect_link' => '', // Where to redirect the user after auth.
      *   'enabled_datetime' => 'NOW', // Use a datetime if wish to enable for a future date.
-     *   'expiry_interval' => 'PT15M', // Always PTxx{Sec,Min,Day} PeriodTime
-     *   'email' => '']
+     *   'expiry_interval' => 'PT15M', // Always PTxx{Sec,Min,Day} PeriodTime.
+     *   'email' => '', // Email to send the onetime pin to.
+     *   // Array of actions to be stored within encrypted token and retrieved in decodePortalOneTime() then passed to authorization.
+     *   'actions' => [
+     *        'enforce_onetime_use' => true, // Enforces the onetime token to be used only once.
+     *        'extend_portal_visit' => false, // Extends the portal visit by not forcing logout redirect.
+     *        'enforce_auth_pin' => false, // Requires the pin to be entered.
+     *        'max_access_count' => 0, // 0 = unlimited.
+     *     ]
      */
+
     public function createPortalOneTime(array $p, bool $encrypt_redirect = false): array|bool
     {
         $redirect_token = null;
@@ -97,13 +107,16 @@ public function createPortalOneTime(array $p, bool $encrypt_redirect = false): a
             throw new RuntimeException($err);
         }
 
+        $actions = ($p['actions'] ?? []);
+        // Create the encoded link and return the onetime token data set
         $rtn['encoded_link'] = $this->encodeLink($site_addr, $token_encrypt, $redirect_token);
         $rtn['onetime_token'] = $token_encrypt;
         $rtn['redirect_token'] = $redirect_token;
         $rtn['pin'] = $pin;
         $rtn['email'] = $email;
 
-        $save = $this->insertOnetime($passed_in_pid, $pin, $token_raw, $redirect_raw, $expiry->format('U'));
+        // Save the onetime token to the database
+        $save = $this->insertOnetime($passed_in_pid, $pin, $token_raw, $redirect_raw, $expiry->format('U'), $this->scope, $this->profile, $actions);
         if (empty($save)) {
             $err = xlt("Onetime save failed!");
             $this->systemLogger->error($err);
@@ -121,7 +134,7 @@ public function createPortalOneTime(array $p, bool $encrypt_redirect = false): a
      * @return array
      * @throws OneTimeAuthExpiredException
      */
-    public function decodePortalOneTime($onetime_token, $redirect_token = null): array
+    public function decodePortalOneTime($onetime_token, $redirect_token = null, $logUpdate = true): array
     {
         $auth = false;
         $rtn = [];
@@ -173,6 +186,7 @@ public function decodePortalOneTime($onetime_token, $redirect_token = null): arr
                 $this->systemLogger->debug("Redirect token decrypted: pid = " . $redirect_array['pid'] . " redirect = " . $redirect);
             }
         }
+
         $rtn['pid'] = $auth['pid'];
         $rtn['pin'] = $t_info['onetime_pin'];
         $rtn['redirect'] = $redirect;
@@ -180,9 +194,12 @@ public function decodePortalOneTime($onetime_token, $redirect_token = null): arr
         $rtn['login_username'] = $auth['portal_login_username'];
         $rtn['portal_pwd'] = $auth['portal_pwd'];
         $rtn['onetime_decrypted'] = $one_time;
+        $rtn['actions'] = $t_info['onetime_actions'] ?? [];
 
-        $this->updateOnetime($auth['pid'], $one_time);
-        $this->systemLogger->debug("Onetime successfully decoded. $one_time");
+        if ($logUpdate) {
+            $this->updateOnetime($auth['pid'], $one_time);
+            $this->systemLogger->debug("Onetime successfully decoded. $one_time");
+        }
 
         return $rtn;
     }
@@ -246,11 +263,11 @@ private function encodeLink($site_addr, $token_encrypt, $encrypted_redirect = nu
      * @param $expires
      * @return int
      */
-    public function insertOnetime($pid, $onetime_pin, $onetime_token, $redirect_url, $expires): int
+    public function insertOnetime($pid, $onetime_pin, $onetime_token, $redirect_url, $expires, $scope = '', $profile = '', $actions = []): int
     {
-        $bind = [$pid, $_SESSION['authUserID'] ?? null, $this->context, $onetime_pin, $onetime_token, $redirect_url, $expires];
-
-        $sql = "INSERT INTO `onetime_auth` (`id`, `pid`, `create_user_id`, `context`, `onetime_pin`, `onetime_token`, `redirect_url`, `expires`, `date_created`) VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, current_timestamp())";
+        $actions = json_encode($actions);
+        $bind = [$pid, $_SESSION['authUserID'] ?? null, $this->context, $onetime_pin, $onetime_token, $redirect_url, $expires, $scope, $profile, $actions];
+        $sql = "INSERT INTO `onetime_auth` (`id`, `pid`, `create_user_id`, `context`, `onetime_pin`, `onetime_token`, `redirect_url`, `expires`, `date_created`, `scope`, `profile`, `onetime_actions`) VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, current_timestamp(), ?, ?, ?)";
 
         return sqlInsert($sql, $bind);
     }
@@ -282,8 +299,9 @@ public function getOnetime($token, $pid = null): bool|array|null
             $bind = [$pid, $token];
             $sql = "SELECT * FROM `onetime_auth` WHERE `pid` = ? AND `onetime_token` = ? LIMIT 1";
         }
-
-        return sqlQuery($sql, $bind);
+        $data = sqlQuery($sql, $bind);
+        $data['onetime_actions'] = json_decode($data['onetime_actions'] ?? [], true);
+        return $data;
     }
 
     /**
@@ -293,7 +311,20 @@ public function getOnetime($token, $pid = null): bool|array|null
      */
     public function processOnetime($token, $redirect_token): array
     {
-        $auth = $this->decodePortalOneTime($token, $redirect_token);
+        try {
+            $auth = $this->decodePortalOneTime($token, $redirect_token);
+            if ($auth["actions"]["enforce_auth_pin"]) {
+                $this->systemLogger->debug("Pin auth required");
+                if ($auth['pin'] != $_POST['login_pin'] ?? null) {
+                    $this->systemLogger->error("Failed Pin auth");
+                    throw new OneTimeAuthException(xlt("Pin Authentication Failed! Contact administrator."));
+                }
+            }
+        } catch (OneTimeAuthExpiredException $e) {
+            $this->systemLogger->error("Failed " . $e->getMessage());
+            unset($auth);
+            throw new OneTimeAuthException(xlt("Decode Authentication Failed! Contact administrator."));
+        }
         if (!empty($auth['error'] ?? null)) {
             $this->systemLogger->error("Failed " . $auth['error']);
             unset($auth);
@@ -309,6 +340,7 @@ public function processOnetime($token, $redirect_token): array
         $_SESSION['redirect_target'] = $auth['redirect'];
         $_SESSION['onetime'] = $auth['portal_pwd'];
         $_SESSION['patient_portal_onsite_two'] = 1;
+        $_SESSION['onetime_actions'] = $auth['actions'];
 
         // set up the other variables needed for the session interaction
         // this was taken from portal/get_patient_info.php
@@ -326,8 +358,11 @@ public function processOnetime($token, $redirect_token): array
         // Set up the csrf private_key (for the patient portal)
         //  Note this key always remains private and never leaves server session. It is used to create
         //  the csrf tokens.
+
+        $extend = $auth['actions']['extend_portal_visit'] ? 1 : 0;
+        $_SESSION['portal_visit_extended'] = $extend;
+
         CsrfUtils::setupCsrfKey();
-        // $auth['redirect'] .= "&me=" . session_id();
         header('Location: ' . $auth['redirect']);
         // allows logging and any other processing to be handled on the return
         return $auth;
diff --git a/src/Events/Messaging/SendNotificationEvent.php b/src/Events/Messaging/SendNotificationEvent.php
index a024206e888..a3bf9e6240c 100644
--- a/src/Events/Messaging/SendNotificationEvent.php
+++ b/src/Events/Messaging/SendNotificationEvent.php
@@ -7,7 +7,7 @@
  * @link      http://www.open-emr.org
  *
  * @author    Jerry Padgett <sjpadgett@gmail.com>
- * @copyright Copyright (c) 2023-2024 Jerry Padgett <sjpadgett@gmail.com>
+ * @copyright Copyright (c) 2023-2025 Jerry Padgett <sjpadgett@gmail.com>
  * @license   https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
  */
 
@@ -30,22 +30,61 @@
 
 class SendNotificationEvent extends Event
 {
-    const ACTIONS_RENDER_NOTIFICATION_POST = 'sendNotification.actions.render.post';
-    const JAVASCRIPT_READY_NOTIFICATION_POST = 'sendNotification.javascript.load.post';
+    const ACTIONS_RENDER_NOTIFICATION_POST = 'sendNotification.actions.render.post'; // button
+    const JAVASCRIPT_READY_NOTIFICATION_POST = 'sendNotification.javascript.load.post'; // javascript load
     const SEND_NOTIFICATION_BY_SERVICE = 'sendNotification.send';
     const SEND_NOTIFICATION_SERVICE_ONETIME = 'sendNotification.service.onetime';
+    const ACTIONS_RENDER_NOTIFICATION_UNIVERSAL = 'sendNotification.actions.render.universal'; // button
+    const JAVASCRIPT_LOAD_NOTIFICATION_UNIVERSAL = 'sendNotification.javascript.load.universal';
+    const SEND_NOTIFICATION_SERVICE_UNIVERSAL_ONETIME = 'sendNotification.service.universal.onetime';
 
-    private mixed $pid;
-    private array|bool $patientDetails;
     private mixed $eventData;
+    private array|bool $patientDetails;
+    private mixed $pid;
+    private string|null $sendNotificationMethod;
 
-    public function __construct($pid, $data = [])
+    public function __construct($pid, $data = [], $sendMethod = 'both') // 'both', 'sms', 'email'
     {
         $this->pid = $pid ?? 0;
-        $this->eventData = $data;
+        $this->setEventData($data);
+        $this->setSendNotificationMethod($sendMethod);
+        $this->setPatientDetails($this->pid);
+    }
+
+    /**
+     * @param string $sendNotificationMethod
+     * @return SendNotificationEvent
+     */
+    public function setSendNotificationMethod(string $sendNotificationMethod)
+    {
+        $this->sendNotificationMethod = $sendNotificationMethod;
+    }
+
+    /**
+     * @param array|bool $pid
+     * @return void
+     */
+    public function setPatientDetails($pid): void
+    {
         $this->patientDetails = $this->fetchPatientDetails($pid);
     }
 
+    /**
+     * @return array|bool
+     */
+    public function getPatientDetails(): bool|array
+    {
+        return $this->patientDetails;
+    }
+
+    /**
+     * @return string
+     */
+    public function getSendNotificationMethod(): string
+    {
+        return $this->sendNotificationMethod;
+    }
+
     /**
      * @param $id
      * @return bool|array
@@ -62,10 +101,15 @@ public function getEventData()
         return $this->eventData ?? [];
     }
 
+    public function setEventData($data)
+    {
+        $this->eventData = $data;
+    }
+
     /**
      * @return bool|string
      */
-    public function getEncodedPatientDetails(): bool|string
+    public function getEncodedPatientDetails()
     {
         return json_encode($this->patientDetails);
     }
@@ -79,12 +123,12 @@ public function getPid(): string
     }
 
     /**
-     * @param $sect
-     * @param $v
-     * @param $u
+     * @param string $sect
+     * @param string $v
+     * @param string $u
      * @return bool
      */
-    public function verifyAcl($sect = 'patients', $v = 'docs', $u = ''): bool
+    public function verifyAcl(string $sect = 'patients', string $v = 'docs', string $u = ''): bool
     {
         return AclMain::aclCheckCore($sect, $v, $u);
     }
diff --git a/templates/portal/header.html.twig b/templates/portal/header.html.twig
index bc5f5ad01f7..55086b08a07 100644
--- a/templates/portal/header.html.twig
+++ b/templates/portal/header.html.twig
@@ -7,9 +7,11 @@
         <span class="navbar-toggler-icon"></span>
     </button>
     {% endif %}
+    {% if extendVisit %}
     <a class="btn btn-outline-primary" id="quickstart_dashboard" href="#quickstart-card" data-toggle="collapse" data-parent="#cardgroup" aria-expanded="true">
         <i class="ml-1 fas fa-tasks mr-1"></i>{{ 'Dashboard' | xlt }}
     </a>
+    {% endif %}
     {% if navMenu|length > 0 %}
     <div class="collapse navbar-collapse justify-content-end" id="nav">
         <ul class="navbar-nav mt-2 mt-lg-0">
diff --git a/templates/portal/home.html.twig b/templates/portal/home.html.twig
index 12516d51db6..d33ec5cbccc 100644
--- a/templates/portal/home.html.twig
+++ b/templates/portal/home.html.twig
@@ -8,7 +8,7 @@
  * @author    Shiqiang Tao <StrongTSQ@gmail.com>
  * @author    Ben Marte <benmarte@gmail.com>
  * @author    Stephen Nielson <snielson@discoverandchange.com>
- * @copyright Copyright (c) 2016-2024 Jerry Padgett <sjpadgett@gmail.com>
+ * @copyright Copyright (c) 2016-2025 Jerry Padgett <sjpadgett@gmail.com>
  * @copyright Copyright (c) 2019-2021 Brady Miller <brady.g.miller@gmail.com>
  * @copyright Copyright (c) 2020 Shiqiang Tao <StrongTSQ@gmail.com>
  * @copyright Copyright (c) 2021 Ben Marte <benmarte@gmail.com>
@@ -57,6 +57,27 @@
       .navbar-nav .dropdown:hover .dropdown-menu {
         display: block;
       }
+
+      @media (max-width: 768px) {
+        .jumbotron {
+          padding: 1rem;
+        }
+        .card {
+          margin-bottom: 1rem;
+        }
+        .navbar-nav .dropdown-menu {
+          position: static;
+          float: none;
+          width: 100%;
+          text-align: center;
+        }
+        .navbar-nav .dropdown-menu a {
+          padding: 10px;
+        }
+        .card-body {
+          padding: 10px;
+        }
+      }
     </style>
     <script>
         var cpid = {{ patientID | js_escape }},
diff --git a/templates/portal/login/autologin.html.twig b/templates/portal/login/autologin.html.twig
index f2606e1e270..e2f73a5b6b9 100644
--- a/templates/portal/login/autologin.html.twig
+++ b/templates/portal/login/autologin.html.twig
@@ -29,17 +29,32 @@
         <header class="card-header bg-primary text-light">
             <h1>{{ 'Authenticating Login Session' | xlt }}</h1>
         </header>
+        {% if pin_required == 1 %}
+        <h5 class="m-5">{{ "Enter PIN to authenticate your session"|xlt }}</h5>
+        {% else %}
         <h5 class="m-5">{{ "Please wait while we authenticate your session"|xlt }}</h5>
+        {% endif %}
         <form method="POST" id="autologinform" action="{{ action }}">
             <input type="hidden" name="csrf_token" value="{{ csrf_token|attr }}" />
             <input type="hidden" name="service_auth" value="{{ service_auth|attr }}" />
             <input type="hidden" name="target" value="{{ target|attr_url }}" />
+            {% if pin_required %}
+            <label for="login_pin">{{ "PIN Required value"|xlt }}</label>
+            <input type="text" class="text-center m-1" maxlength="6" id="login_pin" name="login_pin" value="" />
+            <input class="btn btn-sm btn-primary" type="submit" name="autoLoginSubmit" value="{{ "Authorize and Go To Destination"|xla }}" />
+            {% else %}
             <input class="btn btn-lg btn-primary" type="submit" name="autoLoginSubmit" value="{{ "Go To Destination"|xla }}" />
+            {% endif %}
         </form>
+        {% if pin_required %}
+        <p>{{ "You are Required to confirm access by entering the companion PIN."|xlt }} {{ target|text }}</p>
+        {% else %}
         <p>{{ "If you have not been redirected in a few seconds click the button above"|xlt }}</p>
         <p>{{ "You will be sent to the following web page"|xlt }} {{ target|text }}</p>
+        {% endif %}
     </div>
     <script>
+        const pinRequired = {{ pin_required|js_escape }};
         (function() {
             window.addEventListener("DOMContentLoaded", function() {
                 let form = window.document.getElementById("autologinform");
@@ -47,7 +62,13 @@
                     console.error("Failed to find autologinform");
                     return;
                 }
-                form.submit(); // submit the form as soon as we load to start the session
+                // if pin is not required, submit the form immediately
+                // otherwise, wait for the user to enter the pin and submit the form.
+                // The pin will be validated on the server side before redirect.
+                // he action must be set in the onetime create actions.
+                if (!pinRequired) {
+                    form.submit(); // submit the form as soon as we load to start the session}
+                }
             });
         })(window);
     </script>


Reviews:
user: login: juggernautsei
body: this looks doable but I don't see the config changes here. Your code indicated there would be a setting to turn on.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T03:08:57Z
user: login: sjpadgett
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T11:10:31Z
user: login: juggernautsei
body: This makes for a vary robust communication system which works the way people work now. Twilio now includes whats app. Do you have plans to integrate for overseas users?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T11:44:29Z
user: login: bradymiller
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-16T07:17:06Z

Review comments:
diff_hunk: @@ -137,7 +137,21 @@ public function onNotifyDocumentRenderOneTime(SendNotificationEvent $event): str
     }
 
     /**
-     * Send a token for universal onetime.
+     * Send a token for universal onetime/token.
+     * Example: Payment link.
+     * $data = [
+     *   'pid' => $e_pid,
+     *   'expiry_interval' => "P2D", // valid for 2 days.
+     *   'text_message' => "Please make a payment for your appointment.",
+     *   'html_message' => "",
+     *   'redirect_url' => $GLOBALS['web_root'] . "/portal/home.php?site=" . urlencode($_SESSION['site_id']) . "&landOn=MakePayment",
+     *   'actions' => [
+     *      'enforce_onetime_use' => true,
+     *      'enforce_auth_pin' => true,
+     *     ]
+     * ];
+     * // Dispatch the event. In this case, the onetime is created and emailed to the recipient.
+     * $GLOBALS["kernel"]->getEventDispatcher()->dispatch(new SendNotificationEvent($e_pid, $data, $to_where), SendNotificationEvent::SEND_NOTIFICATION_SERVICE_UNIVERSAL_ONETIME);
      *
path: interface/modules/custom_modules/oe-module-faxsms/src/Events/NotificationEventListener.php
user: login: sjpadgett
body: @juggernautsei 
Here is an example (ignore the `$to_where` in example as no longer needed) dispatched event where the listener will create the onetime and then email patient. Everything needed is passed in the event data. I also have javascript onetime create and buttons.

I'm working towards making the `onNotifyUniversalOneTime(SendNotificationEvent $event): string` universal to many use cases. I'll most likely move the listener to a more appropriate place besides in faxsmsemail module.

Next I'll be adding the onetime_actions to workflows in order to handle them. Some can be handled in `decodePortalOneTime($onetime_token, $redirect_token = null)` and `processOnetime($token, $redirect_token)`
created_at: 2025-03-10T11:10:30Z
author_association: MEMBER
diff_hunk: @@ -29,25 +29,46 @@
         <header class="card-header bg-primary text-light">
             <h1>{{ 'Authenticating Login Session' | xlt }}</h1>
         </header>
+        {% if pin_required == 1 %}
+        <h5 class="m-5">{{ "Enter PIN to authenticate your session"|xlt }}</h5>
+        {% else %}
         <h5 class="m-5">{{ "Please wait while we authenticate your session"|xlt }}</h5>
+        {% endif %}
         <form method="POST" id="autologinform" action="{{ action }}">
             <input type="hidden" name="csrf_token" value="{{ csrf_token|attr }}" />
             <input type="hidden" name="service_auth" value="{{ service_auth|attr }}" />
             <input type="hidden" name="target" value="{{ target|attr_url }}" />
+            {% if pin_required %}
+            <label for="login_pin">{{ "PIN Required value"|xlt }}</label>
+            <input type="text" class="text-center m-1" maxlength="6" id="login_pin" name="login_pin" value="" />
+            <input class="btn btn-sm btn-primary" type="submit" name="autoLoginSubmit" value="{{ "Authorize and Go To Destination"|xla }}" />
+            {% else %}
             <input class="btn btn-lg btn-primary" type="submit" name="autoLoginSubmit" value="{{ "Go To Destination"|xla }}" />
+            {% endif %}
         </form>
+        {% if pin_required %}
+        <p>{{ "You are Required to confirm access by entering the companion PIN."|xlt }} {{ target|text }}</p>
+        {% else %}
         <p>{{ "If you have not been redirected in a few seconds click the button above"|xlt }}</p>
         <p>{{ "You will be sent to the following web page"|xlt }} {{ target|text }}</p>
+        {% endif %}
     </div>
     <script>
+        const pinRequired = {{ pin_required|attr }};
path: templates/portal/login/autologin.html.twig
user: login: bradymiller
body: would use js_escape instead of attr here
created_at: 2025-03-16T07:17:06Z
author_association: MEMBER

PR comments:
user: login: sjpadgett
created_at: 2025-03-08T04:10:35Z
body: @juggernautsei Are you still interested with this feature? Give some feedback if needed. Also will embed in onetime token types.

https://localhost/openemr/portal/index.php?site=default&landOn=MakePayment

![image](https://github.com/user-attachments/assets/4fc17dd7-de82-4a23-a749-33a09e7ab1b9)

author_association: MEMBER
user: login: juggernautsei
created_at: 2025-03-08T11:48:07Z
body: @sjpadgett it is a great feature add-on because at present the portal login is a huge barrier to revenue collection. The easier we can make it for the clinic the better when I started including the patient's outstanding balance in the appointment text alerts. Clinics saw a 15% rise in patients paying their balances. It linked them back to a payment process that was seamless where the patient could make the payment without hurdles, hoops, and logins to remember. 
Giving the front staff the power to send an electronic collection notice will help clinics stay on top of revenues. Great work!

There is a program one of my clients uses called spotvirtual.com. To log into this site, I always use a one time code. The patient portal can benefit by using one-time codes instead of passwords. When I go to the site my login has expired. The site always offers a one-time passcode. It never requests me to enter a password. It always prompts me to receive a one-time passcode. You can have the portal to send the patient a one-time login that is good for that one visit. Now the barrier to entry is lowered and those who are always having a problem with usernames and passwords become a thing of the past. 
author_association: MEMBER
user: login: sjpadgett
created_at: 2025-03-09T21:36:04Z
body: @juggernautsei note:

I am adding several token events/actions to pass into the login auth process also the way the logged in process is handled. This may need to be a separated issue. Will think on that!
I now allow users to create an array of actions to be preformed on decoding and token lifecycle. The array is embedded and encrypted with token. Will not affect legacy of onetime.
```

    /**
     * @param array $p
     * @param bool  $encrypt_redirect
     * @return array|bool
     * @throws \Exception
     *
     *   $p[
     *   'pid' => '', // required for most onetime auth
     *   'target_link' => '', // Onetime endpoint.
     *   'redirect_link' => '', // Where to redirect the user after auth.
     *   'enabled_datetime' => 'NOW', // Use a datetime if wish to enable for a future date.
     *   'expiry_interval' => 'PT15M', // Always PTxx{Sec,Min,Day} PeriodTime.
     *   'email' => '', // Email to send the onetime pin to.
     *   // Array of actions to be stored within encrypted token and retrieved in decodePortalOneTime() then passed to authorization.
     *   'actions' => [
     *        'enforce_onetime_use' => true, // Enforces the onetime token to be used only once.
     *        'extend_portal_visit' => false, // Extends the portal visit by not forcing logout redirect.
     *        'enforce_auth_pin' => false, // Requires the pin to be entered and verified.
     *        'max_access_count' => 0, // 0 = unlimited.
     *     ] 
     */

```
author_association: MEMBER
user: login: juggernautsei
created_at: 2025-03-09T23:52:20Z
body: @sjpadgett I like the list of actions. The auth pin is a nice touch. Will the pin go out with the link like in the credentials?
author_association: MEMBER
user: login: sjpadgett
created_at: 2025-03-10T00:07:00Z
body: Yes, similar to a password and currently 6 numbers but I may change to 10 chars, plus we can use the pin as a shortcut for background tasks. 
TODO sjp add pin to encrypted token as another way to verify session. A three way match.
TODO Send token and pin in separate emails. We'll see...
author_association: MEMBER
user: login: sjpadgett
created_at: 2025-03-10T20:34:44Z
body: > Twilio now includes whats app. Do you have plans to integrate for overseas users?

@juggernautsei 
I've looked at including as I've had several requests mostly from Latin America, some Africa.
Twilio's API is pretty straight forward with most work in setting up permissions in Dashboard,
I've tried the donation route but no joy and will be one of those contributions I donate because it will have a high impact for our community.

Your work production has been huge lately so maybe you already have a start or will be agreeable to a joint effort between us.

My next big push after getting onetime for billing finished will be a refactor of portal Make Payment to be more agreeable on small devices.
author_association: MEMBER
user: login: sjpadgett
created_at: 2025-03-11T02:48:02Z
body: @stephenwaite Have you been seeing errors in Batch Payments? For example manually changing Post For to any value on the first selection will throw error 
Syntax Error: expected expression, got '}'
URL:  https://opensourcedemr.us/interface/billing/edit_payment.php
Line: 2 Column: 1
Error object: "{}"
Same with Payment Method....

author_association: MEMBER
user: login: stephenwaite
created_at: 2025-03-11T12:54:38Z
body: hi @sjpadgett, no, but will do some testing today 
author_association: MEMBER
user: login: juggernautsei
created_at: 2025-03-11T22:30:43Z
body: > will be agreeable to a joint effort between us.

Yes, I will agree to assist the master. I am finishing up a Twilio project soon. That is why I asked.  
author_association: MEMBER
user: login: stephenwaite
created_at: 2025-03-12T14:22:24Z
body: hi @sjpadgett, did you want this branch to be up for grabs instead of https://github.com/openemr/openemr/pull/8098 
author_association: MEMBER
user: login: sjpadgett
created_at: 2025-03-12T23:28:54Z
body: ![image](https://github.com/user-attachments/assets/6238a6a6-46a8-4bd0-b9c4-9dad3f277603)

author_association: MEMBER
user: login: sjpadgett
created_at: 2025-03-13T04:23:52Z
body: So do we want to allow users creating a time based login token(instead of onetime we will call them manytime:)) using a pin from portal create credentials?
I'm ready to implement batch invoice notification for either SMS, email or both. Heck, may as well fax too!:)
I may need to create a new notification scheduling table or add to existing. Thoughts @bradymiller on your table.
Do we have a twig invoice template? Seems I heard mentioning by someone.

Tomorrow I think I'll start a documentation thread on forum Dev Engineering topic.

So onwards we go!
author_association: MEMBER
user: login: juggernautsei
created_at: 2025-03-14T09:55:42Z
body: @sjpadgett That twig invoice template was going to be a weekend project for me over the course of a few weeks or months. I know you are kidding about the faxing. ;-)
author_association: MEMBER
user: login: bradymiller
created_at: 2025-03-16T07:17:43Z
body: ![dance-bear](https://github.com/user-attachments/assets/2347f3d0-329c-4d21-9e3d-55cbbf4bfd52)

author_association: MEMBER