added user management

added user management
This commit is contained in:
Kosztyk 2026-01-12 12:12:42 +02:00 committed by GitHub
parent bb751163ba
commit c37def317e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 574 additions and 17 deletions

View file

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

View file

@ -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 (
<BaseHtml webroot={WEBROOT} title="ConvertX | Account">
<>
@ -445,6 +466,176 @@ export const user = new Elysia()
</div>
</form>
</article>
{userData.role === "admin" && (
<>
<article class="article mt-6">
<header class="mb-4">
<h2 class="text-xl font-semibold">Add new user</h2>
<p class="text-sm text-neutral-400">
Create additional users for this ConvertX instance. Admins can create other
admins or normal users.
</p>
</header>
<form
method="post"
action={`${WEBROOT}/account/add-user`}
class="flex flex-col gap-4"
>
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="newUserEmail"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
<input
type="password"
name="newUserPassword"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="new-password"
required
/>
</label>
<label class="flex flex-col gap-1">
Role
<select
name="newUserRole"
class="rounded-sm bg-neutral-800 p-3"
required
>
<option value="user">Normal user</option>
<option value="admin">Admin</option>
</select>
</label>
</fieldset>
<div role="group">
<input type="submit" value="Add user" class="w-full btn-secondary" />
</div>
</form>
</article>
{otherUsers.length > 0 && (
<article class="article mt-6">
<header class="mb-4">
<h2 class="text-xl font-semibold">Manage users</h2>
<p class="text-sm text-neutral-400">
Edit or delete users from this instance. You cannot delete yourself or the
last remaining admin.
</p>
</header>
<div class="scrollbar-thin max-h-[50vh] overflow-y-auto">
<table
class={`
w-full table-auto rounded bg-neutral-900
[&_td]:border-b [&_td]:border-neutral-800 [&_td]:p-2
[&_th]:border-b [&_th]:border-neutral-800 [&_th]:p-2
`}
>
<thead>
<tr>
<th class="text-left">Email</th>
<th class="text-left">Role</th>
<th class="text-left">Actions</th>
</tr>
</thead>
<tbody>
{otherUsers.map((u) => (
<tr>
<td>{u.email}</td>
<td class="capitalize">{u.role}</td>
<td>
<div class="flex items-center gap-6">
{/* Edit / details icon */}
<form
method="get"
action={`${WEBROOT}/account/edit-user`}
>
<input
type="hidden"
name="userId"
value={String(u.id)}
/>
<button
type="submit"
class={`
inline-flex items-center justify-center text-accent-400
hover:text-accent-500
`}
title="Edit user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-6 w-6"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2.458 12C3.732 7.943 7.523 5 12 5s8.268 2.943 9.542 7c-1.274 4.057-5.065 7-9.542 7s-8.268-2.943-9.542-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</form>
{/* Delete icon */}
<form
method="post"
action={`${WEBROOT}/account/delete-user`}
onsubmit="return confirm('Are you sure you want to delete this user?');"
>
<input
type="hidden"
name="deleteUserId"
value={String(u.id)}
/>
<button
type="submit"
class={`
inline-flex items-center justify-center text-accent-400
hover:text-accent-500
`}
title="Delete user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-6 w-6"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7h16" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12" />
<path d="M9 4h6a1 1 0 0 1 1 1v2H8V5a1 1 0 0 1 1-1z" />
</svg>
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
)}
</>
)}
</main>
</>
</BaseHtml>
@ -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 (
<BaseHtml webroot={WEBROOT} title="ConvertX | Edit user">
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<header class="mb-4">
<h1 class="text-xl font-semibold">Edit user</h1>
<p class="text-sm text-neutral-400">
Change this user&apos;s role or set a new password. Leave password blank to keep
it unchanged.
</p>
</header>
<form
method="post"
action={`${WEBROOT}/account/edit-user`}
class={`
flex flex-col gap-4
`}
>
<input type="hidden" name="userId" value={String(targetUser.id)} />
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
value={targetUser.email}
class="rounded-sm bg-neutral-800 p-3"
disabled
/>
</label>
<label class="flex flex-col gap-1">
Role
<select
name="role"
class="rounded-sm bg-neutral-800 p-3 capitalize"
required
>
<option value="user" selected={targetUser.role === "user"}>
Normal user
</option>
<option value="admin" selected={targetUser.role === "admin"}>
Admin
</option>
</select>
</label>
<label class="flex flex-col gap-1">
New password (optional)
<input
type="password"
name="newPassword"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Leave blank to keep current password"
autocomplete="new-password"
/>
</label>
</fieldset>
<div class="flex flex-row gap-4">
<a
href={`${WEBROOT}/account`}
class="w-full btn-secondary text-center"
>
Cancel
</a>
<button type="submit" class="w-full btn-primary">
Save changes
</button>
</div>
</form>
</article>
</main>
</>
</BaseHtml>
);
},
{
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",
},
);