death-notifier/src/main/js/Main.ts

660 lines
23 KiB
TypeScript

// @ts-ignore
const {$, $a, doAfterLoad} = window.fwdekker;
const {
clearFormValidity, clearMessageStatus, showMessageInfo, showMessageError, showMessageSuccess, showMessageWarning
// @ts-ignore
} = window.fwdekker.validation;
import {csrfToken, emptyFunction, getApi, postApi, ServerResponse, sharedMessageElement} from "./API";
import {CustomEventHandler} from "./CustomEventHandler";
/**
* Handles listeners to be invoked when the user logs in.
*/
const loginHandler = new CustomEventHandler();
/**
* Handles listeners to be invoked when the user logs out.
*/
const logoutHandler = new CustomEventHandler();
/**
* Returns a placeholder row to be placed in the trackings table.
*
* @param text the text to show in the placeholder row
* @returns a placeholder row to be placed in the trackings table
*/
function createPlaceholderRow(text: string): HTMLTableRowElement {
const row = document.createElement("tr");
row.classList.add("placeholder");
const cell = document.createElement("td");
cell.colSpan = 3;
cell.innerText = text;
row.appendChild(cell);
// Is a button to give exact same height as non-placeholder rows
const spacerCell = document.createElement("td");
const spacer = document.createElement("button");
spacer.ariaHidden = "true";
spacer.tabIndex = -1;
spacer.classList.add("invisible");
spacer.innerText = " ";
spacerCell.appendChild(spacer);
row.appendChild(spacerCell);
return row;
}
/**
* Refreshes the list of trackings in the table.
*/
function refreshTrackings(): void {
getApi(
{action: "list-trackings", token: csrfToken ?? ""},
sharedMessageElement,
(response) => {
// Remove old rows
$a("#trackings tbody tr").forEach((it: HTMLTableRowElement) => it.remove());
// Insert new rows
const tableBody = $("#trackings tbody");
response.payload.forEach((tracking: any) => {
const row = document.createElement("tr");
const nameCell = document.createElement("td");
const nameLink = document.createElement("a");
nameLink.href = "https://en.wikipedia.org/wiki/" + tracking.name;
nameLink.innerText = tracking.name;
if (tracking.deleted)
nameLink.classList.add("red-link");
nameCell.append(nameLink);
row.append(nameCell);
const statusCell = document.createElement("td");
let statusText;
if (tracking.deleted) {
statusText = "article not found";
} else {
switch (window.btoa(tracking.name)) {
case "QWRvbGYgSGl0bGVy":
statusText = "dead 🥳";
break;
case "VmxhZGltaXIgUHV0aW4=":
statusText = tracking.status === "alive" ? "alive ☹️" : "dead 🥳";
break;
default:
statusText = tracking.status;
break;
}
}
statusCell.innerText = statusText;
row.append(statusCell);
const sinceCell = document.createElement("td");
sinceCell.innerText = (new Date(tracking.since * 1000)).toLocaleDateString();
row.append(sinceCell);
const deleteCell = document.createElement("td");
const deleteForm = document.createElement("form");
deleteForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
if (!window.confirm(`Are you sure you want to stop tracking ${tracking.name}?`)) return;
postApi(
{action: "remove-tracking", token: csrfToken, person_name: tracking.name},
deleteForm,
() => {
refreshTrackings();
showMessageSuccess(
$("#remove-trackings-status-card"),
`Successfully removed <b>${tracking.name}</b>.`
);
}
);
});
const deleteButton = document.createElement("button");
deleteButton.innerText = "remove";
deleteButton.classList.add("outline");
deleteForm.append(deleteButton);
deleteCell.append(deleteForm);
row.append(deleteCell);
tableBody.appendChild(row);
});
if (response.payload.length === 0)
tableBody.appendChild(createPlaceholderRow("You haven't added any articles to track yet."));
// Scroll to top, re-apply filter
$("#trackings-wrapper").scrollTop = 0;
$("#filter-trackings-query").dispatchEvent(new InputEvent("input"));
}
);
}
/**
* Refreshes displays of the user's data.
*/
function refreshUserData(): void {
getApi(
{action: "get-user-data", token: csrfToken ?? ""},
sharedMessageElement,
(response) => {
const userData = response.payload;
// Account deletion
$("#delete-email").value = userData.email;
// Email
$("#update-email-email").value = userData.email;
$("#email-verified-checkbox").checked = userData.email_verified;
if (!userData.email_verified)
showMessageWarning(
sharedMessageElement,
"You will not receive any email notifications until you verify your email address. " +
"Check your inbox for further instructions."
);
else
clearMessageStatus(sharedMessageElement);
$("#resend-email-verification-submit").classList.toggle("hidden", userData.email_verified);
// Notifications
const notificationsCheckbox = $("#notifications-enabled-checkbox");
notificationsCheckbox.disabled = !userData.email_verified;
notificationsCheckbox.checked = userData.email_verified && userData.email_notifications_enabled;
// Password update time
const today = new Date();
today.setHours(0, 0, 0, 0);
const updateTime = new Date(userData.password_last_change * 1000);
updateTime.setHours(0, 0, 0, 0);
const diff = (+today - +updateTime) / 86400000;
$("#password-last-changed").innerText =
diff === 0
? "today"
: (diff === 1 ? "1 day ago" : diff + " days ago");
}
);
}
/**
* Handles the action specified in the URL's get parameters.
*
* @param params the URL's get parameters
*/
function handleAction(params: URLSearchParams): void {
const sharedHomeLink = $("#shared-home-link");
let params_are_valid = true;
switch (params.get("action")) {
case "verify-email":
if (!params.has("email") || !params.has("token")) {
params_are_valid = false;
break;
}
postApi(
{
action: "verify-email",
token: csrfToken,
email: params.get("email"),
verify_token: params.get("token"),
},
sharedMessageElement,
() => {
sharedHomeLink.classList.remove("hidden");
redirectWithTimeout(
"./", 3, (secondsLeft) => {
showMessageSuccess(
sharedMessageElement,
`Your email address has been verified. ` +
`You will be redirected after ${secondsLeft} second(s).`
);
});
},
() => sharedHomeLink.classList.remove("hidden")
);
break;
case "reset-password":
if (!params.has("email") || !params.has("token")) {
params_are_valid = false;
break;
}
getApi(
{
action: "validate-password-reset-token",
token: csrfToken ?? "",
email: params.get("email") ?? "",
reset_token: params.get("token") ?? "",
},
sharedMessageElement,
() => {
$("#reset-password-token").value = params.get("token");
$("#reset-password-email").value = params.get("email");
$("#reset-password-part").classList.remove("hidden");
$("#reset-password-password").focus();
},
() => sharedHomeLink.classList.remove("hidden")
);
break;
case null:
break;
default:
params_are_valid = false;
break;
}
if (!params_are_valid) {
sharedHomeLink.classList.remove("hidden");
showMessageError(sharedMessageElement, `Invalid URL.`);
}
}
/**
* Redirects the user to `target` after `seconds` seconds, calling `doEachSecond` after every second.
*
* @param target the location to redirect the user to after the timeout
* @param seconds the number of seconds before redirecting the user
* @param doEachSecond the function to invoke each second; the only argument is the number of seconds left at that time
*/
function redirectWithTimeout(target: string, seconds: number, doEachSecond: (secondsLeft: number) => void): void {
let secondsLeft = seconds;
const update = () => {
doEachSecond(secondsLeft);
secondsLeft--;
setTimeout(update, 1000);
};
update();
setTimeout(() => window.location.href = target, seconds * 1000);
}
// Register event handlers
doAfterLoad(() => {
// Switch between logged-out and logged-in views
loginHandler.addListener(() => {
clearMessageStatus(sharedMessageElement);
refreshUserData();
refreshTrackings();
$("#welcome-part").classList.add("hidden");
$("#tracking-part").classList.remove("hidden");
$("#settings-part").classList.remove("hidden");
});
logoutHandler.addListener(() => {
clearMessageStatus(sharedMessageElement);
$("#welcome-part").classList.remove("hidden");
$("#tracking-part").classList.add("hidden");
$("#settings-part").classList.add("hidden");
$("#login-email").focus();
});
// Password visibility toggling
$a(".password-toggle").forEach((toggle: HTMLElement) => {
const passwordField = $(`#${toggle.dataset.toggles}`);
const setState = (showPassword: boolean) => passwordField.type = showPassword ? "text" : "password";
passwordField.form.addEventListener("reset", () => setState(false));
toggle.addEventListener("click", () => setState(passwordField.type === "password"));
});
// Login
const loginForm = $("#login-form");
loginForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
postApi(
{
action: "login",
token: csrfToken,
email: $("#login-email").value,
password: $("#login-password").value,
},
event.target as HTMLFormElement,
() => loginHandler.invokeListeners()
);
});
loginHandler.addListener(() => {
loginForm.reset();
clearMessageStatus(loginForm);
});
const registerForm = $("#register-form");
registerForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
postApi(
{
action: "register",
token: csrfToken,
email: $("#register-email").value,
password: $("#register-password").value
},
registerForm,
() => {
// TODO: Add client-side form validation
registerForm.reset();
showMessageSuccess(registerForm, "Account created successfully! You may now log in.");
$("#login-email").focus();
}
);
});
loginHandler.addListener(() => {
registerForm.reset();
clearFormValidity(registerForm);
});
const logoutForm = $("#logout-form");
logoutForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
postApi(
{
action: "logout",
token: csrfToken,
},
logoutForm,
() => {
logoutForm.reset();
logoutHandler.invokeListeners();
}
);
});
// Forgot password
const sendPasswordResetForm = $("#send-password-reset-form");
sendPasswordResetForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
postApi(
{
action: "send-password-reset",
token: csrfToken,
email: $("#send-password-reset-email").value,
},
sendPasswordResetForm,
() => {
sendPasswordResetForm.reset();
showMessageSuccess(sendPasswordResetForm, "Password reset email sent successfully!");
}
);
});
loginHandler.addListener(() => {
sendPasswordResetForm.reset();
clearFormValidity(sendPasswordResetForm);
});
const resetPasswordForm = $("#reset-password-form");
resetPasswordForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
postApi(
{
action: "reset-password",
token: csrfToken,
email: $("#reset-password-email").value,
reset_token: $("#reset-password-token").value,
password: $("#reset-password-password").value,
},
resetPasswordForm,
() => {
resetPasswordForm.reset();
redirectWithTimeout(
"./", 3, (secondsLeft) => {
showMessageSuccess(
resetPasswordForm,
`Your password has been updated. You will be redirected after ${secondsLeft} second(s).`
);
}
);
}
);
});
$("#forgot-password-go-to").addEventListener("click", (event: MouseEvent) => {
event.preventDefault();
$("#welcome-part").classList.add("hidden");
$("#send-forgot-password-part").classList.remove("hidden");
const resetEmail = $("#send-password-reset-email");
resetEmail.value = $("#login-email").value;
resetEmail.focus();
});
$("#forgot-password-go-back").addEventListener("click", (event: MouseEvent) => {
event.preventDefault();
$("#send-password-reset-form").reset();
$("#send-forgot-password-part").classList.add("hidden");
$("#welcome-part").classList.remove("hidden");
const loginEmail = $("#login-email");
loginEmail.value = $("#send-password-reset-email").value;
loginEmail.focus();
});
// Account management
const deleteForm = $("#delete-form");
deleteForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
const actual_email = $("#delete-email").value;
const entered_email = window.prompt(
`Are you sure you want to delete your account? ` +
`This action cannot be undone. ` +
`Enter ${actual_email} to confirm.`
);
if (entered_email === null) {
return;
} else if (entered_email !== actual_email) {
showMessageError(deleteForm, "Incorrect email address.");
return;
}
postApi(
{action: "user-delete", token: csrfToken},
deleteForm,
() => {
logoutHandler.invokeListeners();
showMessageSuccess(sharedMessageElement, "Your account has been deleted.");
}
);
});
logoutHandler.addListener(() => clearFormValidity(deleteForm));
const resendEmailVerificationForm = $("#resend-email-verification-form");
resendEmailVerificationForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
postApi(
{action: "resend-verify-email", token: csrfToken},
resendEmailVerificationForm,
() => {
refreshUserData();
showMessageSuccess(resendEmailVerificationForm, "Email verification resent successfully!");
}
);
});
logoutHandler.addListener(() => clearFormValidity(resendEmailVerificationForm));
const toggleNotificationsForm = $("#toggle-notifications-form");
const notificationsEnabledCheckbox = $("#notifications-enabled-checkbox");
notificationsEnabledCheckbox.addEventListener("change", (event: Event) => {
event.preventDefault();
const enableNotifications = notificationsEnabledCheckbox.checked;
postApi(
{
action: "toggle-notifications",
enable_notifications: enableNotifications,
token: csrfToken
},
toggleNotificationsForm,
() => {
refreshUserData();
if (enableNotifications)
showMessageSuccess(toggleNotificationsForm, "Notifications have been enabled.");
else
showMessageSuccess(
toggleNotificationsForm,
"Notifications have been disabled. " +
"You will still receive security notifications, for example if you change your email address " +
"or password.");
}
);
});
logoutHandler.addListener(() => clearFormValidity(toggleNotificationsForm));
const updateEmailForm = $("#update-email-form");
updateEmailForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
postApi(
{
action: "update-email",
token: csrfToken,
email: $("#update-email-email").value,
},
updateEmailForm,
() => {
updateEmailForm.reset();
refreshUserData();
showMessageSuccess(
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();
clearFormValidity(updateEmailForm);
});
const updatePasswordForm = $("#update-password-form");
updatePasswordForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
postApi(
{
action: "update-password",
token: csrfToken,
password_old: $("#update-password-password-old").value,
password_new: $("#update-password-password-new").value,
},
event.target as HTMLFormElement,
() => {
updatePasswordForm.reset();
refreshUserData();
showMessageSuccess(updatePasswordForm, "Password updated successfully!");
}
);
});
logoutHandler.addListener(() => {
updatePasswordForm.reset();
clearFormValidity(updatePasswordForm);
});
// Tracking management
const queryInput = $("#filter-trackings-query");
$("#filter-trackings-form").addEventListener("submit", (event: SubmitEvent) => event.preventDefault());
queryInput.addEventListener("input", (event: InputEvent) => {
event.preventDefault();
$("#trackings-no-matches")?.remove();
const queryWords = queryInput.value.trim().toLowerCase().split(" ");
let foundMatches = false;
$a("#trackings tbody tr").forEach((row: HTMLTableRowElement) => {
const rowText = row.innerText.toLowerCase();
const matches = row.classList.contains("placeholder")
? (queryWords.length === 1 && queryWords[0] === "")
: queryWords.every((word: string) => rowText.includes(word));
foundMatches = foundMatches || matches;
row.classList.toggle("hidden", !matches);
});
if (!foundMatches) {
const row = createPlaceholderRow("No articles match your query.");
row.id = "trackings-no-matches";
$("#trackings tbody").appendChild(row);
}
});
const addTrackingForm = $("#add-tracking-form");
addTrackingForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
const inputName = $("#add-tracking-name").value;
postApi(
{
action: "add-tracking",
token: csrfToken,
person_name: inputName,
},
addTrackingForm,
(response: ServerResponse) => {
addTrackingForm.reset();
refreshTrackings();
showMessageSuccess(
addTrackingForm,
inputName.toLowerCase() !== response.payload["normalized_name"].toLowerCase()
? (
`Successfully added <b>${inputName}</b> as ` +
`<b>${response.payload["normalized_name"]}</b>!`
)
: `Successfully added <b>${response.payload["normalized_name"]}</b>!`
);
}
);
});
logoutHandler.addListener(() => {
addTrackingForm.reset();
clearFormValidity(addTrackingForm);
});
});
// Run initialization code
doAfterLoad(() => {
const params = new URLSearchParams(window.location.search);
getApi(
{action: "start-session"},
sharedMessageElement,
(response: ServerResponse) => {
if (!params.has("action")) {
if (response.payload["logged_in"] === true)
loginHandler.invokeListeners();
else
logoutHandler.invokeListeners();
}
},
emptyFunction,
emptyFunction,
(response) => {
// Always execute the following
$("main").classList.remove("hidden");
const message = (response?.payload ?? {})["global_message"];
if (message != null)
showMessageInfo($("#global-message"), message);
handleAction(params);
}
);
});