feat: expo plugin (#375)

This commit is contained in:
Bereket Engida
2024-11-01 09:24:14 +03:00
committed by GitHub
parent 979923d05e
commit cce28070ee
87 changed files with 10116 additions and 2059 deletions

20
examples/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,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.

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,10 @@
{
"$schema": "https://turborepo.org/schema.json",
"extends": ["//"],
"tasks": {
"dev": {
"persistent": true,
"interactive": true
}
}
}

View File

@@ -35,7 +35,7 @@ export const {
signIn,
signOut,
useSession,
user,
organization,
useListOrganizations,
useActiveOrganization,

View File

@@ -12,28 +12,68 @@ import {
import { reactInvitationEmail } from "./email/invitation";
import { reactResetPasswordEmail } from "./email/rest-password";
import { resend } from "./email/resend";
import { expo } from "@better-auth/expo";
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
const to = process.env.TEST_EMAIL || "";
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({
database,
emailVerification: {
async sendVerificationEmail(user, url) {
console.log("Sending verification email to", user.email);
const res = await resend.emails.send({
from,
to: to || user.email,
subject: "Verify your email address",
html: `<a href="${url}">Verify your email address</a>`,
});
console.log(res, user.email);
// const res = await resend.emails.send({
// from,
// to: to || user.email,
// subject: "Verify your email address",
// html: `<a href="${url}">Verify your email address</a>`,
// });
// console.log(res, user.email);
},
sendOnSignUp: true,
},
account: {
enabled: true,
accountLinking: {
trustedProviders: ["google", "github"],
},
@@ -60,7 +100,8 @@ export const auth = betterAuth({
google: {
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
accessType: "offline",
// accessType: "offline",
// prompt: "select_account",
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID || "",
@@ -76,46 +117,14 @@ export const auth = betterAuth({
},
},
plugins: [
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);
},
}),
twoFactor({
otpOptions: {
async sendOTP(user, otp) {
await resend.emails.send({
from,
to: user.email,
subject: "Your OTP",
html: `Your OTP is ${otp}`,
});
},
},
}),
organizationPlugin,
twoFactorPlugin,
passkey(),
bearer(),
admin(),
multiSession(),
username(),
expo(),
],
trustedOrigins: ["better-auth://", "exp://"],
});

View File

@@ -1,5 +1,5 @@
{
"name": "@better-auth/nextjs",
"name": "@example/nextjs",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -11,9 +11,10 @@
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@libsql/client": "^0.12.0",
"@better-auth/expo": "workspace:*",
"@libsql/kysely-libsql": "^0.4.1",
"@prisma/adapter-libsql": "^5.19.1",
"@prisma/client": "^5.19.1",