feat: expo plugin (#375)
3
.npmrc
@@ -1 +1,2 @@
|
|||||||
link-workspace-packages=true
|
link-workspace-packages=true
|
||||||
|
node-linker=hoisted
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"prisma": "^5.19.1",
|
"prisma": "^5.19.1",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
"react": "19.0.0-rc-69d4b800-20241021",
|
"react": "^19.0.0-rc-603e6108-20241029",
|
||||||
"react-dom": "19.0.0-rc-69d4b800-20241021",
|
"react-dom": "^19.0.0-rc-603e6108-20241029",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-qr-code": "^2.0.15",
|
"react-qr-code": "^2.0.15",
|
||||||
"react-resizable-panels": "^2.1.2",
|
"react-resizable-panels": "^2.1.2",
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/three": "^0.168.0",
|
"@types/three": "^0.168.0",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
|||||||
@@ -188,8 +188,9 @@ export default function Features({ stars }: { stars: string | null }) {
|
|||||||
"svelteKit",
|
"svelteKit",
|
||||||
"astro",
|
"astro",
|
||||||
"solidStart",
|
"solidStart",
|
||||||
"react",
|
// "react",
|
||||||
"hono",
|
// "hono",
|
||||||
|
"expo",
|
||||||
"tanstack",
|
"tanstack",
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -392,4 +392,18 @@ export const Icons = {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
expo: (props?: SVGProps<any>) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1.2em"
|
||||||
|
height="1.2em"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M24.292 15.547a3.93 3.93 0 0 0 4.115-3.145a2.57 2.57 0 0 0-2.161-1.177c-2.272-.052-3.491 2.651-1.953 4.323zm-9.177-10.85l5.359-3.104L18.766.63l-7.391 4.281l.589.328l1.119.629l2.032-1.176zm6.046-3.39c.089.027.161.1.188.188l2.484 7.593a.285.285 0 0 1-.125.344a5.06 5.06 0 0 0-2.317 5.693a5.066 5.066 0 0 0 5.401 3.703a.3.3 0 0 1 .307.203l2.563 7.803a.3.3 0 0 1-.125.344l-7.859 4.771a.3.3 0 0 1-.131.036a.26.26 0 0 1-.203-.041l-2.765-1.797a.3.3 0 0 1-.109-.129l-5.396-12.896l-8.219 4.875c-.016.011-.037.021-.052.032a.3.3 0 0 1-.261-.021l-1.859-1.093a.283.283 0 0 1-.115-.381l7.953-15.749a.27.27 0 0 1 .135-.131L18.615.045a.29.29 0 0 1 .292-.005zm-8.322 5.1l-1.932-1.089l-7.693 15.229l1.396.823l6.631-9.015a.28.28 0 0 1 .271-.12a.29.29 0 0 1 .235.177l7.228 17.296l1.933 1.251l-8.063-24.552zm13.406 10.557c-2.256 0-3.787-2.292-2.923-4.376c.86-2.083 3.563-2.619 5.156-1.025c.595.593.928 1.396.928 2.235a3.16 3.16 0 0 1-3.161 3.167z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -632,6 +632,17 @@ export const contents: Content[] = [
|
|||||||
icon: Icons.elysia,
|
icon: Icons.elysia,
|
||||||
href: "/docs/integrations/elysia",
|
href: "/docs/integrations/elysia",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: true,
|
||||||
|
title: "Mobile & Desktop",
|
||||||
|
href: "/docs/integrations",
|
||||||
|
icon: LucideAArrowDown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Expo",
|
||||||
|
icon: Icons.expo,
|
||||||
|
href: "/docs/integrations/expo",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,4 +38,8 @@ export const techStackIcons: TechStackIconType = {
|
|||||||
name: "TanStack Start",
|
name: "TanStack Start",
|
||||||
icon: <Icons.tanstack className="w-10 h-10" />,
|
icon: <Icons.tanstack className="w-10 h-10" />,
|
||||||
},
|
},
|
||||||
|
expo: {
|
||||||
|
name: "Expo",
|
||||||
|
icon: <Icons.expo className="w-10 h-10" />,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ Create a new file or route in your framework's designated catch-all route handle
|
|||||||
Better Auth supports any backend framework with standard Request and Response objects and offers helper functions for popular frameworks.
|
Better Auth supports any backend framework with standard Request and Response objects and offers helper functions for popular frameworks.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
<Tabs items={["next-js", "nuxt", "svelte-kit", "remix", "solid-start", "hono", "express", "elysia", "tanstack-start"]} defaultValue="react">
|
<Tabs items={["next-js", "nuxt", "svelte-kit", "remix", "solid-start", "hono", "express", "elysia", "tanstack", "expo"]} defaultValue="react">
|
||||||
<Tab value="next-js">
|
<Tab value="next-js">
|
||||||
```ts title="/app/api/auth/[...all]/route.ts"
|
```ts title="/app/api/auth/[...all]/route.ts"
|
||||||
import { auth } from "@/lib/auth"; // path to your auth file
|
import { auth } from "@/lib/auth"; // path to your auth file
|
||||||
@@ -355,8 +355,7 @@ Better Auth supports any backend framework with standard Request and Response ob
|
|||||||
```
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab value="tanstack-start">
|
<Tab value="tanstack-start">
|
||||||
```ts
|
```ts title="app/routes/api/auth/$.ts"
|
||||||
// app/routes/api/auth/$.ts
|
|
||||||
import { auth } from '~/lib/server/auth'
|
import { auth } from '~/lib/server/auth'
|
||||||
import { createAPIFileRoute } from '@tanstack/start/api'
|
import { createAPIFileRoute } from '@tanstack/start/api'
|
||||||
|
|
||||||
@@ -370,6 +369,14 @@ Better Auth supports any backend framework with standard Request and Response ob
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab value="expo">
|
||||||
|
```ts title="app/api/auth/[..all]+api.ts"
|
||||||
|
import { auth } from '@/lib/server/auth'; // path to your auth file
|
||||||
|
|
||||||
|
const handler = auth.handler;
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
|
|||||||
224
docs/content/docs/integrations/expo.mdx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
---
|
||||||
|
title: Expo
|
||||||
|
description: Learn how to use Better Auth with Expo.
|
||||||
|
---
|
||||||
|
|
||||||
|
Expo is a popular framework for building cross-platform apps with React Native. Better Auth supports both Expo native and web apps.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step>
|
||||||
|
## Configure A Better Auth Backend
|
||||||
|
Before using Better Auth with Expo, make sure you have a Better Auth backend set up. You can either use a separate server or leverage Expo's new [API Routes](https://docs.expo.dev/router/reference/api-routes) feature to host your Better Auth instance.
|
||||||
|
|
||||||
|
To get started, check out our [installation](/docs/installation) guide for setting up Better Auth on your server.
|
||||||
|
|
||||||
|
To use the new API routes feature in Expo to host your Better Auth instance you can create a new API route in your Expo app and mount the Better Auth handler.
|
||||||
|
|
||||||
|
```ts title="app/api/auth/[...auth]+api.ts"
|
||||||
|
import { auth } from "@/lib/auth"; // import Better Auth handler
|
||||||
|
|
||||||
|
const handler = auth.handler;
|
||||||
|
export { handler as GET, handler as POST }; // export handler for both GET and POST requests
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
## Install Better Auth and Expo Plugin
|
||||||
|
|
||||||
|
With your server up and running, install both Better Auth and the Expo plugin in your Expo project.
|
||||||
|
|
||||||
|
```package-install
|
||||||
|
better-auth @better-auth/expo
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
## Initialize Better Auth Client
|
||||||
|
|
||||||
|
To initialize Better Auth in your Expo app, you need to call `createAuthClient` with the base url of your Better Auth backend. Make sure to import the client from `/react`.
|
||||||
|
|
||||||
|
```ts title="src/auth-client.ts"
|
||||||
|
import { createAuthClient } from 'better-auth/react';
|
||||||
|
|
||||||
|
const authClient = createAuthClient({
|
||||||
|
basURL: 'http://localhost:8081', /* base url of your Better Auth backend. */
|
||||||
|
});
|
||||||
|
```
|
||||||
|
<Callout>
|
||||||
|
Be sure to include the full URL, including the path, if you've changed the default path from `/api/auth`.
|
||||||
|
</Callout>
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
## Configure Metro Bundler
|
||||||
|
|
||||||
|
To resolve better auth exports you'll need to enable `unstable_enablePackageExports` in your metro config.
|
||||||
|
|
||||||
|
```js title="metro.config.js"
|
||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname)
|
||||||
|
|
||||||
|
config.resolver.unstable_enablePackageExports = true; // [!code highlight]
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Authenticating Users
|
||||||
|
|
||||||
|
With Better Auth initialized, you can now use the `authClient` to authenticate users in your Expo app.
|
||||||
|
|
||||||
|
<Tabs items={["sign-in", "sign-up"]}>
|
||||||
|
<Tab value="sign-in">
|
||||||
|
```tsx title="app/sign-in.tsx"
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { View, TextInput, Button } from 'react-native';
|
||||||
|
import { authClient } from './auth-client';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
await authClient.signIn.email({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
/>
|
||||||
|
<Button title="Login" onPress={handleLogin} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
<Tab value="sign-up">
|
||||||
|
```tsx title="app/sign-up.tsx"
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { View, TextInput, Button } from 'react-native';
|
||||||
|
import { authClient } from './auth-client';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
await authClient.signUp.email({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
name
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Name"
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
/>
|
||||||
|
<Button title="Login" onPress={handleLogin} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
For social sign-in, you can use the `authClient.signIn.social` method with the provider name and a callback URL.
|
||||||
|
|
||||||
|
```tsx title="app/social-sign-in.tsx"
|
||||||
|
|
||||||
|
import { Button } from 'react-native';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const handleLogin = async () => {
|
||||||
|
await authClient.signIn.social({
|
||||||
|
provider: 'google',
|
||||||
|
callbackURL: "/dashboard" // this will be converted to a deep link (eg. `myapp://dashboard`) on native
|
||||||
|
})
|
||||||
|
};
|
||||||
|
return <Button title="Login with Google" onPress={handleLogin} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session
|
||||||
|
|
||||||
|
Better Auth provides a `useSession` hook to access the current user's session in your app.
|
||||||
|
|
||||||
|
```tsx title="src/App.tsx"
|
||||||
|
|
||||||
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
|
return <Text>Welcome, {data.user.name}</Text>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On native, the session data will be cached in SecureStore. This will allow you to remove the need for a loading spinner when the app is reloaded. You can disable this behavior by passing the `disableCache` option to the client.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
|
||||||
|
**storage**: on native you can pass a storage option to the client to change the storage mechanism. By default, the client will use SecureStore.
|
||||||
|
|
||||||
|
```ts title="src/auth-client.ts"
|
||||||
|
import { createAuthClient } from 'better-auth/react';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const authClient = createAuthClient({
|
||||||
|
basURL: 'http://localhost:8081',
|
||||||
|
storage: AsyncStorage
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**scheme**: scheme is used to deep link back to your app after a user has authenticated using oAuth providers. By default, Better Auth tries to read the scheme from the `app.json` file. If you need to override this, you can pass the scheme option to the client.
|
||||||
|
|
||||||
|
```ts title="src/auth-client.ts"
|
||||||
|
import { createAuthClient } from 'better-auth/react';
|
||||||
|
|
||||||
|
const authClient = createAuthClient({
|
||||||
|
basURL: 'http://localhost:8081',
|
||||||
|
scheme: 'myapp'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**disableCache**: By default, the client will cache the session data in SecureStore. You can disable this behavior by passing the `disableCache` option to the client.
|
||||||
|
|
||||||
|
```ts title="src/auth-client.ts"
|
||||||
|
import { createAuthClient } from 'better-auth/react';
|
||||||
|
|
||||||
|
const authClient = createAuthClient({
|
||||||
|
basURL: 'http://localhost:8081',
|
||||||
|
disableCache: true
|
||||||
|
});
|
||||||
|
```
|
||||||
20
examples/expo-example/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
npm-debug.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
50
examples/expo-example/README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Welcome to your Expo app 👋
|
||||||
|
|
||||||
|
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
In the output, you'll find options to open the app in a
|
||||||
|
|
||||||
|
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||||
|
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
|
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||||
|
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||||
|
|
||||||
|
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||||
|
|
||||||
|
## Get a fresh project
|
||||||
|
|
||||||
|
When you're ready, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run reset-project
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||||
|
|
||||||
|
## Learn more
|
||||||
|
|
||||||
|
To learn more about developing your project with Expo, look at the following resources:
|
||||||
|
|
||||||
|
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||||
|
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||||
|
|
||||||
|
## Join the community
|
||||||
|
|
||||||
|
Join our community of developers creating universal apps.
|
||||||
|
|
||||||
|
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||||
|
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||||
54
examples/expo-example/app.config.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ConfigContext, ExpoConfig } from "expo/config";
|
||||||
|
|
||||||
|
export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||||
|
...config,
|
||||||
|
name: "Better Auth",
|
||||||
|
slug: "better-auth",
|
||||||
|
scheme: "better-auth",
|
||||||
|
version: "0.1.0",
|
||||||
|
orientation: "portrait",
|
||||||
|
icon: "./assets/icon.png",
|
||||||
|
userInterfaceStyle: "automatic",
|
||||||
|
splash: {
|
||||||
|
image: "./assets/icon.png",
|
||||||
|
resizeMode: "contain",
|
||||||
|
backgroundColor: "#1F104A",
|
||||||
|
},
|
||||||
|
web: {
|
||||||
|
bundler: "metro",
|
||||||
|
output: "server",
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
fallbackToCacheTimeout: 0,
|
||||||
|
},
|
||||||
|
assetBundlePatterns: ["**/*"],
|
||||||
|
ios: {
|
||||||
|
bundleIdentifier: "your.bundle.identifier",
|
||||||
|
supportsTablet: true,
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
package: "your.bundle.identifier",
|
||||||
|
adaptiveIcon: {
|
||||||
|
foregroundImage: "./assets/icon.png",
|
||||||
|
backgroundColor: "#1F104A",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// extra: {
|
||||||
|
// eas: {
|
||||||
|
// projectId: "your-eas-project-id",
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
experiments: {
|
||||||
|
tsconfigPaths: true,
|
||||||
|
typedRoutes: true,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
"expo-router",
|
||||||
|
{
|
||||||
|
origin: "http://localhost:8081",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"expo-secure-store",
|
||||||
|
],
|
||||||
|
});
|
||||||
BIN
examples/expo-example/assets/bg-image.jpeg
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
examples/expo-example/assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
examples/expo-example/assets/icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
examples/expo-example/assets/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
examples/expo-example/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
examples/expo-example/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
examples/expo-example/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
examples/expo-example/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
examples/expo-example/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
examples/expo-example/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
examples/expo-example/assets/images/splash.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
9
examples/expo-example/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||||
|
"nativewind/babel",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
6
examples/expo-example/components.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"lib": "@/lib"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
examples/expo-example/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "expo-router/entry";
|
||||||
56
examples/expo-example/metro.config.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Learn more: https://docs.expo.dev/guides/monorepos/
|
||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
const { FileStore } = require("metro-cache");
|
||||||
|
const { withNativeWind } = require("nativewind/metro");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const config = withTurborepoManagedCache(
|
||||||
|
withMonorepoPaths(
|
||||||
|
withNativeWind(getDefaultConfig(__dirname), { input: "./src/global.css" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// XXX: Resolve our exports in workspace packages
|
||||||
|
// https://github.com/expo/expo/issues/26926
|
||||||
|
config.resolver.unstable_enablePackageExports = true;
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the monorepo paths to the Metro config.
|
||||||
|
* This allows Metro to resolve modules from the monorepo.
|
||||||
|
*
|
||||||
|
* @see https://docs.expo.dev/guides/monorepos/#modify-the-metro-config
|
||||||
|
* @param {import('expo/metro-config').MetroConfig} config
|
||||||
|
* @returns {import('expo/metro-config').MetroConfig}
|
||||||
|
*/
|
||||||
|
function withMonorepoPaths(config) {
|
||||||
|
const projectRoot = __dirname;
|
||||||
|
const workspaceRoot = path.resolve(projectRoot, "../..");
|
||||||
|
|
||||||
|
// #1 - Watch all files in the monorepo
|
||||||
|
config.watchFolders = [workspaceRoot];
|
||||||
|
|
||||||
|
// #2 - Resolve modules within the project's `node_modules` first, then all monorepo modules
|
||||||
|
config.resolver.nodeModulesPaths = [
|
||||||
|
path.resolve(projectRoot, "node_modules"),
|
||||||
|
path.resolve(workspaceRoot, "node_modules"),
|
||||||
|
];
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the Metro cache to the `.cache/metro` folder.
|
||||||
|
* If you have any environment variables, you can configure Turborepo to invalidate it when needed.
|
||||||
|
*
|
||||||
|
* @see https://turbo.build/repo/docs/reference/configuration#env
|
||||||
|
* @param {import('expo/metro-config').MetroConfig} config
|
||||||
|
* @returns {import('expo/metro-config').MetroConfig}
|
||||||
|
*/
|
||||||
|
function withTurborepoManagedCache(config) {
|
||||||
|
config.cacheStores = [
|
||||||
|
new FileStore({ root: path.join(__dirname, ".cache/metro") }),
|
||||||
|
];
|
||||||
|
return config;
|
||||||
|
}
|
||||||
3
examples/expo-example/nativewind-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="nativewind/types" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
|
||||||
63
examples/expo-example/package.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "expo-example",
|
||||||
|
"main": "index.ts",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "git clean -xdf .cache .expo .turbo android ios node_modules",
|
||||||
|
"start": "expo start",
|
||||||
|
"dev": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"test": "jest --watchAll",
|
||||||
|
"lint": "expo lint",
|
||||||
|
"android": "expo run:android"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@better-auth/expo": "workspace:*",
|
||||||
|
"@expo/vector-icons": "^14.0.2",
|
||||||
|
"@nanostores/react": "^0.8.0",
|
||||||
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
|
"@react-navigation/native": "^6.0.2",
|
||||||
|
"@rn-primitives/avatar": "^1.1.0",
|
||||||
|
"@rn-primitives/separator": "^1.1.0",
|
||||||
|
"@rn-primitives/slot": "^1.1.0",
|
||||||
|
"@rn-primitives/types": "^1.1.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"babel-plugin-transform-import-meta": "^2.2.1",
|
||||||
|
"better-sqlite3": "^11.5.0",
|
||||||
|
"expo": "~51.0.38",
|
||||||
|
"expo-constants": "~16.0.2",
|
||||||
|
"expo-font": "~12.0.9",
|
||||||
|
"expo-linking": "~6.3.1",
|
||||||
|
"expo-router": "~3.5.23",
|
||||||
|
"expo-secure-store": "~13.0.2",
|
||||||
|
"expo-splash-screen": "~0.27.5",
|
||||||
|
"expo-status-bar": "~1.12.1",
|
||||||
|
"expo-system-ui": "~3.0.7",
|
||||||
|
"expo-web-browser": "~13.0.3",
|
||||||
|
"nanostores": "^0.11.2",
|
||||||
|
"nativewind": "^4.1.21",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-native": "~0.74.6",
|
||||||
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
|
"react-native-reanimated": "~3.10.1",
|
||||||
|
"react-native-safe-area-context": "4.10.5",
|
||||||
|
"react-native-screens": "3.31.1",
|
||||||
|
"react-native-svg": "^15.8.0",
|
||||||
|
"react-native-web": "~0.19.10",
|
||||||
|
"tailwindcss": "^3.4.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.25.8",
|
||||||
|
"@babel/preset-env": "^7.25.8",
|
||||||
|
"@babel/runtime": "^7.25.7",
|
||||||
|
"@types/babel__core": "^7.20.5",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/react": "^18.3.1",
|
||||||
|
"@types/react-test-renderer": "^18.0.7",
|
||||||
|
"typescript": "~5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
examples/expo-example/src/app/_layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Slot, Stack } from "expo-router";
|
||||||
|
import "../global.css";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import { ImageBackground, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<ImageBackground
|
||||||
|
className="z-0 flex items-center justify-center"
|
||||||
|
source={require("../../assets/bg-image.jpeg")}
|
||||||
|
resizeMode="cover"
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "black",
|
||||||
|
opacity: 0.2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Slot />
|
||||||
|
</ImageBackground>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
examples/expo-example/src/app/api/auth/[...route]+api.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const GET = (request: Request) => {
|
||||||
|
return auth.handler(request);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = (request: Request) => {
|
||||||
|
return auth.handler(request);
|
||||||
|
};
|
||||||
67
examples/expo-example/src/app/dashboard.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardFooter, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import Ionicons from "@expo/vector-icons/AntDesign";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { data: session, error } = authClient.useSession();
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
return (
|
||||||
|
<Card className="w-10/12">
|
||||||
|
<CardHeader>
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Avatar alt="user-image">
|
||||||
|
<AvatarImage
|
||||||
|
source={{
|
||||||
|
uri: session?.user?.image,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
<Text>{session?.user?.name[0]}</Text>
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<View>
|
||||||
|
<Text className="font-bold">{session?.user?.name}</Text>
|
||||||
|
<Text className="text-sm">{session?.user?.email}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="justify-between">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-row items-center gap-2 "
|
||||||
|
>
|
||||||
|
<Ionicons name="edit" size={16} color="white" />
|
||||||
|
<Text>Edit User</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-row items-center gap-2"
|
||||||
|
size="sm"
|
||||||
|
onPress={async () => {
|
||||||
|
await authClient.signOut({
|
||||||
|
fetchOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push("/");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="logout" size={14} color="black" />
|
||||||
|
<Text>Sign Out</Text>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
examples/expo-example/src/app/forget-password.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import Icons from "@expo/vector-icons/AntDesign";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
export default function ForgetPassword() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
return (
|
||||||
|
<Card className="w-10/12 ">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Forget Password</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email to reset your password
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<View className="px-6 mb-2">
|
||||||
|
<Input
|
||||||
|
autoCapitalize="none"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={(text) => setEmail(text)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<CardFooter>
|
||||||
|
<View className="w-full gap-2">
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
authClient.forgetPassword({
|
||||||
|
email,
|
||||||
|
redirectTo: "/reset-password",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<Text>Send Email</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/");
|
||||||
|
}}
|
||||||
|
className="w-full flex-row gap-4 items-center"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Icons name="arrowleft" size={18} />
|
||||||
|
<Text>Back to Sign In</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
examples/expo-example/src/app/index.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import Ionicons from "@expo/vector-icons/AntDesign";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { Image, View } from "react-native";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { router, useNavigationContainerRef } from "expo-router";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { data: isAuthenticated } = authClient.useSession();
|
||||||
|
const navContainerRef = useNavigationContainerRef();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
if (navContainerRef.isReady()) {
|
||||||
|
router.push("/dashboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navContainerRef.isReady()]);
|
||||||
|
return (
|
||||||
|
<Card className="z-50 mx-6 backdrop-blur-lg bg-gray-200/70">
|
||||||
|
<CardHeader className="flex items-center justify-center gap-8">
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/images/logo.png")}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CardTitle>Sign In to your account</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<View className="px-6 flex gap-2">
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
authClient.signIn.social({
|
||||||
|
provider: "google",
|
||||||
|
callbackURL: "/dashboard",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex flex-row gap-2 items-center bg-white/50"
|
||||||
|
>
|
||||||
|
<Ionicons name="google" size={16} />
|
||||||
|
<Text>Sign In with Google</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="flex flex-row gap-2 items-center bg-white/50"
|
||||||
|
onPress={() => {
|
||||||
|
authClient.signIn.social({
|
||||||
|
provider: "github",
|
||||||
|
callbackURL: "/dashboard",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="github" size={16} />
|
||||||
|
<Text>Sign In with Github</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-2 w-full items-center px-6 my-4">
|
||||||
|
<Separator className="flex-grow w-3/12" />
|
||||||
|
<Text>or continue with</Text>
|
||||||
|
<Separator className="flex-grow w-3/12" />
|
||||||
|
</View>
|
||||||
|
<View className="px-6">
|
||||||
|
<Input
|
||||||
|
placeholder="Email Address"
|
||||||
|
className="rounded-b-none border-b-0"
|
||||||
|
value={email}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setEmail(text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Password"
|
||||||
|
className="rounded-t-none"
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPassword(text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<CardFooter>
|
||||||
|
<View className="w-full">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="w-full"
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/forget-password");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="underline text-center">Forget Password?</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
authClient.signIn.email(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (ctx) => {
|
||||||
|
alert(ctx.error.message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>Continue</Text>
|
||||||
|
</Button>
|
||||||
|
<Text className="text-center mt-2">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Text
|
||||||
|
className="underline"
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/sign-up");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
examples/expo-example/src/app/sign-up.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { KeyboardAvoidingView, View } from "react-native";
|
||||||
|
import { Image } from "react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function SignUp() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
return (
|
||||||
|
<Card className="z-50 mx-6">
|
||||||
|
<CardHeader className="flex items-center justify-center gap-8">
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/images/logo.png")}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CardTitle>Create new Account</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<View className="px-6">
|
||||||
|
<KeyboardAvoidingView>
|
||||||
|
<Input
|
||||||
|
placeholder="Name"
|
||||||
|
className="rounded-b-none border-b-0"
|
||||||
|
value={name}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setName(text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
<KeyboardAvoidingView>
|
||||||
|
<Input
|
||||||
|
placeholder="Email"
|
||||||
|
className="rounded-b-none border-b-0"
|
||||||
|
value={email}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setEmail(text);
|
||||||
|
}}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView>
|
||||||
|
<Input
|
||||||
|
placeholder="Password"
|
||||||
|
secureTextEntry
|
||||||
|
className="rounded-t-none"
|
||||||
|
value={password}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPassword(text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</View>
|
||||||
|
<CardFooter>
|
||||||
|
<View className="w-full mt-2">
|
||||||
|
<Button
|
||||||
|
onPress={async () => {
|
||||||
|
const res = await authClient.signUp.email(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (ctx) => {
|
||||||
|
alert(ctx.error.message);
|
||||||
|
},
|
||||||
|
onSuccess: (ctx) => {
|
||||||
|
router.push("/dashboard");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
console.log(res);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>Sign Up</Text>
|
||||||
|
</Button>
|
||||||
|
<Text className="text-center mt-2">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Text
|
||||||
|
className="underline"
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
examples/expo-example/src/components/icons/google.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Svg, { Path, SvgProps } from "react-native-svg";
|
||||||
|
|
||||||
|
export function GoogleIcon(props: SvgProps) {
|
||||||
|
return (
|
||||||
|
<Svg width="1em" height="1em" viewBox="0 0 128 128">
|
||||||
|
<Path
|
||||||
|
fill="#fff"
|
||||||
|
d="M44.59 4.21a63.28 63.28 0 0 0 4.33 120.9a67.6 67.6 0 0 0 32.36.35a57.13 57.13 0 0 0 25.9-13.46a57.44 57.44 0 0 0 16-26.26a74.3 74.3 0 0 0 1.61-33.58H65.27v24.69h34.47a29.72 29.72 0 0 1-12.66 19.52a36.2 36.2 0 0 1-13.93 5.5a41.3 41.3 0 0 1-15.1 0A37.2 37.2 0 0 1 44 95.74a39.3 39.3 0 0 1-14.5-19.42a38.3 38.3 0 0 1 0-24.63a39.25 39.25 0 0 1 9.18-14.91A37.17 37.17 0 0 1 76.13 27a34.3 34.3 0 0 1 13.64 8q5.83-5.8 11.64-11.63c2-2.09 4.18-4.08 6.15-6.22A61.2 61.2 0 0 0 87.2 4.59a64 64 0 0 0-42.61-.38"
|
||||||
|
></Path>
|
||||||
|
<Path
|
||||||
|
fill="#e33629"
|
||||||
|
d="M44.59 4.21a64 64 0 0 1 42.61.37a61.2 61.2 0 0 1 20.35 12.62c-2 2.14-4.11 4.14-6.15 6.22Q95.58 29.23 89.77 35a34.3 34.3 0 0 0-13.64-8a37.17 37.17 0 0 0-37.46 9.74a39.25 39.25 0 0 0-9.18 14.91L8.76 35.6A63.53 63.53 0 0 1 44.59 4.21"
|
||||||
|
></Path>
|
||||||
|
<Path
|
||||||
|
fill="#f8bd00"
|
||||||
|
d="M3.26 51.5a63 63 0 0 1 5.5-15.9l20.73 16.09a38.3 38.3 0 0 0 0 24.63q-10.36 8-20.73 16.08a63.33 63.33 0 0 1-5.5-40.9"
|
||||||
|
></Path>
|
||||||
|
<Path
|
||||||
|
fill="#587dbd"
|
||||||
|
d="M65.27 52.15h59.52a74.3 74.3 0 0 1-1.61 33.58a57.44 57.44 0 0 1-16 26.26c-6.69-5.22-13.41-10.4-20.1-15.62a29.72 29.72 0 0 0 12.66-19.54H65.27c-.01-8.22 0-16.45 0-24.68"
|
||||||
|
></Path>
|
||||||
|
<Path
|
||||||
|
fill="#319f43"
|
||||||
|
d="M8.75 92.4q10.37-8 20.73-16.08A39.3 39.3 0 0 0 44 95.74a37.2 37.2 0 0 0 14.08 6.08a41.3 41.3 0 0 0 15.1 0a36.2 36.2 0 0 0 13.93-5.5c6.69 5.22 13.41 10.4 20.1 15.62a57.13 57.13 0 0 1-25.9 13.47a67.6 67.6 0 0 1-32.36-.35a63 63 0 0 1-23-11.59A63.7 63.7 0 0 1 8.75 92.4"
|
||||||
|
></Path>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
examples/expo-example/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as AvatarPrimitive from "@rn-primitives/avatar";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
AvatarPrimitive.RootRef,
|
||||||
|
AvatarPrimitive.RootProps
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
AvatarPrimitive.ImageRef,
|
||||||
|
AvatarPrimitive.ImageProps
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
AvatarPrimitive.FallbackRef,
|
||||||
|
AvatarPrimitive.FallbackProps
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarFallback, AvatarImage };
|
||||||
92
examples/expo-example/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Pressable } from "react-native";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { TextClassContext } from "@/components/ui/text";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group flex items-center justify-center rounded-md web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary web:hover:opacity-90 active:opacity-90",
|
||||||
|
destructive: "bg-destructive web:hover:opacity-90 active:opacity-90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
|
||||||
|
secondary: "bg-secondary web:hover:opacity-80 active:opacity-80",
|
||||||
|
ghost:
|
||||||
|
"web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
|
||||||
|
link: "web:underline-offset-4 web:hover:underline web:focus:underline ",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2 native:h-12 native:px-5 native:py-3",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8 native:h-14",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttonTextVariants = cva(
|
||||||
|
"web:whitespace-nowrap text-sm native:text-base font-medium text-foreground web:transition-colors",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "text-primary-foreground",
|
||||||
|
destructive: "text-destructive-foreground",
|
||||||
|
outline: "group-active:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"text-secondary-foreground group-active:text-secondary-foreground",
|
||||||
|
ghost: "group-active:text-accent-foreground",
|
||||||
|
link: "text-primary group-active:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "",
|
||||||
|
sm: "",
|
||||||
|
lg: "native:text-lg",
|
||||||
|
icon: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type ButtonProps = React.ComponentPropsWithoutRef<typeof Pressable> &
|
||||||
|
VariantProps<typeof buttonVariants>;
|
||||||
|
|
||||||
|
const Button = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Pressable>,
|
||||||
|
ButtonProps
|
||||||
|
>(({ className, variant, size, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<TextClassContext.Provider
|
||||||
|
value={buttonTextVariants({
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
className: "web:pointer-events-none",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
className={cn(
|
||||||
|
props.disabled && "opacity-50 web:pointer-events-none",
|
||||||
|
buttonVariants({ variant, size, className }),
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
role="button"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TextClassContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonTextVariants, buttonVariants };
|
||||||
|
export type { ButtonProps };
|
||||||
86
examples/expo-example/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { TextRef, ViewRef } from "@rn-primitives/types";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Text, type TextProps, View, type ViewProps } from "react-native";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { TextClassContext } from "@/components/ui/text";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<ViewRef, ViewProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<View
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-border bg-card shadow-sm shadow-foreground/10",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<ViewRef, ViewProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<View
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<TextRef, TextProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<Text
|
||||||
|
role="heading"
|
||||||
|
aria-level={3}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl text-card-foreground font-semibold leading-none tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<TextRef, TextProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<Text
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<ViewRef, ViewProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<TextClassContext.Provider value="text-card-foreground">
|
||||||
|
<View ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
</TextClassContext.Provider>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<ViewRef, ViewProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<View
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-row items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
};
|
||||||
166
examples/expo-example/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import * as DialogPrimitive from "@rn-primitives/dialog";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
||||||
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
|
import { X } from "@/lib/icons/X";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlayWeb = React.forwardRef<
|
||||||
|
DialogPrimitive.OverlayRef,
|
||||||
|
DialogPrimitive.OverlayProps
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { open } = DialogPrimitive.useRootContext();
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"bg-black/80 flex justify-center items-center p-2 absolute top-0 right-0 bottom-0 left-0",
|
||||||
|
open
|
||||||
|
? "web:animate-in web:fade-in-0"
|
||||||
|
: "web:animate-out web:fade-out-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DialogOverlayWeb.displayName = "DialogOverlayWeb";
|
||||||
|
|
||||||
|
const DialogOverlayNative = React.forwardRef<
|
||||||
|
DialogPrimitive.OverlayRef,
|
||||||
|
DialogPrimitive.OverlayProps
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
className={cn(
|
||||||
|
"flex bg-black/80 justify-center items-center p-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(150)}
|
||||||
|
exiting={FadeOut.duration(150)}
|
||||||
|
>
|
||||||
|
<>{children}</>
|
||||||
|
</Animated.View>
|
||||||
|
</DialogPrimitive.Overlay>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DialogOverlayNative.displayName = "DialogOverlayNative";
|
||||||
|
|
||||||
|
const DialogOverlay = Platform.select({
|
||||||
|
web: DialogOverlayWeb,
|
||||||
|
default: DialogOverlayNative,
|
||||||
|
});
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
DialogPrimitive.ContentRef,
|
||||||
|
DialogPrimitive.ContentProps & { portalHost?: string }
|
||||||
|
>(({ className, children, portalHost, ...props }, ref) => {
|
||||||
|
const { open } = DialogPrimitive.useRootContext();
|
||||||
|
return (
|
||||||
|
<DialogPortal hostName={portalHost}>
|
||||||
|
<DialogOverlay>
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"max-w-lg gap-4 border border-border web:cursor-default bg-background p-6 shadow-lg web:duration-200 rounded-lg",
|
||||||
|
open
|
||||||
|
? "web:animate-in web:fade-in-0 web:zoom-in-95"
|
||||||
|
: "web:animate-out web:fade-out-0 web:zoom-out-95",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
className={
|
||||||
|
"absolute right-4 top-4 p-0.5 web:group rounded-sm opacity-70 web:ring-offset-background web:transition-opacity web:hover:opacity-100 web:focus:outline-none web:focus:ring-2 web:focus:ring-ring web:focus:ring-offset-2 web:disabled:pointer-events-none"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X
|
||||||
|
size={Platform.OS === "web" ? 16 : 18}
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground",
|
||||||
|
open && "text-accent-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogOverlay>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: ViewProps) => (
|
||||||
|
<View
|
||||||
|
className={cn("flex flex-col gap-1.5 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: ViewProps) => (
|
||||||
|
<View
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
DialogPrimitive.TitleRef,
|
||||||
|
DialogPrimitive.TitleProps
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg native:text-xl text-foreground font-semibold leading-none tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
DialogPrimitive.DescriptionRef,
|
||||||
|
DialogPrimitive.DescriptionProps
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm native:text-base text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
25
examples/expo-example/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { TextInput, type TextInputProps } from "react-native";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Input = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TextInput>,
|
||||||
|
TextInputProps
|
||||||
|
>(({ className, placeholderClassName, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"web:flex h-10 native:h-12 web:w-full rounded-md border border-input bg-background px-3 web:py-2 text-base lg:text-sm native:text-lg native:leading-[1.25] text-foreground placeholder:text-muted-foreground web:ring-offset-background file:border-0 file:bg-transparent file:font-medium web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2",
|
||||||
|
props.editable === false && "opacity-50 web:cursor-not-allowed",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
placeholderClassName={cn("text-muted-foreground", placeholderClassName)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
28
examples/expo-example/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as SeparatorPrimitive from "@rn-primitives/separator";
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
SeparatorPrimitive.RootRef,
|
||||||
|
SeparatorPrimitive.RootProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
28
examples/expo-example/src/components/ui/text.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as Slot from "@rn-primitives/slot";
|
||||||
|
import { SlottableTextProps, TextRef } from "@rn-primitives/types";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Text as RNText } from "react-native";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TextClassContext = React.createContext<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const Text = React.forwardRef<TextRef, SlottableTextProps>(
|
||||||
|
({ className, asChild = false, ...props }, ref) => {
|
||||||
|
const textClass = React.useContext(TextClassContext);
|
||||||
|
const Component = asChild ? Slot.Text : RNText;
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={cn(
|
||||||
|
"text-base text-foreground web:select-text",
|
||||||
|
textClass,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Text.displayName = "Text";
|
||||||
|
|
||||||
|
export { Text, TextClassContext };
|
||||||
71
examples/expo-example/src/global.css
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 224 71.4% 4.1%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 224 71.4% 4.1%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 224 71.4% 4.1%;
|
||||||
|
--primary: 220.9 39.3% 11%;
|
||||||
|
--primary-foreground: 210 20% 98%;
|
||||||
|
--secondary: 220 14.3% 95.9%;
|
||||||
|
--secondary-foreground: 220.9 39.3% 11%;
|
||||||
|
--muted: 220 14.3% 95.9%;
|
||||||
|
--muted-foreground: 220 8.9% 46.1%;
|
||||||
|
--accent: 220 14.3% 95.9%;
|
||||||
|
--accent-foreground: 220.9 39.3% 11%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 20% 98%;
|
||||||
|
--border: 220 13% 91%;
|
||||||
|
--input: 220 13% 91%;
|
||||||
|
--ring: 224 71.4% 4.1%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 224 71.4% 4.1%;
|
||||||
|
--foreground: 210 20% 98%;
|
||||||
|
--card: 224 71.4% 4.1%;
|
||||||
|
--card-foreground: 210 20% 98%;
|
||||||
|
--popover: 224 71.4% 4.1%;
|
||||||
|
--popover-foreground: 210 20% 98%;
|
||||||
|
--primary: 210 20% 98%;
|
||||||
|
--primary-foreground: 220.9 39.3% 11%;
|
||||||
|
--secondary: 215 27.9% 16.9%;
|
||||||
|
--secondary-foreground: 210 20% 98%;
|
||||||
|
--muted: 215 27.9% 16.9%;
|
||||||
|
--muted-foreground: 217.9 10.6% 64.9%;
|
||||||
|
--accent: 215 27.9% 16.9%;
|
||||||
|
--accent-foreground: 210 20% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 20% 98%;
|
||||||
|
--border: 215 27.9% 16.9%;
|
||||||
|
--input: 215 27.9% 16.9%;
|
||||||
|
--ring: 216 12.2% 83.9%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
examples/expo-example/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import { expoClient } from "@better-auth/expo/client";
|
||||||
|
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
|
||||||
|
export const getBaseUrl = () => {
|
||||||
|
const debuggerHost = Constants.expoConfig?.hostUri;
|
||||||
|
const localhost = debuggerHost?.split(":")[0];
|
||||||
|
return `http://${localhost || "localhost"}:8081`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: getBaseUrl(),
|
||||||
|
disableDefaultFetchPlugins: true,
|
||||||
|
plugins: [
|
||||||
|
expoClient({
|
||||||
|
scheme: "better-auth",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
19
examples/expo-example/src/lib/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { expo } from "@better-auth/expo";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: new Pool({
|
||||||
|
connectionString: "postgresql://user:password@localhost:5432/better_auth",
|
||||||
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
plugins: [expo()],
|
||||||
|
socialProviders: {
|
||||||
|
github: {
|
||||||
|
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
4
examples/expo-example/src/lib/icons/X.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { X } from "lucide-react-native";
|
||||||
|
import { iconWithClassName } from "./iconWithClassName";
|
||||||
|
iconWithClassName(X);
|
||||||
|
export { X };
|
||||||
14
examples/expo-example/src/lib/icons/iconWithClassName.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react-native";
|
||||||
|
import { cssInterop } from "nativewind";
|
||||||
|
|
||||||
|
export function iconWithClassName(icon: LucideIcon) {
|
||||||
|
cssInterop(icon, {
|
||||||
|
className: {
|
||||||
|
target: "style",
|
||||||
|
nativeStyleToProp: {
|
||||||
|
color: true,
|
||||||
|
opacity: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
16
examples/expo-example/src/lib/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { PressableStateCallbackType } from "react-native";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
export function isTextChildren(
|
||||||
|
children:
|
||||||
|
| React.ReactNode
|
||||||
|
| ((state: PressableStateCallbackType) => React.ReactNode),
|
||||||
|
) {
|
||||||
|
return Array.isArray(children)
|
||||||
|
? children.every((child) => typeof child === "string")
|
||||||
|
: typeof children === "string";
|
||||||
|
}
|
||||||
75
examples/expo-example/tailwind.config.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
// NOTE: Update this to include the paths to all of your component files.
|
||||||
|
content: ["./src/**/*.{js,jsx,ts,tsx}"],
|
||||||
|
presets: [require("nativewind/preset")],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
input: `0px 2px 3px -1px rgba(0,0,0,0.1), 0px 1px 0px 0px rgba(25,28,33,0.02), 0px 0px 0px 1px rgba(25,28,33,0.08)`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
35
examples/expo-example/tsconfig.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"allowJs": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"incremental": true,
|
||||||
|
"disableSourceOfProjectReferenceRedirect": true,
|
||||||
|
"tsBuildInfoFile": "${configDir}/.cache/tsbuildinfo.json",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"types": ["nativewind"],
|
||||||
|
"module": "es2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-native",
|
||||||
|
"moduleSuffixes": [".ios", ".android", ".native", ""],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts",
|
||||||
|
"nativewind-env.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules", "build", "dist", ".next", ".expo"],
|
||||||
|
"extends": "expo/tsconfig.base"
|
||||||
|
}
|
||||||
10
examples/expo-example/turbo.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turborepo.org/schema.json",
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"dev": {
|
||||||
|
"persistent": true,
|
||||||
|
"interactive": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export const {
|
|||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
useSession,
|
useSession,
|
||||||
user,
|
|
||||||
organization,
|
organization,
|
||||||
useListOrganizations,
|
useListOrganizations,
|
||||||
useActiveOrganization,
|
useActiveOrganization,
|
||||||
|
|||||||
@@ -12,28 +12,68 @@ import {
|
|||||||
import { reactInvitationEmail } from "./email/invitation";
|
import { reactInvitationEmail } from "./email/invitation";
|
||||||
import { reactResetPasswordEmail } from "./email/rest-password";
|
import { reactResetPasswordEmail } from "./email/rest-password";
|
||||||
import { resend } from "./email/resend";
|
import { resend } from "./email/resend";
|
||||||
|
import { expo } from "@better-auth/expo";
|
||||||
|
|
||||||
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
|
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
|
||||||
const to = process.env.TEST_EMAIL || "";
|
const to = process.env.TEST_EMAIL || "";
|
||||||
|
|
||||||
const database = new Database("better-auth.sqlite");
|
const database = new Database("better-auth.sqlite");
|
||||||
|
|
||||||
|
const twoFactorPlugin = twoFactor({
|
||||||
|
otpOptions: {
|
||||||
|
async sendOTP(user, otp) {
|
||||||
|
await resend.emails.send({
|
||||||
|
from,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Your OTP",
|
||||||
|
html: `Your OTP is ${otp}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizationPlugin = organization({
|
||||||
|
async sendInvitationEmail(data) {
|
||||||
|
const res = await resend.emails.send({
|
||||||
|
from,
|
||||||
|
to: data.email,
|
||||||
|
subject: "You've been invited to join an organization",
|
||||||
|
react: reactInvitationEmail({
|
||||||
|
username: data.email,
|
||||||
|
invitedByUsername: data.inviter.user.name,
|
||||||
|
invitedByEmail: data.inviter.user.email,
|
||||||
|
teamName: data.organization.name,
|
||||||
|
inviteLink:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? `http://localhost:3000/accept-invitation/${data.id}`
|
||||||
|
: `https://${
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
process.env.VERCEL_URL ||
|
||||||
|
process.env.BETTER_AUTH_URL
|
||||||
|
}/accept-invitation/${data.id}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(res, data.email);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database,
|
database,
|
||||||
emailVerification: {
|
emailVerification: {
|
||||||
async sendVerificationEmail(user, url) {
|
async sendVerificationEmail(user, url) {
|
||||||
console.log("Sending verification email to", user.email);
|
console.log("Sending verification email to", user.email);
|
||||||
const res = await resend.emails.send({
|
// const res = await resend.emails.send({
|
||||||
from,
|
// from,
|
||||||
to: to || user.email,
|
// to: to || user.email,
|
||||||
subject: "Verify your email address",
|
// subject: "Verify your email address",
|
||||||
html: `<a href="${url}">Verify your email address</a>`,
|
// html: `<a href="${url}">Verify your email address</a>`,
|
||||||
});
|
// });
|
||||||
console.log(res, user.email);
|
// console.log(res, user.email);
|
||||||
},
|
},
|
||||||
sendOnSignUp: true,
|
sendOnSignUp: true,
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
|
enabled: true,
|
||||||
accountLinking: {
|
accountLinking: {
|
||||||
trustedProviders: ["google", "github"],
|
trustedProviders: ["google", "github"],
|
||||||
},
|
},
|
||||||
@@ -60,7 +100,8 @@ export const auth = betterAuth({
|
|||||||
google: {
|
google: {
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||||
accessType: "offline",
|
// accessType: "offline",
|
||||||
|
// prompt: "select_account",
|
||||||
},
|
},
|
||||||
discord: {
|
discord: {
|
||||||
clientId: process.env.DISCORD_CLIENT_ID || "",
|
clientId: process.env.DISCORD_CLIENT_ID || "",
|
||||||
@@ -76,46 +117,14 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
organization({
|
organizationPlugin,
|
||||||
async sendInvitationEmail(data) {
|
twoFactorPlugin,
|
||||||
const res = await resend.emails.send({
|
|
||||||
from,
|
|
||||||
to: data.email,
|
|
||||||
subject: "You've been invited to join an organization",
|
|
||||||
react: reactInvitationEmail({
|
|
||||||
username: data.email,
|
|
||||||
invitedByUsername: data.inviter.user.name,
|
|
||||||
invitedByEmail: data.inviter.user.email,
|
|
||||||
teamName: data.organization.name,
|
|
||||||
inviteLink:
|
|
||||||
process.env.NODE_ENV === "development"
|
|
||||||
? `http://localhost:3000/accept-invitation/${data.id}`
|
|
||||||
: `https://${
|
|
||||||
process.env.NEXT_PUBLIC_APP_URL ||
|
|
||||||
process.env.VERCEL_URL ||
|
|
||||||
process.env.BETTER_AUTH_URL
|
|
||||||
}/accept-invitation/${data.id}`,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
console.log(res, data.email);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
twoFactor({
|
|
||||||
otpOptions: {
|
|
||||||
async sendOTP(user, otp) {
|
|
||||||
await resend.emails.send({
|
|
||||||
from,
|
|
||||||
to: user.email,
|
|
||||||
subject: "Your OTP",
|
|
||||||
html: `Your OTP is ${otp}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
passkey(),
|
passkey(),
|
||||||
bearer(),
|
bearer(),
|
||||||
admin(),
|
admin(),
|
||||||
multiSession(),
|
multiSession(),
|
||||||
username(),
|
username(),
|
||||||
|
expo(),
|
||||||
],
|
],
|
||||||
|
trustedOrigins: ["better-auth://", "exp://"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@better-auth/nextjs",
|
"name": "@example/nextjs",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,9 +11,10 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@libsql/client": "^0.12.0",
|
"@libsql/client": "^0.12.0",
|
||||||
|
"@better-auth/expo": "workspace:*",
|
||||||
"@libsql/kysely-libsql": "^0.4.1",
|
"@libsql/kysely-libsql": "^0.4.1",
|
||||||
"@prisma/adapter-libsql": "^5.19.1",
|
"@prisma/adapter-libsql": "^5.19.1",
|
||||||
"@prisma/client": "^5.19.1",
|
"@prisma/client": "^5.19.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "better-auth",
|
"name": "better-auth",
|
||||||
"version": "0.6.3-beta.3",
|
"version": "0.6.3-beta.4",
|
||||||
"description": "The most comprehensive authentication library for TypeScript.",
|
"description": "The most comprehensive authentication library for TypeScript.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"directory": "packages/better-auth"
|
"directory": "packages/better-auth"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8000 tsup --clean --dts",
|
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8000 tsup --clean --dts --minify",
|
||||||
"dev": "cross-env NODE_OPTIONS='--max-old-space-size=4000' tsup --watch --sourcemap",
|
"dev": "cross-env NODE_OPTIONS='--max-old-space-size=4000' tsup --watch --sourcemap",
|
||||||
"dev:dts": "cross-env NODE_OPTIONS='--max-old-space-size=8192' tsup --watch --dts",
|
"dev:dts": "cross-env NODE_OPTIONS='--max-old-space-size=8192' tsup --watch --dts",
|
||||||
"test": "pnpm prisma:push && vitest",
|
"test": "pnpm prisma:push && vitest",
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
},
|
},
|
||||||
"./react": {
|
"./react": {
|
||||||
"types": "./dist/react.d.ts",
|
"types": "./dist/react.d.ts",
|
||||||
|
"react-native": "./dist/react.cjs",
|
||||||
"import": "./dist/react.js",
|
"import": "./dist/react.js",
|
||||||
"require": "./dist/react.cjs"
|
"require": "./dist/react.cjs"
|
||||||
},
|
},
|
||||||
@@ -164,6 +165,9 @@
|
|||||||
"client/plugins": [
|
"client/plugins": [
|
||||||
"./dist/client/plugins.d.ts"
|
"./dist/client/plugins.d.ts"
|
||||||
],
|
],
|
||||||
|
"expo": [
|
||||||
|
"./dist/expo.d.ts"
|
||||||
|
],
|
||||||
"types": [
|
"types": [
|
||||||
"./dist/types.d.ts"
|
"./dist/types.d.ts"
|
||||||
],
|
],
|
||||||
@@ -230,6 +234,7 @@
|
|||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"prisma": "^5.19.1",
|
"prisma": "^5.19.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-native": "~0.74.6",
|
||||||
"solid-js": "^1.8.18",
|
"solid-js": "^1.8.18",
|
||||||
"tsup": "^8.2.4",
|
"tsup": "^8.2.4",
|
||||||
"typescript": "5.6.1-rc",
|
"typescript": "5.6.1-rc",
|
||||||
@@ -254,7 +259,6 @@
|
|||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nanostores": "^0.11.2",
|
"nanostores": "^0.11.2",
|
||||||
"oslo": "^1.2.1",
|
"oslo": "^1.2.1",
|
||||||
"std-env": "^3.7.0",
|
|
||||||
"uncrypto": "^0.1.3",
|
"uncrypto": "^0.1.3",
|
||||||
"zod": "^3.22.5"
|
"zod": "^3.22.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,26 +22,6 @@ exports[`init > should match config 1`] = `
|
|||||||
"secure": false,
|
"secure": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"nonce": {
|
|
||||||
"name": "better-auth.nonce",
|
|
||||||
"options": {
|
|
||||||
"httpOnly": true,
|
|
||||||
"maxAge": 900,
|
|
||||||
"path": "/",
|
|
||||||
"sameSite": "lax",
|
|
||||||
"secure": false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"pkCodeVerifier": {
|
|
||||||
"name": "better-auth.pk_code_verifier",
|
|
||||||
"options": {
|
|
||||||
"httpOnly": true,
|
|
||||||
"maxAge": 900,
|
|
||||||
"path": "/",
|
|
||||||
"sameSite": "lax",
|
|
||||||
"secure": false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"sessionData": {
|
"sessionData": {
|
||||||
"name": "better-auth.session_data",
|
"name": "better-auth.session_data",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -62,16 +42,6 @@ exports[`init > should match config 1`] = `
|
|||||||
"secure": false,
|
"secure": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"state": {
|
|
||||||
"name": "better-auth.state",
|
|
||||||
"options": {
|
|
||||||
"httpOnly": true,
|
|
||||||
"maxAge": 900,
|
|
||||||
"path": "/",
|
|
||||||
"sameSite": "lax",
|
|
||||||
"secure": false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"baseURL": "http://localhost:3000/api/auth",
|
"baseURL": "http://localhost:3000/api/auth",
|
||||||
"createAuthCookie": [Function],
|
"createAuthCookie": [Function],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createAuthMiddleware } from "../call";
|
|||||||
import { logger } from "../../utils";
|
import { logger } from "../../utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A middleware to validate callbackURL, redirectURL, currentURL and origin against trustedOrigins.
|
* A middleware to validate callbackURL, redirectURL, errorURL, currentURL and origin against trustedOrigins.
|
||||||
*/
|
*/
|
||||||
export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
|
export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
|
||||||
if (ctx.request?.method !== "POST") {
|
if (ctx.request?.method !== "POST") {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe("account", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { headers } = await signInWithTestUser();
|
const { headers } = await signInWithTestUser();
|
||||||
it("should list all accounts", async () => {
|
it("should list all accounts", async () => {
|
||||||
const accounts = await client.listAccounts({
|
const accounts = await client.listAccounts({
|
||||||
@@ -87,17 +88,14 @@ describe("account", async () => {
|
|||||||
);
|
);
|
||||||
expect(linkAccountRes.data).toMatchObject({
|
expect(linkAccountRes.data).toMatchObject({
|
||||||
url: expect.stringContaining("google.com"),
|
url: expect.stringContaining("google.com"),
|
||||||
codeVerifier: expect.any(String),
|
|
||||||
state: {
|
|
||||||
hash: expect.any(String),
|
|
||||||
raw: expect.any(String),
|
|
||||||
},
|
|
||||||
redirect: true,
|
redirect: true,
|
||||||
});
|
});
|
||||||
|
const state =
|
||||||
|
new URL(linkAccountRes.data!.url).searchParams.get("state") || "";
|
||||||
email = "test@test.com";
|
email = "test@test.com";
|
||||||
await client.$fetch("/callback/google", {
|
await client.$fetch("/callback/google", {
|
||||||
query: {
|
query: {
|
||||||
state: linkAccountRes.data?.state.raw,
|
state,
|
||||||
code: "test",
|
code: "test",
|
||||||
},
|
},
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
|||||||
@@ -74,44 +74,14 @@ export const linkSocialAccount = createAuthEndpoint(
|
|||||||
message: "Provider not found",
|
message: "Provider not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const cookie = c.context.authCookies;
|
const state = await generateState(c);
|
||||||
const currentURL = c.query?.currentURL
|
|
||||||
? new URL(c.query?.currentURL)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const callbackURL = c.body.callbackURL?.startsWith("http")
|
|
||||||
? c.body.callbackURL
|
|
||||||
: `${currentURL?.origin}${c.body.callbackURL || ""}`;
|
|
||||||
|
|
||||||
const state = await generateState(
|
|
||||||
callbackURL || currentURL?.origin || c.context.options.baseURL,
|
|
||||||
{
|
|
||||||
email: session.user.email,
|
|
||||||
userId: session.user.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await c.setSignedCookie(
|
|
||||||
cookie.state.name,
|
|
||||||
state.hash,
|
|
||||||
c.context.secret,
|
|
||||||
cookie.state.options,
|
|
||||||
);
|
|
||||||
const codeVerifier = generateCodeVerifier();
|
|
||||||
await c.setSignedCookie(
|
|
||||||
cookie.pkCodeVerifier.name,
|
|
||||||
codeVerifier,
|
|
||||||
c.context.secret,
|
|
||||||
cookie.pkCodeVerifier.options,
|
|
||||||
);
|
|
||||||
const url = await provider.createAuthorizationURL({
|
const url = await provider.createAuthorizationURL({
|
||||||
state: state.raw,
|
state: state.state,
|
||||||
codeVerifier,
|
codeVerifier: state.codeVerifier,
|
||||||
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
|
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
|
||||||
});
|
});
|
||||||
return c.json({
|
return c.json({
|
||||||
url: url.toString(),
|
url: url.toString(),
|
||||||
state: state,
|
|
||||||
codeVerifier,
|
|
||||||
redirect: true,
|
redirect: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { HIDE_METADATA } from "../../utils/hide-metadata";
|
|||||||
import { setSessionCookie } from "../../cookies";
|
import { setSessionCookie } from "../../cookies";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import type { OAuth2Tokens } from "../../oauth2";
|
import type { OAuth2Tokens } from "../../oauth2";
|
||||||
import { compareHash } from "../../crypto/hash";
|
|
||||||
import { createEmailVerificationToken } from "./email-verification";
|
import { createEmailVerificationToken } from "./email-verification";
|
||||||
import { isDevelopment } from "std-env";
|
import { isDevelopment } from "../../utils/env";
|
||||||
|
|
||||||
export const callbackOAuth = createAuthEndpoint(
|
export const callbackOAuth = createAuthEndpoint(
|
||||||
"/callback/:id",
|
"/callback/:id",
|
||||||
@@ -23,20 +22,14 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
metadata: HIDE_METADATA,
|
metadata: HIDE_METADATA,
|
||||||
},
|
},
|
||||||
async (c) => {
|
async (c) => {
|
||||||
if (c.query.error || !c.query.code) {
|
if (!c.query.code) {
|
||||||
const parsedState = parseState(c.query.state);
|
|
||||||
const callbackURL =
|
|
||||||
parsedState.data?.callbackURL || `${c.context.baseURL}/error`;
|
|
||||||
c.context.logger.error(c.query.error, c.params.id);
|
|
||||||
throw c.redirect(
|
throw c.redirect(
|
||||||
`${callbackURL}?error=${c.query.error || "oAuth_code_missing"}`,
|
`${c.context.baseURL}/error?error=${c.query.error || "no_code"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = c.context.socialProviders.find(
|
const provider = c.context.socialProviders.find(
|
||||||
(p) => p.id === c.params.id,
|
(p) => p.id === c.params.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
c.context.logger.error(
|
c.context.logger.error(
|
||||||
"Oauth provider with id",
|
"Oauth provider with id",
|
||||||
@@ -47,42 +40,7 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
`${c.context.baseURL}/error?error=oauth_provider_not_found`,
|
`${c.context.baseURL}/error?error=oauth_provider_not_found`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const { codeVerifier, callbackURL, link, errorURL } = await parseState(c);
|
||||||
const parsedState = parseState(c.query.state);
|
|
||||||
if (!parsedState.success) {
|
|
||||||
c.context.logger.error("Unable to parse state");
|
|
||||||
throw c.redirect(
|
|
||||||
`${c.context.baseURL}/error?error=please_restart_the_process`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: { callbackURL, currentURL, link },
|
|
||||||
} = parsedState;
|
|
||||||
|
|
||||||
const storedState = await c.getSignedCookie(
|
|
||||||
c.context.authCookies.state.name,
|
|
||||||
c.context.secret,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!storedState) {
|
|
||||||
logger.error("No stored state found");
|
|
||||||
throw c.redirect(
|
|
||||||
`${c.context.baseURL}/error?error=please_restart_the_process`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidState = await compareHash(c.query.state, storedState);
|
|
||||||
if (!isValidState) {
|
|
||||||
logger.error("OAuth state mismatch");
|
|
||||||
throw c.redirect(
|
|
||||||
`${c.context.baseURL}/error?error=please_restart_the_process`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const codeVerifier = await c.getSignedCookie(
|
|
||||||
c.context.authCookies.pkCodeVerifier.name,
|
|
||||||
c.context.secret,
|
|
||||||
);
|
|
||||||
let tokens: OAuth2Tokens;
|
let tokens: OAuth2Tokens;
|
||||||
try {
|
try {
|
||||||
tokens = await provider.validateAuthorizationCode({
|
tokens = await provider.validateAuthorizationCode({
|
||||||
@@ -130,13 +88,13 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
if (!newAccount) {
|
if (!newAccount) {
|
||||||
return redirectOnError("unable_to_link_account");
|
return redirectOnError("unable_to_link_account");
|
||||||
}
|
}
|
||||||
throw c.redirect(callbackURL || currentURL || c.context.options.baseURL!);
|
throw c.redirect(errorURL || callbackURL || c.context.options.baseURL!);
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectOnError(error: string) {
|
function redirectOnError(error: string) {
|
||||||
throw c.redirect(
|
throw c.redirect(
|
||||||
`${
|
`${
|
||||||
currentURL || callbackURL || `${c.context.baseURL}/error`
|
errorURL || callbackURL || `${c.context.baseURL}/error`
|
||||||
}?error=${error}`,
|
}?error=${error}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,6 +114,7 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let user = dbUser?.user;
|
let user = dbUser?.user;
|
||||||
|
|
||||||
if (dbUser) {
|
if (dbUser) {
|
||||||
const hasBeenLinked = dbUser.accounts.find(
|
const hasBeenLinked = dbUser.accounts.find(
|
||||||
(a) => a.providerId === provider.id,
|
(a) => a.providerId === provider.id,
|
||||||
@@ -168,7 +127,7 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
(!isTrustedProvider && !userInfo.emailVerified) ||
|
(!isTrustedProvider && !userInfo.emailVerified) ||
|
||||||
!c.context.options.account?.accountLinking?.enabled
|
c.context.options.account?.accountLinking?.enabled === false
|
||||||
) {
|
) {
|
||||||
if (isDevelopment) {
|
if (isDevelopment) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -240,7 +199,6 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
redirectOnError("unable_to_create_user");
|
redirectOnError("unable_to_create_user");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return redirectOnError("unable_to_create_user");
|
return redirectOnError("unable_to_create_user");
|
||||||
}
|
}
|
||||||
@@ -256,6 +214,8 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
});
|
});
|
||||||
throw c.redirect(callbackURL);
|
|
||||||
|
const url = new URL(callbackURL);
|
||||||
|
throw c.redirect(url.toString());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -330,7 +330,6 @@ describe("session storage", async () => {
|
|||||||
},
|
},
|
||||||
id: session.data?.session?.id || "",
|
id: session.data?.session?.id || "",
|
||||||
});
|
});
|
||||||
console.log(res);
|
|
||||||
const revokedSession = await client.getSession({
|
const revokedSession = await client.getSession({
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { APIError } from "better-call";
|
import { APIError } from "better-call";
|
||||||
import { generateCodeVerifier } from "oslo/oauth2";
|
import { generateCodeVerifier } from "oslo/oauth2";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { generateState } from "../../oauth2/state";
|
|
||||||
import { createAuthEndpoint } from "../call";
|
import { createAuthEndpoint } from "../call";
|
||||||
import { setSessionCookie } from "../../cookies";
|
import { setSessionCookie } from "../../cookies";
|
||||||
import { socialProviderList } from "../../social-providers";
|
import { socialProviderList } from "../../social-providers";
|
||||||
import { createEmailVerificationToken } from "./email-verification";
|
import { createEmailVerificationToken } from "./email-verification";
|
||||||
import { logger } from "../../utils";
|
import { generateState, logger } from "../../utils";
|
||||||
|
import { hmac } from "../../crypto/hash";
|
||||||
|
|
||||||
export const signInOAuth = createAuthEndpoint(
|
export const signInOAuth = createAuthEndpoint(
|
||||||
"/sign-in/social",
|
"/sign-in/social",
|
||||||
@@ -48,40 +48,15 @@ export const signInOAuth = createAuthEndpoint(
|
|||||||
message: "Provider not found",
|
message: "Provider not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const cookie = c.context.authCookies;
|
const { codeVerifier, state } = await generateState(c);
|
||||||
const currentURL = c.query?.currentURL
|
|
||||||
? new URL(c.query?.currentURL)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const callbackURL = c.body.callbackURL?.startsWith("http")
|
|
||||||
? c.body.callbackURL
|
|
||||||
: `${currentURL?.origin}${c.body.callbackURL || ""}`;
|
|
||||||
|
|
||||||
const state = await generateState(
|
|
||||||
callbackURL || currentURL?.origin || c.context.options.baseURL,
|
|
||||||
);
|
|
||||||
await c.setSignedCookie(
|
|
||||||
cookie.state.name,
|
|
||||||
state.hash,
|
|
||||||
c.context.secret,
|
|
||||||
cookie.state.options,
|
|
||||||
);
|
|
||||||
const codeVerifier = generateCodeVerifier();
|
|
||||||
await c.setSignedCookie(
|
|
||||||
cookie.pkCodeVerifier.name,
|
|
||||||
codeVerifier,
|
|
||||||
c.context.secret,
|
|
||||||
cookie.pkCodeVerifier.options,
|
|
||||||
);
|
|
||||||
const url = await provider.createAuthorizationURL({
|
const url = await provider.createAuthorizationURL({
|
||||||
state: state.raw,
|
state,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
|
redirectURI: `${c.context.baseURL}/callback/${provider.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
url: url.toString(),
|
url: url.toString(),
|
||||||
state: state,
|
|
||||||
codeVerifier,
|
|
||||||
redirect: true,
|
redirect: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export const redirectPlugin = {
|
|||||||
onSuccess(context) {
|
onSuccess(context) {
|
||||||
if (context.data?.url && context.data?.redirect) {
|
if (context.data?.url && context.data?.redirect) {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.href = context.data.url;
|
if (window.location) {
|
||||||
|
window.location.href = context.data.url;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -20,9 +22,11 @@ export const addCurrentURL = {
|
|||||||
hooks: {
|
hooks: {
|
||||||
onRequest(context) {
|
onRequest(context) {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const url = new URL(context.url);
|
if (window.location) {
|
||||||
url.searchParams.set("currentURL", window.location.href);
|
const url = new URL(context.url);
|
||||||
context.url = url;
|
url.searchParams.set("currentURL", window.location.href);
|
||||||
|
context.url = url;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type AtomListener = {
|
|||||||
signal: "$sessionSignal" | Omit<string, "$sessionSignal">;
|
signal: "$sessionSignal" | Omit<string, "$sessionSignal">;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Store {
|
export interface Store {
|
||||||
notify: (signal: string) => void;
|
notify: (signal: string) => void;
|
||||||
listen: (signal: string, listener: () => void) => void;
|
listen: (signal: string, listener: () => void) => void;
|
||||||
atoms: Record<string, WritableAtom<any>>;
|
atoms: Record<string, WritableAtom<any>>;
|
||||||
|
|||||||
@@ -49,12 +49,10 @@ describe("cookies", async () => {
|
|||||||
{
|
{
|
||||||
onResponse(context) {
|
onResponse(context) {
|
||||||
const setCookie = context.response.headers.get("set-cookie");
|
const setCookie = context.response.headers.get("set-cookie");
|
||||||
console.log(setCookie, context);
|
|
||||||
expect(setCookie).toContain("Secure");
|
expect(setCookie).toContain("Secure");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
console.log(res);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use secure cookies when the base url is https", async () => {
|
it("should use secure cookies when the base url is https", async () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TimeSpan } from "oslo";
|
|||||||
import type { BetterAuthOptions } from "../types/options";
|
import type { BetterAuthOptions } from "../types/options";
|
||||||
import type { GenericEndpointContext } from "../types/context";
|
import type { GenericEndpointContext } from "../types/context";
|
||||||
import { BetterAuthError } from "../error";
|
import { BetterAuthError } from "../error";
|
||||||
import { env, isProduction } from "std-env";
|
import { env, isProduction } from "../utils/env";
|
||||||
import type { Session, User } from "../types";
|
import type { Session, User } from "../types";
|
||||||
|
|
||||||
export function getCookies(options: BetterAuthOptions) {
|
export function getCookies(options: BetterAuthOptions) {
|
||||||
@@ -62,28 +62,6 @@ export function getCookies(options: BetterAuthOptions) {
|
|||||||
...(crossSubdomainEnabled ? { domain } : {}),
|
...(crossSubdomainEnabled ? { domain } : {}),
|
||||||
} satisfies CookieOptions,
|
} satisfies CookieOptions,
|
||||||
},
|
},
|
||||||
state: {
|
|
||||||
name: `${secureCookiePrefix}${cookiePrefix}.state`,
|
|
||||||
options: {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite,
|
|
||||||
path: "/",
|
|
||||||
secure: !!secureCookiePrefix,
|
|
||||||
maxAge: 60 * 15,
|
|
||||||
...(crossSubdomainEnabled ? { domain } : {}),
|
|
||||||
} satisfies CookieOptions,
|
|
||||||
},
|
|
||||||
pkCodeVerifier: {
|
|
||||||
name: `${secureCookiePrefix}${cookiePrefix}.pk_code_verifier`,
|
|
||||||
options: {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite,
|
|
||||||
path: "/",
|
|
||||||
secure: !!secureCookiePrefix,
|
|
||||||
maxAge: 60 * 15,
|
|
||||||
...(crossSubdomainEnabled ? { domain } : {}),
|
|
||||||
} as CookieOptions,
|
|
||||||
},
|
|
||||||
dontRememberToken: {
|
dontRememberToken: {
|
||||||
name: `${secureCookiePrefix}${cookiePrefix}.dont_remember`,
|
name: `${secureCookiePrefix}${cookiePrefix}.dont_remember`,
|
||||||
options: {
|
options: {
|
||||||
@@ -95,17 +73,6 @@ export function getCookies(options: BetterAuthOptions) {
|
|||||||
...(crossSubdomainEnabled ? { domain } : {}),
|
...(crossSubdomainEnabled ? { domain } : {}),
|
||||||
} as CookieOptions,
|
} as CookieOptions,
|
||||||
},
|
},
|
||||||
nonce: {
|
|
||||||
name: `${secureCookiePrefix}${cookiePrefix}.nonce`,
|
|
||||||
options: {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite,
|
|
||||||
path: "/",
|
|
||||||
secure: !!secureCookiePrefix,
|
|
||||||
maxAge: 60 * 15,
|
|
||||||
...(crossSubdomainEnabled ? { domain } : {}),
|
|
||||||
} as CookieOptions,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +213,7 @@ export function parseSetCookieHeader(
|
|||||||
|
|
||||||
return cookieMap;
|
return cookieMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCookies(cookieHeader: string) {
|
export function parseCookies(cookieHeader: string) {
|
||||||
const cookies = cookieHeader.split("; ");
|
const cookies = cookieHeader.split("; ");
|
||||||
const cookieMap = new Map<string, string>();
|
const cookieMap = new Map<string, string>();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { sha256 } from "oslo/crypto";
|
import { HMAC, sha256 } from "oslo/crypto";
|
||||||
import { constantTimeEqual } from "./buffer";
|
import { constantTimeEqual } from "./buffer";
|
||||||
|
|
||||||
export async function hashToBase64(
|
export async function hashToBase64(
|
||||||
@@ -20,3 +20,28 @@ export async function compareHash(
|
|||||||
const hashBuffer = Buffer.from(hash, "base64");
|
const hashBuffer = Buffer.from(hash, "base64");
|
||||||
return constantTimeEqual(buffer, hashBuffer);
|
return constantTimeEqual(buffer, hashBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function signValue({ value, secret }: { value: string; secret: string }) {
|
||||||
|
const hmac = new HMAC("SHA-256");
|
||||||
|
return hmac
|
||||||
|
.sign(new TextEncoder().encode(secret), new TextEncoder().encode(value))
|
||||||
|
.then((buffer) => Buffer.from(buffer).toString("base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyValue({
|
||||||
|
value,
|
||||||
|
signature,
|
||||||
|
secret,
|
||||||
|
}: { value: string; signature: string; secret: string }) {
|
||||||
|
const hmac = new HMAC("SHA-256");
|
||||||
|
return hmac.verify(
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
Buffer.from(signature, "base64"),
|
||||||
|
new TextEncoder().encode(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hmac = {
|
||||||
|
sign: signValue,
|
||||||
|
verify: verifyValue,
|
||||||
|
};
|
||||||
|
|||||||
@@ -555,7 +555,7 @@ export const createInternalAdapter = (
|
|||||||
},
|
},
|
||||||
"verification",
|
"verification",
|
||||||
);
|
);
|
||||||
return verification;
|
return verification as Verification;
|
||||||
},
|
},
|
||||||
findVerificationValue: async (identifier: string) => {
|
findVerificationValue: async (identifier: string) => {
|
||||||
const verification = await adapter.findOne<Verification>({
|
const verification = await adapter.findOne<Verification>({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createKyselyAdapter } from "./adapters/kysely-adapter/dialect";
|
|||||||
import { getAdapter } from "./db/utils";
|
import { getAdapter } from "./db/utils";
|
||||||
import { hashPassword, verifyPassword } from "./crypto/password";
|
import { hashPassword, verifyPassword } from "./crypto/password";
|
||||||
import { createInternalAdapter } from "./db";
|
import { createInternalAdapter } from "./db";
|
||||||
import { env, isProduction } from "std-env";
|
import { env, isProduction } from "./utils/env";
|
||||||
import type {
|
import type {
|
||||||
Adapter,
|
Adapter,
|
||||||
BetterAuthOptions,
|
BetterAuthOptions,
|
||||||
|
|||||||
@@ -1,37 +1,88 @@
|
|||||||
import { generateState as generateStateOAuth } from "oslo/oauth2";
|
import {
|
||||||
|
generateCodeVerifier,
|
||||||
|
generateState as generateSateCode,
|
||||||
|
} from "oslo/oauth2";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { hashToBase64 } from "../crypto/hash";
|
import { hmac } from "../crypto/hash";
|
||||||
|
import type { GenericEndpointContext } from "../types";
|
||||||
|
import { APIError } from "better-call";
|
||||||
|
import { logger } from "../utils";
|
||||||
|
import { checkURLValidity } from "../utils/url";
|
||||||
|
|
||||||
export async function generateState(
|
export async function generateState(c: GenericEndpointContext) {
|
||||||
callbackURL?: string,
|
const callbackURL = c.body?.callbackURL;
|
||||||
link?: {
|
if (!callbackURL) {
|
||||||
email: string;
|
throw new APIError("BAD_REQUEST", {
|
||||||
userId: string;
|
message: "callbackURL is required",
|
||||||
},
|
});
|
||||||
) {
|
}
|
||||||
const code = generateStateOAuth();
|
const codeVerifier = generateCodeVerifier();
|
||||||
const raw = JSON.stringify({
|
const state = generateSateCode();
|
||||||
code,
|
const data = JSON.stringify({
|
||||||
callbackURL,
|
callbackURL,
|
||||||
link,
|
codeVerifier,
|
||||||
|
errorURL: c.query?.currentURL,
|
||||||
});
|
});
|
||||||
const hash = await hashToBase64(raw);
|
|
||||||
return { raw, hash };
|
const verification = await c.context.internalAdapter.createVerificationValue({
|
||||||
|
value: data,
|
||||||
|
identifier: state,
|
||||||
|
expiresAt: new Date(Date.now() + 1000 * 60 * 10),
|
||||||
|
});
|
||||||
|
if (!verification) {
|
||||||
|
logger.error(
|
||||||
|
"Unable to create verification. Make sure the database adapter is properly working and there is a verification table in the database",
|
||||||
|
);
|
||||||
|
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||||
|
message: "Unable to create verification",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: verification.identifier,
|
||||||
|
codeVerifier,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseState(state: string) {
|
export async function parseState(c: GenericEndpointContext) {
|
||||||
const data = z
|
const state = c.query.state;
|
||||||
|
const data = await c.context.internalAdapter.findVerificationValue(state);
|
||||||
|
if (!data || data.expiresAt < new Date()) {
|
||||||
|
if (data) {
|
||||||
|
await c.context.internalAdapter.deleteVerificationValue(data.id);
|
||||||
|
logger.error("State expired.", {
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error("State Mismatch. Verification not found", {
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw c.redirect(
|
||||||
|
`${c.context.baseURL}/error?error=please_restart_the_process`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parsedData = z
|
||||||
.object({
|
.object({
|
||||||
code: z.string(),
|
callbackURL: z.string(),
|
||||||
callbackURL: z.string().optional(),
|
codeVerifier: z.string(),
|
||||||
currentURL: z.string().optional(),
|
errorURL: z.string().optional(),
|
||||||
link: z
|
|
||||||
.object({
|
|
||||||
email: z.string(),
|
|
||||||
userId: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
})
|
||||||
.safeParse(JSON.parse(state));
|
.parse(JSON.parse(data.value));
|
||||||
return data;
|
if (!parsedData.errorURL) {
|
||||||
|
parsedData.errorURL = `${c.context.baseURL}/error`;
|
||||||
|
}
|
||||||
|
const isFullURL = checkURLValidity(parsedData.callbackURL);
|
||||||
|
if (!isFullURL) {
|
||||||
|
const origin = new URL(c.context.baseURL).origin;
|
||||||
|
parsedData.callbackURL = `${origin}${parsedData.callbackURL}`;
|
||||||
|
}
|
||||||
|
return parsedData as {
|
||||||
|
callbackURL: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
link?: {
|
||||||
|
email: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
errorURL: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,23 +201,8 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
|||||||
const callbackURL = ctx.body.callbackURL?.startsWith("http")
|
const callbackURL = ctx.body.callbackURL?.startsWith("http")
|
||||||
? ctx.body.callbackURL
|
? ctx.body.callbackURL
|
||||||
: `${currentURL?.origin}${ctx.body.callbackURL || ""}`;
|
: `${currentURL?.origin}${ctx.body.callbackURL || ""}`;
|
||||||
const state = await generateState(
|
const { state, codeVerifier } = await generateState(ctx);
|
||||||
callbackURL || currentURL?.origin || ctx.context.options.baseURL,
|
|
||||||
);
|
|
||||||
const cookie = ctx.context.authCookies;
|
|
||||||
await ctx.setSignedCookie(
|
|
||||||
cookie.state.name,
|
|
||||||
state.hash,
|
|
||||||
ctx.context.secret,
|
|
||||||
cookie.state.options,
|
|
||||||
);
|
|
||||||
const codeVerifier = generateCodeVerifier();
|
|
||||||
await ctx.setSignedCookie(
|
|
||||||
cookie.pkCodeVerifier.name,
|
|
||||||
codeVerifier,
|
|
||||||
ctx.context.secret,
|
|
||||||
cookie.pkCodeVerifier.options,
|
|
||||||
);
|
|
||||||
const authUrl = await createAuthorizationURL({
|
const authUrl = await createAuthorizationURL({
|
||||||
id: providerId,
|
id: providerId,
|
||||||
options: {
|
options: {
|
||||||
@@ -226,8 +211,8 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
|||||||
redirectURI,
|
redirectURI,
|
||||||
},
|
},
|
||||||
authorizationEndpoint: finalAuthUrl,
|
authorizationEndpoint: finalAuthUrl,
|
||||||
state: state.raw,
|
state,
|
||||||
codeVerifier: codeVerifier,
|
codeVerifier,
|
||||||
scopes: scopes || [],
|
scopes: scopes || [],
|
||||||
disablePkce: !pkce,
|
disablePkce: !pkce,
|
||||||
redirectURI: `${ctx.context.baseURL}/oauth2/callback/${providerId}`,
|
redirectURI: `${ctx.context.baseURL}/oauth2/callback/${providerId}`,
|
||||||
@@ -247,8 +232,6 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
|||||||
|
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
url: authUrl.toString(),
|
url: authUrl.toString(),
|
||||||
state: state,
|
|
||||||
codeVerifier,
|
|
||||||
redirect: true,
|
redirect: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -265,12 +248,10 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
|||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
if (ctx.query.error || !ctx.query.code) {
|
if (ctx.query.error || !ctx.query.code) {
|
||||||
const parsedState = parseState(ctx.query.state);
|
|
||||||
const callbackURL =
|
|
||||||
parsedState.data?.currentURL || `${ctx.context.baseURL}/error`;
|
|
||||||
ctx.context.logger.error(ctx.query.error, ctx.params.providerId);
|
|
||||||
throw ctx.redirect(
|
throw ctx.redirect(
|
||||||
`${callbackURL}?error=${ctx.query.error || "oAuth_code_missing"}`,
|
`${ctx.context.baseURL}?error=${
|
||||||
|
ctx.query.error || "oAuth_code_missing"
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const provider = options.config.find(
|
const provider = options.config.find(
|
||||||
@@ -282,39 +263,12 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
|||||||
message: `No config found for provider ${ctx.params.providerId}`,
|
message: `No config found for provider ${ctx.params.providerId}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeVerifier = await ctx.getSignedCookie(
|
|
||||||
ctx.context.authCookies.pkCodeVerifier.name,
|
|
||||||
ctx.context.secret,
|
|
||||||
);
|
|
||||||
|
|
||||||
let tokens: OAuth2Tokens | undefined = undefined;
|
let tokens: OAuth2Tokens | undefined = undefined;
|
||||||
const parsedState = parseState(ctx.query.state);
|
const parsedState = await parseState(ctx);
|
||||||
if (!parsedState.success) {
|
|
||||||
throw ctx.redirect(
|
const { callbackURL, codeVerifier, errorURL } = parsedState;
|
||||||
`${ctx.context.baseURL}/error?error=invalid_state`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const state = ctx.query.state;
|
|
||||||
const {
|
|
||||||
data: { callbackURL, currentURL },
|
|
||||||
} = parsedState;
|
|
||||||
const code = ctx.query.code;
|
const code = ctx.query.code;
|
||||||
const errorURL =
|
|
||||||
parsedState.data?.currentURL || `${ctx.context.baseURL}/error`;
|
|
||||||
const storedState = await ctx.getSignedCookie(
|
|
||||||
ctx.context.authCookies.state.name,
|
|
||||||
ctx.context.secret,
|
|
||||||
);
|
|
||||||
if (!storedState) {
|
|
||||||
logger.error("No stored state found");
|
|
||||||
throw ctx.redirect(`${errorURL}?error=please_restart_the_process`);
|
|
||||||
}
|
|
||||||
const isValidState = await compareHash(state, storedState);
|
|
||||||
if (!isValidState) {
|
|
||||||
logger.error("OAuth code mismatch");
|
|
||||||
throw ctx.redirect(`${errorURL}?error=please_restart_the_process`);
|
|
||||||
}
|
|
||||||
let finalTokenUrl = provider.tokenUrl;
|
let finalTokenUrl = provider.tokenUrl;
|
||||||
let finalUserInfoUrl = provider.userInfoUrl;
|
let finalUserInfoUrl = provider.userInfoUrl;
|
||||||
if (provider.discoveryUrl) {
|
if (provider.discoveryUrl) {
|
||||||
@@ -406,7 +360,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
|||||||
) {
|
) {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
url = new URL(errorURL);
|
url = new URL(errorURL!);
|
||||||
url.searchParams.set("error", "account_not_linked");
|
url.searchParams.set("error", "account_not_linked");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw ctx.redirect(`${errorURL}?error=account_not_linked`);
|
throw ctx.redirect(`${errorURL}?error=account_not_linked`);
|
||||||
@@ -452,7 +406,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
|||||||
expiresAt: tokens.accessTokenExpiresAt,
|
expiresAt: tokens.accessTokenExpiresAt,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const url = new URL(errorURL);
|
const url = new URL(errorURL!);
|
||||||
url.searchParams.set("error", "unable_to_create_user");
|
url.searchParams.set("error", "unable_to_create_user");
|
||||||
ctx.setHeader("Location", url.toString());
|
ctx.setHeader("Location", url.toString());
|
||||||
throw ctx.redirect(url.toString());
|
throw ctx.redirect(url.toString());
|
||||||
@@ -474,7 +428,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
|||||||
} catch {
|
} catch {
|
||||||
throw ctx.redirect(`${errorURL}?error=unable_to_create_session`);
|
throw ctx.redirect(`${errorURL}?error=unable_to_create_session`);
|
||||||
}
|
}
|
||||||
throw ctx.redirect(callbackURL || currentURL || "");
|
throw ctx.redirect(callbackURL);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -91,54 +91,27 @@ describe("oauth2", async () => {
|
|||||||
|
|
||||||
it("should redirect to the provider and handle the response", async () => {
|
it("should redirect to the provider and handle the response", async () => {
|
||||||
let headers = new Headers();
|
let headers = new Headers();
|
||||||
const res = await authClient.signIn.oauth2(
|
const signInRes = await authClient.signIn.oauth2({
|
||||||
{
|
providerId: "test",
|
||||||
providerId: "test",
|
callbackURL: "http://localhost:3000/dashboard",
|
||||||
callbackURL: "http://localhost:3000/dashboard",
|
});
|
||||||
},
|
expect(signInRes.data).toMatchObject({
|
||||||
{
|
url: expect.stringContaining("http://localhost:8080/authorize"),
|
||||||
onSuccess(context) {
|
redirect: true,
|
||||||
const parsedSetCookie = parseSetCookieHeader(
|
});
|
||||||
context.response.headers.get("Set-Cookie") || "",
|
const callbackURL = await simulateOAuthFlow(
|
||||||
);
|
signInRes.data?.url || "",
|
||||||
headers.set(
|
headers,
|
||||||
"cookie",
|
|
||||||
`better-auth.state=${
|
|
||||||
parsedSetCookie.get("better-auth.state")?.value
|
|
||||||
}; better-auth.pk_code_verifier=${
|
|
||||||
parsedSetCookie.get("better-auth.pk_code_verifier")?.value
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const callbackURL = await simulateOAuthFlow(res.data?.url || "", headers);
|
|
||||||
expect(callbackURL).toBe("http://localhost:3000/dashboard");
|
expect(callbackURL).toBe("http://localhost:3000/dashboard");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should redirect to the provider and handle the response after linked", async () => {
|
it("should redirect to the provider and handle the response after linked", async () => {
|
||||||
let headers = new Headers();
|
let headers = new Headers();
|
||||||
const res = await authClient.signIn.oauth2(
|
const res = await authClient.signIn.oauth2({
|
||||||
{
|
providerId: "test",
|
||||||
providerId: "test",
|
callbackURL: "http://localhost:3000/dashboard",
|
||||||
callbackURL: "http://localhost:3000/dashboard",
|
});
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(context) {
|
|
||||||
const parsedSetCookie = parseSetCookieHeader(
|
|
||||||
context.response.headers.get("Set-Cookie") || "",
|
|
||||||
);
|
|
||||||
headers.set(
|
|
||||||
"cookie",
|
|
||||||
`better-auth.state=${
|
|
||||||
parsedSetCookie.get("better-auth.state")?.value
|
|
||||||
}; better-auth.pk_code_verifier=${
|
|
||||||
parsedSetCookie.get("better-auth.pk_code_verifier")?.value
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const callbackURL = await simulateOAuthFlow(res.data?.url || "", headers);
|
const callbackURL = await simulateOAuthFlow(res.data?.url || "", headers);
|
||||||
expect(callbackURL).toBe("http://localhost:3000/dashboard");
|
expect(callbackURL).toBe("http://localhost:3000/dashboard");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { BetterAuthPlugin } from "../../types/plugins";
|
|||||||
import { setSessionCookie } from "../../cookies";
|
import { setSessionCookie } from "../../cookies";
|
||||||
import { BetterAuthError } from "../../error";
|
import { BetterAuthError } from "../../error";
|
||||||
import { generateId } from "../../utils/id";
|
import { generateId } from "../../utils/id";
|
||||||
import { env } from "std-env";
|
import { env } from "../../utils/env";
|
||||||
|
|
||||||
interface WebAuthnChallengeValue {
|
interface WebAuthnChallengeValue {
|
||||||
expectedChallenge: string;
|
expectedChallenge: string;
|
||||||
|
|||||||
@@ -63,34 +63,15 @@ describe("Social Providers", async () => {
|
|||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
it("should be able to add social providers", async () => {
|
it("should be able to add social providers", async () => {
|
||||||
const signInRes = await client.signIn.social(
|
const signInRes = await client.signIn.social({
|
||||||
{
|
provider: "google",
|
||||||
provider: "google",
|
callbackURL: "/callback",
|
||||||
callbackURL: "/callback",
|
});
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess(context) {
|
|
||||||
const cookies = parseSetCookieHeader(
|
|
||||||
context.response.headers.get("set-cookie") || "",
|
|
||||||
);
|
|
||||||
headers.set(
|
|
||||||
"cookie",
|
|
||||||
`better-auth.state=${cookies.get("better-auth.state")?.value}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(signInRes.data).toMatchObject({
|
expect(signInRes.data).toMatchObject({
|
||||||
url: expect.stringContaining("google.com"),
|
url: expect.stringContaining("google.com"),
|
||||||
codeVerifier: expect.any(String),
|
|
||||||
state: {
|
|
||||||
hash: expect.any(String),
|
|
||||||
raw: expect.any(String),
|
|
||||||
},
|
|
||||||
redirect: true,
|
redirect: true,
|
||||||
});
|
});
|
||||||
state = signInRes.data?.state.raw || "";
|
state = new URL(signInRes.data!.url).searchParams.get("state") || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be able to sign in with social providers", async () => {
|
it("should be able to sign in with social providers", async () => {
|
||||||
@@ -100,7 +81,6 @@ describe("Social Providers", async () => {
|
|||||||
code: "test",
|
code: "test",
|
||||||
},
|
},
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
|
||||||
onError(context) {
|
onError(context) {
|
||||||
expect(context.response.status).toBe(302);
|
expect(context.response.status).toBe(302);
|
||||||
const location = context.response.headers.get("location");
|
const location = context.response.headers.get("location");
|
||||||
|
|||||||
57
packages/better-auth/src/utils/env.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//https://github.com/unjs/std-env/blob/main/src/env.ts
|
||||||
|
|
||||||
|
const _envShim = Object.create(null);
|
||||||
|
|
||||||
|
export type EnvObject = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
const _getEnv = (useShim?: boolean) =>
|
||||||
|
globalThis.process?.env ||
|
||||||
|
//@ts-expect-error
|
||||||
|
globalThis.Deno?.env.toObject() ||
|
||||||
|
//@ts-expect-error
|
||||||
|
globalThis.__env__ ||
|
||||||
|
(useShim ? _envShim : globalThis);
|
||||||
|
|
||||||
|
export const env = new Proxy<EnvObject>(_envShim, {
|
||||||
|
get(_, prop) {
|
||||||
|
const env = _getEnv();
|
||||||
|
return env[prop as any] ?? _envShim[prop];
|
||||||
|
},
|
||||||
|
has(_, prop) {
|
||||||
|
const env = _getEnv();
|
||||||
|
return prop in env || prop in _envShim;
|
||||||
|
},
|
||||||
|
set(_, prop, value) {
|
||||||
|
const env = _getEnv(true);
|
||||||
|
env[prop as any] = value;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
deleteProperty(_, prop) {
|
||||||
|
if (!prop) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const env = _getEnv(true);
|
||||||
|
delete env[prop as any];
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
ownKeys() {
|
||||||
|
const env = _getEnv(true);
|
||||||
|
return Object.keys(env);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function toBoolean(val: boolean | string | undefined) {
|
||||||
|
return val ? val !== "false" : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nodeENV =
|
||||||
|
(typeof process !== "undefined" && process.env && process.env.NODE_ENV) || "";
|
||||||
|
|
||||||
|
/** Detect if `NODE_ENV` environment variable is `production` */
|
||||||
|
export const isProduction = nodeENV === "production";
|
||||||
|
|
||||||
|
/** Detect if `NODE_ENV` environment variable is `dev` or `development` */
|
||||||
|
export const isDevelopment = nodeENV === "dev" || nodeENV === "development";
|
||||||
|
|
||||||
|
/** Detect if `NODE_ENV` environment variable is `test` */
|
||||||
|
export const isTest = nodeENV === "test" || toBoolean(env.TEST);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isTest } from "std-env";
|
import { isTest } from "../utils/env";
|
||||||
|
|
||||||
export function getIp(req: Request | Headers): string | null {
|
export function getIp(req: Request | Headers): string | null {
|
||||||
const testIP = "127.0.0.1";
|
const testIP = "127.0.0.1";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { env } from "std-env";
|
import { env } from "../utils/env";
|
||||||
import { BetterAuthError } from "../error";
|
import { BetterAuthError } from "../error";
|
||||||
|
|
||||||
function checkHasPath(url: string): boolean {
|
function checkHasPath(url: string): boolean {
|
||||||
@@ -47,3 +47,8 @@ export function getOrigin(url: string) {
|
|||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
return parsedUrl.origin.replace("http://", "").replace("https://", "");
|
return parsedUrl.origin.replace("http://", "").replace("https://", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const checkURLValidity = (url: string) => {
|
||||||
|
const urlPattern = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//;
|
||||||
|
return urlPattern.test(url);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from "tsup";
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
export default defineConfig((env) => {
|
export default defineConfig((env) => {
|
||||||
return {
|
return {
|
||||||
entry: {
|
entry: {
|
||||||
@@ -28,8 +29,6 @@ export default defineConfig((env) => {
|
|||||||
node: "./src/integrations/node.ts",
|
node: "./src/integrations/node.ts",
|
||||||
},
|
},
|
||||||
format: ["esm", "cjs"],
|
format: ["esm", "cjs"],
|
||||||
minify: true,
|
|
||||||
splitting: true,
|
|
||||||
bundle: true,
|
bundle: true,
|
||||||
skipNodeModulesBundle: true,
|
skipNodeModulesBundle: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@better-auth/cli",
|
"name": "@better-auth/cli",
|
||||||
"version": "0.6.3-beta.3",
|
"version": "0.6.3-beta.4",
|
||||||
"description": "The CLI for Better Auth",
|
"description": "The CLI for Better Auth",
|
||||||
"module": "dist/index.mjs",
|
"module": "dist/index.mjs",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
42
packages/expo/package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@better-auth/expo",
|
||||||
|
"version": "0.6.3-beta.4",
|
||||||
|
"description": "",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"module": "dist/index.mjs",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest",
|
||||||
|
"build": "tsup --dts --minify --clean",
|
||||||
|
"dev": "tsup --watch --sourcemap --dts"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./client": {
|
||||||
|
"types": "./dist/client.d.ts",
|
||||||
|
"import": "./dist/client.mjs",
|
||||||
|
"require": "./dist/client.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"better-auth": "workspace:*",
|
||||||
|
"better-sqlite3": "^11.5.0",
|
||||||
|
"expo-constants": "~16.0.2",
|
||||||
|
"expo-linking": "~6.3.1",
|
||||||
|
"expo-secure-store": "~13.0.2",
|
||||||
|
"expo-web-browser": "~13.0.3",
|
||||||
|
"vitest": "^1.6.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nanostores": "^0.11.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"better-auth": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
198
packages/expo/src/client.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import type { BetterAuthClientPlugin, Store } from "better-auth";
|
||||||
|
import * as Browser from "expo-web-browser";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
|
||||||
|
interface CookieAttributes {
|
||||||
|
value: string;
|
||||||
|
expires?: Date;
|
||||||
|
"max-age"?: number;
|
||||||
|
domain?: string;
|
||||||
|
path?: string;
|
||||||
|
secure?: boolean;
|
||||||
|
httpOnly?: boolean;
|
||||||
|
sameSite?: "Strict" | "Lax" | "None";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSetCookieHeader(header: string): Map<string, CookieAttributes> {
|
||||||
|
const cookieMap = new Map<string, CookieAttributes>();
|
||||||
|
const cookies = header.split(", ");
|
||||||
|
cookies.forEach((cookie) => {
|
||||||
|
const [nameValue, ...attributes] = cookie.split("; ");
|
||||||
|
const [name, value] = nameValue.split("=");
|
||||||
|
|
||||||
|
const cookieObj: CookieAttributes = { value };
|
||||||
|
|
||||||
|
attributes.forEach((attr) => {
|
||||||
|
const [attrName, attrValue] = attr.split("=");
|
||||||
|
cookieObj[attrName.toLowerCase() as "value"] = attrValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
cookieMap.set(name, cookieObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cookieMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpoClientOptions {
|
||||||
|
scheme?: string;
|
||||||
|
storage?: {
|
||||||
|
setItem: (key: string, value: string) => any;
|
||||||
|
getItem: (key: string) => string | null;
|
||||||
|
};
|
||||||
|
storagePrefix?: string;
|
||||||
|
disableCache?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredCookie {
|
||||||
|
value: string;
|
||||||
|
expires: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSetCookie(header: string) {
|
||||||
|
const parsed = parseSetCookieHeader(header);
|
||||||
|
const toSetCookie: Record<string, StoredCookie> = {};
|
||||||
|
parsed.forEach((cookie, key) => {
|
||||||
|
const expiresAt = cookie["expires"];
|
||||||
|
const maxAge = cookie["max-age"];
|
||||||
|
const expires = expiresAt
|
||||||
|
? new Date(String(expiresAt))
|
||||||
|
: maxAge
|
||||||
|
? new Date(Date.now() + Number(maxAge))
|
||||||
|
: null;
|
||||||
|
toSetCookie[key] = {
|
||||||
|
value: cookie["value"],
|
||||||
|
expires,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return JSON.stringify(toSetCookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(cookie: string) {
|
||||||
|
let parsed = {} as Record<string, StoredCookie>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(cookie) as Record<string, StoredCookie>;
|
||||||
|
} catch (e) {}
|
||||||
|
const toSend = Object.entries(parsed).reduce((acc, [key, value]) => {
|
||||||
|
if (value.expires && value.expires < new Date()) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
return `${acc}; ${key}=${value.value}`;
|
||||||
|
}, "");
|
||||||
|
return toSend;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrigin(scheme: string) {
|
||||||
|
const schemeURI = Linking.createURL("", { scheme });
|
||||||
|
return schemeURI;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const expoClient = (opts: ExpoClientOptions) => {
|
||||||
|
let store: Store | null = null;
|
||||||
|
const cookieName = `${opts.storagePrefix || "better-auth"}_cookie`;
|
||||||
|
const localCacheName = `${opts.storagePrefix || "better-auth"}_session_data`;
|
||||||
|
const storage = opts.storage || SecureStore;
|
||||||
|
const scheme = opts.scheme || Constants.platform?.scheme;
|
||||||
|
const isWeb = Platform.OS === "web";
|
||||||
|
if (!scheme && !isWeb) {
|
||||||
|
throw new Error(
|
||||||
|
"Scheme not found in app.json. Please provide a scheme in the options.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: "expo",
|
||||||
|
getActions(_, $store) {
|
||||||
|
if (Platform.OS === "web") return {};
|
||||||
|
store = $store;
|
||||||
|
const localSession = storage.getItem(cookieName);
|
||||||
|
localSession &&
|
||||||
|
$store.atoms.session.set({
|
||||||
|
data: JSON.parse(localSession),
|
||||||
|
error: null,
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
fetchPlugins: [
|
||||||
|
{
|
||||||
|
id: "expo",
|
||||||
|
name: "Expo",
|
||||||
|
hooks: {
|
||||||
|
async onSuccess(context) {
|
||||||
|
if (isWeb) return;
|
||||||
|
const setCookie = context.response.headers.get("set-cookie");
|
||||||
|
if (setCookie) {
|
||||||
|
const toSetCookie = getSetCookie(setCookie || "");
|
||||||
|
await storage.setItem(cookieName, toSetCookie);
|
||||||
|
store?.notify("$sessionSignal");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
context.request.url.toString().includes("/get-session") &&
|
||||||
|
!opts.disableCache
|
||||||
|
) {
|
||||||
|
const data = context.data;
|
||||||
|
storage.setItem(localCacheName, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
context.data.redirect &&
|
||||||
|
context.request.url.toString().includes("/sign-in")
|
||||||
|
) {
|
||||||
|
const callbackURL = JSON.parse(context.request.body)?.callbackURL;
|
||||||
|
const to = callbackURL;
|
||||||
|
const signInURL = context.data?.url;
|
||||||
|
const result = await Browser.openAuthSessionAsync(signInURL, to);
|
||||||
|
if (result.type !== "success") return;
|
||||||
|
const url = new URL(result.url);
|
||||||
|
const cookie = String(url.searchParams.get("cookie"));
|
||||||
|
if (!cookie) return;
|
||||||
|
storage.setItem(cookieName, getSetCookie(cookie));
|
||||||
|
store?.notify("$sessionSignal");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async init(url, options) {
|
||||||
|
if (isWeb) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
options = options || {};
|
||||||
|
const storedCookie = storage.getItem(cookieName);
|
||||||
|
const cookie = getCookie(storedCookie || "{}");
|
||||||
|
options.credentials = "omit";
|
||||||
|
options.headers = {
|
||||||
|
...options.headers,
|
||||||
|
cookie,
|
||||||
|
origin: getOrigin(scheme!),
|
||||||
|
};
|
||||||
|
if (options.body?.callbackURL) {
|
||||||
|
if (options.body.callbackURL.startsWith("/")) {
|
||||||
|
const url = Linking.createURL(options.body.callbackURL, {
|
||||||
|
scheme,
|
||||||
|
});
|
||||||
|
options.body.callbackURL = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.includes("/sign-out")) {
|
||||||
|
await storage.setItem(cookieName, "{}");
|
||||||
|
store?.atoms.session?.set({
|
||||||
|
data: null,
|
||||||
|
error: null,
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
storage.setItem(localCacheName, "{}");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies BetterAuthClientPlugin;
|
||||||
|
};
|
||||||
133
packages/expo/src/expo.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { createAuthClient } from "better-auth/client";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { expo } from ".";
|
||||||
|
import { expoClient } from "./client";
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { getMigrations } from "better-auth/db";
|
||||||
|
|
||||||
|
vi.mock("expo-web-browser", async () => {
|
||||||
|
return {
|
||||||
|
openAuthSessionAsync: vi.fn(async (...args) => {
|
||||||
|
fn(...args);
|
||||||
|
return {
|
||||||
|
type: "success",
|
||||||
|
url: "better-auth://?cookie=better-auth.session_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjYxMzQwZj",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native", async () => {
|
||||||
|
return {
|
||||||
|
Platform: {
|
||||||
|
OS: "android",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("expo-constants", async () => {
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
platform: {
|
||||||
|
scheme: "better-auth",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("expo-linking", async () => {
|
||||||
|
return {
|
||||||
|
createURL: vi.fn((url) => `better-auth://${url}`),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("expo-secure-store", async () => {
|
||||||
|
return {
|
||||||
|
getItemAsync: vi.fn(async (key) => null),
|
||||||
|
setItemAsync: vi.fn(),
|
||||||
|
deleteItemAsync: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fn = vi.fn();
|
||||||
|
|
||||||
|
describe("expo", async () => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
|
const auth = betterAuth({
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
database: new Database(":memory:"),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
socialProviders: {
|
||||||
|
google: {
|
||||||
|
clientId: "test",
|
||||||
|
clientSecret: "test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [expo()],
|
||||||
|
trustedOrigins: ["better-auth://"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
fetchOptions: {
|
||||||
|
customFetchImpl: (url, init) => {
|
||||||
|
const req = new Request(url.toString(), init);
|
||||||
|
return auth.handler(req);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
expoClient({
|
||||||
|
storage: {
|
||||||
|
getItem: (key) => storage.get(key) || null,
|
||||||
|
setItem: async (key, value) => storage.set(key, value),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { runMigrations } = await getMigrations(auth.options);
|
||||||
|
await runMigrations();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should store cookie with expires date", async () => {
|
||||||
|
const testUser = {
|
||||||
|
email: "test@test.com",
|
||||||
|
password: "password",
|
||||||
|
name: "Test User",
|
||||||
|
};
|
||||||
|
await client.signUp.email(testUser);
|
||||||
|
const storedCookie = storage.get("better-auth_cookie");
|
||||||
|
expect(storedCookie).toBeDefined();
|
||||||
|
const parsedCookie = JSON.parse(storedCookie || "");
|
||||||
|
expect(parsedCookie["better-auth.session_token"]).toMatchObject({
|
||||||
|
value: expect.any(String),
|
||||||
|
expires: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send cookie and get session", async () => {
|
||||||
|
const { data } = await client.getSession();
|
||||||
|
expect(data).toMatchObject({
|
||||||
|
session: expect.any(Object),
|
||||||
|
user: expect.any(Object),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use the scheme to open the browser", async () => {
|
||||||
|
const { data: res } = await client.signIn.social({
|
||||||
|
provider: "google",
|
||||||
|
callbackURL: "/dashboard",
|
||||||
|
});
|
||||||
|
expect(res).toMatchObject({
|
||||||
|
url: expect.stringContaining("accounts.google"),
|
||||||
|
});
|
||||||
|
expect(fn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("accounts.google"),
|
||||||
|
"better-auth:///dashboard",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
packages/expo/src/index.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { BetterAuthPlugin } from "better-auth";
|
||||||
|
|
||||||
|
export const expo = () => {
|
||||||
|
return {
|
||||||
|
id: "expo",
|
||||||
|
init: (ctx) => {
|
||||||
|
const trustedOrigins =
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? [...(ctx.options.trustedOrigins || []), "exp://"]
|
||||||
|
: ctx.options.trustedOrigins;
|
||||||
|
return {
|
||||||
|
options: {
|
||||||
|
trustedOrigins,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
after: [
|
||||||
|
{
|
||||||
|
matcher(context) {
|
||||||
|
return context.path?.startsWith("/callback");
|
||||||
|
},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const response = ctx.context.returned as Response;
|
||||||
|
if (response.status === 302) {
|
||||||
|
const location = response.headers.get("location");
|
||||||
|
if (!location) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trustedOrigins = ctx.context.trustedOrigins.filter(
|
||||||
|
(origin) => !origin.startsWith("http"),
|
||||||
|
);
|
||||||
|
const isTrustedOrigin = trustedOrigins.some((origin) =>
|
||||||
|
location?.startsWith(origin),
|
||||||
|
);
|
||||||
|
if (!isTrustedOrigin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cookie = response.headers.get("set-cookie");
|
||||||
|
if (!cookie) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL(location);
|
||||||
|
url.searchParams.set("cookie", cookie);
|
||||||
|
response.headers.set("location", url.toString());
|
||||||
|
return {
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} satisfies BetterAuthPlugin;
|
||||||
|
};
|
||||||
20
packages/expo/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"allowJs": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"noEmit": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules"],
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
13
packages/expo/tsup.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig((env) => {
|
||||||
|
return {
|
||||||
|
entry: {
|
||||||
|
index: "src/index.ts",
|
||||||
|
client: "src/client.ts",
|
||||||
|
},
|
||||||
|
format: ["esm", "cjs"],
|
||||||
|
bundle: true,
|
||||||
|
skipNodeModulesBundle: true,
|
||||||
|
};
|
||||||
|
});
|
||||||