add admin page

This commit is contained in:
Pouria Ezzati 2024-11-19 07:58:57 +03:30
parent 3ed2a8de3e
commit 8a73c5ec4c
No known key found for this signature in database
73 changed files with 3241 additions and 193 deletions

8
server/consts.js Normal file
View File

@ -0,0 +1,8 @@
const ROLES = {
USER: "USER",
ADMIN: "ADMIN"
};
module.exports = {
ROLES,
}

View File

@ -42,19 +42,20 @@ function authenticate(type, error, isStrict, redirect) {
throw new CustomError(error, 401);
}
if (user && user.banned) {
throw new CustomError("You're banned from using this website.", 403);
}
if (user && isStrict && !user.verified) {
throw new CustomError("Your email address is not verified. " +
"Sign up to get the verification link again.", 400);
}
if (user && user.banned) {
throw new CustomError("You're banned from using this website.", 403);
}
if (user) {
res.locals.isAdmin = utils.isAdmin(user.email);
res.locals.isAdmin = utils.isAdmin(user);
req.user = {
...user,
admin: utils.isAdmin(user.email)
admin: utils.isAdmin(user)
};
// renew token if it's been at least one day since the token has been created

View File

@ -3,6 +3,7 @@ const { Handler } = require("express");
const { CustomError, sanitize } = require("../utils");
const query = require("../queries");
const redis = require("../redis");
const utils = require("../utils");
const env = require("../env");
async function add(req, res) {
@ -26,6 +27,27 @@ async function add(req, res) {
return res.status(200).send(sanitize.domain(domain));
};
async function addAdmin(req, res) {
const { address, banned, homepage } = req.body;
const domain = await query.domain.add({
address,
homepage,
banned,
...(banned && { banned_by_id: req.user.id })
});
if (req.isHTML) {
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/add_domain_success", {
address: domain.address,
});
return;
}
return res.status(200).send({ message: "The domain has been added successfully." });
};
async function remove(req, res) {
const domain = await query.domain.find({
uuid: req.params.id,
@ -33,7 +55,7 @@ async function remove(req, res) {
});
if (!domain) {
throw new CustomError("Could not delete the domain.", 500);
throw new CustomError("Could not delete the domain.", 400);
}
const [updatedDomain] = await query.domain.update(
@ -62,7 +84,130 @@ async function remove(req, res) {
return res.status(200).send({ message: "Domain deleted successfully" });
};
async function removeAdmin(req, res) {
const id = req.params.id;
const links = req.query.links
const domain = await query.domain.find({ id });
if (!domain) {
throw new CustomError("Could not find the domain.", 400);
}
if (links) {
await query.link.batchRemove({ domain_id: id });
}
await query.domain.remove(domain);
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/delete_domain_success", {
address: domain.address,
});
return;
}
return res.status(200).send({ message: "Domain deleted successfully" });
}
async function getAdmin(req, res) {
const { limit, skip } = req.context;
const search = req.query.search;
const user = req.query.user;
const banned = utils.parseBooleanQuery(req.query.banned);
const owner = utils.parseBooleanQuery(req.query.owner);
const links = utils.parseBooleanQuery(req.query.links);
const match = {
...(banned !== undefined && { banned }),
...(owner !== undefined && { user_id: [owner ? "is not" : "is", null] }),
};
const [data, total] = await Promise.all([
query.domain.getAdmin(match, { limit, search, user, links, skip }),
query.domain.totalAdmin(match, { search, user, links })
]);
const domains = data.map(utils.sanitize.domain_admin);
if (req.isHTML) {
res.render("partials/admin/domains/table", {
total,
total_formatted: total.toLocaleString("en-US"),
limit,
skip,
table_domains: domains,
})
return;
}
return res.send({
total,
limit,
skip,
data: domains,
});
}
async function ban(req, res) {
const { id } = req.params;
const update = {
banned_by_id: req.user.id,
banned: true
};
// 1. check if domain exists
const domain = await query.domain.find({ id });
if (!domain) {
throw new CustomError("No domain has been found.", 400);
}
if (domain.banned) {
throw new CustomError("Domain has been banned already.", 400);
}
const tasks = [];
// 2. ban domain
tasks.push(query.domain.update({ id }, update));
// 3. ban user
if (req.body.user && domain.user_id) {
tasks.push(query.user.update({ id: domain.user_id }, update));
}
// 4. ban links
if (req.body.links) {
tasks.push(query.link.update({ domain_id: id }, update));
}
// 5. wait for all tasks to finish
await Promise.all(tasks).catch((err) => {
throw new CustomError("Couldn't ban entries.");
});
// 6. send response
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/ban_domain_success", {
address: domain.address,
});
return;
}
return res.status(200).send({ message: "Banned domain successfully." });
}
module.exports = {
add,
addAdmin,
ban,
getAdmin,
remove,
removeAdmin,
}

View File

