From c37def317ecab2bf3ea20cbdd990a3ba94fa2d25 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:12:42 +0200 Subject: [PATCH] added user management added user management --- src/pages/root.tsx | 30 ++- src/pages/user.tsx | 561 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 574 insertions(+), 17 deletions(-) diff --git a/src/pages/root.tsx b/src/pages/root.tsx index f2a83de..6134722 100644 --- a/src/pages/root.tsx +++ b/src/pages/root.tsx @@ -30,18 +30,22 @@ export const root = new Elysia().use(userService).get( } // validate jwt - let user: ({ id: string } & JWTPayloadSpec) | false = false; + let user: ({ id: string; role: string } & JWTPayloadSpec) | false = false; + if (ALLOW_UNAUTHENTICATED) { const newUserId = String( UNAUTHENTICATED_USER_SHARING ? 0 : randomInt(2 ** 24, Math.min(2 ** 48 + 2 ** 24 - 1, Number.MAX_SAFE_INTEGER)), ); + const accessToken = await jwt.sign({ id: newUserId, + role: "user", }); - user = { id: newUserId }; + user = { id: newUserId, role: "user" }; + if (!auth) { return { message: "No auth cookie, perhaps your browser is blocking cookies.", @@ -57,7 +61,24 @@ export const root = new Elysia().use(userService).get( sameSite: "strict", }); } else if (auth?.value) { - user = await jwt.verify(auth.value); + const verified = await jwt.verify(auth.value); + + // If verification fails, keep user as false + if (verified === false) { + user = false; + } else { + // Ensure role is present (defensive, for older tokens) + const verifiedUser = verified as { id?: string; role?: string } & JWTPayloadSpec; + + if (!verifiedUser.id) { + user = false; + } else if (!verifiedUser.role) { + // fallback: treat as normal user if role missing + user = { ...verifiedUser, id: verifiedUser.id, role: "user" }; + } else { + user = verifiedUser as ({ id: string; role: string } & JWTPayloadSpec); + } + } if ( user !== false && @@ -73,6 +94,9 @@ export const root = new Elysia().use(userService).get( } return redirect(`${WEBROOT}/login`, 302); } + + // Optional: if you want DB to be the source of truth for role, uncomment below: + // user = { ...user, role: existingUser.role ?? "user" }; } } diff --git a/src/pages/user.tsx b/src/pages/user.tsx index 0fd9065..1d14ccd 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -21,6 +21,7 @@ export const userService = new Elysia({ name: "user/service" }) name: "jwt", schema: t.Object({ id: t.String(), + role: t.String(), // user role in JWT }), secret: process.env.JWT_SECRET ?? randomUUID(), exp: "7d", @@ -183,7 +184,10 @@ export const user = new Elysia() .post( "/register", async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => { - if (!ACCOUNT_REGISTRATION && !FIRST_RUN) { + // first user allowed even if ACCOUNT_REGISTRATION=false + const isFirstUser = FIRST_RUN; + + if (!ACCOUNT_REGISTRATION && !isFirstUser) { return redirect(`${WEBROOT}/login`, 302); } @@ -200,11 +204,17 @@ export const user = new Elysia() } const savedPassword = await Bun.password.hash(password); - db.query("INSERT INTO users (email, password) VALUES (?, ?)").run(email, savedPassword); + const role = isFirstUser ? "admin" : "user"; - const user = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); + db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run( + email, + savedPassword, + role, + ); - if (!user) { + const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); + + if (!userRow) { set.status = 500; return { message: "Failed to create user.", @@ -212,7 +222,8 @@ export const user = new Elysia() } const accessToken = await jwt.sign({ - id: String(user.id), + id: String(userRow.id), + role: userRow.role, }); if (!auth) { @@ -318,7 +329,9 @@ export const user = new Elysia() .post( "/login", async function handler({ body, set, redirect, jwt, cookie: { auth } }) { - const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get(body.email); + const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get( + body.email, + ); if (!existingUser) { set.status = 403; @@ -338,6 +351,7 @@ export const user = new Elysia() const accessToken = await jwt.sign({ id: String(existingUser.id), + role: existingUser.role ?? "user", }); if (!auth) { @@ -387,6 +401,13 @@ export const user = new Elysia() return redirect(`${WEBROOT}/`, 302); } + let otherUsers: { id: number; email: string; role: string }[] = []; + if (userData.role === "admin") { + otherUsers = db + .query("SELECT id, email, role FROM users WHERE id != ? ORDER BY email ASC") + .all(userData.id) as { id: number; email: string; role: string }[]; + } + return ( <> @@ -445,6 +466,176 @@ export const user = new Elysia() + + {userData.role === "admin" && ( + <> +
+
+

