Browse Source

add admin page

Pouria Ezzati 1 year ago
parent
commit
8a73c5ec4c
73 changed files with 3239 additions and 191 deletions
  1. 8 0
      server/consts.js
  2. 6 5
      server/handlers/auth.handler.js
  3. 146 1
      server/handlers/domains.handler.js
  4. 0 1
      server/handlers/helpers.handler.js
  5. 158 13
      server/handlers/links.handler.js
  6. 15 0
      server/handlers/locals.handler.js
  7. 97 1
      server/handlers/renders.handler.js
  8. 150 0
      server/handlers/users.handler.js
  9. 135 2
      server/handlers/validators.handler.js
  10. 41 0
      server/migrations/20241103083933_user-roles.js
  11. 6 0
      server/models/user.model.js
  12. 138 0
      server/queries/domain.queries.js
  13. 91 7
      server/queries/link.queries.js
  14. 125 0
      server/queries/user.queries.js
  15. 44 0
      server/routes/domain.routes.js
  16. 23 1
      server/routes/link.routes.js
  17. 66 0
      server/routes/renders.routes.js
  18. 44 0
      server/routes/user.routes.js
  19. 46 5
      server/utils/utils.js
  20. 3 0
      server/views/admin.hbs
  21. 56 0
      server/views/partials/admin/dialog/add_domain.hbs
  22. 12 0
      server/views/partials/admin/dialog/add_domain_success.hbs
  23. 46 0
      server/views/partials/admin/dialog/ban_domain.hbs
  24. 12 0
      server/views/partials/admin/dialog/ban_domain_success.hbs
  25. 42 0
      server/views/partials/admin/dialog/ban_user.hbs
  26. 12 0
      server/views/partials/admin/dialog/ban_user_success.hbs
  27. 81 0
      server/views/partials/admin/dialog/create_user.hbs
  28. 12 0
      server/views/partials/admin/dialog/create_user_success.hbs
  29. 40 0
      server/views/partials/admin/dialog/delete_domain.hbs
  30. 12 0
      server/views/partials/admin/dialog/delete_domain_success.hbs
  31. 30 0
      server/views/partials/admin/dialog/delete_user.hbs
  32. 12 0
      server/views/partials/admin/dialog/delete_user_success.hbs
  33. 8 0
      server/views/partials/admin/dialog/frame.hbs
  34. 11 0
      server/views/partials/admin/dialog/mesasge.hbs
  35. 29 0
      server/views/partials/admin/domains/actions.hbs
  36. 16 0
      server/views/partials/admin/domains/loading.hbs
  37. 30 0
      server/views/partials/admin/domains/table.hbs
  38. 6 0
      server/views/partials/admin/domains/tbody.hbs
  39. 5 0
      server/views/partials/admin/domains/tfoot.hbs
  40. 88 0
      server/views/partials/admin/domains/thead.hbs
  41. 93 0
      server/views/partials/admin/domains/tr.hbs
  42. 5 0
      server/views/partials/admin/index.hbs
  43. 71 0
      server/views/partials/admin/links/actions.hbs
  44. 117 0
      server/views/partials/admin/links/edit.hbs
  45. 16 0
      server/views/partials/admin/links/loading.hbs
  46. 31 0
      server/views/partials/admin/links/table.hbs
  47. 6 0
      server/views/partials/admin/links/tbody.hbs
  48. 5 0
      server/views/partials/admin/links/tfoot.hbs
  49. 112 0
      server/views/partials/admin/links/thead.hbs
  50. 99 0
      server/views/partials/admin/links/tr.hbs
  51. 16 0
      server/views/partials/admin/table_nav.hbs
  52. 62 0
      server/views/partials/admin/table_tab.hbs
  53. 29 0
      server/views/partials/admin/users/actions.hbs
  54. 16 0
      server/views/partials/admin/users/loading.hbs
  55. 31 0
      server/views/partials/admin/users/table.hbs
  56. 6 0
      server/views/partials/admin/users/tbody.hbs
  57. 5 0
      server/views/partials/admin/users/tfoot.hbs
  58. 79 0
      server/views/partials/admin/users/thead.hbs
  59. 69 0
      server/views/partials/admin/users/tr.hbs
  60. 1 1
      server/views/partials/auth/form.hbs
  61. 9 0
      server/views/partials/header.hbs
  62. 1 0
      server/views/partials/icons/cog.hbs
  63. 1 1
      server/views/partials/icons/new_user.hbs
  64. 1 0
      server/views/partials/icons/shield.hbs
  65. 1 15
      server/views/partials/links/actions.hbs
  66. 1 1
      server/views/partials/links/edit.hbs
  67. 1 1
      server/views/partials/links/loading.hbs
  68. 2 3
      server/views/partials/links/table.hbs
  69. 1 1
      server/views/partials/links/tfoot.hbs
  70. 2 8
      server/views/partials/links/thead.hbs
  71. 4 2
      server/views/partials/links/tr.hbs
  72. 379 118
      static/css/styles.css
  73. 65 4
      static/scripts/main.js

