This commit is contained in:
 Илья Шалонин 2025-10-29 13:43:43 +05:00
commit 8538ab969a
434 changed files with 44051 additions and 0 deletions

272
packages/auth/auth.ts Normal file
View file

@ -0,0 +1,272 @@
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;

3
packages/auth/biome.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": ["../../biome.json"]
}

25
packages/auth/client.ts Normal file
View file

@ -0,0 +1,25 @@
import {
adminClient,
inferAdditionalFields,
magicLinkClient,
organizationClient,
passkeyClient,
twoFactorClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import type { auth } from ".";
export const authClient = createAuthClient({
plugins: [
inferAdditionalFields<typeof auth>(),
magicLinkClient(),
organizationClient(),
adminClient(),
passkeyClient(),
twoFactorClient(),
],
});
export type AuthClientErrorCodes = typeof authClient.$ERROR_CODES & {
INVALID_INVITATION: string;
};

1
packages/auth/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./auth";

View file

@ -0,0 +1,18 @@
import type { ActiveOrganization } from "../auth";
export function isOrganizationAdmin(
organization?: ActiveOrganization | null,
user?: {
id: string;
role?: string | null;
} | null,
) {
const userOrganizationRole = organization?.members.find(
(member) => member.userId === user?.id,
)?.role;
return (
["owner", "admin"].includes(userOrganizationRole ?? "") ||
user?.role === "admin"
);
}

View file

@ -0,0 +1,34 @@
import { getOrganizationWithPurchasesAndMembersCount } from "@repo/database";
import { logger } from "@repo/logs";
import { setSubscriptionSeats } from "@repo/payments";
export async function updateSeatsInOrganizationSubscription(
organizationId: string,
) {
const organization =
await getOrganizationWithPurchasesAndMembersCount(organizationId);
if (!organization?.purchases.length) {
return;
}
const activeSubscription = organization.purchases.find(
(purchase) => purchase.type === "SUBSCRIPTION",
);
if (!activeSubscription?.subscriptionId) {
return;
}
try {
await setSubscriptionSeats({
id: activeSubscription.subscriptionId,
seats: organization.membersCount,
});
} catch (error) {
logger.error("Could not update seats in organization subscription", {
organizationId,
error,
});
}
}

View file

@ -0,0 +1,27 @@
{
"dependencies": {
"@repo/config": "workspace:*",
"@repo/database": "workspace:*",
"@repo/i18n": "workspace:*",
"@repo/logs": "workspace:*",
"@repo/mail": "workspace:*",
"@repo/payments": "workspace:*",
"@repo/utils": "workspace:*",
"better-auth": "1.2.8",
"cookie": "^1.0.2",
"next": "15.3.3"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@repo/tsconfig": "workspace:*",
"@types/node": "^22.15.30"
},
"main": "./index.ts",
"name": "@repo/auth",
"scripts": {
"migrate": "dotenv -e ../../.env.local -- pnpm dlx @better-auth/cli@latest generate --config ./auth.ts --output ../database/prisma/schema.prisma",
"type-check": "tsc --noEmit"
},
"types": "./**/.tsx",
"version": "0.0.0"
}

View file

@ -0,0 +1,39 @@
import { config } from "@repo/config";
import { getPendingInvitationByEmail } from "@repo/database";
import type { BetterAuthPlugin } from "better-auth";
import { APIError } from "better-auth/api";
import { createAuthMiddleware } from "better-auth/plugins";
export const invitationOnlyPlugin = () =>
({
id: "invitationOnlyPlugin",
hooks: {
before: [
{
matcher: (context) =>
context.path.startsWith("/sign-up/email"),
handler: createAuthMiddleware(async (ctx) => {
if (config.auth.enableSignup) {
return;
}
const { email } = ctx.body;
// check if there is an invitation for the email
const hasInvitation =
await getPendingInvitationByEmail(email);
if (!hasInvitation) {
throw new APIError("BAD_REQUEST", {
code: "INVALID_INVITATION",
message: "No invitation found for this email",
});
}
}),
},
],
},
$ERROR_CODES: {
INVALID_INVITATION: "No invitation found for this email",
},
}) satisfies BetterAuthPlugin;

View file

@ -0,0 +1,5 @@
{
"extends": "@repo/tsconfig/base.json",
"include": ["**/*.ts"],
"exclude": ["dist", "build", "node_modules"]
}