1083 lines
35 KiB
TypeScript
1083 lines
35 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { jwt } from "@elysiajs/jwt";
|
|
import { Elysia, t } from "elysia";
|
|
import { BaseHtml } from "../components/base";
|
|
import { Header } from "../components/header";
|
|
import db from "../db/db";
|
|
import { User } from "../db/types";
|
|
import {
|
|
ACCOUNT_REGISTRATION,
|
|
ALLOW_UNAUTHENTICATED,
|
|
HIDE_HISTORY,
|
|
HTTP_ALLOWED,
|
|
WEBROOT,
|
|
} from "../helpers/env";
|
|
|
|
function computeFirstRun(): boolean {
|
|
// DB-driven, so it stays correct across reloads and per-request updates.
|
|
return db.query("SELECT 1 FROM users LIMIT 1").get() === null;
|
|
}
|
|
|
|
// Kept as an exported boolean for backwards-compatibility (e.g. root.tsx imports FIRST_RUN),
|
|
// but it is refreshed per-request via userService. Do not rely on it being constant.
|
|
export let FIRST_RUN = computeFirstRun();
|
|
|
|
export const userService = new Elysia({ name: "user/service" })
|
|
.derive(() => {
|
|
FIRST_RUN = computeFirstRun();
|
|
return {};
|
|
})
|
|
.use(
|
|
jwt({
|
|
name: "jwt",
|
|
schema: t.Object({
|
|
id: t.String(),
|
|
role: t.String(), // user role in JWT
|
|
}),
|
|
secret: process.env.JWT_SECRET ?? randomUUID(),
|
|
exp: "7d",
|
|
}),
|
|
)
|
|
.model({
|
|
signIn: t.Object({
|
|
email: t.String(),
|
|
password: t.String(),
|
|
}),
|
|
session: t.Cookie({
|
|
auth: t.String(),
|
|
jobId: t.Optional(t.String()),
|
|
}),
|
|
optionalSession: t.Cookie({
|
|
auth: t.Optional(t.String()),
|
|
jobId: t.Optional(t.String()),
|
|
}),
|
|
})
|
|
.macro("auth", {
|
|
cookie: "session",
|
|
async resolve({ status, jwt, cookie: { auth } }) {
|
|
if (!auth.value) {
|
|
return status(401, {
|
|
success: false,
|
|
message: "Unauthorized",
|
|
});
|
|
}
|
|
const user = await jwt.verify(auth.value);
|
|
if (!user) {
|
|
return status(401, {
|
|
success: false,
|
|
message: "Unauthorized",
|
|
});
|
|
}
|
|
return {
|
|
success: true,
|
|
user,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const user = new Elysia()
|
|
.use(userService)
|
|
.get("/setup", ({ redirect }) => {
|
|
const isFirstRun = computeFirstRun();
|
|
if (!isFirstRun) {
|
|
return redirect(`${WEBROOT}/login`, 302);
|
|
}
|
|
|
|
return (
|
|
<BaseHtml title="ConvertX | Setup" webroot={WEBROOT}>
|
|
<main
|
|
class={`
|
|
mx-auto w-full max-w-4xl flex-1 px-2
|
|
sm:px-4
|
|
`}
|
|
>
|
|
<h1 class="my-8 text-3xl">Welcome to ConvertX!</h1>
|
|
<article class="article p-0">
|
|
<header class="w-full bg-neutral-800 p-4">Create your account</header>
|
|
<form method="post" action={`${WEBROOT}/register`} class="p-4">
|
|
<fieldset class="mb-4 flex flex-col gap-4">
|
|
<label class="flex flex-col gap-1">
|
|
Email
|
|
<input
|
|
type="email"
|
|
name="email"
|
|
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="password"
|
|
class="rounded-sm bg-neutral-800 p-3"
|
|
placeholder="Password"
|
|
autocomplete="current-password"
|
|
required
|
|
/>
|
|
</label>
|
|
</fieldset>
|
|
<input type="submit" value="Create account" class="btn-primary" />
|
|
</form>
|
|
<footer class="p-4">
|
|
Report any issues on{" "}
|
|
<a
|
|
class={`
|
|
text-accent-500 underline
|
|
hover:text-accent-400
|
|
`}
|
|
href="https://github.com/C4illin/ConvertX"
|
|
>
|
|
GitHub
|
|
</a>
|
|
.
|
|
</footer>
|
|
</article>
|
|
</main>
|
|
</BaseHtml>
|
|
);
|
|
})
|
|
.get("/register", ({ redirect }) => {
|
|
if (!ACCOUNT_REGISTRATION) {
|
|
return redirect(`${WEBROOT}/login`, 302);
|
|
}
|
|
|
|
return (
|
|
<BaseHtml webroot={WEBROOT} title="ConvertX | Register">
|
|
<>
|
|
<Header
|
|
webroot={WEBROOT}
|
|
accountRegistration={ACCOUNT_REGISTRATION}
|
|
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
|
hideHistory={HIDE_HISTORY}
|
|
/>
|
|
<main
|
|
class={`
|
|
w-full flex-1 px-2
|
|
sm:px-4
|
|
`}
|
|
>
|
|
<article class="article">
|
|
<form method="post" 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="email"
|
|
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="password"
|
|
class="rounded-sm bg-neutral-800 p-3"
|
|
placeholder="Password"
|
|
autocomplete="current-password"
|
|
required
|
|
/>
|
|
</label>
|
|
</fieldset>
|
|
<input type="submit" value="Register" class="w-full btn-primary" />
|
|
</form>
|
|
</article>
|
|
</main>
|
|
</>
|
|
</BaseHtml>
|
|
);
|
|
})
|
|
.post(
|
|
"/register",
|
|
async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => {
|
|
// DB-driven "first user" detection (no stale in-memory flag) + race-safe creation.
|
|
// We hash outside the write-lock to keep the lock window short.
|
|
const savedPassword = await Bun.password.hash(password);
|
|
|
|
// Acquire a write lock so only one instance can perform "count==0 then insert" at a time.
|
|
db.exec("BEGIN IMMEDIATE");
|
|
try {
|
|
const isFirstUser = computeFirstRun();
|
|
|
|
// first user allowed even if ACCOUNT_REGISTRATION=false
|
|
if (!ACCOUNT_REGISTRATION && !isFirstUser) {
|
|
db.exec("ROLLBACK");
|
|
return redirect(`${WEBROOT}/login`, 302);
|
|
}
|
|
|
|
const existingUser = db.query("SELECT 1 FROM users WHERE email = ?").get(email);
|
|
if (existingUser) {
|
|
db.exec("ROLLBACK");
|
|
set.status = 400;
|
|
return {
|
|
message: "Email already in use.",
|
|
};
|
|
}
|
|
|
|
const role = isFirstUser ? "admin" : "user";
|
|
|
|
db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run(
|
|
email,
|
|
savedPassword,
|
|
role,
|
|
);
|
|
|
|
const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email);
|
|
|
|
if (!userRow) {
|
|
db.exec("ROLLBACK");
|
|
set.status = 500;
|
|
return {
|
|
message: "Failed to create user.",
|
|
};
|
|
}
|
|
|
|
db.exec("COMMIT");
|
|
FIRST_RUN = false;
|
|
|
|
const accessToken = await jwt.sign({
|
|
id: String(userRow.id),
|
|
role: userRow.role ?? "user",
|
|
});
|
|
|
|
if (!auth) {
|
|
set.status = 500;
|
|
return {
|
|
message: "No auth cookie, perhaps your browser is blocking cookies.",
|
|
};
|
|
}
|
|
|
|
// set cookie
|
|
auth.set({
|
|
value: accessToken,
|
|
httpOnly: true,
|
|
secure: !HTTP_ALLOWED,
|
|
maxAge: 60 * 60 * 24 * 7,
|
|
sameSite: "strict",
|
|
});
|
|
|
|
return redirect(`${WEBROOT}/`, 302);
|
|
} catch (e) {
|
|
try {
|
|
db.exec("ROLLBACK");
|
|
} catch {
|
|
// ignore rollback errors
|
|
}
|
|
throw e;
|
|
}
|
|
},
|
|
{ body: "signIn" },
|
|
)
|
|
|
|
|
|
.get(
|
|
"/login",
|
|
async ({ jwt, redirect, cookie: { auth } }) => {
|
|
if (computeFirstRun()) {
|
|
return redirect(`${WEBROOT}/setup`, 302);
|
|
}
|
|
|
|
// if already logged in, redirect to home
|
|
if (auth?.value) {
|
|
const user = await jwt.verify(auth.value);
|
|
|
|
if (user) {
|
|
return redirect(`${WEBROOT}/`, 302);
|
|
}
|
|
|
|
auth.remove();
|
|
}
|
|
|
|
return (
|
|
<BaseHtml webroot={WEBROOT} title="ConvertX | Login">
|
|
<>
|
|
<Header
|
|
webroot={WEBROOT}
|
|
accountRegistration={ACCOUNT_REGISTRATION}
|
|
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
|
hideHistory={HIDE_HISTORY}
|
|
/>
|
|
<main
|
|
class={`
|
|
w-full flex-1 px-2
|
|
sm:px-4
|
|
`}
|
|
>
|
|
<article class="article">
|
|
<form method="post" 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="email"
|
|
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="password"
|
|
class="rounded-sm bg-neutral-800 p-3"
|
|
placeholder="Password"
|
|
autocomplete="current-password"
|
|
required
|
|
/>
|
|
</label>
|
|
</fieldset>
|
|
<div class="flex flex-row gap-4">
|
|
{ACCOUNT_REGISTRATION ? (
|
|
<a
|
|
href={`${WEBROOT}/register`}
|
|
role="button"
|
|
class="w-full btn-secondary text-center"
|
|
>
|
|
Register
|
|
</a>
|
|
) : null}
|
|
<input type="submit" value="Login" class="w-full btn-primary" />
|
|
</div>
|
|
</form>
|
|
</article>
|
|
</main>
|
|
</>
|
|
</BaseHtml>
|
|
);
|
|
},
|
|
{ body: "signIn", cookie: "optionalSession" },
|
|
)
|
|
.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,
|
|
);
|
|
|
|
if (!existingUser) {
|
|
set.status = 403;
|
|
return {
|
|
message: "Invalid credentials.",
|
|
};
|
|
}
|
|
|
|
const validPassword = await Bun.password.verify(body.password, existingUser.password);
|
|
|
|
if (!validPassword) {
|
|
set.status = 403;
|
|
return {
|
|
message: "Invalid credentials.",
|
|
};
|
|
}
|
|
|
|
const accessToken = await jwt.sign({
|
|
id: String(existingUser.id),
|
|
role: existingUser.role ?? "user",
|
|
});
|
|
|
|
if (!auth) {
|
|
set.status = 500;
|
|
return {
|
|
message: "No auth cookie, perhaps your browser is blocking cookies.",
|
|
};
|
|
}
|
|
|
|
// set cookie
|
|
auth.set({
|
|
value: accessToken,
|
|
httpOnly: true,
|
|
secure: !HTTP_ALLOWED,
|
|
maxAge: 60 * 60 * 24 * 7,
|
|
sameSite: "strict",
|
|
});
|
|
|
|
return redirect(`${WEBROOT}/`, 302);
|
|
},
|
|
{ body: "signIn" },
|
|
)
|
|
.get("/logoff", ({ redirect, cookie: { auth } }) => {
|
|
if (auth?.value) {
|
|
auth.remove();
|
|
}
|
|
|
|
return redirect(`${WEBROOT}/login`, 302);
|
|
})
|
|
.post("/logoff", ({ redirect, cookie: { auth } }) => {
|
|
if (auth?.value) {
|
|
auth.remove();
|
|
}
|
|
|
|
return redirect(`${WEBROOT}/login`, 302);
|
|
})
|
|
.get(
|
|
"/account",
|
|
async ({ user, redirect }) => {
|
|
if (!user) {
|
|
return redirect(`${WEBROOT}/`, 302);
|
|
}
|
|
|
|
const userData = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);
|
|
|
|
if (!userData) {
|
|
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">
|
|
<>
|
|
<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">
|
|
<form method="post" 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="email"
|
|
class="rounded-sm bg-neutral-800 p-3"
|
|
placeholder="Email"
|
|
autocomplete="email"
|
|
value={userData.email}
|
|
required
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1">
|
|
Password (leave blank for unchanged)
|
|
<input
|
|
type="password"
|
|
name="newPassword"
|
|
class="rounded-sm bg-neutral-800 p-3"
|
|
placeholder="Password"
|
|
autocomplete="new-password"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1">
|
|
Current Password
|
|
<input
|
|
type="password"
|
|
name="password"
|
|
class="rounded-sm bg-neutral-800 p-3"
|
|
placeholder="Password"
|
|
autocomplete="current-password"
|
|
required
|
|
/>
|
|
</label>
|
|
</fieldset>
|
|
<div role="group">
|
|
<input type="submit" value="Update" class="w-full btn-primary" />
|
|
</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>
|
|
);
|
|
},
|
|
{
|
|
auth: true,
|
|
},
|
|
)
|
|
.post(
|
|
"/account",
|
|
async function handler({ 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 existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(
|
|
tokenUser.id,
|
|
);
|
|
|
|
if (!existingUser) {
|
|
if (auth?.value) {
|
|
auth.remove();
|
|
}
|
|
return redirect(`${WEBROOT}/login`, 302);
|
|
}
|
|
|
|
const validPassword = await Bun.password.verify(body.password, existingUser.password);
|
|
|
|
if (!validPassword) {
|
|
set.status = 403;
|
|
return {
|
|
message: "Invalid credentials.",
|
|
};
|
|
}
|
|
|
|
const fields: string[] = [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const values: any[] = [];
|
|
|
|
if (body.email) {
|
|
const existingUserWithEmail = await db
|
|
.query("SELECT id FROM users WHERE email = ?")
|
|
.as(User)
|
|
.get(body.email);
|
|
if (existingUserWithEmail && existingUserWithEmail.id.toString() !== tokenUser.id) {
|
|
set.status = 409;
|
|
return { message: "Email already in use." };
|
|
}
|
|
fields.push("email");
|
|
values.push(body.email);
|
|
}
|
|
if (body.newPassword) {
|
|
fields.push("password");
|
|
values.push(await Bun.password.hash(body.newPassword));
|
|
}
|
|
|
|
if (fields.length > 0) {
|
|
db.query(
|
|
`UPDATE users SET ${fields.map((field) => `${field}=?`).join(", ")} WHERE id=?`,
|
|
).run(...values, tokenUser.id);
|
|
}
|
|
|
|
return redirect(`${WEBROOT}/`, 302);
|
|
},
|
|
{
|
|
body: t.Object({
|
|
email: t.MaybeEmpty(t.String()),
|
|
newPassword: t.MaybeEmpty(t.String()),
|
|
password: t.String(),
|
|
}),
|
|
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'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",
|
|
},
|
|
);
|
|
|