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:
unknown
2026-09-07 07:01:55 +02:00
co-authored by Claude Opus 5
parent 220a3303cd
commit 52d69e5654
11 changed files with 686 additions and 11 deletions
+12 -4
View File
@@ -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 } };
},
},
},
+140
View File
@@ -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';
}