Add new user

+

+ Create additional users for this ConvertX instance. Admins can create other + admins or normal users. +

+
+
+
+ + + +
+
+ +
+
+
+ + {otherUsers.length > 0 && ( +
+
+

Manage users

+

+ Edit or delete users from this instance. You cannot delete yourself or the + last remaining admin. +

+
+
+ + + + + + + + + + {otherUsers.map((u) => ( + + + + + + ))} + +
EmailRoleActions
{u.email}{u.role} +
+ {/* Edit / details icon */} +
+ + +
+ + {/* Delete icon */} +
+ + +
+
+
+
+
+ )} + + )}
@@ -461,11 +652,13 @@ export const user = new Elysia() return redirect(`${WEBROOT}/login`, 302); } - const user = await jwt.verify(auth.value); - if (!user) { + const tokenUser = await jwt.verify(auth.value); + if (!tokenUser) { return redirect(`${WEBROOT}/login`, 302); } - const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id); + const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get( + tokenUser.id, + ); if (!existingUser) { if (auth?.value) { @@ -483,15 +676,16 @@ export const user = new Elysia() }; } - const fields = []; - const values = []; + const fields: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const values: any[] = []; if (body.email) { - const existingUser = await db + const existingUserWithEmail = await db .query("SELECT id FROM users WHERE email = ?") .as(User) .get(body.email); - if (existingUser && existingUser.id.toString() !== user.id) { + if (existingUserWithEmail && existingUserWithEmail.id.toString() !== tokenUser.id) { set.status = 409; return { message: "Email already in use." }; } @@ -506,7 +700,7 @@ export const user = new Elysia() if (fields.length > 0) { db.query( `UPDATE users SET ${fields.map((field) => `${field}=?`).join(", ")} WHERE id=?`, - ).run(...values, user.id); + ).run(...values, tokenUser.id); } return redirect(`${WEBROOT}/`, 302); @@ -519,4 +713,343 @@ export const user = new Elysia() }), cookie: "session", }, + ) + .post( + "/account/add-user", + async ({ body, set, redirect, jwt, cookie: { auth } }) => { + if (!auth?.value) { + return redirect(`${WEBROOT}/login`, 302); + } + + const tokenUser = await jwt.verify(auth.value); + if (!tokenUser) { + return redirect(`${WEBROOT}/login`, 302); + } + + const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(tokenUser.id); + + if (!actingUser) { + if (auth?.value) { + auth.remove(); + } + return redirect(`${WEBROOT}/login`, 302); + } + + if (actingUser.role !== "admin") { + set.status = 403; + return { + message: "Only admins can create new users.", + }; + } + + const { newUserEmail, newUserPassword, newUserRole } = body as { + newUserEmail: string; + newUserPassword: string; + newUserRole: string; + }; + + if (!newUserEmail || !newUserPassword) { + set.status = 400; + return { + message: "Missing email or password.", + }; + } + + const existingNewUser = db.query("SELECT id FROM users WHERE email = ?").get(newUserEmail); + if (existingNewUser) { + set.status = 400; + return { + message: "A user with this email already exists.", + }; + } + + const hashedPassword = await Bun.password.hash(newUserPassword); + const role = newUserRole === "admin" ? "admin" : "user"; + + db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run( + newUserEmail, + hashedPassword, + role, + ); + + return redirect(`${WEBROOT}/account`, 302); + }, + { + body: t.Object({ + newUserEmail: t.String(), + newUserPassword: t.String(), + newUserRole: t.String(), + }), + cookie: "session", + }, + ) + .get( + "/account/edit-user", + async ({ query, user, redirect }) => { + if (!user) { + return redirect(`${WEBROOT}/login`, 302); + } + + const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id); + if (!actingUser || actingUser.role !== "admin") { + return redirect(`${WEBROOT}/account`, 302); + } + + const targetId = Number.parseInt(query.userId, 10); + if (!Number.isFinite(targetId) || targetId === actingUser.id) { + return redirect(`${WEBROOT}/account`, 302); + } + + const targetUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(targetId); + if (!targetUser) { + return redirect(`${WEBROOT}/account`, 302); + } + + return ( + + <> +
+
+
+
+

Edit user

+

+ Change this user's role or set a new password. Leave password blank to keep + it unchanged. +

+
+
+ +
+ + + +
+
+ + Cancel + + +
+
+
+
+ + + ); + }, + { + auth: true, + query: t.Object({ + userId: t.String(), + }), + }, + ) + .post( + "/account/edit-user", + async ({ body, set, redirect, jwt, cookie: { auth } }) => { + if (!auth?.value) { + return redirect(`${WEBROOT}/login`, 302); + } + + const tokenUser = await jwt.verify(auth.value); + if (!tokenUser) { + return redirect(`${WEBROOT}/login`, 302); + } + + const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(tokenUser.id); + if (!actingUser || actingUser.role !== "admin") { + set.status = 403; + return { message: "Only admins can edit users." }; + } + + const { userId, role, newPassword } = body as { + userId: string; + role: string; + newPassword?: string; + }; + + const targetId = Number.parseInt(userId, 10); + if (!Number.isFinite(targetId) || targetId === actingUser.id) { + return redirect(`${WEBROOT}/account`, 302); + } + + const targetUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(targetId); + if (!targetUser) { + return redirect(`${WEBROOT}/account`, 302); + } + + // Prevent demoting the last admin + if (targetUser.role === "admin" && role !== "admin") { + const adminCountRow = db + .query("SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'") + .get() as { cnt: number }; + if (adminCountRow.cnt <= 1) { + set.status = 400; + return { message: "You cannot demote the last remaining admin." }; + } + } + + const fields: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const values: any[] = []; + + if (role === "admin" || role === "user") { + fields.push("role"); + values.push(role); + } + + if (newPassword && newPassword.trim().length > 0) { + fields.push("password"); + values.push(await Bun.password.hash(newPassword)); + } + + if (fields.length > 0) { + db.query( + `UPDATE users SET ${fields.map((f) => `${f}=?`).join(", ")} WHERE id=?`, + ).run(...values, targetId); + } + + return redirect(`${WEBROOT}/account`, 302); + }, + { + body: t.Object({ + userId: t.String(), + role: t.String(), + newPassword: t.MaybeEmpty(t.String()), + }), + cookie: "session", + }, + ) + .post( + "/account/delete-user", + async ({ body, set, redirect, jwt, cookie: { auth } }) => { + if (!auth?.value) { + return redirect(`${WEBROOT}/login`, 302); + } + + const tokenUser = await jwt.verify(auth.value); + if (!tokenUser) { + return redirect(`${WEBROOT}/login`, 302); + } + + const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(tokenUser.id); + + if (!actingUser) { + if (auth?.value) { + auth.remove(); + } + return redirect(`${WEBROOT}/login`, 302); + } + + if (actingUser.role !== "admin") { + set.status = 403; + return { message: "Only admins can delete users." }; + } + + const { deleteUserId } = body as { deleteUserId: string }; + const targetId = Number.parseInt(deleteUserId, 10); + + if (!Number.isFinite(targetId)) { + set.status = 400; + return { message: "Invalid user id." }; + } + + if (targetId === actingUser.id) { + set.status = 400; + return { message: "You cannot delete your own account from here." }; + } + + const targetUser = db + .query("SELECT * FROM users WHERE id = ?") + .as(User) + .get(targetId as unknown as number); + + if (!targetUser) { + return redirect(`${WEBROOT}/account`, 302); + } + + if (targetUser.role === "admin") { + const adminCountRow = db + .query("SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'") + .get() as { cnt: number }; + if (adminCountRow.cnt <= 1) { + set.status = 400; + return { message: "You cannot delete the last remaining admin." }; + } + } + + // delete this user's jobs and files (to avoid FK issues) in a single transaction + const deleteUserTx = db.transaction((id: number) => { + db.query( + "DELETE FROM file_names WHERE job_id IN (SELECT id FROM jobs WHERE user_id = ?)", + ).run(id); + db.query("DELETE FROM jobs WHERE user_id = ?").run(id); + db.query("DELETE FROM users WHERE id = ?").run(id); + }); + + deleteUserTx(targetId); + + return redirect(`${WEBROOT}/account`, 302); + }, + { + body: t.Object({ + deleteUserId: t.String(), + }), + cookie: "session", + }, ); +