chore: misc

This commit is contained in:
Bereket Engida
2025-09-26 11:53:40 -07:00
committed by Alex Yang
parent 270daad9c8
commit 2f8a0e1168
52 changed files with 4094 additions and 435 deletions

View File

@@ -0,0 +1,7 @@
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
BETTER_AUTH_SECRET=
DATABASE_URL=
BETTER_AUTH_URL=http://localohst:8081

20
demo/expo-example/.gitignore vendored Normal file
View 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

View File

@@ -0,0 +1,15 @@
# Better Auth Expo Example
This is an example of how to use Better Auth with Expo. It uses Expo's new API Router to host the auth server.
## How to run
1. Clone the code sandbox (or the repo) and open it in your code editor
2. Move and Provide environment variable
3. Run the following commands
```bash
pnpm install
pnpm start
```s
Checkout the [expo guide](https://www.better-auth.com/docs/integrations/expo) to learn more.

View File

@@ -0,0 +1,55 @@
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",
"expo-font",
],
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View File

@@ -0,0 +1,6 @@
{
"aliases": {
"components": "@/components",
"lib": "@/lib"
}
}

View File

@@ -0,0 +1 @@
import "expo-router/entry";

View File

@@ -0,0 +1,39 @@
// 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 = 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;
}

3
demo/expo-example/nativewind-env.d.ts vendored Normal file
View 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.

View File

@@ -0,0 +1,68 @@
{
"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/metro-runtime": "^6.1.2",
"@expo/vector-icons": "^15.0.2",
"@nanostores/react": "^0.8.4",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.1.17",
"@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.12",
"babel-plugin-transform-import-meta": "^2.2.1",
"better-auth": "workspace:*",
"better-sqlite3": "^11.6.0",
"expo": "~54.0.10",
"expo-constants": "~18.0.9",
"expo-crypto": "^15.0.7",
"expo-font": "~14.0.8",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.8",
"expo-secure-store": "~15.0.7",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.7",
"nanostores": "^0.11.3",
"nativewind": "^4.1.23",
"pg": "^8.13.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-native": "~0.81.4",
"react-native-css-interop": "^0.2.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.2",
"react-native-safe-area-context": "5.6.1",
"react-native-screens": "4.16.0",
"react-native-svg": "^15.12.1",
"react-native-web": "~0.21.1",
"react-native-worklets": "^0.5.1",
"tailwindcss": "^3.4.16"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
"@types/babel__core": "^7.20.5",
"@types/jest": "^29.5.14",
"@types/react": "^19.1.12",
"@types/react-test-renderer": "^18.3.1",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,34 @@
import { Slot } 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 as any),
width: "100%",
}}
>
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "black",
opacity: 0.2,
}}
/>
<Slot />
</ImageBackground>
</SafeAreaProvider>
);
}

View 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);
};

View File

@@ -0,0 +1,68 @@
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";
import { useStore } from "@nanostores/react";
export default function Dashboard() {
const { data: session, isPending } = useStore(authClient.useSession);
useEffect(() => {
if (!session && !isPending) {
router.push("/");
}
}, [session, isPending]);
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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,133 @@
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";
import { useStore } from "@nanostores/react";
export default function Index() {
const { data: isAuthenticated } = useStore(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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View 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 };

View 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,
};

View 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,
};

View 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 };

View 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 };

View 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 };

View File

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

View File

@@ -0,0 +1,14 @@
import { createAuthClient } from "better-auth/client";
import { expoClient } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({
baseURL: "http://localhost:8081",
disableDefaultFetchPlugins: true,
plugins: [
expoClient({
scheme: "better-auth",
storage: SecureStore,
}),
],
});

View File

@@ -0,0 +1,24 @@
import { betterAuth } from "better-auth";
import { expo } from "@better-auth/expo";
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL,
}),
emailAndPassword: {
enabled: true,
},
plugins: [expo()],
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
trustedOrigins: ["exp://"],
});

View File

@@ -0,0 +1,4 @@
import { X } from "lucide-react-native";
import { iconWithClassName } from "./iconWithClassName";
iconWithClassName(X);
export { X };

View 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,
},
},
});
}

View 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";
}

View 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: [],
};

View 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"
}

View File

