feat(einladungen): admin erzeugt einmal gueltige registrierungslinks
Nach dem ersten Nutzer bleibt die Registrierung geschlossen; weitere Konten entstehen nur ueber einen Einladungslink. Der Klartext-Token wird genau einmal angezeigt, in der Datenbank liegt sein SHA-256-Hash. Optional laesst sich eine Einladung an eine E-Mail-Adresse binden, die Rolle und die Gueltigkeit sind waehlbar. Die Freigabe zum Anlegen eines Kontos laeuft ueber AsyncLocalStorage: Der Server-Action, der den Token geprueft hat, umschliesst damit den signUp-Aufruf, und nur in diesem Kontext laesst der user-create-Hook von better-auth die Registrierung zu. Die Rolle kommt aus der Einladung und kann nicht ueber das Formular gesetzt werden. Der Smoke-Test deckt den ganzen Weg ab: Admin erstellt die Einladung, der zweite Elternteil registriert sich damit, bekommt die Rolle "Mitglied" (und wird von der Einladungsverwaltung weggeleitet), und der Link funktioniert kein zweites Mal. Refs Phase 2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+12
-4
@@ -6,6 +6,7 @@ import { nextCookies } from 'better-auth/next-js';
|
||||
import { count } from 'drizzle-orm';
|
||||
import { db, schema } from '@/db';
|
||||
import { env } from '@/env';
|
||||
import { currentInviteGrant } from './invites';
|
||||
|
||||
/**
|
||||
* Returns true while the instance has no user at all.
|
||||
@@ -90,15 +91,22 @@ export const auth = betterAuth({
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => {
|
||||
const firstRun = isFirstRun();
|
||||
if (!firstRun) {
|
||||
// Phase 2 opens this up again for invited users.
|
||||
// The very first account owns the instance.
|
||||
if (isFirstRun()) {
|
||||
return { data: { ...user, role: 'admin' as const } };
|
||||
}
|
||||
|
||||
// Everyone after that needs an invitation. The grant is set by the
|
||||
// server action that verified the link token, never by the client.
|
||||
const grant = currentInviteGrant();
|
||||
if (!grant) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message:
|
||||
'Die Registrierung ist geschlossen. Bitte lass dir vom Administrator einen Einladungslink schicken.',
|
||||
});
|
||||
}
|
||||
return { data: { ...user, role: 'admin' as const } };
|
||||
|
||||
return { data: { ...user, role: grant.role } };
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'server-only';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { and, desc, eq, isNull, gt } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { invite, user } from '@/db/schema';
|
||||
import type { UserRole } from '@/db/types';
|
||||
import { createToken, hashToken } from './tokens';
|
||||
|
||||
/**
|
||||
* Invitations.
|
||||
*
|
||||
* After the first user, sign-up is closed. An admin issues a link; the
|
||||
* plaintext token only ever lives in that link, the database keeps its
|
||||
* SHA-256 hash.
|
||||
*/
|
||||
|
||||
export type InviteGrant = { inviteId: string; role: UserRole };
|
||||
|
||||
/**
|
||||
* Marks the current call stack as "covered by a valid invitation".
|
||||
*
|
||||
* better-auth's user-create hook has no way of receiving extra arguments, and
|
||||
* the sign-up payload must not be trusted with a role. AsyncLocalStorage keeps
|
||||
* the grant tied to the one server action that verified the token - it can
|
||||
* neither leak into another request nor be forged from the client.
|
||||
*/
|
||||
const grantStorage = new AsyncLocalStorage<InviteGrant>();
|
||||
|
||||
export function withInviteGrant<T>(grant: InviteGrant, fn: () => Promise<T>): Promise<T> {
|
||||
return grantStorage.run(grant, fn);
|
||||
}
|
||||
|
||||
export function currentInviteGrant(): InviteGrant | undefined {
|
||||
return grantStorage.getStore();
|
||||
}
|
||||
|
||||
export const INVITE_VALID_DAYS = 14;
|
||||
|
||||
export function createInvite(input: {
|
||||
createdByUserId: string;
|
||||
email: string | null;
|
||||
role: UserRole;
|
||||
validDays?: number;
|
||||
}): { id: string; token: string; expiresAt: Date } {
|
||||
const token = createToken();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + (input.validDays ?? INVITE_VALID_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
db.insert(invite)
|
||||
.values({
|
||||
id,
|
||||
tokenHash: hashToken(token),
|
||||
email: input.email,
|
||||
role: input.role,
|
||||
createdByUserId: input.createdByUserId,
|
||||
expiresAt,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
|
||||
return { id, token, expiresAt };
|
||||
}
|
||||
|
||||
export type UsableInvite = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
role: UserRole;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
/** Returns the invite behind a link token, or null if it cannot be used. */
|
||||
export function findUsableInvite(token: string): UsableInvite | null {
|
||||
if (!token) return null;
|
||||
|
||||
const row = db
|
||||
.select({
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt,
|
||||
})
|
||||
.from(invite)
|
||||
.where(
|
||||
and(
|
||||
eq(invite.tokenHash, hashToken(token)),
|
||||
isNull(invite.usedAt),
|
||||
gt(invite.expiresAt, new Date()),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function markInviteUsed(inviteId: string, userId: string): void {
|
||||
db.update(invite)
|
||||
.set({ usedAt: new Date(), usedByUserId: userId })
|
||||
.where(eq(invite.id, inviteId))
|
||||
.run();
|
||||
}
|
||||
|
||||
export function deleteInvite(inviteId: string): void {
|
||||
db.delete(invite).where(eq(invite.id, inviteId)).run();
|
||||
}
|
||||
|
||||
export type InviteRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
role: UserRole;
|
||||
expiresAt: Date;
|
||||
usedAt: Date | null;
|
||||
usedByName: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export function listInvites(): InviteRow[] {
|
||||
return db
|
||||
.select({
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt,
|
||||
usedAt: invite.usedAt,
|
||||
usedByName: user.name,
|
||||
createdAt: invite.createdAt,
|
||||
})
|
||||
.from(invite)
|
||||
.leftJoin(user, eq(user.id, invite.usedByUserId))
|
||||
.orderBy(desc(invite.createdAt))
|
||||
.all();
|
||||
}
|
||||
|
||||
/** An invite is only interesting while it can still be used. */
|
||||
export function inviteState(row: InviteRow): 'used' | 'expired' | 'open' {
|
||||
if (row.usedAt) return 'used';
|
||||
if (row.expiresAt.getTime() <= Date.now()) return 'expired';
|
||||
return 'open';
|
||||
}
|
||||
Reference in New Issue
Block a user