first commit
This commit is contained in:
74
README.md
Normal file
74
README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# @packages/auth
|
||||||
|
|
||||||
|
Package d'authentification réutilisable pour projets React + Express.
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
- `@packages/auth/react`
|
||||||
|
- `@packages/auth/server`
|
||||||
|
|
||||||
|
## Contrat Prisma minimal
|
||||||
|
|
||||||
|
Le package serveur suppose un socle Auth.js compatible, avec les modèles suivants:
|
||||||
|
|
||||||
|
- `User`
|
||||||
|
- `Account`
|
||||||
|
- `Session`
|
||||||
|
- `VerificationToken`
|
||||||
|
|
||||||
|
Champs attendus pour le flux local email/mot de passe et reset:
|
||||||
|
|
||||||
|
- `User.id`
|
||||||
|
- `User.email`
|
||||||
|
- `User.name`
|
||||||
|
- `User.image`
|
||||||
|
- `User.passwordHash`
|
||||||
|
- `User.emailVerified`
|
||||||
|
- `Session.sessionToken`
|
||||||
|
- `Session.userId`
|
||||||
|
- `Session.expires`
|
||||||
|
- `VerificationToken.identifier`
|
||||||
|
- `VerificationToken.token`
|
||||||
|
- `VerificationToken.expires`
|
||||||
|
|
||||||
|
Le package n'impose pas les champs métier supplémentaires. Ils peuvent rester propres à chaque application.
|
||||||
|
|
||||||
|
## Intégration côté serveur
|
||||||
|
|
||||||
|
Le noyau serveur expose:
|
||||||
|
|
||||||
|
- `createAuthModule`
|
||||||
|
- `registerAuthApiRoutes`
|
||||||
|
|
||||||
|
Les points configurables importants:
|
||||||
|
|
||||||
|
- `signInPath`
|
||||||
|
- `authenticatedRedirectPath`
|
||||||
|
- `messages`
|
||||||
|
- `passwordReset`
|
||||||
|
- `onUserRegistered`
|
||||||
|
- `onSessionValidated`
|
||||||
|
|
||||||
|
## Intégration côté React
|
||||||
|
|
||||||
|
Le package React expose:
|
||||||
|
|
||||||
|
- `createAuthClient`
|
||||||
|
- `AuthGuard`
|
||||||
|
- `LoginForm`
|
||||||
|
- `PasswordResetRequestForm`
|
||||||
|
- `PasswordResetConfirmForm`
|
||||||
|
|
||||||
|
`createAuthClient` accepte notamment `defaultOAuthCallbackUrl` pour éviter toute dépendance à une route post-login fixe.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build --workspace @packages/auth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Watch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev --workspace @packages/auth
|
||||||
|
```
|
||||||
135
dist/react/index.d.ts
vendored
Normal file
135
dist/react/index.d.ts
vendored
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type AuthGuardProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
fetchCurrentUser: () => Promise<unknown>;
|
||||||
|
redirectTo?: string;
|
||||||
|
loadingFallback?: React.ReactNode;
|
||||||
|
authenticatedWrapper?: (children: React.ReactNode) => React.ReactNode;
|
||||||
|
};
|
||||||
|
declare function AuthGuard({ children, fetchCurrentUser, redirectTo, loadingFallback, authenticatedWrapper }: AuthGuardProps): react_jsx_runtime.JSX.Element;
|
||||||
|
|
||||||
|
type AuthProviderKey = "google" | "slack" | (string & {});
|
||||||
|
type AuthProviderAvailability = Partial<Record<AuthProviderKey, boolean>>;
|
||||||
|
type LoginMode = "signIn" | "register";
|
||||||
|
type AuthSubmitValues = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
passwordConfirm: string;
|
||||||
|
};
|
||||||
|
type PasswordResetMode = "reset" | "create";
|
||||||
|
type PasswordResetTokenState = {
|
||||||
|
status: "loading";
|
||||||
|
} | {
|
||||||
|
status: "invalid";
|
||||||
|
error: string;
|
||||||
|
} | {
|
||||||
|
status: "valid";
|
||||||
|
email: string;
|
||||||
|
mode: PasswordResetMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateAuthClientOptions = {
|
||||||
|
apiUrl: (path: string) => string;
|
||||||
|
authUrl?: (path: string) => string;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
credentials?: RequestCredentials;
|
||||||
|
defaultOAuthCallbackUrl?: string | (() => string);
|
||||||
|
};
|
||||||
|
type LoginInput = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
type RegisterInput = LoginInput & {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
declare function createAuthClient(options: CreateAuthClientOptions): {
|
||||||
|
getProviders(): Promise<AuthProviderAvailability>;
|
||||||
|
getCurrentUser<TUser>(): Promise<TUser>;
|
||||||
|
register(input: RegisterInput): Promise<void>;
|
||||||
|
login(input: LoginInput): Promise<void>;
|
||||||
|
requestPasswordReset(email: string): Promise<void>;
|
||||||
|
validatePasswordResetToken(token: string): Promise<{
|
||||||
|
email: string;
|
||||||
|
mode: PasswordResetMode;
|
||||||
|
}>;
|
||||||
|
confirmPasswordReset(input: {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
logout(): Promise<void>;
|
||||||
|
startOAuthSignIn(provider: string, callbackUrl?: string): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginFormTexts = {
|
||||||
|
nameLabel: string;
|
||||||
|
emailLabel: string;
|
||||||
|
passwordLabel: string;
|
||||||
|
passwordConfirmLabel: string;
|
||||||
|
submitRegisterLabel: string;
|
||||||
|
submitSignInLabel: string;
|
||||||
|
toggleToRegisterLabel: string;
|
||||||
|
toggleToSignInLabel: string;
|
||||||
|
forgotPasswordLabel: string;
|
||||||
|
googleLabel: string;
|
||||||
|
slackLabel: string;
|
||||||
|
};
|
||||||
|
type LoginFormProps = {
|
||||||
|
mode: LoginMode;
|
||||||
|
texts: LoginFormTexts;
|
||||||
|
onSubmit: (values: AuthSubmitValues) => void | Promise<void>;
|
||||||
|
onModeToggle: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
oauthLoadingProvider?: AuthProviderKey | null;
|
||||||
|
providers?: AuthProviderAvailability;
|
||||||
|
onOAuthSignIn?: (provider: AuthProviderKey) => void | Promise<void>;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
successMessage?: string | null;
|
||||||
|
footer?: ReactNode;
|
||||||
|
forgotPasswordLink?: ReactNode;
|
||||||
|
emailPlaceholder?: string;
|
||||||
|
namePlaceholder?: string;
|
||||||
|
};
|
||||||
|
declare function LoginForm({ mode, texts, onSubmit, onModeToggle, loading, oauthLoadingProvider, providers, onOAuthSignIn, errorMessage, successMessage, footer, forgotPasswordLink, emailPlaceholder, namePlaceholder }: LoginFormProps): react_jsx_runtime.JSX.Element;
|
||||||
|
|
||||||
|
type PasswordResetRequestTexts = {
|
||||||
|
emailLabel: string;
|
||||||
|
submitLabel: string;
|
||||||
|
requestSentMessage: string;
|
||||||
|
};
|
||||||
|
type PasswordResetRequestFormProps = {
|
||||||
|
texts: PasswordResetRequestTexts;
|
||||||
|
helperText: ReactNode;
|
||||||
|
loading?: boolean;
|
||||||
|
requestSent?: boolean;
|
||||||
|
onSubmit: (values: {
|
||||||
|
email: string;
|
||||||
|
}) => void | Promise<void>;
|
||||||
|
emailPlaceholder?: string;
|
||||||
|
};
|
||||||
|
type PasswordResetConfirmTexts = {
|
||||||
|
loadingLabel: string;
|
||||||
|
passwordLabel: string;
|
||||||
|
passwordConfirmLabel: string;
|
||||||
|
invalidLinkLabel: string;
|
||||||
|
resetSubmitLabel: string;
|
||||||
|
createSubmitLabel: string;
|
||||||
|
resetSuccessLabel: string;
|
||||||
|
createSuccessLabel: string;
|
||||||
|
};
|
||||||
|
type PasswordResetConfirmFormProps = {
|
||||||
|
texts: PasswordResetConfirmTexts;
|
||||||
|
tokenState: PasswordResetTokenState;
|
||||||
|
loading?: boolean;
|
||||||
|
completedMode?: PasswordResetMode | null;
|
||||||
|
onSubmit: (values: {
|
||||||
|
password: string;
|
||||||
|
passwordConfirm: string;
|
||||||
|
}) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
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 };
|
||||||
371
dist/react/index.js
vendored
Normal file
371
dist/react/index.js
vendored
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
// react/AuthGuard.tsx
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Center, Spinner } from "@chakra-ui/react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { Fragment, jsx } from "react/jsx-runtime";
|
||||||
|
function AuthGuard({
|
||||||
|
children,
|
||||||
|
fetchCurrentUser,
|
||||||
|
redirectTo = "/login",
|
||||||
|
loadingFallback,
|
||||||
|
authenticatedWrapper
|
||||||
|
}) {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
loading: true,
|
||||||
|
authenticated: false
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchCurrentUser().then(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setState({ loading: false, authenticated: true });
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setState({ loading: false, authenticated: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fetchCurrentUser]);
|
||||||
|
if (state.loading) {
|
||||||
|
return /* @__PURE__ */ jsx(Fragment, { children: loadingFallback ?? /* @__PURE__ */ jsx(Center, { h: "var(--app-height)", children: /* @__PURE__ */ jsx(Spinner, { size: "xl" }) }) });
|
||||||
|
}
|
||||||
|
if (!state.authenticated) {
|
||||||
|
return /* @__PURE__ */ jsx(Navigate, { to: redirectTo, replace: true });
|
||||||
|
}
|
||||||
|
return /* @__PURE__ */ jsx(Fragment, { children: authenticatedWrapper ? authenticatedWrapper(children) : children });
|
||||||
|
}
|
||||||
|
|
||||||
|
// react/client.ts
|
||||||
|
async function readJsonError(response, fallback) {
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
return new Error(payload?.error ?? fallback);
|
||||||
|
}
|
||||||
|
function createAuthClient(options) {
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
const authUrl = options.authUrl ?? options.apiUrl;
|
||||||
|
const credentials = options.credentials ?? "include";
|
||||||
|
function resolveDefaultOAuthCallbackUrl() {
|
||||||
|
const configured = options.defaultOAuthCallbackUrl;
|
||||||
|
if (typeof configured === "function") {
|
||||||
|
return configured();
|
||||||
|
}
|
||||||
|
if (typeof configured === "string" && configured.trim().length > 0) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return `${window.location.origin}/chat`;
|
||||||
|
}
|
||||||
|
async function request(path, init) {
|
||||||
|
return fetchImpl(options.apiUrl(path), {
|
||||||
|
...init,
|
||||||
|
credentials,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...init?.headers ?? {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async getProviders() {
|
||||||
|
const response = await request("/api/auth/providers");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "providers_unavailable");
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
async getCurrentUser() {
|
||||||
|
const response = await request("/api/me");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Unauthorized");
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
return payload.user;
|
||||||
|
},
|
||||||
|
async register(input) {
|
||||||
|
const response = await request("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Registration failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async login(input) {
|
||||||
|
const response = await request("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Sign in failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async requestPasswordReset(email) {
|
||||||
|
const response = await request("/api/auth/password-reset/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Password reset request failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async validatePasswordResetToken(token) {
|
||||||
|
const response = await request(`/api/auth/password-reset/validate?token=${encodeURIComponent(token)}`, {
|
||||||
|
headers: {}
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !payload?.email || payload.mode !== "reset" && payload.mode !== "create") {
|
||||||
|
throw new Error(payload?.error ?? "Invalid reset link");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
email: payload.email,
|
||||||
|
mode: payload.mode
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async confirmPasswordReset(input) {
|
||||||
|
const response = await request("/api/auth/password-reset/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Invalid reset link");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
const response = await request("/api/auth/logout", {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Logout failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async startOAuthSignIn(provider, callbackUrl = resolveDefaultOAuthCallbackUrl()) {
|
||||||
|
const response = await fetchImpl(authUrl("/auth/csrf"), {
|
||||||
|
credentials
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Sign in failed");
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!payload.csrfToken) {
|
||||||
|
throw new Error("Sign in failed");
|
||||||
|
}
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.method = "POST";
|
||||||
|
form.action = authUrl(`/auth/signin/${provider}`);
|
||||||
|
form.style.display = "none";
|
||||||
|
const csrfInput = document.createElement("input");
|
||||||
|
csrfInput.type = "hidden";
|
||||||
|
csrfInput.name = "csrfToken";
|
||||||
|
csrfInput.value = payload.csrfToken;
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
const callbackInput = document.createElement("input");
|
||||||
|
callbackInput.type = "hidden";
|
||||||
|
callbackInput.name = "callbackUrl";
|
||||||
|
callbackInput.value = callbackUrl;
|
||||||
|
form.appendChild(callbackInput);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// react/LoginForm.tsx
|
||||||
|
import { Alert, AlertDescription, AlertIcon, Button, Center as Center2, FormControl, FormLabel, HStack, Icon, Input, Stack } from "@chakra-ui/react";
|
||||||
|
import { FcGoogle } from "react-icons/fc";
|
||||||
|
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
||||||
|
function LoginForm({
|
||||||
|
mode,
|
||||||
|
texts,
|
||||||
|
onSubmit,
|
||||||
|
onModeToggle,
|
||||||
|
loading = false,
|
||||||
|
oauthLoadingProvider = null,
|
||||||
|
providers,
|
||||||
|
onOAuthSignIn,
|
||||||
|
errorMessage,
|
||||||
|
successMessage,
|
||||||
|
footer,
|
||||||
|
forgotPasswordLink,
|
||||||
|
emailPlaceholder = "you@example.com",
|
||||||
|
namePlaceholder = "Jane Doe"
|
||||||
|
}) {
|
||||||
|
const registerMode = mode === "register";
|
||||||
|
function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
void onSubmit({
|
||||||
|
name: String(form.get("name") ?? ""),
|
||||||
|
email: String(form.get("email") ?? ""),
|
||||||
|
password: String(form.get("password") ?? ""),
|
||||||
|
passwordConfirm: String(form.get("passwordConfirm") ?? "")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return /* @__PURE__ */ jsxs(Stack, { spacing: 5, children: [
|
||||||
|
errorMessage ? /* @__PURE__ */ jsxs(Alert, { status: "error", borderRadius: "md", children: [
|
||||||
|
/* @__PURE__ */ jsx2(AlertIcon, {}),
|
||||||
|
/* @__PURE__ */ jsx2(AlertDescription, { children: errorMessage })
|
||||||
|
] }) : null,
|
||||||
|
successMessage ? /* @__PURE__ */ jsxs(Alert, { status: "success", borderRadius: "md", children: [
|
||||||
|
/* @__PURE__ */ jsx2(AlertIcon, {}),
|
||||||
|
/* @__PURE__ */ jsx2(AlertDescription, { children: successMessage })
|
||||||
|
] }) : null,
|
||||||
|
/* @__PURE__ */ jsx2("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs(Stack, { spacing: 4, children: [
|
||||||
|
registerMode ? /* @__PURE__ */ jsxs(FormControl, { isRequired: true, children: [
|
||||||
|
/* @__PURE__ */ jsx2(FormLabel, { children: texts.nameLabel }),
|
||||||
|
/* @__PURE__ */ jsx2(Input, { name: "name", placeholder: namePlaceholder })
|
||||||
|
] }) : null,
|
||||||
|
/* @__PURE__ */ jsxs(FormControl, { isRequired: true, children: [
|
||||||
|
/* @__PURE__ */ jsx2(FormLabel, { children: texts.emailLabel }),
|
||||||
|
/* @__PURE__ */ jsx2(Input, { name: "email", type: "email", placeholder: emailPlaceholder })
|
||||||
|
] }),
|
||||||
|
/* @__PURE__ */ jsxs(FormControl, { isRequired: true, children: [
|
||||||
|
/* @__PURE__ */ jsx2(FormLabel, { children: texts.passwordLabel }),
|
||||||
|
/* @__PURE__ */ jsx2(Input, { name: "password", type: "password", minLength: 8 })
|
||||||
|
] }),
|
||||||
|
!registerMode && forgotPasswordLink ? /* @__PURE__ */ jsx2(Stack, { align: "flex-end", children: forgotPasswordLink }) : null,
|
||||||
|
registerMode ? /* @__PURE__ */ jsxs(FormControl, { isRequired: true, children: [
|
||||||
|
/* @__PURE__ */ jsx2(FormLabel, { children: texts.passwordConfirmLabel }),
|
||||||
|
/* @__PURE__ */ jsx2(Input, { name: "passwordConfirm", type: "password", minLength: 8 })
|
||||||
|
] }) : null,
|
||||||
|
/* @__PURE__ */ jsx2(Button, { type: "submit", isLoading: loading, children: registerMode ? texts.submitRegisterLabel : texts.submitSignInLabel })
|
||||||
|
] }) }),
|
||||||
|
registerMode || !onOAuthSignIn || !providers?.google && !providers?.slack ? null : /* @__PURE__ */ jsxs(HStack, { children: [
|
||||||
|
providers.google ? /* @__PURE__ */ jsx2(
|
||||||
|
Button,
|
||||||
|
{
|
||||||
|
flex: 1,
|
||||||
|
bg: "gray.200",
|
||||||
|
color: "gray.900",
|
||||||
|
borderRadius: "full",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
h: "56px",
|
||||||
|
px: 3,
|
||||||
|
fontSize: { base: "md", md: "lg" },
|
||||||
|
fontWeight: "semibold",
|
||||||
|
iconSpacing: 4,
|
||||||
|
leftIcon: /* @__PURE__ */ jsx2(Center2, { boxSize: "40px", bg: "white", borderRadius: "full", boxShadow: "sm", children: /* @__PURE__ */ jsx2(Icon, { as: FcGoogle, boxSize: 6 }) }),
|
||||||
|
_hover: { bg: "gray.300" },
|
||||||
|
_active: { bg: "gray.300" },
|
||||||
|
isLoading: oauthLoadingProvider === "google",
|
||||||
|
onClick: () => void onOAuthSignIn("google"),
|
||||||
|
children: texts.googleLabel
|
||||||
|
}
|
||||||
|
) : null,
|
||||||
|
providers.slack ? /* @__PURE__ */ jsx2(
|
||||||
|
Button,
|
||||||
|
{
|
||||||
|
flex: 1,
|
||||||
|
variant: "outline",
|
||||||
|
isLoading: oauthLoadingProvider === "slack",
|
||||||
|
onClick: () => void onOAuthSignIn("slack"),
|
||||||
|
children: texts.slackLabel
|
||||||
|
}
|
||||||
|
) : null
|
||||||
|
] }),
|
||||||
|
/* @__PURE__ */ jsx2(Button, { variant: "ghost", onClick: onModeToggle, children: registerMode ? texts.toggleToSignInLabel : texts.toggleToRegisterLabel }),
|
||||||
|
footer ?? null
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// react/PasswordResetForms.tsx
|
||||||
|
import {
|
||||||
|
Alert as Alert2,
|
||||||
|
AlertDescription as AlertDescription2,
|
||||||
|
AlertIcon as AlertIcon2,
|
||||||
|
Button as Button2,
|
||||||
|
FormControl as FormControl2,
|
||||||
|
FormLabel as FormLabel2,
|
||||||
|
Input as Input2,
|
||||||
|
Spinner as Spinner2,
|
||||||
|
Stack as Stack2,
|
||||||
|
Text
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
||||||
|
function PasswordResetRequestForm({
|
||||||
|
texts,
|
||||||
|
helperText,
|
||||||
|
loading = false,
|
||||||
|
requestSent = false,
|
||||||
|
onSubmit,
|
||||||
|
emailPlaceholder = "you@example.com"
|
||||||
|
}) {
|
||||||
|
function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
void onSubmit({
|
||||||
|
email: String(form.get("email") ?? "")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return /* @__PURE__ */ jsxs2(Stack2, { spacing: 5, children: [
|
||||||
|
requestSent ? /* @__PURE__ */ jsxs2(Alert2, { status: "success", borderRadius: "md", children: [
|
||||||
|
/* @__PURE__ */ jsx3(AlertIcon2, {}),
|
||||||
|
/* @__PURE__ */ jsx3(AlertDescription2, { children: texts.requestSentMessage })
|
||||||
|
] }) : null,
|
||||||
|
/* @__PURE__ */ jsx3("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs2(Stack2, { spacing: 4, children: [
|
||||||
|
/* @__PURE__ */ jsxs2(FormControl2, { isRequired: true, children: [
|
||||||
|
/* @__PURE__ */ jsx3(FormLabel2, { children: texts.emailLabel }),
|
||||||
|
/* @__PURE__ */ jsx3(Input2, { name: "email", type: "email", placeholder: emailPlaceholder })
|
||||||
|
] }),
|
||||||
|
/* @__PURE__ */ jsx3(Text, { fontSize: "sm", color: "gray.600", children: helperText }),
|
||||||
|
/* @__PURE__ */ jsx3(Button2, { type: "submit", isLoading: loading, children: texts.submitLabel })
|
||||||
|
] }) })
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
function PasswordResetConfirmForm({
|
||||||
|
texts,
|
||||||
|
tokenState,
|
||||||
|
loading = false,
|
||||||
|
completedMode = null,
|
||||||
|
onSubmit
|
||||||
|
}) {
|
||||||
|
function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
void onSubmit({
|
||||||
|
password: String(form.get("password") ?? ""),
|
||||||
|
passwordConfirm: String(form.get("passwordConfirm") ?? "")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (tokenState.status === "loading") {
|
||||||
|
return /* @__PURE__ */ jsxs2(Stack2, { align: "center", py: 6, spacing: 3, children: [
|
||||||
|
/* @__PURE__ */ jsx3(Spinner2, {}),
|
||||||
|
/* @__PURE__ */ jsx3(Text, { color: "gray.600", children: texts.loadingLabel })
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
if (tokenState.status === "invalid") {
|
||||||
|
return /* @__PURE__ */ jsxs2(Alert2, { status: "error", borderRadius: "md", children: [
|
||||||
|
/* @__PURE__ */ jsx3(AlertIcon2, {}),
|
||||||
|
/* @__PURE__ */ jsx3(AlertDescription2, { children: tokenState.error || texts.invalidLinkLabel })
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
if (completedMode !== null) {
|
||||||
|
return /* @__PURE__ */ jsxs2(Alert2, { status: "success", borderRadius: "md", children: [
|
||||||
|
/* @__PURE__ */ jsx3(AlertIcon2, {}),
|
||||||
|
/* @__PURE__ */ jsx3(AlertDescription2, { children: completedMode === "create" ? texts.createSuccessLabel : texts.resetSuccessLabel })
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
return /* @__PURE__ */ jsxs2(Stack2, { spacing: 4, children: [
|
||||||
|
/* @__PURE__ */ jsx3(Text, { fontSize: "sm", color: "gray.600", children: tokenState.email }),
|
||||||
|
/* @__PURE__ */ jsx3("form", { onSubmit: handleSubmit, children: /* @__PURE__ */ jsxs2(Stack2, { spacing: 4, children: [
|
||||||
|
/* @__PURE__ */ jsxs2(FormControl2, { isRequired: true, children: [
|
||||||
|
/* @__PURE__ */ jsx3(FormLabel2, { children: texts.passwordLabel }),
|
||||||
|
/* @__PURE__ */ jsx3(Input2, { name: "password", type: "password", minLength: 8 })
|
||||||
|
] }),
|
||||||
|
/* @__PURE__ */ jsxs2(FormControl2, { isRequired: true, children: [
|
||||||
|
/* @__PURE__ */ jsx3(FormLabel2, { children: texts.passwordConfirmLabel }),
|
||||||
|
/* @__PURE__ */ jsx3(Input2, { name: "passwordConfirm", type: "password", minLength: 8 })
|
||||||
|
] }),
|
||||||
|
/* @__PURE__ */ jsx3(Button2, { type: "submit", isLoading: loading, children: tokenState.mode === "create" ? texts.createSubmitLabel : texts.resetSubmitLabel })
|
||||||
|
] }) })
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
export {
|
||||||
|
AuthGuard,
|
||||||
|
LoginForm,
|
||||||
|
PasswordResetConfirmForm,
|
||||||
|
PasswordResetRequestForm,
|
||||||
|
createAuthClient
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
1
dist/react/index.js.map
vendored
Normal file
1
dist/react/index.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
136
dist/server/index.d.ts
vendored
Normal file
136
dist/server/index.d.ts
vendored
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import * as qs from 'qs';
|
||||||
|
import * as express_serve_static_core from 'express-serve-static-core';
|
||||||
|
import * as express from 'express';
|
||||||
|
import { Request, RequestHandler, Express } from 'express';
|
||||||
|
import * as _auth_core_adapters from '@auth/core/adapters';
|
||||||
|
|
||||||
|
type CredentialUser = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
passwordHash: string | null;
|
||||||
|
};
|
||||||
|
type CreateAuthModuleOptions<TAuthUser> = {
|
||||||
|
prisma: any;
|
||||||
|
clientUrl: string;
|
||||||
|
sessionCookieName: string;
|
||||||
|
sessionCookieSecure: boolean;
|
||||||
|
authUrl?: string;
|
||||||
|
authDebug?: boolean;
|
||||||
|
authSecret?: string;
|
||||||
|
trustHost?: boolean;
|
||||||
|
extraSessionCookieNames?: string[];
|
||||||
|
signInPath?: string;
|
||||||
|
authenticatedRedirectPath?: string;
|
||||||
|
googleClientId?: string;
|
||||||
|
googleClientSecret?: string;
|
||||||
|
slackClientId?: string;
|
||||||
|
slackClientSecret?: string;
|
||||||
|
findCredentialsUserByEmail: (email: string) => Promise<CredentialUser | null>;
|
||||||
|
comparePassword: (password: string, passwordHash: string) => Promise<boolean>;
|
||||||
|
sessionUserSelect: Record<string, boolean>;
|
||||||
|
mapSessionUser: (user: any) => TAuthUser;
|
||||||
|
onSessionValidated?: (user: TAuthUser) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
declare function createAuthModule<TAuthUser>(options: CreateAuthModuleOptions<TAuthUser>): {
|
||||||
|
authConfig: {
|
||||||
|
adapter: _auth_core_adapters.Adapter;
|
||||||
|
trustHost: boolean;
|
||||||
|
debug: boolean;
|
||||||
|
logger: {
|
||||||
|
error(error: Error): void;
|
||||||
|
warn(code: string): void;
|
||||||
|
debug(message: string, metadata?: unknown): void;
|
||||||
|
} | undefined;
|
||||||
|
session: {
|
||||||
|
strategy: "database";
|
||||||
|
};
|
||||||
|
secret: string | undefined;
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: string;
|
||||||
|
options: {
|
||||||
|
httpOnly: boolean;
|
||||||
|
sameSite: "lax";
|
||||||
|
path: string;
|
||||||
|
secure: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
providers: any[];
|
||||||
|
pages: {
|
||||||
|
signIn: string;
|
||||||
|
};
|
||||||
|
callbacks: {
|
||||||
|
redirect: ({ url, baseUrl }: {
|
||||||
|
url: string;
|
||||||
|
baseUrl: string;
|
||||||
|
}) => Promise<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
authHandler: (req: Request, res: express.Response, next: express.NextFunction) => Promise<void>;
|
||||||
|
requireSession: RequestHandler<express_serve_static_core.ParamsDictionary, any, any, qs.ParsedQs, Record<string, any>>;
|
||||||
|
extractSessionToken: (cookieHeader: string | undefined) => string | null;
|
||||||
|
googleAuthEnabled: boolean;
|
||||||
|
slackAuthEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegisterAuthApiRoutesOptions = {
|
||||||
|
app: Express;
|
||||||
|
prisma: any;
|
||||||
|
authHandler: RequestHandler;
|
||||||
|
requireSession: RequestHandler;
|
||||||
|
extractSessionToken: (cookieHeader: string | undefined) => string | null;
|
||||||
|
providersAvailability: Record<string, boolean>;
|
||||||
|
sessionCookieName: string;
|
||||||
|
sessionCookieSecure: boolean;
|
||||||
|
extraCookieNamesToClear?: string[];
|
||||||
|
messages?: Partial<AuthRouteMessages>;
|
||||||
|
authBasePath?: string;
|
||||||
|
authApiBasePath?: string;
|
||||||
|
mePath?: string;
|
||||||
|
normalizeEmail?: (email: string) => string;
|
||||||
|
passwordHasher?: (password: string) => Promise<string>;
|
||||||
|
passwordComparator?: (password: string, passwordHash: string) => Promise<boolean>;
|
||||||
|
passwordReset?: {
|
||||||
|
enabled: boolean;
|
||||||
|
tokenTtlMs?: number;
|
||||||
|
identifierPrefix?: string;
|
||||||
|
buildResetUrl: (token: string) => string;
|
||||||
|
sendMessage: (input: {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
passwordHash: string | null;
|
||||||
|
};
|
||||||
|
resetUrl: string;
|
||||||
|
isPasswordCreation: boolean;
|
||||||
|
expiresAt: Date;
|
||||||
|
}) => Promise<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;
|
||||||
|
};
|
||||||
|
type AuthRouteMessages = {
|
||||||
|
invalidPayload: string;
|
||||||
|
emailAlreadyUsed: string;
|
||||||
|
accountNotFound: string;
|
||||||
|
externalAccountOnly: string;
|
||||||
|
invalidPassword: string;
|
||||||
|
passwordResetUnavailable: string;
|
||||||
|
invalidResetLink: string;
|
||||||
|
expiredResetLink: string;
|
||||||
|
};
|
||||||
|
declare function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): void;
|
||||||
|
|
||||||
|
export { createAuthModule, registerAuthApiRoutes };
|
||||||
489
dist/server/index.js
vendored
Normal file
489
dist/server/index.js
vendored
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
// server/module.ts
|
||||||
|
import { ExpressAuth } from "@auth/express";
|
||||||
|
import Credentials from "@auth/express/providers/credentials";
|
||||||
|
import Google from "@auth/express/providers/google";
|
||||||
|
import Slack from "@auth/express/providers/slack";
|
||||||
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||||
|
import { z } from "zod";
|
||||||
|
function parseBoolean(value, fallback) {
|
||||||
|
if (value === void 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (["1", "true", "yes", "on"].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (["0", "false", "no", "off"].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
function createAuthModule(options) {
|
||||||
|
const signInPath = options.signInPath ?? "/login";
|
||||||
|
const authenticatedRedirectPath = options.authenticatedRedirectPath ?? "/chat";
|
||||||
|
const googleAuthEnabled = Boolean(options.googleClientId && options.googleClientSecret);
|
||||||
|
const slackAuthEnabled = Boolean(options.slackClientId && options.slackClientSecret);
|
||||||
|
const providers = [
|
||||||
|
Credentials({
|
||||||
|
name: "Email et mot de passe",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" }
|
||||||
|
},
|
||||||
|
authorize: async (rawCredentials) => {
|
||||||
|
const parsed = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8)
|
||||||
|
}).safeParse(rawCredentials);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const user = await options.findCredentialsUserByEmail(parsed.data.email);
|
||||||
|
if (!user?.passwordHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const valid = await options.comparePassword(parsed.data.password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
image: user.image ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
if (googleAuthEnabled) {
|
||||||
|
providers.push(
|
||||||
|
Google({
|
||||||
|
clientId: options.googleClientId,
|
||||||
|
clientSecret: options.googleClientSecret,
|
||||||
|
allowDangerousEmailAccountLinking: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (slackAuthEnabled) {
|
||||||
|
providers.push(
|
||||||
|
Slack({
|
||||||
|
clientId: options.slackClientId,
|
||||||
|
clientSecret: options.slackClientSecret,
|
||||||
|
allowDangerousEmailAccountLinking: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const authConfig = {
|
||||||
|
adapter: PrismaAdapter(options.prisma),
|
||||||
|
trustHost: options.trustHost ?? parseBoolean(process.env.AUTH_TRUST_HOST, true),
|
||||||
|
debug: options.authDebug ?? parseBoolean(process.env.AUTH_DEBUG, false),
|
||||||
|
logger: options.authDebug ? {
|
||||||
|
error(error) {
|
||||||
|
console.error("[authjs:error]", error.name, error.message, error.cause ?? "");
|
||||||
|
},
|
||||||
|
warn(code) {
|
||||||
|
console.warn("[authjs:warn]", code);
|
||||||
|
},
|
||||||
|
debug(message, metadata) {
|
||||||
|
console.log("[authjs:debug]", message, metadata ?? "");
|
||||||
|
}
|
||||||
|
} : void 0,
|
||||||
|
session: { strategy: "database" },
|
||||||
|
secret: options.authSecret ?? process.env.AUTH_SECRET,
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: options.sessionCookieName,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
secure: options.sessionCookieSecure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
providers,
|
||||||
|
pages: {
|
||||||
|
signIn: signInPath
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
redirect: async ({ url, baseUrl }) => {
|
||||||
|
const clientOrigin = new URL(options.clientUrl).origin;
|
||||||
|
const successUrl = new URL(authenticatedRedirectPath, clientOrigin).toString();
|
||||||
|
const shouldForceChat = (pathname, searchParams) => {
|
||||||
|
if (pathname !== "/" && pathname !== signInPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !searchParams.has("error");
|
||||||
|
};
|
||||||
|
if (url.startsWith("/")) {
|
||||||
|
const relative = new URL(url, clientOrigin);
|
||||||
|
if (shouldForceChat(relative.pathname, relative.searchParams)) {
|
||||||
|
return successUrl;
|
||||||
|
}
|
||||||
|
return `${clientOrigin}${relative.pathname}${relative.search}${relative.hash}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const target = new URL(url);
|
||||||
|
const base = new URL(baseUrl);
|
||||||
|
if (target.origin === clientOrigin) {
|
||||||
|
if (shouldForceChat(target.pathname, target.searchParams)) {
|
||||||
|
return successUrl;
|
||||||
|
}
|
||||||
|
return target.toString();
|
||||||
|
}
|
||||||
|
if (target.origin === base.origin) {
|
||||||
|
if (shouldForceChat(target.pathname, target.searchParams)) {
|
||||||
|
return successUrl;
|
||||||
|
}
|
||||||
|
return `${clientOrigin}${target.pathname}${target.search}${target.hash}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return successUrl;
|
||||||
|
}
|
||||||
|
return successUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const authHandler = ExpressAuth(authConfig);
|
||||||
|
const requireSession = async (req, res, next) => {
|
||||||
|
const token = extractSessionToken(req.headers.cookie);
|
||||||
|
const authDebug = options.authDebug ?? parseBoolean(process.env.AUTH_DEBUG, false);
|
||||||
|
if (!token) {
|
||||||
|
const payload = { error: "Unauthorized" };
|
||||||
|
if (authDebug) {
|
||||||
|
payload.reason = "missing_session_cookie";
|
||||||
|
}
|
||||||
|
return res.status(401).json(payload);
|
||||||
|
}
|
||||||
|
const session = await options.prisma.session.findUnique({
|
||||||
|
where: { sessionToken: token },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: options.sessionUserSelect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!session || session.expires <= /* @__PURE__ */ new Date()) {
|
||||||
|
const payload = { error: "Unauthorized" };
|
||||||
|
if (authDebug) {
|
||||||
|
payload.reason = !session ? "session_not_found" : "session_expired";
|
||||||
|
}
|
||||||
|
return res.status(401).json(payload);
|
||||||
|
}
|
||||||
|
const authUser = options.mapSessionUser(session.user);
|
||||||
|
req.authUser = authUser;
|
||||||
|
await options.onSessionValidated?.(authUser);
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
const extractSessionToken = (cookieHeader) => {
|
||||||
|
if (!cookieHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cookies = cookieHeader.split(";").map((part) => part.trim()).map((part) => {
|
||||||
|
const index = part.indexOf("=");
|
||||||
|
if (index < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))];
|
||||||
|
}).filter((entry) => entry !== null);
|
||||||
|
const possibleNames = [
|
||||||
|
options.sessionCookieName,
|
||||||
|
...options.extraSessionCookieNames ?? [],
|
||||||
|
"__Secure-authjs.session-token",
|
||||||
|
"authjs.session-token",
|
||||||
|
"__Secure-next-auth.session-token",
|
||||||
|
"next-auth.session-token"
|
||||||
|
];
|
||||||
|
for (const name of possibleNames) {
|
||||||
|
const exact = cookies.find(([cookieName]) => cookieName === name);
|
||||||
|
if (exact) {
|
||||||
|
return exact[1];
|
||||||
|
}
|
||||||
|
const chunks = cookies.filter(([cookieName]) => cookieName.startsWith(`${name}.`)).map(([cookieName, value]) => {
|
||||||
|
const suffix = cookieName.slice(name.length + 1);
|
||||||
|
return [Number.parseInt(suffix, 10), value];
|
||||||
|
}).filter(([index]) => Number.isInteger(index)).sort((left, right) => left[0] - right[0]);
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
return chunks.map(([, value]) => value).join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
authConfig,
|
||||||
|
authHandler,
|
||||||
|
requireSession,
|
||||||
|
extractSessionToken,
|
||||||
|
googleAuthEnabled,
|
||||||
|
slackAuthEnabled
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// server/routes.ts
|
||||||
|
import { createHash, randomBytes } from "crypto";
|
||||||
|
import { z as z2 } from "zod";
|
||||||
|
var defaultNormalizeEmail = (email) => email.trim();
|
||||||
|
var defaultPasswordResetIdentifierPrefix = "password-reset:";
|
||||||
|
var defaultMessages = {
|
||||||
|
invalidPayload: "Invalid payload",
|
||||||
|
emailAlreadyUsed: "Email already used",
|
||||||
|
accountNotFound: "Account not found",
|
||||||
|
externalAccountOnly: "This account uses an external sign-in provider.",
|
||||||
|
invalidPassword: "Invalid password",
|
||||||
|
passwordResetUnavailable: "Email service is not configured.",
|
||||||
|
invalidResetLink: "Invalid reset link",
|
||||||
|
expiredResetLink: "Invalid or expired reset link"
|
||||||
|
};
|
||||||
|
function hashPasswordResetToken(token) {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
function buildPasswordResetIdentifier(prefix, userId) {
|
||||||
|
return `${prefix}${userId}`;
|
||||||
|
}
|
||||||
|
function registerAuthApiRoutes(options) {
|
||||||
|
const authBasePath = options.authBasePath ?? "/auth";
|
||||||
|
const authApiBasePath = options.authApiBasePath ?? "/api/auth";
|
||||||
|
const mePath = options.mePath ?? "/api/me";
|
||||||
|
const normalizeEmail = options.normalizeEmail ?? defaultNormalizeEmail;
|
||||||
|
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 messages = { ...defaultMessages, ...options.messages ?? {} };
|
||||||
|
const findUserByEmail = async (email) => {
|
||||||
|
const normalized = normalizeEmail(email);
|
||||||
|
const lowered = normalized.toLowerCase();
|
||||||
|
return options.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: lowered === normalized ? [{ email: normalized }] : [{ email: normalized }, { email: lowered }]
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
passwordHash: true,
|
||||||
|
emailVerified: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const getPasswordResetContext = async (rawToken) => {
|
||||||
|
const verificationToken = await options.prisma.verificationToken.findUnique({
|
||||||
|
where: { token: hashPasswordResetToken(rawToken) },
|
||||||
|
select: { identifier: true, expires: true }
|
||||||
|
});
|
||||||
|
if (!verificationToken || verificationToken.expires <= /* @__PURE__ */ new Date() || !verificationToken.identifier.startsWith(passwordResetIdentifierPrefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const userId = verificationToken.identifier.slice(passwordResetIdentifierPrefix.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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!user?.email) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { verificationToken, user };
|
||||||
|
};
|
||||||
|
options.app.use(authBasePath, options.authHandler);
|
||||||
|
options.app.get(`${authApiBasePath}/providers`, (_req, res) => {
|
||||||
|
res.json(options.providersAvailability);
|
||||||
|
});
|
||||||
|
options.app.post(`${authApiBasePath}/register`, async (req, res) => {
|
||||||
|
const parsed = z2.object({
|
||||||
|
name: z2.string().min(2).max(60),
|
||||||
|
email: z2.string().email(),
|
||||||
|
password: z2.string().min(8)
|
||||||
|
}).safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: messages.invalidPayload });
|
||||||
|
}
|
||||||
|
const email = normalizeEmail(parsed.data.email);
|
||||||
|
const exists = await findUserByEmail(email);
|
||||||
|
if (exists) {
|
||||||
|
return res.status(409).json({ error: messages.emailAlreadyUsed });
|
||||||
|
}
|
||||||
|
const passwordHash = await passwordHasher(parsed.data.password);
|
||||||
|
const created = await options.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: parsed.data.name,
|
||||||
|
email,
|
||||||
|
passwordHash
|
||||||
|
},
|
||||||
|
select: { id: true, email: true, name: true }
|
||||||
|
});
|
||||||
|
await options.onUserRegistered?.(created);
|
||||||
|
return res.status(201).json(created);
|
||||||
|
});
|
||||||
|
options.app.post(`${authApiBasePath}/login`, async (req, res) => {
|
||||||
|
const parsed = z2.object({
|
||||||
|
email: z2.string().email(),
|
||||||
|
password: z2.string().min(8)
|
||||||
|
}).safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: messages.invalidPayload });
|
||||||
|
}
|
||||||
|
const email = normalizeEmail(parsed.data.email);
|
||||||
|
const user = await findUserByEmail(email);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: messages.accountNotFound });
|
||||||
|
}
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
return res.status(400).json({ error: messages.externalAccountOnly });
|
||||||
|
}
|
||||||
|
const valid = await passwordComparator(parsed.data.password, user.passwordHash);
|
||||||
|
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);
|
||||||
|
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
|
||||||
|
});
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
options.app.post(`${authApiBasePath}/password-reset/request`, async (req, res) => {
|
||||||
|
const parsed = z2.object({
|
||||||
|
email: z2.string().email()
|
||||||
|
}).safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: messages.invalidPayload });
|
||||||
|
}
|
||||||
|
if (!options.passwordReset?.enabled) {
|
||||||
|
return res.status(503).json({ error: messages.passwordResetUnavailable });
|
||||||
|
}
|
||||||
|
const email = normalizeEmail(parsed.data.email);
|
||||||
|
const user = await findUserByEmail(email);
|
||||||
|
if (!user?.email) {
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
}
|
||||||
|
const rawToken = randomBytes(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);
|
||||||
|
const isPasswordCreation = !user.passwordHash;
|
||||||
|
await options.prisma.verificationToken.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ identifier }, { expires: { lt: /* @__PURE__ */ new Date() } }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await options.prisma.verificationToken.create({
|
||||||
|
data: {
|
||||||
|
identifier,
|
||||||
|
token: hashPasswordResetToken(rawToken),
|
||||||
|
expires: expiresAt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await options.passwordReset.sendMessage({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
passwordHash: user.passwordHash
|
||||||
|
},
|
||||||
|
resetUrl,
|
||||||
|
isPasswordCreation,
|
||||||
|
expiresAt
|
||||||
|
});
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
options.app.get(`${authApiBasePath}/password-reset/validate`, async (req, res) => {
|
||||||
|
if (!options.passwordReset?.enabled) {
|
||||||
|
return res.status(400).json({ error: messages.expiredResetLink });
|
||||||
|
}
|
||||||
|
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.invalidResetLink });
|
||||||
|
}
|
||||||
|
const context = await getPasswordResetContext(parsed.data.token);
|
||||||
|
if (!context) {
|
||||||
|
return res.status(400).json({ error: messages.expiredResetLink });
|
||||||
|
}
|
||||||
|
return res.status(200).json({
|
||||||
|
ok: true,
|
||||||
|
email: context.user.email,
|
||||||
|
mode: context.user.passwordHash ? "reset" : "create"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
options.app.post(`${authApiBasePath}/password-reset/confirm`, async (req, res) => {
|
||||||
|
if (!options.passwordReset?.enabled) {
|
||||||
|
return res.status(400).json({ error: messages.expiredResetLink });
|
||||||
|
}
|
||||||
|
const parsed = z2.object({
|
||||||
|
token: z2.string().min(1),
|
||||||
|
password: z2.string().min(8)
|
||||||
|
}).safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: messages.invalidPayload });
|
||||||
|
}
|
||||||
|
const context = await getPasswordResetContext(parsed.data.token);
|
||||||
|
if (!context) {
|
||||||
|
return res.status(400).json({ error: messages.expiredResetLink });
|
||||||
|
}
|
||||||
|
const passwordHash = await passwordHasher(parsed.data.password);
|
||||||
|
await options.prisma.$transaction([
|
||||||
|
options.prisma.verificationToken.deleteMany({
|
||||||
|
where: { identifier: context.verificationToken.identifier }
|
||||||
|
}),
|
||||||
|
options.prisma.session.deleteMany({
|
||||||
|
where: { userId: context.user.id }
|
||||||
|
}),
|
||||||
|
options.prisma.user.update({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
data: {
|
||||||
|
passwordHash,
|
||||||
|
emailVerified: context.user.emailVerified ?? /* @__PURE__ */ new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
await options.onPasswordResetConfirmed?.(context.user);
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
options.app.post(`${authApiBasePath}/logout`, async (req, res) => {
|
||||||
|
const token = options.extractSessionToken(req.headers.cookie);
|
||||||
|
if (token) {
|
||||||
|
await options.prisma.session.deleteMany({ where: { sessionToken: token } });
|
||||||
|
}
|
||||||
|
const cookieNamesToClear = [
|
||||||
|
options.sessionCookieName,
|
||||||
|
...options.extraCookieNamesToClear ?? [],
|
||||||
|
"authjs.session-token",
|
||||||
|
"__Secure-authjs.session-token",
|
||||||
|
"next-auth.session-token",
|
||||||
|
"__Secure-next-auth.session-token"
|
||||||
|
];
|
||||||
|
for (const cookieName of cookieNamesToClear) {
|
||||||
|
res.clearCookie(cookieName, { path: "/" });
|
||||||
|
}
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
options.app.get(mePath, options.requireSession, async (req, res) => {
|
||||||
|
res.json({ user: req.authUser });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export {
|
||||||
|
createAuthModule,
|
||||||
|
registerAuthApiRoutes
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
1
dist/server/index.js.map
vendored
Normal file
1
dist/server/index.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@packages/auth",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
"./react": {
|
||||||
|
"types": "./dist/react/index.d.ts",
|
||||||
|
"default": "./dist/react/index.js"
|
||||||
|
},
|
||||||
|
"./server": {
|
||||||
|
"types": "./dist/server/index.d.ts",
|
||||||
|
"default": "./dist/server/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup --config tsup.config.ts",
|
||||||
|
"dev": "tsup --config tsup.config.ts --watch"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@auth/express": "^0.12.1",
|
||||||
|
"@auth/prisma-adapter": "^2.9.1",
|
||||||
|
"@chakra-ui/react": "^2.10.7",
|
||||||
|
"@prisma/client": "^6.5.0",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-icons": "^5.6.0",
|
||||||
|
"react-router-dom": "^6.30.0",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
62
react/AuthGuard.tsx
Normal file
62
react/AuthGuard.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Center, Spinner } from "@chakra-ui/react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
|
type AuthGuardProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
fetchCurrentUser: () => Promise<unknown>;
|
||||||
|
redirectTo?: string;
|
||||||
|
loadingFallback?: React.ReactNode;
|
||||||
|
authenticatedWrapper?: (children: React.ReactNode) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthGuard({
|
||||||
|
children,
|
||||||
|
fetchCurrentUser,
|
||||||
|
redirectTo = "/login",
|
||||||
|
loadingFallback,
|
||||||
|
authenticatedWrapper
|
||||||
|
}: AuthGuardProps) {
|
||||||
|
const [state, setState] = useState<{ loading: boolean; authenticated: boolean }>({
|
||||||
|
loading: true,
|
||||||
|
authenticated: false
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
fetchCurrentUser()
|
||||||
|
.then(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setState({ loading: false, authenticated: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setState({ loading: false, authenticated: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fetchCurrentUser]);
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{loadingFallback ?? (
|
||||||
|
<Center h="var(--app-height)">
|
||||||
|
<Spinner size="xl" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.authenticated) {
|
||||||
|
return <Navigate to={redirectTo} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{authenticatedWrapper ? authenticatedWrapper(children) : children}</>;
|
||||||
|
}
|
||||||
163
react/LoginForm.tsx
Normal file
163
react/LoginForm.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import type { FormEvent, ReactNode } from "react";
|
||||||
|
import { Alert, AlertDescription, AlertIcon, Button, Center, FormControl, FormLabel, HStack, Icon, Input, Stack } from "@chakra-ui/react";
|
||||||
|
import { FcGoogle } from "react-icons/fc";
|
||||||
|
import type { AuthProviderAvailability, AuthProviderKey, AuthSubmitValues, LoginMode } from "./types";
|
||||||
|
|
||||||
|
type LoginFormTexts = {
|
||||||
|
nameLabel: string;
|
||||||
|
emailLabel: string;
|
||||||
|
passwordLabel: string;
|
||||||
|
passwordConfirmLabel: string;
|
||||||
|
submitRegisterLabel: string;
|
||||||
|
submitSignInLabel: string;
|
||||||
|
toggleToRegisterLabel: string;
|
||||||
|
toggleToSignInLabel: string;
|
||||||
|
forgotPasswordLabel: string;
|
||||||
|
googleLabel: string;
|
||||||
|
slackLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginFormProps = {
|
||||||
|
mode: LoginMode;
|
||||||
|
texts: LoginFormTexts;
|
||||||
|
onSubmit: (values: AuthSubmitValues) => void | Promise<void>;
|
||||||
|
onModeToggle: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
oauthLoadingProvider?: AuthProviderKey | null;
|
||||||
|
providers?: AuthProviderAvailability;
|
||||||
|
onOAuthSignIn?: (provider: AuthProviderKey) => void | Promise<void>;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
successMessage?: string | null;
|
||||||
|
footer?: ReactNode;
|
||||||
|
forgotPasswordLink?: ReactNode;
|
||||||
|
emailPlaceholder?: string;
|
||||||
|
namePlaceholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoginForm({
|
||||||
|
mode,
|
||||||
|
texts,
|
||||||
|
onSubmit,
|
||||||
|
onModeToggle,
|
||||||
|
loading = false,
|
||||||
|
oauthLoadingProvider = null,
|
||||||
|
providers,
|
||||||
|
onOAuthSignIn,
|
||||||
|
errorMessage,
|
||||||
|
successMessage,
|
||||||
|
footer,
|
||||||
|
forgotPasswordLink,
|
||||||
|
emailPlaceholder = "you@example.com",
|
||||||
|
namePlaceholder = "Jane Doe"
|
||||||
|
}: LoginFormProps) {
|
||||||
|
const registerMode = mode === "register";
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
void onSubmit({
|
||||||
|
name: String(form.get("name") ?? ""),
|
||||||
|
email: String(form.get("email") ?? ""),
|
||||||
|
password: String(form.get("password") ?? ""),
|
||||||
|
passwordConfirm: String(form.get("passwordConfirm") ?? "")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={5}>
|
||||||
|
{errorMessage ? (
|
||||||
|
<Alert status="error" borderRadius="md">
|
||||||
|
<AlertIcon />
|
||||||
|
<AlertDescription>{errorMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{successMessage ? (
|
||||||
|
<Alert status="success" borderRadius="md">
|
||||||
|
<AlertIcon />
|
||||||
|
<AlertDescription>{successMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
{registerMode ? (
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel>{texts.nameLabel}</FormLabel>
|
||||||
|
<Input name="name" placeholder={namePlaceholder} />
|
||||||
|
</FormControl>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel>{texts.emailLabel}</FormLabel>
|
||||||
|
<Input name="email" type="email" placeholder={emailPlaceholder} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel>{texts.passwordLabel}</FormLabel>
|
||||||
|
<Input name="password" type="password" minLength={8} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{!registerMode && forgotPasswordLink ? <Stack align="flex-end">{forgotPasswordLink}</Stack> : null}
|
||||||
|
|
||||||
|
{registerMode ? (
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel>{texts.passwordConfirmLabel}</FormLabel>
|
||||||
|
<Input name="passwordConfirm" type="password" minLength={8} />
|
||||||
|
</FormControl>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button type="submit" isLoading={loading}>
|
||||||
|
{registerMode ? texts.submitRegisterLabel : texts.submitSignInLabel}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{registerMode || !onOAuthSignIn || (!providers?.google && !providers?.slack) ? null : (
|
||||||
|
<HStack>
|
||||||
|
{providers.google ? (
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
bg="gray.200"
|
||||||
|
color="gray.900"
|
||||||
|
borderRadius="full"
|
||||||
|
justifyContent="flex-start"
|
||||||
|
h="56px"
|
||||||
|
px={3}
|
||||||
|
fontSize={{ base: "md", md: "lg" }}
|
||||||
|
fontWeight="semibold"
|
||||||
|
iconSpacing={4}
|
||||||
|
leftIcon={
|
||||||
|
<Center boxSize="40px" bg="white" borderRadius="full" boxShadow="sm">
|
||||||
|
<Icon as={FcGoogle} boxSize={6} />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
_hover={{ bg: "gray.300" }}
|
||||||
|
_active={{ bg: "gray.300" }}
|
||||||
|
isLoading={oauthLoadingProvider === "google"}
|
||||||
|
onClick={() => void onOAuthSignIn("google")}
|
||||||
|
>
|
||||||
|
{texts.googleLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{providers.slack ? (
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
variant="outline"
|
||||||
|
isLoading={oauthLoadingProvider === "slack"}
|
||||||
|
onClick={() => void onOAuthSignIn("slack")}
|
||||||
|
>
|
||||||
|
{texts.slackLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="ghost" onClick={onModeToggle}>
|
||||||
|
{registerMode ? texts.toggleToSignInLabel : texts.toggleToRegisterLabel}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{footer ?? null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
react/PasswordResetForms.tsx
Normal file
163
react/PasswordResetForms.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import type { FormEvent, ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertIcon,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
Stack,
|
||||||
|
Text
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import type { PasswordResetMode, PasswordResetTokenState } from "./types";
|
||||||
|
|
||||||
|
type PasswordResetRequestTexts = {
|
||||||
|
emailLabel: string;
|
||||||
|
submitLabel: string;
|
||||||
|
requestSentMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PasswordResetRequestFormProps = {
|
||||||
|
texts: PasswordResetRequestTexts;
|
||||||
|
helperText: ReactNode;
|
||||||
|
loading?: boolean;
|
||||||
|
requestSent?: boolean;
|
||||||
|
onSubmit: (values: { email: string }) => void | Promise<void>;
|
||||||
|
emailPlaceholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PasswordResetConfirmTexts = {
|
||||||
|
loadingLabel: string;
|
||||||
|
passwordLabel: string;
|
||||||
|
passwordConfirmLabel: string;
|
||||||
|
invalidLinkLabel: string;
|
||||||
|
resetSubmitLabel: string;
|
||||||
|
createSubmitLabel: string;
|
||||||
|
resetSuccessLabel: string;
|
||||||
|
createSuccessLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PasswordResetConfirmFormProps = {
|
||||||
|
texts: PasswordResetConfirmTexts;
|
||||||
|
tokenState: PasswordResetTokenState;
|
||||||
|
loading?: boolean;
|
||||||
|
completedMode?: PasswordResetMode | null;
|
||||||
|
onSubmit: (values: { password: string; passwordConfirm: string }) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PasswordResetRequestForm({
|
||||||
|
texts,
|
||||||
|
helperText,
|
||||||
|
loading = false,
|
||||||
|
requestSent = false,
|
||||||
|
onSubmit,
|
||||||
|
emailPlaceholder = "you@example.com"
|
||||||
|
}: PasswordResetRequestFormProps) {
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
void onSubmit({
|
||||||
|
email: String(form.get("email") ?? "")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={5}>
|
||||||
|
{requestSent ? (
|
||||||
|
<Alert status="success" borderRadius="md">
|
||||||
|
<AlertIcon />
|
||||||
|
<AlertDescription>{texts.requestSentMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel>{texts.emailLabel}</FormLabel>
|
||||||
|
<Input name="email" type="email" placeholder={emailPlaceholder} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{helperText}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button type="submit" isLoading={loading}>
|
||||||
|
{texts.submitLabel}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordResetConfirmForm({
|
||||||
|
texts,
|
||||||
|
tokenState,
|
||||||
|
loading = false,
|
||||||
|
completedMode = null,
|
||||||
|
onSubmit
|
||||||
|
}: PasswordResetConfirmFormProps) {
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
void onSubmit({
|
||||||
|
password: String(form.get("password") ?? ""),
|
||||||
|
passwordConfirm: String(form.get("passwordConfirm") ?? "")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenState.status === "loading") {
|
||||||
|
return (
|
||||||
|
<Stack align="center" py={6} spacing={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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedMode !== null) {
|
||||||
|
return (
|
||||||
|
<Alert status="success" borderRadius="md">
|
||||||
|
<AlertIcon />
|
||||||
|
<AlertDescription>{completedMode === "create" ? texts.createSuccessLabel : texts.resetSuccessLabel}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{tokenState.email}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<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" isLoading={loading}>
|
||||||
|
{tokenState.mode === "create" ? texts.createSubmitLabel : texts.resetSubmitLabel}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
react/client.ts
Normal file
177
react/client.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import type { AuthProviderAvailability, PasswordResetMode } from "./types";
|
||||||
|
|
||||||
|
type CreateAuthClientOptions = {
|
||||||
|
apiUrl: (path: string) => string;
|
||||||
|
authUrl?: (path: string) => string;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
credentials?: RequestCredentials;
|
||||||
|
defaultOAuthCallbackUrl?: string | (() => string);
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginInput = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegisterInput = LoginInput & {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PasswordResetValidationPayload = {
|
||||||
|
email?: string;
|
||||||
|
mode?: PasswordResetMode;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonErrorPayload = {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readJsonError(response: Response, fallback: string): Promise<Error> {
|
||||||
|
const payload = (await response.json().catch(() => null)) as JsonErrorPayload | null;
|
||||||
|
return new Error(payload?.error ?? fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthClient(options: CreateAuthClientOptions) {
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
const authUrl = options.authUrl ?? options.apiUrl;
|
||||||
|
const credentials = options.credentials ?? "include";
|
||||||
|
|
||||||
|
function resolveDefaultOAuthCallbackUrl(): string {
|
||||||
|
const configured = options.defaultOAuthCallbackUrl;
|
||||||
|
if (typeof configured === "function") {
|
||||||
|
return configured();
|
||||||
|
}
|
||||||
|
if (typeof configured === "string" && configured.trim().length > 0) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return `${window.location.origin}/chat`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
return fetchImpl(options.apiUrl(path), {
|
||||||
|
...init,
|
||||||
|
credentials,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers ?? {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getProviders(): Promise<AuthProviderAvailability> {
|
||||||
|
const response = await request("/api/auth/providers");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "providers_unavailable");
|
||||||
|
}
|
||||||
|
return (await response.json()) as AuthProviderAvailability;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCurrentUser<TUser>(): Promise<TUser> {
|
||||||
|
const response = await request("/api/me");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Unauthorized");
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as { user: TUser };
|
||||||
|
return payload.user;
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(input: RegisterInput): Promise<void> {
|
||||||
|
const response = await request("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Registration failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(input: LoginInput): Promise<void> {
|
||||||
|
const response = await request("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Sign in failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async requestPasswordReset(email: string): Promise<void> {
|
||||||
|
const response = await request("/api/auth/password-reset/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Password reset request failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async validatePasswordResetToken(token: string): Promise<{ email: string; mode: PasswordResetMode }> {
|
||||||
|
const response = await request(`/api/auth/password-reset/validate?token=${encodeURIComponent(token)}`, {
|
||||||
|
headers: {}
|
||||||
|
});
|
||||||
|
const payload = (await response.json().catch(() => null)) as PasswordResetValidationPayload | null;
|
||||||
|
if (!response.ok || !payload?.email || (payload.mode !== "reset" && payload.mode !== "create")) {
|
||||||
|
throw new Error(payload?.error ?? "Invalid reset link");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
email: payload.email,
|
||||||
|
mode: payload.mode
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmPasswordReset(input: { token: string; password: string }): Promise<void> {
|
||||||
|
const response = await request("/api/auth/password-reset/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Invalid reset link");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
const response = await request("/api/auth/logout", {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Logout failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async startOAuthSignIn(provider: string, callbackUrl = resolveDefaultOAuthCallbackUrl()): Promise<void> {
|
||||||
|
const response = await fetchImpl(authUrl("/auth/csrf"), {
|
||||||
|
credentials
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await readJsonError(response, "Sign in failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { csrfToken?: string };
|
||||||
|
if (!payload.csrfToken) {
|
||||||
|
throw new Error("Sign in failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.method = "POST";
|
||||||
|
form.action = authUrl(`/auth/signin/${provider}`);
|
||||||
|
form.style.display = "none";
|
||||||
|
|
||||||
|
const csrfInput = document.createElement("input");
|
||||||
|
csrfInput.type = "hidden";
|
||||||
|
csrfInput.name = "csrfToken";
|
||||||
|
csrfInput.value = payload.csrfToken;
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
|
||||||
|
const callbackInput = document.createElement("input");
|
||||||
|
callbackInput.type = "hidden";
|
||||||
|
callbackInput.name = "callbackUrl";
|
||||||
|
callbackInput.value = callbackUrl;
|
||||||
|
form.appendChild(callbackInput);
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
12
react/index.ts
Normal file
12
react/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export { AuthGuard } from "./AuthGuard";
|
||||||
|
export { createAuthClient } from "./client";
|
||||||
|
export { LoginForm } from "./LoginForm";
|
||||||
|
export { PasswordResetConfirmForm, PasswordResetRequestForm } from "./PasswordResetForms";
|
||||||
|
export type {
|
||||||
|
AuthProviderAvailability,
|
||||||
|
AuthProviderKey,
|
||||||
|
AuthSubmitValues,
|
||||||
|
LoginMode,
|
||||||
|
PasswordResetMode,
|
||||||
|
PasswordResetTokenState
|
||||||
|
} from "./types";
|
||||||
19
react/types.ts
Normal file
19
react/types.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type AuthProviderKey = "google" | "slack" | (string & {});
|
||||||
|
|
||||||
|
export type AuthProviderAvailability = Partial<Record<AuthProviderKey, boolean>>;
|
||||||
|
|
||||||
|
export type LoginMode = "signIn" | "register";
|
||||||
|
|
||||||
|
export type AuthSubmitValues = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
passwordConfirm: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PasswordResetMode = "reset" | "create";
|
||||||
|
|
||||||
|
export type PasswordResetTokenState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "invalid"; error: string }
|
||||||
|
| { status: "valid"; email: string; mode: PasswordResetMode };
|
||||||
2
server/index.ts
Normal file
2
server/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { createAuthModule } from "./module.js";
|
||||||
|
export { registerAuthApiRoutes } from "./routes.js";
|
||||||
293
server/module.ts
Normal file
293
server/module.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { ExpressAuth } from "@auth/express";
|
||||||
|
import Credentials from "@auth/express/providers/credentials";
|
||||||
|
import Google from "@auth/express/providers/google";
|
||||||
|
import Slack from "@auth/express/providers/slack";
|
||||||
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||||
|
import type { Request, RequestHandler } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
type CredentialUser = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
passwordHash: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateAuthModuleOptions<TAuthUser> = {
|
||||||
|
prisma: any;
|
||||||
|
clientUrl: string;
|
||||||
|
sessionCookieName: string;
|
||||||
|
sessionCookieSecure: boolean;
|
||||||
|
authUrl?: string;
|
||||||
|
authDebug?: boolean;
|
||||||
|
authSecret?: string;
|
||||||
|
trustHost?: boolean;
|
||||||
|
extraSessionCookieNames?: string[];
|
||||||
|
signInPath?: string;
|
||||||
|
authenticatedRedirectPath?: string;
|
||||||
|
googleClientId?: string;
|
||||||
|
googleClientSecret?: string;
|
||||||
|
slackClientId?: string;
|
||||||
|
slackClientSecret?: string;
|
||||||
|
findCredentialsUserByEmail: (email: string) => Promise<CredentialUser | null>;
|
||||||
|
comparePassword: (password: string, passwordHash: string) => Promise<boolean>;
|
||||||
|
sessionUserSelect: Record<string, boolean>;
|
||||||
|
mapSessionUser: (user: any) => TAuthUser;
|
||||||
|
onSessionValidated?: (user: TAuthUser) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (value === undefined) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (["1", "true", "yes", "on"].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (["0", "false", "no", "off"].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthModule<TAuthUser>(options: CreateAuthModuleOptions<TAuthUser>) {
|
||||||
|
const signInPath = options.signInPath ?? "/login";
|
||||||
|
const authenticatedRedirectPath = options.authenticatedRedirectPath ?? "/chat";
|
||||||
|
const googleAuthEnabled = Boolean(options.googleClientId && options.googleClientSecret);
|
||||||
|
const slackAuthEnabled = Boolean(options.slackClientId && options.slackClientSecret);
|
||||||
|
|
||||||
|
const providers: any[] = [
|
||||||
|
Credentials({
|
||||||
|
name: "Email et mot de passe",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" }
|
||||||
|
},
|
||||||
|
authorize: async (rawCredentials) => {
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8)
|
||||||
|
})
|
||||||
|
.safeParse(rawCredentials);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await options.findCredentialsUserByEmail(parsed.data.email);
|
||||||
|
if (!user?.passwordHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await options.comparePassword(parsed.data.password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
image: user.image ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
if (googleAuthEnabled) {
|
||||||
|
providers.push(
|
||||||
|
Google({
|
||||||
|
clientId: options.googleClientId!,
|
||||||
|
clientSecret: options.googleClientSecret!,
|
||||||
|
allowDangerousEmailAccountLinking: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slackAuthEnabled) {
|
||||||
|
providers.push(
|
||||||
|
Slack({
|
||||||
|
clientId: options.slackClientId!,
|
||||||
|
clientSecret: options.slackClientSecret!,
|
||||||
|
allowDangerousEmailAccountLinking: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authConfig = {
|
||||||
|
adapter: PrismaAdapter(options.prisma),
|
||||||
|
trustHost: options.trustHost ?? parseBoolean(process.env.AUTH_TRUST_HOST, true),
|
||||||
|
debug: options.authDebug ?? parseBoolean(process.env.AUTH_DEBUG, false),
|
||||||
|
logger: options.authDebug
|
||||||
|
? {
|
||||||
|
error(error: Error) {
|
||||||
|
console.error("[authjs:error]", error.name, error.message, error.cause ?? "");
|
||||||
|
},
|
||||||
|
warn(code: string) {
|
||||||
|
console.warn("[authjs:warn]", code);
|
||||||
|
},
|
||||||
|
debug(message: string, metadata?: unknown) {
|
||||||
|
console.log("[authjs:debug]", message, metadata ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
session: { strategy: "database" as const },
|
||||||
|
secret: options.authSecret ?? process.env.AUTH_SECRET,
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: options.sessionCookieName,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
path: "/",
|
||||||
|
secure: options.sessionCookieSecure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
providers,
|
||||||
|
pages: {
|
||||||
|
signIn: signInPath
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
redirect: async ({ url, baseUrl }: { url: string; baseUrl: string }) => {
|
||||||
|
const clientOrigin = new URL(options.clientUrl).origin;
|
||||||
|
const successUrl = new URL(authenticatedRedirectPath, clientOrigin).toString();
|
||||||
|
|
||||||
|
const shouldForceChat = (pathname: string, searchParams: URLSearchParams): boolean => {
|
||||||
|
if (pathname !== "/" && pathname !== signInPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !searchParams.has("error");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (url.startsWith("/")) {
|
||||||
|
const relative = new URL(url, clientOrigin);
|
||||||
|
if (shouldForceChat(relative.pathname, relative.searchParams)) {
|
||||||
|
return successUrl;
|
||||||
|
}
|
||||||
|
return `${clientOrigin}${relative.pathname}${relative.search}${relative.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = new URL(url);
|
||||||
|
const base = new URL(baseUrl);
|
||||||
|
|
||||||
|
if (target.origin === clientOrigin) {
|
||||||
|
if (shouldForceChat(target.pathname, target.searchParams)) {
|
||||||
|
return successUrl;
|
||||||
|
}
|
||||||
|
return target.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.origin === base.origin) {
|
||||||
|
if (shouldForceChat(target.pathname, target.searchParams)) {
|
||||||
|
return successUrl;
|
||||||
|
}
|
||||||
|
return `${clientOrigin}${target.pathname}${target.search}${target.hash}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return successUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return successUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const authHandler = ExpressAuth(authConfig);
|
||||||
|
|
||||||
|
const requireSession: RequestHandler = async (req, res, next) => {
|
||||||
|
const token = extractSessionToken(req.headers.cookie);
|
||||||
|
const authDebug = options.authDebug ?? parseBoolean(process.env.AUTH_DEBUG, false);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
const payload: { error: string; reason?: string } = { error: "Unauthorized" };
|
||||||
|
if (authDebug) {
|
||||||
|
payload.reason = "missing_session_cookie";
|
||||||
|
}
|
||||||
|
return res.status(401).json(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await options.prisma.session.findUnique({
|
||||||
|
where: { sessionToken: token },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: options.sessionUserSelect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session || session.expires <= new Date()) {
|
||||||
|
const payload: { error: string; reason?: string } = { error: "Unauthorized" };
|
||||||
|
if (authDebug) {
|
||||||
|
payload.reason = !session ? "session_not_found" : "session_expired";
|
||||||
|
}
|
||||||
|
return res.status(401).json(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUser = options.mapSessionUser(session.user);
|
||||||
|
(req as Request & { authUser?: TAuthUser }).authUser = authUser;
|
||||||
|
await options.onSessionValidated?.(authUser);
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractSessionToken = (cookieHeader: string | undefined): string | null => {
|
||||||
|
if (!cookieHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.map((part) => {
|
||||||
|
const index = part.indexOf("=");
|
||||||
|
if (index < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] as const;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is readonly [string, string] => entry !== null);
|
||||||
|
|
||||||
|
const possibleNames = [
|
||||||
|
options.sessionCookieName,
|
||||||
|
...(options.extraSessionCookieNames ?? []),
|
||||||
|
"__Secure-authjs.session-token",
|
||||||
|
"authjs.session-token",
|
||||||
|
"__Secure-next-auth.session-token",
|
||||||
|
"next-auth.session-token"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of possibleNames) {
|
||||||
|
const exact = cookies.find(([cookieName]) => cookieName === name);
|
||||||
|
if (exact) {
|
||||||
|
return exact[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = cookies
|
||||||
|
.filter(([cookieName]) => cookieName.startsWith(`${name}.`))
|
||||||
|
.map(([cookieName, value]) => {
|
||||||
|
const suffix = cookieName.slice(name.length + 1);
|
||||||
|
return [Number.parseInt(suffix, 10), value] as const;
|
||||||
|
})
|
||||||
|
.filter(([index]) => Number.isInteger(index))
|
||||||
|
.sort((left, right) => left[0] - right[0]);
|
||||||
|
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
return chunks.map(([, value]) => value).join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
authConfig,
|
||||||
|
authHandler,
|
||||||
|
requireSession,
|
||||||
|
extractSessionToken,
|
||||||
|
googleAuthEnabled,
|
||||||
|
slackAuthEnabled
|
||||||
|
};
|
||||||
|
}
|
||||||
381
server/routes.ts
Normal file
381
server/routes.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import type { Express, RequestHandler } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
type RegisterAuthApiRoutesOptions = {
|
||||||
|
app: Express;
|
||||||
|
prisma: any;
|
||||||
|
authHandler: RequestHandler;
|
||||||
|
requireSession: RequestHandler;
|
||||||
|
extractSessionToken: (cookieHeader: string | undefined) => string | null;
|
||||||
|
providersAvailability: Record<string, boolean>;
|
||||||
|
sessionCookieName: string;
|
||||||
|
sessionCookieSecure: boolean;
|
||||||
|
extraCookieNamesToClear?: string[];
|
||||||
|
messages?: Partial<AuthRouteMessages>;
|
||||||
|
authBasePath?: string;
|
||||||
|
authApiBasePath?: string;
|
||||||
|
mePath?: string;
|
||||||
|
normalizeEmail?: (email: string) => string;
|
||||||
|
passwordHasher?: (password: string) => Promise<string>;
|
||||||
|
passwordComparator?: (password: string, passwordHash: string) => Promise<boolean>;
|
||||||
|
passwordReset?: {
|
||||||
|
enabled: boolean;
|
||||||
|
tokenTtlMs?: number;
|
||||||
|
identifierPrefix?: string;
|
||||||
|
buildResetUrl: (token: string) => string;
|
||||||
|
sendMessage: (input: {
|
||||||
|
user: { id: string; email: string; name: string | null; passwordHash: string | null };
|
||||||
|
resetUrl: string;
|
||||||
|
isPasswordCreation: boolean;
|
||||||
|
expiresAt: Date;
|
||||||
|
}) => Promise<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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthRouteMessages = {
|
||||||
|
invalidPayload: string;
|
||||||
|
emailAlreadyUsed: string;
|
||||||
|
accountNotFound: string;
|
||||||
|
externalAccountOnly: string;
|
||||||
|
invalidPassword: string;
|
||||||
|
passwordResetUnavailable: string;
|
||||||
|
invalidResetLink: string;
|
||||||
|
expiredResetLink: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultNormalizeEmail = (email: string) => email.trim();
|
||||||
|
const defaultPasswordResetIdentifierPrefix = "password-reset:";
|
||||||
|
const defaultMessages: AuthRouteMessages = {
|
||||||
|
invalidPayload: "Invalid payload",
|
||||||
|
emailAlreadyUsed: "Email already used",
|
||||||
|
accountNotFound: "Account not found",
|
||||||
|
externalAccountOnly: "This account uses an external sign-in provider.",
|
||||||
|
invalidPassword: "Invalid password",
|
||||||
|
passwordResetUnavailable: "Email service is not configured.",
|
||||||
|
invalidResetLink: "Invalid reset link",
|
||||||
|
expiredResetLink: "Invalid or expired reset link"
|
||||||
|
};
|
||||||
|
|
||||||
|
function hashPasswordResetToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPasswordResetIdentifier(prefix: string, userId: string): string {
|
||||||
|
return `${prefix}${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): void {
|
||||||
|
const authBasePath = options.authBasePath ?? "/auth";
|
||||||
|
const authApiBasePath = options.authApiBasePath ?? "/api/auth";
|
||||||
|
const mePath = options.mePath ?? "/api/me";
|
||||||
|
const normalizeEmail = options.normalizeEmail ?? defaultNormalizeEmail;
|
||||||
|
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 messages = { ...defaultMessages, ...(options.messages ?? {}) };
|
||||||
|
|
||||||
|
const findUserByEmail = async (email: string) => {
|
||||||
|
const normalized = normalizeEmail(email);
|
||||||
|
const lowered = normalized.toLowerCase();
|
||||||
|
|
||||||
|
return options.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: lowered === normalized ? [{ email: normalized }] : [{ email: normalized }, { email: lowered }]
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
passwordHash: true,
|
||||||
|
emailVerified: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPasswordResetContext = async (
|
||||||
|
rawToken: string
|
||||||
|
): Promise<
|
||||||
|
| {
|
||||||
|
verificationToken: { identifier: string; expires: Date };
|
||||||
|
user: { id: string; email: string | null; name: string | null; passwordHash: string | null; emailVerified: Date | null };
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
> => {
|
||||||
|
const verificationToken = await options.prisma.verificationToken.findUnique({
|
||||||
|
where: { token: hashPasswordResetToken(rawToken) },
|
||||||
|
select: { identifier: true, expires: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!verificationToken ||
|
||||||
|
verificationToken.expires <= new Date() ||
|
||||||
|
!verificationToken.identifier.startsWith(passwordResetIdentifierPrefix)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = verificationToken.identifier.slice(passwordResetIdentifierPrefix.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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.email) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { verificationToken, user };
|
||||||
|
};
|
||||||
|
|
||||||
|
options.app.use(authBasePath, options.authHandler);
|
||||||
|
|
||||||
|
options.app.get(`${authApiBasePath}/providers`, (_req, res) => {
|
||||||
|
res.json(options.providersAvailability);
|
||||||
|
});
|
||||||
|
|
||||||
|
options.app.post(`${authApiBasePath}/register`, async (req, res) => {
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(2).max(60),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8)
|
||||||
|
})
|
||||||
|
.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: messages.invalidPayload });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = normalizeEmail(parsed.data.email);
|
||||||
|
const exists = await findUserByEmail(email);
|
||||||
|
if (exists) {
|
||||||
|
return res.status(409).json({ error: messages.emailAlreadyUsed });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await passwordHasher(parsed.data.password);
|
||||||
|
const created = await options.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: parsed.data.name,
|
||||||
|
email,
|
||||||
|
passwordHash
|
||||||
|
},
|
||||||
|
select: { id: true, email: true, name: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
await options.onUserRegistered?.(created);
|
||||||
|
return res.status(201).json(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
options.app.post(`${authApiBasePath}/login`, async (req, res) => {
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8)
|
||||||
|
})
|
||||||
|
.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: messages.invalidPayload });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = normalizeEmail(parsed.data.email);
|
||||||
|
const user = await findUserByEmail(email);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: messages.accountNotFound });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
return res.status(400).json({ error: messages.externalAccountOnly });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await passwordComparator(parsed.data.password, user.passwordHash);
|
||||||
|
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 * 1000);
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
options.app.post(`${authApiBasePath}/password-reset/request`, async (req, res) => {
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email()
|
||||||
|
})
|
||||||
|
.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: messages.invalidPayload });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.passwordReset?.enabled) {
|
||||||
|
return res.status(503).json({ error: messages.passwordResetUnavailable });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = normalizeEmail(parsed.data.email);
|
||||||
|
const user = await findUserByEmail(email);
|
||||||
|
if (!user?.email) {
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawToken = randomBytes(32).toString("hex");
|
||||||
|
const identifier = buildPasswordResetIdentifier(passwordResetIdentifierPrefix, user.id);
|
||||||
|
const expiresAt = new Date(Date.now() + (options.passwordReset.tokenTtlMs ?? 2 * 60 * 60 * 1000));
|
||||||
|
const resetUrl = options.passwordReset.buildResetUrl(rawToken);
|
||||||
|
const isPasswordCreation = !user.passwordHash;
|
||||||
|
|
||||||
|
await options.prisma.verificationToken.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ identifier }, { expires: { lt: new Date() } }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await options.prisma.verificationToken.create({
|
||||||
|
data: {
|
||||||
|
identifier,
|
||||||
|
token: hashPasswordResetToken(rawToken),
|
||||||
|
expires: expiresAt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await options.passwordReset.sendMessage({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
passwordHash: user.passwordHash
|
||||||
|
},
|
||||||
|
resetUrl,
|
||||||
|
isPasswordCreation,
|
||||||
|
expiresAt
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
options.app.get(`${authApiBasePath}/password-reset/validate`, async (req, res) => {
|
||||||
|
if (!options.passwordReset?.enabled) {
|
||||||
|
return res.status(400).json({ error: messages.expiredResetLink });
|
||||||
|
}
|
||||||
|
|
||||||
|
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.invalidResetLink });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await getPasswordResetContext(parsed.data.token);
|
||||||
|
if (!context) {
|
||||||
|
return res.status(400).json({ error: messages.expiredResetLink });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
ok: true,
|
||||||
|
email: context.user.email,
|
||||||
|
mode: context.user.passwordHash ? "reset" : "create"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
options.app.post(`${authApiBasePath}/password-reset/confirm`, async (req, res) => {
|
||||||
|
if (!options.passwordReset?.enabled) {
|
||||||
|
return res.status(400).json({ error: messages.expiredResetLink });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
password: z.string().min(8)
|
||||||
|
})
|
||||||
|
.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: messages.invalidPayload });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await getPasswordResetContext(parsed.data.token);
|
||||||
|
if (!context) {
|
||||||
|
return res.status(400).json({ error: messages.expiredResetLink });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await passwordHasher(parsed.data.password);
|
||||||
|
await options.prisma.$transaction([
|
||||||
|
options.prisma.verificationToken.deleteMany({
|
||||||
|
where: { identifier: context.verificationToken.identifier }
|
||||||
|
}),
|
||||||
|
options.prisma.session.deleteMany({
|
||||||
|
where: { userId: context.user.id }
|
||||||
|
}),
|
||||||
|
options.prisma.user.update({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
data: {
|
||||||
|
passwordHash,
|
||||||
|
emailVerified: context.user.emailVerified ?? new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
await options.onPasswordResetConfirmed?.(context.user);
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
options.app.post(`${authApiBasePath}/logout`, async (req, res) => {
|
||||||
|
const token = options.extractSessionToken(req.headers.cookie);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
await options.prisma.session.deleteMany({ where: { sessionToken: token } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieNamesToClear = [
|
||||||
|
options.sessionCookieName,
|
||||||
|
...(options.extraCookieNamesToClear ?? []),
|
||||||
|
"authjs.session-token",
|
||||||
|
"__Secure-authjs.session-token",
|
||||||
|
"next-auth.session-token",
|
||||||
|
"__Secure-next-auth.session-token"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const cookieName of cookieNamesToClear) {
|
||||||
|
res.clearCookie(cookieName, { path: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
options.app.get(mePath, options.requireSession, async (req, res) => {
|
||||||
|
res.json({ user: (req as { authUser?: unknown }).authUser });
|
||||||
|
});
|
||||||
|
}
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"emitDeclarationOnly": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["react", "server"]
|
||||||
|
}
|
||||||
26
tsup.config.ts
Normal file
26
tsup.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
tsconfig: "tsconfig.json",
|
||||||
|
entry: {
|
||||||
|
"react/index": "react/index.ts",
|
||||||
|
"server/index": "server/index.ts"
|
||||||
|
},
|
||||||
|
format: ["esm"],
|
||||||
|
dts: true,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
outDir: "dist",
|
||||||
|
external: [
|
||||||
|
"@auth/express",
|
||||||
|
"@auth/prisma-adapter",
|
||||||
|
"@chakra-ui/react",
|
||||||
|
"@prisma/client",
|
||||||
|
"express",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-icons",
|
||||||
|
"react-router-dom",
|
||||||
|
"zod"
|
||||||
|
]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user