first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
272
packages/auth/auth.ts
Normal file
272
packages/auth/auth.ts
Normal 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
3
packages/auth/biome.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
25
packages/auth/client.ts
Normal file
25
packages/auth/client.ts
Normal 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
1
packages/auth/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./auth";
|
||||
18
packages/auth/lib/helper.ts
Normal file
18
packages/auth/lib/helper.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
34
packages/auth/lib/organization.ts
Normal file
34
packages/auth/lib/organization.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
27
packages/auth/package.json
Normal file
27
packages/auth/package.json
Normal 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"
|
||||
}
|
||||
39
packages/auth/plugins/invitation-only/index.ts
Normal file
39
packages/auth/plugins/invitation-only/index.ts
Normal 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;
|
||||
5
packages/auth/tsconfig.json
Normal file
5
packages/auth/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "@repo/tsconfig/base.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue