Ajoute le flux d'invitation avec activation de compte

This commit is contained in:
2026-07-19 19:40:38 +02:00
parent 08b9bb8095
commit 18bf4e60d1
13 changed files with 747 additions and 97 deletions
+46 -1
View File
@@ -30,6 +30,16 @@ type PasswordResetTokenState = {
email: string; email: string;
mode: PasswordResetMode; mode: PasswordResetMode;
}; };
type AccountInviteTokenState = {
status: "loading";
} | {
status: "invalid";
error: string;
} | {
status: "valid";
email: string;
name: string | null;
};
type CreateAuthClientOptions = { type CreateAuthClientOptions = {
apiUrl: (path: string) => string; apiUrl: (path: string) => string;
@@ -59,10 +69,45 @@ declare function createAuthClient(options: CreateAuthClientOptions): {
token: string; token: string;
password: string; password: string;
}): Promise<void>; }): Promise<void>;
validateAccountInviteToken(token: string): Promise<{
email: string;
name: string | null;
}>;
acceptAccountInvite(input: {
token: string;
name: string;
password: string;
}): Promise<void>;
logout(): Promise<void>; logout(): Promise<void>;
startOAuthSignIn(provider: string, callbackUrl?: string): Promise<void>; startOAuthSignIn(provider: string, callbackUrl?: string): Promise<void>;
}; };
type InviteAcceptFormTexts = {
loadingLabel: string;
invalidLinkLabel: string;
emailLabel: string;
nameLabel: string;
passwordLabel: string;
passwordConfirmLabel: string;
submitLabel: string;
googleLabel: string;
};
type InviteAcceptFormProps = {
texts: InviteAcceptFormTexts;
tokenState: AccountInviteTokenState;
loading?: boolean;
initialName?: string | null;
showGoogleSignIn?: boolean;
googleLoading?: boolean;
onSubmit: (values: {
name: string;
password: string;
passwordConfirm: string;
}) => void | Promise<void>;
onGoogleSignIn?: () => void | Promise<void>;
};
declare function InviteAcceptForm({ texts, tokenState, loading, initialName, showGoogleSignIn, googleLoading, onSubmit, onGoogleSignIn }: InviteAcceptFormProps): react_jsx_runtime.JSX.Element;
type LoginFormTexts = { type LoginFormTexts = {
nameLabel: string; nameLabel: string;
emailLabel: string; emailLabel: string;
@@ -132,4 +177,4 @@ type PasswordResetConfirmFormProps = {
declare function PasswordResetRequestForm({ texts, helperText, loading, requestSent, onSubmit, emailPlaceholder }: PasswordResetRequestFormProps): react_jsx_runtime.JSX.Element; declare function PasswordResetRequestForm({ texts, helperText, loading, requestSent, onSubmit, emailPlaceholder }: PasswordResetRequestFormProps): react_jsx_runtime.JSX.Element;
declare function PasswordResetConfirmForm({ texts, tokenState, loading, completedMode, onSubmit }: PasswordResetConfirmFormProps): react_jsx_runtime.JSX.Element; declare function PasswordResetConfirmForm({ texts, tokenState, loading, completedMode, onSubmit }: PasswordResetConfirmFormProps): react_jsx_runtime.JSX.Element;
export { AuthGuard, type AuthProviderAvailability, type AuthProviderKey, type AuthSubmitValues, LoginForm, type LoginMode, PasswordResetConfirmForm, type PasswordResetMode, PasswordResetRequestForm, type PasswordResetTokenState, createAuthClient }; export { type AccountInviteTokenState, AuthGuard, type AuthProviderAvailability, type AuthProviderKey, type AuthSubmitValues, InviteAcceptForm, LoginForm, type LoginMode, PasswordResetConfirmForm, type PasswordResetMode, PasswordResetRequestForm, type PasswordResetTokenState, createAuthClient };
+144 -58
View File
@@ -209,6 +209,28 @@ function createAuthClient(options) {
throw await readJsonError(response, "Invalid reset link"); throw await readJsonError(response, "Invalid reset link");
} }
}, },
async validateAccountInviteToken(token) {
const response = await request(`/api/auth/invite/validate?token=${encodeURIComponent(token)}`, {
headers: {}
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.email) {
throw new Error(payload?.error ?? "Invalid invite link");
}
return {
email: payload.email,
name: payload.name ?? null
};
},
async acceptAccountInvite(input) {
const response = await request("/api/auth/invite/accept", {
method: "POST",
body: JSON.stringify(input)
});
if (!response.ok) {
throw await readJsonError(response, "Invalid invite link");
}
},
async logout() { async logout() {
const response = await request("/api/auth/logout", { const response = await request("/api/auth/logout", {
method: "POST" method: "POST"
@@ -248,9 +270,72 @@ function createAuthClient(options) {
}; };
} }
// react/LoginForm.tsx // react/InviteAcceptForm.tsx
import { useEffect as useEffect2, useState as useState2 } from "react";
import { FcGoogle } from "react-icons/fc"; import { FcGoogle } from "react-icons/fc";
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime"; import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
function InviteAcceptForm({
texts,
tokenState,
loading = false,
initialName = null,
showGoogleSignIn = false,
googleLoading = false,
onSubmit,
onGoogleSignIn
}) {
const [name, setName] = useState2(initialName ?? "");
useEffect2(() => {
setName(initialName ?? "");
}, [initialName, tokenState.status]);
function handleSubmit(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
void onSubmit({
name: String(form.get("name") ?? ""),
password: String(form.get("password") ?? ""),
passwordConfirm: String(form.get("passwordConfirm") ?? "")
});
}
if (tokenState.status === "loading") {
return /* @__PURE__ */ jsxs2(Stack, { align: "center", py: 6, gap: 3, children: [
/* @__PURE__ */ jsx3(Spinner, {}),
/* @__PURE__ */ jsx3(Text, { color: "gray.600", children: texts.loadingLabel })
] });
}
if (tokenState.status === "invalid") {
return /* @__PURE__ */ jsxs2(Alert, { status: "error", borderRadius: "md", children: [
/* @__PURE__ */ jsx3(AlertIcon, {}),
/* @__PURE__ */ jsx3(AlertDescription, { children: tokenState.error || texts.invalidLinkLabel })
] });
}
return /* @__PURE__ */ jsxs2(Stack, { gap: 4, children: [
/* @__PURE__ */ jsxs2(FormControl, { children: [
/* @__PURE__ */ jsx3(FormLabel, { children: texts.emailLabel }),
/* @__PURE__ */ jsx3(Input, { value: tokenState.email, readOnly: true, disabled: true })
] }),
/* @__PURE__ */ jsx3("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs2(Stack, { gap: 4, children: [
/* @__PURE__ */ jsxs2(FormControl, { isRequired: true, children: [
/* @__PURE__ */ jsx3(FormLabel, { children: texts.nameLabel }),
/* @__PURE__ */ jsx3(Input, { name: "name", value: name, onChange: (event) => setName(event.target.value) })
] }),
/* @__PURE__ */ jsxs2(FormControl, { isRequired: true, children: [
/* @__PURE__ */ jsx3(FormLabel, { children: texts.passwordLabel }),
/* @__PURE__ */ jsx3(Input, { name: "password", type: "password", minLength: 8 })
] }),
/* @__PURE__ */ jsxs2(FormControl, { isRequired: true, children: [
/* @__PURE__ */ jsx3(FormLabel, { children: texts.passwordConfirmLabel }),
/* @__PURE__ */ jsx3(Input, { name: "passwordConfirm", type: "password", minLength: 8 })
] }),
/* @__PURE__ */ jsx3(Button, { type: "submit", loading, children: texts.submitLabel })
] }) }),
showGoogleSignIn && onGoogleSignIn ? /* @__PURE__ */ jsx3(Button, { variant: "outline", loading: googleLoading, leftIcon: /* @__PURE__ */ jsx3(FcGoogle, {}), onClick: () => void onGoogleSignIn(), children: texts.googleLabel }) : null
] });
}
// react/LoginForm.tsx
import { FcGoogle as FcGoogle2 } from "react-icons/fc";
import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
function LoginForm({ function LoginForm({
mode, mode,
texts, texts,
@@ -278,37 +363,37 @@ function LoginForm({
passwordConfirm: String(form.get("passwordConfirm") ?? "") passwordConfirm: String(form.get("passwordConfirm") ?? "")
}); });
} }
return /* @__PURE__ */ jsxs2(Stack, { spacing: 5, children: [ return /* @__PURE__ */ jsxs3(Stack, { spacing: 5, children: [
errorMessage ? /* @__PURE__ */ jsxs2(Alert, { status: "error", borderRadius: "md", children: [ errorMessage ? /* @__PURE__ */ jsxs3(Alert, { status: "error", borderRadius: "md", children: [
/* @__PURE__ */ jsx3(AlertIcon, {}), /* @__PURE__ */ jsx4(AlertIcon, {}),
/* @__PURE__ */ jsx3(AlertDescription, { children: errorMessage }) /* @__PURE__ */ jsx4(AlertDescription, { children: errorMessage })
] }) : null, ] }) : null,
successMessage ? /* @__PURE__ */ jsxs2(Alert, { status: "success", borderRadius: "md", children: [ successMessage ? /* @__PURE__ */ jsxs3(Alert, { status: "success", borderRadius: "md", children: [
/* @__PURE__ */ jsx3(AlertIcon, {}), /* @__PURE__ */ jsx4(AlertIcon, {}),
/* @__PURE__ */ jsx3(AlertDescription, { children: successMessage }) /* @__PURE__ */ jsx4(AlertDescription, { children: successMessage })
] }) : null, ] }) : null,
/* @__PURE__ */ jsx3("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs2(Stack, { spacing: 4, children: [ /* @__PURE__ */ jsx4("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs3(Stack, { spacing: 4, children: [
registerMode ? /* @__PURE__ */ jsxs2(FormControl, { isRequired: true, children: [ registerMode ? /* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [
/* @__PURE__ */ jsx3(FormLabel, { children: texts.nameLabel }), /* @__PURE__ */ jsx4(FormLabel, { children: texts.nameLabel }),
/* @__PURE__ */ jsx3(Input, { name: "name", placeholder: namePlaceholder }) /* @__PURE__ */ jsx4(Input, { name: "name", placeholder: namePlaceholder })
] }) : null, ] }) : null,
/* @__PURE__ */ jsxs2(FormControl, { isRequired: true, children: [ /* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [
/* @__PURE__ */ jsx3(FormLabel, { children: texts.emailLabel }), /* @__PURE__ */ jsx4(FormLabel, { children: texts.emailLabel }),
/* @__PURE__ */ jsx3(Input, { name: "email", type: "email", placeholder: emailPlaceholder }) /* @__PURE__ */ jsx4(Input, { name: "email", type: "email", placeholder: emailPlaceholder })
] }), ] }),
/* @__PURE__ */ jsxs2(FormControl, { isRequired: true, children: [ /* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [
/* @__PURE__ */ jsx3(FormLabel, { children: texts.passwordLabel }), /* @__PURE__ */ jsx4(FormLabel, { children: texts.passwordLabel }),
/* @__PURE__ */ jsx3(Input, { name: "password", type: "password", minLength: 8 }) /* @__PURE__ */ jsx4(Input, { name: "password", type: "password", minLength: 8 })
] }), ] }),
!registerMode && forgotPasswordLink ? /* @__PURE__ */ jsx3(Stack, { align: "flex-end", children: forgotPasswordLink }) : null, !registerMode && forgotPasswordLink ? /* @__PURE__ */ jsx4(Stack, { align: "flex-end", children: forgotPasswordLink }) : null,
registerMode ? /* @__PURE__ */ jsxs2(FormControl, { isRequired: true, children: [ registerMode ? /* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [
/* @__PURE__ */ jsx3(FormLabel, { children: texts.passwordConfirmLabel }), /* @__PURE__ */ jsx4(FormLabel, { children: texts.passwordConfirmLabel }),
/* @__PURE__ */ jsx3(Input, { name: "passwordConfirm", type: "password", minLength: 8 }) /* @__PURE__ */ jsx4(Input, { name: "passwordConfirm", type: "password", minLength: 8 })
] }) : null, ] }) : null,
/* @__PURE__ */ jsx3(Button, { type: "submit", isLoading: loading, children: registerMode ? texts.submitRegisterLabel : texts.submitSignInLabel }) /* @__PURE__ */ jsx4(Button, { type: "submit", isLoading: loading, children: registerMode ? texts.submitRegisterLabel : texts.submitSignInLabel })
] }) }), ] }) }),
registerMode || !onOAuthSignIn || !providers?.google && !providers?.slack ? null : /* @__PURE__ */ jsxs2(HStack, { children: [ registerMode || !onOAuthSignIn || !providers?.google && !providers?.slack ? null : /* @__PURE__ */ jsxs3(HStack, { children: [
providers.google ? /* @__PURE__ */ jsx3( providers.google ? /* @__PURE__ */ jsx4(
Button, Button,
{ {
flex: 1, flex: 1,
@@ -321,7 +406,7 @@ function LoginForm({
fontSize: { base: "md", md: "lg" }, fontSize: { base: "md", md: "lg" },
fontWeight: "semibold", fontWeight: "semibold",
iconSpacing: 4, iconSpacing: 4,
leftIcon: /* @__PURE__ */ jsx3(Center, { boxSize: "40px", bg: "white", borderRadius: "full", boxShadow: "sm", children: /* @__PURE__ */ jsx3(Icon, { as: FcGoogle, boxSize: 6 }) }), leftIcon: /* @__PURE__ */ jsx4(Center, { boxSize: "40px", bg: "white", borderRadius: "full", boxShadow: "sm", children: /* @__PURE__ */ jsx4(Icon, { as: FcGoogle2, boxSize: 6 }) }),
_hover: { bg: "gray.300" }, _hover: { bg: "gray.300" },
_active: { bg: "gray.300" }, _active: { bg: "gray.300" },
isLoading: oauthLoadingProvider === "google", isLoading: oauthLoadingProvider === "google",
@@ -329,7 +414,7 @@ function LoginForm({
children: texts.googleLabel children: texts.googleLabel
} }
) : null, ) : null,
providers.slack ? /* @__PURE__ */ jsx3( providers.slack ? /* @__PURE__ */ jsx4(
Button, Button,
{ {
flex: 1, flex: 1,
@@ -340,13 +425,13 @@ function LoginForm({
} }
) : null ) : null
] }), ] }),
/* @__PURE__ */ jsx3(Button, { variant: "ghost", onClick: onModeToggle, children: registerMode ? texts.toggleToSignInLabel : texts.toggleToRegisterLabel }), /* @__PURE__ */ jsx4(Button, { variant: "ghost", onClick: onModeToggle, children: registerMode ? texts.toggleToSignInLabel : texts.toggleToRegisterLabel }),
footer ?? null footer ?? null
] }); ] });
} }
// react/PasswordResetForms.tsx // react/PasswordResetForms.tsx
import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime"; import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
function PasswordResetRequestForm({ function PasswordResetRequestForm({
texts, texts,
helperText, helperText,
@@ -362,18 +447,18 @@ function PasswordResetRequestForm({
email: String(form.get("email") ?? "") email: String(form.get("email") ?? "")
}); });
} }
return /* @__PURE__ */ jsxs3(Stack, { spacing: 5, children: [ return /* @__PURE__ */ jsxs4(Stack, { spacing: 5, children: [
requestSent ? /* @__PURE__ */ jsxs3(Alert, { status: "success", borderRadius: "md", children: [ requestSent ? /* @__PURE__ */ jsxs4(Alert, { status: "success", borderRadius: "md", children: [
/* @__PURE__ */ jsx4(AlertIcon, {}), /* @__PURE__ */ jsx5(AlertIcon, {}),
/* @__PURE__ */ jsx4(AlertDescription, { children: texts.requestSentMessage }) /* @__PURE__ */ jsx5(AlertDescription, { children: texts.requestSentMessage })
] }) : null, ] }) : null,
/* @__PURE__ */ jsx4("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs3(Stack, { spacing: 4, children: [ /* @__PURE__ */ jsx5("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs4(Stack, { spacing: 4, children: [
/* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [ /* @__PURE__ */ jsxs4(FormControl, { isRequired: true, children: [
/* @__PURE__ */ jsx4(FormLabel, { children: texts.emailLabel }), /* @__PURE__ */ jsx5(FormLabel, { children: texts.emailLabel }),
/* @__PURE__ */ jsx4(Input, { name: "email", type: "email", placeholder: emailPlaceholder }) /* @__PURE__ */ jsx5(Input, { name: "email", type: "email", placeholder: emailPlaceholder })
] }), ] }),
/* @__PURE__ */ jsx4(Text, { fontSize: "sm", color: "gray.600", children: helperText }), /* @__PURE__ */ jsx5(Text, { fontSize: "sm", color: "gray.600", children: helperText }),
/* @__PURE__ */ jsx4(Button, { type: "submit", isLoading: loading, children: texts.submitLabel }) /* @__PURE__ */ jsx5(Button, { type: "submit", isLoading: loading, children: texts.submitLabel })
] }) }) ] }) })
] }); ] });
} }
@@ -393,40 +478,41 @@ function PasswordResetConfirmForm({
}); });
} }
if (tokenState.status === "loading") { if (tokenState.status === "loading") {
return /* @__PURE__ */ jsxs3(Stack, { align: "center", py: 6, spacing: 3, children: [ return /* @__PURE__ */ jsxs4(Stack, { align: "center", py: 6, spacing: 3, children: [
/* @__PURE__ */ jsx4(Spinner, {}), /* @__PURE__ */ jsx5(Spinner, {}),
/* @__PURE__ */ jsx4(Text, { color: "gray.600", children: texts.loadingLabel }) /* @__PURE__ */ jsx5(Text, { color: "gray.600", children: texts.loadingLabel })
] }); ] });
} }
if (tokenState.status === "invalid") { if (tokenState.status === "invalid") {
return /* @__PURE__ */ jsxs3(Alert, { status: "error", borderRadius: "md", children: [ return /* @__PURE__ */ jsxs4(Alert, { status: "error", borderRadius: "md", children: [
/* @__PURE__ */ jsx4(AlertIcon, {}), /* @__PURE__ */ jsx5(AlertIcon, {}),
/* @__PURE__ */ jsx4(AlertDescription, { children: tokenState.error || texts.invalidLinkLabel }) /* @__PURE__ */ jsx5(AlertDescription, { children: tokenState.error || texts.invalidLinkLabel })
] }); ] });
} }
if (completedMode !== null) { if (completedMode !== null) {
return /* @__PURE__ */ jsxs3(Alert, { status: "success", borderRadius: "md", children: [ return /* @__PURE__ */ jsxs4(Alert, { status: "success", borderRadius: "md", children: [
/* @__PURE__ */ jsx4(AlertIcon, {}), /* @__PURE__ */ jsx5(AlertIcon, {}),
/* @__PURE__ */ jsx4(AlertDescription, { children: completedMode === "create" ? texts.createSuccessLabel : texts.resetSuccessLabel }) /* @__PURE__ */ jsx5(AlertDescription, { children: completedMode === "create" ? texts.createSuccessLabel : texts.resetSuccessLabel })
] }); ] });
} }
return /* @__PURE__ */ jsxs3(Stack, { spacing: 4, children: [ return /* @__PURE__ */ jsxs4(Stack, { spacing: 4, children: [
/* @__PURE__ */ jsx4(Text, { fontSize: "sm", color: "gray.600", children: tokenState.email }), /* @__PURE__ */ jsx5(Text, { fontSize: "sm", color: "gray.600", children: tokenState.email }),
/* @__PURE__ */ jsx4("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs3(Stack, { spacing: 4, children: [ /* @__PURE__ */ jsx5("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs4(Stack, { spacing: 4, children: [
/* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [ /* @__PURE__ */ jsxs4(FormControl, { isRequired: true, children: [
/* @__PURE__ */ jsx4(FormLabel, { children: texts.passwordLabel }), /* @__PURE__ */ jsx5(FormLabel, { children: texts.passwordLabel }),
/* @__PURE__ */ jsx4(Input, { name: "password", type: "password", minLength: 8 }) /* @__PURE__ */ jsx5(Input, { name: "password", type: "password", minLength: 8 })
] }), ] }),
/* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [ /* @__PURE__ */ jsxs4(FormControl, { isRequired: true, children: [
/* @__PURE__ */ jsx4(FormLabel, { children: texts.passwordConfirmLabel }), /* @__PURE__ */ jsx5(FormLabel, { children: texts.passwordConfirmLabel }),
/* @__PURE__ */ jsx4(Input, { name: "passwordConfirm", type: "password", minLength: 8 }) /* @__PURE__ */ jsx5(Input, { name: "passwordConfirm", type: "password", minLength: 8 })
] }), ] }),
/* @__PURE__ */ jsx4(Button, { type: "submit", isLoading: loading, children: tokenState.mode === "create" ? texts.createSubmitLabel : texts.resetSubmitLabel }) /* @__PURE__ */ jsx5(Button, { type: "submit", isLoading: loading, children: tokenState.mode === "create" ? texts.createSubmitLabel : texts.resetSubmitLabel })
] }) }) ] }) })
] }); ] });
} }
export { export {
AuthGuard, AuthGuard,
InviteAcceptForm,
LoginForm, LoginForm,
PasswordResetConfirmForm, PasswordResetConfirmForm,
PasswordResetRequestForm, PasswordResetRequestForm,
+1 -1
View File
File diff suppressed because one or more lines are too long
+19 -1
View File
@@ -76,6 +76,18 @@ declare function createAuthModule<TAuthUser>(options: CreateAuthModuleOptions<TA
slackAuthEnabled: boolean; slackAuthEnabled: boolean;
}; };
type AccountInviteOptions = {
prisma: any;
tokenTtlMs?: number;
identifierPrefix?: string;
};
declare function createAccountInviteToken(options: AccountInviteOptions & {
userId: string;
}): Promise<{
token: string;
expiresAt: Date;
}>;
type RegisterAuthApiRoutesOptions = { type RegisterAuthApiRoutesOptions = {
app: Express; app: Express;
prisma: any; prisma: any;
@@ -110,6 +122,10 @@ type RegisterAuthApiRoutesOptions = {
expiresAt: Date; expiresAt: Date;
}) => Promise<void>; }) => Promise<void>;
}; };
accountInvite?: {
enabled: boolean;
identifierPrefix?: string;
};
onUserRegistered?: (user: { onUserRegistered?: (user: {
id: string; id: string;
email: string | null; email: string | null;
@@ -130,7 +146,9 @@ type AuthRouteMessages = {
passwordResetUnavailable: string; passwordResetUnavailable: string;
invalidResetLink: string; invalidResetLink: string;
expiredResetLink: string; expiredResetLink: string;
invalidInviteLink: string;
inviteAlreadyAccepted: string;
}; };
declare function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): void; declare function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): void;
export { createAuthModule, registerAuthApiRoutes }; export { createAccountInviteToken, createAuthModule, registerAuthApiRoutes };
+161 -18
View File
@@ -224,8 +224,73 @@ function createAuthModule(options) {
}; };
} }
// server/routes.ts // server/invites.ts
import { createHash, randomBytes } from "crypto"; 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"; import { z as z2 } from "zod";
var defaultNormalizeEmail = (email) => email.trim(); var defaultNormalizeEmail = (email) => email.trim();
var defaultPasswordResetIdentifierPrefix = "password-reset:"; var defaultPasswordResetIdentifierPrefix = "password-reset:";
@@ -237,10 +302,12 @@ var defaultMessages = {
invalidPassword: "Invalid password", invalidPassword: "Invalid password",
passwordResetUnavailable: "Email service is not configured.", passwordResetUnavailable: "Email service is not configured.",
invalidResetLink: "Invalid reset link", 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) { function hashPasswordResetToken(token) {
return createHash("sha256").update(token).digest("hex"); return createHash2("sha256").update(token).digest("hex");
} }
function buildPasswordResetIdentifier(prefix, userId) { function buildPasswordResetIdentifier(prefix, userId) {
return `${prefix}${userId}`; return `${prefix}${userId}`;
@@ -253,7 +320,22 @@ function registerAuthApiRoutes(options) {
const passwordHasher = options.passwordHasher ?? ((password) => Promise.resolve(password)); const passwordHasher = options.passwordHasher ?? ((password) => Promise.resolve(password));
const passwordComparator = options.passwordComparator ?? ((password, hash) => Promise.resolve(password === hash)); const passwordComparator = options.passwordComparator ?? ((password, hash) => Promise.resolve(password === hash));
const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix; const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix;
const accountInviteIdentifierPrefix = options.accountInvite?.identifierPrefix ?? DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX;
const messages = { ...defaultMessages, ...options.messages ?? {} }; 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 findUserByEmail = async (email) => {
const normalized = normalizeEmail(email); const normalized = normalizeEmail(email);
const lowered = normalized.toLowerCase(); const lowered = normalized.toLowerCase();
@@ -348,22 +430,11 @@ function registerAuthApiRoutes(options) {
if (!valid) { if (!valid) {
return res.status(401).json({ error: messages.invalidPassword }); return res.status(401).json({ error: messages.invalidPassword });
} }
const sessionToken = randomBytes(32).toString("hex"); const session = buildSession(user.id);
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3);
await options.prisma.session.create({ await options.prisma.session.create({
data: { data: session
sessionToken,
userId: user.id,
expires
}
});
res.cookie(options.sessionCookieName, sessionToken, {
httpOnly: true,
sameSite: "lax",
secure: options.sessionCookieSecure,
path: "/",
expires
}); });
applySessionCookie(res, session);
return res.status(200).json({ ok: true }); return res.status(200).json({ ok: true });
}); });
options.app.post(`${authApiBasePath}/password-reset/request`, async (req, res) => { options.app.post(`${authApiBasePath}/password-reset/request`, async (req, res) => {
@@ -381,7 +452,7 @@ function registerAuthApiRoutes(options) {
if (!user?.email) { if (!user?.email) {
return res.status(200).json({ ok: true }); 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 identifier = buildPasswordResetIdentifier(passwordResetIdentifierPrefix, user.id);
const expiresAt = new Date(Date.now() + (options.passwordReset.tokenTtlMs ?? 2 * 60 * 60 * 1e3)); const expiresAt = new Date(Date.now() + (options.passwordReset.tokenTtlMs ?? 2 * 60 * 60 * 1e3));
const resetUrl = options.passwordReset.buildResetUrl(rawToken); const resetUrl = options.passwordReset.buildResetUrl(rawToken);
@@ -465,6 +536,77 @@ function registerAuthApiRoutes(options) {
await options.onPasswordResetConfirmed?.(context.user); await options.onPasswordResetConfirmed?.(context.user);
return res.status(200).json({ ok: true }); 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) => { options.app.post(`${authApiBasePath}/logout`, async (req, res) => {
const token = options.extractSessionToken(req.headers.cookie); const token = options.extractSessionToken(req.headers.cookie);
if (token) { if (token) {
@@ -488,6 +630,7 @@ function registerAuthApiRoutes(options) {
}); });
} }
export { export {
createAccountInviteToken,
createAuthModule, createAuthModule,
registerAuthApiRoutes registerAuthApiRoutes
}; };
+1 -1
View File
File diff suppressed because one or more lines are too long
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
import { Alert, AlertDescription, AlertIcon, Button, FormControl, FormLabel, Input, Spinner, Stack, Text } from "./chakra-compat";
import { FcGoogle } from "react-icons/fc";
import type { AccountInviteTokenState } from "./types";
type InviteAcceptFormTexts = {
loadingLabel: string;
invalidLinkLabel: string;
emailLabel: string;
nameLabel: string;
passwordLabel: string;
passwordConfirmLabel: string;
submitLabel: string;
googleLabel: string;
};
type InviteAcceptFormProps = {
texts: InviteAcceptFormTexts;
tokenState: AccountInviteTokenState;
loading?: boolean;
initialName?: string | null;
showGoogleSignIn?: boolean;
googleLoading?: boolean;
onSubmit: (values: { name: string; password: string; passwordConfirm: string }) => void | Promise<void>;
onGoogleSignIn?: () => void | Promise<void>;
};
export function InviteAcceptForm({
texts,
tokenState,
loading = false,
initialName = null,
showGoogleSignIn = false,
googleLoading = false,
onSubmit,
onGoogleSignIn
}: InviteAcceptFormProps) {
const [name, setName] = useState(initialName ?? "");
useEffect(() => {
setName(initialName ?? "");
}, [initialName, tokenState.status]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
void onSubmit({
name: String(form.get("name") ?? ""),
password: String(form.get("password") ?? ""),
passwordConfirm: String(form.get("passwordConfirm") ?? "")
});
}
if (tokenState.status === "loading") {
return (
<Stack align="center" py={6} gap={3}>
<Spinner />
<Text color="gray.600">{texts.loadingLabel}</Text>
</Stack>
);
}
if (tokenState.status === "invalid") {
return (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertDescription>{tokenState.error || texts.invalidLinkLabel}</AlertDescription>
</Alert>
);
}
return (
<Stack gap={4}>
<FormControl>
<FormLabel>{texts.emailLabel}</FormLabel>
<Input value={tokenState.email} readOnly disabled />
</FormControl>
<form onSubmit={handleSubmit}>
<Stack gap={4}>
<FormControl isRequired>
<FormLabel>{texts.nameLabel}</FormLabel>
<Input name="name" value={name} onChange={(event: ChangeEvent<HTMLInputElement>) => setName(event.target.value)} />
</FormControl>
<FormControl isRequired>
<FormLabel>{texts.passwordLabel}</FormLabel>
<Input name="password" type="password" minLength={8} />
</FormControl>
<FormControl isRequired>
<FormLabel>{texts.passwordConfirmLabel}</FormLabel>
<Input name="passwordConfirm" type="password" minLength={8} />
</FormControl>
<Button type="submit" loading={loading}>
{texts.submitLabel}
</Button>
</Stack>
</form>
{showGoogleSignIn && onGoogleSignIn ? (
<Button variant="outline" loading={googleLoading} leftIcon={<FcGoogle />} onClick={() => void onGoogleSignIn()}>
{texts.googleLabel}
</Button>
) : null}
</Stack>
);
}
+30
View File
@@ -23,6 +23,12 @@ type PasswordResetValidationPayload = {
error?: string; error?: string;
}; };
type AccountInviteValidationPayload = {
email?: string;
name?: string | null;
error?: string;
};
type JsonErrorPayload = { type JsonErrorPayload = {
error?: string; error?: string;
}; };
@@ -131,6 +137,30 @@ export function createAuthClient(options: CreateAuthClientOptions) {
} }
}, },
async validateAccountInviteToken(token: string): Promise<{ email: string; name: string | null }> {
const response = await request(`/api/auth/invite/validate?token=${encodeURIComponent(token)}`, {
headers: {}
});
const payload = (await response.json().catch(() => null)) as AccountInviteValidationPayload | null;
if (!response.ok || !payload?.email) {
throw new Error(payload?.error ?? "Invalid invite link");
}
return {
email: payload.email,
name: payload.name ?? null
};
},
async acceptAccountInvite(input: { token: string; name: string; password: string }): Promise<void> {
const response = await request("/api/auth/invite/accept", {
method: "POST",
body: JSON.stringify(input)
});
if (!response.ok) {
throw await readJsonError(response, "Invalid invite link");
}
},
async logout(): Promise<void> { async logout(): Promise<void> {
const response = await request("/api/auth/logout", { const response = await request("/api/auth/logout", {
method: "POST" method: "POST"
+2
View File
@@ -1,8 +1,10 @@
export { AuthGuard } from "./AuthGuard"; export { AuthGuard } from "./AuthGuard";
export { createAuthClient } from "./client"; export { createAuthClient } from "./client";
export { InviteAcceptForm } from "./InviteAcceptForm";
export { LoginForm } from "./LoginForm"; export { LoginForm } from "./LoginForm";
export { PasswordResetConfirmForm, PasswordResetRequestForm } from "./PasswordResetForms"; export { PasswordResetConfirmForm, PasswordResetRequestForm } from "./PasswordResetForms";
export type { export type {
AccountInviteTokenState,
AuthProviderAvailability, AuthProviderAvailability,
AuthProviderKey, AuthProviderKey,
AuthSubmitValues, AuthSubmitValues,
+5
View File
@@ -17,3 +17,8 @@ export type PasswordResetTokenState =
| { status: "loading" } | { status: "loading" }
| { status: "invalid"; error: string } | { status: "invalid"; error: string }
| { status: "valid"; email: string; mode: PasswordResetMode }; | { status: "valid"; email: string; mode: PasswordResetMode };
export type AccountInviteTokenState =
| { status: "loading" }
| { status: "invalid"; error: string }
| { status: "valid"; email: string; name: string | null };
+1
View File
@@ -1,2 +1,3 @@
export { createAuthModule } from "./module.js"; export { createAuthModule } from "./module.js";
export { createAccountInviteToken } from "./invites.js";
export { registerAuthApiRoutes } from "./routes.js"; export { registerAuthApiRoutes } from "./routes.js";
+109
View File
@@ -0,0 +1,109 @@
import { createHash, randomBytes } from "node:crypto";
type AccountInviteContext = {
verificationToken: { identifier: string; expires: Date };
user: {
id: string;
email: string | null;
name: string | null;
passwordHash: string | null;
emailVerified: Date | null;
accounts: Array<{ id: string }>;
};
};
type AccountInviteOptions = {
prisma: any;
tokenTtlMs?: number;
identifierPrefix?: string;
};
const DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX = "account-invite:";
const DEFAULT_ACCOUNT_INVITE_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
function hashAccountInviteToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function buildAccountInviteIdentifier(prefix: string, userId: string): string {
return `${prefix}${userId}`;
}
export async function createAccountInviteToken(
options: AccountInviteOptions & { userId: string }
): Promise<{ token: string; expiresAt: Date }> {
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: new Date() } }]
}
});
await options.prisma.verificationToken.create({
data: {
identifier,
token: hashAccountInviteToken(token),
expires: expiresAt
}
});
return { token, expiresAt };
}
export async function getAccountInviteContext(
options: AccountInviteOptions & { token: string }
): Promise<AccountInviteContext | null> {
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 <= 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
};
}
export {
DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX,
DEFAULT_ACCOUNT_INVITE_TOKEN_TTL_MS,
buildAccountInviteIdentifier,
hashAccountInviteToken
};
+119 -17
View File
@@ -1,6 +1,7 @@
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import type { Express, RequestHandler } from "express"; import type { Express, RequestHandler } from "express";
import { z } from "zod"; import { z } from "zod";
import { DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX, getAccountInviteContext } from "./invites.js";
type RegisterAuthApiRoutesOptions = { type RegisterAuthApiRoutesOptions = {
app: Express; app: Express;
@@ -31,6 +32,10 @@ type RegisterAuthApiRoutesOptions = {
expiresAt: Date; expiresAt: Date;
}) => Promise<void>; }) => Promise<void>;
}; };
accountInvite?: {
enabled: boolean;
identifierPrefix?: string;
};
onUserRegistered?: (user: { id: string; email: string | null; name: string | null }) => Promise<void> | void; onUserRegistered?: (user: { id: string; email: string | null; name: string | null }) => Promise<void> | void;
onPasswordResetConfirmed?: (user: { id: string; email: string | null; name: string | null }) => Promise<void> | void; onPasswordResetConfirmed?: (user: { id: string; email: string | null; name: string | null }) => Promise<void> | void;
}; };
@@ -44,6 +49,8 @@ type AuthRouteMessages = {
passwordResetUnavailable: string; passwordResetUnavailable: string;
invalidResetLink: string; invalidResetLink: string;
expiredResetLink: string; expiredResetLink: string;
invalidInviteLink: string;
inviteAlreadyAccepted: string;
}; };
const defaultNormalizeEmail = (email: string) => email.trim(); const defaultNormalizeEmail = (email: string) => email.trim();
@@ -56,7 +63,9 @@ const defaultMessages: AuthRouteMessages = {
invalidPassword: "Invalid password", invalidPassword: "Invalid password",
passwordResetUnavailable: "Email service is not configured.", passwordResetUnavailable: "Email service is not configured.",
invalidResetLink: "Invalid reset link", 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: string): string { function hashPasswordResetToken(token: string): string {
@@ -75,8 +84,25 @@ export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): vo
const passwordHasher = options.passwordHasher ?? ((password: string) => Promise.resolve(password)); const passwordHasher = options.passwordHasher ?? ((password: string) => Promise.resolve(password));
const passwordComparator = options.passwordComparator ?? ((password: string, hash: string) => Promise.resolve(password === hash)); const passwordComparator = options.passwordComparator ?? ((password: string, hash: string) => Promise.resolve(password === hash));
const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix; const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix;
const accountInviteIdentifierPrefix = options.accountInvite?.identifierPrefix ?? DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX;
const messages = { ...defaultMessages, ...(options.messages ?? {}) }; const messages = { ...defaultMessages, ...(options.messages ?? {}) };
const buildSession = (userId: string) => ({
sessionToken: randomBytes(32).toString("hex"),
userId,
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
});
const applySessionCookie = (res: any, session: { sessionToken: string; expires: Date }) => {
res.cookie(options.sessionCookieName, session.sessionToken, {
httpOnly: true,
sameSite: "lax",
secure: options.sessionCookieSecure,
path: "/",
expires: session.expires
});
};
const findUserByEmail = async (email: string) => { const findUserByEmail = async (email: string) => {
const normalized = normalizeEmail(email); const normalized = normalizeEmail(email);
const lowered = normalized.toLowerCase(); const lowered = normalized.toLowerCase();
@@ -207,24 +233,11 @@ export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): vo
return res.status(401).json({ error: messages.invalidPassword }); return res.status(401).json({ error: messages.invalidPassword });
} }
const sessionToken = randomBytes(32).toString("hex"); const session = buildSession(user.id);
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await options.prisma.session.create({ await options.prisma.session.create({
data: { data: session
sessionToken,
userId: user.id,
expires
}
});
res.cookie(options.sessionCookieName, sessionToken, {
httpOnly: true,
sameSite: "lax",
secure: options.sessionCookieSecure,
path: "/",
expires
}); });
applySessionCookie(res, session);
return res.status(200).json({ ok: true }); return res.status(200).json({ ok: true });
}); });
@@ -352,6 +365,95 @@ export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): vo
return res.status(200).json({ ok: true }); 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 = z.object({ token: z.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 = z
.object({
token: z.string().min(1),
name: z.string().min(2).max(60),
password: z.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 ?? new Date()
}
})
]);
applySessionCookie(res, session);
return res.status(200).json({ ok: true });
});
options.app.post(`${authApiBasePath}/logout`, async (req, res) => { options.app.post(`${authApiBasePath}/logout`, async (req, res) => {
const token = options.extractSessionToken(req.headers.cookie); const token = options.extractSessionToken(req.headers.cookie);