Implement re-sending verification email

This commit is contained in:
Florine W. Dekker 2022-08-27 16:06:48 +02:00
parent 5ae5dc2c83
commit 37db33b2f5
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
13 changed files with 580 additions and 321 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "fwdekker/death-notifier", "name": "fwdekker/death-notifier",
"description": "Get notified when a famous person dies.", "description": "Get notified when a famous person dies.",
"version": "0.0.22", "version": "0.0.23",
"type": "project", "type": "project",
"license": "MIT", "license": "MIT",
"homepage": "https://git.fwdekker.com/tools/death-notifier", "homepage": "https://git.fwdekker.com/tools/death-notifier",

BIN
composer.lock generated

Binary file not shown.

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{ {
"name": "death-notifier", "name": "death-notifier",
"version": "0.0.22", "version": "0.0.23",
"description": "Get notified when a famous person dies.", "description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker", "author": "Florine W. Dekker",
"browser": "dist/bundle.js", "browser": "dist/bundle.js",

View File

@ -152,10 +152,11 @@ if (isset($_POST["action"])) {
case "login": case "login":
$response = validate_csrf() $response = validate_csrf()
?? validate_logged_out() ?? validate_logged_out()
?? validate_has_arguments($_POST["email"], $_POST["password"]) ?? validate_has_arguments($_POST["email"], $_POST["password"]);
?? $user_manager->check_login($_POST["email"], $_POST["password"]); if ($response !== null) break;
if ($response->satisfied) $_SESSION["uuid"] = $response->payload["uuid"]; [$response, $uuid] = $user_manager->check_login($_POST["email"], $_POST["password"]);
if ($response->satisfied) $_SESSION["uuid"] = $uuid;
break; break;
case "logout": case "logout":
$response = validate_csrf() ?? validate_logged_in(); $response = validate_csrf() ?? validate_logged_in();
@ -176,6 +177,11 @@ if (isset($_POST["action"])) {
$response = validate_has_arguments($_POST["email"], $_POST["token"]) $response = validate_has_arguments($_POST["email"], $_POST["token"])
?? $user_manager->verify_email($_POST["email"], $_POST["token"]); ?? $user_manager->verify_email($_POST["email"], $_POST["token"]);
break; break;
case "resend-verify-email":
$response = validate_csrf()
?? validate_logged_in()
?? $user_manager->resend_verify_email($_SESSION["uuid"], $mailer);
break;
case "update-password": case "update-password":
$response = validate_csrf() $response = validate_csrf()
?? validate_logged_in() ?? validate_logged_in()
@ -216,11 +222,11 @@ if (isset($_POST["action"])) {
switch ($_GET["action"]) { switch ($_GET["action"]) {
case "start-session": case "start-session":
if (!isset($_SESSION["uuid"])) { if (!isset($_SESSION["uuid"])) {
$response = new Response(payload: null, satisfied: true); $response = new Response(payload: ["target" => null, "message" => null], satisfied: false);
break; break;
} }
$response = $user_manager->get_user_data($_SESSION["uuid"]); $response = $user_manager->user_exists($_SESSION["uuid"]);
if (!$response->satisfied) { if (!$response->satisfied) {
session_destroy(); session_destroy();
session_start(); session_start();

View File

@ -1,6 +1,13 @@
:root { :root {
--error-color: red; /* Colors taken from https://isabelcastillo.com/error-info-messages-css */
--success-color: green; --success-color: #4f8a10;
--success-bg-color: #dff2bf;
--warning-color: #9f6000;
--warning-bg-color: #feefb3;
--error-color: #d8000c;
--error-bg-color: #ffbaba;
/* Color taken from https://fandom.com */
--fandom-redlink: #ba0000; --fandom-redlink: #ba0000;
} }
@ -25,6 +32,23 @@ a.redLink {
} }
/* Email validation forms */
#resendEmailVerificationForm label {
display: inline;
}
#resendEmailVerificationForm label .validationInfo {
display: none;
}
#resendEmailVerificationButton {
margin: 0;
padding: .4rem;
height: unset;
line-height: unset;
}
/* Input validation elements */ /* Input validation elements */
.validationInfo { .validationInfo {
display: block; display: block;
@ -36,49 +60,67 @@ input + .validationInfo {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.valid { .success {
color: var(--success-color); color: var(--success-color);
} }
.valid > input, .valid > input:focus { .success > input, .success > input:focus {
color: var(--success-color); color: var(--success-color);
border-color: var(--success-color); border-color: var(--success-color);
} }
.valid > .validationInfo { .success > .validationInfo {
color: var(--success-color); color: var(--success-color);
} }
.invalid { .warning {
color: var(--warning-color);
}
.warning > input, .warning > input:focus {
color: var(--warning-color);
border-color: var(--warning-color);
}
.warning > .validationInfo {
color: var(--warning-color);
}
.error {
color: var(--error-color); color: var(--error-color);
} }
.invalid > input, .invalid > input:focus { .error > input, .error > input:focus {
color: var(--error-color); color: var(--error-color);
border-color: var(--error-color); border-color: var(--error-color);
} }
.invalid > .validationInfo { .error > .validationInfo {
color: var(--error-color); color: var(--error-color);
} }
.formValidationInfo:not(.valid):not(.invalid) { .formValidationInfo {
margin: 0;
padding: 0;
}
.formValidationInfo.valid, .formValidationInfo.invalid {
padding: 1rem; padding: 1rem;
} }
.formValidationInfo.valid { .formValidationInfo:not(.success):not(.warning):not(.error) {
color: green; display: none;
border: 1px solid green;
background-color: palegreen;
} }
.formValidationInfo.invalid { .formValidationInfo.success {
color: red; color: var(--success-color);
border: 1px solid red; border: 1px solid var(--success-color);
background-color: lavenderblush; background-color: var(--success-bg-color);
}
.formValidationInfo.warning {
color: var(--warning-color);
border: 1px solid var(--warning-color);
background-color: var(--warning-bg-color);
}
.formValidationInfo.error {
color: var(--error-color);
border: 1px solid var(--error-color);
background-color: var(--error-bg-color);
} }

View File

@ -38,6 +38,9 @@
<p id="sharedValidationInfo" class="formValidationInfo"> <p id="sharedValidationInfo" class="formValidationInfo">
<span class="validationInfo"></span> <span class="validationInfo"></span>
</p> </p>
<p id="sharedHomeLink" class="hidden">
<a href="./">Click here to return to the main page</a>
</p>
</div> </div>
</div> </div>
@ -142,11 +145,17 @@
</div> </div>
<div class="column"> <div class="column">
<h3>Change email</h3> <h3>Change email</h3>
<p> <form id="resendEmailVerificationForm" novalidate>
<b>Current email:</b> <span id="emailCurrent">ERROR</span><br /> <p class="formValidationInfo">
<!-- TODO: Allow resending email verification --> <span class="validationInfo"></span>
<b>Verified:</b> <span id="emailVerified">ERROR</span> </p>
</p> <label>Current email:<span class="validationInfo"></span></label>
<span id="emailCurrent">ERROR</span>
<br />
<label>Verified:<span class="validationInfo"></span></label>
<span id="emailVerified">ERROR</span>
<button id="resendEmailVerificationButton" class="hidden">resend</button>
</form>
<form id="updateEmailForm" novalidate> <form id="updateEmailForm" novalidate>
<p class="formValidationInfo"> <p class="formValidationInfo">
<span class="validationInfo"></span> <span class="validationInfo"></span>
@ -161,7 +170,9 @@
</div> </div>
<div class="column"> <div class="column">
<h3>Change password</h3> <h3>Change password</h3>
<p>Last changed: <span id="passwordLastChanged">ERROR</span></p> <form>
<b>Last changed:</b> <span id="passwordLastChanged">ERROR</span>
</form>
<form id="updatePasswordForm" novalidate> <form id="updatePasswordForm" novalidate>
<p class="formValidationInfo"> <p class="formValidationInfo">
<span class="validationInfo"></span> <span class="validationInfo"></span>

141
src/main/js/API.ts Normal file
View File

@ -0,0 +1,141 @@
// @ts-ignore
const {$} = window.fwdekker;
import {clearMessages, showError} from "./Message";
/**
* The CSRF token to be used for the next API request.
*/
export let csrfToken: string | null = null;
/**
* A shared element to place global messages in.
*/
export const sharedMessageElement: HTMLFormElement = $("#sharedValidationInfo");
/**
* A response sent by the server after a request has been sent to it.
*/
export type ServerResponse = {
/**
* The message or payload, depending on the type of request that is being responded to.
*/
payload: any;
/**
* `true` if and only if the request was completed successfully.
*/
satisfied: boolean;
/**
* The CSRF token to use for the next request.
*/
token: string;
};
/**
* Does nothing.
*
* Exists because auto-format adds that annoying line in an empty function.
*/
const emptyFunction = () => {
};
/**
* 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
*/
export function getApi(
params: Record<string, string>,
form: HTMLFormElement,
onSatisfied: (response: ServerResponse) => void = emptyFunction,
onUnsatisfied: (response: ServerResponse) => void = emptyFunction,
onError: (error: any) => void = emptyFunction
): void {
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
*/
export function postApi(
params: object,
form: HTMLFormElement,
onSatisfied: (response: ServerResponse) => void = emptyFunction,
onUnsatisfied: (response: ServerResponse) => void = emptyFunction,
onError: (error: any) => void = emptyFunction
): void {
interactWithApi("api.php",
{
method: "post",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(params)
},
form,
onSatisfied,
onUnsatisfied,
onError
);
}
/**
* 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,
form: HTMLFormElement,
onSatisfied: (response: ServerResponse) => void = emptyFunction,
onUnsatisfied: (response: ServerResponse) => void = emptyFunction,
onError: (error: any) => void = emptyFunction
): void {
clearMessages(form);
const topErrorElement = $(".formValidationInfo", form) ?? sharedMessageElement;
fetch(url, options)
.then(it => it.json())
.then(it => {
csrfToken = it.token;
if (!it.satisfied) {
if (it.payload.message !== null) {
if (it.payload.target === null) {
showError(topErrorElement, it.payload.message);
} else {
const target = form?.querySelector(`input[name=${it.payload.target}]`)?.parentElement;
showError(target ?? topErrorElement, it.payload.message);
target?.focus();
}
}
onUnsatisfied(it);
} else {
onSatisfied(it);
}
})
.catch((error) => {
showError(topErrorElement, "Unexpected error. Please try again later.");
onError(error);
});
}

View File

@ -0,0 +1,23 @@
/**
* Collects callback functions to be invoked.
*/
export class CustomEventHandler {
private _listeners: (() => void)[] = [];
/**
* Adds a listener to be invoked when `invokeListeners` is called.
*
* @param listener a listener to invoke when `invokeListeners` is called
*/
addListener(listener: () => void) {
this._listeners.push(listener);
}
/**
* Invokes all listeners that were added using `addListener`.
*/
invokeListeners() {
this._listeners.forEach(it => it());
}
}

View File

@ -1,186 +1,19 @@
// @ts-ignore // @ts-ignore
const {$, $a, doAfterLoad, footer, header, nav} = window.fwdekker; const {$, $a, doAfterLoad, footer, header, nav} = window.fwdekker;
import {csrfToken, getApi, postApi, sharedMessageElement} from "./API";
/** import {CustomEventHandler} from "./CustomEventHandler";
* The CSRF token to be used for the next API request. import {clearMessage, clearMessages, showError, showSuccess, showWarning} from "./Message";
*/
let csrfToken: string | null = null;
/**
* A shared element to place global messages in.
*/
const sharedMessageElement: HTMLFormElement = $("#sharedValidationInfo");
/**
* Does nothing.
*
* Exists because auto-format adds that annoying line in an empty function.
*/
const emptyFunction = () => {
};
/** /**
* A response sent by the server after a request has been sent to it. * Handles listeners to be invoked when the user logs in.
*/ */
type ServerResponse = { const loginHandler = new CustomEventHandler();
/**
* The message or payload, depending on the type of request that is being responed to.
*/
payload: any;
/**
* `true` if and only if the request was completed successfully.
*/
satisfied: boolean;
/**
* The CSRF token to use for the next request.
*/
token: string;
};
/** /**
* Clears all validation messages from the given form. * Handles listeners to be invoked when the user logs out.
*
* @param form the form to clear all validation messages in
*/ */
function clearMessages(form: HTMLFormElement) { const logoutHandler = new CustomEventHandler();
const formValidationInfo = $(".formValidationInfo", form);
if (formValidationInfo !== null) clearMessage(formValidationInfo);
$a("label", form).forEach(clearMessage);
}
/**
* Clears the validation message from the given element.
*
* @param element the element to clear the validation message from
*/
function clearMessage(element: HTMLElement) {
element.classList.remove("valid", "invalid");
$(".validationInfo", element).innerText = "";
}
/**
* Shows a success message at the given element.
*
* @param element the element to show the success message at
* @param message the success message to display
*/
function showSuccess(element: HTMLElement, message?: string) {
element.classList.remove("invalid");
element.classList.add("valid");
$(".validationInfo", element).innerText = message ?? "";
}
/**
* Shows an error message at the given element.
*
* @param element the element to show the error message at
* @param message the error message to display
*/
function showError(element: HTMLElement, message?: string) {
element.classList.remove("valid");
element.classList.add("invalid");
$(".validationInfo", element).innerText = message ?? "";
}
/**
* 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<string, string>,
form: HTMLFormElement,
onSatisfied: (response: ServerResponse) => void = emptyFunction,
onUnsatisfied: (response: ServerResponse) => void = emptyFunction,
onError: (error: any) => void = emptyFunction
): void {
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,
onSatisfied: (response: ServerResponse) => void = emptyFunction,
onUnsatisfied: (response: ServerResponse) => void = emptyFunction,
onError: (error: any) => void = emptyFunction
): void {
interactWithApi("api.php",
{
method: "post",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(params)
},
form,
onSatisfied,
onUnsatisfied,
onError
);
}
/**
* 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,
form: HTMLFormElement,
onSatisfied: (response: ServerResponse) => void = emptyFunction,
onUnsatisfied: (response: ServerResponse) => void = emptyFunction,
onError: (error: any) => void = emptyFunction
): void {
clearMessages(form);
const topErrorElement = $(".formValidationInfo", form) ?? sharedMessageElement;
fetch(url, options)
.then(it => it.json())
.then(it => {
csrfToken = it.token;
if (!it.satisfied) {
if (it.payload.target === null) {
showError(topErrorElement, it.payload.message);
} else {
const target = form?.querySelector(`input[name=${it.payload.target}]`)?.parentElement;
showError(target ?? topErrorElement, it.payload.message);
target?.focus();
}
onUnsatisfied(it);
} else {
onSatisfied(it);
}
})
.catch((error) => {
showError(topErrorElement, "Unexpected error. Please try again later.");
onError(error);
});
}
/** /**
@ -241,21 +74,35 @@ function refreshTrackings(): void {
/** /**
* Refreshes displays of the user's data. * Refreshes displays of the user's data.
*
* @param userData the most up-to-date information on the user
*/ */
function refreshUserData(userData: any): void { function refreshUserData(): void {
// Email getApi(
$("#emailCurrent").innerText = userData.email; {action: "get-user-data"},
$("#emailVerified").innerText = userData.email_is_verified ? "yes" : "no"; sharedMessageElement,
(response) => {
const userData = response.payload;
// Password update time // Email
const today = new Date(); $("#emailCurrent").innerText = userData.email;
today.setHours(0, 0, 0, 0) $("#emailVerified").innerText = userData.email_is_verified ? "yes" : "no";
const updateTime = new Date(userData.password_update_time * 1000); if (!userData.email_is_verified) {
updateTime.setHours(0, 0, 0, 0); showWarning(
const diff = (+today - +updateTime) / 86400000; sharedMessageElement,
$("#passwordLastChanged").innerText = diff === 0 ? "today" : diff + " days ago"; "You will not receive any email notifications until you verify your email address. " +
"Check your inbox for further instructions."
);
$("#resendEmailVerificationButton").classList.remove("hidden");
}
// 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";
}
)
} }
@ -273,52 +120,24 @@ doAfterLoad(() => {
$("main").classList.remove("hidden"); $("main").classList.remove("hidden");
}); });
// Event handlers and so on // Register event handlers
doAfterLoad(() => { doAfterLoad(() => {
// Find rows loginHandler.addListener(() => {
const loginRow = $("#loginRow"); refreshUserData();
const trackingRow = $("#trackingRow");
const accountRow = $("#accountRow");
// Find forms
const loginForm = $("#loginForm");
const registerForm = $("#registerForm");
const logoutForm = $("#logoutForm");
const updateEmailForm = $("#updateEmailForm");
const updatePasswordForm = $("#updatePasswordForm");
const addTrackingForm = $("#addTrackingForm");
// Add common event code
function onLogin() {
// TODO: Show header telling user that their email address has not been verified yet, with option to resend
loginRow.classList.add("hidden")
trackingRow.classList.remove("hidden");
accountRow.classList.remove("hidden");
refreshTrackings(); refreshTrackings();
loginForm.reset(); $("#loginRow").classList.add("hidden")
clearMessages(loginForm); $("#trackingRow").classList.remove("hidden");
registerForm.reset(); $("#accountRow").classList.remove("hidden");
clearMessages(registerForm); });
} logoutHandler.addListener(() => {
$("#loginRow").classList.remove("hidden")
function onLogout() { $("#trackingRow").classList.add("hidden");
loginRow.classList.remove("hidden") $("#accountRow").classList.add("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 // Add event handlers
const loginForm = $("#loginForm");
loginForm.addEventListener("submit", (event: SubmitEvent) => { loginForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault(); event.preventDefault();
@ -329,14 +148,16 @@ doAfterLoad(() => {
email: $("#loginEmail").value, email: $("#loginEmail").value,
password: $("#loginPassword").value, password: $("#loginPassword").value,
}, },
loginForm, event.target as HTMLFormElement,
(response) => { () => loginHandler.invokeListeners()
onLogin();
refreshUserData(response.payload);
}
); );
}); });
loginHandler.addListener(() => {
loginForm.reset();
clearMessages(loginForm);
});
const registerForm = $("#registerForm");
registerForm.addEventListener("submit", (event: SubmitEvent) => { registerForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault(); event.preventDefault();
@ -353,13 +174,18 @@ doAfterLoad(() => {
// TODO: Add client-side form validation // TODO: Add client-side form validation
registerForm.reset(); registerForm.reset();
showSuccess( showSuccess(
$("#registerForm .formValidationInfo"), $(".formValidationInfo", registerForm),
"Account created successfully! You may now log in." "Account created successfully! You may now log in."
); );
} }
); );
}); });
loginHandler.addListener(() => {
registerForm.reset();
clearMessages(registerForm);
});
const logoutForm = $("#logoutForm");
logoutForm.addEventListener("submit", (event: SubmitEvent) => { logoutForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault(); event.preventDefault();
@ -369,10 +195,14 @@ doAfterLoad(() => {
token: csrfToken, token: csrfToken,
}, },
logoutForm, logoutForm,
() => onLogout() () => {
logoutForm.reset();
logoutHandler.invokeListeners();
}
); );
}); });
const updateEmailForm = $("#updateEmailForm");
updateEmailForm.addEventListener("submit", (event: SubmitEvent) => { updateEmailForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault(); event.preventDefault();
@ -384,17 +214,45 @@ doAfterLoad(() => {
}, },
updateEmailForm, updateEmailForm,
() => { () => {
$("#emailCurrent").innerText = $("#updateEmailEmail").value;
$("#emailVerified").innerText = "no";
updateEmailForm.reset(); updateEmailForm.reset();
refreshUserData();
showSuccess( showSuccess(
$("#updateEmailForm .formValidationInfo"), $(".formValidationInfo", updateEmailForm),
"Email updated successfully! Check your inbox for the verification email." "Email address updated successfully! " +
"Check your inbox for the verification email. " +
"You will not receive notifications until you verify your email address."
); );
} }
); );
}); });
logoutHandler.addListener(() => {
updateEmailForm.reset();
clearMessages(updateEmailForm);
});
const resendEmailVerificationForm = $("#resendEmailVerificationForm");
resendEmailVerificationForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
postApi(
{action: "resend-verify-email", token: csrfToken},
resendEmailVerificationForm,
() => {
resendEmailVerificationForm.reset();
refreshUserData();
showSuccess(
$(".formValidationInfo", resendEmailVerificationForm),
"Email verification resent successfully!"
);
}
);
});
logoutHandler.addListener(() => {
resendEmailVerificationForm.reset();
clearMessages(resendEmailVerificationForm);
});
const updatePasswordForm = $("#updatePasswordForm");
updatePasswordForm.addEventListener("submit", (event: SubmitEvent) => { updatePasswordForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault(); event.preventDefault();
@ -406,15 +264,20 @@ doAfterLoad(() => {
password_new: $("#updatePasswordPasswordNew").value, password_new: $("#updatePasswordPasswordNew").value,
password_confirm: $("#updatePasswordPasswordConfirm").value, password_confirm: $("#updatePasswordPasswordConfirm").value,
}, },
updatePasswordForm, event.target as HTMLFormElement,
() => { () => {
$("#passwordLastChanged").innerText = "today";
updatePasswordForm.reset(); updatePasswordForm.reset();
showSuccess($("#updatePasswordForm .formValidationInfo"), "Password updated successfully!"); refreshUserData();
showSuccess($(".formValidationInfo", updatePasswordForm), "Password updated successfully!");
} }
); );
}); });
logoutHandler.addListener(() => {
updatePasswordForm.reset();
clearMessages(updatePasswordForm);
});
const addTrackingForm = $("#addTrackingForm");
addTrackingForm.addEventListener("submit", (event: SubmitEvent) => { addTrackingForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault(); event.preventDefault();
@ -431,21 +294,42 @@ doAfterLoad(() => {
} }
); );
}); });
logoutHandler.addListener(() => {
addTrackingForm.reset();
clearMessages(addTrackingForm);
});
});
// Run GET actions // Run initialization code
doAfterLoad(() => {
const get_params = new URLSearchParams(window.location.search); const get_params = new URLSearchParams(window.location.search);
if (get_params.get("action") === "verify-email" && get_params.has("email") && get_params.has("token")) { if (get_params.get("action") === "verify-email" && get_params.has("email") && get_params.has("token")) {
postApi( postApi(
{action: "verify-email", email: get_params.get("email"), token: get_params.get("token")}, {action: "verify-email", email: get_params.get("email"), token: get_params.get("token")},
sharedMessageElement, sharedMessageElement,
() => { () => {
showSuccess( let secondsUntilRedirect = 3;
sharedMessageElement, const updateMessage = () => {
"Your email address has been verified. You will be redirected after 3 seconds." showSuccess(
); sharedMessageElement,
`Your email address has been verified. ` +
`You will be redirected after ${secondsUntilRedirect} seconds.`
);
secondsUntilRedirect -= 1;
setTimeout(updateMessage, 1000);
};
updateMessage();
setTimeout(() => window.location.href = "//" + location.host + location.pathname, 3000); setTimeout(() => window.location.href = "//" + location.host + location.pathname, 3000);
},
() => {
$("#sharedHomeLink").classList.remove("hidden");
showError(
sharedMessageElement,
"Failed to verify email address. Maybe you already verified your email address?"
);
} }
// TODO: Also redirect on failure/error?
); );
return; return;
} }
@ -454,13 +338,7 @@ doAfterLoad(() => {
getApi( getApi(
{action: "start-session"}, {action: "start-session"},
sharedMessageElement, sharedMessageElement,
(response: ServerResponse) => { () => loginHandler.invokeListeners(),
if (response.payload === null) { () => logoutHandler.invokeListeners()
onLogout();
} else {
onLogin();
refreshUserData(response.payload);
}
}
); );
}); });

