Files
jfa-go/ts/modules/accounts.ts
Harvey Tindall ee96bb9f1b tabs: add clearURL method, loading tabs clears previous qps
Navigatable has clearURL, which for Search clears "search" qp, and
invites clears "invite" qp. Tab interfaces optionally include
"contentObject: AsTab", and show/hide funcs are passed the contentObject
of the previously loaded tab if one is available, so that they can call
it's clearURL method. This means searches you typed for the accounts tab
won't pop up when switching to activity.
2026-01-05 10:39:20 +00:00

3076 lines
126 KiB
TypeScript

import {
_get,
_post,
_delete,
toggleLoader,
addLoader,
removeLoader,
toDateString,
insertText,
toClipboard,
} from "../modules/common";
import { templateEmail } from "../modules/settings";
import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd";
import { DiscordUser, newDiscordSearch } from "../modules/discord";
import { SearchConfiguration, QueryType, SearchableItem, SearchableItemDataAttribute } from "../modules/search";
import { HiddenInputField, RadioBasedTabSelector } from "./ui";
import { PaginatedList } from "./list";
import { TableRow } from "./row";
declare var window: GlobalWindow;
const USER_DEFAULT_SORT_FIELD = "name";
const USER_DEFAULT_SORT_ASCENDING = true;
const dateParser = require("any-date-parser");
enum SelectAllState {
None = 0,
Some = 0.1,
AllVisible = 0.9,
All = 1,
}
interface UserDTO {
id: string;
name: string;
email: string | undefined;
notify_email: boolean;
last_active: number;
admin: boolean;
disabled: boolean;
expiry: number;
telegram: string;
notify_telegram: boolean;
discord: string;
notify_discord: boolean;
discord_id: string;
matrix: string;
notify_matrix: boolean;
label: string;
accounts_admin: boolean;
referrals_enabled: boolean;
}
interface getPinResponse {
token: string;
username: string;
}
interface announcementTemplate {
name: string;
subject: string;
message: string;
}
var addDiscord: (passData: string) => void;
const queries = (): { [field: string]: QueryType } => {
return {
id: {
// We don't use a translation here to circumvent the name substitution feature.
name: "Jellyfin/Emby ID",
getter: "id",
bool: false,
string: true,
date: false,
},
label: {
name: window.lang.strings("label"),
getter: "label",
bool: true,
string: true,
date: false,
},
username: {
name: window.lang.strings("username"),
getter: "name",
bool: false,
string: true,
date: false,
},
name: {
name: window.lang.strings("username"),
getter: "name",
bool: false,
string: true,
date: false,
show: false,
},
admin: {
name: window.lang.strings("admin"),
getter: "admin",
bool: true,
string: false,
date: false,
},
disabled: {
name: window.lang.strings("disabled"),
getter: "disabled",
bool: true,
string: false,
date: false,
},
"access-jfa": {
name: window.lang.strings("accessJFA"),
getter: "accounts_admin",
bool: true,
string: false,
date: false,
dependsOnElement: ".accounts-header-access-jfa",
},
email: {
name: window.lang.strings("emailAddress"),
getter: "email",
bool: true,
string: true,
date: false,
dependsOnElement: ".accounts-header-email",
},
telegram: {
name: "Telegram",
getter: "telegram",
bool: true,
string: true,
date: false,
dependsOnElement: ".accounts-header-telegram",
},
matrix: {
name: "Matrix",
getter: "matrix",
bool: true,
string: true,
date: false,
dependsOnElement: ".accounts-header-matrix",
},
discord: {
name: "Discord",
getter: "discord",
bool: true,
string: true,
date: false,
dependsOnElement: ".accounts-header-discord",
},
expiry: {
name: window.lang.strings("expiry"),
getter: "expiry",
bool: true,
string: false,
date: true,
dependsOnElement: ".accounts-header-expiry",
},
"last-active": {
name: window.lang.strings("lastActiveTime"),
getter: "last_active",
bool: true,
string: false,
date: true,
},
"referrals-enabled": {
name: window.lang.strings("referrals"),
getter: "referrals_enabled",
bool: true,
string: false,
date: false,
dependsOnElement: ".accounts-header-referrals",
},
};
};
class User extends TableRow implements UserDTO, SearchableItem {
private _id = "";
private _check: HTMLInputElement;
private _username: HTMLSpanElement;
private _admin: HTMLSpanElement;
private _disabled: HTMLSpanElement;
private _email: HTMLInputElement;
private _emailEditor: HiddenInputField;
private _notifyEmail: boolean;
private _emailAddress: string;
private _telegram: HTMLTableDataCellElement;
private _telegramUsername: string;
private _notifyTelegram: boolean;
private _discord: HTMLTableDataCellElement;
private _discordUsername: string;
private _discordID: string;
private _notifyDiscord: boolean;
private _matrix: HTMLTableDataCellElement;
private _matrixID: string;
private _notifyMatrix: boolean;
private _expiry: HTMLTableDataCellElement;
private _expiryUnix: number;
private _lastActive: HTMLTableDataCellElement;
private _lastActiveUnix: number;
private _notifyDropdown: HTMLDivElement;
private _label: HTMLInputElement;
private _labelEditor: HiddenInputField;
private _userLabel: string;
private _accounts_admin: HTMLInputElement;
private _selected: boolean;
private _referralsEnabled: boolean;
private _referralsEnabledCheck: HTMLElement;
notInList: boolean = false;
focus = () => this._row.scrollIntoView({ behavior: "smooth", block: "center" });
lastNotifyMethod = (): string => {
// Telegram, Matrix, Discord
const telegram = window.telegramEnabled && this._telegramUsername && this._telegramUsername != "";
const discord = window.discordEnabled && this._discordUsername && this._discordUsername != "";
const matrix = window.matrixEnabled && this._matrixID && this._matrixID != "";
const email = window.emailEnabled && this.email != "";
if (discord) return "discord";
if (matrix) return "matrix";
if (telegram) return "telegram";
if (email) return "email";
};
private _checkUnlinkArea = () => {
const unlinkHeader = this._notifyDropdown.querySelector(".accounts-unlink-header") as HTMLSpanElement;
if (this.lastNotifyMethod() == "email" || !this.lastNotifyMethod()) {
unlinkHeader.classList.add("unfocused");
} else {
unlinkHeader.classList.remove("unfocused");
}
};
get selected(): boolean {
return this._selected;
}
set selected(state: boolean) {
this.setSelected(state, true);
}
setSelected(state: boolean, dispatchEvent: boolean) {
this._selected = state;
this._check.checked = state;
if (dispatchEvent && !this.notInList)
state ? document.dispatchEvent(this._checkEvent()) : document.dispatchEvent(this._uncheckEvent());
}
get name(): string {
return this._username.textContent;
}
set name(value: string) {
this._username.textContent = value;
}
get admin(): boolean {
return !this._admin.classList.contains("hidden");
}
set admin(state: boolean) {
if (state) {
this._admin.classList.remove("hidden");
this._admin.textContent = window.lang.strings("admin");
} else {
this._admin.classList.add("hidden");
this._admin.textContent = "";
}
}
get accounts_admin(): boolean {
return this._accounts_admin.checked;
}
set accounts_admin(a: boolean) {
if (!window.jellyfinLogin) return;
this._accounts_admin.checked = a;
this._accounts_admin.disabled = window.jfAllowAll || (a && this.admin && window.jfAdminOnly);
if (this._accounts_admin.disabled) {
this._accounts_admin.title = window.lang.strings("accessJFASettings");
} else {
this._accounts_admin.title = "";
}
}
get disabled(): boolean {
return !this._disabled.classList.contains("hidden");
}
set disabled(state: boolean) {
if (state) {
this._disabled.classList.remove("hidden");
this._disabled.textContent = window.lang.strings("disabled");
} else {
this._disabled.classList.add("hidden");
this._disabled.textContent = "";
}
}
get email(): string {
return this._emailAddress;
}
set email(value: string) {
this._emailAddress = value;
this._emailEditor.value = value;
const lastNotifyMethod = this.lastNotifyMethod() == "email";
if (!value) {
this._notifyDropdown.querySelector(".accounts-area-email").classList.add("unfocused");
} else {
this._notifyDropdown.querySelector(".accounts-area-email").classList.remove("unfocused");
if (lastNotifyMethod) {
(this._email.parentElement as HTMLDivElement).appendChild(this._notifyDropdown);
}
}
}
get notify_email(): boolean {
return this._notifyEmail;
}
set notify_email(s: boolean) {
if (this._notifyDropdown) {
(this._notifyDropdown.querySelector(".accounts-contact-email") as HTMLInputElement).checked = s;
}
}
get referrals_enabled(): boolean {
return this._referralsEnabled;
}
set referrals_enabled(v: boolean) {
this._referralsEnabled = v;
if (!window.referralsEnabled) return;
if (!v) {
this._referralsEnabledCheck.textContent = ``;
} else {
this._referralsEnabledCheck.innerHTML = `<i class="ri-check-line" aria-label="${window.lang.strings("enabled")}"></i>`;
}
}
private _constructDropdown = (): HTMLDivElement => {
const el = document.createElement("div") as HTMLDivElement;
const telegram = this._telegramUsername != "";
const discord = this._discordUsername != "";
const matrix = this._matrixID != "";
const email = this._emailAddress != "";
if (!telegram && !discord && !matrix && !email) return;
let innerHTML = `
<i class="icon ri-settings-2-line dropdown-button"></i>
<div class="dropdown manual over-top">
<div class="dropdown-display lg">
<div class="card ~neutral @low flex flex-col gap-2 w-max">
<div class="supra sm">${window.lang.strings("contactThrough")}</div>
<div class="accounts-area-email">
<label class="row switch flex flex-row gap-2">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-email">
</span>Email</span>
</label>
</div>
<div class="accounts-area-telegram">
<label class="row switch flex flex-row gap-2">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-telegram">
<span>Telegram</span>
</label>
</div>
<div class="accounts-area-discord">
<label class="row switch flex flex-row gap-2">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-discord">
<span>Discord</span>
</label>
</div>
<div class="accounts-area-matrix">
<label class="row switch flex flex-row gap-2">
<input type="checkbox" name="accounts-contact-${this.id}" class="accounts-contact-matrix">
<span>Matrix</span>
</label>
</div>
<div class="supra sm accounts-unlink-header">${window.lang.strings("unlink")}:</div>
<div class="accounts-unlink-telegram">
<button class="button ~critical w-full">Telegram</button>
</div>
<div class="accounts-unlink-discord">
<button class="button ~critical w-full">Discord</button>
</div>
<div class="accounts-unlink-matrix">
<button class="button ~critical w-full">Matrix</button>
</div>
</div>
</div>
</div>
`;
el.innerHTML = innerHTML;
const button = el.querySelector("i");
const dropdown = el.querySelector("div.dropdown") as HTMLDivElement;
const checks = el.querySelectorAll("input") as NodeListOf<HTMLInputElement>;
for (let i = 0; i < checks.length; i++) {
checks[i].onclick = () => this._setNotifyMethod();
}
for (let service of ["telegram", "discord", "matrix"]) {
el.querySelector(".accounts-unlink-" + service).addEventListener("click", () =>
_delete(`/users/${service}`, { id: this.id }, () =>
document.dispatchEvent(new CustomEvent("accounts-reload")),
),
);
}
button.onclick = () => {
dropdown.classList.add("selected");
document.addEventListener("click", outerClickListener);
};
const outerClickListener = (event: Event) => {
if (
!(event.target instanceof HTMLElement && (el.contains(event.target) || button.contains(event.target)))
) {
dropdown.classList.remove("selected");
document.removeEventListener("click", outerClickListener);
}
};
return el;
};
get matrix(): string {
return this._matrixID;
}
set matrix(u: string) {
if (!window.matrixEnabled) {
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.add("unfocused");
this._notifyDropdown.querySelector(".accounts-unlink-matrix").classList.add("unfocused");
return;
}
const lastNotifyMethod = this.lastNotifyMethod() == "matrix";
this._matrixID = u;
if (!u) {
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.add("unfocused");
this._notifyDropdown.querySelector(".accounts-unlink-matrix").classList.add("unfocused");
this._matrix.innerHTML = `
<div class="table-inline justify-center">
<span class="chip btn @low"><i class="ri-link" alt="${window.lang.strings("add")}"></i></span>
<input type="text" class="input ~neutral @low stealth-input unfocused" placeholder="@user:riot.im">
</div>
`;
(this._matrix.querySelector("span") as HTMLSpanElement).onclick = this._addMatrix;
} else {
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.remove("unfocused");
this._notifyDropdown.querySelector(".accounts-unlink-matrix").classList.remove("unfocused");
this._matrix.innerHTML = `
<div class="accounts-settings-area flex flex-row gap-2 justify-center">
${u}
</div>
`;
if (lastNotifyMethod) {
(this._matrix.querySelector(".accounts-settings-area") as HTMLDivElement).appendChild(
this._notifyDropdown,
);
}
}
this._checkUnlinkArea();
}
private _addMatrix = () => {
const addButton = this._matrix.querySelector(".btn") as HTMLSpanElement;
const input = this._matrix.querySelector("input.stealth-input") as HTMLInputElement;
const addIcon = addButton.querySelector("i");
if (addButton.classList.contains("chip")) {
input.classList.remove("unfocused");
addIcon.classList.add("ri-check-line");
addIcon.classList.remove("ri-link");
addButton.classList.remove("chip");
const outerClickListener = (event: Event) => {
if (
!(
event.target instanceof HTMLElement &&
(this._matrix.contains(event.target) || addButton.contains(event.target))
)
) {
document.dispatchEvent(new CustomEvent("accounts-reload"));
document.removeEventListener("click", outerClickListener);
}
};
document.addEventListener("click", outerClickListener);
} else {
if (input.value.charAt(0) != "@" || !input.value.includes(":")) return;
const send = {
jf_id: this.id,
user_id: input.value,
};
_post("/users/matrix", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
document.dispatchEvent(new CustomEvent("accounts-reload"));
if (req.status != 200) {
window.notifications.customError(
"errorConnectMatrix",
window.lang.notif("errorFailureCheckLogs"),
);
return;
}
window.notifications.customSuccess("connectMatrix", window.lang.notif("accountConnected"));
}
});
}
};
get notify_matrix(): boolean {
return this._notifyMatrix;
}
set notify_matrix(s: boolean) {
if (this._notifyDropdown) {
(this._notifyDropdown.querySelector(".accounts-contact-matrix") as HTMLInputElement).checked = s;
}
}
get telegram(): string {
return this._telegramUsername;
}
set telegram(u: string) {
if (!window.telegramEnabled) {
this._notifyDropdown.querySelector(".accounts-area-telegram").classList.add("unfocused");
this._notifyDropdown.querySelector(".accounts-unlink-telegram").classList.add("unfocused");
return;
}
const lastNotifyMethod = this.lastNotifyMethod() == "telegram";
this._telegramUsername = u;
if (!u) {
this._notifyDropdown.querySelector(".accounts-area-telegram").classList.add("unfocused");
this._notifyDropdown.querySelector(".accounts-unlink-telegram").classList.add("unfocused");
this._telegram.innerHTML = `<div class="table-inline justify-center"><span class="chip btn @low"><i class="ri-link" alt="${window.lang.strings("add")}"></i></span></div>`;
(this._telegram.querySelector("span") as HTMLSpanElement).onclick = this._addTelegram;
} else {
this._notifyDropdown.querySelector(".accounts-area-telegram").classList.remove("unfocused");
this._notifyDropdown.querySelector(".accounts-unlink-telegram").classList.remove("unfocused");
this._telegram.innerHTML = `
<div class="accounts-settings-area flex flex-row gap-2 justify-center">
<a class="force-ltr" href="https://t.me/${u}" target="_blank">@${u}</a>
</div>
`;
if (lastNotifyMethod) {
(this._telegram.querySelector(".accounts-settings-area") as HTMLDivElement).appendChild(
this._notifyDropdown,
);
}
}
this._checkUnlinkArea();
}
get notify_telegram(): boolean {
return this._notifyTelegram;
}
set notify_telegram(s: boolean) {
if (this._notifyDropdown) {
(this._notifyDropdown.querySelector(".accounts-contact-telegram") as HTMLInputElement).checked = s;
}
}
private _setNotifyMethod = () => {
const email = this._notifyDropdown.getElementsByClassName("accounts-contact-email")[0] as HTMLInputElement;
let send = {
id: this.id,
email: email.checked,
};
if (window.telegramEnabled && this._telegramUsername) {
const telegram = this._notifyDropdown.getElementsByClassName(
"accounts-contact-telegram",
)[0] as HTMLInputElement;
send["telegram"] = telegram.checked;
}
if (window.discordEnabled && this._discordUsername) {
const discord = this._notifyDropdown.getElementsByClassName(
"accounts-contact-discord",
)[0] as HTMLInputElement;
send["discord"] = discord.checked;
}
_post(
"/users/contact",
send,
(req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
window.notifications.customError("errorSetNotify", window.lang.notif("errorSaveSettings"));
document.dispatchEvent(new CustomEvent("accounts-reload"));
return;
}
}
},
false,
(req: XMLHttpRequest) => {
if (req.status == 0) {
window.notifications.connectionError();
document.dispatchEvent(new CustomEvent("accounts-reload"));
return;
} else if (req.status == 401) {
window.notifications.customError("401Error", window.lang.notif("error401Unauthorized"));
document.dispatchEvent(new CustomEvent("accounts-reload"));
}
},
);
};
get discord(): string {
return this._discordUsername;
}
set discord(u: string) {
if (!window.discordEnabled) {
this._notifyDropdown.querySelector(".accounts-area-discord").classList.add("unfocused");
this._notifyDropdown.querySelector(".accounts-unlink-discord").classList.add("unfocused");
return;
}
const lastNotifyMethod = this.lastNotifyMethod() == "discord";
this._discordUsername = u;
if (!u) {
this._discord.innerHTML = `<div class="table-inline justify-center"><span class="chip btn @low"><i class="ri-link" alt="${window.lang.strings("add")}"></i></span></div>`;
(this._discord.querySelector("span") as HTMLSpanElement).onclick = () => addDiscord(this.id);
this._notifyDropdown.querySelector(".accounts-area-discord").classList.add("unfocused");
this._notifyDropdown.querySelector(".accounts-unlink-discord").classList.add("unfocused");
} else {
this._notifyDropdown.querySelector(".accounts-area-discord").classList.remove("unfocused");
this._notifyDropdown.querySelector(".accounts-unlink-discord").classList.remove("unfocused");
this._discord.innerHTML = `
<div class="accounts-settings-area flex flex-row gap-2 justify-center">
<a href="https://discord.com/users/${this._discordID}" class="discord-link force-ltr" target="_blank">${u}</a>
</div>
`;
if (lastNotifyMethod) {
(this._discord.querySelector(".accounts-settings-area") as HTMLDivElement).appendChild(
this._notifyDropdown,
);
}
}
this._checkUnlinkArea();
}
get discord_id(): string {
return this._discordID;
}
set discord_id(id: string) {
if (!window.discordEnabled || this._discordUsername == "") return;
this._discordID = id;
const link = this._discord.getElementsByClassName("discord-link")[0] as HTMLAnchorElement;
link.href = `https://discord.com/users/${id}`;
}
get notify_discord(): boolean {
return this._notifyDiscord;
}
set notify_discord(s: boolean) {
if (this._notifyDropdown) {
(this._notifyDropdown.querySelector(".accounts-contact-discord") as HTMLInputElement).checked = s;
}
}
get expiry(): number {
return this._expiryUnix;
}
set expiry(unix: number) {
this._expiryUnix = unix;
if (unix == 0) {
this._expiry.textContent = "";
} else {
this._expiry.textContent = toDateString(new Date(unix * 1000));
}
}
get last_active(): number {
return this._lastActiveUnix;
}
set last_active(unix: number) {
this._lastActiveUnix = unix;
if (unix == 0) {
this._lastActive.textContent == "n/a";
} else {
this._lastActive.textContent = toDateString(new Date(unix * 1000));
}
}
get label(): string {
return this._userLabel;
}
set label(l: string) {
this._userLabel = l ? l : "";
this._labelEditor.value = l ? l : "";
}
matchesSearch = (query: string): boolean => {
return (
this.id.includes(query) ||
this.name.toLowerCase().includes(query) ||
this.label.toLowerCase().includes(query) ||
this.email.toLowerCase().includes(query) ||
this.discord.toLowerCase().includes(query) ||
this.matrix.toLowerCase().includes(query) ||
this.telegram.toLowerCase().includes(query)
);
};
private _checkEvent = () => new CustomEvent("accountCheckEvent", { detail: this.id });
private _uncheckEvent = () => new CustomEvent("accountUncheckEvent", { detail: this.id });
constructor(user: UserDTO) {
super();
let innerHTML = `
<td><input type="checkbox" class="accounts-select-user" value=""></td>
<td><div class="flex flex-row gap-2 items-center">
<span class="accounts-username hover:underline hover:cursor-pointer"></span>
<div class="flex flex-row gap-2 items-baseline">
<span class="accounts-label-container" title="${window.lang.strings("label")}"></span>
<span class="accounts-admin chip ~info hidden"></span>
<span class="accounts-disabled chip ~warning hidden"></span></span>
</div>
</div></td>
`;
if (window.jellyfinLogin) {
innerHTML += `
<td><div class="table-inline justify-center"><input type="checkbox" class="accounts-access-jfa" value=""></div></td>
`;
}
innerHTML += `
<td><div class="flex flex-row gap-2 items-baseline">
<span class="accounts-email-container" title="${window.lang.strings("emailAddress")}"></span>
</div></td>
`;
if (window.telegramEnabled) {
innerHTML += `
<td class="accounts-telegram"></td>
`;
}
if (window.matrixEnabled) {
innerHTML += `
<td class="accounts-matrix"></td>
`;
}
if (window.discordEnabled) {
innerHTML += `
<td class="accounts-discord"></td>
`;
}
if (window.referralsEnabled) {
innerHTML += `
<td class="accounts-referrals grid gap-4 place-items-stretch"></td>
`;
}
innerHTML += `
<td class="accounts-expiry"></td>
<td class="accounts-last-active whitespace-nowrap"></td>
`;
this._row.innerHTML = innerHTML;
this._check = this._row.querySelector("input[type=checkbox].accounts-select-user") as HTMLInputElement;
this._accounts_admin = this._row.querySelector("input[type=checkbox].accounts-access-jfa") as HTMLInputElement;
this._username = this._row.querySelector(".accounts-username") as HTMLSpanElement;
this._username.onclick = () =>
document.dispatchEvent(
new CustomEvent("accounts-show-details", {
detail: {
username: this.name,
id: this.id,
},
}),
);
this._admin = this._row.querySelector(".accounts-admin") as HTMLSpanElement;
this._disabled = this._row.querySelector(".accounts-disabled") as HTMLSpanElement;
this._email = this._row.querySelector(".accounts-email-container") as HTMLInputElement;
this._emailEditor = new HiddenInputField({
container: this._email,
onSet: this._updateEmail,
customContainerHTML: `<span class="hidden-input-content"></span>`,
buttonOnLeft: true,
clickAwayShouldSave: false,
});
this._telegram = this._row.querySelector(".accounts-telegram") as HTMLTableDataCellElement;
this._discord = this._row.querySelector(".accounts-discord") as HTMLTableDataCellElement;
this._matrix = this._row.querySelector(".accounts-matrix") as HTMLTableDataCellElement;
this._expiry = this._row.querySelector(".accounts-expiry") as HTMLTableDataCellElement;
this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement;
this._label = this._row.querySelector(".accounts-label-container") as HTMLInputElement;
this._labelEditor = new HiddenInputField({
container: this._label,
onSet: this._updateLabel,
customContainerHTML: `<span class="chip ~gray hidden-input-content"></span>`,
buttonOnLeft: true,
clickAwayShouldSave: false,
});
this._check.onchange = () => {
this.selected = this._check.checked;
};
if (window.jellyfinLogin) {
this._accounts_admin.onchange = () => {
this.accounts_admin = this._accounts_admin.checked;
let send = {};
send[this.id] = this.accounts_admin;
_post("/users/accounts-admin", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 204) {
this.accounts_admin = !this.accounts_admin;
window.notifications.customError("accountsAdminChanged", window.lang.notif("errorUnknown"));
}
}
});
};
}
if (window.referralsEnabled) {
this._referralsEnabledCheck = this._row.querySelector(".accounts-referrals");
}
this._notifyDropdown = this._constructDropdown();
this.update(user);
document.addEventListener("timefmt-change", () => {
this.expiry = this.expiry;
this.last_active = this.last_active;
});
}
private _updateLabel = () => {
let send = {};
send[this.id] = this._labelEditor.value;
_post("/users/labels", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 204) {
this.label = this._labelEditor.previous;
window.notifications.customError("labelChanged", window.lang.notif("errorUnknown"));
}
}
});
};
private _updateEmail = () => {
let send = {};
send[this.id] = this._emailEditor.value;
_post("/users/emails", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200) {
window.notifications.customSuccess(
"emailChanged",
window.lang.var("notifications", "changedEmailAddress", `"${this.name}"`),
);
} else {
this.email = this._emailEditor.previous;
window.notifications.customError(
"emailChanged",
window.lang.var("notifications", "errorChangedEmailAddress", `"${this.name}"`),
);
}
}
});
};
private _addTelegram = () =>
_get("/telegram/pin", null, (req: XMLHttpRequest) => {
if (req.readyState == 4 && req.status == 200) {
const modal = window.modals.telegram.modal;
const pin = modal.getElementsByClassName("pin")[0] as HTMLElement;
const link = modal.getElementsByClassName("link")[0] as HTMLAnchorElement;
const username = modal.getElementsByClassName("username")[0] as HTMLElement;
const waiting = document.getElementById("telegram-waiting") as HTMLSpanElement;
let resp = req.response as getPinResponse;
pin.textContent = resp.token;
link.href = "https://t.me/" + resp.username;
username.textContent = resp.username;
addLoader(waiting);
let modalClosed = false;
window.modals.telegram.onclose = () => {
modalClosed = true;
removeLoader(waiting);
};
let send = {
token: resp.token,
id: this.id,
};
const checkVerified = () =>
_post(
"/users/telegram",
send,
(req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200 && (req.response["success"] as boolean)) {
removeLoader(waiting);
waiting.classList.add("~positive");
waiting.classList.remove("~info");
window.notifications.customSuccess(
"telegramVerified",
window.lang.notif("telegramVerified"),
);
setTimeout(() => {
window.modals.telegram.close();
waiting.classList.add("~info");
waiting.classList.remove("~positive");
}, 2000);
document.dispatchEvent(new CustomEvent("accounts-reload"));
} else if (!modalClosed) {
setTimeout(checkVerified, 1500);
}
}
},
true,
);
window.modals.telegram.show();
checkVerified();
}
});
get id() {
return this._id;
}
set id(v: string) {
this._id = v;
this._row.setAttribute(SearchableItemDataAttribute, v);
}
update = (user: UserDTO) => {
this.id = user.id;
this.name = user.name;
this.email = user.email || "";
// Little hack to get settings cogs to appear on first load
this._discordUsername = user.discord;
this._telegramUsername = user.telegram;
this._matrixID = user.matrix;
this.discord = user.discord;
this.telegram = user.telegram;
this.matrix = user.matrix;
this.last_active = user.last_active;
this.admin = user.admin;
this.disabled = user.disabled;
this.expiry = user.expiry;
this.notify_discord = user.notify_discord;
this.notify_telegram = user.notify_telegram;
this.notify_matrix = user.notify_matrix;
this.notify_email = user.notify_email;
this.discord_id = user.discord_id;
this.label = user.label;
this.accounts_admin = user.accounts_admin;
this.referrals_enabled = user.referrals_enabled;
};
remove = () => {
if (this.selected && !this.notInList) {
document.dispatchEvent(this._uncheckEvent());
}
super.remove();
};
}
interface UsersDTO extends PaginatedDTO {
users: UserDTO[];
}
declare interface ExtendExpiryDTO {
users: string[];
months?: number;
days?: number;
hours?: number;
minutes?: number;
timestamp?: number;
notify: boolean;
reason?: string;
try_extend_from_previous_expiry?: boolean;
}
export class accountsList extends PaginatedList implements Navigatable, AsTab {
readonly tabName = "accounts";
readonly pagePath = "accounts";
private _details: UserInfo;
private _table = document.getElementById("accounts-table") as HTMLTableElement;
protected _container = document.getElementById("accounts-list") as HTMLTableSectionElement;
private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement;
private _announceButton = document.getElementById("accounts-announce") as HTMLSpanElement;
private _announceSaveButton = document.getElementById("save-announce") as HTMLSpanElement;
private _announceNameLabel = document.getElementById("announce-name") as HTMLLabelElement;
private _announcePreview: HTMLElement;
private _previewLoaded = false;
private _announceTextarea = document.getElementById("textarea-announce") as HTMLTextAreaElement;
private _deleteUser = document.getElementById("accounts-delete-user") as HTMLSpanElement;
private _disableEnable = document.getElementById("accounts-disable-enable") as HTMLSpanElement;
private _enableExpiry = document.getElementById("accounts-enable-expiry") as HTMLSpanElement;
private _deleteNotify = document.getElementById("delete-user-notify") as HTMLInputElement;
private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement;
private _expiryDropdown = document.getElementById("accounts-expiry-dropdown") as HTMLElement;
private _extendExpiry = document.getElementById("accounts-extend-expiry") as HTMLSpanElement;
private _extendExpiryForm = document.getElementById("form-extend-expiry") as HTMLFormElement;
private _extendExpiryTextInput = document.getElementById("extend-expiry-text") as HTMLInputElement;
private _extendExpiryFieldInputs = document.getElementById("extend-expiry-field-inputs") as HTMLElement;
private _extendExpiryFromPreviousExpiry = document.getElementById("expiry-use-previous") as HTMLInputElement;
private _usingExtendExpiryTextInput = true;
private _extendExpiryDate = document.getElementById("extend-expiry-date") as HTMLElement;
private _removeExpiry = document.getElementById("accounts-remove-expiry") as HTMLSpanElement;
private _enableExpiryNotify = document.getElementById("expiry-extend-enable") as HTMLInputElement;
private _enableExpiryReason = document.getElementById("textarea-extend-enable") as HTMLTextAreaElement;
private _modifySettings = document.getElementById("accounts-modify-user") as HTMLSpanElement;
/*private _modifySettingsProfile = document.getElementById("radio-use-profile") as HTMLInputElement;
private _modifySettingsUser = document.getElementById("radio-use-user") as HTMLInputElement;*/
private _modifySettingsProfileSource: RadioBasedTabSelector;
private _enableReferrals = document.getElementById("accounts-enable-referrals") as HTMLSpanElement;
/*private _enableReferralsProfile = document.getElementById("radio-referrals-use-profile") as HTMLInputElement;
private _enableReferralsInvite = document.getElementById("radio-referrals-use-invite") as HTMLInputElement;*/
private _enableReferralsSource: RadioBasedTabSelector;
private _sendPWR = document.getElementById("accounts-send-pwr") as HTMLSpanElement;
private _profileSelect = document.getElementById("modify-user-profiles") as HTMLSelectElement;
private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement;
private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement;
private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement;
private _referralsExpiry = document.getElementById("enable-referrals-user-expiry") as HTMLInputElement;
private _applyHomescreen = document.getElementById("modify-user-homescreen") as HTMLInputElement;
private _applyConfiguration = document.getElementById("modify-user-configuration") as HTMLInputElement;
private _applyOmbi = window.ombiEnabled ? (document.getElementById("modify-user-ombi") as HTMLInputElement) : null;
private _applyJellyseerr = window.jellyseerrEnabled
? (document.getElementById("modify-user-jellyseerr") as HTMLInputElement)
: null;
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
private _selectAllState: SelectAllState = SelectAllState.None;
// private _users: { [id: string]: user };
// private _ordering: string[] = [];
get users(): Map<string, User> {
return this._search.items as Map<string, User>;
}
// set users(v: { [id: string]: user }) { this._search.items = v as SearchableItems; }
// Whether the enable/disable button should enable or not.
private _shouldEnable = false;
private _addUserForm = document.getElementById("form-add-user") as HTMLFormElement;
private _addUserName = this._addUserForm.querySelector("input[type=text]") as HTMLInputElement;
private _addUserEmail = this._addUserForm.querySelector("input[type=email]") as HTMLInputElement;
private _addUserPassword = this._addUserForm.querySelector("input[type=password]") as HTMLInputElement;
private _addUserProfile = this._addUserForm.querySelector("select") as HTMLSelectElement;
// Columns for sorting.
private _columns: { [className: string]: Column } = {};
// Whether the "Extend expiry" is extending or setting an expiry.
private _settingExpiry = false;
private _sortingByButton = document.getElementById("accounts-sort-by-field") as HTMLButtonElement;
private _maxDayHourMinuteOptions = 30;
private _populateNumbers = () => {
const fieldIDs = ["months", "days", "hours", "minutes"];
const prefixes = ["extend-expiry-"];
for (let i = 0; i < fieldIDs.length; i++) {
for (let j = 0; j < prefixes.length; j++) {
const field = document.getElementById(prefixes[j] + fieldIDs[i]);
field.textContent = "";
for (let n = 0; n <= this._maxDayHourMinuteOptions; n++) {
const opt = document.createElement("option") as HTMLOptionElement;
opt.textContent = "" + n;
opt.value = "" + n;
field.appendChild(opt);
}
}
}
};
private _inDetails: boolean = false;
details(jfId?: string) {
if (!jfId) jfId = this.users.keys().next().value;
this._details.load(
this.users.get(jfId),
() => {
this.unbindPageEvents();
this._inDetails = true;
// To make things look better, run processSelectedAccounts before -actually- unhiding.
this.processSelectedAccounts();
this._table.classList.add("unfocused");
this._details.hidden = false;
const url = new URL(window.location.href);
url.searchParams.set("details", jfId);
window.history.pushState(null, "", url.toString());
},
this.closeDetails,
);
}
closeDetails = () => {
if (!this._inDetails) return;
this._inDetails = false;
this.processSelectedAccounts();
this._details.hidden = true;
this._table.classList.remove("unfocused");
this.bindPageEvents();
const url = new URL(window.location.href);
url.searchParams.delete("details");
window.history.pushState(null, "", url.toString());
};
constructor() {
super({
loader: document.getElementById("accounts-loader"),
loadMoreButtons: Array.from([
document.getElementById("accounts-load-more") as HTMLButtonElement,
]) as Array<HTMLButtonElement>,
loadAllButtons: Array.from(
document.getElementsByClassName("accounts-load-all"),
) as Array<HTMLButtonElement>,
refreshButton: document.getElementById("accounts-refresh") as HTMLButtonElement,
filterArea: document.getElementById("accounts-filter-area"),
searchOptionsHeader: document.getElementById("accounts-search-options-header"),
searchBox: document.getElementById("accounts-search") as HTMLInputElement,
recordCounter: document.getElementById("accounts-record-counter"),
totalEndpoint: "/users/count",
getPageEndpoint: "/users",
itemsPerPage: 40,
maxItemsLoadedForSearch: 200,
appendNewItems: (resp: PaginatedDTO) => {
console.log("append");
for (let u of (resp as UsersDTO).users || []) {
if (this.users.has(u.id)) {
this.users.get(u.id).update(u);
} else {
this.add(u);
}
}
this._search.setOrdering(
this._columns[this._search.sortField].sort(this.users),
this._search.sortField,
this._search.ascending,
);
},
replaceWithNewItems: (resp: PaginatedDTO) => {
console.log("replace");
let accountsOnDOM = new Map<string, boolean>();
for (let id of this.users.keys()) {
accountsOnDOM.set(id, true);
}
for (let u of (resp as UsersDTO).users || []) {
if (accountsOnDOM.has(u.id)) {
this.users.get(u.id).update(u);
accountsOnDOM.delete(u.id);
} else {
this.add(u);
}
}
// Delete accounts w/ remaining IDs (those not in resp.users)
// console.log("Removing", Object.keys(accountsOnDOM).length, "from DOM");
for (let id of accountsOnDOM.keys()) {
this.users.get(id).remove();
this.users.delete(id);
}
this._search.setOrdering(
this._columns[this._search.sortField].sort(this.users),
this._search.sortField,
this._search.ascending,
);
},
defaultSortField: USER_DEFAULT_SORT_FIELD,
defaultSortAscending: USER_DEFAULT_SORT_ASCENDING,
pageLoadCallback: (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
// FIXME: Error message
if (req.status != 200) return;
},
});
this._populateNumbers();
let searchConfig: SearchConfiguration = {
filterArea: this._c.filterArea,
sortingByButton: this._sortingByButton,
searchOptionsHeader: this._c.searchOptionsHeader,
notFoundPanel: document.getElementById("accounts-not-found"),
notFoundLocallyText: document.getElementById("accounts-no-local-results"),
filterList: document.getElementById("accounts-filter-list"),
search: this._c.searchBox,
queries: queries(),
setVisibility: null,
clearSearchButtonSelector: ".accounts-search-clear",
serverSearchButtonSelector: ".accounts-search-server",
onSearchCallback: (_0: boolean, _1: boolean) => {
this.closeDetails();
this.processSelectedAccounts();
},
searchServer: null,
clearServerSearch: null,
};
this.initSearch(searchConfig);
this._selectAll.checked = false;
this._selectAllState = SelectAllState.None;
this._selectAll.onchange = () => this.cycleSelectAll();
document.addEventListener("accounts-reload", () => this.reload());
document.addEventListener("accountCheckEvent", () => {
this._counter.selected++;
this.processSelectedAccounts();
});
document.addEventListener("accountUncheckEvent", () => {
this._counter.selected--;
this.processSelectedAccounts();
});
this._addUserButton.onclick = () => {
this._populateAddUserProfiles();
window.modals.addUser.toggle();
};
this._addUserForm.addEventListener("submit", this._addUser);
this._deleteNotify.onchange = () => {
if (this._deleteNotify.checked) {
this._deleteReason.classList.remove("unfocused");
} else {
this._deleteReason.classList.add("unfocused");
}
};
this._modifySettings.onclick = this.modifyUsers;
this._modifySettings.classList.add("unfocused");
this._modifySettingsProfileSource = new RadioBasedTabSelector(
document.getElementById("modify-user-profile-source"),
"modify-user-profile-source",
{
name: window.lang.strings("profile"),
id: "profile",
content: this._profileSelect.parentElement,
onShow: () => {
this._applyOmbi?.parentElement.classList.remove("unfocused");
this._applyJellyseerr?.parentElement.classList.remove("unfocused");
},
},
{
name: window.lang.strings("user"),
id: "user",
content: this._userSelect.parentElement,
onShow: () => {
this._applyOmbi?.parentElement.classList.add("unfocused");
this._applyJellyseerr?.parentElement.classList.add("unfocused");
},
},
);
if (window.referralsEnabled) {
this._enableReferralsSource = new RadioBasedTabSelector(
document.getElementById("enable-referrals-user-source"),
"enable-referrals-user-source",
{
name: window.lang.strings("profile"),
id: "profile",
content: this._referralsProfileSelect.parentElement,
},
{
name: window.lang.strings("invite"),
id: "invite",
content: this._referralsInviteSelect.parentElement,
},
);
this._enableReferrals.onclick = () => {
this.enableReferrals();
this._enableReferralsSource.selected = 0;
};
}
this._deleteUser.onclick = this.deleteUsers;
this._deleteUser.classList.add("unfocused");
this._announceButton.onclick = this.announce;
this._announceButton.parentElement.classList.add("unfocused");
this._extendExpiry.onclick = () => {
this.extendExpiry();
};
this._removeExpiry.onclick = () => {
this.removeExpiry();
};
this._expiryDropdown.classList.add("unfocused");
this._extendExpiryDate.classList.add("unfocused");
this._extendExpiryTextInput.onkeyup = () => {
this._extendExpiryTextInput.parentElement.classList.remove("opacity-60");
this._extendExpiryFieldInputs.classList.add("opacity-60");
this._usingExtendExpiryTextInput = true;
this._displayExpiryDate();
};
this._extendExpiryTextInput.onclick = () => {
this._extendExpiryTextInput.parentElement.classList.remove("opacity-60");
this._extendExpiryFieldInputs.classList.add("opacity-60");
this._usingExtendExpiryTextInput = true;
this._displayExpiryDate();
};
this._extendExpiryFieldInputs.onclick = () => {
this._extendExpiryFieldInputs.classList.remove("opacity-60");
this._extendExpiryTextInput.parentElement.classList.add("opacity-60");
this._usingExtendExpiryTextInput = false;
this._displayExpiryDate();
};
this._extendExpiryFromPreviousExpiry.onclick = this._displayExpiryDate;
for (let field of ["months", "days", "hours", "minutes"]) {
(document.getElementById("extend-expiry-" + field) as HTMLSelectElement).onchange = () => {
this._extendExpiryFieldInputs.classList.remove("opacity-60");
this._extendExpiryTextInput.parentElement.classList.add("opacity-60");
this._usingExtendExpiryTextInput = false;
this._displayExpiryDate();
};
}
this._disableEnable.onclick = this.enableDisableUsers;
this._disableEnable.parentElement.classList.add("unfocused");
this._enableExpiry.onclick = () => {
this.extendExpiry(true);
};
this._enableExpiryNotify.onchange = () => {
if (this._enableExpiryNotify.checked) {
this._enableExpiryReason.classList.remove("unfocused");
} else {
this._enableExpiryReason.classList.add("unfocused");
}
};
if (!window.usernameEnabled) {
this._addUserName.classList.add("unfocused");
this._addUserName = this._addUserEmail;
}
if (!window.linkResetEnabled) {
this._sendPWR.classList.add("unfocused");
} else {
this._sendPWR.onclick = this.sendPWR;
}
/*if (!window.emailEnabled) {
this._deleteNotify.parentElement.classList.add("unfocused");
this._deleteNotify.checked = false;
}*/
this._announceTextarea.onkeyup = this.loadPreview;
addDiscord = newDiscordSearch(
window.lang.strings("linkDiscord"),
window.lang.strings("searchDiscordUser"),
window.lang.strings("add"),
(user: DiscordUser, id: string) => {
_post("/users/discord", { jf_id: id, discord_id: user.id }, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
document.dispatchEvent(new CustomEvent("accounts-reload"));
if (req.status != 200) {
window.notifications.customError(
"errorConnectDiscord",
window.lang.notif("errorFailureCheckLogs"),
);
return;
}
window.notifications.customSuccess("discordConnected", window.lang.notif("accountConnected"));
window.modals.discord.close();
}
});
},
);
this._announceSaveButton.onclick = this.saveAnnouncement;
const announceVarUsername = document.getElementById("announce-variables-username") as HTMLSpanElement;
announceVarUsername.onclick = () => {
insertText(this._announceTextarea, announceVarUsername.children[0].textContent);
this.loadPreview();
};
const headerNames: string[] = [
"username",
"access-jfa",
"email",
"telegram",
"matrix",
"discord",
"expiry",
"last-active",
"referrals",
];
const headerGetters: string[] = [
"name",
"accounts_admin",
"email",
"telegram",
"matrix",
"discord",
"expiry",
"last_active",
"referrals_enabled",
];
for (let i = 0; i < headerNames.length; i++) {
const header: HTMLTableCellElement = document.querySelector(
".accounts-header-" + headerNames[i],
) as HTMLTableCellElement;
if (header !== null) {
this._columns[headerGetters[i]] = new Column(
header,
headerGetters[i],
Object.getOwnPropertyDescriptor(User.prototype, headerGetters[i]).get,
);
}
}
// Start off sorting by username (this._c.defaultSortField)
const defaultSort = () => {
document.dispatchEvent(new CustomEvent("header-click", { detail: this._c.defaultSortField }));
this._columns[this._c.defaultSortField].ascending = this._c.defaultSortAscending;
this._columns[this._c.defaultSortField].hideIcon();
this._sortingByButton.classList.add("hidden");
this._search.showHideSearchOptionsHeader();
};
this._sortingByButton.addEventListener("click", defaultSort);
document.addEventListener("header-click", (event: CustomEvent) => {
this._search.setOrdering(
this._columns[event.detail].sort(this.users),
event.detail,
this._columns[event.detail].ascending,
);
this._sortingByButton.replaceChildren(this._columns[event.detail].asElement());
this._sortingByButton.classList.remove("hidden");
// console.log("ordering by", event.detail, ": ", this._ordering);
if (this._search.inSearch) {
this._search.onSearchBoxChange();
} else {
this.setVisibility(this._search.ordering, true);
this._search.setNotFoundPanelVisibility(false);
}
this._search.inServerSearch = false;
this.autoSetServerSearchButtonsDisabled();
this._search.showHideSearchOptionsHeader();
});
defaultSort();
this._search.showHideSearchOptionsHeader();
this.registerURLListener();
// FIXME: registerParamListener once PageManager is global
//
this._details = new UserInfo(document.getElementsByClassName("accounts-details")[0] as HTMLElement);
document.addEventListener("accounts-show-details", (ev: ShowDetailsEvent) => {
const url = new URL(window.location.href);
url.searchParams.set("details", ev.detail.id);
window.history.pushState(null, "", url.toString());
// this.details(ev.detail.id);
});
// Get rid of nasty CSS
window.modals.announce.onclose = () => {
const preview = document.getElementById("announce-preview") as HTMLDivElement;
preview.textContent = ``;
};
}
reload = (callback?: (resp: PaginatedDTO) => void) => {
this._reload(callback);
this.loadTemplates();
};
loadMore = (loadAll: boolean = false, callback?: (resp?: PaginatedDTO) => void) => {
this._loadMore(loadAll, callback);
};
loadAll = (callback?: (resp?: PaginatedDTO) => void) => {
this._loadAll(callback);
};
get selectAll(): SelectAllState {
return this._selectAllState;
}
cycleSelectAll = () => {
let next: SelectAllState;
switch (this.selectAll) {
case SelectAllState.None:
case SelectAllState.Some:
next = SelectAllState.AllVisible;
break;
case SelectAllState.AllVisible:
next = SelectAllState.All;
break;
case SelectAllState.All:
next = SelectAllState.None;
break;
}
this._selectAllState = next;
console.debug("New check state:", next);
if (next == SelectAllState.None) {
// Deselect -all- users, rather than just visible ones, to be safe.
for (let id of this.users.keys()) {
this.users.get(id).setSelected(false, false);
}
this._selectAll.checked = false;
this._selectAll.indeterminate = false;
this.processSelectedAccounts();
return;
}
// FIXME: Decide whether to keep the AllVisible/All distinction and actually use it, or to get rid of it an just make "load all" more visible.
const selectAllVisible = () => {
let count = 0;
for (let id of this._visible) {
this.users.get(id).setSelected(true, false);
count++;
}
console.debug("Selected", count);
this._selectAll.checked = true;
if (this.lastPage) {
this._selectAllState = SelectAllState.All;
}
this._selectAll.indeterminate = this.lastPage ? false : true;
this.processSelectedAccounts();
};
if (next == SelectAllState.AllVisible) {
selectAllVisible();
return;
}
if (next == SelectAllState.All) {
this.loadAll((_: PaginatedDTO) => {
if (!this.lastPage) {
// Pretend to live-select elements as they load.
this._counter.selected = this._counter.shown;
return;
}
selectAllVisible();
});
return;
}
};
selectAllBetweenIDs = (startID: string, endID: string) => {
let inRange = false;
for (let id of this._search.ordering) {
if (!(inRange || id == startID)) continue;
inRange = true;
if (!this._container.contains(this.users.get(id).asElement())) continue;
this.users.get(id).selected = true;
if (id == endID) return;
}
};
add = (u: UserDTO) => {
this.users.set(u.id, new User(u));
// console.log("after appending lengths:", Object.keys(this.users).length, Object.keys(this._search.items).length);
};
private processSelectedAccounts = () => {
console.debug("processSelectedAccounts");
const list = this._collectUsers();
this._counter.selected = list.length;
if (this._counter.selected == 0) {
this._selectAll.indeterminate = false;
this._selectAll.checked = false;
this._selectAll.title = "";
this._modifySettings.classList.add("unfocused");
if (window.referralsEnabled) {
this._enableReferrals.classList.add("unfocused");
}
this._deleteUser.classList.add("unfocused");
if (window.emailEnabled || window.telegramEnabled) {
this._announceButton.parentElement.classList.add("unfocused");
}
this._expiryDropdown.classList.add("unfocused");
this._disableEnable.parentElement.classList.add("unfocused");
this._sendPWR.classList.add("unfocused");
} else {
if (this._counter.selected == this._visible.length) {
this._selectAll.checked = true;
if (this.lastPage) {
this._selectAll.indeterminate = false;
this._selectAll.title = window.lang.strings("allMatchingSelected");
// FIXME: Hover text "all matching records selected."
} else {
this._selectAll.indeterminate = true;
this._selectAll.title = window.lang.strings("allLoadedSelected");
// FIXME: Hover text "all loaded matching records selected. Click again to load all."
}
} else {
this._selectAll.checked = false;
this._selectAll.indeterminate = true;
}
this._modifySettings.classList.remove("unfocused");
if (window.referralsEnabled) {
this._enableReferrals.classList.remove("unfocused");
}
this._deleteUser.classList.remove("unfocused");
this._deleteUser.textContent = window.lang.quantity("deleteUser", list.length);
if (window.emailEnabled || window.telegramEnabled) {
this._announceButton.parentElement.classList.remove("unfocused");
}
let anyNonExpiries = list.length == 0 ? true : false;
let allNonExpiries = true;
let noContactCount = 0;
let referralState = Number(this.users.get(list[0]).referrals_enabled); // -1 = hide, 0 = show "enable", 1 = show "disable"
// Only show enable/disable button if all selected have the same state.
this._shouldEnable = this.users.get(list[0]).disabled;
let showDisableEnable = true;
for (let id of list) {
if (!anyNonExpiries && !this.users.get(id).expiry) {
anyNonExpiries = true;
this._expiryDropdown.classList.add("unfocused");
}
if (this.users.get(id).expiry) {
allNonExpiries = false;
}
if (showDisableEnable && this.users.get(id).disabled != this._shouldEnable) {
showDisableEnable = false;
this._disableEnable.parentElement.classList.add("unfocused");
}
if (!showDisableEnable && anyNonExpiries) {
break;
}
if (!this.users.get(id).lastNotifyMethod()) {
noContactCount++;
}
if (
window.referralsEnabled &&
referralState != -1 &&
Number(this.users.get(id).referrals_enabled) != referralState
) {
referralState = -1;
}
}
this._settingExpiry = false;
if (!anyNonExpiries && !allNonExpiries) {
this._expiryDropdown.classList.remove("unfocused");
this._extendExpiry.textContent = window.lang.strings("extendExpiry");
this._removeExpiry.classList.remove("unfocused");
}
if (allNonExpiries) {
this._expiryDropdown.classList.remove("unfocused");
this._extendExpiry.textContent = window.lang.strings("setExpiry");
this._settingExpiry = true;
this._removeExpiry.classList.add("unfocused");
}
// Only show "Send PWR" if a maximum of 1 user selected doesn't have a contact method
if (noContactCount > 1) {
this._sendPWR.classList.add("unfocused");
} else if (window.linkResetEnabled) {
this._sendPWR.classList.remove("unfocused");
}
if (showDisableEnable) {
let message: string;
if (this._shouldEnable) {
this._disableEnable.parentElement.classList.remove("manual");
message = window.lang.strings("reEnable");
this._disableEnable.classList.add("~positive");
this._disableEnable.classList.remove("~warning");
} else {
this._disableEnable.parentElement.classList.add("manual");
message = window.lang.strings("disable");
this._disableEnable.classList.add("~warning");
this._disableEnable.classList.remove("~positive");
}
this._disableEnable.parentElement.classList.remove("unfocused");
this._disableEnable.textContent = message;
}
if (window.referralsEnabled) {
if (referralState == -1) {
this._enableReferrals.classList.add("unfocused");
} else {
this._enableReferrals.classList.remove("unfocused");
}
if (referralState == 0) {
this._enableReferrals.classList.add("~urge");
this._enableReferrals.classList.remove("~warning");
this._enableReferrals.textContent = window.lang.strings("enableReferrals");
} else if (referralState == 1) {
this._enableReferrals.classList.add("~warning");
this._enableReferrals.classList.remove("~urge");
this._enableReferrals.textContent = window.lang.strings("disableReferrals");
}
}
if (this._details.hidden) {
this._c.loadAllButtons.forEach((el) => {
// FIXME: Using hidden here instead of unfocused so that it doesn't interfere with any PaginatedList behaviour. Don't do this.
el.classList.add("hidden");
});
this._addUserButton.classList.remove("unfocused");
} else {
this._c.loadAllButtons.forEach((el) => {
// FIXME: Using hidden here instead of unfocused so that it doesn't interfere with any PaginatedList behaviour. Don't do this.
el.classList.add("hidden");
});
this._addUserButton.classList.add("unfocused");
}
}
};
private _collectUsers = (): string[] => {
if (this._inDetails && this._details.jfId != "") return [this._details.jfId];
let list: string[] = [];
for (let id of this._visible) {
if (this.users.get(id).selected) {
list.push(id);
}
}
return list;
};
private _addUser = (event: Event) => {
event.preventDefault();
const button = this._addUserForm.querySelector("span.submit") as HTMLSpanElement;
const send = {
username: this._addUserName.value,
email: this._addUserEmail.value,
password: this._addUserPassword.value,
profile: this._addUserProfile.value,
};
for (let field in send) {
if (!send[field]) {
window.notifications.customError("addUserBlankField", window.lang.notif("errorBlankFields"));
return;
}
}
toggleLoader(button);
_post(
"/user",
send,
(req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 200 || (req.response["user"] as boolean)) {
window.notifications.customSuccess(
"addUser",
window.lang.var("notifications", "userCreated", `"${send["username"]}"`),
);
if (!req.response["email"]) {
window.notifications.customError("sendWelcome", window.lang.notif("errorSendWelcomeEmail"));
console.error("User created, but welcome email failed");
}
} else {
let msg = window.lang.var("notifications", "errorUserCreated", `"${send["username"]}"`);
if ("error" in req.response) {
let realError = window.lang.notif(req.response["error"]);
if (realError) msg = realError;
}
window.notifications.customError("addUser", msg);
}
if (req.response["error"] as String) {
console.error(req.response["error"]);
}
this.reload();
window.modals.addUser.close();
}
},
true,
);
};
loadPreview = () => {
let content = this._announceTextarea.value;
if (!this._previewLoaded) {
content = stripMarkdown(content);
this._announcePreview.textContent = content;
} else {
content = Marked.parse(content);
this._announcePreview.innerHTML = content;
}
};
saveAnnouncement = (event: Event) => {
event.preventDefault();
const form = document.getElementById("form-announce") as HTMLFormElement;
const button = form.querySelector("span.submit") as HTMLSpanElement;
if (this._announceNameLabel.classList.contains("unfocused")) {
this._announceNameLabel.classList.remove("unfocused");
form.onsubmit = this.saveAnnouncement;
button.textContent = window.lang.get("strings", "saveAsTemplate");
this._announceSaveButton.classList.add("unfocused");
const details = document.getElementById("announce-details");
details.classList.add("unfocused");
return;
}
const name = (this._announceNameLabel.querySelector("input") as HTMLInputElement).value;
if (!name) {
return;
}
const subject = document.getElementById("announce-subject") as HTMLInputElement;
let send: announcementTemplate = {
name: name,
subject: subject.value,
message: this._announceTextarea.value,
};
_post("/users/announce/template", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
this.reload();
toggleLoader(button);
window.modals.announce.close();
if (req.status != 200 && req.status != 204) {
window.notifications.customError("announcementError", window.lang.notif("errorFailureCheckLogs"));
} else {
window.notifications.customSuccess("announcementSuccess", window.lang.notif("savedAnnouncement"));
}
}
});
};
announce = (event?: Event, template?: announcementTemplate) => {
const modalHeader = document.getElementById("header-announce");
modalHeader.textContent = window.lang.quantity("announceTo", this._collectUsers().length);
const form = document.getElementById("form-announce") as HTMLFormElement;
let list = this._collectUsers();
const button = form.querySelector("span.submit") as HTMLSpanElement;
removeLoader(button);
button.textContent = window.lang.get("strings", "send");
const details = document.getElementById("announce-details");
details.classList.remove("unfocused");
this._announceSaveButton.classList.remove("unfocused");
const subject = document.getElementById("announce-subject") as HTMLInputElement;
this._announceNameLabel.classList.add("unfocused");
if (template) {
subject.value = template.subject;
this._announceTextarea.value = template.message;
} else {
subject.value = "";
this._announceTextarea.value = "";
}
form.onsubmit = (event: Event) => {
event.preventDefault();
toggleLoader(button);
let send = {
users: list,
subject: subject.value,
message: this._announceTextarea.value,
};
_post("/users/announce", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
window.modals.announce.close();
if (req.status != 200 && req.status != 204) {
window.notifications.customError(
"announcementError",
window.lang.notif("errorFailureCheckLogs"),
);
} else {
window.notifications.customSuccess(
"announcementSuccess",
window.lang.notif("sentAnnouncement"),
);
}
}
});
};
_get("/config/emails/Announcement", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
const preview = document.getElementById("announce-preview") as HTMLDivElement;
if (req.status != 200) {
preview.innerHTML = `<pre class="preview-content" class="font-mono bg-inherit"></pre>`;
window.modals.announce.show();
this._previewLoaded = false;
return;
}
let templ = req.response as templateEmail;
if (!templ.html) {
preview.innerHTML = `<pre class="preview-content" class="font-mono bg-inherit"></pre>`;
this._previewLoaded = false;
} else {
preview.innerHTML = templ.html;
this._previewLoaded = true;
}
this._announcePreview = preview.getElementsByClassName("preview-content")[0] as HTMLElement;
this.loadPreview();
window.modals.announce.show();
}
});
};
loadTemplates = () =>
_get("/users/announce", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
this._announceButton.nextElementSibling.children[0].classList.add("unfocused");
return;
}
this._announceButton.nextElementSibling.children[0].classList.remove("unfocused");
const list = req.response["announcements"] as string[];
if (list.length == 0) {
this._announceButton.nextElementSibling.children[0].classList.add("unfocused");
return;
}
if (list.length > 0) {
this._announceButton.innerHTML = `${window.lang.strings("announce")} <i class="ri-arrow-drop-down-line"></i>`;
}
const dList = document.getElementById("accounts-announce-templates") as HTMLDivElement;
dList.textContent = "";
for (let name of list) {
const el = document.createElement("div") as HTMLDivElement;
el.classList.add("flex", "flex-row", "gap-2", "justify-between", "truncate");
el.innerHTML = `
<span class="button ~neutral sm full-width accounts-announce-template-button">${name}</span><span class="button ~critical accounts-announce-template-delete">&times;</span>
`;
let urlSafeName = encodeURIComponent(encodeURIComponent(name));
(el.querySelector("span.accounts-announce-template-button") as HTMLSpanElement).onclick = () => {
_get("/users/announce/" + urlSafeName, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
let template: announcementTemplate;
if (req.status != 200) {
window.notifications.customError(
"getTemplateError",
window.lang.notif("errorFailureCheckLogs"),
);
} else {
template = req.response;
}
this.announce(null, template);
}
});
};
(el.querySelector("span.accounts-announce-template-delete") as HTMLSpanElement).onclick = () => {
_delete("/users/announce/" + urlSafeName, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
window.notifications.customError(
"deleteTemplateError",
window.lang.notif("errorFailureCheckLogs"),
);
}
this.reload();
}
});
};
dList.appendChild(el);
}
}
});
private _enableDisableUsers = (
users: string[],
enable: boolean,
notify: boolean,
reason: string | null,
post: (req: XMLHttpRequest) => void,
) => {
let send = {
users: users,
enabled: enable,
notify: notify,
};
if (reason) send["reason"] = reason;
_post("/users/enable", send, post, true);
};
enableDisableUsers = () => {
// We can share the delete modal for this
const modalHeader = document.getElementById("header-delete-user");
const form = document.getElementById("form-delete-user") as HTMLFormElement;
const button = form.querySelector("span.submit") as HTMLSpanElement;
let list = this._collectUsers();
if (this._shouldEnable) {
modalHeader.textContent = window.lang.quantity("reEnableUsers", list.length);
button.textContent = window.lang.strings("reEnable");
button.classList.add("~urge");
button.classList.remove("~critical");
} else {
modalHeader.textContent = window.lang.quantity("disableUsers", list.length);
button.textContent = window.lang.strings("disable");
button.classList.add("~critical");
button.classList.remove("~urge");
}
this._deleteNotify.checked = false;
this._deleteReason.value = "";
this._deleteReason.classList.add("unfocused");
form.onsubmit = (event: Event) => {
event.preventDefault();
toggleLoader(button);
this._enableDisableUsers(
list,
this._shouldEnable,
this._deleteNotify.checked,
this._deleteNotify ? this._deleteReason.value : null,
(req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
window.modals.deleteUser.close();
if (req.status != 200 && req.status != 204) {
let errorMsg = window.lang.notif("errorFailureCheckLogs");
if (!("error" in req.response)) {
errorMsg = window.lang.notif("errorPartialFailureCheckLogs");
}
window.notifications.customError("deleteUserError", errorMsg);
} else if (this._shouldEnable) {
window.notifications.customSuccess(
"enableUserSuccess",
window.lang.quantity("enabledUser", list.length),
);
} else {
window.notifications.customSuccess(
"disableUserSuccess",
window.lang.quantity("disabledUser", list.length),
);
}
this.reload();
}
},
);
};
window.modals.deleteUser.show();
};
deleteUsers = () => {
const modalHeader = document.getElementById("header-delete-user");
let list = this._collectUsers();
modalHeader.textContent = window.lang.quantity("deleteNUsers", list.length);
const form = document.getElementById("form-delete-user") as HTMLFormElement;
const button = form.querySelector("span.submit") as HTMLSpanElement;
button.textContent = window.lang.strings("delete");
button.classList.add("~critical");
button.classList.remove("~urge");
this._deleteNotify.checked = false;
this._deleteReason.value = "";
this._deleteReason.classList.add("unfocused");
form.onsubmit = (event: Event) => {
event.preventDefault();
toggleLoader(button);
let send = {
users: list,
notify: this._deleteNotify.checked,
reason: this._deleteNotify ? this._deleteReason.value : "",
};
_delete("/users", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
window.modals.deleteUser.close();
if (req.status != 200 && req.status != 204) {
let errorMsg = window.lang.notif("errorFailureCheckLogs");
if (!("error" in req.response)) {
errorMsg = window.lang.notif("errorPartialFailureCheckLogs");
}
window.notifications.customError("deleteUserError", errorMsg);
} else {
window.notifications.customSuccess(
"deleteUserSuccess",
window.lang.quantity("deletedUser", list.length),
);
}
this.reload();
}
});
};
window.modals.deleteUser.show();
};
sendPWR = () => {
addLoader(this._sendPWR);
let list = this._collectUsers();
let manualUser: User;
for (let id of list) {
let user = this.users.get(id);
if (!user.lastNotifyMethod() && !user.email) {
manualUser = user;
break;
}
}
const messageBox = document.getElementById("send-pwr-note") as HTMLParagraphElement;
let message: string;
let send = {
users: list,
};
_post(
"/users/password-reset",
send,
(req: XMLHttpRequest) => {
if (req.readyState != 4) return;
removeLoader(this._sendPWR);
let link: string;
if (req.status == 200) {
link = req.response["link"];
if (req.response["manual"] as boolean) {
message = window.lang.var("strings", "sendPWRManual", manualUser.name);
} else {
message =
window.lang.strings("sendPWRSuccess") + " " + window.lang.strings("sendPWRSuccessManual");
}
} else if (req.status == 204) {
message = window.lang.strings("sendPWRSuccess");
} else {
window.notifications.customError("errorSendPWR", window.lang.strings("errorFailureCheckLogs"));
return;
}
message += " " + window.lang.strings("sendPWRValidFor");
messageBox.textContent = message;
let linkButton = document.getElementById("send-pwr-link") as HTMLSpanElement;
if (link) {
linkButton.classList.remove("unfocused");
linkButton.onclick = () => {
toClipboard(link);
linkButton.textContent = window.lang.strings("copied");
linkButton.classList.add("~positive");
linkButton.classList.remove("~urge");
setTimeout(() => {
linkButton.textContent = window.lang.strings("copy");
linkButton.classList.add("~urge");
linkButton.classList.remove("~positive");
}, 800);
};
} else {
linkButton.classList.add("unfocused");
}
window.modals.sendPWR.show();
},
true,
);
};
modifyUsers = () => {
const modalHeader = document.getElementById("header-modify-user");
modalHeader.textContent = window.lang.quantity("modifySettingsFor", this._collectUsers().length);
let list = this._collectUsers();
(() => {
let innerHTML = "";
for (const profile of window.availableProfiles) {
innerHTML += `<option value="${profile}">${profile}</option>`;
}
this._profileSelect.innerHTML = innerHTML;
})();
(() => {
let innerHTML = "";
for (let id of this.users.keys()) {
innerHTML += `<option value="${id}">${this.users.get(id).name}</option>`;
}
this._userSelect.innerHTML = innerHTML;
})();
const form = document.getElementById("form-modify-user") as HTMLFormElement;
const button = form.querySelector("span.submit") as HTMLSpanElement;
this._modifySettingsProfileSource.selected = 0;
form.onsubmit = (event: Event) => {
event.preventDefault();
toggleLoader(button);
let send = {
apply_to: list,
homescreen: this._applyHomescreen.checked,
configuration: this._applyConfiguration.checked,
};
if (window.ombiEnabled) {
send["ombi"] = this._applyOmbi.checked;
}
if (window.jellyseerrEnabled) {
send["jellyseerr"] = this._applyJellyseerr.checked;
}
if (this._modifySettingsProfileSource.selected == "profile") {
send["from"] = "profile";
send["profile"] = this._profileSelect.value;
} else if (this._modifySettingsProfileSource.selected == "user") {
send["from"] = "user";
send["id"] = this._userSelect.value;
}
_post("/users/settings", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 500) {
let response = JSON.parse(req.response);
let errorMsg = "";
if ("homescreen" in response && "policy" in response) {
const homescreen = Object.keys(response["homescreen"]).length;
const policy = Object.keys(response["policy"]).length;
if (homescreen != 0 && policy == 0) {
errorMsg = window.lang.notif("errorSettingsAppliedNoHomescreenLayout");
} else if (policy != 0 && homescreen == 0) {
errorMsg = window.lang.notif("errorHomescreenAppliedNoSettings");
} else if (policy != 0 && homescreen != 0) {
errorMsg = window.lang.notif("errorSettingsFailed");
}
} else if ("error" in response) {
errorMsg = response["error"];
}
window.notifications.customError("modifySettingsError", errorMsg);
} else if (req.status == 200 || req.status == 204) {
window.notifications.customSuccess(
"modifySettingsSuccess",
window.lang.quantity("appliedSettings", this._collectUsers().length),
);
}
this.reload();
window.modals.modifyUser.close();
}
});
};
window.modals.modifyUser.show();
};
enableReferrals = () => {
const modalHeader = document.getElementById("header-enable-referrals-user");
modalHeader.textContent = window.lang.quantity("enableReferralsFor", this._collectUsers().length);
let list = this._collectUsers();
// Check if we're disabling or enabling
if (this.users.get(list[0]).referrals_enabled) {
_delete("/users/referral", { users: list }, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
window.notifications.customSuccess(
"disabledReferralsSuccess",
window.lang.quantity("appliedSettings", list.length),
);
this.reload();
});
return;
}
(() => {
_get("/invites", null, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
// 1. Invites
let innerHTML = "";
let invites = req.response["invites"] as Array<Invite>;
if (invites) {
for (let inv of invites) {
let name = inv.code;
if (inv.label) {
name = `${inv.label} (${inv.code})`;
}
innerHTML += `<option value="${inv.code}">${name}</option>`;
}
this._enableReferralsSource.selected = "invite";
} else {
this._enableReferralsSource.selected = "profile";
innerHTML += `<option>${window.lang.strings("inviteNoInvites")}</option>`;
}
this._referralsInviteSelect.innerHTML = innerHTML;
// 2. Profiles
innerHTML = "";
for (const profile of window.availableProfiles) {
innerHTML += `<option value="${profile}">${profile}</option>`;
}
this._referralsProfileSelect.innerHTML = innerHTML;
});
})();
const form = document.getElementById("form-enable-referrals-user") as HTMLFormElement;
const button = form.querySelector("span.submit") as HTMLSpanElement;
form.onsubmit = (event: Event) => {
event.preventDefault();
toggleLoader(button);
let send = {
users: list,
};
// console.log("profile:", this._enableReferralsProfile.checked, this._enableReferralsInvite.checked);
if (this._enableReferralsSource.selected == "profile") {
send["from"] = "profile";
send["profile"] = this._referralsProfileSelect.value;
} else if (this._enableReferralsSource.selected == "invite") {
send["from"] = "invite";
send["id"] = this._referralsInviteSelect.value;
}
_post(
"/users/referral/" +
send["from"] +
"/" +
(send["id"] ? send["id"] : send["profile"]) +
"/" +
(this._referralsExpiry.checked ? "with-expiry" : "none"),
send,
(req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 400) {
window.notifications.customError(
"noReferralTemplateError",
window.lang.notif("errorNoReferralTemplate"),
);
} else if (req.status == 200 || req.status == 204) {
window.notifications.customSuccess(
"enableReferralsSuccess",
window.lang.quantity("appliedSettings", list.length),
);
}
this.reload();
window.modals.enableReferralsUser.close();
}
},
);
};
this._enableReferralsSource.selected = 0;
this._referralsExpiry.checked = false;
window.modals.enableReferralsUser.show();
};
removeExpiry = () => {
const list = this._collectUsers();
let success = true;
for (let id of list) {
_delete("/users/" + id + "/expiry", null, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
success = false;
return;
}
});
if (!success) break;
}
if (success) {
window.notifications.customSuccess(
"modifySettingsSuccess",
window.lang.quantity("appliedSettings", list.length),
);
} else {
window.notifications.customError("modifySettingsError", window.lang.notif("errorSettingsFailed"));
}
this.reload();
};
_displayExpiryDate = () => {
let date: Date;
let invalid = false;
let cantShow = false;
let users = this._collectUsers();
if (this._usingExtendExpiryTextInput) {
date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
invalid = "invalid" in (date as any);
} else {
if (this._extendExpiryFromPreviousExpiry.checked) {
cantShow = true;
} else {
let fields: Array<HTMLSelectElement> = [
document.getElementById("extend-expiry-months") as HTMLSelectElement,
document.getElementById("extend-expiry-days") as HTMLSelectElement,
document.getElementById("extend-expiry-hours") as HTMLSelectElement,
document.getElementById("extend-expiry-minutes") as HTMLSelectElement,
];
invalid =
fields[0].value == "0" &&
fields[1].value == "0" &&
fields[2].value == "0" &&
fields[3].value == "0";
let id = users.length > 0 ? users[0] : "";
if (!id) invalid = true;
else {
date = new Date(this.users.get(id).expiry * 1000);
if (this.users.get(id).expiry == 0) date = new Date();
date.setMonth(date.getMonth() + +fields[0].value);
date.setDate(date.getDate() + +fields[1].value);
date.setHours(date.getHours() + +fields[2].value);
date.setMinutes(date.getMinutes() + +fields[3].value);
}
}
}
const submit = this._extendExpiryForm.querySelector(`input[type="submit"]`) as HTMLInputElement;
const submitSpan = submit.nextElementSibling;
if (invalid || cantShow) {
this._extendExpiryDate.classList.add("unfocused");
}
if (invalid) {
submit.disabled = true;
submitSpan.classList.add("opacity-60");
} else {
submit.disabled = false;
submitSpan.classList.remove("opacity-60");
if (!cantShow) {
this._extendExpiryDate.innerHTML = `
<div class="flex flex-col">
<span>${window.lang.strings("accountWillExpire").replace("{date}", toDateString(date))}</span>
${users.length > 1 ? "<span>" + window.lang.strings("expirationBasedOn") + "</span>" : ""}
</div>
`;
this._extendExpiryDate.classList.remove("unfocused");
}
}
};
extendExpiry = (enableUser?: boolean) => {
const list = this._collectUsers();
let applyList: string[] = [];
for (let id of list) {
applyList.push(id);
}
this._enableExpiryReason.classList.add("unfocused");
this._enableExpiryNotify.parentElement.classList.remove("unfocused");
this._enableExpiryNotify.checked = false;
this._enableExpiryReason.value = "";
let header: string;
if (enableUser) {
header = window.lang.quantity("reEnableUsers", list.length);
} else if (this._settingExpiry) {
header = window.lang.quantity("setExpiry", list.length);
// this._enableExpiryNotify.parentElement.classList.add("unfocused");
} else {
header = window.lang.quantity("extendExpiry", applyList.length);
// this._enableExpiryNotify.parentElement.classList.add("unfocused");
}
document.getElementById("header-extend-expiry").textContent = header;
const extend = () => {
let send: ExtendExpiryDTO = {
users: applyList,
timestamp: 0,
notify: this._enableExpiryNotify.checked,
};
if (this._enableExpiryNotify.checked) {
send.reason = this._enableExpiryReason.value;
}
if (this._usingExtendExpiryTextInput) {
let date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
send.timestamp = Math.floor(date.getTime() / 1000);
if ("invalid" in (date as any)) {
window.notifications.customError("extendExpiryError", window.lang.notif("errorInvalidDate"));
return;
}
} else {
if (this._extendExpiryFromPreviousExpiry.checked) {
send.try_extend_from_previous_expiry = true;
}
for (let field of ["months", "days", "hours", "minutes"]) {
send[field] = +(document.getElementById("extend-expiry-" + field) as HTMLSelectElement).value;
}
}
_post("/users/extend", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200 && req.status != 204) {
window.notifications.customError(
"extendExpiryError",
window.lang.notif("errorFailureCheckLogs"),
);
} else {
window.notifications.customSuccess(
"extendExpiry",
window.lang.quantity("extendedExpiry", applyList.length),
);
}
window.modals.extendExpiry.close();
this.reload();
}
});
};
this._extendExpiryForm.onsubmit = (event: Event) => {
event.preventDefault();
if (enableUser) {
this._enableDisableUsers(
applyList,
true,
this._enableExpiryNotify.checked,
this._enableExpiryNotify.checked ? this._enableExpiryReason.value : null,
(req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200 && req.status != 204) {
window.modals.extendExpiry.close();
let errorMsg = window.lang.notif("errorFailureCheckLogs");
if (!("error" in req.response)) {
errorMsg = window.lang.notif("errorPartialFailureCheckLogs");
}
window.notifications.customError("deleteUserError", errorMsg);
return;
}
extend();
}
},
);
} else {
extend();
}
};
this._extendExpiryTextInput.value = "";
this._usingExtendExpiryTextInput = false;
this._extendExpiryDate.classList.add("unfocused");
this._displayExpiryDate();
window.modals.extendExpiry.show();
};
private _populateAddUserProfiles = () => {
this._addUserProfile.textContent = "";
let innerHTML = `<option value="none">${window.lang.strings("inviteNoProfile")}</option>`;
for (let i = 0; i < window.availableProfiles.length; i++) {
innerHTML += `<option value="${window.availableProfiles[i]}" ${i == 0 ? "selected" : ""}>${window.availableProfiles[i]}</option>`;
}
this._addUserProfile.innerHTML = innerHTML;
};
focusAccount = (userID: string) => {
console.debug("focusing user", userID);
this._search.setQueryParam(`id:"${userID}"`);
if (userID in this.users) this.users.get(userID).focus();
};
public static readonly _accountURLEvent = "account-url";
registerURLListener = () => {
document.addEventListener(accountsList._accountURLEvent, (event: CustomEvent) => {
this.focusAccount(event.detail);
});
window.tabs.pages.registerParamListener(
this.tabName,
(_: URLSearchParams) => {
this.navigate();
},
"user",
"details",
"search",
);
};
isURL = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const userID = urlParams.get("user");
const details = urlParams.get("details");
return Boolean(userID) || Boolean(details) || this._search.isURL(url);
};
navigate = (url?: string) => {
const urlParams = new URLSearchParams(url || window.location.search);
const details = urlParams.get("details");
const userID = details || urlParams.get("user");
let search = urlParams.get("search") || "";
if (userID) {
search = `id:"${userID}"`;
urlParams.set("search", search);
// Get rid of it, as it'll now be included in the "search" param anyway
urlParams.delete("user");
}
this._search.navigate(urlParams.toString(), () => {
if (details) this.details(details);
});
};
clearURL() {
this._search.clearURL();
}
}
// An alternate view showing accounts in sub-lists grouped by group/label.
export class groupedAccountsList {}
export const accountURLEvent = (id: string) => {
return new CustomEvent(accountsList._accountURLEvent, { detail: id });
};
type GetterReturnType = Boolean | boolean | String | Number | number;
type Getter = () => GetterReturnType;
// When a column is clicked, it broadcasts it's name and ordering to be picked up and stored by accountsList
// When list is refreshed, accountList calls method of the specific Column and re-orders accordingly.
// Listen for broadcast event from others, check its not us by comparing the header className in the message, then hide the arrow icon
class Column {
private _header: HTMLTableCellElement;
private _card: HTMLElement;
private _cardSortingByIcon: HTMLElement;
private _name: string;
private _headerContent: string;
private _getter: Getter;
private _ascending: boolean;
private _active: boolean;
constructor(header: HTMLTableCellElement, name: string, getter: Getter) {
this._header = header;
this._name = name;
this._headerContent = this._header.textContent;
this._getter = getter;
this._ascending = true;
this._active = false;
this._header.addEventListener("click", () => {
// If we are the active sort column, a click means to switch between ascending/descending.
if (this._active) {
this.ascending = !this.ascending;
return;
}
this._active = true;
this._header.setAttribute("aria-sort", this._headerContent);
this.updateHeader();
document.dispatchEvent(new CustomEvent("header-click", { detail: this._name }));
});
document.addEventListener("header-click", (event: CustomEvent) => {
if (event.detail != this._name) {
this._active = false;
this._header.removeAttribute("aria-sort");
this.hideIcon();
}
});
this._card = document.createElement("button");
this._card.classList.add("button", "~neutral", "@low", "center", "flex", "flex-row", "gap-1");
this._card.innerHTML = `
<i class="sorting-by-direction ri-arrow-up-s-line"></i>
<span class="font-bold">${window.lang.strings("sortingBy")}: </span><span>${this._headerContent}</span>
<i class="ri-close-line text-2xl"></i>
`;
this._cardSortingByIcon = this._card.querySelector(".sorting-by-direction");
}
hideIcon = () => {
this._header.textContent = this._headerContent;
};
updateHeader = () => {
this._header.innerHTML = `
<span class="">${this._headerContent}</span>
<i class="ri-arrow-${this._ascending ? "up" : "down"}-s-line" aria-hidden="true"></i>
`;
};
asElement = () => {
return this._card;
};
get ascending() {
return this._ascending;
}
set ascending(v: boolean) {
this._ascending = v;
if (v) {
this._cardSortingByIcon.classList.add("ri-arrow-up-s-line");
this._cardSortingByIcon.classList.remove("ri-arrow-down-s-line");
} else {
this._cardSortingByIcon.classList.add("ri-arrow-down-s-line");
this._cardSortingByIcon.classList.remove("ri-arrow-up-s-line");
}
if (!this._active) return;
this.updateHeader();
this._header.setAttribute("aria-sort", this._headerContent);
document.dispatchEvent(new CustomEvent("header-click", { detail: this._name }));
}
// Sorts the user list. previouslyActive is whether this column was previously sorted by, indicating that the direction should change.
sort = (users: Map<string, User>): string[] => {
let userIDs = Array.from(users.keys());
userIDs.sort((a: string, b: string): number => {
let av: GetterReturnType = this._getter.call(users.get(a));
if (typeof av === "string") av = av.toLowerCase();
let bv: GetterReturnType = this._getter.call(users.get(b));
if (typeof bv === "string") bv = bv.toLowerCase();
if (av < bv) return this._ascending ? -1 : 1;
if (av > bv) return this._ascending ? 1 : -1;
return 0;
});
return userIDs;
};
}
type ActivitySeverity =
| "Fatal"
| "None"
| "Trace"
| "Debug"
| "Information"
| "Info"
| "Warn"
| "Warning"
| "Error"
| "Critical";
interface ActivityLogEntryDTO {
Id: number;
Name: string;
Overview: string;
ShortOverview: string;
Type: string;
ItemId: string;
Date: number;
UserId: string;
UserPrimaryImageTag: string;
Severity: ActivitySeverity;
}
interface PaginatedActivityLogEntriesDTO {
entries: ActivityLogEntryDTO[];
last_page: boolean;
}
class ActivityLogEntry extends TableRow implements ActivityLogEntryDTO, SearchableItem {
private _e: ActivityLogEntryDTO;
private _username: string;
private _severity: HTMLElement;
private _user: HTMLElement;
private _name: HTMLElement;
private _type: HTMLElement;
private _overview: HTMLElement;
private _time: HTMLElement;
update = (user: string | null, entry: ActivityLogEntryDTO) => {
this._e = entry;
if (user != null) this.User = user;
this.Id = entry.Id;
this.Name = entry.Name;
this.Overview = entry.Overview;
this.ShortOverview = entry.ShortOverview;
this.Type = entry.Type;
this.ItemId = entry.ItemId;
this.Date = entry.Date;
this.UserId = entry.UserId;
this.UserPrimaryImageTag = entry.UserPrimaryImageTag;
this.Severity = entry.Severity;
};
constructor(user: string, entry: ActivityLogEntryDTO) {
super();
this._row.innerHTML = `
<td class="text-center-i"><span class="jf-activity-log-severity chip ~info dark:~d_info unfocused"></span></td>
<td class="jf-activity-log-user-name-combined max-w-96 truncate"><div class="flex flex-row gap-2 items-baseline"><span class="chip ~gray dark:~d_gray jf-activity-log-user unfocused"></span><span class="jf-activity-log-name truncate"></span></div></td>
<td class="jf-activity-log-type italic"></td>
<td class="jf-activity-log-overview"></td>
<td class="jf-activity-log-time"></td>
`;
this._severity = this._row.getElementsByClassName("jf-activity-log-severity")[0] as HTMLElement;
this._user = this._row.getElementsByClassName("jf-activity-log-user")[0] as HTMLElement;
this._name = this._row.getElementsByClassName("jf-activity-log-name")[0] as HTMLElement;
this._type = this._row.getElementsByClassName("jf-activity-log-type")[0] as HTMLElement;
this._overview = this._row.getElementsByClassName("jf-activity-log-overview")[0] as HTMLElement;
this._time = this._row.getElementsByClassName("jf-activity-log-time")[0] as HTMLElement;
this.update(user, entry);
}
matchesSearch = (query: string): boolean => {
return (
("" + this.Id).includes(query) ||
this.User.includes(query) ||
this.UserId.includes(query) ||
this.Name.includes(query) ||
this.Overview.includes(query) ||
this.ShortOverview.includes(query) ||
this.Type.includes(query) ||
this.ItemId.includes(query) ||
this.UserId.includes(query) ||
this.Severity.includes(query)
);
};
get Id(): number {
return this._e.Id;
}
set Id(v: number) {
this._e.Id = v;
}
private setName() {
const space = this.Name.indexOf(" ");
let nameContent = this.Name;
let endOfUserBadge = ":";
if (space != -1 && this.User != "" && nameContent.substring(0, space) == this.User) {
endOfUserBadge = "";
nameContent = nameContent.substring(space + 1, nameContent.length);
}
if (this.User == "") this._user.classList.add("unfocused");
else this._user.classList.remove("unfocused");
this._user.textContent = this.User + endOfUserBadge;
this._name.textContent = nameContent;
this._name.title = nameContent;
}
// User is the username of the user. It is not part of an ActivityLogEntryDTO, rather the UserId is, but in most contexts the parent should know it anyway.
get User(): string {
return this._username;
}
set User(v: string) {
this._username = v;
this.setName();
}
// Name is a description of the entry. It often starts with the name of the user, so
get Name(): string {
return this._e.Name;
}
set Name(v: string) {
this._e.Name = v;
this.setName();
}
// "Overview" doesn't seem to be used, but we'll let it take precedence anyway.
private setOverview() {
this._overview.textContent = this.Overview || this.ShortOverview;
}
// Overview is something, but I haven't seen it actually used.
get Overview(): string {
return this._e.Overview;
}
set Overview(v: string) {
this._e.Overview = v;
this.setOverview();
}
// ShortOverview usually seems to be the IP address of the user in applicable entries.
get ShortOverview(): string {
return this._e.ShortOverview;
}
set ShortOverview(v: string) {
this._e.ShortOverview = v;
this.setOverview();
}
get Type(): string {
return this._e.Type;
}
set Type(v: string) {
this._e.Type = v;
this._type.textContent = v;
}
get ItemId(): string {
return this._e.ItemId;
}
set ItemId(v: string) {
this._e.ItemId = v;
}
get Date(): number {
return this._e.Date;
}
set Date(v: number) {
this._e.Date = v;
this._time.textContent = toDateString(new Date(v * 1000));
}
get UserId(): string {
return this._e.UserId;
}
set UserId(v: string) {
this._e.UserId = v;
}
get UserPrimaryImageTag(): string {
return this._e.UserPrimaryImageTag;
}
set UserPrimaryImageTag(v: string) {
this._e.UserPrimaryImageTag = v;
}
get Severity(): ActivitySeverity {
return this._e.Severity;
}
set Severity(v: ActivitySeverity) {
this._e.Severity = v;
if (v) this._severity.classList.remove("unfocused");
else this._severity.classList.add("unfocused");
["~neutral", "~positive", "~warning", "~critical", "~info", "~urge"].forEach((c) =>
this._severity.classList.remove(c),
);
switch (v) {
case "Info":
case "Information":
this._severity.textContent = window.lang.strings("info");
this._severity.classList.add("~info");
break;
case "Debug":
case "Trace":
this._severity.textContent = window.lang.strings("debug");
this._severity.classList.add("~urge");
break;
case "Warn":
case "Warning":
this._severity.textContent = window.lang.strings("warn");
this._severity.classList.add("~warning");
break;
case "Error":
this._severity.textContent = window.lang.strings("error");
this._severity.classList.add("~critical");
break;
case "Critical":
case "Fatal":
this._severity.textContent = window.lang.strings("fatal");
this._severity.classList.add("~critical");
break;
case "None":
this._severity.textContent = window.lang.strings("none");
this._severity.classList.add("~neutral");
default:
console.warn("Unknown key in activity severity:", v);
this._severity.textContent = v;
this._severity.classList.add("~neutral");
}
}
}
interface ShowDetailsEvent extends Event {
detail: {
username: string;
id: string;
};
}
class UserInfo extends PaginatedList {
private _hidden: boolean = true;
private _table: HTMLElement;
private _card: HTMLElement;
private _rowArea: HTMLElement;
private _noResults: HTMLElement;
private _link: HTMLAnchorElement;
get entries(): Map<string, ActivityLogEntry> {
return this._search.items as Map<string, ActivityLogEntry>;
}
private _back: HTMLButtonElement;
username: string;
jfId: string;
constructor(card: HTMLElement) {
card.classList.add("unfocused");
card.innerHTML = `
<div class="flex flex-col gap-2">
<div class="overflow-x-scroll">
<table class="table text-base leading-5">
<thead class="user-info-table-header"></thead>
<tbody class="user-info-row"></tbody>
</table>
</div>
<div class="flex flex-col gap-2">
<div class="jf-activity-source flex flex-row justify-between gap-2">
<h3 class="heading text-lg">${window.lang.strings("activityFromJF")}</h3>
<a role="link" tabindex="0" class="jf-activity-jfa-link hover:underline cursor-pointer button ~info @high flex flex-row gap-1 items-baseline">${window.lang.strings("activityFromJfa")}<i class="icon ri-external-link-line text-current"></i></a>
</div>
<!-- <h2 class="heading text-2xl">${window.lang.strings("activity")}</h2> -->
<div class="card @low overflow-x-scroll jf-activity-table">
<table class="table text-xs leading-5">
<thead>
<tr>
<th>${window.lang.strings("severity")}</th>
<th>${window.lang.strings("details")}</th>
<th>${window.lang.strings("type")}</th>
<th>${window.lang.strings("other")}</th>
<th>${window.lang.strings("date")}</th>
</tr>
</thead>
<tbody class="jf-activity-table-content"></tbody>
</table>
<div class="unfocused h-[100%] my-3 jf-activity-no-activity">
<div class="flex flex-col gap-2 h-[100%] justify-center items-center">
<span class="text-2xl font-medium italic text-center">${window.lang.strings("noActivityFound")}</span>
<span class="text-sm font-light italic text-center" id="accounts-no-local-results">${window.lang.strings("noActivityFoundLocally")}</span>
</div>
</div>
</div>
<div class="flex flex-row gap-2 justify-center">
<button class="button ~neutral @low jf-activity-load-more">${window.lang.strings("loadMore")}</button>
<button class="button ~neutral @low accounts-load-all jf-activity-load-all">${window.lang.strings("loadAll")}</button>
</div>
</div>
</div>
`;
super({
loader: card.getElementsByClassName("jf-activity-loader")[0] as HTMLElement,
loadMoreButtons: Array.from(
document.getElementsByClassName("jf-activity-load-more"),
) as Array<HTMLButtonElement>,
loadAllButtons: Array.from(
document.getElementsByClassName("jf-activity-load-all"),
) as Array<HTMLButtonElement>,
totalEndpoint: () => "/users/" + this.jfId + "/activities/jellyfin/count",
getPageEndpoint: () => "/users/" + this.jfId + "/activities/jellyfin",
itemsPerPage: 20,
maxItemsLoadedForSearch: 200,
disableSearch: true,
hideButtonsOnLastPage: true,
appendNewItems: (resp: PaginatedDTO) => {
for (let entry of (resp as PaginatedActivityLogEntriesDTO).entries || []) {
if (this.entries.has("" + entry.Id)) {
this.entries.get("" + entry.Id).update(null, entry);
} else {
this.add(entry);
}
}
this._search.setOrdering(Array.from(this.entries.keys()), "Date", true);
},
replaceWithNewItems: (resp: PaginatedDTO) => {
let entriesOnDOM = new Map<string, boolean>();
for (let id of this.entries.keys()) {
entriesOnDOM.set(id, true);
}
for (let entry of (resp as PaginatedActivityLogEntriesDTO).entries || []) {
if (entriesOnDOM.has("" + entry.Id)) {
this.entries.get("" + entry.Id).update(null, entry);
entriesOnDOM.delete("" + entry.Id);
} else {
this.add(entry);
}
}
// Delete entries w/ remaining IDs (those not in resp.entries)
// console.log("Removing", Object.keys(entriesOnDOM).length, "from DOM");
for (let id of entriesOnDOM.keys()) {
this.entries.get(id).remove();
this.entries.delete(id);
}
this._search.setOrdering(Array.from(this.entries.keys()), "Date", true);
},
});
this._hidden = true;
this._card = card;
this._rowArea = this._card.getElementsByClassName("user-info-row")[0] as HTMLElement;
const realHead = document.getElementById("accounts-table-header") as HTMLElement;
const cloneHead = realHead.cloneNode(true) as HTMLElement;
// Remove "select all" check from header
cloneHead.querySelector("input[type=checkbox]").remove();
const head = this._card.getElementsByClassName("user-info-table-header")[0] as HTMLElement;
head.replaceWith(cloneHead);
this._table = this._card.getElementsByClassName("jf-activity-table")[0] as HTMLElement;
this._container = this._table.getElementsByClassName("jf-activity-table-content")[0] as HTMLElement;
this._back = document.getElementById("user-details-back") as HTMLButtonElement;
this._noResults = this._card.getElementsByClassName("jf-activity-no-activity")[0] as HTMLElement;
this._link = this._card.getElementsByClassName("jf-activity-jfa-link")[0] as HTMLAnchorElement;
let searchConfig: SearchConfiguration = {
queries: {},
setVisibility: null,
searchServer: null,
clearServerSearch: null,
onSearchCallback: (_0: boolean, _1: boolean) => {},
notFoundPanel: this._noResults,
};
this.initSearch(searchConfig);
}
add = (entry: ActivityLogEntryDTO) => {
this.entries.set("" + entry.Id, new ActivityLogEntry(this.username, entry));
};
loadMore = (loadAll: boolean = false, callback?: (resp?: PaginatedDTO) => void) => {
this._loadMore(loadAll, callback);
};
loadAll = (callback?: (resp?: PaginatedDTO) => void) => {
this._loadAll(callback);
};
reload = (callback?: (resp: PaginatedDTO) => void) => {
this._reload(callback);
};
load = (user: User, onLoad?: () => void, onBack?: () => void) => {
this.username = user.name;
this.jfId = user.id;
const clone = new User(user);
clone.notInList = true;
clone.setSelected(true, false);
this._rowArea.replaceChildren(clone.asElement());
this._link.setAttribute("data-id", this.jfId);
this._link.href = `${window.pages.Base}${window.pages.Admin}/activity?user=${this.username}`;
if (onBack) {
this._back.classList.remove("unfocused");
this._back.onclick = () => onBack();
} else {
this._back.classList.add("unfocused");
this._back.onclick = null;
}
this.reload(onLoad);
/*_get("/users/" + jfId + "/activities/jellyfin", null, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
window.notifications.customError("errorLoadJFActivities", window.lang.notif("errorLoadActivities"));
return;
}
// FIXME: Lazy loading table
this._table.textContent = ``;
let entries = (req.response as PaginatedActivityLogEntriesDTO).entries;
for (let entry of entries) {
const row = new ActivityLogEntry(username, entry);
this._table.appendChild(row.asElement());
}
if (onLoad) onLoad();
});*/
};
get hidden(): boolean {
return this._hidden;
}
set hidden(v: boolean) {
this._hidden = v;
if (v) {
this.unbindPageEvents();
this._card.classList.add("unfocused");
this._back.classList.add("unfocused");
this.username = "";
this.jfId = "";
} else {
this.bindPageEvents();
this._card.classList.remove("unfocused");
this._back.classList.remove("unfocused");
}
}
}