Ajoute le flux d'invitation avec activation de compte
This commit is contained in:
Vendored
+19
-1
@@ -76,6 +76,18 @@ declare function createAuthModule<TAuthUser>(options: CreateAuthModuleOptions<TA
|
||||
slackAuthEnabled: boolean;
|
||||
};
|
||||
|
||||
type AccountInviteOptions = {
|
||||
prisma: any;
|
||||
tokenTtlMs?: number;
|
||||
identifierPrefix?: string;
|
||||
};
|
||||
declare function createAccountInviteToken(options: AccountInviteOptions & {
|
||||
userId: string;
|
||||
}): Promise<{
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}>;
|
||||
|
||||
type RegisterAuthApiRoutesOptions = {
|
||||
app: Express;
|
||||
prisma: any;
|
||||
@@ -110,6 +122,10 @@ type RegisterAuthApiRoutesOptions = {
|
||||
expiresAt: Date;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
accountInvite?: {
|
||||
enabled: boolean;
|
||||
identifierPrefix?: string;
|
||||
};
|
||||
onUserRegistered?: (user: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
@@ -130,7 +146,9 @@ type AuthRouteMessages = {
|
||||
passwordResetUnavailable: string;
|
||||
invalidResetLink: string;
|
||||
expiredResetLink: string;
|
||||
invalidInviteLink: string;
|
||||
inviteAlreadyAccepted: string;
|
||||
};
|
||||
declare function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): void;
|
||||
|
||||
export { createAuthModule, registerAuthApiRoutes };
|
||||
export { createAccountInviteToken, createAuthModule, registerAuthApiRoutes };
|
||||
|
||||
Vendored
+161
-18
@@ -224,8 +224,73 @@ function createAuthModule(options) {
|
||||
};
|
||||
}
|
||||
|
||||
// server/routes.ts
|
||||
// server/invites.ts
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
var DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX = "account-invite:";
|
||||
var DEFAULT_ACCOUNT_INVITE_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
||||
function hashAccountInviteToken(token) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
function buildAccountInviteIdentifier(prefix, userId) {
|
||||
return `${prefix}${userId}`;
|
||||
}
|
||||
async function createAccountInviteToken(options) {
|
||||
const identifierPrefix = options.identifierPrefix ?? DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX;
|
||||
const identifier = buildAccountInviteIdentifier(identifierPrefix, options.userId);
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + (options.tokenTtlMs ?? DEFAULT_ACCOUNT_INVITE_TOKEN_TTL_MS));
|
||||
await options.prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
OR: [{ identifier }, { expires: { lt: /* @__PURE__ */ new Date() } }]
|
||||
}
|
||||
});
|
||||
await options.prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier,
|
||||
token: hashAccountInviteToken(token),
|
||||
expires: expiresAt
|
||||
}
|
||||
});
|
||||
return { token, expiresAt };
|
||||
}
|
||||
async function getAccountInviteContext(options) {
|
||||
const identifierPrefix = options.identifierPrefix ?? DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX;
|
||||
const verificationToken = await options.prisma.verificationToken.findUnique({
|
||||
where: { token: hashAccountInviteToken(options.token) },
|
||||
select: { identifier: true, expires: true }
|
||||
});
|
||||
if (!verificationToken || verificationToken.expires <= /* @__PURE__ */ new Date() || !verificationToken.identifier.startsWith(identifierPrefix)) {
|
||||
return null;
|
||||
}
|
||||
const userId = verificationToken.identifier.slice(identifierPrefix.length);
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
const user = await options.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
passwordHash: true,
|
||||
emailVerified: true,
|
||||
accounts: {
|
||||
select: { id: true },
|
||||
take: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!user?.email) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
verificationToken,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
// server/routes.ts
|
||||
import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
|
||||
import { z as z2 } from "zod";
|
||||
var defaultNormalizeEmail = (email) => email.trim();
|
||||
var defaultPasswordResetIdentifierPrefix = "password-reset:";
|
||||
@@ -237,10 +302,12 @@ var defaultMessages = {
|
||||
invalidPassword: "Invalid password",
|
||||
passwordResetUnavailable: "Email service is not configured.",
|
||||
invalidResetLink: "Invalid reset link",
|
||||
expiredResetLink: "Invalid or expired reset link"
|
||||
expiredResetLink: "Invalid or expired reset link",
|
||||
invalidInviteLink: "Invalid or expired invite link",
|
||||
inviteAlreadyAccepted: "This invite has already been accepted"
|
||||
};
|
||||
function hashPasswordResetToken(token) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
return createHash2("sha256").update(token).digest("hex");
|
||||
}
|
||||
function buildPasswordResetIdentifier(prefix, userId) {
|
||||
return `${prefix}${userId}`;
|
||||
@@ -253,7 +320,22 @@ function registerAuthApiRoutes(options) {
|
||||
const passwordHasher = options.passwordHasher ?? ((password) => Promise.resolve(password));
|
||||
const passwordComparator = options.passwordComparator ?? ((password, hash) => Promise.resolve(password === hash));
|
||||
const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix;
|
||||
const accountInviteIdentifierPrefix = options.accountInvite?.identifierPrefix ?? DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX;
|
||||
const messages = { ...defaultMessages, ...options.messages ?? {} };
|
||||
const buildSession = (userId) => ({
|
||||
sessionToken: randomBytes2(32).toString("hex"),
|
||||
userId,
|
||||
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3)
|
||||
});
|
||||
const applySessionCookie = (res, session) => {
|
||||
res.cookie(options.sessionCookieName, session.sessionToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: options.sessionCookieSecure,
|
||||
path: "/",
|
||||
expires: session.expires
|
||||
});
|
||||
};
|
||||
const findUserByEmail = async (email) => {
|
||||
const normalized = normalizeEmail(email);
|
||||
const lowered = normalized.toLowerCase();
|
||||
@@ -348,22 +430,11 @@ function registerAuthApiRoutes(options) {
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: messages.invalidPassword });
|
||||
}
|
||||
const sessionToken = randomBytes(32).toString("hex");
|
||||
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3);
|
||||
const session = buildSession(user.id);
|
||||
await options.prisma.session.create({
|
||||
data: {
|
||||
sessionToken,
|
||||
userId: user.id,
|
||||
expires
|
||||
}
|
||||
});
|
||||
res.cookie(options.sessionCookieName, sessionToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: options.sessionCookieSecure,
|
||||
path: "/",
|
||||
expires
|
||||
data: session
|
||||
});
|
||||
applySessionCookie(res, session);
|
||||
return res.status(200).json({ ok: true });
|
||||
});
|
||||
options.app.post(`${authApiBasePath}/password-reset/request`, async (req, res) => {
|
||||
@@ -381,7 +452,7 @@ function registerAuthApiRoutes(options) {
|
||||
if (!user?.email) {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
const rawToken = randomBytes(32).toString("hex");
|
||||
const rawToken = randomBytes2(32).toString("hex");
|
||||
const identifier = buildPasswordResetIdentifier(passwordResetIdentifierPrefix, user.id);
|
||||
const expiresAt = new Date(Date.now() + (options.passwordReset.tokenTtlMs ?? 2 * 60 * 60 * 1e3));
|
||||
const resetUrl = options.passwordReset.buildResetUrl(rawToken);
|
||||
@@ -465,6 +536,77 @@ function registerAuthApiRoutes(options) {
|
||||
await options.onPasswordResetConfirmed?.(context.user);
|
||||
return res.status(200).json({ ok: true });
|
||||
});
|
||||
options.app.get(`${authApiBasePath}/invite/validate`, async (req, res) => {
|
||||
if (!options.accountInvite?.enabled) {
|
||||
return res.status(404).json({ error: messages.invalidInviteLink });
|
||||
}
|
||||
const parsed = z2.object({ token: z2.string().min(1) }).safeParse({
|
||||
token: Array.isArray(req.query.token) ? req.query.token[0] : req.query.token
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: messages.invalidInviteLink });
|
||||
}
|
||||
const context = await getAccountInviteContext({
|
||||
prisma: options.prisma,
|
||||
token: parsed.data.token,
|
||||
identifierPrefix: accountInviteIdentifierPrefix
|
||||
});
|
||||
if (!context) {
|
||||
return res.status(400).json({ error: messages.invalidInviteLink });
|
||||
}
|
||||
if (context.user.passwordHash || context.user.accounts.length > 0) {
|
||||
return res.status(409).json({ error: messages.inviteAlreadyAccepted });
|
||||
}
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
email: context.user.email,
|
||||
name: context.user.name
|
||||
});
|
||||
});
|
||||
options.app.post(`${authApiBasePath}/invite/accept`, async (req, res) => {
|
||||
if (!options.accountInvite?.enabled) {
|
||||
return res.status(404).json({ error: messages.invalidInviteLink });
|
||||
}
|
||||
const parsed = z2.object({
|
||||
token: z2.string().min(1),
|
||||
name: z2.string().min(2).max(60),
|
||||
password: z2.string().min(8)
|
||||
}).safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: messages.invalidPayload });
|
||||
}
|
||||
const context = await getAccountInviteContext({
|
||||
prisma: options.prisma,
|
||||
token: parsed.data.token,
|
||||
identifierPrefix: accountInviteIdentifierPrefix
|
||||
});
|
||||
if (!context) {
|
||||
return res.status(400).json({ error: messages.invalidInviteLink });
|
||||
}
|
||||
if (context.user.passwordHash || context.user.accounts.length > 0) {
|
||||
return res.status(409).json({ error: messages.inviteAlreadyAccepted });
|
||||
}
|
||||
const passwordHash = await passwordHasher(parsed.data.password);
|
||||
const session = buildSession(context.user.id);
|
||||
await options.prisma.$transaction([
|
||||
options.prisma.verificationToken.deleteMany({
|
||||
where: { identifier: context.verificationToken.identifier }
|
||||
}),
|
||||
options.prisma.session.create({
|
||||
data: session
|
||||
}),
|
||||
options.prisma.user.update({
|
||||
where: { id: context.user.id },
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
passwordHash,
|
||||
emailVerified: context.user.emailVerified ?? /* @__PURE__ */ new Date()
|
||||
}
|
||||
})
|
||||
]);
|
||||
applySessionCookie(res, session);
|
||||
return res.status(200).json({ ok: true });
|
||||
});
|
||||
options.app.post(`${authApiBasePath}/logout`, async (req, res) => {
|
||||
const token = options.extractSessionToken(req.headers.cookie);
|
||||
if (token) {
|
||||
@@ -488,6 +630,7 @@ function registerAuthApiRoutes(options) {
|
||||
});
|
||||
}
|
||||
export {
|
||||
createAccountInviteToken,
|
||||
createAuthModule,
|
||||
registerAuthApiRoutes
|
||||
};
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user