+ 8 - 0
server/consts.js

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

+ 6 - 5
server/handlers/auth.handler.js

@@ -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

+ 146 - 1
server/handlers/domains.handler.js

@@ -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,
 }

+ 0 - 1
server/handlers/helpers.handler.js

@@ -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();

+ 158 - 13
server/handlers/links.handler.js

@@ -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,

+ 15 - 0
server/handlers/locals.handler.js

@@ -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,

+ 97 - 1
server/handlers/renders.handler.js

@@ -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,

+ 150 - 0
server/handlers/users.handler.js

@@ -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,
 }

+ 135 - 2
server/handlers/validators.handler.js

@@ -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,

+ 41 - 0
server/migrations/20241103083933_user-roles.js

@@ -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,
+}

+ 6 - 0
server/models/user.model.js

@@ -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);

+ 138 - 0
server/queries/domain.queries.js

@@ -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,
 }

+ 91 - 7
server/queries/link.queries.js

@@ -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,
 }

+ 125 - 0
server/queries/user.queries.js

@@ -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,
 }

+ 44 - 0
server/routes/domain.routes.js

@@ -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;

+ 23 - 1
server/routes/link.routes.js

@@ -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"),

+ 66 - 0
server/routes/renders.routes.js

@@ -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,

+ 44 - 0
server/routes/user.routes.js

@@ -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;

+ 46 - 5
server/utils/utils.js

@@ -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 - 0
server/views/admin.hbs

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

+ 56 - 0
server/views/partials/admin/dialog/add_domain.hbs

@@ -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>

+ 12 - 0
server/views/partials/admin/dialog/add_domain_success.hbs

@@ -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>
+

+ 46 - 0
server/views/partials/admin/dialog/ban_domain.hbs

@@ -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>

+ 12 - 0
server/views/partials/admin/dialog/ban_domain_success.hbs

@@ -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>
+

+ 42 - 0
server/views/partials/admin/dialog/ban_user.hbs

@@ -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>

+ 12 - 0
server/views/partials/admin/dialog/ban_user_success.hbs

@@ -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>
+

+ 81 - 0
server/views/partials/admin/dialog/create_user.hbs

@@ -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>

+ 12 - 0
server/views/partials/admin/dialog/create_user_success.hbs

@@ -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>
+

+ 40 - 0
server/views/partials/admin/dialog/delete_domain.hbs

@@ -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>

+ 12 - 0
server/views/partials/admin/dialog/delete_domain_success.hbs

@@ -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>
+

+ 30 - 0
server/views/partials/admin/dialog/delete_user.hbs

@@ -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>

+ 12 - 0
server/views/partials/admin/dialog/delete_user_success.hbs

@@ -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>
+

+ 8 - 0
server/views/partials/admin/dialog/frame.hbs

@@ -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>

+ 11 - 0
server/views/partials/admin/dialog/mesasge.hbs

@@ -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>
+

