Merge ca61491a31 into 393441faa1
This commit is contained in:
commit
feee452d2e
4 changed files with 616 additions and 35 deletions
68
src/db/db.ts
68
src/db/db.ts
|
|
@ -4,40 +4,70 @@ import { Database } from "bun:sqlite";
|
|||
mkdirSync("./data", { recursive: true });
|
||||
const db = new Database("./data/mydb.sqlite", { create: true });
|
||||
|
||||
if (!db.query("SELECT * FROM sqlite_master WHERE type='table'").get()) {
|
||||
// Always keep foreign keys on (SQLite defaults to off).
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
const hasAnyTable = db.query("SELECT 1 FROM sqlite_master WHERE type='table' LIMIT 1").get();
|
||||
if (!hasAnyTable) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
date_created TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
num_files INTEGER NOT NULL DEFAULT 0,
|
||||
finished_files INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
output_file_name TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'not started',
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id)
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
date_created TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'not started',
|
||||
num_files INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
PRAGMA user_version = 1;`);
|
||||
|
||||
PRAGMA user_version = 1;
|
||||
`);
|
||||
}
|
||||
|
||||
const dbVersion = (db.query("PRAGMA user_version").get() as { user_version?: number }).user_version;
|
||||
if (dbVersion === 0) {
|
||||
db.exec("ALTER TABLE file_names ADD COLUMN status TEXT DEFAULT 'not started';");
|
||||
|
||||
const dbVersion = (db.query("PRAGMA user_version").get() as { user_version?: number })
|
||||
.user_version;
|
||||
|
||||
if ((dbVersion ?? 0) === 0) {
|
||||
db.exec("PRAGMA user_version = 1;");
|
||||
console.log("Updated database to version 1.");
|
||||
}
|
||||
|
||||
// enable WAL mode
|
||||
const userColumns = db.query("PRAGMA table_info(users)").all() as { name: string }[];
|
||||
const hasRoleColumn = userColumns.some((col) => col.name === "role");
|
||||
|
||||
if (!hasRoleColumn) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';");
|
||||
|
||||
const oldest = db
|
||||
.query("SELECT id FROM users ORDER BY id ASC LIMIT 1")
|
||||
.get() as { id: number } | null;
|
||||
|
||||
if (oldest) {
|
||||
db.query("UPDATE users SET role = 'admin' WHERE id = ?").run(oldest.id);
|
||||
console.log("Added 'role' column; promoted oldest existing user to admin (Policy A).");
|
||||
} else {
|
||||
console.log("Added 'role' column to users table (no users to promote).");
|
||||
}
|
||||
}
|
||||
|
||||
// enable WAL mode (better concurrency for Bun + SQLite)
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
|
||||
export default db;
|
||||
|
|
|
|||
|
|
@ -20,4 +20,5 @@ export class User {
|
|||
id!: number;
|
||||
email!: string;
|
||||
password!: string;
|
||||
role!: string; // 'admin' | 'user'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -338,6 +349,7 @@ export const user = new Elysia()
|
|||
|
||||
const accessToken = await jwt.sign({
|
||||
id: String(existingUser.id),
|
||||
role: existingUser.role ?? "user",
|
||||
});
|
||||
|
||||
if (!auth) {
|
||||
|
|
@ -387,6 +399,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 +464,165 @@ 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 +639,11 @@ 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 +661,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 +685,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 +698,351 @@ 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'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);
|
||||
}
|
||||
|
||||
// IMPORTANT: do not hold a SQLite write transaction open across an `await`.
|
||||
// Hash any password outside the transaction to avoid lock contention.
|
||||
let hashedPassword: string | null = null;
|
||||
if (newPassword && newPassword.trim().length > 0) {
|
||||
hashedPassword = await Bun.password.hash(newPassword);
|
||||
}
|
||||
|
||||
// Atomic last-admin protection: concurrent demotions must not be able to leave zero admins.
|
||||
// Serialize writers and make demotion conditional in a single statement.
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
// Role change
|
||||
if (role === "admin") {
|
||||
db.query("UPDATE users SET role = 'admin' WHERE id = ?").run(targetId);
|
||||
} else if (role === "user") {
|
||||
const demoteRes = db
|
||||
.query(
|
||||
`UPDATE users
|
||||
SET role = 'user'
|
||||
WHERE id = ?
|
||||
AND role = 'admin'
|
||||
AND (SELECT COUNT(*) FROM users WHERE role = 'admin') > 1`,
|
||||
)
|
||||
.run(targetId);
|
||||
|
||||
if (targetUser.role === "admin" && demoteRes.changes === 0) {
|
||||
db.exec("ROLLBACK");
|
||||
set.status = 400;
|
||||
return { message: "You cannot demote the last remaining admin." };
|
||||
}
|
||||
}
|
||||
|
||||
// Password change (optional)
|
||||
if (hashedPassword) {
|
||||
db.query("UPDATE users SET password = ? WHERE id = ?").run(hashedPassword, targetId);
|
||||
}
|
||||
|
||||
db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
try {
|
||||
db.exec("ROLLBACK");
|
||||
} catch (rollbackErr) {
|
||||
console.warn("[user/edit-user] ROLLBACK failed:", rollbackErr);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue