feat: basic admin interface

This commit is contained in:
Zoe
2026-10-08 13:41:02 -05:00
parent 7b0800882a
commit f6e1bc1f47
27 changed files with 4095 additions and 49 deletions
+4 -32
View File
@@ -1,26 +1,12 @@
import { and, eq, ne } from 'drizzle-orm';
import * as z from 'zod';
import {
agents,
files,
models,
providers,
settings,
toolCalls,
topics,
users,
} from '~~/drizzle/schema';
import { users } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
/**
* Deletes the signed in user's account and everything that belongs to it.
*
* The app tables only store a plain user id, nothing cascades from the users
* table, so every one of them is cleared here. It all happens in one
* transaction: either the whole account is gone or nothing changed.
*
* Objects in file storage are left alone on purpose.
* Deletes the signed in user's account and everything that belongs to it,
* after they proved it is really them.
*/
export default defineEventHandler(async (event) => {
await protectRoute(event);
@@ -125,21 +111,7 @@ export default defineEventHandler(async (event) => {
});
}
await db.transaction(async (tx) => {
// messages, their parts, generations and attachments cascade from topics
await tx.delete(topics).where(eq(topics.userId, userId));
await tx.delete(agents).where(eq(agents.userId, userId));
await tx.delete(models).where(eq(models.userId, userId));
await tx.delete(providers).where(eq(providers.userId, userId));
// nothing points a tool call back at its message, so these never cascade
await tx.delete(toolCalls).where(eq(toolCalls.userId, userId));
// embeddings go with their file
await tx.delete(files).where(eq(files.userId, userId));
await tx.delete(settings).where(eq(settings.userId, userId));
// sessions, accounts and two-factor rows cascade from the user
await tx.delete(users).where(eq(users.id, userId));
});
await deleteAccount(userId);
// the session is gone, but the browser would keep trusting its cached
// copy for a few minutes unless the cookies go too
+9
View File
@@ -0,0 +1,9 @@
export default defineEventHandler(async (event) => {
await protectAdminRoute(event);
return {
registration: await getRegistrationMode(),
// set when DISABLE_SIGNUP in the environment overrules the setting
registrationLocked: isRegistrationLocked(),
};
});
+43
View File
@@ -0,0 +1,43 @@
import * as z from 'zod';
import { INSTANCE_SETTINGS_ID, REGISTRATION_MODES, instanceSettings } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
export default defineEventHandler(async (event) => {
await protectAdminRoute(event);
const result = await readValidatedBody(event, (body) =>
z
.object({
registration: z.enum(REGISTRATION_MODES),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
if (isRegistrationLocked()) {
throw createError({
statusCode: 409,
statusMessage: 'Registration is locked by the environment',
data: {
code: 'REGISTRATION_LOCKED',
},
});
}
const { registration } = result.data;
await db.insert(instanceSettings)
.values({ id: INSTANCE_SETTINGS_ID, registration })
.onConflictDoUpdate({
target: instanceSettings.id,
set: { registration, updatedAt: new Date() },
});
return { registration, registrationLocked: false };
});
@@ -0,0 +1,40 @@
import { db } from '~~/server/lib/db';
/**
* Deletes someone else's account and everything that belongs to it.
*
* Admins delete their own account from their profile, where it asks for the
* password and keeps the last admin from leaving.
*/
export default defineEventHandler(async (event) => {
await protectAdminRoute(event);
const id = getRouterParam(event, 'id')!;
if (id === event.context.user!.id) {
throw createError({
statusCode: 400,
statusMessage: 'You cannot delete your own account here',
data: {
code: 'CANNOT_DELETE_YOURSELF',
},
});
}
const account = await db.query.users.findFirst({
where: {
id,
},
});
if (!account) {
throw createError({
statusCode: 404,
statusMessage: 'User not found',
});
}
await deleteAccount(id);
return { ok: true };
});
@@ -0,0 +1,34 @@
import { eq } from 'drizzle-orm';
import { twoFactors, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/**
* Turns two-factor sign-in off for a user who lost their authenticator app
* and backup codes. They sign in with just their password again and can set
* it up from scratch.
*/
export default defineEventHandler(async (event) => {
await protectAdminRoute(event);
const id = getRouterParam(event, 'id')!;
const account = await db.query.users.findFirst({
where: {
id,
},
});
if (!account) {
throw createError({
statusCode: 404,
statusMessage: 'User not found',
});
}
await db.transaction(async (tx) => {
await tx.delete(twoFactors).where(eq(twoFactors.userId, id));
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, id));
});
return { ok: true };
});
+39
View File
@@ -0,0 +1,39 @@
import { asc, eq, max } from 'drizzle-orm';
import { sessions, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/**
* Every account of the instance, for the admin user list.
*
* Only what is needed to manage accounts is returned. Chats, agents, files and
* API keys of other users are never part of this.
*/
export default defineEventHandler(async (event) => {
await protectAdminRoute(event);
const rows = await db
.select({
id: users.id,
name: users.name,
email: users.email,
image: users.image,
role: users.role,
banned: users.banned,
twoFactorEnabled: users.twoFactorEnabled,
createdAt: users.createdAt,
// sessions are touched as they are used, the newest one is the
// closest thing there is to a last seen time
lastActiveAt: max(sessions.updatedAt),
})
.from(users)
.leftJoin(sessions, eq(sessions.userId, users.id))
.groupBy(users.id)
.orderBy(asc(users.createdAt));
return rows.map((row) => ({
...row,
role: row.role === 'admin' ? 'admin' as const : 'user' as const,
banned: !!row.banned,
twoFactorEnabled: !!row.twoFactorEnabled,
}));
});
+6
View File
@@ -0,0 +1,6 @@
// Public on purpose: the sign in pages ask this before anyone is signed in.
export default defineEventHandler(async () => {
const [setupNeeded, registration] = await Promise.all([isSetupNeeded(), getRegistrationMode()]);
return { setupNeeded, registration };
});
-4
View File
@@ -1,4 +0,0 @@
// Public on purpose: the sign in pages ask this before anyone has an account.
export default defineEventHandler(async () => {
return { needed: await isSetupNeeded() };
});