diff --git a/composer.json b/composer.json index 696de19..8a1c99f 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "fwdekker/death-notifier", "description": "Get notified when a famous person dies.", - "version": "0.0.13", + "version": "0.0.14", "type": "project", "license": "MIT", "homepage": "https://git.fwdekker.com/tools/death-notifier", diff --git a/composer.lock b/composer.lock index 383b894..f727082 100644 Binary files a/composer.lock and b/composer.lock differ diff --git a/package-lock.json b/package-lock.json index 22046f0..616de50 100644 Binary files a/package-lock.json and b/package-lock.json differ diff --git a/package.json b/package.json index 6aea983..7a79ad9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "death-notifier", - "version": "0.0.13", + "version": "0.0.14", "description": "Get notified when a famous person dies.", "author": "Florine W. Dekker", "browser": "dist/bundle.js", diff --git a/src/main/api.php b/src/main/api.php index 50b1c6a..89beb1b 100644 --- a/src/main/api.php +++ b/src/main/api.php @@ -145,11 +145,10 @@ if (isset($_POST["action"])) { case "login": $response = validate_csrf() ?? validate_logged_out() - ?? validate_has_arguments($_POST["email"], $_POST["password"]); - if ($response !== null) break; + ?? validate_has_arguments($_POST["email"], $_POST["password"]) + ?? $user_manager->check_login($_POST["email"], $_POST["password"]); - [$response, $uuid] = $user_manager->check_login($_POST["email"], $_POST["password"]); - if ($uuid !== null) $_SESSION["uuid"] = $uuid; + if ($response->satisfied) $_SESSION["uuid"] = $response->payload["uuid"]; break; case "logout": $response = validate_csrf() ?? validate_logged_in(); @@ -206,14 +205,31 @@ if (isset($_POST["action"])) { } } elseif (isset($_GET["action"])) { // GET requests; do not alter state - $response = match ($_GET["action"]) { - "get-user-data" => validate_logged_in() ?? $user_manager->get_user_data($_SESSION["uuid"]), - "list-trackings" => validate_logged_in() ?? $tracking_manager->list_trackings($_SESSION["uuid"]), - default => new Response( - payload: ["target" => null, "message" => "Unknown GET action '" . $_GET["action"] . "'."], - satisfied: false - ), - }; + switch ($_GET["action"]) { + case "start-session": + if (!isset($_SESSION["uuid"])) { + $response = new Response(payload: null, satisfied: true); + break; + } + + $response = $user_manager->get_user_data($_SESSION["uuid"]); + if (!$response->satisfied) { + session_destroy(); + session_start(); + } + break; + case "get-user-data": + $response = validate_logged_in() ?? $user_manager->get_user_data($_SESSION["uuid"]); + break; + case "list-trackings": + $response = validate_logged_in() ?? $tracking_manager->list_trackings($_SESSION["uuid"]); + break; + default: + $response = new Response( + payload: ["target" => null, "message" => "Unknown GET action '" . $_GET["action"] . "'."], + satisfied: false + ); + } } elseif ($argc > 1) { // CLI if (hash_equals($config["admin"]["update_secret"], "REPLACE THIS WITH A SECRET VALUE")) diff --git a/src/main/css/main.css b/src/main/css/main.css index d5e6b7f..8a13728 100644 --- a/src/main/css/main.css +++ b/src/main/css/main.css @@ -4,11 +4,21 @@ } -table form, table button { +/* Trackings table */ +#trackings form, #trackings button, #addTrackingPersonName { margin-bottom: unset; } +#addTrackingPersonName, #addTrackingButton { + display: inline; +} +#addTrackingPersonName { + width: unset; +} + + +/* Input validation elements */ .validationInfo { display: block; font-weight: normal; diff --git a/src/main/index.html b/src/main/index.html index fa637f9..782ecb7 100644 --- a/src/main/index.html +++ b/src/main/index.html @@ -34,9 +34,12 @@
-

- -

+
+ +

+ +

+
@@ -81,7 +84,7 @@ - +
@@ -99,18 +102,16 @@ - +
-
- - @@ -122,25 +123,27 @@

Manage account

- +

Change email

- Current email: TODO
- Verified? TODO +

+ Current email: TODO
+ Validated: TODO +

- +

Change password

- Last changed: TODO +

Last changed: TODO

- +
diff --git a/src/main/js/Main.ts b/src/main/js/Main.ts index eb24709..d7fdb7a 100644 --- a/src/main/js/Main.ts +++ b/src/main/js/Main.ts @@ -82,6 +82,15 @@ function showError(element: HTMLElement, message?: string) { } +/** + * Sends a GET request to the API. + * + * @param params the GET parameters to send + * @param form the form to display validation info in + * @param onSatisfied the callback to execute if the request returns successfully + * @param onUnsatisfied the callback to execute if the request returns unsuccessfully + * @param onError the callback to execute if there was an HTTP error + */ function getApi( params: Record, form: HTMLFormElement, @@ -92,6 +101,15 @@ function getApi( interactWithApi("api.php?" + new URLSearchParams(params), undefined, form, onSatisfied, onUnsatisfied, onError); } +/** + * Sends a POST request to the API. + * + * @param params the POST parameters to send + * @param form the form to display validation info in + * @param onSatisfied the callback to execute if the request returns successfully + * @param onUnsatisfied the callback to execute if the request returns unsuccessfully + * @param onError the callback to execute if there was an HTTP error + */ function postApi( params: object, form: HTMLFormElement, @@ -115,6 +133,16 @@ function postApi( ); } +/** + * Sends a request to the API. + * + * @param url the URL to send the request to + * @param options the options to send with the request + * @param form the form to display validation info in + * @param onSatisfied the callback to execute if the request returns successfully + * @param onUnsatisfied the callback to execute if the request returns unsuccessfully + * @param onError the callback to execute if there was an HTTP error + */ function interactWithApi( url: string, options: object | undefined, @@ -152,10 +180,11 @@ function interactWithApi( }); } + /** * Refreshes the list of trackings in the table. */ -function refreshTrackings() { +function refreshTrackings(): void { getApi( {action: "list-trackings"}, sharedMessageElement, @@ -182,9 +211,7 @@ function refreshTrackings() { const deleteCell = document.createElement("td"); const deleteForm = document.createElement("form"); - const deleteButton = document.createElement("button"); - deleteButton.innerText = "remove"; - deleteButton.addEventListener("submit", (event: SubmitEvent) => { + deleteForm.addEventListener("submit", (event: SubmitEvent) => { event.preventDefault(); postApi( @@ -193,22 +220,36 @@ function refreshTrackings() { () => refreshTrackings() ) }); + const deleteButton = document.createElement("button"); + deleteButton.innerText = "remove"; deleteForm.append(deleteButton); deleteCell.append(deleteForm); row.append(deleteCell); tableBody.insertBefore(row, lastRow); }); - }, - () => { - // TODO - }, - () => { - // TODO } ); } +/** + * Refreshes displays of the user's data. + * + * @param userData the most up-to-date information on the user + */ +function refreshUserData(userData: any): void { + // Email + $("#emailCurrent").innerText = userData.email; + + // Password update time + const today = new Date(); + today.setHours(0, 0, 0, 0) + const updateTime = new Date(userData.password_update_time * 1000); + updateTime.setHours(0, 0, 0, 0); + const diff = (+today - +updateTime) / 86400000; + $("#passwordLastChanged").innerText = diff === 0 ? "today" : diff + " days ago"; +} + // Initialize template doAfterLoad(() => { @@ -226,10 +267,12 @@ doAfterLoad(() => { // Event handlers and so on doAfterLoad(() => { + // Find rows const loginRow = $("#loginRow"); const trackingRow = $("#trackingRow"); const accountRow = $("#accountRow"); + // Find forms const loginForm = $("#loginForm"); const registerForm = $("#registerForm"); const logoutForm = $("#logoutForm"); @@ -237,6 +280,35 @@ doAfterLoad(() => { const updatePasswordForm = $("#updatePasswordForm"); const addTrackingForm = $("#addTrackingForm"); + // Add common event code + function onLogin() { + loginRow.classList.add("hidden") + trackingRow.classList.remove("hidden"); + accountRow.classList.remove("hidden"); + refreshTrackings(); + + loginForm.reset(); + clearMessages(loginForm); + registerForm.reset(); + clearMessages(registerForm); + } + + function onLogout() { + loginRow.classList.remove("hidden") + trackingRow.classList.add("hidden"); + accountRow.classList.add("hidden"); + + addTrackingForm.reset(); + clearMessages(addTrackingForm); + updateEmailForm.reset(); + clearMessages(updateEmailForm); + updatePasswordForm.reset(); + clearMessages(updatePasswordForm); + addTrackingForm.reset(); + clearMessages(addTrackingForm); + } + + // Add event handlers loginForm.addEventListener("submit", (event: SubmitEvent) => { event.preventDefault(); @@ -248,11 +320,10 @@ doAfterLoad(() => { password: $("#loginPassword").value, }, loginForm, - () => { - loginRow.classList.add("hidden"); - trackingRow.classList.remove("hidden"); - accountRow.classList.remove("hidden"); - refreshTrackings(); + (response) => { + $("#emailCurrent").innerText = response.payload.email; + $("#passwordLastChanged").innerText = response.payload.password_last_update; + onLogin(); } ); }); @@ -285,11 +356,7 @@ doAfterLoad(() => { token: csrfToken, }, logoutForm, - () => { - loginRow.classList.remove("hidden"); - trackingRow.classList.add("hidden"); - accountRow.classList.add("hidden"); - } + () => onLogout() ); }); @@ -303,7 +370,11 @@ doAfterLoad(() => { email: $("#updateEmailEmail").value, }, updateEmailForm, - () => sharedMessageElement.innerText = "Email updated successfully!" + () => { + $("#emailCurrent").innerText = $("#updateEmailEmail").value; + updateEmailForm.reset(); + showSuccess(sharedMessageElement, "Email updated successfully!"); + } ); }); @@ -314,12 +385,16 @@ doAfterLoad(() => { { action: "update-password", token: csrfToken, - password_old: $("#updatePasswordPasswordOld"), - password_new: $("#updatePasswordPasswordNew"), - password_confirm: $("#updatePasswordPasswordConfirm"), + password_old: $("#updatePasswordPasswordOld").value, + password_new: $("#updatePasswordPasswordNew").value, + password_confirm: $("#updatePasswordPasswordConfirm").value, }, updatePasswordForm, - () => sharedMessageElement.innerText = "Password updated successfully!" + () => { + $("#passwordLastChanged").innerText = "today"; + updatePasswordForm.reset(); + showSuccess(sharedMessageElement, "Password updated successfully!"); + } ); }); @@ -340,21 +415,17 @@ doAfterLoad(() => { ); }); - - // TODO: Add appropriate message when session is expired - // TODO: Log out if session is expired + // Show content depending on whether user is logged in getApi( - // TODO: Rename to `start-session` for semantic reasons? - {action: "get-user-data"}, + {action: "start-session"}, sharedMessageElement, - () => { - trackingRow.classList.remove("hidden"); - accountRow.classList.remove("hidden"); - refreshTrackings(); - }, - () => { - clearMessage(sharedMessageElement); - loginRow.classList.remove("hidden") + (response: ServerResponse) => { + if (response.payload === null) { + onLogout(); + } else { + onLogin(); + refreshUserData(response.payload); + } } ); }); diff --git a/src/main/php/TrackingManager.php b/src/main/php/TrackingManager.php index 0418b66..7d12307 100644 --- a/src/main/php/TrackingManager.php +++ b/src/main/php/TrackingManager.php @@ -64,6 +64,11 @@ class TrackingManager */ public function add_tracking(string $user_uuid, string $person_name): Response { + if (trim($person_name) === "") + return new Response( + payload: ["target" => "personName", "message" => "Invalid page name: empty."], + satisfied: false + ); if (strlen($person_name) > self::MAX_TITLE_LENGTH) return new Response( payload: ["target" => "personName", "message" => "Invalid page name: too long."], diff --git a/src/main/php/UserManager.php b/src/main/php/UserManager.php index 487a878..6ae5e60 100644 --- a/src/main/php/UserManager.php +++ b/src/main/php/UserManager.php @@ -57,7 +57,8 @@ class UserManager public function install(): void { $conn = Database::connect($this->db_filename); - $conn->exec("CREATE TABLE users(uuid text primary key not null, email text not null, password text not null);"); + $conn->exec("CREATE TABLE users(uuid text primary key not null, email text not null, + password text not null, password_update_time int not null);"); } /** @@ -117,7 +118,8 @@ class UserManager } // Register user - $stmt = $conn->prepare("INSERT INTO users (uuid, email, password) VALUES (:uuid, :email, :password);"); + $stmt = $conn->prepare("INSERT INTO users (uuid, email, password, password_update_time) + VALUES (:uuid, :email, :password, unixepoch());"); $stmt->bindValue(":uuid", $uuid); $stmt->bindValue(":email", $email); $stmt->bindValue(":password", password_hash($password, PASSWORD_DEFAULT)); @@ -151,44 +153,38 @@ class UserManager * * @param string $email the email address of the user whose password should be checked * @param string $password the password to check against the specified user - * @return array{Response, ?string} the first element is a response with message `null` if the login was successful, - * or a response with a message explaining what went wrong otherwise; the second element is the UUID of the user - * that was logged in as, or `null` if the login should not be performed + * @return Response a response with user data if the login was successful, or a response with a message explaining + * what went wrong otherwise */ - public function check_login(string $email, string $password): array + public function check_login(string $email, string $password): Response { if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > self::MAX_EMAIL_LENGTH) - return [ - new Response( - payload: ["target" => "email", "message" => "Invalid email address."], - satisfied: false - ), - null - ]; + return new Response( + payload: ["target" => "email", "message" => "Invalid email address."], + satisfied: false + ); if (strlen($password) > self::MAX_PASSWORD_LENGTH) - return [ - new Response( - payload: ["target" => "password", "message" => "Incorrect combination of email and password."], - satisfied: false - ), - null - ]; + return new Response( + payload: ["target" => "password", "message" => "Incorrect combination of email and password."], + satisfied: false + ); $conn = Database::connect($this->db_filename); - $stmt = $conn->prepare("SELECT uuid, password FROM users WHERE email=:email;"); + $stmt = $conn->prepare("SELECT uuid, email, password, password_update_time FROM users WHERE email=:email;"); $stmt->bindValue(":email", $email); $stmt->execute(); $results = $stmt->fetchAll(PDO::FETCH_ASSOC); - return (sizeof($results) === 0 || !password_verify($password, $results[0]["password"])) - ? [ - new Response( - payload: ["target" => "password", "message" => "Incorrect combination of email and password."], - satisfied: false - ), - null - ] - : [new Response(payload: null, satisfied: true), $results[0]["uuid"]]; + if (sizeof($results) === 0 || !password_verify($password, $results[0]["password"])) { + return new Response( + payload: ["target" => "password", "message" => "Incorrect combination of email and password."], + satisfied: false + ); + } + + $user = $results[0]; + unset($user["password"]); + return new Response(payload: $user, satisfied: true); } /** @@ -200,12 +196,15 @@ class UserManager public function get_user_data(string $uuid): Response { $conn = Database::connect($this->db_filename); - $stmt = $conn->prepare("SELECT * FROM users WHERE uuid=:uuid;"); + $stmt = $conn->prepare("SELECT uuid, email, password_update_time FROM users WHERE uuid=:uuid;"); $stmt->bindValue(":uuid", $uuid); $stmt->execute(); $user = $stmt->fetch(PDO::FETCH_ASSOC); if ($user === false) - return new Response(payload: ["target" => "uuid", "message" => "Invalid user."], satisfied: false); + return new Response( + payload: ["target" => "uuid", "message" => "Something went wrong. Please try logging in again."], + satisfied: false + ); return new Response(payload: $user, satisfied: true); } @@ -305,7 +304,8 @@ class UserManager } // Update password - $stmt = $conn->prepare("UPDATE users SET password=:password WHERE uuid=:uuid;"); + $stmt = $conn->prepare("UPDATE users SET password=:password, password_update_time=unixepoch() + WHERE uuid=:uuid;"); $stmt->bindValue(":uuid", $uuid); $stmt->bindValue(":password", password_hash($password_new, PASSWORD_DEFAULT)); $stmt->execute();