Merge branch 'main' into v1.2

This commit is contained in:
Bereket Engida
2025-02-14 15:17:41 +03:00
32 changed files with 468 additions and 98 deletions

View File

@@ -46,3 +46,5 @@ export const {
useListOrganizations,
useActiveOrganization,
} = client;
client.$store.listen("$sessionSignal", async () => {});

View File

@@ -173,27 +173,33 @@ export const socialProviders = {
Icon: (props: SVGProps<any>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
width="0.98em"
height="1em"
viewBox="0 0 24 24"
{...props}
viewBox="0 0 256 262"
>
<path
fill="currentColor"
d="M11.99 13.9v-3.72h9.36c.14.63.25 1.22.25 2.05c0 5.71-3.83 9.77-9.6 9.77c-5.52 0-10-4.48-10-10S6.48 2 12 2c2.7 0 4.96.99 6.69 2.61l-2.84 2.76c-.72-.68-1.98-1.48-3.85-1.48c-3.31 0-6.01 2.75-6.01 6.12s2.7 6.12 6.01 6.12c3.83 0 5.24-2.65 5.5-4.22h-5.51z"
fill="#4285F4"
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
></path>
<path
fill="#34A853"
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
></path>
<path
fill="#FBBC05"
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"
></path>
<path
fill="#EB4335"
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
></path>
</svg>
),
stringIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M11.99 13.9v-3.72h9.36c.14.63.25 1.22.25 2.05c0 5.71-3.83 9.77-9.6 9.77c-5.52 0-10-4.48-10-10S6.48 2 12 2c2.7 0 4.96.99 6.69 2.61l-2.84 2.76c-.72-.68-1.98-1.48-3.85-1.48c-3.31 0-6.01 2.75-6.01 6.12s2.7 6.12 6.01 6.12c3.83 0 5.24-2.65 5.5-4.22h-5.51z"
></path>
stringIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="0.98em" height="1em" viewBox="0 0 256 262">
<path fill="#4285F4" d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"></path>
<path fill="#34A853" d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"></path>
<path fill="#FBBC05" d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"></path>
<path fill="#EB4335" d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"></path>
</svg>`,
},
linkedin: {

View File

@@ -223,6 +223,24 @@ const { data, error } = await authClient.resetPassword({
});
```
### Update password
<Callout type="warn">
This only works on server-side, and the following code may change over time.
</Callout>
To set a password, you must hash it first:
```ts
const ctx = await auth.$context;
const hash = await ctx.password.hash("your-new-password");
```
Then, to set the password:
```ts
await ctx.internalAdapter.updatePassword("userId", hash) //(you can also use your orm directly)
```
### Configuration
**Password**

View File

@@ -73,7 +73,7 @@ The `getSession` function retrieves the current active session.
```ts client="client.ts"
import { authClient } from "@/lib/client"
const session = await authClient.getSession()
const { data: session } = await authClient.getSession()
```
To learn how to customize the session response check the [Customizing Session Response](#customizing-session-response) section.
@@ -85,7 +85,7 @@ The `useSession` action provides a reactive way to access the current session.
```ts client="client.ts"
import { authClient } from "@/lib/client"
const session = await authClient.useSession()
const { data: session } = authClient.useSession()
```
### List Sessions

View File

@@ -37,7 +37,7 @@ import { cors } from "hono/cors";
const app = new Hono();
app.use(
"/api/auth/**", // or replace with "*" to enable cors for all routes
"/api/auth/*", // or replace with "*" to enable cors for all routes
cors({
origin: "http://localhost:3001", // replace with your origin
allowHeaders: ["Content-Type", "Authorization"],
@@ -82,7 +82,7 @@ app.use("*", async (c, next) => {
return next();
});
app.on(["POST", "GET"], "/api/auth/**", (c) => {
app.on(["POST", "GET"], "/api/auth/*", (c) => {
return auth.handler(c.req.raw);
});

View File

@@ -7,7 +7,7 @@ Better Auth is a framework-agnostic authentication and authorization framework f
## Why Better Auth?
*Authentication in the TypeScript ecosystem has long been a half-solved problem. Other open-source libraries often require a lot of additional code for anything beyond basic authentication features.Rather than just pushing third-party services as the solution, I believe we can do better as a community—hence, Better Auth*
*Authentication in the TypeScript ecosystem has long been a half-solved problem. Other open-source libraries often require a lot of additional code for anything beyond basic authentication features. Rather than just pushing third-party services as the solution, I believe we can do better as a community—hence, Better Auth*
## Features

View File

@@ -53,7 +53,7 @@ The Email OTP plugin allows user to sign-in, verify their email, or reset their
First, send an OTP to the user's email address.
```ts title="example.ts"
await authClient.emailOtp.sendVerificationOtp({
const { data, error } = await authClient.emailOtp.sendVerificationOtp({
email: "user-email@email.com",
type: "sign-in" // or "email-verification", "forget-password"
})
@@ -64,7 +64,7 @@ await authClient.emailOtp.sendVerificationOtp({
Once the user provides the OTP, you can sign in the user using the `signIn.emailOTP()` method.
```ts title="example.ts"
const user = await authClient.signIn.emailOtp({
const { data, error } = await authClient.signIn.emailOtp({
email: "user-email@email.com",
otp: "123456"
})
@@ -77,7 +77,7 @@ If the user is not registered, they'll be automatically registered. If you want
To verify the user's email address, use the `verifyEmail()` method.
```ts title="example.ts"
const user = await authClient.emailOtp.verifyEmail({
const { data, error } = await authClient.emailOtp.verifyEmail({
email: "user-email@email.com",
otp: "123456"
})
@@ -88,7 +88,7 @@ const user = await authClient.emailOtp.verifyEmail({
To reset the user's password, use the `resetPassword()` method.
```ts title="example.ts"
await authClient.emailOtp.resetPassword({
const { data, error } = await authClient.emailOtp.resetPassword({
email: "user-email@email.com",
otp: "123456",
password: "password"

View File

@@ -70,6 +70,17 @@ const response = await authClient.signIn.oauth2({
});
```
### Linking OAuth Accounts
To link an OAuth account to an existing user:
```ts title="link-account.ts"
const response = await authClient.oauth2.link({
providerId: "provider-id",
callbackURL: "/dashboard" // the path to redirect to after the account is linked
});
```
### Handle OAuth Callback
The plugin mounts a route to handle the OAuth callback `/oauth2/callback/:providerId`. This means by default `${baseURL}/api/auth/oauth2/callback/:providerId` will be used as the callback URL. Make sure your OAuth provider is configured to use this URL.

View File

@@ -6,6 +6,7 @@ description: Magic link plugin
Magic link or email link is a way to authenticate users without a password. When a user enters their email, a link is sent to their email. When the user clicks on the link, they are authenticated.
## Installation
<Steps>
<Step>
### Add the server Plugin
@@ -43,6 +44,7 @@ Magic link or email link is a way to authenticate users without a password. When
});
```
</Step>
</Steps>
## Usage
@@ -54,7 +56,7 @@ To sign in with a magic link, you need to call `signIn.magicLink` with the user'
```ts title="magic-link.ts"
const { data, error } = await authClient.signIn.magicLink({
email: "user@email.com",
callbackURL: "/dashboard" //redirect after successful login (optional)
callbackURL: "/dashboard", //redirect after successful login (optional)
});
```
@@ -65,7 +67,7 @@ If the user has not signed up, unless `disableSignUp` is set to `true`, the user
When you send the URL generated by the `sendMagicLink` function to a user, clicking the link will authenticate them and redirect them to the `callbackURL` specified in the `signIn.magicLink` function. If an error occurs, the user will be redirected to the `callbackURL` with an error query parameter.
<Callout type="warn">
If no `callbackURL` is provided, the user will be redirected to the root URL.
If no `callbackURL` is provided, the user will be redirected to the root URL.
</Callout>
If you want to handle the verification manually, (e.g, if you send the user a different url), you can use the `verify` function.
@@ -73,8 +75,8 @@ If you want to handle the verification manually, (e.g, if you send the user a di
```ts title="magic-link.ts"
const { data, error } = await authClient.magicLink.verify({
query: {
token
}
token,
},
});
```
@@ -91,3 +93,13 @@ and a `request` object as the second parameter.
**expiresIn**: specifies the time in seconds after which the magic link will expire. The default value is `300` seconds (5 minutes).
**disableSignUp**: If set to `true`, the user will not be able to sign up using the magic link. The default value is `false`.
**generateToken**: The `generateToken` function is called to generate a token which is used to uniquely identify the user. The default value is a random string. There is one parameter:
- `email`: The email address of the user.
<Callout type="warn">
When using `generateToken`, ensure that the returned string is hard to guess
because it is used to verify who someone actually is in a confidential way. By
default, we return a long and cryptographically secure string.
</Callout>

View File

@@ -544,6 +544,16 @@ auth.api.addMember({
})
```
### Leave Organization
To leave organization you can use `organization.leave` function. This function will remove the current user from the organization.
```ts title="auth-client.ts"
await authClient.organization.leave({
organizationId: "organization-id"
})
```
## Access Control
The organization plugin providers a very flexible access control system. You can control the access of the user based on the role they have in the organization. You can define your own set of permissions based on the role of the user.

View File

@@ -85,7 +85,6 @@ const authClient = createAuthClient({
passkeyClient(), // [!code highlight]
], // [!code highlight]
});
// ---cut---
const data = await authClient.passkey.addPasskey();
```
@@ -111,7 +110,6 @@ const authClient = createAuthClient({
passkeyClient(), // [!code highlight]
], // [!code highlight]
});
// ---cut---
const data = await authClient.signIn.passkey();
```

View File

@@ -141,6 +141,35 @@ describe("Origin Check", async (it) => {
expect(res.error?.message).toBe("Invalid redirectURL");
});
it("should work with list of trusted origins", async (ctx) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
headers: {
origin: "https://trusted.com",
},
},
});
const res = await client.forgetPassword({
email: testUser.email,
redirectTo: "http://localhost:5000/reset-password",
});
expect(res.data?.status).toBeTruthy();
const res2 = await client.signIn.email({
email: testUser.email,
password: testUser.password,
fetchOptions: {
// @ts-expect-error - query is not defined in the type
query: {
currentURL: "http://localhost:5000",
},
},
});
expect(res2.data?.user).toBeDefined();
});
it("should work with wildcard trusted origins", async (ctx) => {
const client = createAuthClient({
baseURL: "https://sub-domain.my-site.com",

View File

@@ -9,7 +9,7 @@ import type { GenericEndpointContext } from "../../types";
* trustedOrigins.
*/
export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
if (ctx.request?.method !== "POST") {
if (ctx.request?.method !== "POST" || !ctx.request) {
return;
}
const { body, query, context } = ctx;
@@ -19,7 +19,12 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
const redirectURL = body?.redirectTo;
const errorCallbackURL = body?.errorCallbackURL;
const newUserCallbackURL = body?.newUserCallbackURL;
const trustedOrigins = context.trustedOrigins;
const trustedOrigins: string[] = Array.isArray(context.options.trustedOrigins)
? context.trustedOrigins
: [
...context.trustedOrigins,
...(context.options.trustedOrigins?.(ctx.request) || []),
];
const usesCookies = ctx.headers?.has("cookie");
const matchesPattern = (url: string, pattern: string): boolean => {
@@ -66,9 +71,19 @@ export const originCheck = (
getValue: (ctx: GenericEndpointContext) => string,
) =>
createAuthMiddleware(async (ctx) => {
if (!ctx.request) {
return;
}
const { context } = ctx;
const callbackURL = getValue(ctx);
const trustedOrigins = context.trustedOrigins;
const trustedOrigins: string[] = Array.isArray(
context.options.trustedOrigins,
)
? context.trustedOrigins
: [
...context.trustedOrigins,
...(context.options.trustedOrigins?.(ctx.request) || []),
];
const matchesPattern = (url: string, pattern: string): boolean => {
if (url.startsWith("/")) {

View File

@@ -35,7 +35,7 @@ function getRetryAfter(lastRequest: number, window: number) {
}
function createDBStorage(ctx: AuthContext, modelName?: string) {
const model = "rateLimit";
const model = ctx.options.rateLimit?.modelName || "rateLimit";
const db = ctx.adapter;
return {
get: async (key: string) => {

View File

@@ -40,7 +40,11 @@ export const betterAuth = <O extends BetterAuthOptions>(options: O) => {
ctx.baseURL = baseURL;
}
ctx.trustedOrigins = [
...(options.trustedOrigins || []),
...(options.trustedOrigins
? Array.isArray(options.trustedOrigins)
? options.trustedOrigins
: options.trustedOrigins(request)
: []),
ctx.baseURL,
url.origin,
];

View File

@@ -94,8 +94,8 @@ export type InferRoute<API, COpts extends ClientOptions> = API extends Record<
? C extends InputContext<any, any>
? <
FetchOptions extends BetterFetchOption<
C["body"] & Record<string, any>,
C["query"] & Record<string, any>,
Partial<C["body"]> & Record<string, any>,
Partial<C["query"]> & Record<string, any>,
C["params"]
>,
>(

View File

@@ -100,8 +100,4 @@ export function createAuthClient<Option extends ClientOptions>(
};
}
export type * from "@better-fetch/fetch";
//@ts-expect-error
export type * from "zod";
export { useStore };

View File

@@ -24,13 +24,16 @@ export function getWithHooks(
for (const hook of hooks || []) {
const toRun = hook[model]?.create?.before;
if (toRun) {
const result = await toRun(data as any);
const result = await toRun(actualData as any);
if (result === false) {
return null;
}
const isObject = typeof result === "object" && "data" in result;
if (isObject) {
actualData = result.data as T;
actualData = {
...actualData,
...result.data,
};
}
}
}

View File

@@ -250,7 +250,7 @@ function getTrustedOrigins(options: BetterAuthOptions) {
return [];
}
const trustedOrigins = [new URL(baseURL).origin];
if (options.trustedOrigins) {
if (options.trustedOrigins && Array.isArray(options.trustedOrigins)) {
trustedOrigins.push(...options.trustedOrigins);
}
const envTrustedOrigins = env.BETTER_AUTH_TRUSTED_ORIGINS;

View File

@@ -138,7 +138,8 @@ export async function handleOAuthUserInfo(
c.request,
);
}
} catch (e) {
} catch (e: any) {
logger.error(e);
if (e instanceof APIError) {
return {
error: e.message,

View File

@@ -120,7 +120,6 @@ export const admin = <O extends AdminOptions>(options?: O) => {
return {
data: {
role: options?.defaultRole ?? "user",
...user,
},
};
},
@@ -896,6 +895,29 @@ export const admin = <O extends AdminOptions>(options?: O) => {
});
},
),
setUserPassword: createAuthEndpoint(
"/admin/set-user-password",
{
method: "POST",
body: z.object({
newPassword: z.string(),
userId: z.string(),
}),
use: [adminMiddleware],
},
async (ctx) => {
const hashedPassword = await ctx.context.password.hash(
ctx.body.newPassword,
);
await ctx.context.internalAdapter.updatePassword(
ctx.body.userId,
hashedPassword,
);
return ctx.json({
status: true,
});
},
),
},
$ERROR_CODES: ERROR_CODES,
schema: mergeSchema(schema, opts.schema),

View File

@@ -1,7 +1,7 @@
import { betterFetch } from "@better-fetch/fetch";
import { APIError } from "better-call";
import { z } from "zod";
import { createAuthEndpoint } from "../../api";
import { createAuthEndpoint, sessionMiddleware } from "../../api";
import { setSessionCookie } from "../../cookies";
import {
createAuthorizationURL,
@@ -13,6 +13,7 @@ import { handleOAuthUserInfo } from "../../oauth2/link-account";
import { generateState, parseState } from "../../oauth2/state";
import type { BetterAuthPlugin, User } from "../../types";
import { decodeJwt } from "jose";
import { BASE_ERROR_CODES } from "../../error/codes";
/**
* Configuration interface for generic OAuth providers.
@@ -475,7 +476,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
let tokens: OAuth2Tokens | undefined = undefined;
const parsedState = await parseState(ctx);
const { callbackURL, codeVerifier, errorURL, newUserURL } =
const { callbackURL, codeVerifier, errorURL, newUserURL, link } =
parsedState;
const code = ctx.query.code;
@@ -544,6 +545,28 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
? await provider.mapProfileToUser(userInfo)
: null;
if (link) {
if (link.email !== userInfo.email.toLowerCase()) {
return redirectOnError("email_doesn't_match");
}
const newAccount = await ctx.context.internalAdapter.createAccount({
userId: link.userId,
providerId: provider.providerId,
accountId: userInfo.id,
});
if (!newAccount) {
return redirectOnError("unable_to_link_account");
}
let toRedirectTo: string;
try {
const url = callbackURL;
toRedirectTo = url.toString();
} catch {
toRedirectTo = callbackURL;
}
throw ctx.redirect(toRedirectTo);
}
const result = await handleOAuthUserInfo(ctx, {
userInfo: {
...userInfo,
@@ -586,6 +609,102 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
throw ctx.redirect(toRedirectTo);
},
),
oAuth2LinkAccount: createAuthEndpoint(
"/oauth2/link",
{
method: "POST",
body: z.object({
providerId: z.string(),
callbackURL: z.string(),
}),
use: [sessionMiddleware],
},
async (c) => {
const session = c.context.session;
const account = await c.context.internalAdapter.findAccounts(
session.user.id,
);
const existingAccount = account.find(
(a) => a.providerId === c.body.providerId,
);
if (existingAccount) {
throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.SOCIAL_ACCOUNT_ALREADY_LINKED,
});
}
const provider = options.config.find(
(p) => p.providerId === c.body.providerId,
);
if (!provider) {
throw new APIError("NOT_FOUND", {
message: BASE_ERROR_CODES.PROVIDER_NOT_FOUND,
});
}
const {
providerId,
clientId,
clientSecret,
redirectURI,
authorizationUrl,
discoveryUrl,
pkce,
scopes,
} = provider;
let finalAuthUrl = authorizationUrl;
if (!finalAuthUrl) {
if (!discoveryUrl) {
throw new APIError("BAD_REQUEST", {
message: ERROR_CODES.INVALID_OAUTH_CONFIGURATION,
});
}
const discovery = await betterFetch<{
authorization_endpoint: string;
token_endpoint: string;
}>(discoveryUrl, {
onError(context) {
c.context.logger.error(context.error.message, context.error, {
discoveryUrl,
});
},
});
if (discovery.data) {
finalAuthUrl = discovery.data.authorization_endpoint;
}
}
if (!finalAuthUrl) {
throw new APIError("BAD_REQUEST", {
message: ERROR_CODES.INVALID_OAUTH_CONFIGURATION,
});
}
const state = await generateState(c, {
userId: session.user.id,
email: session.user.email,
});
const url = await createAuthorizationURL({
id: providerId,
options: {
clientId,
clientSecret,
redirectURI:
redirectURI || `${c.context.baseURL}/oauth2/callback`,
},
authorizationEndpoint: finalAuthUrl,
state: state.state,
codeVerifier: pkce ? state.codeVerifier : undefined,
scopes: scopes || [],
redirectURI: `${c.context.baseURL}/oauth2/callback/${providerId}`,
});
return c.json({
url: url.toString(),
redirect: true,
});
},
),
},
$ERROR_CODES: ERROR_CODES,
} satisfies BetterAuthPlugin;

View File

@@ -42,6 +42,10 @@ interface MagicLinkOptions {
window: number;
max: number;
};
/**
* Custom function to generate a token
*/
generateToken?: (email: string) => Promise<string> | string;
}
export const magicLink = (options: MagicLinkOptions) => {
@@ -108,7 +112,9 @@ export const magicLink = (options: MagicLinkOptions) => {
}
}
const verificationToken = generateRandomString(32, "a-z", "A-Z");
const verificationToken = options?.generateToken
? await options.generateToken(email)
: generateRandomString(32, "a-z", "A-Z");
await ctx.context.internalAdapter.createVerificationValue({
identifier: verificationToken,
value: JSON.stringify({ email, name: ctx.body.name }),

View File

@@ -133,6 +133,36 @@ describe("magic link", async () => {
emailVerified: true,
});
});
it("should use custom generateToken function", async () => {
const customGenerateToken = vi.fn(() => "custom_token");
const { customFetchImpl } = await getTestInstance({
plugins: [
magicLink({
async sendMagicLink(data) {
verificationEmail = data;
},
generateToken: customGenerateToken,
}),
],
});
const customClient = createAuthClient({
plugins: [magicLinkClient()],
fetchOptions: {
customFetchImpl,
},
baseURL: "http://localhost:3000/api/auth",
});
await customClient.signIn.magicLink({
email: testUser.email,
});
expect(customGenerateToken).toHaveBeenCalled();
expect(verificationEmail.token).toBe("custom_token");
});
});
describe("magic link verify", async () => {

View File

@@ -8,7 +8,8 @@ import { ORGANIZATION_ERROR_CODES } from "./error-codes";
import { BetterAuthError } from "../../error";
describe("organization", async (it) => {
const { auth, signInWithTestUser, signInWithUser } = await getTestInstance({
const { auth, signInWithTestUser, signInWithUser, cookieSetter } =
await getTestInstance({
user: {
modelName: "users",
},
@@ -333,6 +334,36 @@ describe("organization", async (it) => {
);
});
it("should allow leaving organization", async () => {
const newUser = {
email: "leave@org.com",
name: "leaving member",
password: "password",
};
const headers = new Headers();
const res = await client.signUp.email(newUser, {
onSuccess: cookieSetter(headers),
});
const member = await auth.api.addMember({
body: {
organizationId,
userId: res.data?.user.id!,
role: "admin",
},
});
const leaveRes = await client.organization.leave(
{
organizationId,
},
{
headers,
},
);
expect(leaveRes.data).toMatchObject({
userId: res.data?.user.id!,
});
});
it("should allow removing member from organization", async () => {
const { headers } = await signInWithTestUser();
const orgBefore = await client.organization.getFullOrganization({

View File

@@ -31,6 +31,7 @@ import {
import {
addMember,
getActiveMember,
leaveOrganization,
removeMember,
updateMemberRole,
} from "./routes/crud-members";
@@ -256,6 +257,7 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
removeMember,
updateMemberRole: updateMemberRole(options as O),
getActiveMember,
leaveOrganization,
};
const roles = {

View File

@@ -6,7 +6,7 @@ import type { InferRolesFromOption, Member } from "../schema";
import { APIError } from "better-call";
import { generateId } from "../../../utils";
import type { OrganizationOptions } from "../organization";
import { getSessionFromCtx } from "../../../api";
import { getSessionFromCtx, sessionMiddleware } from "../../../api";
import { ORGANIZATION_ERROR_CODES } from "../error-codes";
import { BASE_ERROR_CODES } from "../../../error/codes";
import { hasPermission } from "../has-permission";
@@ -402,3 +402,55 @@ export const getActiveMember = createAuthEndpoint(
return ctx.json(member);
},
);
export const leaveOrganization = createAuthEndpoint(
"/organization/leave",
{
method: "POST",
body: z.object({
organizationId: z.string(),
}),
use: [sessionMiddleware, orgMiddleware],
},
async (ctx) => {
const session = ctx.context.session;
const adapter = getOrgAdapter(ctx.context);
const member = await adapter.findMemberByOrgId({
userId: session.user.id,
organizationId: ctx.body.organizationId,
});
if (!member) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND,
});
}
const isOwnerLeaving =
member.role === (ctx.context.orgOptions?.creatorRole || "owner");
if (isOwnerLeaving) {
const members = await ctx.context.adapter.findMany<Member>({
model: "member",
where: [
{
field: "organizationId",
value: ctx.body.organizationId,
},
],
});
const owners = members.filter(
(member) =>
member.role === (ctx.context.orgOptions?.creatorRole || "owner"),
);
if (owners.length <= 1) {
throw new APIError("BAD_REQUEST", {
message:
ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER,
});
}
}
await adapter.deleteMember(member.id);
if (session.session.activeOrganizationId === ctx.body.organizationId) {
await adapter.setActiveOrganization(session.session.token, null);
}
return ctx.json(member);
},
);

View File

@@ -68,7 +68,7 @@ export interface PhoneNumberOptions {
callbackOnVerification?: (
data: {
phoneNumber: string;
user: UserWithPhoneNumber | null;
user: UserWithPhoneNumber;
},
request?: Request,
) => void | Promise<void>;
@@ -475,13 +475,6 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
},
],
});
await options?.callbackOnVerification?.(
{
phoneNumber: ctx.body.phoneNumber,
user,
},
ctx.request,
);
if (!user) {
if (options?.signUpOnVerification) {
user = await ctx.context.internalAdapter.createUser({
@@ -501,8 +494,6 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
message: BASE_ERROR_CODES.FAILED_TO_CREATE_USER,
});
}
} else {
return ctx.json(null);
}
} else {
user = await ctx.context.internalAdapter.updateUser(user.id, {
@@ -510,6 +501,18 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
});
}
if (!user) {
return ctx.json(null);
}
await options?.callbackOnVerification?.(
{
phoneNumber: ctx.body.phoneNumber,
user,
},
ctx.request,
);
if (!user) {
throw new APIError("INTERNAL_SERVER_ERROR", {
message: BASE_ERROR_CODES.FAILED_TO_UPDATE_USER,

View File

@@ -440,7 +440,7 @@ export type BetterAuthOptions = {
/**
* List of trusted origins.
*/
trustedOrigins?: string[];
trustedOrigins?: string[] | ((request: Request) => string[]);
/**
* Rate limiting configuration
*/
@@ -637,7 +637,7 @@ export type BetterAuthOptions = {
| boolean
| void
| {
data: User & Record<string, any>;
data: Partial<User> & Record<string, any>;
}
>;
/**
@@ -678,7 +678,7 @@ export type BetterAuthOptions = {
| boolean
| void
| {
data: Session & Record<string, any>;
data: Partial<Session> & Record<string, any>;
}
>;
/**
@@ -722,7 +722,7 @@ export type BetterAuthOptions = {
| boolean
| void
| {
data: Account & Record<string, any>;
data: Partial<Account> & Record<string, any>;
}
>;
/**
@@ -766,7 +766,7 @@ export type BetterAuthOptions = {
| boolean
| void
| {
data: Verification & Record<string, any>;
data: Partial<Verification> & Record<string, any>;
}
>;
/**

View File

@@ -89,7 +89,7 @@ export const generateDrizzleSchema: SchemaGenerator = async ({
usePlural
? `${attr.references.model}s`
: attr.references.model
}.${attr.references.field})`
}.${attr.references.field}, { onDelete: 'cascade' })`
: ""
}`;
})

View File

@@ -21,14 +21,14 @@ export const session = pgTable("session", {
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id').notNull().references(()=> user.id)
userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' })
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(()=> user.id),
userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
@@ -53,5 +53,5 @@ export const twoFactor = pgTable("two_factor", {
id: text("id").primaryKey(),
secret: text('secret').notNull(),
backupCodes: text('backup_codes').notNull(),
userId: text('user_id').notNull().references(()=> user.id)
userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' })
});

View File

@@ -14,8 +14,8 @@ export const expo = (options?: ExpoOptions) => {
init: (ctx) => {
const trustedOrigins =
process.env.NODE_ENV === "development"
? [...(ctx.options.trustedOrigins || []), "exp://"]
: ctx.options.trustedOrigins;
? [...(ctx.trustedOrigins || []), "exp://"]
: ctx.trustedOrigins;
return {
options: {
trustedOrigins,
@@ -56,9 +56,9 @@ export const expo = (options?: ExpoOptions) => {
return;
}
const trustedOrigins = ctx.context.trustedOrigins.filter(
(origin) => !origin.startsWith("http"),
(origin: string) => !origin.startsWith("http"),
);
const isTrustedOrigin = trustedOrigins.some((origin) =>
const isTrustedOrigin = trustedOrigins.some((origin: string) =>
location?.startsWith(origin),
);
if (!isTrustedOrigin) {