Implement re-sending verification email
This commit is contained in:
parent
5ae5dc2c83
commit
37db33b2f5
|
@ -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",
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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 ?? "";
|
||||
}
|
|
@ -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"
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
Loading…
Reference in New Issue