@@ -34,24 +34,22 @@ export default async function HomePage() {
<div className="flex flex-col md:flex-row items-center justify-center h-12">
<span className="font-medium flex gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<span className=" text-zinc-900 dark:text-white/90 hover:text-zinc-950 text-xs md:text-sm dark:hover:text-zinc-100 transition-colors">
Introducing{" "}
<span className="font-semibold">
Better Auth Infrastructure
</span>
Auth.js (formerly NextAuth.js) is now part of{" "}
<span className="font-semibold">Better Auth</span>
</span>
<span className=" text-zinc-400 hidden md:block">|</span>
<Link
href="https://better-auth.build"
href="/blog/authjs-joins-better-auth"
className="font-semibold text-blue-600 dark:text-blue-400 hover:text-blue-700 hidden dark:hover:text-blue-300 transition-colors md:block"
>
Join the waitlist
Read the announcement
</Link>
</span>
<Link
href="https://better-auth.build"
href="/blog/authjs-joins-better-auth"
className="font-semibold text-blue-600 dark:text-blue-400 hover:text-blue-700 text-xs dark:hover:text-blue-300 transition-colors md:hidden"
>
Join the waitlist
Read the announcement
</Link>
</div>
</div>

View File

@@ -36,6 +36,12 @@ We are deeply grateful to the Auth.js community who have carried the project to
Better Auth beginning was inspired by Auth.js, and now, together, the two projects can carry the ecosystem further. The end goal remains unchanged: you should own your auth!
<Callout type="none">
For the Auth.js team's announcement, see [GitHub discussion](https://github.com/nextauthjs/next-auth/discussions/13252).
</Callout>
### Learn More
<Cards>

View File

@@ -706,7 +706,7 @@
"nanostores": "^1.0.1",
"zod": "^4.1.5"
},
"peerDependencies": {
"peerDependenciesOptional": {
"@lynx-js/react": "*",
"@sveltejs/kit": "^2.0.0",
"next": "^14.0.0 || ^15.0.0",

View File

@@ -16,8 +16,18 @@ export async function generateState(
message: "callbackURL is required",
});
}
const codeVerifier = generateRandomString(128);
const state = generateRandomString(32);
const stateCookie = c.context.createAuthCookie("state", {
maxAge: 5 * 60 * 1000, // 5 minutes
});
await c.setSignedCookie(
stateCookie.name,
state,
c.context.secret,
stateCookie.attributes,
);
const data = JSON.stringify({
callbackURL,
codeVerifier,
@@ -65,6 +75,7 @@ export async function parseState(c: GenericEndpointContext) {
c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`;
throw c.redirect(`${errorURL}?error=please_restart_the_process`);
}
const parsedData = z
.object({
callbackURL: z.string(),
@@ -85,6 +96,19 @@ export async function parseState(c: GenericEndpointContext) {
if (!parsedData.errorURL) {
parsedData.errorURL = `${c.context.baseURL}/error`;
}
const stateCookie = c.context.createAuthCookie("state");
const stateCookieValue = await c.getSignedCookie(
stateCookie.name,
c.context.secret,
);
if (!stateCookieValue || stateCookieValue !== state) {
const errorURL =
c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`;
throw c.redirect(`${errorURL}?error=state_mismatch`);
}
c.setCookie(stateCookie.name, "", {
maxAge: 0,
});
if (parsedData.expiresAt < Date.now()) {
await c.context.internalAdapter.deleteVerificationValue(data.id);
const errorURL =

View File

@@ -49,13 +49,7 @@ export type InferSessionAPI<API> = API extends {
headers: Headers;
response: PrettifyDeep<Awaited<ReturnType<E>>> | null;
}>
: Promise<
| (PrettifyDeep<Awaited<ReturnType<E>>> & {
options: E["options"];
path: E["path"];
})
| null
>
: Promise<PrettifyDeep<Awaited<ReturnType<E>>> | null>
: Promise<Response>;
}
: never

View File

@@ -72,7 +72,8 @@
"expo-web-browser": ">=14.0.0"
},
"dependencies": {
"@better-fetch/fetch": "catalog:"
"@better-fetch/fetch": "catalog:",
"zod": "^4.1.5"
},
"files": [
"dist"

View File

@@ -221,7 +221,8 @@ export const expoClient = (opts: ExpoClientOptions) => {
},
);
}
const result = await Browser.openAuthSessionAsync(signInURL, to);
const proxyURL = `${context.request.baseURL}/expo-authorization-proxy?authorizationURL=${encodeURIComponent(signInURL)}`;
const result = await Browser.openAuthSessionAsync(proxyURL, to);
if (result.type !== "success") return;
const url = new URL(result.url);
const cookie = String(url.searchParams.get("cookie"));

View File

@@ -1,5 +1,10 @@
import type { BetterAuthPlugin } from "better-auth/types";
import { createAuthMiddleware } from "better-auth/api";
import {
APIError,
createAuthEndpoint,
createAuthMiddleware,
} from "better-auth/api";
import { z } from "zod";
export interface ExpoOptions {
/**
@@ -77,5 +82,39 @@ export const expo = (options?: ExpoOptions) => {
},
],
},
endpoints: {
expoAuthorizationProxy: createAuthEndpoint(
"/expo-authorization-proxy",
{
method: "GET",
query: z.object({
authorizationURL: z.string(),
}),
metadata: {
isAction: false,
},
},
async (ctx) => {
const { authorizationURL } = ctx.query;
const url = new URL(authorizationURL);
const state = url.searchParams.get("state");
if (!state) {
throw new APIError("BAD_REQUEST", {
message: "Unexpected error",
});
}
const stateCookie = ctx.context.createAuthCookie("state", {
maxAge: 5 * 60 * 1000, // 5 minutes
});
await ctx.setSignedCookie(
stateCookie.name,
state,
ctx.context.secret,
stateCookie.attributes,
);
return ctx.redirect(ctx.query.authorizationURL);
},
),
},
} satisfies BetterAuthPlugin;
};

3045
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff