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",
"description": "Get notified when a famous person dies.",
"version": "0.0.22",
"version": "0.0.23",
"type": "project",
"license": "MIT",
"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",
"version": "0.0.22",
"version": "0.0.23",
"description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",

View File

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

View File

@ -1,6 +1,13 @@
:root {
--error-color: red;
--success-color: green;
/* Colors taken from https://isabelcastillo.com/error-info-messages-css */
--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;
}
@ -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 */
.validationInfo {
display: block;
@ -36,49 +60,67 @@ input + .validationInfo {
margin-bottom: 1.5rem;
}
.valid {
.success {
color: var(--success-color);
}
.valid > input, .valid > input:focus {
.success > input, .success > input:focus {
color: var(--success-color);
border-color: var(--success-color);
}
.valid > .validationInfo {
.success > .validationInfo {
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);
}
.invalid > input, .invalid > input:focus {
.error > input, .error > input:focus {
color: var(--error-color);
border-color: var(--error-color);
}
.invalid > .validationInfo {
.error > .validationInfo {
color: var(--error-color);
}
.formValidationInfo:not(.valid):not(.invalid) {
margin: 0;
padding: 0;
}
.formValidationInfo.valid, .formValidationInfo.invalid {
.formValidationInfo {
padding: 1rem;
}
.formValidationInfo.valid {
color: green;
border: 1px solid green;
background-color: palegreen;
.formValidationInfo:not(.success):not(.warning):not(.error) {
display: none;
}
.formValidationInfo.invalid {
color: red;
border: 1px solid red;
background-color: lavenderblush;
.formValidationInfo.success {
color: var(--success-color);
border: 1px solid var(--success-color);
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">
<span class="validationInfo"></span>
</p>
<p id="sharedHomeLink" class="hidden">
<a href="./">Click here to return to the main page</a>
</p>
</div>
</div>
@ -142,11 +145,17 @@
</div>
<div class="column">
<h3>Change email</h3>
<p>
<b>Current email:</b> <span id="emailCurrent">ERROR</span><br />
<!-- TODO: Allow resending email verification -->
<b>Verified:</b> <span id="emailVerified">ERROR</span>
</p>
<form id="resendEmailVerificationForm" novalidate>
<p class="formValidationInfo">
<span class="validationInfo"></span>
</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>
<p class="formValidationInfo">
<span class="validationInfo"></span>
@ -161,7 +170,9 @@
</div>
<div class="column">
<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>
<p class="formValidationInfo">
<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
const {$, $a, doAfterLoad, footer, header, nav} = window.fwdekker;
/**
* The CSRF token to be used for the next API request.
*/
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 = () => {
};
import {csrfToken, getApi, postApi, sharedMessageElement} from "./API";
import {CustomEventHandler} from "./CustomEventHandler";
import {clearMessage, clearMessages, showError, showSuccess, showWarning} from "./Message";
/**
* 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 = {
/**
* 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;
};
const loginHandler = new CustomEventHandler();
/**
* Clears all validation messages from the given form.
*
* @param form the form to clear all validation messages in
* Handles listeners to be invoked when the user logs out.
*/
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
*/
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);
});
}
const logoutHandler = new CustomEventHandler();
/**
@ -241,21 +74,35 @@ function refreshTrackings(): void {
/**
* 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;
$("#emailVerified").innerText = userData.email_is_verified ? "yes" : "no";
function refreshUserData(): void {
getApi(
{action: "get-user-data"},
sharedMessageElement,
(response) => {
const userData = response.payload;
// 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";
// Email
$("#emailCurrent").innerText = userData.email;
$("#emailVerified").innerText = userData.email_is_verified ? "yes" : "no";
if (!userData.email_is_verified) {
showWarning(
sharedMessageElement,
"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");
});
// Event handlers and so on
// Register event handlers
doAfterLoad(() => {
// Find rows
const loginRow = $("#loginRow");
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");
loginHandler.addListener(() => {
refreshUserData();
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);
}
$("#loginRow").classList.add("hidden")
$("#trackingRow").classList.remove("hidden");
$("#accountRow").classList.remove("hidden");
});
logoutHandler.addListener(() => {
$("#loginRow").classList.remove("hidden")
$("#trackingRow").classList.add("hidden");
$("#accountRow").classList.add("hidden");
});
// Add event handlers
const loginForm = $("#loginForm");
loginForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
@ -329,14 +148,16 @@ doAfterLoad(() => {
email: $("#loginEmail").value,
password: $("#loginPassword").value,
},
loginForm,
(response) => {
onLogin();
refreshUserData(response.payload);
}
event.target as HTMLFormElement,
() => loginHandler.invokeListeners()
);
});
loginHandler.addListener(() => {
loginForm.reset();
clearMessages(loginForm);
});
const registerForm = $("#registerForm");
registerForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
@ -353,13 +174,18 @@ doAfterLoad(() => {
// TODO: Add client-side form validation
registerForm.reset();
showSuccess(
$("#registerForm .formValidationInfo"),
$(".formValidationInfo", registerForm),
"Account created successfully! You may now log in."
);
}
);
});
loginHandler.addListener(() => {
registerForm.reset();
clearMessages(registerForm);
});
const logoutForm = $("#logoutForm");
logoutForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
@ -369,10 +195,14 @@ doAfterLoad(() => {
token: csrfToken,
},
logoutForm,
() => onLogout()
() => {
logoutForm.reset();
logoutHandler.invokeListeners();
}
);
});
const updateEmailForm = $("#updateEmailForm");
updateEmailForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
@ -384,17 +214,45 @@ doAfterLoad(() => {
},
updateEmailForm,
() => {
$("#emailCurrent").innerText = $("#updateEmailEmail").value;
$("#emailVerified").innerText = "no";
updateEmailForm.reset();
refreshUserData();
showSuccess(
$("#updateEmailForm .formValidationInfo"),
"Email updated successfully! Check your inbox for the verification email."
$(".formValidationInfo", updateEmailForm),
"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) => {
event.preventDefault();
@ -406,15 +264,20 @@ doAfterLoad(() => {
password_new: $("#updatePasswordPasswordNew").value,
password_confirm: $("#updatePasswordPasswordConfirm").value,
},
updatePasswordForm,
event.target as HTMLFormElement,
() => {
$("#passwordLastChanged").innerText = "today";
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) => {
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);
if (get_params.get("action") === "verify-email" && get_params.has("email") && get_params.has("token")) {
postApi(
{action: "verify-email", email: get_params.get("email"), token: get_params.get("token")},
sharedMessageElement,
() => {
showSuccess(
sharedMessageElement,
"Your email address has been verified. You will be redirected after 3 seconds."
);
let secondsUntilRedirect = 3;
const updateMessage = () => {
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);
},
() => {
$("#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;
}
@ -454,13 +338,7 @@ doAfterLoad(() => {
getApi(
{action: "start-session"},
sharedMessageElement,
(response: ServerResponse) => {
if (response.payload === null) {
onLogout();
} else {
onLogin();
refreshUserData(response.payload);
}
}
() => loginHandler.invokeListeners(),
() => logoutHandler.invokeListeners()
);
});

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"];
$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 [
"Verify your email address",
"Your email address for the Death Notifier has been changed.
Please verify your email address by going to $verify_path.
Until you verify your email address, you will not receive any notifications."
"Your email address for Death Notifier has not been verified yet. " .
"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;
use DateTime;
use PDO;
@ -23,6 +24,10 @@ class UserManager
* The maximum length of a password.
*/
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.
@ -51,6 +56,7 @@ class UserManager
$this->conn->exec("CREATE TABLE users(uuid text not null unique primary key default(lower(hex(randomblob(16)))),
email text not null unique,
email_verification_token text,
email_verification_token_timestamp int not null,
password text not null,
password_update_time int not null);");
}
@ -106,10 +112,16 @@ class UserManager
}
// Register user
$stmt = $this->conn->prepare("INSERT INTO users (email, email_verification_token,
password, password_update_time)
VALUES (:email, lower(hex(randomblob(16))),
:password, unixepoch())
$stmt = $this->conn->prepare("INSERT INTO users (email,
email_verification_token,
email_verification_token_timestamp,
password,
password_update_time)
VALUES (:email,
lower(hex(randomblob(16))),
unixepoch(),
:password,
unixepoch())
RETURNING email_verification_token;");
$stmt->bindValue(":email", $email);
$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 $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
* what went wrong otherwise
* @return array{0: Response, 1: string|null} a response and the user's UUID
*/
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)
return new Response(
payload: ["target" => "email", "message" => "Invalid email address."],
satisfied: false
);
return [
new Response(
payload: ["target" => "email", "message" => "Invalid email address."],
satisfied: false
),
null
];
if (strlen($password) > self::MAX_PASSWORD_LENGTH)
return new Response(
payload: ["target" => "password", "message" => "Incorrect combination of email and password."],
satisfied: false
);
return [
new Response(
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,
password, password_update_time
FROM users
WHERE email=:email;");
$stmt = $this->conn->prepare("SELECT uuid, password FROM users WHERE email=:email;");
$stmt->bindValue(":email", $email);
$stmt->execute();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
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
);
return [
new Response(
payload: ["target" => "password", "message" => "Incorrect combination of email and password."],
satisfied: false
),
null
];
}
$user = $results[0];
unset($user["password"]);
return new Response(payload: $user, satisfied: true);
return [new Response(payload: null, satisfied: true), $results[0]["uuid"]];
}
/**
* 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
);
// TODO: Reject update if email address was changed too recently (within, say, 5 minutes)
// Check if email address is already in use
$stmt = $this->conn->prepare("SELECT COUNT(*) AS count
FROM users
@ -255,7 +290,9 @@ class UserManager
// Update email address
$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
RETURNING email_verification_token;");
$stmt->bindValue(":uuid", $uuid);
@ -293,6 +330,7 @@ class UserManager
$stmt->execute();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
// TODO: Reformat the following line
if ($result === false ||
($result["email_verification_token"] !== null
&& !hash_equals($result["email_verification_token"], $email_verification_token))) {
@ -319,6 +357,61 @@ class UserManager
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.
*