feat: complete admin invite and user creation and recovery flows
This commit is contained in:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user