660 lines
23 KiB
TypeScript
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);
|
|
}
|
|
);
|
|
});
|