feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
39
packages/auth/src/client/mobile.ts
Normal file
39
packages/auth/src/client/mobile.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { expoClient } from "@better-auth/expo/client";
|
||||
import { lastLoginMethodClient } from "@better-auth/expo/plugins";
|
||||
import {
|
||||
magicLinkClient,
|
||||
twoFactorClient,
|
||||
anonymousClient,
|
||||
adminClient,
|
||||
organizationClient,
|
||||
inferAdditionalFields,
|
||||
} from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
import type { AuthMobileClientOptions } from "..";
|
||||
import type { auth } from "../server";
|
||||
import type { AuthClientOptions } from "../types";
|
||||
import type { LastLoginMethodClientConfig } from "@better-auth/expo/plugins";
|
||||
|
||||
export const createClient = ({
|
||||
mobile,
|
||||
lastLoginMethod,
|
||||
...options
|
||||
}: AuthClientOptions & {
|
||||
mobile: AuthMobileClientOptions;
|
||||
lastLoginMethod: LastLoginMethodClientConfig;
|
||||
}) =>
|
||||
createAuthClient({
|
||||
...options,
|
||||
plugins: [
|
||||
...(options.plugins ?? []),
|
||||
anonymousClient(),
|
||||
magicLinkClient(),
|
||||
twoFactorClient(),
|
||||
adminClient(),
|
||||
organizationClient(),
|
||||
lastLoginMethodClient(lastLoginMethod),
|
||||
inferAdditionalFields<typeof auth>(),
|
||||
expoClient(mobile),
|
||||
],
|
||||
});
|
||||
30
packages/auth/src/client/web.ts
Normal file
30
packages/auth/src/client/web.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { passkeyClient } from "@better-auth/passkey/client";
|
||||
import {
|
||||
magicLinkClient,
|
||||
twoFactorClient,
|
||||
anonymousClient,
|
||||
adminClient,
|
||||
organizationClient,
|
||||
inferAdditionalFields,
|
||||
lastLoginMethodClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
import type { auth } from "../server";
|
||||
import type { AuthClientOptions } from "../types";
|
||||
|
||||
export const createClient = (options?: AuthClientOptions) =>
|
||||
createAuthClient({
|
||||
...options,
|
||||
plugins: [
|
||||
...(options?.plugins ?? []),
|
||||
passkeyClient(),
|
||||
anonymousClient(),
|
||||
magicLinkClient(),
|
||||
twoFactorClient(),
|
||||
adminClient(),
|
||||
organizationClient(),
|
||||
lastLoginMethodClient(),
|
||||
inferAdditionalFields<typeof auth>(),
|
||||
],
|
||||
});
|
||||
34
packages/auth/src/env.ts
Normal file
34
packages/auth/src/env.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig, NodeEnv } from "@turbostarter/shared/constants";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "auth",
|
||||
server: {
|
||||
BETTER_AUTH_SECRET: z.string().optional(),
|
||||
|
||||
APPLE_CLIENT_ID: z.string().optional().default(""),
|
||||
APPLE_CLIENT_SECRET: z.string().optional().default(""),
|
||||
APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional().default(""),
|
||||
GOOGLE_CLIENT_ID: z.string().optional().default(""),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional().default(""),
|
||||
GITHUB_CLIENT_ID: z.string().optional().default(""),
|
||||
GITHUB_CLIENT_SECRET: z.string().optional().default(""),
|
||||
|
||||
FREE_TIER_CREDITS: z.coerce.number().optional().default(100),
|
||||
|
||||
SEED_EMAIL: z.email().optional().default("me@turbostarter.dev"),
|
||||
SEED_PASSWORD: z.string().optional().default("Pa$$w0rd"),
|
||||
},
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
shared: {
|
||||
NODE_ENV: z.enum(NodeEnv).default(NodeEnv.DEVELOPMENT),
|
||||
},
|
||||
});
|
||||
3
packages/auth/src/index.ts
Normal file
3
packages/auth/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./lib/utils";
|
||||
export * from "./lib/schema";
|
||||
150
packages/auth/src/lib/schema.ts
Normal file
150
packages/auth/src/lib/schema.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { MemberRole, UserRole } from "../types";
|
||||
|
||||
const emailSchema = z.object({
|
||||
email: z.email().max(254),
|
||||
});
|
||||
|
||||
const password = z.string().min(8);
|
||||
const passwordSchema = z.object({
|
||||
password,
|
||||
});
|
||||
|
||||
const otpSchema = z.object({
|
||||
code: z.string().min(6).max(6),
|
||||
});
|
||||
|
||||
const backupCodeSchema = z.object({
|
||||
code: z.string().min(11).max(11),
|
||||
});
|
||||
|
||||
const trustDeviceSchema = z.object({
|
||||
trustDevice: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
name: z.string().min(2).max(32).optional(),
|
||||
role: z.enum(UserRole).optional(),
|
||||
image: z.url().optional(),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
...passwordSchema.shape,
|
||||
newPassword: password,
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
...emailSchema.shape,
|
||||
...passwordSchema.shape,
|
||||
});
|
||||
|
||||
const passwordLoginSchema = z.object({
|
||||
...emailSchema.shape,
|
||||
...passwordSchema.shape,
|
||||
rememberMe: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
const magicLinkLoginSchema = emailSchema;
|
||||
const forgotPasswordSchema = emailSchema;
|
||||
const updatePasswordSchema = passwordSchema;
|
||||
|
||||
const otpVerificationSchema = z.object({
|
||||
...otpSchema.shape,
|
||||
...trustDeviceSchema.shape,
|
||||
});
|
||||
|
||||
const backupCodeVerificationSchema = z.object({
|
||||
...backupCodeSchema.shape,
|
||||
...trustDeviceSchema.shape,
|
||||
});
|
||||
|
||||
const createOrganizationSchema = z.object({
|
||||
name: z.string().min(2).max(32),
|
||||
});
|
||||
|
||||
const updateOrganizationSchema = z.object({
|
||||
name: z.string().min(2).max(32).optional(),
|
||||
slug: z.string().optional(),
|
||||
logo: z.string().optional(),
|
||||
});
|
||||
|
||||
const inviteMemberSchema = z.object({
|
||||
email: z.email().max(254),
|
||||
role: z.enum(MemberRole),
|
||||
});
|
||||
|
||||
const updateMemberSchema = z.object({
|
||||
role: z.enum(MemberRole).optional(),
|
||||
});
|
||||
|
||||
const banUserSchema = z.object({
|
||||
reason: z.string().min(1).max(1000).optional(),
|
||||
expiresIn: z
|
||||
.date()
|
||||
.optional()
|
||||
.refine((date) => !date || date > new Date()),
|
||||
});
|
||||
|
||||
type EmailPayload = z.infer<typeof emailSchema>;
|
||||
type PasswordLoginPayload = z.infer<typeof passwordLoginSchema>;
|
||||
type MagicLinkLoginPayload = z.infer<typeof magicLinkLoginSchema>;
|
||||
type RegisterPayload = z.infer<typeof registerSchema>;
|
||||
type ForgotPasswordPayload = z.infer<typeof forgotPasswordSchema>;
|
||||
type UpdatePasswordPayload = z.infer<typeof updatePasswordSchema>;
|
||||
type PasswordPayload = z.infer<typeof passwordSchema>;
|
||||
type UpdateUserPayload = z.infer<typeof updateUserSchema>;
|
||||
type ChangePasswordPayload = z.infer<typeof changePasswordSchema>;
|
||||
type OtpPayload = z.infer<typeof otpSchema>;
|
||||
type OtpVerificationPayload = z.infer<typeof otpVerificationSchema>;
|
||||
type BackupCodePayload = z.infer<typeof backupCodeSchema>;
|
||||
type BackupCodeVerificationPayload = z.infer<
|
||||
typeof backupCodeVerificationSchema
|
||||
>;
|
||||
type CreateOrganizationPayload = z.infer<typeof createOrganizationSchema>;
|
||||
type UpdateOrganizationPayload = z.infer<typeof updateOrganizationSchema>;
|
||||
type InviteMemberPayload = z.infer<typeof inviteMemberSchema>;
|
||||
type UpdateMemberPayload = z.infer<typeof updateMemberSchema>;
|
||||
type BanUserPayload = z.infer<typeof banUserSchema>;
|
||||
|
||||
export {
|
||||
passwordSchema,
|
||||
registerSchema,
|
||||
passwordLoginSchema,
|
||||
magicLinkLoginSchema,
|
||||
forgotPasswordSchema,
|
||||
updatePasswordSchema,
|
||||
updateUserSchema,
|
||||
emailSchema,
|
||||
changePasswordSchema,
|
||||
otpSchema,
|
||||
otpVerificationSchema,
|
||||
backupCodeSchema,
|
||||
backupCodeVerificationSchema,
|
||||
createOrganizationSchema,
|
||||
updateOrganizationSchema,
|
||||
inviteMemberSchema,
|
||||
updateMemberSchema,
|
||||
banUserSchema,
|
||||
};
|
||||
|
||||
export type {
|
||||
PasswordLoginPayload,
|
||||
MagicLinkLoginPayload,
|
||||
RegisterPayload,
|
||||
ForgotPasswordPayload,
|
||||
UpdatePasswordPayload,
|
||||
PasswordPayload,
|
||||
UpdateUserPayload,
|
||||
EmailPayload,
|
||||
ChangePasswordPayload,
|
||||
OtpPayload,
|
||||
OtpVerificationPayload,
|
||||
BackupCodePayload,
|
||||
BackupCodeVerificationPayload,
|
||||
CreateOrganizationPayload,
|
||||
UpdateOrganizationPayload,
|
||||
InviteMemberPayload,
|
||||
UpdateMemberPayload,
|
||||
BanUserPayload,
|
||||
};
|
||||
137
packages/auth/src/lib/test/utils.test.ts
Normal file
137
packages/auth/src/lib/test/utils.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MemberRole, UserRole } from "../../types";
|
||||
import {
|
||||
generateName,
|
||||
getAllRolesAtOrAbove,
|
||||
getAllRolesAtOrBelow,
|
||||
getUrl,
|
||||
hasAdminPermission,
|
||||
} from "../utils";
|
||||
|
||||
import type { User } from "../../types";
|
||||
|
||||
const BASE_URL = "http://localhost:3000";
|
||||
|
||||
describe("getUrl", () => {
|
||||
it("should use x-url header if present", () => {
|
||||
const request = new Request(BASE_URL, {
|
||||
headers: { "x-url": "https://custom-url.com/path" },
|
||||
});
|
||||
const url = getUrl({ request });
|
||||
expect(url.toString()).toBe("https://custom-url.com/path");
|
||||
});
|
||||
|
||||
it("should merge params from passed url into x-url", () => {
|
||||
const request = new Request(BASE_URL, {
|
||||
headers: { "x-url": "https://custom.com?foo=bar" },
|
||||
});
|
||||
// mergeSearchParams with overwrite: false (default behavior for x-url branch)
|
||||
const url = getUrl({
|
||||
request,
|
||||
url: "https://ignored.com?baz=qux&foo=new",
|
||||
});
|
||||
// Should preserve foo=bar from x-url, add baz=qux
|
||||
expect(url.searchParams.get("foo")).toBe("bar");
|
||||
expect(url.searchParams.get("baz")).toBe("qux");
|
||||
expect(url.origin).toBe("https://custom.com");
|
||||
});
|
||||
|
||||
it("should use expo-origin header if present (mobile)", () => {
|
||||
const request = new Request(BASE_URL, {
|
||||
headers: { "expo-origin": "exp://192.168.1.1:8081" },
|
||||
});
|
||||
const url = getUrl({ request });
|
||||
expect(url.toString()).toBe("exp://192.168.1.1:8081");
|
||||
});
|
||||
|
||||
it("should replace params from passed url into expo-origin", () => {
|
||||
const request = new Request(BASE_URL, {
|
||||
headers: { "expo-origin": "exp://host" },
|
||||
});
|
||||
const url = getUrl({
|
||||
request,
|
||||
url: "https://ignored.com?foo=bar",
|
||||
});
|
||||
// Mobile branch uses mergeSearchParams with replace: true
|
||||
expect(url.searchParams.get("foo")).toBe("bar");
|
||||
expect(url.protocol).toBe("exp:");
|
||||
expect(url.host).toBe("host");
|
||||
});
|
||||
|
||||
it("should fallback to request url for web", () => {
|
||||
const request = new Request("https://web.com/current");
|
||||
const url = getUrl({ request });
|
||||
expect(url.toString()).toBe("https://web.com/current");
|
||||
});
|
||||
|
||||
it("should use provided url for web if no headers", () => {
|
||||
const request = new Request("https://web.com");
|
||||
const url = getUrl({ request, url: "https://target.com/page" });
|
||||
expect(url.toString()).toBe("https://target.com/page");
|
||||
});
|
||||
|
||||
it("should append type param if provided", () => {
|
||||
const request = new Request("https://web.com");
|
||||
const url = getUrl({ request, type: "invite" });
|
||||
expect(url.searchParams.get("type")).toBe("invite");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateName", () => {
|
||||
it("should generate name from email", () => {
|
||||
expect(generateName("john.doe@example.com")).toBe("john.doe");
|
||||
});
|
||||
|
||||
it.each([[undefined, null, "john.doe@example.com"]])(
|
||||
"should fallback to Anonymous if email is empty",
|
||||
(email) => {
|
||||
expect(generateName(email)).toBe("Anonymous");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("getAllRolesAtOrBelow", () => {
|
||||
it.each([
|
||||
[MemberRole.OWNER, [MemberRole.MEMBER, MemberRole.ADMIN, MemberRole.OWNER]],
|
||||
[MemberRole.MEMBER, [MemberRole.MEMBER]],
|
||||
])("should return correct roles for %s", (inputRole, expectedRoles) => {
|
||||
const roles = getAllRolesAtOrBelow(inputRole);
|
||||
expect(roles).toEqual(expectedRoles);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllRolesAtOrAbove", () => {
|
||||
it.each([
|
||||
[MemberRole.OWNER, [MemberRole.OWNER]],
|
||||
[
|
||||
MemberRole.MEMBER,
|
||||
[MemberRole.MEMBER, MemberRole.ADMIN, MemberRole.OWNER],
|
||||
],
|
||||
])("should return correct roles for %s", (inputRole, expectedRoles) => {
|
||||
const roles = getAllRolesAtOrAbove(inputRole);
|
||||
expect(roles).toEqual(expectedRoles);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAdminPermission", () => {
|
||||
it("should return true if user has ADMIN role", () => {
|
||||
const user = { role: UserRole.ADMIN } as User;
|
||||
expect(hasAdminPermission(user)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if user has multiple roles including ADMIN", () => {
|
||||
const user = { role: `${UserRole.USER},${UserRole.ADMIN}` } as User;
|
||||
expect(hasAdminPermission(user)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if user does not have ADMIN role", () => {
|
||||
const user = { role: UserRole.USER } as User;
|
||||
expect(hasAdminPermission(user)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if user is missing role", () => {
|
||||
const user = {} as User;
|
||||
expect(hasAdminPermission(user)).toBe(false);
|
||||
});
|
||||
});
|
||||
76
packages/auth/src/lib/utils.ts
Normal file
76
packages/auth/src/lib/utils.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { getOrigin, mergeSearchParams } from "@turbostarter/shared/utils";
|
||||
|
||||
import { MemberRole, UserRole } from "../types";
|
||||
|
||||
import type { User } from "../types";
|
||||
|
||||
export const getUrl = ({
|
||||
request,
|
||||
url,
|
||||
type,
|
||||
}: {
|
||||
request?: Request;
|
||||
url?: string;
|
||||
type?: string;
|
||||
}) => {
|
||||
const passedUrl = request?.headers.get("x-url");
|
||||
const expoOrigin = request?.headers.get("expo-origin");
|
||||
|
||||
let resultUrl: URL;
|
||||
|
||||
if (passedUrl) {
|
||||
// Base on x-url; merge in params from provided `url` without overwriting existing keys
|
||||
resultUrl = new URL(passedUrl);
|
||||
if (url) {
|
||||
const urlObj = new URL(
|
||||
url,
|
||||
getOrigin(resultUrl.toString()) ?? expoOrigin ?? undefined,
|
||||
);
|
||||
mergeSearchParams(resultUrl, urlObj, { overwrite: false });
|
||||
}
|
||||
} else if (expoOrigin) {
|
||||
// For Expo/mobile, base on expo-origin; if `url` has a query, adopt it entirely
|
||||
resultUrl = new URL(expoOrigin);
|
||||
if (url) {
|
||||
const targetUrl = new URL(url);
|
||||
if (targetUrl.search) {
|
||||
mergeSearchParams(resultUrl, targetUrl, { replace: true });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For web, use the provided URL or fall back to the request URL
|
||||
resultUrl = new URL(url ?? request?.url ?? "");
|
||||
}
|
||||
|
||||
if (type) {
|
||||
resultUrl.searchParams.set("type", type);
|
||||
}
|
||||
|
||||
return resultUrl;
|
||||
};
|
||||
|
||||
const hierarchy: MemberRole[] = [
|
||||
MemberRole.MEMBER,
|
||||
MemberRole.ADMIN,
|
||||
MemberRole.OWNER,
|
||||
];
|
||||
|
||||
export const generateName = (email?: string) => {
|
||||
return email?.split("@")[0] ?? "Anonymous";
|
||||
};
|
||||
|
||||
export const getAllRolesAtOrBelow = (role: MemberRole): MemberRole[] => {
|
||||
const idx = hierarchy.indexOf(role);
|
||||
if (idx === -1) return [];
|
||||
|
||||
return hierarchy.slice(0, idx + 1);
|
||||
};
|
||||
|
||||
export const getAllRolesAtOrAbove = (role: MemberRole): MemberRole[] => {
|
||||
const idx = hierarchy.indexOf(role);
|
||||
if (idx === -1) return [];
|
||||
return hierarchy.slice(idx, hierarchy.length);
|
||||
};
|
||||
|
||||
export const hasAdminPermission = (user: User) =>
|
||||
user.role?.split(",").includes(UserRole.ADMIN) ?? false;
|
||||
242
packages/auth/src/scripts/seed.ts
Normal file
242
packages/auth/src/scripts/seed.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { eq } from "@turbostarter/db";
|
||||
import * as schema from "@turbostarter/db/schema";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { env } from "../env";
|
||||
import { generateName } from "../lib/utils";
|
||||
import { auth } from "../server";
|
||||
import { MemberRole, UserRole } from "../types";
|
||||
|
||||
const context = await auth.$context;
|
||||
|
||||
const SEED_PASSWORD_HASH = await context.password.hash(env.SEED_PASSWORD);
|
||||
|
||||
const getEmail = (suffix: string | string[]) => {
|
||||
const [name, domain] = env.SEED_EMAIL.split("@");
|
||||
|
||||
return `${name}+${Array.isArray(suffix) ? suffix.join("-") : suffix}@${domain}`;
|
||||
};
|
||||
|
||||
const getImage = (name: string) => `https://avatar.vercel.sh/${name}`;
|
||||
|
||||
const seedUser = async ({
|
||||
email,
|
||||
role = UserRole.USER,
|
||||
}: {
|
||||
email: string;
|
||||
role?: UserRole;
|
||||
}) => {
|
||||
const name = generateName(email);
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const userId = context.generateId({ model: "user" }).toString();
|
||||
const userToInsert = {
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
image: getImage(name),
|
||||
emailVerified: true,
|
||||
isAnonymous: false,
|
||||
};
|
||||
|
||||
const [user] = await tx
|
||||
.insert(schema.user)
|
||||
.values({ ...userToInsert, id: userId })
|
||||
.onConflictDoUpdate({
|
||||
target: schema.user.email,
|
||||
set: userToInsert,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyExistingAccount = await tx.query.account.findFirst({
|
||||
where: (account, { eq, and }) =>
|
||||
and(eq(account.userId, user.id), eq(account.providerId, "credential")),
|
||||
});
|
||||
|
||||
if (!alreadyExistingAccount) {
|
||||
const accountToInsert = {
|
||||
id: context.generateId({ model: "account" }).toString(),
|
||||
accountId: context.generateId({ model: "account" }).toString(),
|
||||
providerId: "credential",
|
||||
password: SEED_PASSWORD_HASH,
|
||||
userId: user.id,
|
||||
};
|
||||
|
||||
await tx.insert(schema.account).values(accountToInsert);
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
};
|
||||
|
||||
const seedOrganizationMember = async ({
|
||||
organizationId,
|
||||
role,
|
||||
}: {
|
||||
organizationId: string;
|
||||
role: MemberRole;
|
||||
}) =>
|
||||
db.transaction(async (tx) => {
|
||||
const email = getEmail([`org`, role]);
|
||||
const user = await seedUser({ email });
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memberToInsert = {
|
||||
id: context.generateId({ model: "member" }).toString(),
|
||||
organizationId,
|
||||
role,
|
||||
userId: user.id,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const alreadyExistingMember = await tx.query.member.findFirst({
|
||||
where: (member, { eq, and }) =>
|
||||
and(
|
||||
eq(member.userId, memberToInsert.userId),
|
||||
eq(member.organizationId, memberToInsert.organizationId),
|
||||
),
|
||||
});
|
||||
|
||||
if (alreadyExistingMember) {
|
||||
const [updatedMember] = await tx
|
||||
.update(schema.member)
|
||||
.set(memberToInsert)
|
||||
.where(eq(schema.member.id, alreadyExistingMember.id))
|
||||
.returning();
|
||||
|
||||
return updatedMember;
|
||||
}
|
||||
|
||||
const [member] = await tx
|
||||
.insert(schema.member)
|
||||
.values(memberToInsert)
|
||||
.returning();
|
||||
|
||||
return member;
|
||||
});
|
||||
|
||||
const seedOrganizationInvitation = async ({
|
||||
organizationId,
|
||||
inviterId,
|
||||
role,
|
||||
}: {
|
||||
organizationId: string;
|
||||
inviterId: string;
|
||||
role: MemberRole;
|
||||
}) =>
|
||||
db.transaction(async (tx) => {
|
||||
const invitationToInsert = {
|
||||
id: context.generateId({ model: "invitation" }).toString(),
|
||||
organizationId,
|
||||
role,
|
||||
email: getEmail([`org`, `invite`, role]),
|
||||
expiresAt: new Date(
|
||||
Date.now() +
|
||||
(Math.random() < 0.5 ? -1 : 1) *
|
||||
Math.floor(Math.random() * 1000 * 60 * 60 * 24),
|
||||
),
|
||||
inviterId,
|
||||
};
|
||||
|
||||
const alreadyExistingInvitation = await tx.query.invitation.findFirst({
|
||||
where: (invitation, { eq, and }) =>
|
||||
and(
|
||||
eq(invitation.organizationId, invitationToInsert.organizationId),
|
||||
eq(invitation.email, invitationToInsert.email),
|
||||
),
|
||||
});
|
||||
|
||||
if (alreadyExistingInvitation) {
|
||||
const [updatedInvitation] = await tx
|
||||
.update(schema.invitation)
|
||||
.set(invitationToInsert)
|
||||
.where(eq(schema.invitation.id, alreadyExistingInvitation.id))
|
||||
.returning();
|
||||
return updatedInvitation;
|
||||
}
|
||||
|
||||
const [invitation] = await tx
|
||||
.insert(schema.invitation)
|
||||
.values(invitationToInsert)
|
||||
|
||||
.returning();
|
||||
|
||||
return invitation;
|
||||
});
|
||||
|
||||
const seedOrganization = async () => {
|
||||
const organizationId = context
|
||||
.generateId({ model: "organization" })
|
||||
.toString();
|
||||
const organizationSlug = "seed-organization";
|
||||
|
||||
const organizationToInsert = {
|
||||
name: organizationSlug,
|
||||
slug: organizationSlug,
|
||||
logo: getImage(organizationSlug),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const [organization] = await db
|
||||
.insert(schema.organization)
|
||||
.values({ ...organizationToInsert, id: organizationId })
|
||||
.onConflictDoUpdate({
|
||||
target: schema.organization.slug,
|
||||
set: organizationToInsert,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!organization) {
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await Promise.all(
|
||||
Object.values(MemberRole).map((role) =>
|
||||
seedOrganizationMember({ organizationId: organization.id, role }),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
members.flatMap((member) =>
|
||||
Object.values(MemberRole)
|
||||
.filter((role) => role !== MemberRole.OWNER)
|
||||
.map((role) =>
|
||||
seedOrganizationInvitation({
|
||||
organizationId: organization.id,
|
||||
role,
|
||||
inviterId: member?.userId ?? "",
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return organization;
|
||||
};
|
||||
|
||||
const seedUsers = async () =>
|
||||
Promise.all(
|
||||
Object.values(UserRole).map((role) =>
|
||||
seedUser({ email: getEmail(role), role }),
|
||||
),
|
||||
);
|
||||
|
||||
async function main() {
|
||||
await seedUsers();
|
||||
await seedOrganization();
|
||||
|
||||
logger.info("Auth seeded successfully");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logger.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
255
packages/auth/src/server.ts
Normal file
255
packages/auth/src/server.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { expo } from "@better-auth/expo";
|
||||
import { passkey } from "@better-auth/passkey";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { betterAuth } from "better-auth/minimal";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import {
|
||||
anonymous,
|
||||
magicLink,
|
||||
twoFactor,
|
||||
organization,
|
||||
admin,
|
||||
lastLoginMethod,
|
||||
} from "better-auth/plugins";
|
||||
|
||||
import * as schema from "@turbostarter/db/schema";
|
||||
import { creditTransaction, customer } from "@turbostarter/db/schema";
|
||||
import { db } from "@turbostarter/db/server";
|
||||
import { EmailTemplate } from "@turbostarter/email";
|
||||
import { sendEmail } from "@turbostarter/email/server";
|
||||
import { getLocaleFromRequest } from "@turbostarter/i18n/server";
|
||||
import { NodeEnv } from "@turbostarter/shared/constants";
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { env } from "./env";
|
||||
import { getUrl } from "./lib/utils";
|
||||
import { AuthProvider, SocialProvider, VerificationType } from "./types";
|
||||
|
||||
/**
|
||||
* Credits for new free-tier users.
|
||||
* Configurable via FREE_TIER_CREDITS env var. Defaults: 10000 (dev), 100 (prod).
|
||||
*/
|
||||
const FREE_TIER_CREDITS =
|
||||
env.FREE_TIER_CREDITS ??
|
||||
(env.NODE_ENV === NodeEnv.DEVELOPMENT ? 10000 : 100);
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "TurboStarter",
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
},
|
||||
},
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
sendDeleteAccountVerification: async ({ user, url }, request) =>
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
template: EmailTemplate.DELETE_ACCOUNT,
|
||||
locale: getLocaleFromRequest(request),
|
||||
variables: {
|
||||
url: getUrl({
|
||||
request,
|
||||
url,
|
||||
type: VerificationType.DELETE_ACCOUNT,
|
||||
}).toString(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
updateEmailWithoutVerification: true,
|
||||
sendChangeEmailConfirmation: async ({ user, newEmail, url }, request) =>
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
template: EmailTemplate.CHANGE_EMAIL,
|
||||
locale: getLocaleFromRequest(request),
|
||||
variables: {
|
||||
url: getUrl({
|
||||
request,
|
||||
url,
|
||||
type: VerificationType.CONFIRM_EMAIL,
|
||||
}).toString(),
|
||||
newEmail,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
trustedOrigins: [
|
||||
"chrome-extension://",
|
||||
"turbostarter://",
|
||||
/* Needed only for Apple ID authentication */
|
||||
"https://appleid.apple.com",
|
||||
...(env.NODE_ENV === NodeEnv.DEVELOPMENT
|
||||
? ["http://localhost*", "https://localhost*"]
|
||||
: []),
|
||||
...(process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? []),
|
||||
],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendResetPassword: async ({ user, url }, request) =>
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
template: EmailTemplate.RESET_PASSWORD,
|
||||
locale: getLocaleFromRequest(request),
|
||||
variables: {
|
||||
url,
|
||||
},
|
||||
}),
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendVerificationEmail: async ({ user, url }, request) =>
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
template: EmailTemplate.CONFIRM_EMAIL,
|
||||
locale: getLocaleFromRequest(request),
|
||||
variables: {
|
||||
url: getUrl({
|
||||
request,
|
||||
url,
|
||||
type: VerificationType.CONFIRM_EMAIL,
|
||||
}).toString(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
// Auto-create customer record with free credits on signup
|
||||
const customerId = generateId();
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(customer).values({
|
||||
id: customerId,
|
||||
userId: user.id,
|
||||
customerId: `free_${user.id}`,
|
||||
status: "active",
|
||||
plan: "free",
|
||||
credits: FREE_TIER_CREDITS,
|
||||
});
|
||||
|
||||
await tx.insert(creditTransaction).values({
|
||||
id: generateId(),
|
||||
customerId,
|
||||
amount: FREE_TIER_CREDITS,
|
||||
type: "signup",
|
||||
reason: "Welcome credits for new user",
|
||||
balanceAfter: FREE_TIER_CREDITS,
|
||||
});
|
||||
});
|
||||
logger.info(`Created customer with ${FREE_TIER_CREDITS} credits for user ${user.id}`);
|
||||
} catch (error) {
|
||||
// Log but don't fail user creation if customer creation fails
|
||||
logger.error("Failed to create customer for user", { userId: user.id, error });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }, ctx) =>
|
||||
sendEmail({
|
||||
to: email,
|
||||
template: EmailTemplate.MAGIC_LINK,
|
||||
locale: getLocaleFromRequest(ctx?.request),
|
||||
variables: {
|
||||
url: getUrl({
|
||||
request: ctx?.request,
|
||||
url,
|
||||
type: VerificationType.MAGIC_LINK,
|
||||
}).toString(),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
passkey(),
|
||||
twoFactor(),
|
||||
anonymous(),
|
||||
admin(),
|
||||
organization({
|
||||
sendInvitationEmail: async (
|
||||
{ invitation, inviter, organization },
|
||||
request,
|
||||
) => {
|
||||
const url = getUrl({
|
||||
request,
|
||||
});
|
||||
url.searchParams.set("invitationId", invitation.id);
|
||||
url.searchParams.set("email", invitation.email);
|
||||
|
||||
return sendEmail({
|
||||
to: invitation.email,
|
||||
template: EmailTemplate.ORGANIZATION_INVITATION,
|
||||
locale: getLocaleFromRequest(request),
|
||||
variables: {
|
||||
url: url.toString(),
|
||||
inviter: inviter.user.name,
|
||||
organization: organization.name,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
lastLoginMethod({
|
||||
customResolveMethod: (ctx) => {
|
||||
switch (ctx.path) {
|
||||
case "/magic-link/verify":
|
||||
return AuthProvider.MAGIC_LINK;
|
||||
case "/passkey/verify-authentication":
|
||||
return AuthProvider.PASSKEY;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
expo(),
|
||||
nextCookies(),
|
||||
],
|
||||
socialProviders: {
|
||||
[SocialProvider.APPLE]: {
|
||||
clientId: env.APPLE_CLIENT_ID,
|
||||
clientSecret: env.APPLE_CLIENT_SECRET,
|
||||
appBundleIdentifier: env.APPLE_APP_BUNDLE_IDENTIFIER,
|
||||
},
|
||||
[SocialProvider.GOOGLE]: {
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
[SocialProvider.GITHUB]: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
cookiePrefix: "turbostarter",
|
||||
cookies: {
|
||||
state: {
|
||||
attributes: {
|
||||
sameSite: "none",
|
||||
secure: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
log: (level, ...args) => logger[level](...args),
|
||||
},
|
||||
});
|
||||
|
||||
export type AuthErrorCode = keyof typeof auth.$ERROR_CODES;
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
export type User = Session["user"];
|
||||
export type Invitation = typeof auth.$Infer.Invitation;
|
||||
export type Organization = typeof auth.$Infer.Organization;
|
||||
export type ActiveOrganization = typeof auth.$Infer.ActiveOrganization;
|
||||
export type Member = typeof auth.$Infer.Member;
|
||||
271
packages/auth/src/types.ts
Normal file
271
packages/auth/src/types.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import type { AuthErrorCode } from "./server";
|
||||
import type { expoClient } from "@better-auth/expo/client";
|
||||
import type { TranslationKey } from "@turbostarter/i18n";
|
||||
import type {
|
||||
BetterAuthClientPlugin,
|
||||
BetterAuthClientOptions,
|
||||
} from "better-auth";
|
||||
|
||||
type AuthMobileClientOptions = Parameters<typeof expoClient>[0];
|
||||
|
||||
const SocialProvider = {
|
||||
APPLE: "apple",
|
||||
GOOGLE: "google",
|
||||
GITHUB: "github",
|
||||
} as const;
|
||||
|
||||
type SocialProvider = (typeof SocialProvider)[keyof typeof SocialProvider];
|
||||
|
||||
const AuthProvider = {
|
||||
...SocialProvider,
|
||||
PASSWORD: "password",
|
||||
MAGIC_LINK: "magicLink",
|
||||
ANONYMOUS: "anonymous",
|
||||
PASSKEY: "passkey",
|
||||
} as const;
|
||||
|
||||
type AuthProvider = (typeof AuthProvider)[keyof typeof AuthProvider];
|
||||
|
||||
const SecondFactor = {
|
||||
TOTP: "totp",
|
||||
BACKUP_CODE: "backupCode",
|
||||
} as const;
|
||||
|
||||
type SecondFactor = (typeof SecondFactor)[keyof typeof SecondFactor];
|
||||
|
||||
const authConfigSchema = z.object({
|
||||
providers: z.object({
|
||||
[AuthProvider.PASSWORD]: z.boolean(),
|
||||
[AuthProvider.MAGIC_LINK]: z.boolean(),
|
||||
[AuthProvider.ANONYMOUS]: z.boolean(),
|
||||
[AuthProvider.PASSKEY]: z.boolean().optional(),
|
||||
oAuth: z.array(z.enum(SocialProvider)),
|
||||
}),
|
||||
});
|
||||
|
||||
const UserRole = {
|
||||
USER: "user",
|
||||
ADMIN: "admin",
|
||||
} as const;
|
||||
|
||||
type UserRole = (typeof UserRole)[keyof typeof UserRole];
|
||||
|
||||
const MemberRole = {
|
||||
MEMBER: "member",
|
||||
ADMIN: "admin",
|
||||
OWNER: "owner",
|
||||
} as const;
|
||||
|
||||
type MemberRole = (typeof MemberRole)[keyof typeof MemberRole];
|
||||
|
||||
const InvitationStatus = {
|
||||
PENDING: "pending",
|
||||
ACCEPTED: "accepted",
|
||||
CANCELED: "canceled",
|
||||
REJECTED: "rejected",
|
||||
} as const;
|
||||
|
||||
type InvitationStatus =
|
||||
(typeof InvitationStatus)[keyof typeof InvitationStatus];
|
||||
|
||||
const VerificationType = {
|
||||
MAGIC_LINK: "magic-link",
|
||||
DELETE_ACCOUNT: "delete-account",
|
||||
CONFIRM_EMAIL: "confirm-email",
|
||||
} as const;
|
||||
|
||||
type VerificationType =
|
||||
(typeof VerificationType)[keyof typeof VerificationType];
|
||||
|
||||
type AuthConfig = z.infer<typeof authConfigSchema>;
|
||||
|
||||
const ERROR_MESSAGES: Record<AuthErrorCode, TranslationKey> = {
|
||||
YOU_CANNOT_BAN_YOURSELF: "auth:error.user.cannotBanYourself",
|
||||
YOU_CANNOT_IMPERSONATE_ADMINS: "auth:error.user.cannotImpersonateAdmins",
|
||||
INVALID_EMAIL_FORMAT: "auth:error.credentials.email.invalidFormat",
|
||||
ORGANIZATION_SLUG_ALREADY_TAKEN: "organization:error.slugAlreadyTaken",
|
||||
YOU_ARE_NOT_ALLOWED_TO_SET_NON_EXISTENT_VALUE:
|
||||
"auth:error.cannotSetNonExistentValue",
|
||||
YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE: "auth:error.user.cannotChangeRole",
|
||||
YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS: "admin:error.cannotCreateUsers",
|
||||
YOU_ARE_NOT_ALLOWED_TO_LIST_USERS: "admin:error.cannotListUsers",
|
||||
YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS: "admin:error.cannotUpdateUsers",
|
||||
YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS: "admin:error.cannotDeleteUsers",
|
||||
YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS:
|
||||
"admin:error.cannotListUsersSessions",
|
||||
YOU_ARE_NOT_ALLOWED_TO_BAN_USERS: "admin:error.cannotBanUsers",
|
||||
YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS:
|
||||
"admin:error.cannotImpersonateUsers",
|
||||
YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS:
|
||||
"admin:error.cannotRevokeUsersSessions",
|
||||
YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD:
|
||||
"admin:error.cannotSetUsersPassword",
|
||||
BANNED_USER: "auth:error.user.banned",
|
||||
NO_DATA_TO_UPDATE: "auth:error.noDataToUpdate",
|
||||
YOU_CANNOT_REMOVE_YOURSELF: "auth:error.user.cannotRemoveYourself",
|
||||
YOU_ARE_NOT_ALLOWED_TO_GET_USER: "auth:error.user.cannotGetUser",
|
||||
YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER:
|
||||
"organization:error.cannotLeaveWithoutOwner",
|
||||
MISSING_AC_INSTANCE: "organization:error.ac.missingAcInstance",
|
||||
YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE:
|
||||
"organization:error.ac.mustBeInOrganizationToCreateRole",
|
||||
YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE:
|
||||
"organization:error.ac.cannotCreateRole",
|
||||
YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE:
|
||||
"organization:error.ac.cannotUpdateRole",
|
||||
YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE:
|
||||
"organization:error.ac.cannotDeleteRole",
|
||||
YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE: "organization:error.ac.cannotReadRole",
|
||||
YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE: "organization:error.ac.cannotListRole",
|
||||
YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE: "organization:error.ac.cannotGetRole",
|
||||
TOO_MANY_ROLES: "organization:error.ac.tooManyRoles",
|
||||
INVALID_RESOURCE: "organization:error.ac.invalidResource",
|
||||
CANNOT_DELETE_A_PRE_DEFINED_ROLE:
|
||||
"organization:error.ac.cannotDeletePreDefinedRole",
|
||||
ROLE_NAME_IS_ALREADY_TAKEN: "organization:error.ac.roleNameAlreadyTaken",
|
||||
YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION:
|
||||
"organization:error.cannotCreateNew",
|
||||
YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS:
|
||||
"organization:error.maximumNumberOfOrganizations",
|
||||
ORGANIZATION_ALREADY_EXISTS: "organization:error.alreadyExists",
|
||||
ORGANIZATION_NOT_FOUND: "organization:error.notFound",
|
||||
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL:
|
||||
"auth:error.user.alreadyExistsUseAnotherEmail",
|
||||
USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION: "organization:error.userNotMember",
|
||||
YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION:
|
||||
"organization:error.cannotUpdate",
|
||||
YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION:
|
||||
"organization:error.cannotDelete",
|
||||
NO_ACTIVE_ORGANIZATION: "organization:error.noActive",
|
||||
USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION:
|
||||
"organization:error.userAlreadyMember",
|
||||
MEMBER_NOT_FOUND: "organization:error.memberNotFound",
|
||||
YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM: "organization:error.team.noActive",
|
||||
YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION: "organization:error.userNotMember",
|
||||
ROLE_NOT_FOUND: "organization:error.roleNotFound",
|
||||
YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER:
|
||||
"organization:error.team.cannotCreateNewMember",
|
||||
YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER:
|
||||
"organization:error.team.cannotRemoveMember",
|
||||
YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION:
|
||||
"organization:error.cannotAccess",
|
||||
YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM:
|
||||
"organization:error.team.cannotCreateNew",
|
||||
TEAM_ALREADY_EXISTS: "organization:error.team.alreadyExists",
|
||||
TEAM_NOT_FOUND: "organization:error.team.notFound",
|
||||
YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER:
|
||||
"organization:error.cannotLeaveAsOnlyOwner",
|
||||
YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER:
|
||||
"organization:error.cannotDeleteMember",
|
||||
YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION:
|
||||
"organization:error.invitation.cannotInviteUsers",
|
||||
YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION:
|
||||
"organization:error.invitation.cannotCancel",
|
||||
INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION:
|
||||
"organization:error.invitation.inviterNoLongerMember",
|
||||
YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE:
|
||||
"organization:error.invitation.cannotInviteUserWithRole",
|
||||
USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION:
|
||||
"organization:error.invitation.userAlreadyInvited",
|
||||
INVITATION_NOT_FOUND: "organization:error.invitation.notFound",
|
||||
YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION:
|
||||
"organization:error.team.cannotCreateNew",
|
||||
YOU_ARE_NOT_ALLOWED_TO_DELETE_TEAMS_IN_THIS_ORGANIZATION:
|
||||
"organization:error.team.cannotDelete",
|
||||
YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM:
|
||||
"organization:error.team.cannotUpdate",
|
||||
YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM:
|
||||
"organization:error.team.cannotDelete",
|
||||
INVITATION_LIMIT_REACHED: "organization:error.invitation.limitReached",
|
||||
TEAM_MEMBER_LIMIT_REACHED: "organization:error.team.memberLimitReached",
|
||||
USER_IS_NOT_A_MEMBER_OF_THE_TEAM: "organization:error.team.userNotMember",
|
||||
YOU_CAN_NOT_ACCESS_THE_MEMBERS_OF_THIS_TEAM:
|
||||
"organization:error.team.cannotAccessMembers",
|
||||
YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS:
|
||||
"organization:error.team.maximumNumberOfTeams",
|
||||
UNABLE_TO_REMOVE_LAST_TEAM: "organization:error.team.unableToRemoveLastTeam",
|
||||
EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION:
|
||||
"organization:error.invitation.emailVerificationRequired",
|
||||
FAILED_TO_RETRIEVE_INVITATION:
|
||||
"organization:error.invitation.failedToRetrieve",
|
||||
YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER:
|
||||
"organization:error.cannotUpdateMember",
|
||||
ORGANIZATION_MEMBERSHIP_LIMIT_REACHED:
|
||||
"organization:error.membershipLimitReached",
|
||||
YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION:
|
||||
"organization:error.invitation.notRecipient",
|
||||
USER_NOT_FOUND: "auth:error.user.notFound",
|
||||
USER_ALREADY_HAS_PASSWORD: "auth:error.user.alreadyHasPassword",
|
||||
AUTHENTICATION_FAILED: "auth:error.authenticationFailed",
|
||||
FAILED_TO_CREATE_USER: "auth:error.account.creation",
|
||||
FAILED_TO_CREATE_SESSION: "auth:error.session.creation",
|
||||
UNABLE_TO_CREATE_SESSION: "auth:error.session.creation",
|
||||
COULD_NOT_CREATE_SESSION: "auth:error.session.creation",
|
||||
FAILED_TO_UPDATE_USER: "auth:error.account.update",
|
||||
FAILED_TO_GET_SESSION: "auth:error.session.retrieval",
|
||||
INVALID_PASSWORD: "auth:error.credentials.password.invalid",
|
||||
INVALID_EMAIL: "auth:error.credentials.email.invalid",
|
||||
INVALID_EMAIL_OR_PASSWORD: "auth:error.credentials.invalidEmailOrPassword",
|
||||
SOCIAL_ACCOUNT_ALREADY_LINKED: "auth:error.social.alreadyLinked",
|
||||
PROVIDER_NOT_FOUND: "auth:error.social.providerNotFound",
|
||||
INVALID_TOKEN: "auth:error.token.invalid",
|
||||
ID_TOKEN_NOT_SUPPORTED: "auth:error.token.idNotSupported",
|
||||
FAILED_TO_GET_USER_INFO: "auth:error.user.infoNotFound",
|
||||
USER_EMAIL_NOT_FOUND: "auth:error.user.emailNotFound",
|
||||
EMAIL_NOT_VERIFIED: "auth:error.credentials.email.notVerified",
|
||||
PASSWORD_TOO_SHORT: "auth:error.credentials.password.tooShort",
|
||||
PASSWORD_TOO_LONG: "auth:error.credentials.password.tooLong",
|
||||
USER_ALREADY_EXISTS: "auth:error.user.alreadyExists",
|
||||
EMAIL_CAN_NOT_BE_UPDATED: "auth:error.credentials.email.cannotUpdate",
|
||||
CREDENTIAL_ACCOUNT_NOT_FOUND: "auth:error.credentials.notFound",
|
||||
SESSION_EXPIRED: "auth:error.session.expired",
|
||||
FAILED_TO_UNLINK_LAST_ACCOUNT: "auth:error.social.unlinkLastAccount",
|
||||
ACCOUNT_NOT_FOUND: "auth:error.user.accountNotFound",
|
||||
CHALLENGE_NOT_FOUND: "auth:error.passkey.challengeNotFound",
|
||||
YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY: "auth:error.passkey.notAllowed",
|
||||
FAILED_TO_VERIFY_REGISTRATION: "auth:error.passkey.verificationFailed",
|
||||
PASSKEY_NOT_FOUND: "auth:error.passkey.notFound",
|
||||
FAILED_TO_UPDATE_PASSKEY: "auth:error.passkey.updateFailed",
|
||||
ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY:
|
||||
"auth:error.anonymous.cannotSignInAgain",
|
||||
OTP_NOT_ENABLED: "auth:error.otp.notEnabled",
|
||||
OTP_HAS_EXPIRED: "auth:error.otp.expired",
|
||||
TOTP_NOT_ENABLED: "auth:error.totp.notEnabled",
|
||||
TWO_FACTOR_NOT_ENABLED: "auth:error.twoFactor.notEnabled",
|
||||
BACKUP_CODES_NOT_ENABLED: "auth:error.backupCodes.notEnabled",
|
||||
INVALID_BACKUP_CODE: "auth:error.code.invalid",
|
||||
INVALID_CODE: "auth:error.code.invalid",
|
||||
TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: "auth:error.code.tooManyAttempts",
|
||||
INVALID_TWO_FACTOR_COOKIE: "auth:error.twoFactor.invalidCookie",
|
||||
} as const;
|
||||
|
||||
export type {
|
||||
BetterAuthClientOptions as AuthClientOptions,
|
||||
BetterAuthClientPlugin as AuthClientPlugin,
|
||||
AuthMobileClientOptions,
|
||||
AuthConfig,
|
||||
AuthErrorCode,
|
||||
};
|
||||
|
||||
export {
|
||||
authConfigSchema,
|
||||
SocialProvider,
|
||||
AuthProvider,
|
||||
SecondFactor,
|
||||
ERROR_MESSAGES,
|
||||
MemberRole,
|
||||
UserRole,
|
||||
InvitationStatus,
|
||||
VerificationType,
|
||||
};
|
||||
|
||||
export type {
|
||||
User,
|
||||
Session,
|
||||
Invitation,
|
||||
Organization,
|
||||
ActiveOrganization,
|
||||
Member,
|
||||
} from "./server";
|
||||
Reference in New Issue
Block a user