feat: add authentication in native

This commit is contained in:
Aman Varshney
2025-04-04 19:06:08 +05:30
parent ccc3ff2aa5
commit 81dc240e7b
24 changed files with 1119 additions and 717 deletions

View File

@@ -7,10 +7,7 @@
"bin": {
"create-better-t-stack": "dist/index.js"
},
"files": [
"template",
"dist"
],
"files": ["template", "dist"],
"keywords": [
"typescript",
"scaffold",

View File

@@ -26,6 +26,7 @@ export const DEFAULT_CONFIG: ProjectConfig = {
export const dependencyVersionMap = {
"better-auth": "^1.2.4",
"@better-auth/expo": "^1.2.5",
"drizzle-orm": "^0.38.4",
"drizzle-kit": "^0.30.5",

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { log } from "@clack/prompts";
import pc from "picocolors";
import type { ProjectFrontend } from "../types";
import { addPackageDependency } from "../utils/add-package-deps";
export function generateAuthSecret(length = 32): string {
@@ -17,6 +18,7 @@ export function generateAuthSecret(length = 32): string {
export async function setupAuth(
projectDir: string,
enableAuth: boolean,
frontends: ProjectFrontend[] = [],
): Promise<void> {
if (!enableAuth) {
return;
@@ -24,16 +26,33 @@ export async function setupAuth(
const serverDir = path.join(projectDir, "apps/server");
const clientDir = path.join(projectDir, "apps/web");
const nativeDir = path.join(projectDir, "apps/native");
try {
addPackageDependency({
dependencies: ["better-auth"],
projectDir: serverDir,
});
addPackageDependency({
dependencies: ["better-auth"],
projectDir: clientDir,
});
if (
frontends.includes("react-router") ||
frontends.includes("tanstack-router")
) {
addPackageDependency({
dependencies: ["better-auth"],
projectDir: serverDir,
});
addPackageDependency({
dependencies: ["better-auth"],
projectDir: clientDir,
});
}
if (frontends.includes("native")) {
addPackageDependency({
dependencies: ["better-auth", "@better-auth/expo"],
projectDir: nativeDir,
});
addPackageDependency({
dependencies: ["better-auth", "@better-auth/expo"],
projectDir: serverDir,
});
}
} catch (error) {
log.error(pc.red("Failed to configure authentication"));
if (error instanceof Error) {

View File

@@ -66,7 +66,7 @@ export async function createProject(options: ProjectConfig): Promise<string> {
options.database,
options.frontend,
);
await setupAuth(projectDir, options.auth);
await setupAuth(projectDir, options.auth, options.frontend);
await setupRuntime(projectDir, options.runtime, options.backend);

View File

@@ -7,6 +7,7 @@ import type {
ProjectFrontend,
ProjectOrm,
} from "../types";
import { addPackageDependency } from "../utils/add-package-deps";
/**
* Copy base template structure but exclude app-specific folders that will be added based on options
@@ -233,10 +234,83 @@ export async function setupAuthTemplate(
if (await fs.pathExists(nativeAuthDir)) {
await fs.copy(nativeAuthDir, projectNativeDir, { overwrite: true });
}
addPackageDependency({
dependencies: ["@better-auth/expo"],
projectDir: path.join(projectDir, "apps/server"),
});
await updateAuthConfigWithExpoPlugin(projectDir, orm, database);
}
}
}
// Need to find a better way to handle this
async function updateAuthConfigWithExpoPlugin(
projectDir: string,
orm: ProjectOrm,
database: ProjectDatabase,
): Promise<void> {
const serverDir = path.join(projectDir, "apps/server");
let authFilePath: string | undefined;
if (orm === "drizzle") {
if (database === "sqlite") {
authFilePath = path.join(serverDir, "src/lib/auth.ts");
} else if (database === "postgres") {
authFilePath = path.join(serverDir, "src/lib/auth.ts");
}
} else if (orm === "prisma") {
if (database === "sqlite") {
authFilePath = path.join(serverDir, "src/lib/auth.ts");
} else if (database === "postgres") {
authFilePath = path.join(serverDir, "src/lib/auth.ts");
}
}
if (authFilePath && (await fs.pathExists(authFilePath))) {
let authFileContent = await fs.readFile(authFilePath, "utf8");
if (!authFileContent.includes("@better-auth/expo")) {
const importLine = 'import { expo } from "@better-auth/expo";\n';
const lastImportIndex = authFileContent.lastIndexOf("import");
const afterLastImport =
authFileContent.indexOf("\n", lastImportIndex) + 1;
authFileContent =
authFileContent.substring(0, afterLastImport) +
importLine +
authFileContent.substring(afterLastImport);
}
if (!authFileContent.includes("plugins:")) {
authFileContent = authFileContent.replace(
/}\);/,
" plugins: [expo()],\n});",
);
} else if (!authFileContent.includes("expo()")) {
authFileContent = authFileContent.replace(
/plugins: \[(.*?)\]/s,
(match, plugins) => {
return `plugins: [${plugins}${plugins.trim() ? ", " : ""}expo()]`;
},
);
}
if (!authFileContent.includes("my-better-t-app://")) {
authFileContent = authFileContent.replace(
/trustedOrigins: \[(.*?)\]/s,
(match, origins) => {
return `trustedOrigins: [${origins}${origins.trim() ? ", " : ""}"my-better-t-app://"]`;
},
);
}
await fs.writeFile(authFilePath, authFileContent);
}
}
export async function fixGitignoreFiles(projectDir: string): Promise<void> {
const gitignorePaths = await findGitignoreFiles(projectDir);

View File

@@ -10,19 +10,6 @@ export async function getAuthChoice(
): Promise<boolean> {
if (!hasDatabase) return false;
const hasNative = frontends?.includes("native");
const hasWeb =
frontends?.includes("tanstack-router") ||
frontends?.includes("react-router");
if (hasNative) {
log.warn(
pc.yellow("Note: Authentication is not yet available with native"),
);
}
if (!hasWeb) return false;
if (auth !== undefined) return auth;
const response = await confirm({

View File

@@ -14,16 +14,16 @@ export async function getPackageManagerChoice(
message: "Choose package manager",
options: [
{ value: "npm", label: "npm", hint: "Node Package Manager" },
{
value: "bun",
label: "bun",
hint: "All-in-one JavaScript runtime & toolkit",
},
{
value: "pnpm",
label: "pnpm",
hint: "Fast, disk space efficient package manager",
},
{
value: "bun",
label: "bun",
hint: "All-in-one JavaScript runtime & toolkit",
},
],
initialValue: detectedPackageManager,
});

View File

@@ -9,7 +9,10 @@
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-router"],
"plugins": [
"expo-router",
"expo-secure-store"
],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true

View File

@@ -1,9 +1,17 @@
import { View, Text } from "react-native";
import { Container } from "@/components/container";
import { Text, View } from "react-native";
export default function App() {
return (
<View>
<Text>Hello, World!</Text>
</View>
);
export default function TabOne() {
return (
<Container>
<View className="p-6 flex-1 justify-center">
<Text className="text-2xl font-bold text-foreground text-center mb-4">
Tab One
</Text>
<Text className="text-foreground text-center">
This is the first tab of the application.
</Text>
</View>
</Container>
);
}

View File

@@ -1,16 +1,17 @@
import { Stack } from "expo-router";
import { View, Text } from "react-native";
import { Container } from "@/components/container";
import { Text, View } from "react-native";
export default function Home() {
return (
<>
<Container>
<View>
<Text>Tab Two</Text>
</View>
</Container>
</>
);
export default function TabTwo() {
return (
<Container>
<View className="p-6 flex-1 justify-center">
<Text className="text-2xl font-bold text-foreground text-center mb-4">
Tab Two
</Text>
<Text className="text-foreground text-center">
This is the second tab of the application.
</Text>
</View>
</Container>
);
}

View File

@@ -1,15 +1,12 @@
import { Container } from "@/components/container";
import { Text, View } from "react-native";
import { Container } from "@/components/container";
export default function Modal() {
return (
<>
<Container>
<View>
<Text>HI MODAL</Text>
</View>
</Container>
</>
);
return (
<Container>
<View className="flex-1 justify-center items-center">
<Text className="text-xl font-bold text-foreground">Modal View</Text>
</View>
</Container>
);
}

View File

@@ -15,6 +15,8 @@ const config = withTurborepoManagedCache(
config.resolver.unstable_enablePackageExports = true;
config.resolver.disableHierarchicalLookup = true;
module.exports = config;
/**

View File

@@ -3,7 +3,7 @@
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"dev": "expo start",
"dev": "expo start --clear",
"android": "expo run:android",
"ios": "expo run:ios",
"prebuild": "expo prebuild",
@@ -19,18 +19,19 @@
"@trpc/client": "^11.0.0",
"@trpc/server": "^11.0.0",
"@trpc/tanstack-react-query": "^11.0.0",
"expo": "^52.0.41",
"expo": "^52.0.44",
"expo-constants": "~17.0.8",
"expo-linking": "~7.0.5",
"expo-navigation-bar": "~4.0.8",
"expo-router": "~4.0.19",
"expo-secure-store": "~14.0.1",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
"expo-web-browser": "~14.0.2",
"nativewind": "^4.1.23",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.7",
"react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.2",
"react-native-reanimated": "3.16.2",
"react-native-safe-area-context": "4.12.0",

View File

@@ -3,13 +3,13 @@ import { RouterProvider, createRouter } from "@tanstack/react-router";
import ReactDOM from "react-dom/client";
import Loader from "./components/loader";
import { routeTree } from "./routeTree.gen";
import { queryClient, trpcClient } from "./utils/trpc";
import { queryClient, trpc } from "./utils/trpc";
const router = createRouter({
routeTree,
defaultPreload: "intent",
defaultPendingComponent: () => <Loader />,
context: { trpcClient },
context: { trpc, queryClient },
Wrap: function WrapComponent({ children }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>

View File

@@ -2,7 +2,8 @@ import Header from "@/components/header";
import Loader from "@/components/loader";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import type { trpcClient } from "@/utils/trpc";
import type { trpc } from "@/utils/trpc";
import type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import {
HeadContent,
@@ -14,7 +15,8 @@ import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import "../index.css";
export interface RouterAppContext {
trpcClient: typeof trpcClient;
trpc: typeof trpc;
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<RouterAppContext>()({

View File

@@ -0,0 +1,84 @@
import { authClient } from "@/lib/auth-client";
import { useQuery } from "@tanstack/react-query";
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
import { Container } from "@/components/container";
import { SignIn } from "@/components/sign-in";
import { SignUp } from "@/components/sign-up";
import { queryClient, trpc } from "@/utils/trpc";
export default function Home() {
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
const privateData = useQuery(trpc.privateData.queryOptions());
const { data: session } = authClient.useSession();
return (
<Container>
<ScrollView className="flex-1">
<View className="px-4">
<Text className="font-mono text-foreground text-3xl font-bold mb-4">
BETTER T STACK
</Text>
{session?.user ? (
<View className="mb-6 p-4 bg-card rounded-lg border border-border">
<View className="flex-row justify-between items-center mb-2">
<Text className="text-foreground text-base">
Welcome,{" "}
<Text className="font-medium">{session.user.name}</Text>
</Text>
</View>
<Text className="text-muted-foreground text-sm mb-4">
{session.user.email}
</Text>
<TouchableOpacity
className="bg-destructive py-2 px-4 rounded-md self-start"
onPress={() => {
authClient.signOut();
queryClient.invalidateQueries();
}}
>
<Text className="text-white font-medium">Sign Out</Text>
</TouchableOpacity>
</View>
) : null}
<View className="mb-6 rounded-lg border border-border p-4">
<Text className="mb-3 font-medium text-foreground">API Status</Text>
<View className="flex-row items-center gap-2">
<View
className={`h-3 w-3 rounded-full ${
healthCheck.data ? "bg-green-500" : "bg-red-500"
}`}
/>
<Text className="text-muted-foreground">
{healthCheck.isLoading
? "Checking..."
: healthCheck.data
? "Connected to API"
: "API Disconnected"}
</Text>
</View>
</View>
<View className="mb-6 rounded-lg border border-border p-4">
<Text className="mb-3 font-medium text-foreground">
Private Data
</Text>
{privateData && (
<View>
<Text className="text-muted-foreground">
{privateData.data?.message}
</Text>
</View>
)}
</View>
{!session?.user && (
<>
<SignIn />
<SignUp />
</>
)}
</View>
</ScrollView>
</Container>
);
}

View File

@@ -0,0 +1,88 @@
import { authClient } from "@/lib/auth-client";
import { queryClient } from "@/utils/trpc";
import { useState } from "react";
import {
ActivityIndicator,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
export function SignIn() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async () => {
setIsLoading(true);
setError(null);
await authClient.signIn.email(
{
email,
password,
},
{
onError: (error) => {
setError(error.error?.message || "Failed to sign in");
setIsLoading(false);
},
onSuccess: () => {
setEmail("");
setPassword("");
queryClient.refetchQueries();
},
onFinished: () => {
setIsLoading(false);
},
},
);
};
return (
<View className="mt-6 p-4 bg-card rounded-lg border border-border">
<Text className="text-lg font-semibold text-foreground mb-4">
Sign In
</Text>
{error && (
<View className="mb-4 p-3 bg-destructive/10 rounded-md">
<Text className="text-destructive text-sm">{error}</Text>
</View>
)}
<TextInput
className="mb-3 p-4 rounded-md bg-input text-foreground border border-input"
placeholder="Email"
value={email}
onChangeText={setEmail}
placeholderTextColor="#9CA3AF"
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
className="mb-4 p-4 rounded-md bg-input text-foreground border border-input"
placeholder="Password"
value={password}
onChangeText={setPassword}
placeholderTextColor="#9CA3AF"
secureTextEntry
/>
<TouchableOpacity
onPress={handleLogin}
disabled={isLoading}
className="bg-primary p-4 rounded-md flex-row justify-center items-center"
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text className="text-primary-foreground font-medium">Sign In</Text>
)}
</TouchableOpacity>
</View>
);
}

View File

@@ -0,0 +1,99 @@
import { authClient } from "@/lib/auth-client";
import { queryClient } from "@/utils/trpc";
import { useState } from "react";
import {
ActivityIndicator,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
export function SignUp() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSignUp = async () => {
setIsLoading(true);
setError(null);
await authClient.signUp.email(
{
name,
email,
password,
},
{
onError: (error) => {
setError(error.error?.message || "Failed to sign up");
setIsLoading(false);
},
onSuccess: () => {
setName("");
setEmail("");
setPassword("");
queryClient.refetchQueries();
},
onFinished: () => {
setIsLoading(false);
},
},
);
};
return (
<View className="mt-6 p-4 bg-card rounded-lg border border-border">
<Text className="text-lg font-semibold text-foreground mb-4">
Create Account
</Text>
{error && (
<View className="mb-4 p-3 bg-destructive/10 rounded-md">
<Text className="text-destructive text-sm">{error}</Text>
</View>
)}
<TextInput
className="mb-3 p-4 rounded-md bg-input text-foreground border border-input"
placeholder="Name"
value={name}
onChangeText={setName}
placeholderTextColor="#9CA3AF"
/>
<TextInput
className="mb-3 p-4 rounded-md bg-input text-foreground border border-input"
placeholder="Email"
value={email}
onChangeText={setEmail}
placeholderTextColor="#9CA3AF"
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
className="mb-4 p-4 rounded-md bg-input text-foreground border border-input"
placeholder="Password"
value={password}
onChangeText={setPassword}
placeholderTextColor="#9CA3AF"
secureTextEntry
/>
<TouchableOpacity
onPress={handleSignUp}
disabled={isLoading}
className="bg-primary p-4 rounded-md flex-row justify-center items-center"
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text className="text-primary-foreground font-medium">Sign Up</Text>
)}
</TouchableOpacity>
</View>
);
}

View File

@@ -0,0 +1,13 @@
import { createAuthClient } from "better-auth/react";
import { expoClient } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({
baseURL: process.env.EXPO_PUBLIC_SERVER_URL,
plugins: [
expoClient({
storagePrefix: "my-better-t-app",
storage: SecureStore,
}),
],
});

View File

@@ -0,0 +1,28 @@
import { authClient } from "@/lib/auth-client";
import { QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import type { AppRouter } from "../../server/src/routers";
export const queryClient = new QueryClient();
const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${process.env.EXPO_PUBLIC_SERVER_URL}/trpc`,
headers() {
const headers = new Map<string, string>();
const cookies = authClient.getCookie();
if (cookies) {
headers.set("Cookie", cookies);
}
return Object.fromEntries(headers);
},
}),
],
});
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});