61
src/main/js/Message.ts Normal file
View File

@ -0,0 +1,61 @@
// @ts-ignore
const {$, $a} = window.fwdekker;
/**
* Clears all validation messages from the given form.
*
* @param form the form to clear all validation messages in
*/
export function clearMessages(form: HTMLFormElement) {
const formValidationInfo = $(".formValidationInfo", form);
if (formValidationInfo !== null) clearMessage(formValidationInfo);
$a("label", form).forEach(clearMessage);
}
/**
* Clears the validation message from the given element.
*
* @param element the element to clear the validation message from
*/
export function clearMessage(element: HTMLElement) {
element.classList.remove("success", "warning", "error");
$(".validationInfo", element).innerText = "";
}
/**
* Shows a success message at the given element.
*
* @param element the element to show the success message at
* @param message the success message to display
*/
export function showSuccess(element: HTMLElement, message?: string) {
element.classList.remove("warning", "error");
element.classList.add("success");
$(".validationInfo", element).innerText = message ?? "";
}
/**
* Shows a warning message at the given element.
*
* @param element the element to show the warning message at
* @param message the warning message to display
*/
export function showWarning(element: HTMLElement, message?: string) {
element.classList.remove("success", "error");
element.classList.add("warning");
$(".validationInfo", element).innerText = message ?? "";
}
/**
* Shows an error message at the given element.
*
* @param element the element to show the error message at
* @param message the error message to display
*/
export function showError(element: HTMLElement, message?: string) {
element.classList.remove("success", "warning");
element.classList.add("error");
$(".validationInfo", element).innerText = message ?? "";
}

