feat: complete admin invite and user creation and recovery flows

This commit is contained in:
Zoe
2026-10-08 14:24:47 -05:00
parent 61f9bb74b4
commit b6f4d85ed7
23 changed files with 4430 additions and 110 deletions
@@ -0,0 +1,12 @@
import { eq } from 'drizzle-orm';
import { recoveryLinks } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/** Cancels the recovery link of a user, if there is one. */
export default defineEventHandler(async (event) => {
await protectAdminRoute(event);
await db.delete(recoveryLinks).where(eq(recoveryLinks.userId, getRouterParam(event, 'id')!));
return { ok: true };
});
@@ -0,0 +1,44 @@
import * as z from 'zod';
import { db } from '~~/server/lib/db';
/**
* Makes a one-time link the user can set a new password with, or their first
* one if the account was created by an admin and never had a password. The link is
* only returned here, afterwards nobody can read it back, admins included.
*/
export default defineEventHandler(async (event) => {
await protectAdminRoute(event);
const result = await readValidatedBody(event, (body) =>
z
.object({
// also turn two-factor off and sign every device out
resetSecurity: z.boolean(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const id = getRouterParam(event, 'id')!;
const account = await db.query.users.findFirst({
where: {
id,
},
});
if (!account) {
throw createError({
statusCode: 404,
statusMessage: 'User not found',
});
}
return await createRecoveryLink(id, result.data.resetSecurity);
});
+5 -2
View File
@@ -1,5 +1,5 @@
import { asc, eq, max } from 'drizzle-orm';
import { sessions, users } from '~~/drizzle/schema';
import { asc, eq, max, sql } from 'drizzle-orm';
import { accounts, sessions, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/**
@@ -24,6 +24,9 @@ export default defineEventHandler(async (event) => {
// 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),
// no password yet: created by an admin and still waiting for its
// owner to open the setup link
pending: sql<boolean>`not exists (select 1 from ${accounts} where ${accounts.userId} = ${users.id} and ${accounts.providerId} = 'credential')`,
})
.from(users)
.leftJoin(sessions, eq(sessions.userId, users.id))
+63
View File
@@ -0,0 +1,63 @@
import * as z from 'zod';
import { auth } from '~~/lib/auth';
/**
* Creates an account for someone, without a password. Nobody can sign in to
* it until its owner opens the setup link returned here and picks one, so the
* admin never knows a password of theirs. The link is only returned here.
*/
export default defineEventHandler(async (event) => {
await protectAdminRoute(event);
const result = await readValidatedBody(event, (body) =>
z
.object({
name: z.string().trim().min(1).max(128),
email: z.email(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
data: {
code: 'INVALID_BODY',
},
});
}
const { name, email } = result.data;
let userId: string;
try {
// called without the request, the admin check already happened above
const { user } = await auth.api.createUser({
body: {
name,
email,
role: 'user',
},
});
userId = user.id;
} catch (error: any) {
const code = error?.body?.code as string | undefined;
if (code === undefined) {
console.error('Failed to create user:', error);
}
throw createError({
statusCode: code === undefined ? 500 : 400,
statusMessage: 'Failed to create user',
data: {
code: code ?? 'INTERNAL_ERROR',
},
});
}
return await createRecoveryLink(userId, false);
});
+40
View File
@@ -0,0 +1,40 @@
import { and, eq, sql } from 'drizzle-orm';
import { providers } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/**
* Tells the recovery page whether a link still works and what using it will
* do: set the first password of a new account, or replace a forgotten one. Public on purpose: whoever opens it cannot sign in, that is the point.
*/
export default defineEventHandler(async (event) => {
const link = await findRecoveryLink(getRouterParam(event, 'token')!);
if (!link) {
return { valid: false as const };
}
// an account that never had a password is being set up, not recovered
if (!await hasPassword(link.userId)) {
return {
valid: true as const,
newAccount: true as const,
email: link.email,
name: link.name,
};
}
const withKey = await db
.select({ name: providers.name })
.from(providers)
.where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
return {
valid: true as const,
newAccount: false as const,
email: link.email,
// the providers whose saved key is lost when the link is used
keyProviders: withKey.map(p => p.name),
resetSecurity: link.resetSecurity,
twoFactorEnabled: !!link.twoFactorEnabled,
};
});
+95
View File
@@ -0,0 +1,95 @@
import { and, eq, gt, sql } from 'drizzle-orm';
import * as z from 'zod';
import { providers, recoveryLinks, sessions, twoFactors, users } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
/**
* Sets a password with a one-time link.
*
* For an account an admin just created this is its first password, and there
* is nothing else to do. For a forgotten password it is a recovery:
*
* The saved API keys are encrypted with a key derived from the old password.
* Nobody has that password anymore, so the keys can never be read again and
* are deleted here. Chats, agents, files and everything else stay.
*/
export default defineEventHandler(async (event) => {
const result = await readValidatedBody(event, (body) =>
z
.object({
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 tokenHash = hashRecoveryToken(getRouterParam(event, 'token')!);
const { password } = result.data;
const { password: hasher, internalAdapter } = await auth.$context;
const passwordHash = await hasher.hash(password);
const recovered = await db.transaction(async (tx) => {
// deleting the link is what makes it work once: of two requests with
// the same link only one gets a row back
const [link] = await tx.delete(recoveryLinks)
.where(and(eq(recoveryLinks.tokenHash, tokenHash), gt(recoveryLinks.expiresAt, new Date())))
.returning({ userId: recoveryLinks.userId, resetSecurity: recoveryLinks.resetSecurity });
if (!link) return null;
// if either of these fails the link comes back with the rest of the
// transaction
if (!await hasPassword(link.userId)) {
await internalAdapter.linkAccount({
providerId: 'credential',
accountId: link.userId,
password: passwordHash,
userId: link.userId,
});
return 'created';
}
await internalAdapter.updatePassword(link.userId, passwordHash);
// A provider without its key can't be used, so it is switched off
// until the key is entered again. The provider and its models stay.
await tx.update(providers)
.set({
config: sql`${providers.config} - 'apiKey'`,
enabled: false,
})
.where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
if (link.resetSecurity) {
await tx.delete(twoFactors).where(eq(twoFactors.userId, link.userId));
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, link.userId));
await tx.delete(sessions).where(eq(sessions.userId, link.userId));
}
return 'recovered';
});
if (recovered === null) {
throw createError({
statusCode: 410,
statusMessage: 'This recovery link is not valid anymore',
data: {
code: 'RECOVERY_LINK_INVALID',
},
});
}
return { newAccount: recovered === 'created' };
});
+69
View File
@@ -0,0 +1,69 @@
import { createHash, randomBytes } from 'node:crypto';
import { and, eq, gt, lte, sql } from 'drizzle-orm';
import { accounts, recoveryLinks, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000;
// a new person may take a while to get round to it
export const SETUP_LINK_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
/**
* An account an admin created has no password until its owner sets one with
* a setup link. Until then nobody can sign in to it.
*/
export const hasPassword = async (userId: string): Promise<boolean> => {
const rows = await db
.select({ id: accounts.id })
.from(accounts)
.where(and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')))
.limit(1);
return rows.length > 0;
};
// scripts/recover.ts makes links the same way, keep the two in step
export const hashRecoveryToken = (token: string) => createHash('sha256').update(token).digest('hex');
/**
* Makes a one-time link a user can set a password with and returns its token:
* a recovery link for someone who forgot theirs, a setup link for an account
* that never had one. Only one link works at a time, making a new one cancels
* the ones before it.
*/
export const createRecoveryLink = async (userId: string, resetSecurity: boolean) => {
const token = randomBytes(32).toString('base64url');
const lifetime = await hasPassword(userId) ? RECOVERY_LINK_LIFETIME_MS : SETUP_LINK_LIFETIME_MS;
const expiresAt = new Date(Date.now() + lifetime);
await db.transaction(async (tx) => {
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId));
await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date()));
await tx.insert(recoveryLinks).values({
tokenHash: hashRecoveryToken(token),
userId,
resetSecurity,
expiresAt,
});
});
return { token, expiresAt };
};
export const findRecoveryLink = async (token: string) => {
const rows = await db
.select({
id: recoveryLinks.id,
userId: recoveryLinks.userId,
resetSecurity: recoveryLinks.resetSecurity,
expiresAt: recoveryLinks.expiresAt,
email: users.email,
name: users.name,
twoFactorEnabled: users.twoFactorEnabled,
})
.from(recoveryLinks)
.innerJoin(users, eq(users.id, recoveryLinks.userId))
.where(and(eq(recoveryLinks.tokenHash, hashRecoveryToken(token)), gt(recoveryLinks.expiresAt, new Date())))
.limit(1);
return rows[0] ?? null;
};