feat: admin controls

This commit is contained in:
Zoe
2026-10-15 10:07:41 -05:00
parent b6f4d85ed7
commit ff7797d3f8
41 changed files with 612 additions and 427 deletions
+12 -1
View File
@@ -10,6 +10,8 @@ import {
users,
} from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
import { hasUsableAdministrator, lockAccount, lockAdministration } from "~~/server/lib/account-security";
import { Err, Ok } from "~~/types/result";
/**
* Deletes an account and everything that belongs to it.
@@ -21,7 +23,15 @@ import { db } from '~~/server/lib/db';
* Objects in file storage are left alone on purpose.
*/
export const deleteAccount = async (userId: string) => {
await db.transaction(async (tx) => {
return await db.transaction(async (tx) => {
await lockAdministration(tx);
await lockAccount(tx, userId);
const [account] = await tx.select({ role: users.role }).from(users).where(eq(users.id, userId));
if (account?.role === "admin" && !await hasUsableAdministrator(tx, userId)) {
return Err("LAST_ADMIN" as const);
}
// 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));
@@ -35,5 +45,6 @@ export const deleteAccount = async (userId: string) => {
// sessions, accounts and two-factor rows cascade from the user
await tx.delete(users).where(eq(users.id, userId));
return Ok(undefined);
});
};
+6 -12
View File
@@ -1,26 +1,20 @@
import type { H3Event } from 'h3';
import { db } from '~~/server/lib/db';
/**
* Like protectRoute(), but only lets admins through.
*
* The role is read from the database: the session cookie keeps a copy of the
* user for a few minutes, and someone who just stopped being an admin should
* not keep the access for that long.
* protectRoute() reads the user from the database on every request, so someone
* who just stopped being an admin loses the access right away.
*/
export const protectAdminRoute = async (event: H3Event) => {
await protectRoute(event);
const sessionData = await protectRoute(event);
const account = await db.query.users.findFirst({
where: {
id: event.context.user!.id as string,
},
});
if (account?.role !== 'admin' || account.banned) {
if (sessionData.user.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Forbidden',
});
}
return sessionData;
};
+6 -2
View File
@@ -2,9 +2,12 @@ import type { H3Event } from 'h3';
import { auth } from '~~/lib/auth';
export const protectRoute = async (event: H3Event) => {
const sessionData = await auth.api.getSession({ headers: event.headers });
const sessionData = await auth.api.getSession({
headers: event.headers,
query: { disableCookieCache: true },
});
if (sessionData === null) {
if (sessionData === null || sessionData.user.banned) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
@@ -12,4 +15,5 @@ export const protectRoute = async (event: H3Event) => {
}
event.context.user = sessionData.user;
return sessionData;
};
+6 -10
View File
@@ -1,5 +1,6 @@
import { and, eq, gt, lt, lte, or, sql } from 'drizzle-orm';
import { invites, users } from '../../drizzle/schema';
import type { Transaction } from '../lib/auth-transaction';
import { db } from '../lib/db';
export const INVITE_TOKEN_HEADER = 'x-invite-token';
@@ -9,10 +10,12 @@ const usable = () => and(lt(invites.uses, invites.maxUses), gt(invites.expiresAt
/**
* Takes one use of an invite. It is a single statement, so two people
* registering with the same single use link at once cannot both get in.
* registering with the same single use link at once cannot both get in. It
* runs on the transaction of the registration, a failed one gives the use back
* by rolling back.
*/
export const claimInvite = async (token: string): Promise<boolean> => {
const claimed = await db.update(invites)
export const claimInvite = async (tx: Transaction, token: string): Promise<boolean> => {
const claimed = await tx.update(invites)
.set({ uses: sql`${invites.uses} + 1` })
.where(and(eq(invites.token, token), usable()))
.returning({ id: invites.id });
@@ -20,13 +23,6 @@ export const claimInvite = async (token: string): Promise<boolean> => {
return claimed.length > 0;
};
/** Gives a use back when the registration it was taken for did not go through. */
export const releaseInvite = async (token: string) => {
await db.update(invites)
.set({ uses: sql`${invites.uses} - 1` })
.where(and(eq(invites.token, token), gt(invites.uses, 0)));
};
export const findUsableInvite = async (token: string) => {
const rows = await db
.select({
+4 -1
View File
@@ -1,7 +1,8 @@
import { createHash, randomBytes } from 'node:crypto';
import { and, eq, gt, lte, sql } from 'drizzle-orm';
import { and, eq, gt, lte } from 'drizzle-orm';
import { accounts, recoveryLinks, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
import { lockAccount } from "~~/server/lib/account-security";
export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000;
// a new person may take a while to get round to it
@@ -36,7 +37,9 @@ export const createRecoveryLink = async (userId: string, resetSecurity: boolean)
const expiresAt = new Date(Date.now() + lifetime);
await db.transaction(async (tx) => {
await lockAccount(tx, userId);
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId));
// nothing else ever looks at a link that ran out, so they go here
await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date()));
await tx.insert(recoveryLinks).values({
tokenHash: hashRecoveryToken(token),
+2 -2
View File
@@ -1,5 +1,5 @@
import { users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
import { users } from '../../drizzle/schema';
import { db } from '../lib/db';
/**
* An instance needs setting up for as long as it has no accounts. The first