273 lines
6.3 KiB
TypeScript
273 lines
6.3 KiB
TypeScript
|
|
import { config } from "@repo/config";
|
||
|
|
import {
|
||
|
|
db,
|
||
|
|
getInvitationById,
|
||
|
|
getPurchasesByOrganizationId,
|
||
|
|
getPurchasesByUserId,
|
||
|
|
} from "@repo/database";
|
||
|
|
import { getUserByEmail } from "@repo/database";
|
||
|
|
import type { Locale } from "@repo/i18n";
|
||
|
|
import { logger } from "@repo/logs";
|
||
|
|
import { sendEmail } from "@repo/mail";
|
||
|
|
import { cancelSubscription } from "@repo/payments";
|
||
|
|
import { getBaseUrl } from "@repo/utils";
|
||
|
|
import { betterAuth } from "better-auth";
|
||
|
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||
|
|
import {
|
||
|
|
admin,
|
||
|
|
createAuthMiddleware,
|
||
|
|
magicLink,
|
||
|
|
openAPI,
|
||
|
|
organization,
|
||
|
|
twoFactor,
|
||
|
|
username,
|
||
|
|
} from "better-auth/plugins";
|
||
|
|
import { passkey } from "better-auth/plugins/passkey";
|
||
|
|
import { parse as parseCookies } from "cookie";
|
||
|
|
import { updateSeatsInOrganizationSubscription } from "./lib/organization";
|
||
|
|
import { invitationOnlyPlugin } from "./plugins/invitation-only";
|
||
|
|
|
||
|
|
const getLocaleFromRequest = (request?: Request) => {
|
||
|
|
const cookies = parseCookies(request?.headers.get("cookie") ?? "");
|
||
|
|
return (
|
||
|
|
(cookies[config.i18n.localeCookieName] as Locale) ??
|
||
|
|
config.i18n.defaultLocale
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
const appUrl = getBaseUrl();
|
||
|
|
|
||
|
|
export const auth = betterAuth({
|
||
|
|
baseURL: appUrl,
|
||
|
|
trustedOrigins: [appUrl],
|
||
|
|
appName: config.appName,
|
||
|
|
database: prismaAdapter(db, {
|
||
|
|
provider: "postgresql",
|
||
|
|
}),
|
||
|
|
advanced: {
|
||
|
|
database: {
|
||
|
|
generateId: false,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
session: {
|
||
|
|
expiresIn: config.auth.sessionCookieMaxAge,
|
||
|
|
freshAge: 0,
|
||
|
|
},
|
||
|
|
account: {
|
||
|
|
accountLinking: {
|
||
|
|
enabled: true,
|
||
|
|
trustedProviders: ["vk", "github"],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
hooks: {
|
||
|
|
after: createAuthMiddleware(async (ctx) => {
|
||
|
|
if (ctx.path.startsWith("/organization/accept-invitation")) {
|
||
|
|
const { invitationId } = ctx.body;
|
||
|
|
|
||
|
|
if (!invitationId) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const invitation = await getInvitationById(invitationId);
|
||
|
|
|
||
|
|
if (!invitation) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await updateSeatsInOrganizationSubscription(
|
||
|
|
invitation.organizationId,
|
||
|
|
);
|
||
|
|
} else if (ctx.path.startsWith("/organization/remove-member")) {
|
||
|
|
const { organizationId } = ctx.body;
|
||
|
|
|
||
|
|
if (!organizationId) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await updateSeatsInOrganizationSubscription(organizationId);
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
before: createAuthMiddleware(async (ctx) => {
|
||
|
|
if (
|
||
|
|
ctx.path.startsWith("/delete-user") ||
|
||
|
|
ctx.path.startsWith("/organization/delete")
|
||
|
|
) {
|
||
|
|
const userId = ctx.context.session?.session.userId;
|
||
|
|
const { organizationId } = ctx.body;
|
||
|
|
|
||
|
|
if (userId || organizationId) {
|
||
|
|
const purchases = organizationId
|
||
|
|
? await getPurchasesByOrganizationId(organizationId)
|
||
|
|
: await getPurchasesByUserId(userId!);
|
||
|
|
const subscriptions = purchases.filter(
|
||
|
|
(purchase) =>
|
||
|
|
purchase.type === "SUBSCRIPTION" &&
|
||
|
|
purchase.subscriptionId !== null,
|
||
|
|
);
|
||
|
|
|
||
|
|
if (subscriptions.length > 0) {
|
||
|
|
for (const subscription of subscriptions) {
|
||
|
|
await cancelSubscription(
|
||
|
|
subscription.subscriptionId!,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
user: {
|
||
|
|
additionalFields: {
|
||
|
|
onboardingComplete: {
|
||
|
|
type: "boolean",
|
||
|
|
required: false,
|
||
|
|
},
|
||
|
|
locale: {
|
||
|
|
type: "string",
|
||
|
|
required: false,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
deleteUser: {
|
||
|
|
enabled: true,
|
||
|
|
},
|
||
|
|
changeEmail: {
|
||
|
|
enabled: true,
|
||
|
|
sendChangeEmailVerification: async (
|
||
|
|
{ user: { email, name }, url },
|
||
|
|
request,
|
||
|
|
) => {
|
||
|
|
const locale = getLocaleFromRequest(request);
|
||
|
|
await sendEmail({
|
||
|
|
to: email,
|
||
|
|
templateId: "emailVerification",
|
||
|
|
context: {
|
||
|
|
url,
|
||
|
|
name,
|
||
|
|
},
|
||
|
|
locale,
|
||
|
|
});
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
emailAndPassword: {
|
||
|
|
enabled: true,
|
||
|
|
// If signup is disabled, the only way to sign up is via an invitation. So in this case we can auto sign in the user, as the email is already verified by the invitation.
|
||
|
|
// If signup is enabled, we can't auto sign in the user, as the email is not verified yet.
|
||
|
|
autoSignIn: !config.auth.enableSignup,
|
||
|
|
requireEmailVerification: config.auth.enableSignup,
|
||
|
|
sendResetPassword: async ({ user, url }, request) => {
|
||
|
|
const locale = getLocaleFromRequest(request);
|
||
|
|
await sendEmail({
|
||
|
|
to: user.email,
|
||
|
|
templateId: "forgotPassword",
|
||
|
|
context: {
|
||
|
|
url,
|
||
|
|
name: user.name,
|
||
|
|
},
|
||
|
|
locale,
|
||
|
|
});
|
||
|
|
},
|
||
|
|
},
|
||
|
|
emailVerification: {
|
||
|
|
sendOnSignUp: config.auth.enableSignup,
|
||
|
|
sendVerificationEmail: async (
|
||
|
|
{ user: { email, name }, url },
|
||
|
|
request,
|
||
|
|
) => {
|
||
|
|
const locale = getLocaleFromRequest(request);
|
||
|
|
await sendEmail({
|
||
|
|
to: email,
|
||
|
|
templateId: "emailVerification",
|
||
|
|
context: {
|
||
|
|
url,
|
||
|
|
name,
|
||
|
|
},
|
||
|
|
locale,
|
||
|
|
});
|
||
|
|
},
|
||
|
|
},
|
||
|
|
socialProviders: {
|
||
|
|
vk: {
|
||
|
|
clientId: process.env.VK_CLIENT_ID as string,
|
||
|
|
clientSecret: process.env.VK_CLIENT_SECRET as string,
|
||
|
|
scope: ["email"],
|
||
|
|
},
|
||
|
|
github: {
|
||
|
|
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||
|
|
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
|
||
|
|
scope: ["user:email"],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
plugins: [
|
||
|
|
username(),
|
||
|
|
admin(),
|
||
|
|
passkey(),
|
||
|
|
magicLink({
|
||
|
|
disableSignUp: true,
|
||
|
|
sendMagicLink: async ({ email, url }, request) => {
|
||
|
|
const locale = getLocaleFromRequest(request);
|
||
|
|
await sendEmail({
|
||
|
|
to: email,
|
||
|
|
templateId: "magicLink",
|
||
|
|
context: {
|
||
|
|
url,
|
||
|
|
},
|
||
|
|
locale,
|
||
|
|
});
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
organization({
|
||
|
|
sendInvitationEmail: async (
|
||
|
|
{ email, id, organization },
|
||
|
|
request,
|
||
|
|
) => {
|
||
|
|
const locale = getLocaleFromRequest(request);
|
||
|
|
const existingUser = await getUserByEmail(email);
|
||
|
|
|
||
|
|
const url = new URL(
|
||
|
|
existingUser ? "/auth/login" : "/auth/signup",
|
||
|
|
getBaseUrl(),
|
||
|
|
);
|
||
|
|
|
||
|
|
url.searchParams.set("invitationId", id);
|
||
|
|
url.searchParams.set("email", email);
|
||
|
|
|
||
|
|
await sendEmail({
|
||
|
|
to: email,
|
||
|
|
templateId: "organizationInvitation",
|
||
|
|
locale,
|
||
|
|
context: {
|
||
|
|
organizationName: organization.name,
|
||
|
|
url: url.toString(),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
openAPI(),
|
||
|
|
invitationOnlyPlugin(),
|
||
|
|
twoFactor(),
|
||
|
|
],
|
||
|
|
onAPIError: {
|
||
|
|
onError(error, ctx) {
|
||
|
|
logger.error(error, { ctx });
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
export * from "./lib/organization";
|
||
|
|
|
||
|
|
export type Session = typeof auth.$Infer.Session;
|
||
|
|
|
||
|
|
export type ActiveOrganization = NonNullable<
|
||
|
|
Awaited<ReturnType<typeof auth.api.getFullOrganization>>
|
||
|
|
>;
|
||
|
|
|
||
|
|
export type Organization = typeof auth.$Infer.Organization;
|
||
|
|
|
||
|
|
export type OrganizationMemberRole =
|
||
|
|
ActiveOrganization["members"][number]["role"];
|
||
|
|
|
||
|
|
export type OrganizationInvitationStatus = typeof auth.$Infer.Invitation.status;
|
||
|
|
|
||
|
|
export type OrganizationMetadata = Record<string, unknown> | undefined;
|