+ 29 - 0
server/views/partials/admin/domains/actions.hbs

@@ -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>

+ 16 - 0
server/views/partials/admin/domains/loading.hbs

@@ -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}}

+ 30 - 0
server/views/partials/admin/domains/table.hbs

@@ -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>

+ 6 - 0
server/views/partials/admin/domains/tbody.hbs

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

+ 5 - 0
server/views/partials/admin/domains/tfoot.hbs

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

+ 88 - 0
server/views/partials/admin/domains/thead.hbs

@@ -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>

+ 93 - 0
server/views/partials/admin/domains/tr.hbs

@@ -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>

+ 5 - 0
server/views/partials/admin/index.hbs

@@ -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>

+ 71 - 0
server/views/partials/admin/links/actions.hbs

@@ -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>

+ 117 - 0
server/views/partials/admin/links/edit.hbs

@@ -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>

+ 16 - 0
server/views/partials/admin/links/loading.hbs

@@ -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}}

+ 31 - 0
server/views/partials/admin/links/table.hbs

@@ -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>

+ 6 - 0
server/views/partials/admin/links/tbody.hbs

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

+ 5 - 0
server/views/partials/admin/links/tfoot.hbs

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

+ 112 - 0
server/views/partials/admin/links/thead.hbs

@@ -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>

+ 99 - 0
server/views/partials/admin/links/tr.hbs

@@ -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>

+ 16 - 0
server/views/partials/admin/table_nav.hbs

@@ -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>

+ 62 - 0
server/views/partials/admin/table_tab.hbs

@@ -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>

+ 29 - 0
server/views/partials/admin/users/actions.hbs

@@ -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>

+ 16 - 0
server/views/partials/admin/users/loading.hbs

@@ -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}}

+ 31 - 0
server/views/partials/admin/users/table.hbs

@@ -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>

+ 6 - 0
server/views/partials/admin/users/tbody.hbs

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

+ 5 - 0
server/views/partials/admin/users/tfoot.hbs

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

+ 79 - 0
server/views/partials/admin/users/thead.hbs

@@ -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>

+ 69 - 0
server/views/partials/admin/users/tr.hbs

@@ -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>

+ 1 - 1
server/views/partials/auth/form.hbs

@@ -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"

+ 9 - 0
server/views/partials/header.hbs

@@ -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

+ 1 - 0
server/views/partials/icons/cog.hbs

@@ -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>

+ 1 - 1
server/views/partials/icons/new_user.hbs

@@ -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>

+ 1 - 0
server/views/partials/icons/shield.hbs

@@ -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>

+ 1 - 15
server/views/partials/links/actions.hbs

@@ -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")' 

+ 1 - 1
server/views/partials/links/edit.hbs

@@ -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>

+ 1 - 1
server/views/partials/links/loading.hbs

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

+ 2 - 3
server/views/partials/links/table.hbs

@@ -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'],
     "

+ 1 - 1
server/views/partials/links/tfoot.hbs

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

+ 2 - 8
server/views/partials/links/thead.hbs

@@ -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>

+ 4 - 2
server/views/partials/links/tr.hbs

@@ -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}}

+ 379 - 118
static/css/styles.css

@@ -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; }
-
-#links-table-wrapper table td.original-url,
-#links-table-wrapper table td.created-at { 
+#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; }
+
+#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 {
-  flex: 0 0 auto;
+#main-table-wrapper table th.search {
+  flex: 1 1 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;
+#main-table-wrapper table th.filters {
+  flex: 1 1 auto;
+  flex-direction: column;
+  align-items: start;
 }
 
-#links-table-wrapper table [name="search"]::placeholder {
-  font-size: 13px;
+#main-table-wrapper table th.filters > div {
+  display: flex;
+  align-items: center;
+  margin-bottom: 1rem;
 }
 
-#links-table-wrapper table tr.links-controls .checkbox {
+#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;
+}
+
+#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; }
 

+ 65 - 4
static/scripts/main.js

@@ -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;