feat: admin controls
This commit is contained in:
+12
-1
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user