From 856f0041414b35bec5061c6707904c6b3477a05b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 4 Nov 2025 21:48:27 +0100 Subject: [PATCH 1/6] =?UTF-8?q?ST=20Batch=20update=20162=E2=80=93167?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes by task: LotsLerum-162 – Change PIN should not point to OPAC - Implemented internal PIN reset flow within VuFind instead of redirecting to OPAC. - Added: * module/LOTS/src/LOTS/Controller/ResetPasswordController.php * module/LOTS/src/LOTS/Db/Table/PasswordResetToken.php * templates/resetpassword/home.phtml - Updated: * module/LOTS/config/module.config.php * module/LOTS/src/LOTS/Controller/ForgotPasswordController.php * module/LOTS/src/LOTS/Controller/MyResearchController.php * templates/forgotpassword/home.phtml * languages/en.ini, languages/sv.ini LotsLerum-163 – Green button in search removal - Removed the green search button that was outside the facet limits. - Updated: * themes/lots/templates/RecordDriver/DefaultRecord/result-list.phtml * themes/lots/templates/layout/layout.phtml LotsLerum-164 – Show inventory in detail but not reserve button - Separated configuration for showing inventory and reserve button. - Display inventory info (e.g., 2/3) without showing reserve button. - Adjusted layout and verified mobile templates. - Updated: * themes/lots/templates/RecordTab/holdingsils.phtml * themes/lots/templates/RecordTab/holdingsils/standard.phtml * local/config/vufind/LOTS.ini LotsLerum-165 – Better listing - Improved listing layout for holdings to clearly show which library each item belongs to. - Adjusted formatting and spacing for better readability. - Updated: * themes/lots/templates/RecordTab/holdingsils.phtml * local/config/vufind/LOTS.ini LotsLerum-166 – Possible to add status 'Borrowed' - Added logic to handle 'Utlånad' (Borrowed) status when available from Koha API. - Adjusted fallback behavior for 'Ej tillgänglig'. - Updated: * local/config/vufind/KohaRest.ini * languages/en.ini, languages/sv.ini LotsLerum-167 – Make LOTS WCAG compatible - Adjusted templates to meet WCAG accessibility standards (checked with ACE tool). - Fixed aria labels, contrast, alt text, and semantic issues. - Updated: * themes/lots/templates/Auth/ILS/loginfields.phtml * themes/lots/templates/RecordDriver/DefaultRecord/core.phtml * themes/lots/templates/RecordTab/holdingsils.phtml * themes/lots/templates/layout/layout.phtml * themes/lots/templates/search/searchbox.phtml * languages/en.ini, languages/sv.ini --- languages/en.ini | 46 +++ languages/sv.ini | 47 ++- module/LOTS/config/module.config.php | 68 +++- .../Controller/ForgotPasswordController.php | 235 +++++++----- .../LOTS/Controller/MyResearchController.php | 62 +++ .../Controller/ResetPasswordController.php | 217 +++++++++++ .../src/LOTS/Db/Table/PasswordResetToken.php | 146 +++++++ .../lots/templates/Auth/ILS/loginfields.phtml | 21 +- .../RecordDriver/DefaultRecord/core.phtml | 38 +- .../DefaultRecord/result-list.phtml | 1 + .../templates/RecordTab/holdingsils.phtml | 362 ++++++++++++++++-- .../RecordTab/holdingsils/standard.phtml | 94 +++-- .../lots/templates/forgotpassword/home.phtml | 66 ++-- themes/lots/templates/layout/layout.phtml | 81 +++- .../lots/templates/resetpassword/home.phtml | 77 ++++ themes/lots/templates/search/searchbox.phtml | 162 ++++++++ 16 files changed, 1488 insertions(+), 235 deletions(-) create mode 100644 module/LOTS/src/LOTS/Controller/ResetPasswordController.php create mode 100644 module/LOTS/src/LOTS/Db/Table/PasswordResetToken.php create mode 100644 themes/lots/templates/resetpassword/home.phtml create mode 100644 themes/lots/templates/search/searchbox.phtml diff --git a/languages/en.ini b/languages/en.ini index 10bf06822b8..aca8d71a1b6 100644 --- a/languages/en.ini +++ b/languages/en.ini @@ -1329,11 +1329,57 @@ Your Tags = "Your Tags" your_match_would_be_here = "Your match would be here." Zip = "Zip" zoom = "Zoom" + ; LOTS begin session_expired = "Your session has expired. Please login again." +session_expired_ok_button = "Session has expired - click to continue" + Branch = "Branch" Available copies = "Available copies" Total copies = "Total copies" No book found = "No book found" Select branch = "Select branch" +request_new_pin_title = "Request New PIN" +request_new_pin = "Request PIN" +send_pin = "Send PIN" +new_pin_sent = "New PIN sent!" +; Password Reset translations +password_reset_email_sent = "If the personal number exists in the system, an email will be sent with instructions." +password_reset_error = "An error occurred. Please try again later." +password_reset_invalid_token = "Invalid reset link." +password_reset_token_expired = "The reset link has expired. Please request a new one." +password_reset_success = "Your PIN code has been changed. You will be redirected to the login page..." +password_reset_update_error = "Could not update the PIN code. Please try again." +password_reset_pin_required = "Both fields must be filled in." +password_reset_pin_mismatch = "The PIN codes do not match." +password_reset_pin_format = "The PIN code must be exactly 4 digits." +password_reset_email_subject = "Reset your PIN code" +password_reset_line1 = "You have requested to reset your PIN code." +password_reset_line2 = "Click the following link to create a new PIN code:" +password_reset_line3 = "The link is valid for 2 days." +password_reset_line4 = "If you did not request this reset, you can ignore this message." +reset_pin_code_title = "Reset PIN Code" +reset_pin_code_instruction = "Enter your new 4-digit PIN code." +new_pin_code = "New PIN Code" +confirm_pin_code = "Confirm PIN Code" +pin_code_4_digits = "4 digits" +save_new_pin_code = "Save New PIN Code" +go_to_login = "Go to Login" +request_new_reset_link = "Request New Reset Link" +forgot_password_title = "Forgot Password" +forgot_password_instruction = "Enter your personal number and we will send you an email with instructions to reset your PIN code." +personal_number_label = "Personal Number (ÅÅMMDDXXXX)" +personal_number_placeholder = "ÅÅMMDDXXXX" +personal_number_help = "Enter personal number without hyphen" +send_reset_link = "Send Reset Link" + +; Item status translations +Borrowed = "Borrowed" +Charged = "Checked Out" +Overdue = "Overdue" + +search_terms = "Search terms" +search_placeholder = "Search in the library catalog (title, author, subject)..." +search_aria_description = "Search in the library catalog (title, author, subject)..." + ; LOTS end diff --git a/languages/sv.ini b/languages/sv.ini index 5ed455aa965..e68e77c5c68 100644 --- a/languages/sv.ini +++ b/languages/sv.ini @@ -1453,9 +1453,54 @@ Patron ID=Låntagarnummer ils_availability_detail="
Tillgängliga exemplar: %d/%d ; LOTS begin session_expired = "Din session har gått ut. Vänligen logga in igen." +session_expired_ok_button = "Sessionen har löpt ut - klicka för att fortsätta" + Branch = "Filial" Available copies = "Tillgängliga exemplar" Total copies = "Totalt antal exemplar" No book found = "Inga exemplar" Select branch = "Välj filial" -; LOTS end \ No newline at end of file +request_new_pin_title = "Begär ny PIN-kod" +request_new_pin = "Begär PIN" +send_pin = "Skicka PIN" +new_pin_sent = "Ny PIN-kod skickad!" +; Password Reset translations +password_reset_email_sent = "Om personnumret finns i systemet kommer ett e-postmeddelande skickas med instruktioner." +password_reset_error = "Ett fel uppstod. Försök igen senare." +password_reset_invalid_token = "Ogiltig återställningslänk." +password_reset_token_expired = "Återställningslänken har gått ut. Begär en ny." +password_reset_success = "Din PIN-kod har ändrats. Du omdirigeras till inloggningen..." +password_reset_update_error = "Kunde inte uppdatera PIN-koden. Försök igen." +password_reset_pin_required = "Båda fälten måste fyllas i." +password_reset_pin_mismatch = "PIN-koderna matchar inte." +password_reset_pin_format = "PIN-koden måste vara exakt 4 siffror." +password_reset_email_subject = "Återställ din PIN-kod" +password_reset_line1 = "Du har begärt att återställa din PIN-kod." +password_reset_line2 = "Klicka på följande länk för att skapa en ny PIN-kod:" +password_reset_line3 = "Länken är giltig i 2 dagar." +password_reset_line4 = "Om du inte har begärt denna återställning kan du ignorera detta meddelande." +reset_pin_code_title = "Återställ PIN-kod" +reset_pin_code_instruction = "Ange din nya 4-siffriga PIN-kod." +new_pin_code = "Ny PIN-kod" +confirm_pin_code = "Bekräfta PIN-kod" +pin_code_4_digits = "4 siffror" +save_new_pin_code = "Spara ny PIN-kod" +go_to_login = "Gå till inloggning" +request_new_reset_link = "Begär ny återställningslänk" +forgot_password_title = "Glömt lösenord" +forgot_password_instruction = "Ange ditt personnummer så skickar vi ett e-postmeddelande med instruktioner för att återställa din PIN-kod." +personal_number_label = "Personnummer (ÅÅMMDDXXXX)" +personal_number_placeholder = "ÅÅMMDDXXXX" +personal_number_help = "Ange personnummer utan bindestreck" +send_reset_link = "Skicka återställningslänk" + +; Översättningar för exemplar status +Borrowed = "Utlånad" +Charged = "Utlånad" +Overdue = "Försenad" + +search_terms = "Sökvillkor" +search_placeholder = "Sök i bibliotekskatalogen (titel, författare, ämne)..." +search_aria_description = "Sök i bibliotekskatalogen (titel, författare, ämne)..." + +; LOTS end diff --git a/module/LOTS/config/module.config.php b/module/LOTS/config/module.config.php index b2783f569ef..0338dfc1921 100644 --- a/module/LOTS/config/module.config.php +++ b/module/LOTS/config/module.config.php @@ -1,32 +1,58 @@ + 'router' => [ + 'routes' => [ + 'forgotpassword-home' => [ + 'type' => 'Laminas\Router\Http\Literal', + 'options' => [ + 'route' => '/ForgotPassword', + 'defaults' => [ + 'controller' => 'ForgotPassword', + 'action' => 'Home', + ] + ], + ], + 'resetpassword-home' => [ + 'type' => 'Laminas\Router\Http\Literal', + 'options' => [ + 'route' => '/ResetPassword', + 'defaults' => [ + 'controller' => 'ResetPassword', + 'action' => 'Home', + ] + ], + ], + ], + ], + 'controllers' => array ( - 'factories' => + 'factories' => array ( 'LOTS\\Controller\\MyResearchController' => 'VuFind\\Controller\\AbstractBaseFactory', 'LOTS\\Controller\\ForgotPasswordController' => 'VuFind\\Controller\\AbstractBaseFactory', + 'LOTS\\Controller\\ResetPasswordController' => 'VuFind\\Controller\\AbstractBaseFactory', 'LOTS\\Controller\\SuggestionsController' => 'VuFind\\Controller\\AbstractBaseFactory', ), - 'aliases' => + 'aliases' => array ( 'MyResearch' => 'LOTS\\Controller\\MyResearchController', 'myresearch' => 'LOTS\\Controller\\MyResearchController', 'ForgotPassword' => 'LOTS\\Controller\\ForgotPasswordController', 'forgotpassword' => 'LOTS\\Controller\\ForgotPasswordController', + 'ResetPassword' => 'LOTS\\Controller\\ResetPasswordController', + 'resetpassword' => 'LOTS\\Controller\\ResetPasswordController', 'Suggestions' => 'LOTS\\Controller\\SuggestionsController', 'suggestions' => 'LOTS\\Controller\\SuggestionsController', ), ), - 'service_manager' => + 'service_manager' => array ( 'allow_override' => true, - 'factories' => + 'factories' => array ( 'LOTS\\ILS\\Connection' => 'VuFind\\ILS\\ConnectionFactory', ), - 'aliases' => + 'aliases' => array ( 'VuFind\\ILS\\Connection' => 'LOTS\\ILS\\Connection', ), @@ -42,40 +68,42 @@ 'sessionTimeout' => 'LOTS\\View\\Helper\\SessionTimeout', ), ), - 'vufind' => + 'vufind' => array ( 'allow_override' => true, - 'plugin_managers' => + 'plugin_managers' => array ( - 'ils_driver' => + 'ils_driver' => array ( - 'factories' => + 'factories' => array ( 'LOTS\\ILS\\Driver\\KohaRest' => 'LOTS\\ILS\\Driver\\KohaRestFactory', ), - 'aliases' => + 'aliases' => array ( 'VuFind\\ILS\\Driver\\KohaRest' => 'LOTS\\ILS\\Driver\\KohaRest', ), ), - 'db_table' => + 'db_table' => array ( - 'factories' => + 'factories' => array ( + 'LOTS\\Db\\Table\\PasswordResetToken' => 'VuFind\\Db\\Table\\GatewayFactory', // 'LOTS\\Db\\Table\\Ratings' => 'VuFind\\Db\\Row\\GatewayFactory', ), - 'aliases' => + 'aliases' => array ( + 'PasswordResetToken' => 'LOTS\\Db\\Table\\PasswordResetToken', // 'VuFind\\Db\\Table\\Ratings' => 'LOTS\\Db\Table\\Ratings', ), ), - 'db_row' => + 'db_row' => array ( - 'factories' => + 'factories' => array ( 'LOTS\\Db\\Row\\Ratings' => 'VuFind\\Db\\Row\\RowGatewayFactory', ), - 'aliases' => + 'aliases' => array ( 'VuFind\\Db\\Row\\Ratings' => 'LOTS\\Db\Row\\Ratings', ), @@ -83,15 +111,13 @@ ), ), ); - // Define non tab record actions $nonTabRecordActions = [ 'AddComment', 'DeleteComment', 'AddTag', 'DeleteTag', 'Save', 'Email', 'SMS', 'Cite', 'Export', 'RDF', 'Hold', 'Home', 'StorageRetrievalRequest', 'AjaxTab', 'ILLRequest', 'PDF', 'Epub', 'LinkedText', 'Permalink', 'AddRating' ]; - $routeGenerator = new \VuFind\Route\RouteGenerator(); $routeGenerator->addNonTabRecordActions($config, $nonTabRecordActions); - return $config; + diff --git a/module/LOTS/src/LOTS/Controller/ForgotPasswordController.php b/module/LOTS/src/LOTS/Controller/ForgotPasswordController.php index 992e97450a3..0a62b0d4831 100644 --- a/module/LOTS/src/LOTS/Controller/ForgotPasswordController.php +++ b/module/LOTS/src/LOTS/Controller/ForgotPasswordController.php @@ -1,120 +1,163 @@ getConfig('LOTS'); - $opacUrl = $config->PasswordRecovery->kohaOpacUrl; - - # Type of call represent the answer to give bellow. - $typeOfCall = 'home'; - #Get username so we can test if this is a post - $username = $this->params()->fromPost('username'); - # resp contains the responce for a http request. - $resp = ""; - # message store any message to send back to user. $message = ''; - # together with opacUrl we create this url for any local http calls. - # none local we could give the whole url. - $url = ''; - - if ($this->params()->fromQuery('resendEmail', false)) { - /* - * if the querystring has resendEmail=true then this is a - * request to resend the email. We recreeate the request - * and sends it to the opac backend instead. - */ - $typeOfCall = 'resend'; - $email = $this->params()->fromQuery('email'); - $username = $this->params()->fromQuery('username', false); - $message = "Email: ".$email." username: ".$username; - $fields = [ - 'username' => $username, - 'email' => $email, - 'resendEmail' => 'true', - 'language' => 'sv-SE', - ]; - $url = $opacUrl."/cgi-bin/koha/opac-password-recovery.pl"; - $resp = $this->httpPost($url, $fields, 'GET'); - } elseif (empty($username) != true) { - /* - * If we get a value in username, then we should be dealing - * with a request to reset password. So we will take the - * username value and send it to the backend koha-opac. - */ + $messageType = 'info'; + + // Handle form submission + $personnummer = $this->params()->fromPost('username'); + + if (!empty($personnummer)) { + try { + // Search for patron in Koha by cardnumber (personnummer) + $patron = $this->findPatronByCardnumber($personnummer); + + if ($patron && !empty($patron['email'])) { + // Generate and save token + $tokenTable = $this->getTable('PasswordResetToken'); + $token = $tokenTable->createToken($patron['patron_id'], $patron['email']); + + // Send email + $this->sendResetEmail($patron['email'], $token); + + // Generic message (don't reveal if user exists) + $message = $this->translate('password_reset_email_sent'); + $messageType = 'success'; + } else { + // Generic message (don't reveal if user exists or has no email) + $message = $this->translate('password_reset_email_sent'); + $messageType = 'success'; + } + } catch (\Exception $e) { + error_log('Password reset error: ' . $e->getMessage()); + $message = $this->translate('password_reset_error'); + $messageType = 'error'; + } + } + + return $this->createViewModel([ + 'message' => $message, + 'messageType' => $messageType + ]); + } - $typeOfCall = 'recover'; - $fields = [ - 'username' => $username, - 'sendEmail' => 'Submit', - 'language' => 'sv-SE', - ]; - $url = $opacUrl."/cgi-bin/koha/opac-password-recovery.pl"; - $resp = $this->httpPost($url, $fields, 'POST'); + /** + * Find patron by cardnumber using Koha REST API + * + * @param string $cardnumber Personnummer + * + * @return ?array Patron data or null + */ + protected function findPatronByCardnumber(string $cardnumber): ?array + { + $this->koha_rest_config = $this->getConfig('KohaRest'); + $this->oauth_token = $this->getOAuth2Token(); + + $baseUrl = $this->koha_rest_config->Catalog->host . '/v1'; + $url = $baseUrl . '/patrons?cardnumber=' . urlencode($cardnumber); + + $client = $this->httpService->createClient($url); + $client->getRequest()->getHeaders() + ->addHeaderLine('Authorization', $this->oauth_token) + ->addHeaderLine('Content-Type', 'application/json'); + + $response = $client->send(); + + if ($response->getStatusCode() !== 200) { + return null; } - /* ELSE - * If none of the two above is set (resendEmail and username) - * We are dealing with a new visitor to request new password. - * - * We will still test the response for messages from koha-opac - * bellow, we will preg_match to get only the message part of - * the html response. And then we use preg_replace to rewrite - * "known" urls to our own. - */ - if (preg_match("/alert-warning/i",$resp)) { - $typeOfCall = 'warning'; + + $data = json_decode($response->getBody(), true); + + // API returns array of patrons, we need the first one + if (empty($data) || !is_array($data) || count($data) === 0) { + return null; } - preg_match('/
(.*?)<\/div>/s', $resp, $message); - $message = preg_replace('/\(.*)\<\/a\>/m', ''.$this->translate('SendNewEmail').'', $message); - $message = preg_replace('/\(.*)\<\/a\>/m', ''.$this->translate('Go to homepage').'', $message); + + $patron = $data[0]; + + return [ + 'patron_id' => $patron['patron_id'] ?? null, + 'email' => $patron['email'] ?? null, + 'cardnumber' => $patron['cardnumber'] ?? null + ]; + } - # We must test if the message exists or set it to nothing. - # To not get an error - if (isset($message[0])) { - $message = $message[0]; - } else { - $message = ""; - } + /** + * Send password reset email + * + * @param string $email User email + * @param string $token Reset token + * + * @return void + */ +protected function sendResetEmail(string $email, string $token): void +{ + $config = $this->getConfig(); + $fromEmail = $config->Site->email ?? 'noreply@library.se'; - # Here we sendback the variables to the viewmodel. - # typeOfCall to determine the response template and - # any message we got from koha-opac - return $this->createViewModel( - [ - 'typeOfCall' => $typeOfCall, - 'message' => $message, - ] - ); - } - /* - * Simple function to make http requests with vufind - * builtin curl client. + $request = $this->getRequest(); + $serverUrl = $request->getUri()->getScheme() . '://' . $request->getUri()->getHost(); + $resetUrl = $serverUrl . '/vufind/ResetPassword?token=' . urlencode($token); + + $body = $this->translate('password_reset_line1') . PHP_EOL . PHP_EOL . + $this->translate('password_reset_line2') . PHP_EOL . + $resetUrl . PHP_EOL . PHP_EOL . + $this->translate('password_reset_line3') . PHP_EOL . PHP_EOL . + $this->translate('password_reset_line4'); + + $mailer = $this->serviceLocator->get('VuFind\Mailer'); + $mailer->send($email, $fromEmail, $this->translate('password_reset_email_subject'), $body); +} + /** + * Get OAuth2 token for Koha API + * + * @return string Token header value */ - public function httpPost($url, $data, $type) + protected function getOAuth2Token(): string { + $baseUrl = $this->koha_rest_config->Catalog->host . '/v1'; + $clientId = $this->koha_rest_config->Catalog->clientId; + $clientSecret = $this->koha_rest_config->Catalog->clientSecret; + $grantType = $this->koha_rest_config->Catalog->grantType ?? 'client_credentials'; + $tokenUrl = $baseUrl . '/oauth/token'; - $client = $this->httpService->createClient($url); - $adapter = $client->getAdapter(); - $adapter->setCurlOption(CURLOPT_SSL_VERIFYHOST, false); - $adapter->setCurlOption(CURLOPT_CUSTOMREQUEST, $type); - $adapter->setCurlOption(CURLOPT_POSTFIELDS, http_build_query($data)); - $adapter->setCurlOption(CURLOPT_ENCODING, ''); - $adapter->setCurlOption(CURLOPT_RETURNTRANSFER, true); - $adapter->setCurlOption(CURLOPT_COOKIE, "KohaOpacLanguage=sv-SE;a=a"); - $response = $client->send(); - return $response->getBody(); + try { + $token = $this->getNewOAuth2Token( + $tokenUrl, + $clientId, + $clientSecret, + $grantType + ); + } catch (\Exception $exception) { + throw new ILSException( + 'Problem with Koha REST API: ' . $exception->getMessage() + ); + } + return $token->getHeaderValue(); } } diff --git a/module/LOTS/src/LOTS/Controller/MyResearchController.php b/module/LOTS/src/LOTS/Controller/MyResearchController.php index 74d3f4106ab..dfb4c654874 100644 --- a/module/LOTS/src/LOTS/Controller/MyResearchController.php +++ b/module/LOTS/src/LOTS/Controller/MyResearchController.php @@ -343,5 +343,67 @@ public function homeAction() } return $this->forwardTo('MyResearch', $page); } + +public function requestnewpinAction() +{ + // Redirect to login if user is not authenticated + if (!$this->getUser()) { + return $this->forceLogin(); + } + + // Get email from POST or user profile + $email = $this->params()->fromPost('email', $this->getUser()->email); + if (!$email) { + $this->flashMessenger()->addErrorMessage('Email is required'); + return $this->createViewModel(['user' => $this->getUser()]); + } + + if ($this->getRequest()->isPost()) { + // Generate a random 4-digit PIN + $pin = sprintf("%04d", rand(1000, 9999)); + $patronId = $this->getUser()->cat_id; + + // Update PIN in Koha via API (maps to borrowers.password) + try { + $this->koha_rest_config = $this->getConfig('KohaRest'); + $this->oath_token = $this->getOAuth2Token(false); + $response = $this->json_http("PATCH", "/contrib/kohasuomi/patrons/$patronId", json_encode(['password' => $pin])); + error_log("Koha PIN update success for patron $patronId: " . json_encode($response)); + } catch (\Exception $e) { + // Log error and proceed to send email as fallback + error_log("Koha API error: " . $e->getMessage()); + } + + // Send PIN via email + try { + $config = $this->getConfig(); + $fromEmail = $config->Site->email ?? 'no-reply@lots.imcode.com'; // Fallback email + if (!$fromEmail || $fromEmail === 'no-reply@lots.imcode.com') { + error_log("Site email not configured - using fallback"); + } + + $mailer = $this->serviceLocator->get('VuFind\Mailer'); + $mailer->send( + $email, // to + $fromEmail, // from + 'Ny PIN-kod för LOTS', // subject + "Din nya PIN-kod är: $pin
Visa inte för andra!", // body + null, // cc + null, // bcc + 'text/html' // content type + ); + $this->flashMessenger()->addSuccessMessage('new_pin_sent'); + return $this->redirect()->toRoute('myresearch-home'); + } catch (\Exception $e) { + // Log email error and show user-friendly message + error_log("Mailer error: " . $e->getMessage()); + $this->flashMessenger()->addErrorMessage('Failed to send PIN email. Please try again.'); + } + } + + return $this->createViewModel(['user' => $this->getUser()]); +} + + } diff --git a/module/LOTS/src/LOTS/Controller/ResetPasswordController.php b/module/LOTS/src/LOTS/Controller/ResetPasswordController.php new file mode 100644 index 00000000000..6ce28892c99 --- /dev/null +++ b/module/LOTS/src/LOTS/Controller/ResetPasswordController.php @@ -0,0 +1,217 @@ +params()->fromQuery('token') ?? $this->params()->fromPost('token'); + error_log("DEBUG: Received token from URL: " . var_export($token, true)); + error_log("DEBUG: Token length: " . strlen($token)); + $message = ''; + $messageType = 'info'; + $tokenValid = false; + $tokenData = null; + + if (empty($token)) { + $message = $this->translate('password_reset_invalid_token'); + $messageType = 'error'; + } else { + // Validate token + $tokenTable = $this->getTable('PasswordResetToken'); + $tokenData = $tokenTable->getValidToken($token); + error_log("DEBUG: Token data from DB: " . var_export($tokenData, true)); + + if (!$tokenData) { + $message = $this->translate('password_reset_token_expired'); + $messageType = 'error'; + } else { + $tokenValid = true; + + // Handle form submission + if ($this->getRequest()->isPost()) { + $newPin = $this->params()->fromPost('new_pin'); + $confirmPin = $this->params()->fromPost('confirm_pin'); + + // Validate PIN + $validation = $this->validatePin($newPin, $confirmPin); + + if ($validation['valid']) { + try { + // Update password in Koha + $this->updatePatronPassword($tokenData['user_id'], $newPin); + + // Mark token as used + $tokenTable->markAsUsed($token); + + $message = $this->translate('password_reset_success'); + $messageType = 'success'; + $tokenValid = false; // Hide form + + // Redirect to login after 3 seconds + $this->layout()->setVariable('redirectUrl', '/vufind/MyResearch/Home'); + $this->layout()->setVariable('redirectDelay', 3000); + } catch (\Exception $e) { + error_log('Password update error: ' . $e->getMessage()); + $message = $this->translate('password_reset_update_error'); + $messageType = 'error'; + } + } else { + $message = $validation['error']; + $messageType = 'error'; + } + } + } + } + + return $this->createViewModel([ + 'message' => $message, + 'messageType' => $messageType, + 'tokenValid' => $tokenValid, + 'token' => $token + ]); + } + + /** + * Validate PIN format and match + * + * @param ?string $newPin New PIN + * @param ?string $confirmPin Confirmation PIN + * + * @return array ['valid' => bool, 'error' => string] + */ + protected function validatePin(?string $newPin, ?string $confirmPin): array + { + if (empty($newPin) || empty($confirmPin)) { + return [ + 'valid' => false, + 'error' => $this->translate('password_reset_pin_required') + ]; + } + + if ($newPin !== $confirmPin) { + return [ + 'valid' => false, + 'error' => $this->translate('password_reset_pin_mismatch') + ]; + } + + // PIN must be exactly 4 digits + if (!preg_match('/^\d{4}$/', $newPin)) { + return [ + 'valid' => false, + 'error' => $this->translate('password_reset_pin_format') + ]; + } + + return ['valid' => true, 'error' => '']; + } + + /** + * Update patron password in Koha + * + * @param string $patronId Patron ID + * @param string $newPin New PIN + * + * @return void + * @throws \Exception + */ + +protected function updatePatronPassword(string $patronId, string $newPin): void +{ + $this->koha_rest_config = $this->getConfig('KohaRest'); + $this->oauth_token = $this->getOAuth2Token(); + + error_log("DEBUG: Updating password for patron: " . $patronId); + + // Use correct endpoint: POST /patrons/{id}/password + $data = [ + 'password' => $newPin, + 'password_2' => $newPin + ]; + + $response = $this->json_http("POST", "/patrons/$patronId/password", json_encode($data)); + + error_log("Koha password update response: " . $response); + + // Check if response contains error + $result = json_decode($response, true); + if (isset($result['error'])) { + throw new \Exception('Koha API error: ' . $result['error']); + } +} + + /** + * Get OAuth2 token for Koha API + * + * @return string Token header value + */ + protected function getOAuth2Token(): string + { + $baseUrl = $this->koha_rest_config->Catalog->host . '/v1'; + $clientId = $this->koha_rest_config->Catalog->clientId; + $clientSecret = $this->koha_rest_config->Catalog->clientSecret; + $grantType = $this->koha_rest_config->Catalog->grantType ?? 'client_credentials'; + $tokenUrl = $baseUrl . '/oauth/token'; + + try { + $token = $this->getNewOAuth2Token( + $tokenUrl, + $clientId, + $clientSecret, + $grantType + ); + } catch (\Exception $exception) { + throw new ILSException( + 'Problem with Koha REST API: ' . $exception->getMessage() + ); + } + return $token->getHeaderValue(); + } + +public function json_http($method, $api, $postData = null) +{ + $baseUrl = $this->koha_rest_config->Catalog->host . '/v1'; + $url = $baseUrl . $api; + $client = $this->httpService->createClient($url); + + // Set headers + $client->getRequest()->getHeaders() + ->addHeaderLine('Authorization', $this->oauth_token) + ->addHeaderLine('Content-Type', 'application/json'); + + $client->getRequest()->setAllowCustomMethods(true); + // Set method + $client->setMethod($method); + + // Set post data + $client->getRequest()->setContent($postData); + + // Send request to the server + $response = $client->send(); + + // Get the response body/JSON + return $response->getBody(); +} + +} diff --git a/module/LOTS/src/LOTS/Db/Table/PasswordResetToken.php b/module/LOTS/src/LOTS/Db/Table/PasswordResetToken.php new file mode 100644 index 00000000000..b0449ae06d7 --- /dev/null +++ b/module/LOTS/src/LOTS/Db/Table/PasswordResetToken.php @@ -0,0 +1,146 @@ +insert([ + 'user_id' => $userId, + 'token' => $token, + 'email' => $email, + 'expires_at' => $expiresAt, + 'used' => 0 + ]); + + return $token; + } + + /** + * Get token data if valid (not expired and not used) + * + * @param string $token Token to validate + * + * @return ?array Token data or null if invalid + */ + public function getValidToken(string $token): ?array + { + $select = $this->getSql()->select(); + $select->where([ + 'token' => $token, + 'used' => 0 + ]); + $select->where->lessThanOrEqualTo('created_at', date('Y-m-d H:i:s')); + $select->where->greaterThan('expires_at', date('Y-m-d H:i:s')); + + $result = $this->selectWith($select)->current(); + + if (!$result) { + return null; + } + + return [ + 'id' => $result->id, + 'user_id' => $result->user_id, + 'email' => $result->email, + 'token' => $result->token, + 'created_at' => $result->created_at, + 'expires_at' => $result->expires_at + ]; + } + + /** + * Mark token as used + * + * @param string $token Token to mark as used + * + * @return bool Success + */ + public function markAsUsed(string $token): bool + { + return $this->update( + ['used' => 1], + ['token' => $token] + ) > 0; + } + + /** + * Delete expired tokens (cleanup) + * + * @return int Number of deleted tokens + */ + public function deleteExpired(): int + { + return $this->delete([ + 'expires_at < ?' => date('Y-m-d H:i:s') + ]); + } + + /** + * Delete all tokens for a specific user + * + * @param string $userId User ID + * + * @return int Number of deleted tokens + */ + public function deleteByUserId(string $userId): int + { + return $this->delete(['user_id' => $userId]); + } +} diff --git a/themes/lots/templates/Auth/ILS/loginfields.phtml b/themes/lots/templates/Auth/ILS/loginfields.phtml index 7cb9d305761..60973671dd5 100644 --- a/themes/lots/templates/Auth/ILS/loginfields.phtml +++ b/themes/lots/templates/Auth/ILS/loginfields.phtml @@ -4,9 +4,6 @@ $allowRecreatePassword = $config->PasswordRecovery->enable; $newPatronUrl = $config->NewPatron->url; $enableNewPatron = $config->NewPatron->enable; ?> - - - auth()->getManager()->getILSLoginMethod(); ?>
@@ -22,19 +19,19 @@ $enableNewPatron = $config->NewPatron->enable;
- - - - + -
- + - -
- + diff --git a/themes/lots/templates/RecordDriver/DefaultRecord/core.phtml b/themes/lots/templates/RecordDriver/DefaultRecord/core.phtml index e9d281d65af..c4351f034dc 100644 --- a/themes/lots/templates/RecordDriver/DefaultRecord/core.phtml +++ b/themes/lots/templates/RecordDriver/DefaultRecord/core.phtml @@ -147,23 +147,29 @@ $this->metadata()->generateMetatags($this->driver); - - - - - - - - - - - - - - +
transEsc('Bibliographic Details')?>
transEsc($showCorefieldPrefixes[$current['label']])?>
transEsc($showCorefieldPrefixes[$current['label']])?>
+ + + + + + + + + + + + + + + + + - -
transEsc('Bibliographic Details')?>
transEsc($showCorefieldPrefixes[$current['label']])?>
transEsc($showCorefieldPrefixes[$current['label']])?>
+ + + +
@@ -41,9 +44,9 @@ $this->headTitle($this->translate('reset_pin_code_title')); id="confirm_pin" class="form-control" required - pattern="\d{4}" - maxlength="4" - placeholder="****" + + pattern="escapeHtmlAttr(trim($this->passwordRegex, '/'))?>" + autocomplete="new-password">
@@ -62,7 +65,6 @@ $this->headTitle($this->translate('reset_pin_code_title'));