@ -80,7 +80,6 @@ function parseQuery(req, res, next) {
req.context = {
limit: limit > 50 ? 50 : limit,
skip: parseInt(req.query.skip) || 0,
all: admin ? req.query.all === "true" || req.query.all === "on" : false
};
next();

View File

@ -17,12 +17,12 @@ const CustomError = utils.CustomError;
const dnsLookup = promisify(dns.lookup);
async function get(req, res) {
const { limit, skip, all } = req.context;
const { limit, skip } = req.context;
const search = req.query.search;
const userId = req.user.id;
const match = {
...(!all && { user_id: userId })
user_id: userId
};
const [data, total] = await Promise.all([
@ -50,6 +50,54 @@ async function get(req, res) {
});
};
async function getAdmin(req, res) {
const { limit, skip } = req.context;
const search = req.query.search;
const user = req.query.user;
let domain = req.query.domain;
const banned = utils.parseBooleanQuery(req.query.banned);
const anonymous = utils.parseBooleanQuery(req.query.anonymous);
const has_domain = utils.parseBooleanQuery(req.query.has_domain);
const match = {
...(banned !== undefined && { banned }),
...(anonymous !== undefined && { user_id: [anonymous ? "is" : "is not", null] }),
...(has_domain !== undefined && { domain_id: [has_domain ? "is not" : "is", null] }),
};
// if domain is equal to the defualt domain,
// it means admins is looking for links with the defualt domain (no custom user domain)
if (domain === env.DEFAULT_DOMAIN) {
domain = undefined;
match.domain_id = null;
}
const [data, total] = await Promise.all([
query.link.getAdmin(match, { limit, search, user, domain, skip }),
query.link.totalAdmin(match, { search, user, domain })
]);
const links = data.map(utils.sanitize.link_admin);
if (req.isHTML) {
res.render("partials/admin/links/table", {
total,
total_formatted: total.toLocaleString("en-US"),
limit,
skip,
links,
})
return;
}
return res.send({
total,
limit,
skip,
data: links,
});
};
async function create(req, res) {
const { reuse, password, customurl, description, target, fetched_domain, expire_in } = req.body;
const domain_id = fetched_domain ? fetched_domain.id : null;
@ -108,7 +156,7 @@ async function create(req, res) {
link.domain = fetched_domain?.address;
if (req.isHTML) {
res.setHeader("HX-Trigger", "reloadLinks");
res.setHeader("HX-Trigger", "reloadMainTable");
const shortURL = utils.getShortURL(link.address, link.domain);
return res.render("partials/shortener", {
link: shortURL.link,
@ -216,6 +264,101 @@ async function edit(req, res) {
return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
};
async function editAdmin(req, res) {
const link = await query.link.find({
uuid: req.params.id,
...(!req.user.admin && { user_id: req.user.id })
});
if (!link) {
throw new CustomError("Link was not found.");
}
let isChanged = false;
[
[req.body.address, "address"],
[req.body.target, "target"],
[req.body.description, "description"],
[req.body.expire_in, "expire_in"],
[req.body.password, "password"]
].forEach(([value, name]) => {
if (!value) {
if (name === "password" && link.password)
req.body.password = null;
else {
delete req.body[name];
return;
}
}
if (value === link[name] && name !== "password") {
delete req.body[name];
return;
}
if (name === "expire_in" && link.expire_in)
if (Math.abs(differenceInSeconds(utils.parseDatetime(value), utils.parseDatetime(link.expire_in))) < 60)
return;
if (name === "password")
if (value && value.replace(/•/ig, "").length === 0) {
delete req.body.password;
return;
}
isChanged = true;
});
if (!isChanged) {
throw new CustomError("Should at least update one field.");
}
const { address, target, description, expire_in, password } = req.body;
const targetDomain = target && utils.removeWww(URL.parse(target).hostname);
const domain_id = link.domain_id || null;
const tasks = await Promise.all([
validators.cooldown(req.user),
target && validators.malware(req.user, target),
address &&
query.link.find({
address,
domain_id
}),
target && validators.bannedDomain(targetDomain),
target && validators.bannedHost(targetDomain)
]);
// Check if custom link already exists
if (tasks[2]) {
const error = "Custom URL is already in use.";
res.locals.errors = { address: error };
throw new CustomError("Custom URL is already in use.");
}
// Update link
const [updatedLink] = await query.link.update(
{
id: link.id
},
{
...(address && { address }),
...(description && { description }),
...(target && { target }),
...(expire_in && { expire_in }),
...((password || password === null) && { password })
}
);
if (req.isHTML) {
res.render("partials/admin/links/edit", {
swap_oob: true,
success: "Link has been updated.",
...utils.sanitize.linkAdmin({ ...link, ...updatedLink }),
});
return;
}
return res.status(200).send(utils.sanitize.link({ ...link, ...updatedLink }));
};
async function remove(req, res) {
const { error, isRemoved, link } = await query.link.remove({
uuid: req.params.id,
@ -229,7 +372,7 @@ async function remove(req, res) {
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadLinks");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/links/dialog/delete_success", {
link: utils.getShortURL(link.address, link.domain).link,
});
@ -266,7 +409,7 @@ async function ban(req, res) {
banned: true
};
// 1. Check if link exists
// 1. check if link exists
const link = await query.link.find({ uuid: id });
if (!link) {
@ -279,17 +422,17 @@ async function ban(req, res) {
const tasks = [];
// 2. Ban link
// 2. ban link
tasks.push(query.link.update({ uuid: id }, update));
const domain = utils.removeWww(URL.parse(link.target).hostname);
// 3. Ban target's domain
// 3. ban target's domain
if (req.body.domain) {
tasks.push(query.domain.add({ ...update, address: domain }));
}
// 4. Ban target's host
// 4. ban target's host
if (req.body.host) {
const dnsRes = await dnsLookup(domain).catch(() => {
throw new CustomError("Couldn't fetch DNS info.");
@ -298,25 +441,25 @@ async function ban(req, res) {
tasks.push(query.host.add({ ...update, address: host }));
}
// 5. Ban link owner
// 5. ban link owner
if (req.body.user && link.user_id) {
tasks.push(query.user.update({ id: link.user_id }, update));
}
// 6. Ban all of owner's links
// 6. ban all of owner's links
if (req.body.userLinks && link.user_id) {
tasks.push(query.link.update({ user_id: link.user_id }, update));
}
// 7. Wait for all tasks to finish
// 7. wait for all tasks to finish
await Promise.all(tasks).catch((err) => {
throw new CustomError("Couldn't ban entries.");
});
// 8. Send response
// 8. send response
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadLinks");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/links/dialog/ban_success", {
link: utils.getShortURL(link.address, link.domain).link,
});
@ -503,7 +646,9 @@ module.exports = {
ban,
create,
edit,
editAdmin,
get,
getAdmin,
remove,
report,
stats,

View File

@ -53,7 +53,22 @@ function protected(req, res, next) {
next();
}
function adminTable(req, res, next) {
res.locals.query = {
anonymous: req.query.anonymous,
domain: req.query.domain,
domains: req.query.domains,
links: req.query.links,
role: req.query.role,
search: req.query.search,
user: req.query.user,
verified: req.query.verified,
};
next();
}
module.exports = {
adminTable,
config,
createLink,
editLink,

View File

@ -37,6 +37,12 @@ function settings(req, res) {
});
}
function admin(req, res) {
res.render("admin", {
title: "Admin"
});
}
function stats(req, res) {
res.render("stats", {
title: "Stats"
@ -119,6 +125,48 @@ async function confirmLinkBan(req, res) {
});
}
async function confirmUserDelete(req, res) {
const user = await query.user.find({ id: req.query.id });
if (!user) {
return res.render("partials/admin/dialog/message", {
layout: false,
message: "Could not find the user."
});
}
res.render("partials/admin/dialog/delete_user", {
layout: false,
email: user.email,
id: user.id
});
}
async function confirmUserBan(req, res) {
const user = await query.user.find({ id: req.query.id });
if (!user) {
return res.render("partials/admin/dialog/message", {
layout: false,
message: "Could not find the user."
});
}
res.render("partials/admin/dialog/ban_user", {
layout: false,
email: user.email,
id: user.id
});
}
async function createUser(req, res) {
res.render("partials/admin/dialog/create_user", {
layout: false,
});
}
async function addDomainAdmin(req, res) {
res.render("partials/admin/dialog/add_domain", {
layout: false,
});
}
async function addDomainForm(req, res) {
res.render("partials/settings/domain/add_form");
}
@ -129,13 +177,44 @@ async function confirmDomainDelete(req, res) {
user_id: req.user.id
});
if (!domain) {
throw new utils.CustomError("Could not find the link", 400);
throw new utils.CustomError("Could not find the domain.", 400);
}
res.render("partials/settings/domain/delete", {
...utils.sanitize.domain(domain)
});
}
async function confirmDomainBan(req, res) {
const domain = await query.domain.find({
id: req.query.id
});
if (!domain) {
throw new utils.CustomError("Could not find the domain.", 400);
}
const hasUser = !!domain.user_id;
const hasLink = await query.link.find({ domain_id: domain.id });
res.render("partials/admin/dialog/ban_domain", {
id: domain.id,
address: domain.address,
hasUser,
hasLink,
});
}
async function confirmDomainDeleteAdmin(req, res) {
const domain = await query.domain.find({
id: req.query.id
});
if (!domain) {
throw new utils.CustomError("Could not find the domain.", 400);
}
const hasLink = await query.link.find({ domain_id: domain.id });
res.render("partials/admin/dialog/delete_domain", {
id: domain.id,
address: domain.address,
hasLink,
});
}
async function getReportEmail(req, res) {
if (!env.REPORT_EMAIL) {
@ -166,16 +245,33 @@ async function linkEdit(req, res) {
});
}
async function linkEditAdmin(req, res) {
const link = await query.link.find({
uuid: req.params.id,
});
res.render("partials/admin/links/edit", {
...(link && utils.sanitize.link(link)),
});
}
module.exports = {
addDomainAdmin,
addDomainForm,
admin,
banned,
confirmDomainBan,
confirmDomainDelete,
confirmDomainDeleteAdmin,
confirmLinkBan,
confirmLinkDelete,
confirmUserBan,
confirmUserDelete,
createUser,
getReportEmail,
getSupportEmail,
homepage,
linkEdit,
linkEditAdmin,
login,
logout,
notFound,

View File

@ -1,5 +1,8 @@
const bcrypt = require("bcryptjs");
const query = require("../queries");
const utils = require("../utils");
const mail = require("../mail");
const env = require("../env");
async function get(req, res) {
@ -29,7 +32,154 @@ async function remove(req, res) {
return res.status(200).send("OK");
};
async function removeByAdmin(req, res) {
const user = await query.user.find({ id: req.params.id });
if (!user) {
const message = "Could not find the user.";
if (req.isHTML) {
return res.render("partials/admin/dialog/message", {
layout: false,
message
});
} else {
return res.status(400).send({ message });
}
}
await query.user.remove(user);
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/delete_user_success", {
email: user.email,
});
return;
}
return res.status(200).send({ message: "User has been deleted successfully." });
};
async function getAdmin(req, res) {
const { limit, skip, all } = req.context;
const { role, search } = req.query;
const userId = req.user.id;
const verified = utils.parseBooleanQuery(req.query.verified);
const banned = utils.parseBooleanQuery(req.query.banned);
const domains = utils.parseBooleanQuery(req.query.domains);
const links = utils.parseBooleanQuery(req.query.links);
const match = {
...(role && { role }),
...(verified !== undefined && { verified }),
...(banned !== undefined && { banned }),
};
const [data, total] = await Promise.all([
query.user.getAdmin(match, { limit, search, domains, links, skip }),
query.user.totalAdmin(match, { search, domains, links })
]);
const users = data.map(utils.sanitize.user_admin);
if (req.isHTML) {
res.render("partials/admin/users/table", {
total,
total_formatted: total.toLocaleString("en-US"),
limit,
skip,
users,
})
return;
}
return res.send({
total,
limit,
skip,
data: users,
});
};
async function ban(req, res) {
const { id } = req.params;
const update = {
banned_by_id: req.user.id,
banned: true
};
// 1. check if user exists
const user = await query.user.find({ id });
if (!user) {
throw new CustomError("No user has been found.", 400);
}
if (user.banned) {
throw new CustomError("User has been banned already.", 400);
}
const tasks = [];
// 2. ban user
tasks.push(query.user.update({ id }, update));
// 3. ban user links
if (req.body.links) {
tasks.push(query.link.update({ user_id: id }, update));
}
// 4. ban user domains
if (req.body.domains) {
tasks.push(query.domain.update({ user_id: id }, update));
}
// 5. wait for all tasks to finish
await Promise.all(tasks).catch((err) => {
throw new CustomError("Couldn't ban entries.");
});
// 6. send response
if (req.isHTML) {
res.setHeader("HX-Reswap", "outerHTML");
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/ban_user_success", {
email: user.email,
});
return;
}
return res.status(200).send({ message: "Banned user successfully." });
}
async function create(req, res) {
const salt = await bcrypt.genSalt(12);
req.body.password = await bcrypt.hash(req.body.password, salt);
const user = await query.user.create(req.body);
if (req.body.verification_email && !user.banned && !user.verified) {
await mail.verification(user);
}
if (req.isHTML) {
res.setHeader("HX-Trigger", "reloadMainTable");
res.render("partials/admin/dialog/create_user_success", {
email: user.email,
});
return;
}
return res.status(201).send({ message: "The user has been created successfully." });
}
module.exports = {
ban,
create,
get,
getAdmin,
remove,
removeByAdmin,
}

View File

@ -1,11 +1,12 @@
const { isAfter, subDays, subHours, addMilliseconds, differenceInHours } = require("date-fns");
const { body, param } = require("express-validator");
const { body, param, query: queryValidator } = require("express-validator");
const promisify = require("util").promisify;
const bcrypt = require("bcryptjs");
const dns = require("dns");
const URL = require("url");
const ms = require("ms");
const { ROLES } = require("../consts");
const query = require("../queries");
const utils = require("../utils");
const knex = require("../knex");
@ -187,6 +188,36 @@ const addDomain = [
.withMessage("Homepage is not valid.")
];
const addDomainAdmin = [
body("address", "Domain is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 3, max: 64 })
.withMessage("Domain length must be between 3 and 64.")
.trim()
.customSanitizer(utils.addProtocol)
.custom(value => utils.urlRegex.test(value))
.customSanitizer(value => {
const parsed = URL.parse(value);
return utils.removeWww(parsed.hostname || parsed.href);
})
.custom(value => value !== env.DEFAULT_DOMAIN)
.withMessage("You can't add the default domain.")
.custom(async value => {
const domain = await query.domain.find({ address: value });
if (domain) return Promise.reject();
})
.withMessage("Domain already exists."),
body("homepage")
.optional({ checkFalsy: true, nullable: true })
.customSanitizer(utils.addProtocol)
.custom(value => utils.urlRegex.test(value) || /^(?!https?|ftp)(\w+:|\/\/)/.test(value))
.withMessage("Homepage is not valid."),
body("banned")
.optional({ nullable: true })
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
]
const removeDomain = [
param("id", "ID is invalid.")
.exists({
@ -196,6 +227,19 @@ const removeDomain = [
.isLength({ min: 36, max: 36 })
];
const removeDomainAdmin = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isNumeric(),
queryValidator("links")
.optional({ nullable: true })
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
];
const deleteLink = [
param("id", "ID is invalid.")
.exists({
@ -251,6 +295,83 @@ const banLink = [
.isBoolean()
];
const banUser = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isNumeric(),
body("links", '"links" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
body("domains", '"domains" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean()
];
const banDomain = [
param("id", "ID is invalid.")
.exists({
checkFalsy: true,
checkNull: true
})
.isNumeric(),
body("links", '"links" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
body("domains", '"domains" should be a boolean.')
.optional({
nullable: true
})
.customSanitizer(sanitizeCheckbox)
.isBoolean()
];
const createUser = [
body("password", "Password is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 8, max: 64 })
.withMessage("Password length must be between 8 and 64."),
body("email", "Email is not valid.")
.exists({ checkFalsy: true, checkNull: true })
.trim()
.isEmail()
.isLength({ min: 0, max: 255 })
.withMessage("Email length must be max 255.")
.custom(async (value, { req }) => {
const user = await query.user.find({ email: value });
if (user)
return Promise.reject();
})
.withMessage("User already exists."),
body("role", "Role is not valid.")
.optional({ nullable: true, checkFalsy: true })
.trim()
.isIn([ROLES.USER, ROLES.ADMIN]),
body("verified")
.optional({ nullable: true })
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
body("banned")
.optional({ nullable: true })
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
body("verification_email")
.optional({ nullable: true })
.customSanitizer(sanitizeCheckbox)
.isBoolean(),
];
const getStats = [
param("id", "ID is invalid.")
.exists({
@ -340,6 +461,12 @@ const deleteUser = [
.withMessage("Password is not correct.")
];
const deleteUserByAdmin = [
param("id", "ID is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isNumeric()
];
// TODO: if user has posted malware should do something better
function cooldown(user) {
@ -429,7 +556,7 @@ async function bannedDomain(domain) {
});
if (isBanned) {
throw new utils.CustomError("URL is containing malware/scam.", 400);
throw new utils.CustomError("Domain is banned.", 400);
}
};
@ -456,7 +583,10 @@ async function bannedHost(domain) {
module.exports = {
addDomain,
addDomainAdmin,
banDomain,
banLink,
banUser,
bannedDomain,
bannedHost,
changeEmail,
@ -464,8 +594,10 @@ module.exports = {
checkUser,
cooldown,
createLink,
createUser,
deleteLink,
deleteUser,
deleteUserByAdmin,
editLink,
getStats,
linksCount,
@ -473,6 +605,7 @@ module.exports = {
malware,
redirectProtected,
removeDomain,
removeDomainAdmin,
reportLink,
resetPassword,
signup,

View File

@ -0,0 +1,41 @@
const { ROLES } = require("../consts");
const env = require("../env");
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
async function up(knex) {
const hasRole = await knex.schema.hasColumn("users", "role");
if (!hasRole) {
await knex.transaction(async function(trx) {
await trx.schema.alterTable("users", table => {
table
.enu("role", [ROLES.USER, ROLES.ADMIN])
.notNullable()
.defaultTo(ROLES.USER);
});
const adminEmails = env.ADMIN_EMAILS.split(",").map((e) => e.trim());
const adminRoleQuery = trx("users").update("role", ROLES.ADMIN);
adminEmails.forEach((adminEmail, index) => {
if (index === 0) {
adminRoleQuery.where("email", adminEmail);
} else {
adminRoleQuery.orWhere("email", adminEmail);
}
});
await adminRoleQuery;
});
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
async function down(knex) {};
module.exports = {
up,
down,
}

View File

@ -1,3 +1,5 @@
const { ROLES } = require("../consts");
async function createUserTable(knex) {
const hasTable = await knex.schema.hasTable("users");
if (!hasTable) {
@ -17,6 +19,10 @@ async function createUserTable(knex) {
.string("email")
.unique()
.notNullable();
table
.enu("role", [ROLES.USER, ROLES.ADMIN])
.notNullable()
.defaultTo(ROLES.USER);
table.string("password").notNullable();
table.datetime("cooldown").nullable();
table.integer("malicious_attempts").notNullable().defaultTo(0);

View File

@ -73,9 +73,147 @@ async function update(match, update) {
return domains;
}
function normalizeMatch(match) {
const newMatch = { ...match };
if (newMatch.address) {
newMatch["domains.address"] = newMatch.address;
delete newMatch.address;
}
if (newMatch.user_id) {
newMatch["domains.user_id"] = newMatch.user_id;
delete newMatch.user_id;
}
if (newMatch.uuid) {
newMatch["domains.uuid"] = newMatch.uuid;
delete newMatch.uuid;
}
if (newMatch.banned !== undefined) {
newMatch["domains.banned"] = newMatch.banned;
delete newMatch.banned;
}
return newMatch;
}
const selectable_admin = [
"domains.id",
"domains.address",
"domains.homepage",
"domains.banned",
"domains.created_at",
"domains.updated_at",
"domains.user_id",
"domains.uuid",
"users.email as email",
"links_count"
];
async function getAdmin(match, params) {
const query = knex("domains").select(...selectable_admin);
Object.entries(normalizeMatch(match)).forEach(([key, value]) => {
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
});
query
.offset(params.skip)
.limit(params.limit)
.fromRaw("domains")
.orderBy("domains.id", "desc")
.groupBy(1)
.groupBy("l.links_count")
.groupBy("users.email");
if (params?.user) {
const id = parseInt(params?.user);
if (Number.isNaN(id)) {
query.andWhereILike("users.email", "%" + params.user + "%");
} else {
query.andWhere("domains.user_id", id);
}
}
if (params?.search) {
query.andWhereRaw(
"concat_ws(' ', domains.address, domains.homepage) ILIKE '%' || ? || '%'",
[params.search]
);
}
if (params?.links !== undefined) {
query.andWhere("links_count", params?.links ? "is not" : "is", null);
}
query.leftJoin(
knex("links").select("domain_id").count("id as links_count").groupBy("domain_id").as("l"),
"domains.id",
"l.domain_id"
);
query.leftJoin("users", "domains.user_id", "users.id");
return query;
}
async function totalAdmin(match, params) {
const query = knex("domains");
Object.entries(normalizeMatch(match)).forEach(([key, value]) => {
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
});
if (params?.user) {
const id = parseInt(params?.user);
if (Number.isNaN(id)) {
query.andWhereILike("users.email", "%" + params.user + "%");
} else {
query.andWhere("domains.user_id", id);
}
}
if (params?.search) {
query.andWhereILike("domains.address", "%" + params.search + "%");
}
if (params?.links !== undefined) {
query.leftJoin(
knex("links").select("domain_id").count("id as links_count").groupBy("domain_id").as("l"),
"domains.id",
"l.domain_id"
);
query.andWhere("links_count", params?.links ? "is not" : "is", null);
}
query.leftJoin("users", "domains.user_id", "users.id");
query.count("domains.id");
const [{ count }] = await query;
return typeof count === "number" ? count : parseInt(count);
}
async function remove(domain) {
const deletedDomain = await knex("domains").where("id", domain.id).delete();
if (env.REDIS_ENABLED) {
redis.remove.domain(domain);
}
return !!deletedDomain;
}
module.exports = {
add,
find,
get,
getAdmin,
remove,
totalAdmin,
update,
}

View File

@ -24,6 +24,11 @@ const selectable = [
"domains.address as domain"
];
const selectable_admin = [
...selectable,
"users.email as email"
];
function normalizeMatch(match) {
const newMatch = { ...match };
@ -42,6 +47,11 @@ function normalizeMatch(match) {
delete newMatch.uuid;
}
if (newMatch.banned !== undefined) {
newMatch["links.banned"] = newMatch.banned;
delete newMatch.banned;
}
return newMatch;
}
@ -67,13 +77,49 @@ async function total(match, params) {
return typeof count === "number" ? count : parseInt(count);
}
async function totalAdmin(match, params) {
const query = knex("links");
Object.entries(normalizeMatch(match)).forEach(([key, value]) => {
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
});
if (params?.user) {
const id = parseInt(params?.user);
if (Number.isNaN(id)) {
query.andWhereILike("users.email", "%" + params.user + "%");
} else {
query.andWhere("links.user_id", params.user);
}
}
if (params?.search) {
query.andWhereRaw(
"concat_ws(' ', description, links.address, target) ILIKE '%' || ? || '%'",
[params.search]
);
}
if (params?.domain) {
query.andWhereRaw("domains.address ILIKE '%' || ? || '%'", [params.domain]);
}
query.leftJoin("domains", "links.domain_id", "domains.id");
query.leftJoin("users", "links.user_id", "users.id");
query.count("links.id");
const [{ count }] = await query;
return typeof count === "number" ? count : parseInt(count);
}
async function get(match, params) {
const query = knex("links")
.select(...selectable)
.where(normalizeMatch(match))
.offset(params.skip)
.limit(params.limit)
.orderBy("links.created_at", "desc");
.orderBy("links.id", "desc");
if (params?.search) {
query.andWhereRaw(
@ -87,6 +133,44 @@ async function get(match, params) {
return query;
}
async function getAdmin(match, params) {
const query = knex("links").select(...selectable_admin);
Object.entries(normalizeMatch(match)).forEach(([key, value]) => {
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
});
query
.orderBy("links.id", "desc")
.offset(params.skip)
.limit(params.limit)
if (params?.user) {
const id = parseInt(params?.user);
if (Number.isNaN(id)) {
query.andWhereILike("users.email", "%" + params.user + "%");
} else {
query.andWhere("links.user_id", params.user);
}
}
if (params?.search) {
query.andWhereRaw(
"concat_ws(' ', description, links.address, target) ILIKE '%' || ? || '%'",
[params.search]
);
}
if (params?.domain) {
query.andWhereRaw("domains.address ILIKE '%' || ? || '%'", [params.domain]);
}
query.leftJoin("domains", "links.domain_id", "domains.id");
query.leftJoin("users", "links.user_id", "users.id");
return query;
}
async function find(match) {
if (match.address && match.domain_id && env.REDIS_ENABLED) {
const key = redis.key.link(match.address, match.domain_id);
@ -151,17 +235,15 @@ async function remove(match) {
}
async function batchRemove(match) {
const deleteQuery = knex("links");
const findQuery = knex("links");
const query = knex("links");
Object.entries(match).forEach(([key, value]) => {
findQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
deleteQuery.andWhere(key, ...(Array.isArray(value) ? value : [value]));
query.andWhere(key, ...(Array.isArray(value) ? value : [value]));
});
const links = await findQuery;
const links = await query.clone();
await deleteQuery.delete();
await query.delete();
if (env.REDIS_ENABLED) {
links.forEach(redis.remove.link);
@ -197,8 +279,10 @@ module.exports = {
create,
find,
get,
getAdmin,
incrementVisit,
remove,
total,
totalAdmin,
update,
}

View File

@ -1,6 +1,7 @@
const { addMinutes } = require("date-fns");
const { v4: uuid } = require("uuid");
const { ROLES } = require("../consts");
const utils = require("../utils");
const redis = require("../redis");
const knex = require("../knex");
@ -94,9 +95,133 @@ async function remove(user) {
return !!deletedUser;
}
const selectable_admin = [
"users.id",
"users.email",
"users.verified",
"users.role",
"users.banned",
"users.banned_by_id",
"users.created_at",
"users.updated_at"
];
function normalizeMatch(match) {
const newMatch = { ...match }
if (newMatch.banned !== undefined) {
newMatch["users.banned"] = newMatch.banned;
delete newMatch.banned;
}
return newMatch;
}
async function getAdmin(match, params) {
const query = knex("users")
.select(...selectable_admin)
.select("l.links_count")
.select("d.domains")
.fromRaw("users")
.where(normalizeMatch(match))
.offset(params.skip)
.limit(params.limit)
.orderBy("users.id", "desc")
.groupBy(1)
.groupBy("l.links_count")
.groupBy("d.domains");
if (params?.search) {
const id = parseInt(params?.search);
if (Number.isNaN(id)) {
query.andWhereILike("users.email", "%" + params?.search + "%");
} else {
query.andWhere("users.id", params?.search);
}
}
if (params?.domains !== undefined) {
query.andWhere("d.domains", params?.domains ? "is not" : "is", null);
}
if (params?.links !== undefined) {
query.andWhere("links_count", params?.links ? "is not" : "is", null);
}
query.leftJoin(
knex("domains")
.select("user_id", knex.raw("string_agg(address, ', ') AS domains"))
.groupBy("user_id").as("d"),
"users.id",
"d.user_id"
)
query.leftJoin(
knex("links").select("user_id").count("id as links_count").groupBy("user_id").as("l"),
"users.id",
"l.user_id"
);
return query;
}
async function totalAdmin(match, params) {
const query = knex("users")
.count("users.id")
.fromRaw('users')
.where(normalizeMatch(match));
if (params?.search) {
const id = parseInt(params?.search);
if (Number.isNaN(id)) {
query.andWhereILike("users.email", "%" + params?.search + "%");
} else {
query.andWhere("users.id", params?.search);
}
}
if (params?.domains !== undefined) {
query.andWhere("domains", params?.domains ? "is not" : "is", null);
query.leftJoin(
knex("domains")
.select("user_id", knex.raw("string_agg(address, ', ') AS domains"))
.groupBy("user_id").as("d"),
"users.id",
"d.user_id"
);
}
if (params?.links !== undefined) {
query.andWhere("links", params?.links ? "is not" : "is", null);
query.leftJoin(
knex("links").select("user_id").count("id as links").groupBy("user_id").as("l"),
"users.id",
"l.user_id"
);
}
const [{count}] = await query;
return typeof count === "number" ? count : parseInt(count);
}
async function create(params) {
const [user] = await knex("users").insert({
email: params.email,
password: params.password,
role: params.role ?? ROLES.USER,
verified: params.verified ?? false,
banned: params.banned ?? false,
}, "*");
return user;
}
module.exports = {
add,
create,
find,
getAdmin,
remove,
totalAdmin,
update,
}

View File

@ -9,6 +9,17 @@ const auth = require("../handlers/auth.handler");
const router = Router();
router.get(
"/admin",
locals.viewTemplate("partials/admin/domains/table"),
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
helpers.parseQuery,
locals.adminTable,
asyncHandler(domains.getAdmin)
);
router.post(
"/",
locals.viewTemplate("partials/settings/domain/add_form"),
@ -19,6 +30,17 @@ router.post(
asyncHandler(domains.add)
);
router.post(
"/admin",
locals.viewTemplate("partials/admin/dialog/add_domain"),
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
validators.addDomainAdmin,
asyncHandler(helpers.verify),
asyncHandler(domains.addAdmin)
);
router.delete(
"/:id",
locals.viewTemplate("partials/settings/domain/delete"),
@ -29,4 +51,26 @@ router.delete(
asyncHandler(domains.remove)
);
router.delete(
"/admin/:id",
locals.viewTemplate("partials/admin/dialog/delete_domain"),
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
validators.removeDomainAdmin,
asyncHandler(helpers.verify),
asyncHandler(domains.removeAdmin)
);
router.post(
"/admin/ban/:id",
locals.viewTemplate("partials/admin/dialog/ban_domain"),
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
validators.banDomain,
asyncHandler(helpers.verify),
asyncHandler(domains.ban)
);
module.exports = router;

View File

@ -2,7 +2,6 @@ const { Router } = require("express");
const cors = require("cors");
const validators = require("../handlers/validators.handler");
const helpers = require("../handlers/helpers.handler");
const asyncHandler = require("../utils/asyncHandler");
const locals = require("../handlers/locals.handler");
@ -21,6 +20,17 @@ router.get(
asyncHandler(link.get)
);
router.get(
"/admin",
locals.viewTemplate("partials/admin/links/table"),
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
helpers.parseQuery,
locals.adminTable,
asyncHandler(link.getAdmin)
);
router.post(
"/",
cors(),
@ -45,6 +55,18 @@ router.patch(
asyncHandler(link.edit)
);
router.patch(
"/admin/:id",
locals.viewTemplate("partials/links/edit"),
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
locals.editLink,
validators.editLink,
asyncHandler(helpers.verify),
asyncHandler(link.editAdmin)
);
router.delete(
"/:id",
locals.viewTemplate("partials/links/dialog/delete"),

View File

@ -42,6 +42,14 @@ router.get(
asyncHandler(renders.settings)
);
router.get(
"/admin",
asyncHandler(auth.jwtPage),
asyncHandler(auth.admin),
asyncHandler(locals.user),
asyncHandler(renders.admin)
);
router.get(
"/stats",
asyncHandler(auth.jwtPage),
@ -119,6 +127,56 @@ router.get(
asyncHandler(renders.confirmLinkBan)
);
router.get(
"/confirm-user-delete",
locals.noLayout,
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
asyncHandler(renders.confirmUserDelete)
);
router.get(
"/confirm-user-ban",
locals.noLayout,
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
asyncHandler(renders.confirmUserBan)
);
router.get(
"/create-user",
locals.noLayout,
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
asyncHandler(renders.createUser)
);
router.get(
"/add-domain",
locals.noLayout,
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
asyncHandler(renders.addDomainAdmin)
);
router.get(
"/confirm-domain-ban",
locals.noLayout,
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
asyncHandler(renders.confirmDomainBan)
);
router.get(
"/confirm-domain-delete-admin",
locals.noLayout,
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
asyncHandler(renders.confirmDomainDeleteAdmin)
);
router.get(
"/link/edit/:id",
locals.noLayout,
@ -126,6 +184,14 @@ router.get(
asyncHandler(renders.linkEdit)
);
router.get(
"/admin/link/edit/:id",
locals.noLayout,
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
asyncHandler(renders.linkEditAdmin)
);
router.get(
"/add-domain-form",
locals.noLayout,

View File

@ -16,6 +16,28 @@ router.get(
asyncHandler(user.get)
);
router.get(
"/admin",
locals.viewTemplate("partials/admin/users/table"),
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
helpers.parseQuery,
locals.adminTable,
asyncHandler(user.getAdmin)
);
router.post(
"/admin",
locals.viewTemplate("partials/admin/dialog/create_user"),
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
validators.createUser,
asyncHandler(helpers.verify),
asyncHandler(user.create)
);
router.post(
"/delete",
locals.viewTemplate("partials/settings/delete_account"),
@ -26,4 +48,26 @@ router.post(
asyncHandler(user.remove)
);
router.delete(
"/admin/:id",
locals.viewTemplate("partials/admin/dialog/delete_user"),
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
validators.deleteUserByAdmin,
asyncHandler(helpers.verify),
asyncHandler(user.removeByAdmin)
);
router.post(
"/admin/ban/:id",
locals.viewTemplate("partials/admin/dialog/ban_user"),
asyncHandler(auth.apikey),
asyncHandler(auth.jwt),
asyncHandler(auth.admin),
validators.banUser,
asyncHandler(helpers.verify),
asyncHandler(user.ban)
);
module.exports = router;

View File

@ -7,6 +7,7 @@ const path = require("path");
const hbs = require("hbs");
const ms = require("ms");
const { ROLES } = require("../consts");
const env = require("../env");
class CustomError extends Error {
@ -20,10 +21,8 @@ class CustomError extends Error {
const urlRegex = /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i;
function isAdmin(email) {
return env.ADMIN_EMAILS.split(",")
.map((e) => e.trim())
.includes(email)
function isAdmin(user) {
return user.role === ROLES.ADMIN;
}
function signToken(user) {
@ -70,7 +69,7 @@ function getShortURL(address, domain) {
const protocol = (env.CUSTOM_DOMAIN_USE_HTTPS || !domain) && !env.isDev ? "https://" : "http://";
const link = `${domain || env.DEFAULT_DOMAIN}/${address}`;
const url = `${protocol}${link}`;
return { link, url };
return { address, link, url };
}
function getStatsLimit() {
@ -174,6 +173,12 @@ const preservedURLs = [
"pricing"
];
function parseBooleanQuery(query) {
if (query === "true" || query === true) return true;
if (query === "false" || query === false) return false;
return undefined;
}
function getInitStats() {
return Object.create({
browser: {
@ -257,8 +262,43 @@ const sanitize = {
relative_created_at: getTimeAgo(timestamps.created_at),
relative_expire_in: link.expire_in && ms(differenceInMilliseconds(parseDatetime(link.expire_in), new Date()), { long: true }),
password: !!link.password,
visit_count: link.visit_count.toLocaleString("en-US"),
link: getShortURL(link.address, link.domain)
}
},
link_admin: link => {
const timestamps = parseTimestamps(link);
return {
...link,
...timestamps,
domain: link.domain || env.DEFAULT_DOMAIN,
id: link.uuid,
relative_created_at: getTimeAgo(timestamps.created_at),
relative_expire_in: link.expire_in && ms(differenceInMilliseconds(parseDatetime(link.expire_in), new Date()), { long: true }),
password: !!link.password,
visit_count: link.visit_count.toLocaleString("en-US"),
link: getShortURL(link.address, link.domain)
}
},
user_admin: user => {
const timestamps = parseTimestamps(user);
return {
...user,
...timestamps,
links_count: (user.links_count ?? 0).toLocaleString("en-US"),
relative_created_at: getTimeAgo(timestamps.created_at),
relative_updated_at: getTimeAgo(timestamps.updated_at),
}
},
domain_admin: domain => {
const timestamps = parseTimestamps(domain);
return {
...domain,
...timestamps,
links_count: (domain.links_count ?? 0).toLocaleString("en-US"),
relative_created_at: getTimeAgo(timestamps.created_at),
relative_updated_at: getTimeAgo(timestamps.updated_at),
}
}
};
@ -309,6 +349,7 @@ module.exports = {
getStatsLimit,
getStatsPeriods,
isAdmin,
parseBooleanQuery,
parseDatetime,
parseTimestamps,
preservedURLs,

3
server/views/admin.hbs Normal file
View File

@ -0,0 +1,3 @@
{{> header}}
{{> admin/index}}
{{> footer}}

View File

@ -0,0 +1,56 @@
<div class="content admin-create">
<h2>Add domain</h2>
<form
id="add-domain-form"
hx-post="/api/domains/admin"
hx-target="closest .content"
hx-swap="outerHTML"
hx-indicator="closest .content"
>
<label class="{{#if errors.address}}error{{/if}}">
Address:
<input
name="address"
id="add-domain-address"
type="text"
placeholder="Address..."
hx-preserve="true"
/>
{{#if errors.address}}<p class="error">{{errors.address}}</p>{{/if}}
</label>
<label class="{{#if errors.homepage}}error{{/if}}">
Homepage (optional):
<input
name="homepage"
id="add-domain-homepage"
type="text"
placeholder="Homepage address.."
hx-preserve="true"
/>
{{#if errors.homepage}}<p class="error">{{errors.homepage}}</p>{{/if}}
</label>
<label class="checkbox">
<input
id="add-domain-banned"
name="banned"
type="checkbox"
onchange="canSendVerificationEmail();"
hx-preserve="true"
/>
Banned
</label>
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Cancel</button>
<button type="submit" class="primary">
<span>{{> icons/plus}}</span>
Add
</button>
{{> icons/spinner}}
</div>
</form>
<div id="dialog-error">
{{#if error}}
<p class="error">{{error}}</p>
{{/if}}
</div>
</div>

View File

@ -0,0 +1,12 @@
<div class="content">
<div class="icon success">
{{> icons/check}}
</div>
<p>
The domain <b>"{{address}}"</b> has been created successfully.
</p>
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Close</button>
</div>
</div>

View File

@ -0,0 +1,46 @@
<div class="content">
<h2>Ban domain?</h2>
<p>
Are you sure do you want to ban the domain &quot;<b>{{address}}</b>&quot;?
</p>
<div class="ban-checklist">
{{#if hasUser}}
<label class="checkbox">
<input id="ban-domain-user" name="user" type="checkbox" />
Owner
</label>
{{/if}}
{{#if hasLink}}
<label class="checkbox">
<input id="ban-domain-links" name="links" type="checkbox" />
Links
</label>
{{/if}}
</div>
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Cancel</button>
<button
type="button"
class="danger confirm"
hx-post="/api/domains/admin/ban/{id}"
hx-ext="path-params"
hx-vals='{"id":"{{id}}"}'
hx-target="closest .content"
hx-swap="none"
hx-include=".ban-checklist"
hx-indicator="closest .content"
hx-select-oob="#dialog-error"
>
<span class="stop">
{{> icons/stop}}
</span>
Ban
</button>
{{> icons/spinner}}
</div>
<div id="dialog-error">
{{#if error}}
<p class="error">{{error}}</p>
{{/if}}
</div>
</div>

View File

@ -0,0 +1,12 @@
<div class="content">
<div class="icon success">
{{> icons/check}}
</div>
<p>
The domain <b>"{{address}}"</b> is banned.
</p>
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Close</button>
</div>
</div>

View File

@ -0,0 +1,42 @@
<div class="content">
<h2>Ban user?</h2>
<p>
Are you sure do you want to ban the user &quot;<b>{{email}}</b>&quot;?
</p>
<div class="ban-checklist">
<label class="checkbox">
<input id="ban-user-links" name="links" type="checkbox" />
User links
</label>
<label class="checkbox">
<input id="ban-user-domains" name="domains" type="checkbox" />
User domains
</label>
</div>
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Cancel</button>
<button
type="button"
class="danger confirm"
hx-post="/api/users/admin/ban/{id}"
hx-ext="path-params"
hx-vals='{"id":"{{id}}"}'
hx-target="closest .content"
hx-swap="none"
hx-include=".ban-checklist"
hx-indicator="closest .content"
hx-select-oob="#dialog-error"
>
<span class="stop">
{{> icons/stop}}
</span>
Ban
</button>
{{> icons/spinner}}
</div>
<div id="dialog-error">
{{#if error}}
<p class="error">{{error}}</p>
{{/if}}
</div>
</div>

View File

@ -0,0 +1,12 @@
<div class="content">
<div class="icon success">
{{> icons/check}}
</div>
<p>
The user <b>"{{email}}"</b> is banned.
</p>
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Close</button>
</div>
</div>

View File

@ -0,0 +1,81 @@
<div class="content create-user">
<h2>Create user</h2>
<form
id="create-user-form"
hx-post="/api/users/admin"
hx-target="closest .content"
hx-swap="outerHTML"
hx-indicator="closest .content"
>
<label class="{{#if errors.email}}error{{/if}}">
Email address:
<input
name="email"
id="create-user-email"
type="email"
placeholder="Email address..."
hx-preserve="true"
/>
{{#if errors.email}}<p class="error">{{errors.email}}</p>{{/if}}
</label>
<label class="{{#if errors.password}}error{{/if}}">
Password:
<input
name="password"
id="create-user-password"
type="password"
placeholder="Password..."
hx-preserve="true"
/>
{{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
</label>
<label class="{{#if errors.role}}error{{/if}}">
Role:
<select name="role" id="create-user-role" hx-preserve="true">
<option value="USER" selected>User</option>
<option value="ADMIN">Admin</option>
</select>
{{#if errors.role}}<p class="error">{{errors.role}}</p>{{/if}}
</label>
<div class="checkbox-wrapper">
<label class="checkbox">
<input
id="create-user-verified"
name="verified"
type="checkbox"
onchange="canSendVerificationEmail();"
hx-preserve="true"
checked
/>
Verified
</label>
<label class="checkbox">
<input
id="create-user-banned"
name="banned"
type="checkbox"
onchange="canSendVerificationEmail();"
hx-preserve="true"
/>
Banned
</label>
</div>
<label id="send-email-label" class="checkbox hidden" hx-preserve="true">
<input id="create-user-send-email" name="verification_email" type="checkbox" />
Send verification email
</label>
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Cancel</button>
<button type="submit" class="primary">
<span>{{> icons/new_user}}</span>
Create
</button>
{{> icons/spinner}}
</div>
</form>
<div id="dialog-error">
{{#if error}}
<p class="error">{{error}}</p>
{{/if}}
</div>
</div>

View File

@ -0,0 +1,12 @@
<div class="content">
<div class="icon success">
{{> icons/check}}
</div>
<p>
The user <b>"{{email}}"</b> has been created successfully.
</p>
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Close</button>
</div>
</div>

View File

@ -0,0 +1,40 @@
<div class="content">
<h2>Delete domain?</h2>
<p>
Are you sure do you want to delete the domain &quot;<b>{{address}}</b>&quot;?<br/>
</p>
{{#if hasLink}}
<div class="delete-domain-checklist">
<label class="checkbox">
<input id="delete-domain-links" name="links" type="checkbox" />
Delete all links too
</label>
</div>
{{/if }}
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Cancel</button>
<button
type="button"
class="danger confirm"
hx-delete="/api/domains/admin/{id}"
hx-ext="path-params"
hx-vals='{"id":"{{id}}"}'
hx-target="closest .content"
hx-swap="none"
{{#if hasLink}}
hx-include=".delete-domain-checklist"
{{/if}}
hx-indicator="closest .content"
hx-select-oob="#dialog-error"
>
<span>{{> icons/trash}}</span>
Delete
</button>
{{> icons/spinner}}
</div>
<div id="dialog-error">
{{#if error}}
<p class="error">{{error}}</p>
{{/if}}
</div>
</div>

View File

@ -0,0 +1,12 @@
<div class="content">
<div class="icon success">
{{> icons/check}}
</div>
<p>
The domain <b>"{{address}}"</b> has been deleted.
</p>
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Close</button>
</div>
</div>

View File

@ -0,0 +1,30 @@
<div class="content">
<h2>Delete user?</h2>
<p>
Are you sure do you want to delete the user &quot;<b>{{email}}</b>&quot;?<br/>
<b>All their data including their links</b> will be deleted.
</p>
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Cancel</button>
<button
type="button"
class="danger confirm"
hx-delete="/api/users/admin/{id}"
hx-ext="path-params"
hx-vals='{"id":"{{id}}"}'
hx-target="closest .content"
hx-swap="none"
hx-indicator="closest .content"
hx-select-oob="#dialog-error"
>
<span>{{> icons/trash}}</span>
Delete
</button>
{{> icons/spinner}}
</div>
<div id="dialog-error">
{{#if error}}
<p class="error">{{error}}</p>
{{/if}}
</div>
</div>

View File

@ -0,0 +1,12 @@
<div class="content">
<div class="icon success">
{{> icons/check}}
</div>
<p>
The user <b>"{{email}}"</b> has been deleted.
</p>
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Close</button>
</div>
</div>

View File

@ -0,0 +1,8 @@
<div id="admin-table-dialog" class="dialog">
<div class="box">
<div class="content-wrapper"></div>
<div class="loading">
{{> icons/spinner}}
</div>
</div>
</div>

View File

@ -0,0 +1,11 @@
<div class="content">
{{#if error}}
<p>{{error}}</p>
{{else}}
<p>{{message}}</p>
{{/if}}
<div class="buttons">
<button type="button" hx-on:click="closeDialog()">Close</button>
</div>
</div>

View File

@ -0,0 +1,29 @@
<td class="actions domains-actions">
{{#if banned}}
<button class="action banned" disabled="true" data-tooltip="Banned">
{{> icons/stop}}
</button>
{{/if}}
{{#unless banned}}
<button
class="action ban"
hx-on:click='openDialog("admin-table-dialog")'
hx-get="/confirm-domain-ban"
hx-target="#admin-table-dialog .content-wrapper"
hx-indicator="#admin-table-dialog"
hx-vals='{"id":"{{id}}"}'
>
{{> icons/stop}}
</button>
{{/unless}}
<button
class="action delete"
hx-on:click='openDialog("admin-table-dialog")'
hx-get="/confirm-domain-delete-admin"
hx-target="#admin-table-dialog .content-wrapper"
hx-indicator="#admin-table-dialog"
hx-vals='{"id":"{{id}}"}'
>
{{> icons/trash}}
</button>
</td>

View File

@ -0,0 +1,16 @@
{{#unless table_domains}}
{{#ifEquals table_domains.length 0}}
<tr class="no-data">
<td>
No domains.
</td>
</tr>
{{else}}
<tr class="loading-placeholder">
<td>
{{> icons/spinner}}
Loading domains...
</td>
</tr>
{{/ifEquals}}
{{/unless}}

View File

@ -0,0 +1,30 @@
<table
hx-get="/api/domains/admin"
hx-target="tbody"
hx-swap="outerHTML"
hx-select="tbody"
hx-disinherit="*"
hx-include=".domains-controls"
hx-params="not total"
hx-sync="this:replace"
hx-select-oob="#total,#category-total"
hx-trigger="
{{#if onload}}load once,{{/if}}
reloadMainTable from:body,
click delay:100ms from:button.nav,
input changed delay:500ms from:[name='search'],
input changed delay:500ms from:[name='user'],
input changed from:[name='banned'],
input changed from:[name='links'],
input changed from:[name='owner'],
"
hx-on:htmx:after-on-load="updateLinksNav()"
hx-on:htmx:after-settle="onSearchInputLoad();"
>
{{> admin/domains/thead}}
{{> admin/domains/tbody}}
{{> admin/domains/tfoot}}
</table>
<template>
<h2 id="admin-table-title" hx-swap-oob="true">Recent added domains.</h2>
</template>

View File

@ -0,0 +1,6 @@
<tbody>
{{> admin/domains/loading}}
{{#each table_domains}}
{{> admin/domains/tr}}
{{/each}}
</tbody>

View File

@ -0,0 +1,5 @@
<tfoot>
<tr class="controls domains-controls">
{{> admin/table_nav}}
</tr>
</tfoot>

View File

@ -0,0 +1,88 @@
<thead>
{{> admin/table_tab title='domains'}}
<tr class="controls domains-controls with-filters">
<th class="filters">
<div>
<div class="search-input-wrapper">
<input
id="search"
name="search"
type="text"
placeholder="Search domain..."
class="table-input search admin"
hx-on:input="onSearchChange(event)"
hx-on:keyup="resetTableNav()"
value="{{query.search}}"
/>
<button
type="button"
aria-label="Clear search"
class="clear"
onclick="clearSeachInput(event)"
>
{{> icons/x}}
</button>
</div>
<div class="search-input-wrapper">
<input
id="search_user"
name="user"
type="text"
placeholder="Search user..."
class="table-input search admin"
hx-on:input="onSearchChange(event)"
hx-on:keyup="resetTableNav()"
value="{{query.user}}"
/>
<button
type="button"
aria-label="Clear user"
class="clear"
onclick="clearSeachInput(event)"
>
{{> icons/x}}
</button>
</div>
<select id="domains-select-banned" name="banned" class="table-input ban" hx-on:change="resetTableNav()">
<option value="" selected>Banned...</option>
<option value="true">Banned</option>
<option value="false">Not banned</option>
</select>
</div>
<div>
<select id="domains-select-links" name="links" class="table-input links" hx-on:change="resetTableNav()">
<option value="" selected>Links...</option>
<option value="true" {{#ifEquals query.links 'true'}}selected{{/ifEquals}}>With links</option>
<option value="false" {{#ifEquals query.links 'true'}}selected{{/ifEquals}}>No links</option>
</select>
<select id="domains-select-owner" name="owner" class="table-input owner" hx-on:change="resetTableNav()">
<option value="" selected>Owner...</option>
<option value="true" {{#ifEquals query.owner 'true'}}selected{{/ifEquals}}>With owner</option>
<option value="false" {{#ifEquals query.owner 'true'}}selected{{/ifEquals}}>No owner</option>
</select>
<input id="total" name="total" type="hidden" value="{{total}}" />
<input id="limit" name="limit" type="hidden" value="10" />
<input id="skip" name="skip" type="hidden" value="0" />
<button
class="table primary"
hx-on:click='openDialog("admin-table-dialog")'
hx-get="/add-domain"
hx-target="#admin-table-dialog .content-wrapper"
hx-indicator="#admin-table-dialog"
>
<span>{{> icons/plus}}</span>
Add domain
</button>
</div>
</th>
{{> admin/table_nav}}
</tr>
<tr>
<th class="domains-id">ID</th>
<th class="domains-address">Address</th>
<th class="domains-homepage">Homepage</th>
<th class="domains-created-at">Created at</th>
<th class="domains-links-count">Total links</th>
<th class="domains-actions"></th>
</tr>
</thead>

View File

@ -0,0 +1,93 @@
<tr id="tr-{{id}}" {{#if swap_oob}}hx-swap-oob="true"{{/if}}>
<td class="domains-id">
{{id}}
</td>
<td class="domains-address right-fade">
{{address}}
<p class="description">
by&nbsp;
{{~#if user_id~}}
<a
aria-label="View user"
data-tooltip="View user"
hx-get="/api/users/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-sync="this:replace"
hx-indicator="closest table"
hx-vals='{"search":"{{email}}"}'
onclick="setTab(event, 'tab-links')"
>
{{email}}
</a>
{{#ifEquals @root.query.user email}}
{{else}}
&nbsp;(
<a
aria-label="View domains"
data-tooltip="View domains"
hx-get="/api/domains/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-sync="this:replace"
hx-indicator="closest table"
hx-vals='{"user":"{{email}}"}'
>
view domains
</a>)
{{/ifEquals}}
{{~else~}}
<a
aria-label="View system domains"
data-tooltip="View system domains"
hx-get="/api/domains/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-sync="this:replace"
hx-indicator="closest table"
hx-vals='{"owner":"false"}'
>
System
</a>
{{~/if~}}
&nbsp;{{~#if description~}}· {{description}}{{~/if}}
</p>
</td>
<td class="domains-homepage right-fade">
{{#if homepage}}
<a href="{{homepage}}" target="_blank" rel="noopener noreferrer">
{{homepage}}
</a>
{{else}}
No homepage
{{/if}}
</td>
<td class="domains-created-at">
{{relative_created_at}}
</td>
<td class="domains-links-count">
{{#ifEquals links_count '0'}}
{{links_count}}
{{else}}
<a
data-tooltip="View links"
aria-label="View links"
hx-get="/api/links/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-sync="this:replace"
hx-vals='{"domain":"{{address}}"}'
hx-indicator="closest table"
onclick="setTab(event, 'tab-links')"
>
{{links_count}}
</a>
{{/ifEquals}}
</td>
{{> admin/domains/actions}}
</tr>
<tr class="edit">
<td class="loading">
{{> icons/spinner}}
</td>
</tr>

View File

@ -0,0 +1,5 @@
<section id="main-table-wrapper" class="admin-table-wrapper">
<h2 id="admin-table-title">Recent shortened links.</h2>
{{> admin/links/table onload=true}}
{{> admin/dialog/frame}}
</section>

View File

@ -0,0 +1,71 @@
<td class="actions">
{{#if password}}
<button class="action password" disabled="true" data-tooltip="Password protected">
{{> icons/key}}
</button>
{{/if}}
{{#if banned}}
<button class="action banned" disabled="true" data-tooltip="Banned">
{{> icons/stop}}
</button>
{{/if}}
<a
class="button action stats"
href="/stats?id={{id}}"
title="Stats"
class="action stats"
>
{{> icons/chart}}
</a>
<button
class="action qrcode"
hx-on:click="handleQRCode(this, 'admin-table-dialog')"
data-url="{{link.url}}"
>
{{> icons/qrcode}}
</button>
<button
class="action edit"
hx-trigger="click queue:none"
hx-ext="path-params"
hx-get="/admin/link/edit/{id}"
hx-vals='{"id":"{{id}}"}'
hx-swap="beforeend"
hx-target="next tr.edit"
hx-indicator="next tr.edit"
hx-sync="this:drop"
hx-on::before-request="
const tr = event.detail.target;
tr.classList.add('show');
if (tr.querySelector('.content')) {
event.preventDefault();
tr.classList.remove('show');
tr.removeChild(tr.querySelector('.content'));
}
"
>
{{> icons/pencil}}
</button>
{{#unless banned}}
<button
class="action ban"
hx-on:click='openDialog("admin-table-dialog")'
hx-get="/confirm-link-ban"
hx-target="#admin-table-dialog .content-wrapper"
hx-indicator="#admin-table-dialog"
hx-vals='{"id":"{{id}}"}'
>
{{> icons/stop}}
</button>
{{/unless}}
<button
class="action delete"
hx-on:click='openDialog("admin-table-dialog")'
hx-get="/confirm-link-delete"
hx-target="#admin-table-dialog .content-wrapper"
hx-indicator="#admin-table-dialog"
hx-vals='{"id":"{{id}}"}'
>
{{> icons/trash}}
</button>
</td>

View File

@ -0,0 +1,117 @@
<td class="content">
{{#if id}}
<form
id="edit-form-{{id}}"
hx-patch="/api/links/admin/{id}"
hx-ext="path-params"
hx-vals='{"id":"{{id}}"}'
hx-select="form"
hx-swap="outerHTML"
hx-sync="this:replace"
class="{{class}}"
>
<div>
<label class="{{#if errors.target}}error{{/if}}">
Target:
<input
id="edit-target-{{id}}"
name="target"
type="text"
placeholder="Target..."
required="true"
value="{{target}}"
hx-preserve="true"
/>
{{#if errors.target}}<p class="error">{{errors.target}}</p>{{/if}}
</label>
<label class="{{#if errors.address}}error{{/if}}">
localhost:3000/
<input
id="edit-address-{{id}}"
name="address"
type="text"
placeholder="Custom URL..."
required="true"
value="{{address}}"
hx-preserve="true"
/>
{{#if errors.address}}<p class="error">{{errors.address}}</p>{{/if}}
</label>
<label class="{{#if errors.password}}error{{/if}}">
Password:
<input
id="edit-password-{{id}}"
name="password"
type="password"
placeholder="Password..."
value="{{#if password}}••••••••{{/if}}"
hx-preserve="true"
/>
{{#if errors.password}}<p class="error">{{errors.password}}</p>{{/if}}
</label>
</div>
<div>
<label class="{{#if errors.description}}error{{/if}}">
Description:
<input
id="edit-description-{{id}}"
name="description"
type="text"
placeholder="Description..."
value="{{description}}"
hx-preserve="true"
/>
{{#if errors.description}}<p class="error">{{errors.description}}</p>{{/if}}
</label>
<label class="{{#if errors.expire_in}}error{{/if}}">
Expire in:
<input
id="edit-expire_in-{{id}}"
name="expire_in"
type="text"
placeholder="2 minutes/hours/days"
value="{{relative_expire_in}}"
hx-preserve="true"
/>
{{#if errors.expire_in}}<p class="error">{{errors.expire_in}}</p>{{/if}}
</label>
</div>
<div>
<button
type="button"
onclick="
const tr = closest('tr');
if (!tr) return;
tr.classList.remove('show');
tr.removeChild(tr.querySelector('.content'));
"
>
Close
</button>
<button type="submit" class="primary">
<span class="reload">
{{> icons/reload}}
</span>
<span class="loader">
{{> icons/spinner}}
</span>
Update
</button>
</div>
<div class="response">
{{#if error}}
{{#unless errors}}
<p class="error">{{error}}</p>
{{/unless}}
{{else if success}}
<p class="success">{{success}}</p>
{{/if}}
</div>
<template>
{{> admin/links/tr}}
</template>
</form>
{{else}}
<p class="no-data">No link was found.</p>
{{/if}}
</td>

View File

@ -0,0 +1,16 @@
{{#unless links}}
{{#ifEquals links.length 0}}
<tr class="no-data">
<td>
No links.
</td>
</tr>
{{else}}
<tr class="loading-placeholder">
<td>
{{> icons/spinner}}
Loading links...
</td>
</tr>
{{/ifEquals}}
{{/unless}}

View File

@ -0,0 +1,31 @@
<table
hx-get="/api/links/admin"
hx-target="tbody"
hx-swap="outerHTML"
hx-select="tbody"
hx-disinherit="*"
hx-include=".links-controls"
hx-params="not total"
hx-sync="this:replace"
hx-select-oob="#total,#category-total"
hx-trigger="
{{#if onload}}load once,{{/if}}
reloadMainTable from:body,
click delay:100ms from:button.nav,
input changed delay:500ms from:[name='search'],
input changed delay:500ms from:[name='user'],
input changed delay:500ms from:[name='domain'],
input changed from:[name='banned'],
input changed from:[name='anonymous'],
input changed from:[name='has_domain'],
"
hx-on:htmx:after-on-load="updateLinksNav();"
hx-on:htmx:after-settle="onSearchInputLoad();"
>
{{> admin/links/thead}}
{{> admin/links/tbody}}
{{> admin/links/tfoot}}
</table>
<template>
<h2 id="admin-table-title" hx-swap-oob="true">Recent shortened links.</h2>
</template>

View File

@ -0,0 +1,6 @@
<tbody>
{{> admin/links/loading}}
{{#each links}}
{{> admin/links/tr}}
{{/each}}
</tbody>

View File

@ -0,0 +1,5 @@
<tfoot>
<tr class="controls links-controls">
{{> admin/table_nav}}
</tr>
</tfoot>

View File

@ -0,0 +1,112 @@
<thead>
{{> admin/table_tab title='links'}}
<tr class="controls links-controls with-filters">
<th class="filters">
<div>
<div class="search-input-wrapper">
<input
id="search"
name="search"
type="text"
placeholder="Search link..."
class="table-input search admin"
hx-on:input="onSearchChange(event)"
hx-on:keyup="resetTableNav()"
value="{{query.search}}"
/>
<button
type="button"
aria-label="Clear search"
class="clear"
onclick="clearSeachInput(event)"
>
{{> icons/x}}
</button>
</div>
<div class="search-input-wrapper">
<input
id="search_domain"
name="domain"
type="text"
placeholder="Search domain..."
class="table-input search admin"
hx-on:input="onSearchChange(event)"
hx-on:keyup="resetTableNav()"
value="{{query.domain}}"
/>
<button
type="button"
aria-label="Clear user search"
class="clear"
onclick="clearSeachInput(event)"
>
{{> icons/x}}
</button>
</div>
<div class="search-input-wrapper">
<input
id="search_user"
name="user"
type="text"
placeholder="Search user..."
class="table-input search admin"
hx-on:input="onSearchChange(event)"
hx-on:keyup="resetTableNav()"
value="{{query.user}}"
/>
<button
type="button"
aria-label="Clear user search"
class="clear"
onclick="clearSeachInput(event)"
>
{{> icons/x}}
</button>
</div>
</div>
<div>
<select
id="links-select-banned"
name="banned"
class="table-input ban"
hx-on:change="resetTableNav()"
>
<option value="" selected>Banned...</option>
<option value="true">Banned</option>
<option value="false">Not banned</option>
</select>
<select
id="links-select-anonymous"
name="anonymous"
class="table-input anonymous"
hx-on:change="resetTableNav()"
>
<option value="">Anonymous...</option>
<option value="true" {{#ifEquals query.anonymous 'true'}}selected{{/ifEquals}}>Anonymous</option>
<option value="false" {{#ifEquals query.anonymous 'false'}}selected{{/ifEquals}}>User</option>
</select>
<select
id="links-select-anonymous"
name="has_domain"
class="table-input has_domain"
hx-on:change="resetTableNav()"
>
<option value="">Domain...</option>
<option value="true" {{#ifEquals query.has_domain 'true'}}selected{{/ifEquals}}>With domain</option>
<option value="false" {{#ifEquals query.has_domain 'false'}}selected{{/ifEquals}}>No domain</option>
</select>
<input id="total" name="total" type="hidden" value="{{total}}" />
<input id="limit" name="limit" type="hidden" value="10" />
<input id="skip" name="skip" type="hidden" value="0" />
</div>
</th>
{{> admin/table_nav}}
</tr>
<tr>
<th class="original-url">Original URL</th>
<th class="created-at">Created at</th>
<th class="short-link">Short link</th>
<th class="views">Views</th>
<th class="actions"></th>
</tr>
</thead>

View File

@ -0,0 +1,99 @@
<tr id="tr-{{id}}" {{#if swap_oob}}hx-swap-oob="true"{{/if}}>
<td class="original-url right-fade">
<a href="{{target}}" target="_blank" rel="noopener noreferrer">
{{target}}
</a>
<p class="description">
by&nbsp;
{{~#if user_id~}}
<a
aria-label="View user"
data-tooltip="View user"
hx-get="/api/users/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-sync="this:replace"
hx-indicator="closest table"
hx-vals='{"search":"{{email}}"}'
onclick="setTab(event, 'tab-links')"
>
{{email}}
</a>
{{#ifEquals @root.query.user email}}
{{else}}
&nbsp;(
<a
aria-label="View links by this user"
data-tooltip="View links by this user"
hx-get="/api/links/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-sync="this:replace"
hx-indicator="closest table"
hx-vals='{"user":"{{email}}"}'
>
view links
</a>)
{{/ifEquals}}
{{~else~}}
<a
aria-label="View anonymous links"
data-tooltip="View anonymous links"
hx-get="/api/links/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-sync="this:replace"
hx-indicator="closest table"
hx-vals='{"anonymous":"true"}'
>
Anonymous
</a>
{{~/if~}}
&nbsp;{{~#if description~}}· {{description}}{{~/if}}
</p>
</td>
<td class="created-at">
{{relative_created_at}}
{{#if relative_expire_in}}
<p class="expire-in">
Expires in {{relative_expire_in}}
</p>
{{/if}}
</td>
<td class="short-link right-fade">
<div class="short-link-wrapper">
<div class="clipboard small">
<button
aria-label="Copy"
hx-on:click="handleShortURLCopyLink(this);"
data-url="{{link.url}}"
>
{{> icons/copy}}
</button>
{{> icons/check}}
</div>
<a href="{{link.url}}">/{{link.address}}</a>
</div>
<p class="description">
<a
aria-label="View links by this domain"
data-tooltip="View links by this domain"
hx-get="/api/links/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-sync="this:replace"
hx-vals='{"domain":"{{domain}}"}'
hx-indicator="closest table"
>{{domain}}</a>
</p>
</td>
<td class="views">
{{visit_count}}
</td>
{{> admin/links/actions}}
</tr>
<tr class="edit">
<td class="loading">
{{> icons/spinner}}
</td>
</tr>

View File

@ -0,0 +1,16 @@
<th class="nav" >
<div class="limit">
<button type="button" class="nav" onclick="setLinksLimit(event)" disabled="true">10</button>
<button type="button" class="nav" onclick="setLinksLimit(event)">20</button>
<button type="button" class="nav" onclick="setLinksLimit(event)">50</button>
</div>
<div class="nav-divider"></div>
<div id="pagination" class="pagination">
<button type="button" class="nav prev" onclick="setLinksSkip(event, 'prev')" disabled="true">
{{> icons/chevron_left}}
</button>
<button type="button" class="nav next" onclick="setLinksSkip(event, 'next')">
{{> icons/chevron_right}}
</button>
</div>
</th>

View File

@ -0,0 +1,62 @@
<tr class="category">
<th class="category-total">
<p id="category-total">
Total {{title}}: <b>{{#if total includeZero=true}}{{total_formatted}}{{else}}-{{/if}}</b>
</p>
</th>
<th class="category-tab">
<nav class="tab" role="tablist">
<a
id="tab-links"
role="tab"
hx-get="/api/links/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-disinherit="*"
hx-sync="this:replace"
hx-indicator="closest table"
onclick="setTab(event)"
{{#ifEquals title 'links'}}
class="active"
hx-on:htmx:before-request="event.preventDefault()"
{{/ifEquals}}
>
Links
</a>
<a
id="tab-users"
role="tab"
hx-get="/api/users/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-disinherit="*"
hx-sync="this:replace"
hx-indicator="closest table"
onclick="setTab(event)"
{{#ifEquals title 'users'}}
class="active"
hx-on:htmx:before-request="event.preventDefault()"
{{/ifEquals}}
>
Users
</a>
<a
id="tab-domains"
role="tab"
hx-get="/api/domains/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-disinherit="*"
hx-sync="this:replace"
hx-indicator="closest table"
onclick="setTab(event)"
{{#ifEquals title 'domains'}}
class="active"
hx-on:htmx:before-request="event.preventDefault()"
{{/ifEquals}}
>
Domains
</a>
</nav>
</th>
</tr>

View File

@ -0,0 +1,29 @@
<td class="actions users-actions">
{{#if banned}}
<button class="action banned" disabled="true" data-tooltip="Banned">
{{> icons/stop}}
</button>
{{/if}}
{{#unless banned}}
<button
class="action ban"
hx-on:click='openDialog("admin-table-dialog")'
hx-get="/confirm-user-ban"
hx-target="#admin-table-dialog .content-wrapper"
hx-indicator="#admin-table-dialog"
hx-vals='{"id":"{{id}}"}'
>
{{> icons/stop}}
</button>
{{/unless}}
<button
class="action delete"
hx-on:click='openDialog("admin-table-dialog")'
hx-get="/confirm-user-delete"
hx-target="#admin-table-dialog .content-wrapper"
hx-indicator="#admin-table-dialog"
hx-vals='{"id":"{{id}}"}'
>
{{> icons/trash}}
</button>
</td>

View File

@ -0,0 +1,16 @@
{{#unless users}}
{{#ifEquals users.length 0}}
<tr class="no-data">
<td>
No users.
</td>
</tr>
{{else}}
<tr class="loading-placeholder">
<td>
{{> icons/spinner}}
Loading users...
</td>
</tr>
{{/ifEquals}}
{{/unless}}

View File

@ -0,0 +1,31 @@
<table
hx-get="/api/users/admin"
hx-target="tbody"
hx-swap="outerHTML"
hx-select="tbody"
hx-disinherit="*"
hx-include=".users-controls"
hx-params="not total"
hx-sync="this:replace"
hx-select-oob="#total,#category-total"
hx-trigger="
{{#if onload}}load once,{{/if}}
reloadMainTable from:body,
click delay:100ms from:button.nav,
input changed delay:500ms from:[name='search'],
input changed from:[name='verified'],
input changed from:[name='banned'],
input changed from:[name='role'],
input changed from:[name='domains'],
input changed from:[name='links'],
"
hx-on:htmx:after-on-load="updateLinksNav()"
hx-on:htmx:after-settle="onSearchInputLoad();"
>
{{> admin/users/thead}}
{{> admin/users/tbody}}
{{> admin/users/tfoot}}
</table>
<template>
<h2 id="admin-table-title" hx-swap-oob="true">Recent created users.</h2>
</template>

View File

@ -0,0 +1,6 @@
<tbody>
{{> admin/users/loading}}
{{#each users}}
{{> admin/users/tr}}
{{/each}}
</tbody>

View File

@ -0,0 +1,5 @@
<tfoot>
<tr class="controls users-controls">
{{> admin/table_nav}}
</tr>
</tfoot>

View File

@ -0,0 +1,79 @@
<thead>
{{> admin/table_tab title='users'}}
<tr class="controls users-controls with-filters">
<th class="filters">
<div>
<div class="search-input-wrapper">
<input
id="search"
name="search"
type="text"
placeholder="Search user..."
class="table-input search admin"
hx-on:input="onSearchChange(event)"
hx-on:keyup="resetTableNav()"
value="{{query.search}}"
/>
<button
type="button"
aria-label="Clear search"
class="clear"
onclick="clearSeachInput(event)"
>
{{> icons/x}}
</button>
</div>
<select id="users-select-verified" name="verified" class="table-input verification" hx-on:change="resetTableNav()">
<option value="">Verification...</option>
<option value="true" {{#ifEquals query.verified 'true'}}selected{{/ifEquals}}>Verified</option>
<option value="false" {{#ifEquals query.verified 'false'}}selected{{/ifEquals}}>Not verified</option>
</select>
<select id="users-select-banned" name="banned" class="table-input ban" hx-on:change="resetTableNav()">
<option value="" selected>Banned...</option>
<option value="true">Banned</option>
<option value="false">Not banned</option>
</select>
<select id="users-select-role" name="role" class="table-input role" hx-on:change="resetTableNav()">
<option value="">Role...</option>
<option value="USER" {{#ifEquals query.role 'USER'}}selected{{/ifEquals}}>User</option>
<option value="ADMIN" {{#ifEquals query.role 'ADMIN'}}selected{{/ifEquals}}>Admin</option>
</select>
</div>
<div>
<select id="users-select-domain" name="domains" class="table-input domains" hx-on:change="resetTableNav()">
<option value="">Domain...</option>
<option value="true" {{#ifEquals query.domains 'true'}}selected{{/ifEquals}}>With domains</option>
<option value="false" {{#ifEquals query.domains 'false'}}selected{{/ifEquals}}>No domains</option>
</select>
<select id="users-select-links" name="links" class="table-input links" hx-on:change="resetTableNav()">
<option value="" selected>Links...</option>
<option value="true" {{#ifEquals query.links 'true'}}selected{{/ifEquals}}>With links</option>
<option value="false" {{#ifEquals query.links 'true'}}selected{{/ifEquals}}>No links</option>
</select>
<input id="total" name="total" type="hidden" value="{{total}}" />
<input id="limit" name="limit" type="hidden" value="10" />
<input id="skip" name="skip" type="hidden" value="0" />
<button
class="table primary"
hx-on:click='openDialog("admin-table-dialog")'
hx-get="/create-user"
hx-target="#admin-table-dialog .content-wrapper"
hx-indicator="#admin-table-dialog"
>
<span>{{> icons/new_user}}</span>
Create user
</button>
</div>
</th>
{{> admin/table_nav}}
</tr>
<tr>
<th class="users-id">ID</th>
<th class="users-email">Email</th>
<th class="users-created-at">Created at</th>
<th class="users-verified">Verified</th>
<th class="users-role">Role</th>
<th class="users-links-count">Total links</th>
<th class="users-actions"></th>
</tr>
</thead>

View File

@ -0,0 +1,69 @@
<tr id="tr-{{id}}" {{#if swap_oob}}hx-swap-oob="true"{{/if}}>
<td class="users-id">
{{id}}
</td>
<td class="users-email">
{{email}}
<p class="description">
{{#if domains}}
<a
aria-label="View domains"
data-tooltip="View domains"
hx-get="/api/domains/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-sync="this:replace"
hx-indicator="closest table"
hx-vals='{"user":"{{email}}"}'
onclick="setTab(event, 'tab-links')"
>
{{domains}}
</a>
{{else}}
<span>No domains</span>
{{/if}}
</p>
</td>
<td class="users-created-at">
{{relative_created_at}}
</td>
<td class="users-verified">
{{#if verified}}
<span class="status green">VERIFIED</span>
{{else}}
<span class="status gray">NOT VERIFIED</span>
{{/if}}
</td>
<td class="users-role">
{{#ifEquals role "ADMIN"}}
<span class="status red">ADMIN</span>
{{else}}
<span class="status gray">USER</span>
{{/ifEquals}}
</td>
<td class="users-links-count">
{{#ifEquals links_count '0'}}
{{links_count}}
{{else}}
<a
data-tooltip="View links"
aria-label="View links"
hx-get="/api/links/admin"
hx-target="closest table"
hx-swap="outerHTML"
hx-sync="this:replace"
hx-vals='{"user":"{{email}}"}'
hx-indicator="closest table"
onclick="setTab(event, 'tab-links')"
>
{{links_count}}
</a>
{{/ifEquals}}
</td>
{{> admin/users/actions}}
</tr>
<tr class="edit">
<td class="loading">
{{> icons/spinner}}
</td>
</tr>

View File

@ -11,7 +11,7 @@
/>
{{#if errors.email}}<p class="error">{{errors.email}}</p>{{/if}}
</label>
<label class="{{#if errors.password}}error{{/if}}">
<label class="{{#if errors.password}}error{{/if}}">
Password:
<input
name="password"

View File

@ -29,9 +29,18 @@
{{#if user}}
<li>
<a class="button primary" href="/settings" title="Settings">
<span>{{> icons/cog}}</span>
Settings
</a>
</li>
{{#if isAdmin}}
<li>
<a class="button secondary" href="/admin" title="Admin">
<span>{{> icons/shield}}</span>
Admin
</a>
</li>
{{/if}}
<li>
<a class="nav" href="/logout" title="Log out">
Log out

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="prefix__feather prefix__feather-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.7 1.7 0 0 0-1.82-.33 1.7 1.7 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.7 1.7 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.7 1.7 0 0 0 .33-1.82 1.7 1.7 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.7 1.7 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.7 1.7 0 0 0 1.82.33H9a1.7 1.7 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.7 1.7 0 0 0 1 1.51 1.7 1.7 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.7 1.7 0 0 0-.33 1.82V9a1.7 1.7 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.7 1.7 0 0 0-1.51 1"/></svg>

After

Width:  |  Height:  |  Size: 961 B

View File

@ -1 +1 @@
<svg class="with-text icon" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6m3-3h-6"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6m3-3h-6"/></svg>

Before

Width:  |  Height:  |  Size: 225 B

After

Width:  |  Height:  |  Size: 202 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="prefix__feather prefix__feather-shield" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/></svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@ -19,7 +19,7 @@
</a>
<button
class="action qrcode"
hx-on:click="handleQRCode(this)"
hx-on:click="handleQRCode(this, 'link-dialog')"
data-url="{{link.url}}"
>
{{> icons/qrcode}}
@ -46,20 +46,6 @@
>
{{> icons/pencil}}
</button>
{{#unless banned}}
{{#if @root.isAdmin}}
<button
class="action ban"
hx-on:click='openDialog("link-dialog")'
hx-get="/confirm-link-ban"
hx-target="#link-dialog .content-wrapper"
hx-indicator="#link-dialog"
hx-vals='{"id":"{{id}}"}'
>
{{> icons/stop}}
</button>
{{/if}}
{{/unless}}
<button
class="action delete"
hx-on:click='openDialog("link-dialog")'

View File

@ -112,6 +112,6 @@
</template>
</form>
{{else}}
<p class="no-links">No link was found.</p>
<p class="no-data">No link was found.</p>
{{/if}}
</td>

View File

@ -1,6 +1,6 @@
{{#unless links}}
{{#ifEquals links.length 0}}
<tr class="no-links">
<tr class="no-data">
<td>
No links.
</td>

View File

@ -1,4 +1,4 @@
<section id="links-table-wrapper">
<section id="main-table-wrapper">
<h2>Recent shortened links.</h2>
<table
hx-get="/api/links"
@ -12,8 +12,7 @@
hx-select-oob="#total"
hx-trigger="
load once,
reloadLinks from:body,
change from:[name='all'],
reloadMainTable from:body,
click delay:100ms from:button.nav,
input changed delay:500ms from:[name='search'],
"

View File

@ -1,5 +1,5 @@
<tfoot>
<tr class="links-controls">
<tr class="controls links-controls">
{{> links/nav}}
</tr>
</tfoot>

View File

@ -1,16 +1,10 @@
<thead>
<tr class="links-controls">
<tr class="controls links-controls">
<th class="search">
<input id="search" name="search" type="text" placeholder="Search..." hx-on:keyup="resetLinkNav()" />
<input class="table-input search" id="search" name="search" type="text" placeholder="Search..." hx-on:keyup="resetTableNav()" />
<input id="total" name="total" type="hidden" value="{{total}}" />
<input id="limit" name="limit" type="hidden" value="10" />
<input id="skip" name="skip" type="hidden" value="0" />
{{#if @root.isAdmin}}
<label id="all" class="checkbox">
<input name="all" type="checkbox" />
All links
</label>
{{/if}}
</th>
{{> links/nav}}
</tr>

View File

@ -1,6 +1,6 @@
<tr id="tr-{{id}}" {{#if swap_oob}}hx-swap-oob="true"{{/if}}>
<td class="original-url right-fade">
<a href="{{target}}">
<a href="{{target}}" target="_blank" rel="noopener noreferrer">
{{target}}
</a>
{{#if description}}
@ -28,7 +28,9 @@
</button>
{{> icons/check}}
</div>
<a href="{{link.url}}">{{link.link}}</a>
<a href="{{link.url}}" target="_blank" rel="noopener noreferrer">
{{link.link}}
</a>
</td>
<td class="views">
{{visit_count}}

View File

@ -86,12 +86,24 @@ hr {
span.bold { font-weight: bold; }
span.underline { border-bottom: 2px dotted #999; }
.space-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.align-center {
display: flex;
align-items: center;
}
a,
button.link {
color: var(--color-primary);
border-bottom: 1px dotted transparent;
text-decoration: none;
transition: all 0.2s ease-out;
cursor: pointer;
}
a:hover,
@ -99,6 +111,10 @@ button.link:hover {
border-bottom-color: var(--color-primary);
}
a.wrapper-only {
color: inherit;
}
a.nav {
color: inherit;
padding-bottom: 2px;
@ -205,7 +221,7 @@ a.button svg.with-text,
a.button span svg,
button svg.with-text,
button span svg {
width: 16px;
width: 1.1em;
height: auto;
margin-right: 0.5rem;
stroke: white;
@ -332,6 +348,44 @@ button.nav svg { stroke-width: 2.5; }
button.nav:hover { transform: translateY(-2px); }
button.nav:disabled:hover { transform: none; }
button.table {
height: 32px;
padding: 0 1rem;
font-size: 12px;
border-radius: 3px;
transition: all 0.2s ease-in-out;
box-shadow: 0 1px 2px var(--button-bg-box-shadow-color);
}
button.table:hover {
transform: translateY(-2px);
box-shadow: 0 1px 2px var(--button-bg-box-shadow-color);
}
button.table.primary,
button.primary:focus,
button.primary:hover {
box-shadow: 0 1px 2px var(--button-bg-primary-box-shadow-color);
}
button.table.secondary,
button.secondary:focus,
button.secondary:hover {
box-shadow: 0 1px 2px var(--button-bg-secondary-box-shadow-color);
}
button.table.danger,
button.danger:focus,
button.danger:hover {
box-shadow: 0 1px 2px var(--button-bg-danger-box-shadow-color);
}
button.table.success,
button.success:focus,
button.success:hover {
box-shadow: 0 1px 2px var(--button-bg-success-box-shadow-color);
}
button.link {
position: relative;
width: auto;
@ -489,6 +543,29 @@ input[type="checkbox"]:checked:after {
transform: translate(-50%, -50%) scale(1);
}
input.table-input,
select.table-input {
width: auto;
height: 32px;
font-size: 13px;
padding: 0 1.5rem;
border-radius: 3px;
border-bottom-width: 2px;
}
select.table-input {
width: 150px;
}
input.table-input::placeholder {
font-size: 13px;
}
select:has(option[value=""]:checked) {
letter-spacing: 0.05em;
color: #888;
}
label {
display: flex;
color: rgb(41, 71, 86);
@ -616,6 +693,48 @@ table tr.loading-placeholder td {
font-weight: 300;
}
table select {
margin-right: 1rem;
}
table .tab {
display: flex;
align-items: center;
}
table .tab a {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 0.4rem 1rem;
margin: 0 0.5rem;
font-size: 12px;
color: var(--text-color);
border: none;
border-radius: 4px;
background-color: white;
cursor: pointer;
box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1);
font-weight: normal;
transition: all 0.2s ease-in-out;
}
table .tab a:first-child { margin-left: 0}
table .tab a.active {
background-color: #f6f6f6;
box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
color: #aaa;
font-weight: bold;
opacity: 0.9;
cursor: default;
}
table .tab a:not(.active):hover {
transform: translateY(-2px);
}
.dialog {
position: fixed;
width: 100%;
@ -746,10 +865,53 @@ table tr.loading-placeholder td {
.dialog .content.htmx-request svg.spinner { display: block; }
.dialog .content.htmx-request button { display: none; }
.dialog .content label { margin: 0.5rem 0; }
.dialog .content input[type="text"],
.dialog .content input[type="password"],
.dialog .content input[type="email"],
.dialog .content select {
width: 320px;
height: 48px;
}
.inputs { display: flex; align-items: flex-start; margin-bottom: 1rem; }
.inputs label { flex: 0 0 0; margin-right: 1rem; }
.inputs label:last-child { margin-right: 0; }
.search-input-wrapper {
position: relative;
}
.search-input-wrapper button {
position: absolute;
display: none;
right: 0;
top: 50%;
width: auto;
height: auto;
padding: 3px;
margin: 0;
background-color: transparent;
background: none;
box-shadow: none;
transform: translateY(-50%);
cursor: pointer;
margin-right: 0.25rem;
transition: all 0.2s ease-in-out;
}
.search-input-wrapper button:hover {
transform: translateY(-55%);
}
.search-input-wrapper svg {
width: 0.9rem;
height: auto;
stroke-width: 2;
stroke: #888;
}
[data-tooltip] {
position: relative;
overflow: visible;
@ -1182,9 +1344,9 @@ main form label#advanced input {
margin-top: 0.5rem;
}
/* LINKS TABLE */
/* MAIN TABLE */
#links-table-wrapper {
#main-table-wrapper {
width: 1200px;
max-width: 100%;
display: flex;
@ -1195,115 +1357,174 @@ main form label#advanced input {
margin: 7rem 0 7.5rem;
}
#links-table-wrapper h2 {
#main-table-wrapper h2 {
font-weight: 300;
margin-bottom: 1rem;
}
#links-table-wrapper table thead,
#links-table-wrapper table tbody,
#links-table-wrapper table tfoot {
#main-table-wrapper table thead,
#main-table-wrapper table tbody,
#main-table-wrapper table tfoot {
min-width: 1000px;
}
#links-table-wrapper tr {
#main-table-wrapper tr {
padding: 0 0.5rem;
}
#links-table-wrapper th,
#links-table-wrapper td {
#main-table-wrapper th,
#main-table-wrapper td {
padding: 1rem;
}
#links-table-wrapper td {
#main-table-wrapper td {
font-size: 1rem;
}
#links-table-wrapper table .original-url { flex: 7 7 0; }
#links-table-wrapper table .created-at { flex: 2.5 2.5 0; }
#links-table-wrapper table .short-link { flex: 3 3 0; }
#links-table-wrapper table .views { flex: 1 1 0; justify-content: flex-end; }
#links-table-wrapper table .actions { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
#links-table-wrapper table .actions a.button,
#links-table-wrapper table .actions button { margin-right: 0.5rem; }
#links-table-wrapper table .actions a.button:last-child,
#links-table-wrapper table .actions button:last-child { margin-right: 0; }
#main-table-wrapper table .original-url { flex: 7 7 0; }
#main-table-wrapper table .created-at { flex: 2.5 2.5 0; }
#main-table-wrapper table .short-link { flex: 3 3 0; }
#main-table-wrapper.admin-table-wrapper table .short-link { overflow: visible; }
#main-table-wrapper table .views { flex: 1 1 0; justify-content: flex-end; }
#main-table-wrapper table .actions { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
#main-table-wrapper table .actions a.button,
#main-table-wrapper table .actions button { margin-right: 0.5rem; }
#main-table-wrapper table .actions a.button:last-child,
#main-table-wrapper table .actions button:last-child { margin-right: 0; }
#links-table-wrapper table td.original-url,
#links-table-wrapper table td.created-at {
#main-table-wrapper table .users-id { flex: 3 3 0; justify-content: flex-end; }
#main-table-wrapper table .users-email { flex: 9 9 0; }
#main-table-wrapper table .users-created-at { flex: 4 4 0; }
#main-table-wrapper table .users-updated-at { flex: 4 4 0; }
#main-table-wrapper table .users-verified { flex: 3 3 0; overflow: visible; }
#main-table-wrapper table .users-role { flex: 2 2 0; overflow: visible; }
#main-table-wrapper table .users-links-count { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
#main-table-wrapper table .users-actions { flex: 2 2 0; }
#main-table-wrapper table .domains-id { flex: 2 2 0; justify-content: flex-end; }
#main-table-wrapper table .domains-address { flex: 7 7 0; }
#main-table-wrapper table .domains-homepage { flex: 5 5 0; }
#main-table-wrapper table .domains-created-at { flex: 3 3 0; }
#main-table-wrapper table .domains-links-count { flex: 3 3 0; justify-content: flex-end; overflow: visible; }
#main-table-wrapper table .domains-actions { flex: 2 2 0; }
#main-table-wrapper table td.original-url,
#main-table-wrapper table td.created-at,
#main-table-wrapper.admin-table-wrapper table td.short-link,
#main-table-wrapper table td.users-email,
#main-table-wrapper table td.domains-address,
#main-table-wrapper table td.users-created-at,
#main-table-wrapper table td.users-verified {
flex-direction: column;
align-items: flex-start;
justify-content: center;
}
#links-table-wrapper table td.original-url p.description,
#links-table-wrapper table td.created-at p.expire-in {
table .short-link-wrapper { display: flex; align-items: center; }
#main-table-wrapper table td .description {
display: flex;
align-items: center;
margin: 0;
font-size: 14px;
color: #888;
}
}
#main-table-wrapper table td .description a {
color: #aaa;
border-bottom-color: #aaa;
}
#main-table-wrapper table td .description svg {
stroke: #aaa;
stroke-width: 2;
width: 0.85em;
margin-right: 0.25rem;
}
#main-table-wrapper table td .description span { color: #aaa; }
#main-table-wrapper table td .description a:hover { border-bottom-color: transparent; }
#links-table-wrapper table tr.no-links {
#main-table-wrapper table .status {
font-size: 11px;
font-weight: bold;
padding: 4px 12px;
border-radius: 12px;
margin-top: 0.25rem;
}
#main-table-wrapper table .status:first-child {
margin-top: 0;
}
#main-table-wrapper table .status.gray { background-color: hsl(200, 12%, 95%); }
#main-table-wrapper table .status.green { background-color: hsl(102.4, 100%, 93.3%); }
#main-table-wrapper table .status.red { background-color: hsl(0, 100%, 96.7%); }
#main-table-wrapper table tr.no-data {
flex: 1 1 auto;
justify-content: center;
animation: fadein 0.3s ease-in-out;
}
#links-table-wrapper table.htmx-request tbody tr { opacity: 0.5; }
#links-table-wrapper table tr.loading-placeholder { opacity: 0.6 !important; }
#main-table-wrapper table.htmx-request tbody tr { opacity: 0.5; }
#main-table-wrapper table tr.loading-placeholder { opacity: 0.6 !important; }
#links-table-wrapper table tr.loading-placeholder td,
#links-table-wrapper table tr.no-links td {
#main-table-wrapper table tr.loading-placeholder td,
#main-table-wrapper table tr.no-data td {
flex: 0 0 auto;
font-size: 18px;
font-weight: 300;
}
#links-table-wrapper table tr.loading-placeholder svg.spinner {
#main-table-wrapper table tr.loading-placeholder svg.spinner {
width: 1rem;
height: auto;
margin-right: 0.5rem;
stroke-width: 1.5;
}
#links-table-wrapper table tr.links-controls { justify-content: space-between; }
#links-table-wrapper table tfoot tr.links-controls { justify-content: flex-end; }
#main-table-wrapper table thead tr.controls { justify-content: space-between; }
#main-table-wrapper table thead tr.controls.with-filters { align-items: flex-end; }
#main-table-wrapper table tfoot tr.controls { justify-content: flex-end; }
#links-table-wrapper table th.search,
#links-table-wrapper table th.nav {
#main-table-wrapper table th.search {
flex: 1 1 auto;
align-items: center;
}
#main-table-wrapper table th.filters {
flex: 1 1 auto;
flex-direction: column;
align-items: start;
}
#main-table-wrapper table th.filters > div {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
#main-table-wrapper table th.filters > div:last-child { margin-bottom: 0; }
#main-table-wrapper table th.nav {
flex: 0 0 auto;
align-items: center;
}
#links-table-wrapper table [name="search"] {
width: auto;
height: 32px;
font-size: 14px;
padding: 0 1.5rem;
border-radius: 3px;
border-bottom-width: 2px;
}
#links-table-wrapper table [name="search"]::placeholder {
font-size: 13px;
}
#links-table-wrapper table tr.links-controls .checkbox {
#main-table-wrapper table tr.controls .checkbox {
margin-left: 1rem;
font-size: 15px;
}
#links-table-wrapper table .limit,
#links-table-wrapper table .pagination {
#main-table-wrapper table .limit,
#main-table-wrapper table .pagination {
display: flex;
align-items: center;
}
#links-table-wrapper table button.nav { margin-right: 0.75rem; }
#links-table-wrapper table button.nav:last-child { margin-right: 0; }
#main-table-wrapper table button.nav { margin-right: 0.75rem; }
#main-table-wrapper table button.nav:last-child { margin-right: 0; }
#links-table-wrapper table .nav-divider {
#main-table-wrapper table .nav-divider {
height: 20px;
width: 1px;
opacity: 0.4;
@ -1311,11 +1532,11 @@ main form label#advanced input {
margin: 0 1.5rem;
}
#links-table-wrapper table tbody tr:hover {
#main-table-wrapper table tbody tr:hover {
background-color: hsl(200, 14%, 98%);
}
#links-table-wrapper table tbody td.right-fade:after {
#main-table-wrapper table tbody td.right-fade:after {
content: "";
position: absolute;
right: 0;
@ -1325,48 +1546,48 @@ main form label#advanced input {
background: linear-gradient(to left, white, rgba(255, 255, 255, 0.001));
}
#links-table-wrapper table tbody tr:hover td.right-fade:after {
#main-table-wrapper table tbody tr:hover td.right-fade:after {
background: linear-gradient(to left, hsl(200, 14%, 98%), rgba(255, 255, 255, 0.001));
}
#links-table-wrapper table .clipboard { margin-right: 0.5rem; }
#links-table-wrapper table .clipboard svg.check { width: 24px; }
#main-table-wrapper table .clipboard { margin-right: 0.5rem; }
#main-table-wrapper table .clipboard svg.check { width: 24px; }
#links-table-wrapper table tr.edit {
#main-table-wrapper table tr.edit {
background-color: #fafafa;
}
#links-table-wrapper table tr.edit td {
#main-table-wrapper table tr.edit td {
width: 100%;
padding: 2rem 1.5rem;
flex-basis: auto;
}
#links-table-wrapper table tr.edit td form {
#main-table-wrapper table tr.edit td form {
width: 100;
display: flex;
flex-direction: column;
align-items: flex-start;
}
#links-table-wrapper table tr.edit td form > div {
#main-table-wrapper table tr.edit td form > div {
width: 100%;
display: flex;
align-items: start;
}
#links-table-wrapper table tr.edit label { margin: 0 0.5rem 1rem; }
#links-table-wrapper table tr.edit label:first-child { margin-left: 0; }
#links-table-wrapper table tr.edit label:last-child { margin-right: 0; }
#main-table-wrapper table tr.edit label { margin: 0 0.5rem 1rem; }
#main-table-wrapper table tr.edit label:first-child { margin-left: 0; }
#main-table-wrapper table tr.edit label:last-child { margin-right: 0; }
#links-table-wrapper table tr.edit input {
#main-table-wrapper table tr.edit input {
height: 44px;
padding: 0 1.5rem;
font-size: 15px;
}
#links-table-wrapper table tr.edit input,
#links-table-wrapper table tr.edit input + p {
#main-table-wrapper table tr.edit input,
#main-table-wrapper table tr.edit input + p {
width: 240px;
max-width: 100%;
font-size: 14px;
@ -1374,40 +1595,40 @@ main form label#advanced input {
text-align: left;
}
#links-table-wrapper table tr.edit input[name="target"],
#links-table-wrapper table tr.edit input[name="description"],
#links-table-wrapper table tr.edit input[name="target"] + p,
#links-table-wrapper table tr.edit input[name="description"] + p {
#main-table-wrapper table tr.edit input[name="target"],
#main-table-wrapper table tr.edit input[name="description"],
#main-table-wrapper table tr.edit input[name="target"] + p,
#main-table-wrapper table tr.edit input[name="description"] + p {
width: 420px;
}
#links-table-wrapper table tr.edit button {
#main-table-wrapper table tr.edit button {
height: 38px;
margin-right: 1rem;
}
#links-table-wrapper table tr.edit button:last-child { margin-right: 0; }
#main-table-wrapper table tr.edit button:last-child { margin-right: 0; }
#links-table-wrapper table tr.edit form {
#main-table-wrapper table tr.edit form {
--keyframe-slidey-offset: -5px;
animation: fadein 0.3s ease-in-out, slidey 0.32s ease-in-out;
}
#links-table-wrapper table tr.edit form.no-animation { animation: none; }
#main-table-wrapper table tr.edit form.no-animation { animation: none; }
#links-table-wrapper table tr.edit { display: none; }
#links-table-wrapper table tr.edit.show { display: flex; }
#links-table-wrapper table tr.edit td.loading { display: none; }
#links-table-wrapper table tr.edit.htmx-request td.loading { display: block; }
#links-table-wrapper table tr.edit td.loading svg { width: 16px; height: 16px; }
#main-table-wrapper table tr.edit { display: none; }
#main-table-wrapper table tr.edit.show { display: flex; }
#main-table-wrapper table tr.edit td.loading { display: none; }
#main-table-wrapper table tr.edit.htmx-request td.loading { display: block; }
#main-table-wrapper table tr.edit td.loading svg { width: 16px; height: 16px; }
#links-table-wrapper table tr.edit form.htmx-request button .reload { display: none; }
#links-table-wrapper table tr.edit form button .loader { display: none; }
#links-table-wrapper table tr.edit form.htmx-request button .loader { display: inline-block; }
#main-table-wrapper table tr.edit form.htmx-request button .reload { display: none; }
#main-table-wrapper table tr.edit form button .loader { display: none; }
#main-table-wrapper table tr.edit form.htmx-request button .loader { display: inline-block; }
#links-table-wrapper table tr.edit form .response p { margin: 2rem 0 0; }
#main-table-wrapper table tr.edit form .response p { margin: 2rem 0 0; }
#links-table-wrapper table tr.edit p.no-links {
#main-table-wrapper table tr.edit p.no-data {
width: 100%;
text-align: center;
}
@ -1420,6 +1641,35 @@ main form label#advanced input {
.dialog .ban-checklist label { margin: 1rem 1.5rem 1rem 0; }
.dialog .ban-checklist label:last-child { margin-right: 0; }
#main-table-wrapper tr.category { justify-content: space-between; align-items: center; }
#main-table-wrapper th.category-total { flex: 1 1 auto; }
#main-table-wrapper th.category-total p { margin: 0; font-size: 15px; font-weight: normal }
#main-table-wrapper th.category-tab { flex: 2 2 auto; justify-content: flex-end; }
/* ADMIN */
table .search-input-wrapper {
margin-right: 1rem;
}
input.search.admin {
max-width: 200px;
}
.content.admin-create form {
display: flex;
flex-direction: column;
}
.content.admin-create .checkbox-wrapper {
display: flex;
align-items: center;
}
.content.admin-create .checkbox-wrapper label { margin-right: 1rem; }
.content.admin-create .buttons { justify-content: center; }
.content.admin-create .buttons button { flex: 1 1 auto; }
/* INTRO */
@ -2040,11 +2290,12 @@ svg.map path.active { stroke: hsl(261, 46%, 50%); stroke-width: 1.5; }
.dialog .loading { width: 20px; height: 20px; margin: 2rem 0; }
.dialog .content .buttons { margin-top: 1rem; }
header { padding: 0 16px; height: 72px; }
header { padding: 16px 16px 0; height: 72px; }
header a.logo { font-size: 20px; }
header ul.logo-links { display: none; }
header .logo img { margin-right: 8px; }
header nav ul li { margin-left: 1rem }
header nav ul li { margin-left: 0.75rem }
header nav ul li a.button { height: 28px; padding: 0 1rem; font-size: 11px; }
form#login-signup label { margin-bottom: 1.5rem; }
form#login-signup input {
@ -2070,37 +2321,47 @@ svg.map path.active { stroke: hsl(261, 46%, 50%); stroke-width: 1.5; }
main form button.submit { width: 22px; top: 13px; margin: 0 1rem 0; }
main form label#advanced { margin-top: 1.5rem; }
main form label#advanced input { margin-bottom: 3px; }
#links-table-wrapper { margin: 4rem 0 4.5rem;}
#links-table-wrapper h2 { margin-bottom: 0.5rem; }
#links-table-wrapper table thead,
#links-table-wrapper table tbody,
#links-table-wrapper table tfoot { min-width: 800px; }
#links-table-wrapper tr { padding: 0 0.25rem; }
#links-table-wrapper th,
#links-table-wrapper td { padding: 0.75rem; }
#links-table-wrapper table .actions a.button,
#links-table-wrapper table .actions button { margin-right: 0.3rem; }
#links-table-wrapper table td.original-url p.description,
#links-table-wrapper table td.created-at p.expire-in { font-size: 12px; }
#links-table-wrapper table tr.no-links td { font-size: 16px; }
#links-table-wrapper table [name="search"] { height: 28px; font-size: 13px; padding: 0 1rem; }
#links-table-wrapper table [name="search"]::placeholder { font-size: 12px; }
#links-table-wrapper table tr.links-controls .checkbox { font-size: 13px; }
#links-table-wrapper table button.nav { margin-right: 0.5rem; }
#links-table-wrapper table .nav-divider { height: 18px; margin: 0 1rem; }
#links-table-wrapper table tbody td.right-fade:after { width: 14px; }
#links-table-wrapper table tr.edit td { padding: 1.25rem 1rem; }
#links-table-wrapper table tr.edit label { margin: 0 0.25rem 0.5rem; }
#links-table-wrapper table tr.edit input { height: 38px; padding: 0 1rem; font-size: 13px; }
#links-table-wrapper table tr.edit input,
#links-table-wrapper table tr.edit input + p { width: 200px; }
#links-table-wrapper table tr.edit input[name="target"],
#links-table-wrapper table tr.edit input[name="description"],
#links-table-wrapper table tr.edit input[name="target"] + p,
#links-table-wrapper table tr.edit input[name="description"] + p { width: 320px; }
#links-table-wrapper table tr.edit button { height: 32px; margin-right: 0.5rem; }
#links-table-wrapper table tr.edit td.loading svg { width: 14px; height: 14px; }
#links-table-wrapper table tr.edit form .response p { margin: 1rem 0 0; }
#main-table-wrapper { margin: 4rem 0 4.5rem;}
#main-table-wrapper h2 { margin-bottom: 0.5rem; }
#main-table-wrapper table thead,
#main-table-wrapper table tbody,
#main-table-wrapper table tfoot { min-width: 800px; }
#main-table-wrapper tr { padding: 0 0.25rem; }
#main-table-wrapper th,
#main-table-wrapper td { padding: 0.75rem; }
#main-table-wrapper table .actions a.button,
#main-table-wrapper table .actions button { margin-right: 0.3rem; }
#main-table-wrapper table td p.description { font-size: 12px; }
#main-table-wrapper table tr.no-data td { font-size: 16px; }
#main-table-wrapper.admin-table-wrapper table th.nav { flex-direction: column; align-items: flex-end; }
#main-table-wrapper.admin-table-wrapper table th .nav-divider { display: none; }
#main-table-wrapper.admin-table-wrapper table th .limit { margin-bottom: 1rem; }
table .tab a { padding: 0.3rem 0.9rem; }
#main-table-wrapper th.category-total p { font-size: 13px; }
#main-table-wrapper table thead tr.controls.with-filters { align-items: flex-start; }
#main-table-wrapper table th select, input.table-input { height: 28px; font-size: 12px; padding: 0 1rem; }
#main-table-wrapper table th select { background-position: right 0.7em top 50%, 0 0; }
.search-input-wrapper button { padding: 2px; margin-right: 0.15rem; }
#main-table-wrapper table th input.search.admin { max-width: 150px; padding: 0 1.5rem 0 1rem; }
#main-table-wrapper table th select.table-input { max-width: 120px; }
#main-table-wrapper table th button.table { height: 28px; }
#main-table-wrapper table th input::placeholder { font-size: 12px; }
#main-table-wrapper table tr.controls .checkbox { font-size: 13px; }
#main-table-wrapper table button.nav { margin-right: 0.5rem; }
#main-table-wrapper table .nav-divider { height: 18px; margin: 0 1rem; }
#main-table-wrapper table tbody td.right-fade:after { width: 14px; }
#main-table-wrapper table tr.edit td { padding: 1.25rem 1rem; }
#main-table-wrapper table tr.edit label { margin: 0 0.25rem 0.5rem; }
#main-table-wrapper table tr.edit input { height: 38px; padding: 0 1rem; font-size: 13px; }
#main-table-wrapper table tr.edit input,
#main-table-wrapper table tr.edit input + p { width: 200px; }
#main-table-wrapper table tr.edit input[name="target"],
#main-table-wrapper table tr.edit input[name="description"],
#main-table-wrapper table tr.edit input[name="target"] + p,
#main-table-wrapper table tr.edit input[name="description"] + p { width: 320px; }
#main-table-wrapper table tr.edit button { height: 32px; margin-right: 0.5rem; }
#main-table-wrapper table tr.edit td.loading svg { width: 14px; height: 14px; }
#main-table-wrapper table tr.edit form .response p { margin: 1rem 0 0; }
.dialog .ban-checklist label { margin: 0.5rem 1rem 0.5rem 0; }
.introduction img { width: 90%; margin-top: 2rem; }

View File

@ -85,11 +85,11 @@ function formatDateHour(selector) {
}
// show QR code
function handleQRCode(element) {
const dialog = document.querySelector("#link-dialog");
function handleQRCode(element, id) {
const dialog = document.getElementById(id);
const dialogContent = dialog.querySelector(".content-wrapper");
if (!dialogContent) return;
openDialog("link-dialog", "qrcode");
openDialog(id, "qrcode");
dialogContent.textContent = "";
const qrcode = new QRCode(dialogContent, {
text: element.dataset.url,
@ -188,13 +188,14 @@ function updateLinksNav() {
});
}
function resetLinkNav() {
function resetTableNav() {
const totalElm = document.querySelector('#total');
const skipElm = document.querySelector('#skip');
const limitElm = document.querySelector('#limit');
if (!totalElm || !skipElm || !limitElm) return;
skipElm.value = 0;
limitElm.value = 10;
const total = parseInt(totalElm.value);
const skip = parseInt(skipElm.value);
const limit = parseInt(limitElm.value);
document.querySelectorAll('.pagination .next').forEach(elm => {
@ -208,6 +209,66 @@ function resetLinkNav() {
});
}
// tab click
function setTab(event, targetId) {
const tabs = Array.from(closest("nav", event.target).children);
tabs.forEach(function (tab) {
tab.classList.remove("active");
});
if (targetId) {
document.getElementById(targetId).classList.add("active");
} else {
event.target.classList.add("active");
}
}
// show clear search button
function onSearchChange(event) {
const clearButton = event.target.parentElement.querySelector("button.clear");
if (!clearButton) return;
clearButton.style.display = event.target.value.length > 0 ? "block" : "none";
}
function clearSeachInput(event) {
event.preventDefault();
const button = closest("button", event.target);
const input = button.parentElement.querySelector("input");
if (!input) return;
input.value = "";
button.style.display = "none";
htmx.trigger("body", "reloadMainTable");
}
// detect if search inputs have value on load to show clear button
function onSearchInputLoad() {
const linkSearchInput = document.getElementById("search");
if (!linkSearchInput) return;
const linkClearButton = linkSearchInput.parentElement.querySelector("button.clear")
linkClearButton.style.display = linkSearchInput.value.length > 0 ? "block" : "none";
const userSearchInput = document.getElementById("search_user");
if (!userSearchInput) return;
const userClearButton = userSearchInput.parentElement.querySelector("button.clear")
userClearButton.style.display = userSearchInput.value.length > 0 ? "block" : "none";
const domainSearchInput = document.getElementById("search_domain");
if (!domainSearchInput) return;
const domainClearButton = domainSearchInput.parentElement.querySelector("button.clear")
domainClearButton.style.display = domainSearchInput.value.length > 0 ? "block" : "none";
}
onSearchInputLoad();
// create user checkbox control
function canSendVerificationEmail() {
const canSendVerificationEmail = !document.getElementById('create-user-verified').checked && !document.getElementById('create-user-banned').checked;
const checkbox = document.getElementById('send-email-label');
if (canSendVerificationEmail)
checkbox.classList.remove('hidden');
if (!canSendVerificationEmail && !checkbox.classList.contains('hidden'))
checkbox.classList.add('hidden');
}
// create views chart label
function createViewsChartLabel(ctx) {
const period = ctx.dataset.period;