Ajoute le flux d'invitation avec activation de compte
This commit is contained in:
Vendored
+46
-1
@@ -30,6 +30,16 @@ type PasswordResetTokenState = {
|
||||
email: string;
|
||||
mode: PasswordResetMode;
|
||||
};
|
||||
type AccountInviteTokenState = {
|
||||
status: "loading";
|
||||
} | {
|
||||
status: "invalid";
|
||||
error: string;
|
||||
} | {
|
||||
status: "valid";
|
||||
email: string;
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
type CreateAuthClientOptions = {
|
||||
apiUrl: (path: string) => string;
|
||||
@@ -59,10 +69,45 @@ declare function createAuthClient(options: CreateAuthClientOptions): {
|
||||
token: string;
|
||||
password: string;
|
||||
}): Promise<void>;
|
||||
validateAccountInviteToken(token: string): Promise<{
|
||||
email: string;
|
||||
name: string | null;
|
||||
}>;
|
||||
acceptAccountInvite(input: {
|
||||
token: string;
|
||||
name: string;
|
||||
password: string;
|
||||
}): Promise<void>;
|
||||
logout(): 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 = {
|
||||
nameLabel: 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 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 };
|
||||
|
||||
Vendored
+144
-58
@@ -209,6 +209,28 @@ function createAuthClient(options) {
|
||||
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() {
|
||||
const response = await request("/api/auth/logout", {
|
||||
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 { 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({
|
||||
mode,
|
||||
texts,
|
||||
@@ -278,37 +363,37 @@ function LoginForm({
|
||||
passwordConfirm: String(form.get("passwordConfirm") ?? "")
|
||||
});
|
||||
}
|
||||
return /* @__PURE__ */ jsxs2(Stack, { spacing: 5, children: [
|
||||
errorMessage ? /* @__PURE__ */ jsxs2(Alert, { status: "error", borderRadius: "md", children: [
|
||||
/* @__PURE__ */ jsx3(AlertIcon, {}),
|
||||
/* @__PURE__ */ jsx3(AlertDescription, { children: errorMessage })
|
||||
return /* @__PURE__ */ jsxs3(Stack, { spacing: 5, children: [
|
||||
errorMessage ? /* @__PURE__ */ jsxs3(Alert, { status: "error", borderRadius: "md", children: [
|
||||
/* @__PURE__ */ jsx4(AlertIcon, {}),
|
||||
/* @__PURE__ */ jsx4(AlertDescription, { children: errorMessage })
|
||||
] }) : null,
|
||||
successMessage ? /* @__PURE__ */ jsxs2(Alert, { status: "success", borderRadius: "md", children: [
|
||||
/* @__PURE__ */ jsx3(AlertIcon, {}),
|
||||
/* @__PURE__ */ jsx3(AlertDescription, { children: successMessage })
|
||||
successMessage ? /* @__PURE__ */ jsxs3(Alert, { status: "success", borderRadius: "md", children: [
|
||||
/* @__PURE__ */ jsx4(AlertIcon, {}),
|
||||
/* @__PURE__ */ jsx4(AlertDescription, { children: successMessage })
|
||||
] }) : null,
|
||||
/* @__PURE__ */ jsx3("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs2(Stack, { spacing: 4, children: [
|
||||
registerMode ? /* @__PURE__ */ jsxs2(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx3(FormLabel, { children: texts.nameLabel }),
|
||||
/* @__PURE__ */ jsx3(Input, { name: "name", placeholder: namePlaceholder })
|
||||
/* @__PURE__ */ jsx4("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs3(Stack, { spacing: 4, children: [
|
||||
registerMode ? /* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx4(FormLabel, { children: texts.nameLabel }),
|
||||
/* @__PURE__ */ jsx4(Input, { name: "name", placeholder: namePlaceholder })
|
||||
] }) : null,
|
||||
/* @__PURE__ */ jsxs2(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx3(FormLabel, { children: texts.emailLabel }),
|
||||
/* @__PURE__ */ jsx3(Input, { name: "email", type: "email", placeholder: emailPlaceholder })
|
||||
/* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx4(FormLabel, { children: texts.emailLabel }),
|
||||
/* @__PURE__ */ jsx4(Input, { name: "email", type: "email", placeholder: emailPlaceholder })
|
||||
] }),
|
||||
/* @__PURE__ */ jsxs2(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx3(FormLabel, { children: texts.passwordLabel }),
|
||||
/* @__PURE__ */ jsx3(Input, { name: "password", type: "password", minLength: 8 })
|
||||
/* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx4(FormLabel, { children: texts.passwordLabel }),
|
||||
/* @__PURE__ */ jsx4(Input, { name: "password", type: "password", minLength: 8 })
|
||||
] }),
|
||||
!registerMode && forgotPasswordLink ? /* @__PURE__ */ jsx3(Stack, { align: "flex-end", children: forgotPasswordLink }) : null,
|
||||
registerMode ? /* @__PURE__ */ jsxs2(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx3(FormLabel, { children: texts.passwordConfirmLabel }),
|
||||
/* @__PURE__ */ jsx3(Input, { name: "passwordConfirm", type: "password", minLength: 8 })
|
||||
!registerMode && forgotPasswordLink ? /* @__PURE__ */ jsx4(Stack, { align: "flex-end", children: forgotPasswordLink }) : null,
|
||||
registerMode ? /* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx4(FormLabel, { children: texts.passwordConfirmLabel }),
|
||||
/* @__PURE__ */ jsx4(Input, { name: "passwordConfirm", type: "password", minLength: 8 })
|
||||
] }) : 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: [
|
||||
providers.google ? /* @__PURE__ */ jsx3(
|
||||
registerMode || !onOAuthSignIn || !providers?.google && !providers?.slack ? null : /* @__PURE__ */ jsxs3(HStack, { children: [
|
||||
providers.google ? /* @__PURE__ */ jsx4(
|
||||
Button,
|
||||
{
|
||||
flex: 1,
|
||||
@@ -321,7 +406,7 @@ function LoginForm({
|
||||
fontSize: { base: "md", md: "lg" },
|
||||
fontWeight: "semibold",
|
||||
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" },
|
||||
_active: { bg: "gray.300" },
|
||||
isLoading: oauthLoadingProvider === "google",
|
||||
@@ -329,7 +414,7 @@ function LoginForm({
|
||||
children: texts.googleLabel
|
||||
}
|
||||
) : null,
|
||||
providers.slack ? /* @__PURE__ */ jsx3(
|
||||
providers.slack ? /* @__PURE__ */ jsx4(
|
||||
Button,
|
||||
{
|
||||
flex: 1,
|
||||
@@ -340,13 +425,13 @@ function LoginForm({
|
||||
}
|
||||
) : 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
|
||||
] });
|
||||
}
|
||||
|
||||
// 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({
|
||||
texts,
|
||||
helperText,
|
||||
@@ -362,18 +447,18 @@ function PasswordResetRequestForm({
|
||||
email: String(form.get("email") ?? "")
|
||||
});
|
||||
}
|
||||
return /* @__PURE__ */ jsxs3(Stack, { spacing: 5, children: [
|
||||
requestSent ? /* @__PURE__ */ jsxs3(Alert, { status: "success", borderRadius: "md", children: [
|
||||
/* @__PURE__ */ jsx4(AlertIcon, {}),
|
||||
/* @__PURE__ */ jsx4(AlertDescription, { children: texts.requestSentMessage })
|
||||
return /* @__PURE__ */ jsxs4(Stack, { spacing: 5, children: [
|
||||
requestSent ? /* @__PURE__ */ jsxs4(Alert, { status: "success", borderRadius: "md", children: [
|
||||
/* @__PURE__ */ jsx5(AlertIcon, {}),
|
||||
/* @__PURE__ */ jsx5(AlertDescription, { children: texts.requestSentMessage })
|
||||
] }) : null,
|
||||
/* @__PURE__ */ jsx4("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs3(Stack, { spacing: 4, children: [
|
||||
/* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx4(FormLabel, { children: texts.emailLabel }),
|
||||
/* @__PURE__ */ jsx4(Input, { name: "email", type: "email", placeholder: emailPlaceholder })
|
||||
/* @__PURE__ */ jsx5("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs4(Stack, { spacing: 4, children: [
|
||||
/* @__PURE__ */ jsxs4(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx5(FormLabel, { children: texts.emailLabel }),
|
||||
/* @__PURE__ */ jsx5(Input, { name: "email", type: "email", placeholder: emailPlaceholder })
|
||||
] }),
|
||||
/* @__PURE__ */ jsx4(Text, { fontSize: "sm", color: "gray.600", children: helperText }),
|
||||
/* @__PURE__ */ jsx4(Button, { type: "submit", isLoading: loading, children: texts.submitLabel })
|
||||
/* @__PURE__ */ jsx5(Text, { fontSize: "sm", color: "gray.600", children: helperText }),
|
||||
/* @__PURE__ */ jsx5(Button, { type: "submit", isLoading: loading, children: texts.submitLabel })
|
||||
] }) })
|
||||
] });
|
||||
}
|
||||
@@ -393,40 +478,41 @@ function PasswordResetConfirmForm({
|
||||
});
|
||||
}
|
||||
if (tokenState.status === "loading") {
|
||||
return /* @__PURE__ */ jsxs3(Stack, { align: "center", py: 6, spacing: 3, children: [
|
||||
/* @__PURE__ */ jsx4(Spinner, {}),
|
||||
/* @__PURE__ */ jsx4(Text, { color: "gray.600", children: texts.loadingLabel })
|
||||
return /* @__PURE__ */ jsxs4(Stack, { align: "center", py: 6, spacing: 3, children: [
|
||||
/* @__PURE__ */ jsx5(Spinner, {}),
|
||||
/* @__PURE__ */ jsx5(Text, { color: "gray.600", children: texts.loadingLabel })
|
||||
] });
|
||||
}
|
||||
if (tokenState.status === "invalid") {
|
||||
return /* @__PURE__ */ jsxs3(Alert, { status: "error", borderRadius: "md", children: [
|
||||
/* @__PURE__ */ jsx4(AlertIcon, {}),
|
||||
/* @__PURE__ */ jsx4(AlertDescription, { children: tokenState.error || texts.invalidLinkLabel })
|
||||
return /* @__PURE__ */ jsxs4(Alert, { status: "error", borderRadius: "md", children: [
|
||||
/* @__PURE__ */ jsx5(AlertIcon, {}),
|
||||
/* @__PURE__ */ jsx5(AlertDescription, { children: tokenState.error || texts.invalidLinkLabel })
|
||||
] });
|
||||
}
|
||||
if (completedMode !== null) {
|
||||
return /* @__PURE__ */ jsxs3(Alert, { status: "success", borderRadius: "md", children: [
|
||||
/* @__PURE__ */ jsx4(AlertIcon, {}),
|
||||
/* @__PURE__ */ jsx4(AlertDescription, { children: completedMode === "create" ? texts.createSuccessLabel : texts.resetSuccessLabel })
|
||||
return /* @__PURE__ */ jsxs4(Alert, { status: "success", borderRadius: "md", children: [
|
||||
/* @__PURE__ */ jsx5(AlertIcon, {}),
|
||||
/* @__PURE__ */ jsx5(AlertDescription, { children: completedMode === "create" ? texts.createSuccessLabel : texts.resetSuccessLabel })
|
||||
] });
|
||||
}
|
||||
return /* @__PURE__ */ jsxs3(Stack, { spacing: 4, children: [
|
||||
/* @__PURE__ */ jsx4(Text, { fontSize: "sm", color: "gray.600", children: tokenState.email }),
|
||||
/* @__PURE__ */ jsx4("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs3(Stack, { spacing: 4, children: [
|
||||
/* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx4(FormLabel, { children: texts.passwordLabel }),
|
||||
/* @__PURE__ */ jsx4(Input, { name: "password", type: "password", minLength: 8 })
|
||||
return /* @__PURE__ */ jsxs4(Stack, { spacing: 4, children: [
|
||||
/* @__PURE__ */ jsx5(Text, { fontSize: "sm", color: "gray.600", children: tokenState.email }),
|
||||
/* @__PURE__ */ jsx5("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs4(Stack, { spacing: 4, children: [
|
||||
/* @__PURE__ */ jsxs4(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx5(FormLabel, { children: texts.passwordLabel }),
|
||||
/* @__PURE__ */ jsx5(Input, { name: "password", type: "password", minLength: 8 })
|
||||
] }),
|
||||
/* @__PURE__ */ jsxs3(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx4(FormLabel, { children: texts.passwordConfirmLabel }),
|
||||
/* @__PURE__ */ jsx4(Input, { name: "passwordConfirm", type: "password", minLength: 8 })
|
||||
/* @__PURE__ */ jsxs4(FormControl, { isRequired: true, children: [
|
||||
/* @__PURE__ */ jsx5(FormLabel, { children: texts.passwordConfirmLabel }),
|
||||
/* @__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 {
|
||||
AuthGuard,
|
||||
InviteAcceptForm,
|
||||
LoginForm,
|
||||
PasswordResetConfirmForm,
|
||||
PasswordResetRequestForm,
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,12 @@ type PasswordResetValidationPayload = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type AccountInviteValidationPayload = {
|
||||
email?: string;
|
||||
name?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type JsonErrorPayload = {
|
||||
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> {
|
||||
const response = await request("/api/auth/logout", {
|
||||
method: "POST"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export { AuthGuard } from "./AuthGuard";
|
||||
export { createAuthClient } from "./client";
|
||||
export { InviteAcceptForm } from "./InviteAcceptForm";
|
||||
export { LoginForm } from "./LoginForm";
|
||||
export { PasswordResetConfirmForm, PasswordResetRequestForm } from "./PasswordResetForms";
|
||||
export type {
|
||||
AccountInviteTokenState,
|
||||
AuthProviderAvailability,
|
||||
AuthProviderKey,
|
||||
AuthSubmitValues,
|
||||
|
||||
@@ -17,3 +17,8 @@ export type PasswordResetTokenState =
|
||||
| { status: "loading" }
|
||||
| { status: "invalid"; error: string }
|
||||
| { status: "valid"; email: string; mode: PasswordResetMode };
|
||||
|
||||
export type AccountInviteTokenState =
|
||||
| { status: "loading" }
|
||||
| { status: "invalid"; error: string }
|
||||
| { status: "valid"; email: string; name: string | null };
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { createAuthModule } from "./module.js";
|
||||
export { createAccountInviteToken } from "./invites.js";
|
||||
export { registerAuthApiRoutes } from "./routes.js";
|
||||
|
||||
@@ -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
@@ -1,6 +1,7 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import { z } from "zod";
|
||||
import { DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX, getAccountInviteContext } from "./invites.js";
|
||||
|
||||
type RegisterAuthApiRoutesOptions = {
|
||||
app: Express;
|
||||
@@ -31,6 +32,10 @@ type RegisterAuthApiRoutesOptions = {
|
||||
expiresAt: Date;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
accountInvite?: {
|
||||
enabled: boolean;
|
||||
identifierPrefix?: string;
|
||||
};
|
||||
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;
|
||||
};
|
||||
@@ -44,6 +49,8 @@ type AuthRouteMessages = {
|
||||
passwordResetUnavailable: string;
|
||||
invalidResetLink: string;
|
||||
expiredResetLink: string;
|
||||
invalidInviteLink: string;
|
||||
inviteAlreadyAccepted: string;
|
||||
};
|
||||
|
||||
const defaultNormalizeEmail = (email: string) => email.trim();
|
||||
@@ -56,7 +63,9 @@ const defaultMessages: AuthRouteMessages = {
|
||||
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: string): string {
|
||||
@@ -75,8 +84,25 @@ export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): vo
|
||||
const passwordHasher = options.passwordHasher ?? ((password: string) => Promise.resolve(password));
|
||||
const passwordComparator = options.passwordComparator ?? ((password: string, hash: string) => 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: 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 normalized = normalizeEmail(email);
|
||||
const lowered = normalized.toLowerCase();
|
||||
@@ -207,24 +233,11 @@ export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): vo
|
||||
return res.status(401).json({ error: messages.invalidPassword });
|
||||
}
|
||||
|
||||
const sessionToken = randomBytes(32).toString("hex");
|
||||
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
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 });
|
||||
});
|
||||
@@ -352,6 +365,95 @@ export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): vo
|
||||
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) => {
|
||||
const token = options.extractSessionToken(req.headers.cookie);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user