View File

@ -129,11 +129,15 @@ class Mailer
$base_path = $this->config["server"]["base_path"]; $base_path = $this->config["server"]["base_path"];
$verify_path = "$base_path?action=verify-email&email=" . rawurlencode($email) . "&token=$token"; $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($email) . "&token=$token";
// TODO: What if user did not change email address?
// TODO: Separate "verify after changing email" and "resending verify email"
return [ return [
"Verify your email address", "Verify your email address",
"Your email address for the Death Notifier has been changed. "Your email address for Death Notifier has not been verified yet. " .
Please verify your email address by going to $verify_path. "Until you verify your email address, you will not receive any notifications. " .
Until you verify your email address, you will not receive any notifications." "You can verify your email address by clicking the link below." .
"\n\n" .
"Verify: $verify_path"
]; ];
} }

View File

@ -2,6 +2,7 @@
namespace php; namespace php;
use DateTime;
use PDO; use PDO;
@ -23,6 +24,10 @@ class UserManager
* The maximum length of a password. * The maximum length of a password.
*/ */
private const MAX_PASSWORD_LENGTH = 64; private const MAX_PASSWORD_LENGTH = 64;
/**
* The minimum number of minutes between two verification emails.
*/
private const MINUTES_BETWEEN_VERIFICATION_EMAILS = 5;
/** /**
* @var PDO The database connection to interact with. * @var PDO The database connection to interact with.
@ -51,6 +56,7 @@ class UserManager
$this->conn->exec("CREATE TABLE users(uuid text not null unique primary key default(lower(hex(randomblob(16)))), $this->conn->exec("CREATE TABLE users(uuid text not null unique primary key default(lower(hex(randomblob(16)))),
email text not null unique, email text not null unique,
email_verification_token text, email_verification_token text,
email_verification_token_timestamp int not null,
password text not null, password text not null,
password_update_time int not null);"); password_update_time int not null);");
} }
@ -106,10 +112,16 @@ class UserManager
} }
// Register user // Register user
$stmt = $this->conn->prepare("INSERT INTO users (email, email_verification_token, $stmt = $this->conn->prepare("INSERT INTO users (email,
password, password_update_time) email_verification_token,
VALUES (:email, lower(hex(randomblob(16))), email_verification_token_timestamp,
:password, unixepoch()) password,
password_update_time)
VALUES (:email,
lower(hex(randomblob(16))),
unixepoch(),
:password,
unixepoch())
RETURNING email_verification_token;"); RETURNING email_verification_token;");
$stmt->bindValue(":email", $email); $stmt->bindValue(":email", $email);
$stmt->bindValue(":password", password_hash($password, PASSWORD_DEFAULT)); $stmt->bindValue(":password", password_hash($password, PASSWORD_DEFAULT));
@ -147,40 +159,61 @@ class UserManager
* *
* @param string $email the email address of the user whose password should be checked * @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 * @param string $password the password to check against the specified user
* @return Response a response with user data if the login was successful, or a response with a message explaining * @return array{0: Response, 1: string|null} a response and the user's UUID
* what went wrong otherwise
*/ */
public function check_login(string $email, string $password): Response public function check_login(string $email, string $password): array
{ {
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > self::MAX_EMAIL_LENGTH) if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > self::MAX_EMAIL_LENGTH)
return new Response( return [
payload: ["target" => "email", "message" => "Invalid email address."], new Response(
satisfied: false payload: ["target" => "email", "message" => "Invalid email address."],
); satisfied: false
),
null
];
if (strlen($password) > self::MAX_PASSWORD_LENGTH) if (strlen($password) > self::MAX_PASSWORD_LENGTH)
return new Response( return [
payload: ["target" => "password", "message" => "Incorrect combination of email and password."], new Response(
satisfied: false payload: ["target" => "password", "message" => "Incorrect combination of email and password."],
); satisfied: false
),
null
];
$stmt = $this->conn->prepare("SELECT uuid, email, email_verification_token IS NULL AS email_is_verified, $stmt = $this->conn->prepare("SELECT uuid, password FROM users WHERE email=:email;");
password, password_update_time
FROM users
WHERE email=:email;");
$stmt->bindValue(":email", $email); $stmt->bindValue(":email", $email);
$stmt->execute(); $stmt->execute();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC); $results = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (sizeof($results) === 0 || !password_verify($password, $results[0]["password"])) { if (sizeof($results) === 0 || !password_verify($password, $results[0]["password"])) {
return new Response( return [
payload: ["target" => "password", "message" => "Incorrect combination of email and password."], new Response(
satisfied: false payload: ["target" => "password", "message" => "Incorrect combination of email and password."],
); satisfied: false
),
null
];
} }
$user = $results[0]; return [new Response(payload: null, satisfied: true), $results[0]["uuid"]];
unset($user["password"]); }
return new Response(payload: $user, satisfied: true);
/**
* Returns a satisfied response if a user with the given UUID exists, or an unsatisfied response otherwise.
*
* @param string $uuid the UUID of the user to check
* @return Response a satisfied response if a user with the given UUID exists, or an unsatisfied response otherwise
*/
public function user_exists(string $uuid): Response
{
$stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE uuid=:uuid);");
$stmt->bindValue(":uuid", $uuid);
$stmt->execute();
$result = $stmt->fetch();
return $result[0] === 1
? new Response(payload: null, satisfied: true)
: new Response(payload: ["target" => null, "message" => null], satisfied: false);
} }
/** /**
@ -239,6 +272,8 @@ class UserManager
satisfied: false satisfied: false
); );
// TODO: Reject update if email address was changed too recently (within, say, 5 minutes)
// Check if email address is already in use // Check if email address is already in use
$stmt = $this->conn->prepare("SELECT COUNT(*) AS count $stmt = $this->conn->prepare("SELECT COUNT(*) AS count
FROM users FROM users
@ -255,7 +290,9 @@ class UserManager
// Update email address // Update email address
$stmt = $this->conn->prepare("UPDATE users $stmt = $this->conn->prepare("UPDATE users
SET email=:email, email_verification_token=lower(hex(randomblob(16))) SET email=:email,
email_verification_token=lower(hex(randomblob(16))),
email_verification_token_timestamp=unixepoch()
WHERE uuid=:uuid WHERE uuid=:uuid
RETURNING email_verification_token;"); RETURNING email_verification_token;");
$stmt->bindValue(":uuid", $uuid); $stmt->bindValue(":uuid", $uuid);
@ -293,6 +330,7 @@ class UserManager
$stmt->execute(); $stmt->execute();
$result = $stmt->fetch(PDO::FETCH_ASSOC); $result = $stmt->fetch(PDO::FETCH_ASSOC);
// TODO: Reformat the following line
if ($result === false || if ($result === false ||
($result["email_verification_token"] !== null ($result["email_verification_token"] !== null
&& !hash_equals($result["email_verification_token"], $email_verification_token))) { && !hash_equals($result["email_verification_token"], $email_verification_token))) {
@ -319,6 +357,61 @@ class UserManager
return new Response(payload: null, satisfied: true); return new Response(payload: null, satisfied: true);
} }
/**
* Resends the email verification email.
*
* @param string $uuid the UUID of the user to resend the email for
* @param Mailer $mailer the mailer to send the email with to the user
* @return Response a response with message `null` if the email was sent, or a response with a message explaining
* what went wrong otherwise
*/
public function resend_verify_email(string $uuid, Mailer $mailer): Response
{
$this->conn->beginTransaction();
$stmt = $this->conn->prepare("SELECT email, email_verification_token, email_verification_token_timestamp
FROM users
WHERE uuid=:uuid;");
$stmt->bindValue(":uuid", $uuid);
$stmt->execute();
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!isset($user["email_verification_token"])) {
$this->conn->rollBack();
return new Response(
payload: ["target" => null, "message" => "Your email address is already verified"],
satisfied: false
);
}
$verify_email_time = new DateTime("@{$user["email_verification_token_timestamp"]}");
$minutes_since_last_verify_email = $verify_email_time->diff(new DateTime(), absolute: true)->i;
if ($minutes_since_last_verify_email < self::MINUTES_BETWEEN_VERIFICATION_EMAILS) {
$this->conn->rollBack();
$minutes_left = self::MINUTES_BETWEEN_VERIFICATION_EMAILS - $minutes_since_last_verify_email;
return new Response(
payload: [
"target" => null,
"message" =>
"A verification email was sent recently. " .
"Please wait $minutes_left more minute(s) before requesting a new email."
],
satisfied: false
);
}
$mailer->queue_verification($user["email"], $user["email_verification_token"]);
$stmt = $this->conn->prepare("UPDATE users
SET email_verification_token_timestamp=unixepoch()
WHERE uuid=:uuid;");
$stmt->bindValue(":uuid", $uuid);
$stmt->execute();
$this->conn->commit();
return new Response(payload: null, satisfied: true);
}
/** /**
* Updates the indicated user's password. * Updates the indicated user's password.
* *