feat: setup and roles

This commit is contained in:
Zoe
2026-10-08 13:08:20 -05:00
parent 4bba529ba8
commit 7b0800882a
13 changed files with 3502 additions and 2 deletions
+20 -1
View File
@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm';
import { and, eq, ne } from 'drizzle-orm';
import * as z from 'zod';
import {
agents,
@@ -57,6 +57,25 @@ export default defineEventHandler(async (event) => {
const twoFactorEnabled = !!account?.twoFactorEnabled;
// an instance always keeps one admin, someone has to be able to manage it
if (account?.role === 'admin') {
const otherAdmins = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.role, 'admin'), ne(users.id, userId)))
.limit(1);
if (otherAdmins.length === 0) {
throw createError({
statusCode: 409,
statusMessage: 'The last admin cannot be deleted',
data: {
code: 'LAST_ADMIN',
},
});
}
}
if (twoFactorEnabled && code === undefined) {
throw createError({
statusCode: 400,
+4
View File
@@ -0,0 +1,4 @@
// Public on purpose: the sign in pages ask this before anyone has an account.
export default defineEventHandler(async () => {
return { needed: await isSetupNeeded() };
});
+91
View File
@@ -0,0 +1,91 @@
import { sql } from 'drizzle-orm';
import * as z from 'zod';
import { users } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
// any fixed number works, it only has to be the same for every request
const SETUP_LOCK_ID = 7_261_008;
/**
* Creates the first account of the instance as its admin.
*
* Public, because nobody can be signed in yet, and only works while there are
* no accounts at all. It does not go through sign up, so it also works when
* registration is turned off.
*/
export default defineEventHandler(async (event) => {
const result = await readValidatedBody(event, (body) =>
z
.object({
name: z.string().trim().min(1).max(128),
email: z.email(),
password: z.string().min(8).max(128),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
data: {
code: 'INVALID_BODY',
},
});
}
const { name, email, password } = result.data;
let alreadyDone = false;
try {
await db.transaction(async (tx) => {
// two people submitting the form at the same moment wait here, and
// the second one then finds the first one's account
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK_ID})`);
const existing = await tx.select({ id: users.id }).from(users).limit(1);
if (existing.length > 0) {
alreadyDone = true;
return;
}
await auth.api.createUser({
body: {
name,
email,
password,
role: 'admin',
},
});
});
} catch (error: any) {
const code = error?.body?.code as string | undefined;
if (code === undefined) {
console.error('Failed to create the admin account:', error);
}
throw createError({
statusCode: code === undefined ? 500 : 400,
statusMessage: 'Failed to set up the instance',
message: error?.body?.message ?? 'Failed to set up the instance',
data: {
code: code ?? 'INTERNAL_ERROR',
},
});
}
if (alreadyDone) {
throw createError({
statusCode: 409,
statusMessage: 'This instance is already set up',
data: {
code: 'SETUP_ALREADY_DONE',
},
});
}
return { ok: true };
});
+12
View File
@@ -0,0 +1,12 @@
import { users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/**
* An instance needs setting up for as long as it has no accounts. The first
* account is created by the setup page and becomes the admin.
*/
export const isSetupNeeded = async (): Promise<boolean> => {
const existing = await db.select({ id: users.id }).from(users).limit(1);
return existing.length === 0;
};