create admin

This commit is contained in:
Holly Stubbs 2024-09-26 00:47:08 +01:00
parent ad341d4208
commit 45bd4bf351
Signed by: tgpholly
GPG key ID: B8583C4B7D18119E
23 changed files with 404 additions and 12 deletions

View file

@ -1,3 +1,4 @@
import ChangeUsernameViewModel from "../models/account/ChangeUsernameViewModel";
import LoginViewModel from "../models/account/LoginViewModel";
import RegisterViewModel from "../models/account/RegisterViewModel";
import Session from "../objects/Session";
@ -37,7 +38,12 @@ export default class AccountController extends Controller {
}
const username = registerViewModel.username.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
await UserService.CreateUser(0, username, registerViewModel.password);
if (!await UserService.CreateUser(0, username, registerViewModel.password)) {
registerViewModel.password = "";
registerViewModel.message = "Sorry! That username is already taken.";
return this.view(registerViewModel);
}
const user = await UserService.GetUserByUsername(username);
if (!user) {
@ -57,4 +63,22 @@ export default class AccountController extends Controller {
return this.redirectToAction("index", "home");
}
public async ChangeUsername_Get(changeUsernameViewModel:ChangeUsernameViewModel) {
return this.view(changeUsernameViewModel);
}
public async ChangeUsername_Post(changeUsernameViewModel:ChangeUsernameViewModel) {
if (typeof(changeUsernameViewModel.username) !== "string") {
return this.badRequest();
}
const user = await UserService.SaveUsername(this.session.userId, changeUsernameViewModel.username);
if (!user) {
changeUsernameViewModel.message = "Sorry! That username is already taken.";
return this.view(changeUsernameViewModel);
}
return this.redirectToAction("index", "home");
}
}

View file

@ -0,0 +1,33 @@
import AdminIndexViewModel from "../models/admin/AdminIndexViewModel";
import AdminPartiesViewModel from "../models/admin/AdminPartiesViewModel";
import AdminUsersViewModel from "../models/admin/AdminUsersViewModel";
import PartyService from "../services/PartyService";
import UserService from "../services/UserService";
import Controller from "./Controller";
export default class AdminController_Auth$Admin extends Controller {
public async Index_Get() {
const adminIndexViewModel: AdminIndexViewModel = {
userCount: await UserService.GetUserCount(),
partyCount: await PartyService.GetPartyCount()
};
return this.view(adminIndexViewModel);
}
public async Users_Get() {
const adminUsersViewModel: AdminUsersViewModel = {
users: await UserService.GetAll()
};
return this.view(adminUsersViewModel);
}
public async Parties_Get() {
const adminPartiesViewModel: AdminPartiesViewModel = {
parties: await PartyService.GetAll()
};
return this.view(adminPartiesViewModel);
}
}

View file

@ -3,6 +3,7 @@ import { Console } from "hsconsole";
import Session from "../objects/Session";
import SessionUser from "../objects/SessionUser";
import RequestCtx from "../objects/RequestCtx";
import { UserLevel } from "../enums/UserLevel";
// prepare for ts-ignore :3
// TODO: figure out some runtime field / type checking so
@ -12,7 +13,19 @@ export default abstract class Controller {
public constructor() {
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this));
const controllerName = this.constructor.name.replace("Controller", "").toLowerCase();
const rawControllerParts = this.constructor.name.split("_");
const controllerName = rawControllerParts.splice(0, 1)[0].replace("Controller", "").toLowerCase();
let controllerAuthLevel: UserLevel | undefined;
for (const prop of rawControllerParts) {
if (prop.startsWith("Auth")) {
const userLevel = prop.split("$")[1];
// @ts-ignore
controllerAuthLevel = UserLevel[userLevel];
Console.printInfo(`Set Auth level requirement for ${this.constructor.name} to ${userLevel}`);
}
}
for (const method of methods) {
if (method === "constructor" || method[0] !== method[0].toUpperCase()) { // * Anything that starts with lowercase we'll consider "private"
continue;
@ -23,6 +36,7 @@ export default abstract class Controller {
const methodName = methodNameRaw.toLowerCase();
const doAuth = !params.includes("AllowAnonymous");
// @ts-ignore
const controllerRequestHandler = this[method];
const requestHandler = (req:FastifyRequest, res:FastifyReply) => {
@ -30,6 +44,9 @@ export default abstract class Controller {
if (doAuth && session === undefined) {
return res.redirect(`/account/login?returnTo=${encodeURIComponent(req.url)}`);
}
if (session !== undefined && controllerAuthLevel !== undefined && controllerAuthLevel !== session.userLevel) {
return res.status(403).send("Forbidden");
}
const requestCtx = new RequestCtx(req, res, controllerName, methodName, session);
controllerRequestHandler.bind(requestCtx)(req.method === "GET" ? req.query : req.body);
@ -80,4 +97,5 @@ export default abstract class Controller {
ok(message?:string) {}
badRequest(message?:string) {}
unauthorised(message?:string) {}
forbidden(message?:string) {}
}

View file

@ -1,5 +1,11 @@
import { UserLevel } from "../enums/UserLevel";
export default class User {
public Id:number;
public UserLevel:UserLevel;
public get UserLevelString() {
return UserLevel[this.UserLevel];
}
public Username:string;
public PasswordSalt:string;
public PasswordHash:string;
@ -12,9 +18,10 @@ export default class User {
public DeletedDatetime?:Date;
public IsDeleted:boolean;
public constructor(id?:number, username?:string, passwordSalt?:string, passwordHash?:string, apiKey?:string, createdByUserId?:number, createdDateTime?:Date, lastModifiedByUserId?:number, lastModifiedDatetime?:Date, deletedByUserId?:number, deletedDatetime?:Date, isDeleted?:boolean) {
if (typeof(id) == "number" && typeof(username) == "string" && typeof(passwordHash) == "string" && typeof(passwordSalt) == "string" && typeof(apiKey) == "string" && typeof(createdByUserId) == "number" && createdDateTime instanceof Date && typeof(lastModifiedByUserId) == "number" && lastModifiedDatetime instanceof Date && typeof(deletedByUserId) == "number" && deletedDatetime instanceof Date && typeof(isDeleted) == "boolean") {
public constructor(id?:number, userLevel?:UserLevel, username?:string, passwordSalt?:string, passwordHash?:string, apiKey?:string, createdByUserId?:number, createdDateTime?:Date, lastModifiedByUserId?:number, lastModifiedDatetime?:Date, deletedByUserId?:number, deletedDatetime?:Date, isDeleted?:boolean) {
if (typeof(id) == "number" && typeof(userLevel) == "number" && typeof(username) == "string" && typeof(passwordHash) == "string" && typeof(passwordSalt) == "string" && typeof(apiKey) == "string" && typeof(createdByUserId) == "number" && createdDateTime instanceof Date && typeof(lastModifiedByUserId) == "number" && lastModifiedDatetime instanceof Date && typeof(deletedByUserId) == "number" && deletedDatetime instanceof Date && typeof(isDeleted) == "boolean") {
this.Id = id;
this.UserLevel = userLevel;
this.Username = username;
this.PasswordHash = passwordHash;
this.PasswordSalt = passwordSalt;
@ -28,6 +35,7 @@ export default class User {
this.IsDeleted = isDeleted;
} else {
this.Id = Number.MIN_VALUE;
this.UserLevel = UserLevel.Unknown;
this.Username = "";
this.PasswordHash = "";
this.PasswordSalt = "";

View file

@ -0,0 +1,5 @@
export enum UserLevel {
Unknown = 0,
User = 10,
Admin = 999
}

View file

@ -40,6 +40,7 @@ import AccountController from "./controller/AccountController";
import PartyController from "./controller/PartyController";
import ApiController from "./controller/ApiController";
import PartyService from "./services/PartyService";
import AdminController_Auth$Admin from "./controller/AdminController";
Console.customHeader(`MultiProbe server started at ${new Date()}`);
@ -72,6 +73,7 @@ new HomeController();
new AccountController();
new PartyController();
new ApiController();
new AdminController_Auth$Admin();
// Websocket stuff

View file

@ -0,0 +1,4 @@
export default interface ChangeUsernameViewModel {
username?: string
message?: string
}

View file

@ -0,0 +1,4 @@
export default interface AdminIndexViewModel {
userCount: number,
partyCount: number
}

View file

@ -0,0 +1,5 @@
import Party from "../../entities/Party";
export default interface AdminPartiesViewModel {
parties: Array<Party>
}

View file

@ -0,0 +1,5 @@
import User from "../../entities/User";
export default interface AdminUsersViewModel {
users: Array<User>
}

View file

@ -57,4 +57,8 @@ export default class RequestCtx {
unauthorised(message?:string) {
return this.res.status(401).send(message ?? "");
}
forbidden(message?:string) {
return this.res.status(403).send(message ?? "");
}
}

View file

@ -37,7 +37,7 @@ export default abstract class Session {
validPeriod.setTime(validPeriod.getTime() + Config.session.validity);
const key = randomBytes(Config.session.length).toString("hex");
Session.Sessions.set(key, new SessionUser(user.Id, validPeriod));
Session.Sessions.set(key, new SessionUser(user.Id, user.UserLevel, validPeriod));
res.setCookie("MP_SESSION", key, {
path: "/",

View file

@ -1,9 +1,13 @@
import { UserLevel } from "../enums/UserLevel";
export default class SessionUser {
public readonly userId:number;
public readonly userLevel:UserLevel;
public readonly validityPeriod:Date;
constructor(userId:number, validityPeriod:Date) {
constructor(userId:number, userLevel:UserLevel, validityPeriod:Date) {
this.userId = userId;
this.userLevel = userLevel;
this.validityPeriod = validityPeriod;
}
}

View file

@ -4,6 +4,19 @@ import User from "../entities/User";
import RepoBase from "./RepoBase";
export default class PartyRepo {
public static async selectAll() {
const dbUser = await Database.Instance.query("SELECT * FROM Party WHERE IsDeleted = 0");
const parties = new Array<Party>();
for (const row of dbUser) {
const party = new Party();
populatePartyFromDB(party, row);
parties.push(party);
}
return parties;
}
public static async selectById(id:number) {
const dbParty = await Database.Instance.query("SELECT * FROM Party WHERE Id = ? AND IsDeleted = 0 LIMIT 1", [id]);
if (dbParty == null || dbParty.length === 0) {
@ -42,6 +55,12 @@ export default class PartyRepo {
}
}
public static async selectPartyCount() {
const countResult = await Database.Instance.query("SELECT COUNT(Id) FROM `Party` LIMIT 1");
return countResult[0]["COUNT(Id)"];
}
public static async insertUpdate(party:Party) {
if (party.Id === Number.MIN_VALUE) {
await Database.Instance.query("INSERT Party (Name, PartyRef, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [

View file

@ -3,6 +3,19 @@ import User from "../entities/User";
import RepoBase from "./RepoBase";
export default class UserRepo {
public static async selectAll() {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE IsDeleted = 0");
const users = new Array<User>();
for (const row of dbUser) {
const user = new User();
populateUserFromDB(user, row);
users.push(user);
}
return users;
}
public static async selectById(id:number) {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE Id = ? LIMIT 1", [id]);
if (dbUser == null || dbUser.length === 0) {
@ -36,14 +49,20 @@ export default class UserRepo {
}
}
public static async selectUserCount() {
const countResult = await Database.Instance.query("SELECT COUNT(Id) FROM `User` LIMIT 1");
return countResult[0]["COUNT(Id)"];
}
public static async insertUpdate(user:User) {
if (user.Id === Number.MIN_VALUE) {
await Database.Instance.query("INSERT User (Username, PasswordHash, PasswordSalt, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted)
await Database.Instance.query("INSERT User (UserLevelId, Username, PasswordHash, PasswordSalt, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
user.UserLevel, user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted)
]);
} else {
await Database.Instance.query(`UPDATE User SET Username = ?, PasswordHash = ?, PasswordSalt = ?, APIKey = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ?, WHERE Id = ?`, [
user.Username, user.PasswordHash, user.PasswordSalt, user.APIKey, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted), user.Id
await Database.Instance.query(`UPDATE User SET UserLevelId = ?, Username = ?, PasswordHash = ?, PasswordSalt = ?, APIKey = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ? WHERE Id = ?`, [
user.UserLevel, user.Username, user.PasswordHash, user.PasswordSalt, user.APIKey, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted), user.Id
]);
}
}
@ -51,6 +70,7 @@ export default class UserRepo {
function populateUserFromDB(user:User, dbUser:any) {
user.Id = dbUser.Id;
user.UserLevel = dbUser.UserLevelId;
user.Username = dbUser.Username;
user.PasswordHash = dbUser.PasswordHash;
user.PasswordSalt = dbUser.PasswordSalt;

View file

@ -42,6 +42,15 @@ export default abstract class PartyService {
}
}
public static async GetAll() {
try {
return await PartyRepo.selectAll();
} catch (e) {
Console.printError(`MultiProbe server service error:\n${e}`);
throw e;
}
}
public static async GetPartyByPartyRef(partyRef:string) {
try {
return await PartyRepo.selectByPartyRef(partyRef);
@ -81,4 +90,13 @@ export default abstract class PartyService {
throw e;
}
}
public static async GetPartyCount() {
try {
return await PartyRepo.selectPartyCount();
} catch (e) {
Console.printError(`MultiProbe server service error:\n${e}`);
throw e;
}
}
}

View file

@ -33,6 +33,15 @@ export default class UserService {
}
}
public static async GetAll() {
try {
return await UserRepo.selectAll();
} catch (e) {
Console.printError(`MultiProbe server service error:\n${e}`);
throw e;
}
}
public static async GetUserByUsername(username:string) {
try {
return await UserRepo.selectByUsername(username);
@ -62,6 +71,11 @@ export default class UserService {
public static async CreateUser(currentUserId:number, username:string, password:string) {
try {
const existingCheck = await UserRepo.selectByUsername(username);
if (existingCheck) {
return null;
}
const user = new User();
user.Username = username;
user.PasswordSalt = PasswordUtility.GenerateSalt();
@ -70,6 +84,8 @@ export default class UserService {
user.CreatedDatetime = new Date();
await UserRepo.insertUpdate(user);
return user;
} catch (e) {
Console.printError(`MultiProbe server service error:\n${e}`);
throw e;
@ -150,4 +166,39 @@ export default class UserService {
throw e;
}
}
public static async SaveUsername(currentUserId:number, username:string) {
try {
const existingCheck = await UserRepo.selectByUsername(username)
if (existingCheck) {
return null;
}
const user = await UserRepo.selectById(currentUserId);
if (!user) {
return null;
}
user.LastModifiedByUserId = currentUserId;
user.LastModifiedDatetime = new Date();
user.Username = username;
await UserRepo.insertUpdate(user);
return user;
} catch (e) {
Console.printError(`MultiProbe server service error:\n${e}`);
throw e;
}
}
public static async GetUserCount() {
try {
return await UserRepo.selectUserCount();
} catch (e) {
Console.printError(`MultiProbe server service error:\n${e}`);
throw e;
}
}
}

View file

@ -0,0 +1,25 @@
<%- include("../base/header", { title: "Login", userId: session.userId }) %>
<div class="d-flex justify-content-center">
<div class="card my-auto" style="width: 25rem;">
<div class="card-body">
<h4 class="card-title text-center">Change Username</h5>
<% if (typeof(message) === "string") { %>
<div class="alert alert-danger text-center" role="alert"><%= message %></div>
<% } %>
<form method="POST">
<input class="form-control mt-3 mb-2" name="username" placeholder="Username" value="<%= typeof(username) === "undefined" ? "" : username %>" required autocomplete="one-time-code" />
<div class="row mt-3">
<div class="col-auto">
<a class="btn btn-danger d-block" href="/">Cancel</a>
</div>
<div class="col">
<input class="btn btn-primary ms-auto d-block" type="submit" value="Change" />
</div>
</div>
</form>
</div>
</div>
</div>
<%- include("../base/footer") %>

View file

@ -0,0 +1,53 @@
<%- include("../base/header", { title: "Admin Dashboard", userId: session.userId, isAdmin: true }) %>
<div class="row">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active"><a>Admin</a></li>
</ol>
</nav>
</div>
</div>
<div class="row">
<div class="col">
<h1>Admin Dashboard</h1>
</div>
</div>
<div class="row row-cols-3 my-5">
<div class="col mb-3">
<div class="card">
<div class="card-body">
<h4 class="card-title">Users</h4>
<h3 class="card-text mb-0"><%= userCount %></h3>
</div>
</div>
</div>
<div class="col mb-3">
<div class="card">
<div class="card-body">
<h4 class="card-title">Parties</h4>
<h3 class="card-text mb-0"><%= partyCount %></h3>
</div>
</div>
</div>
<div class="col mb-3">
<div class="card">
<div class="card-body">
<h4 class="card-title">Badges</h5>
<h3 class="card-text mb-0"><%= 0 %></h3>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<a class="btn btn-primary btn-lg me-2" href="/admin/users">Manage Users</a>
<a class="btn btn-primary btn-lg me-2" href="/admin/parties">Manage Parties</a>
<a class="btn btn-primary btn-lg me-2">Manage Badges</a>
</div>
</div>
<%- include("../base/footer") %>

View file

@ -0,0 +1,45 @@
<%- include("../base/header", { title: "Party Management", userId: session.userId, isAdmin: true }) %>
<div class="row">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/admin">Admin</a></li>
<li class="breadcrumb-item active"><a>Party Management</a></li>
</ol>
</nav>
</div>
</div>
<div class="row">
<div class="col">
<h1>Party Management</h1>
</div>
</div>
<div class="row my-5">
<div class="col">
<table class="table table-striped">
<thead>
<th>#</th>
<th>Party Ref</th>
<th>Name</th>
<th>&nbsp;</th>
</thead>
<tbody>
<% for (const party of parties) { %>
<tr>
<td><%= party.Id %></td>
<td><%= "redacted" %></td>
<td><%= party.Name %></td>
<td class="text-end">
<a class="btn btn-sm btn-primary" href="/admin/user?id=<%= party.Id %>">Edit</a>
<a class="btn btn-sm btn-danger" href="/admin/userdelete?id=<%= party.Id %>">Delete</a>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
<%- include("../base/footer") %>

View file

@ -0,0 +1,45 @@
<%- include("../base/header", { title: "User Management", userId: session.userId, isAdmin: true }) %>
<div class="row">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/admin">Admin</a></li>
<li class="breadcrumb-item active"><a>User Management</a></li>
</ol>
</nav>
</div>
</div>
<div class="row">
<div class="col">
<h1>User Management</h1>
</div>
</div>
<div class="row my-5">
<div class="col">
<table class="table table-striped">
<thead>
<th>#</th>
<th>Username</th>
<th>Permissions</th>
<th>&nbsp;</th>
</thead>
<tbody>
<% for (const user of users) { %>
<tr>
<td><%= user.Id %></td>
<td><%= user.Username %></td>
<td><%= user.UserLevelString %></td>
<td class="text-end">
<a class="btn btn-sm btn-primary" href="/admin/user?id=<%= user.Id %>">Edit</a>
<a class="btn btn-sm btn-danger" href="/admin/userdelete?id=<%= user.Id %>">Delete</a>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
<%- include("../base/footer") %>

View file

@ -14,7 +14,7 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/">
MultiProbe
MultiProbe<%= typeof(isAdmin) === "undefined" ? "" : " Admin" %>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>

View file

@ -5,7 +5,7 @@
<h3>What would you like to do?</h3>
<div class="mt-3 text-nowrap">
<div>
<a class="btn btn-primary btn-lg me-2 disabled" href="/account/username">Change Username</a>
<a class="btn btn-primary btn-lg me-2" href="/account/changeusername">Change Username</a>
<a class="btn btn-primary btn-lg me-2 disabled" href="/account/password">Change Password</a>
</div>
<div class="mt-3">