feat: add clerk auth support with convex (#548)

This commit is contained in:
Aman Varshney
2025-08-29 00:21:08 +05:30
committed by GitHub
parent 8d48ae0359
commit 54bcdf1cbc
153 changed files with 1954 additions and 771 deletions

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,95 @@
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";
{{#if (eq api "orpc")}}
import { queryClient, orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { queryClient, trpc } from "@/utils/trpc";
{{/if}}
export default function Home() {
{{#if (eq api "orpc")}}
const healthCheck = useQuery(orpc.healthCheck.queryOptions());
const privateData = useQuery(orpc.privateData.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}}
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,93 @@
import { authClient } from "@/lib/auth-client";
{{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { queryClient } from "@/utils/orpc";
{{/if}}
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,104 @@
import { authClient } from "@/lib/auth-client";
{{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { queryClient } from "@/utils/orpc";
{{/if}}
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,179 @@
import { authClient } from "@/lib/auth-client";
import { useQuery } from "@tanstack/react-query";
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Container } from "@/components/container";
import { SignIn } from "@/components/sign-in";
import { SignUp } from "@/components/sign-up";
{{#if (eq api "orpc")}}
import { queryClient, orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { queryClient, trpc } from "@/utils/trpc";
{{/if}}
export default function Home() {
{{#if (eq api "orpc")}}
const healthCheck = useQuery(orpc.healthCheck.queryOptions());
const privateData = useQuery(orpc.privateData.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}}
const { data: session } = authClient.useSession();
return (
<Container>
<ScrollView>
<View style={styles.pageContainer}>
<Text style={styles.headerTitle}>BETTER T STACK</Text>
{session?.user ? (
<View style={styles.sessionInfoCard}>
<View style={styles.sessionUserRow}>
<Text style={styles.welcomeText}>
Welcome,{" "}
<Text style={styles.userNameText}>{session.user.name}</Text>
</Text>
</View>
<Text style={styles.emailText}>{session.user.email}</Text>
<TouchableOpacity
style={styles.signOutButton}
onPress={() => {
authClient.signOut();
queryClient.invalidateQueries();
}}
>
<Text style={styles.signOutButtonText}>Sign Out</Text>
</TouchableOpacity>
</View>
) : null}
<View style={styles.apiStatusCard}>
<Text style={styles.cardTitle}>API Status</Text>
<View style={styles.apiStatusRow}>
<View
style={[
styles.statusIndicatorDot,
healthCheck.data
? styles.statusIndicatorGreen
: styles.statusIndicatorRed,
]}
/>
<Text style={styles.mutedText}>
{healthCheck.isLoading
? "Checking..."
: healthCheck.data
? "Connected to API"
: "API Disconnected"}
</Text>
</View>
</View>
<View style={styles.privateDataCard}>
<Text style={styles.cardTitle}>Private Data</Text>
{privateData && (
<View>
<Text style={styles.mutedText}>
{privateData.data?.message}
</Text>
</View>
)}
</View>
{!session?.user && (
<>
<SignIn />
<SignUp />
</>
)}
</View>
</ScrollView>
</Container>
);
}
const styles = StyleSheet.create((theme) => ({
pageContainer: {
paddingHorizontal: 8,
},
headerTitle: {
color: theme?.colors?.typography,
fontSize: 30,
fontWeight: "bold",
marginBottom: 16,
},
sessionInfoCard: {
marginBottom: 24,
padding: 16,
borderRadius: 8,
borderWidth: 1,
borderColor: theme?.colors?.border,
},
sessionUserRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
},
welcomeText: {
color: theme?.colors?.typography,
fontSize: 16,
},
userNameText: {
fontWeight: "500",
color: theme?.colors?.typography,
},
emailText: {
color: theme?.colors?.typography,
fontSize: 14,
marginBottom: 16,
},
signOutButton: {
backgroundColor: theme?.colors?.destructive,
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 6,
alignSelf: "flex-start",
},
signOutButtonText: {
fontWeight: "500",
},
apiStatusCard: {
marginBottom: 24,
borderRadius: 8,
borderWidth: 1,
borderColor: theme?.colors?.border,
padding: 16,
},
cardTitle: {
marginBottom: 12,
fontWeight: "500",
color: theme?.colors?.typography,
},
apiStatusRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
statusIndicatorDot: {
height: 12,
width: 12,
borderRadius: 9999,
},
statusIndicatorGreen: {
backgroundColor: theme.colors.success,
},
statusIndicatorRed: {
backgroundColor: theme.colors.destructive,
},
mutedText: {
color: theme?.colors?.typography,
},
privateDataCard: {
marginBottom: 24,
borderRadius: 8,
borderWidth: 1,
borderColor: theme?.colors?.border,
padding: 16,
},
}));

View File

@@ -0,0 +1,134 @@
import { authClient } from "@/lib/auth-client";
{{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { queryClient } from "@/utils/orpc";
{{/if}}
import { useState } from "react";
import {
ActivityIndicator,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
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 style={styles.container}>
<Text style={styles.title}>Sign In</Text>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
onPress={handleLogin}
disabled={isLoading}
style={styles.button}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginTop: 24,
padding: 16,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.colors.border,
},
title: {
fontSize: 18,
fontWeight: "600",
color: theme.colors.typography,
marginBottom: 16,
},
errorContainer: {
marginBottom: 16,
padding: 12,
borderRadius: 6,
},
errorText: {
color: theme.colors.destructive,
fontSize: 14,
},
input: {
marginBottom: 12,
padding: 16,
borderRadius: 6,
color: theme.colors.typography,
borderWidth: 1,
borderColor: theme.colors.border,
},
button: {
backgroundColor: theme.colors.primary,
padding: 16,
borderRadius: 6,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
buttonText: {
fontWeight: "500",
},
}));

View File

@@ -0,0 +1,152 @@
import { authClient } from "@/lib/auth-client";
{{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { queryClient } from "@/utils/orpc";
{{/if}}
import { useState } from "react";
import {
ActivityIndicator,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
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 style={styles.container}>
<Text style={styles.title}>Create Account</Text>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<TextInput
style={styles.input}
placeholder="Name"
value={name}
onChangeText={setName}
/>
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.inputLast}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
onPress={handleSignUp}
disabled={isLoading}
style={styles.button}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Sign Up</Text>
)}
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginTop: 24,
padding: 16,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.colors.border,
},
title: {
fontSize: 18,
fontWeight: "600",
color: theme.colors.typography,
marginBottom: 16,
},
errorContainer: {
marginBottom: 16,
padding: 12,
borderRadius: 6,
},
errorText: {
color: theme.colors.destructive,
fontSize: 14,
},
input: {
marginBottom: 12,
padding: 16,
borderRadius: 6,
color: theme.colors.typography,
borderWidth: 1,
borderColor: theme.colors.border,
},
inputLast: {
marginBottom: 16,
padding: 16,
borderRadius: 6,
color: theme.colors.typography,
borderWidth: 1,
borderColor: theme.colors.border,
},
button: {
backgroundColor: theme.colors.primary,
padding: 16,
borderRadius: 6,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
buttonText: {
fontWeight: "500",
},
}));

View File

@@ -0,0 +1,174 @@
{{#if (eq orm "prisma")}}
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
import { expo } from "@better-auth/expo";
{{/if}}
import prisma from "../../prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
{{#if (eq database "postgres")}}provider: "postgresql"{{/if}}
{{#if (eq database "sqlite")}}provider: "sqlite"{{/if}}
{{#if (eq database "mysql")}}provider: "mysql"{{/if}}
{{#if (eq database "mongodb")}}provider: "mongodb"{{/if}}
}),
trustedOrigins: [
process.env.CORS_ORIGIN || "",
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
"my-better-t-app://",
{{/if}}
],
emailAndPassword: {
enabled: true,
},
advanced: {
defaultCookieAttributes: {
sameSite: "none",
secure: true,
httpOnly: true,
},
}
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
, plugins: [expo()]
{{/if}}
});
{{/if}}
{{#if (eq orm "drizzle")}}
{{#if (or (eq runtime "bun") (eq runtime "node"))}}
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
import { expo } from "@better-auth/expo";
{{/if}}
import { db } from "../db";
import * as schema from "../db/schema/auth";
export const auth = betterAuth({
database: drizzleAdapter(db, {
{{#if (eq database "postgres")}}provider: "pg",{{/if}}
{{#if (eq database "sqlite")}}provider: "sqlite",{{/if}}
{{#if (eq database "mysql")}}provider: "mysql",{{/if}}
schema: schema,
}),
trustedOrigins: [
process.env.CORS_ORIGIN || "",
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
"my-better-t-app://",
{{/if}}
],
emailAndPassword: {
enabled: true,
},
advanced: {
defaultCookieAttributes: {
sameSite: "none",
secure: true,
httpOnly: true,
},
},
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
plugins: [expo()],
{{/if}}
});
{{/if}}
{{#if (eq runtime "workers")}}
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
import { expo } from "@better-auth/expo";
{{/if}}
import { db } from "../db";
import * as schema from "../db/schema/auth";
import { env } from "cloudflare:workers";
export const auth = betterAuth({
database: drizzleAdapter(db, {
{{#if (eq database "postgres")}}provider: "pg",{{/if}}
{{#if (eq database "sqlite")}}provider: "sqlite",{{/if}}
{{#if (eq database "mysql")}}provider: "mysql",{{/if}}
schema: schema,
}),
trustedOrigins: [env.CORS_ORIGIN],
emailAndPassword: {
enabled: true,
},
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
advanced: {
defaultCookieAttributes: {
sameSite: "none",
secure: true,
httpOnly: true,
},
},
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
plugins: [expo()],
{{/if}}
});
{{/if}}
{{/if}}
{{#if (eq orm "mongoose")}}
import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
import { expo } from "@better-auth/expo";
{{/if}}
import { client } from "../db";
export const auth = betterAuth({
database: mongodbAdapter(client),
trustedOrigins: [
process.env.CORS_ORIGIN || "",
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
"my-better-t-app://",
{{/if}}
],
emailAndPassword: {
enabled: true,
},
advanced: {
defaultCookieAttributes: {
sameSite: "none",
secure: true,
httpOnly: true,
},
}
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
, plugins: [expo()]
{{/if}}
});
{{/if}}
{{#if (eq orm "none")}}
import { betterAuth } from "better-auth";
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
import { expo } from "@better-auth/expo";
{{/if}}
export const auth = betterAuth({
database: "", // Invalid configuration
trustedOrigins: [
process.env.CORS_ORIGIN || "",
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
"my-better-t-app://",
{{/if}}
],
emailAndPassword: {
enabled: true,
},
advanced: {
defaultCookieAttributes: {
sameSite: "none",
secure: true,
httpOnly: true,
},
}
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
, plugins: [expo()]
{{/if}}
});
{{/if}}

View File

@@ -0,0 +1,58 @@
import {
mysqlTable,
varchar,
text,
timestamp,
boolean,
} from "drizzle-orm/mysql-core";
export const user = mysqlTable("user", {
id: varchar("id", { length: 36 }).primaryKey(),
name: text("name").notNull(),
email: varchar("email", { length: 255 }).notNull().unique(),
emailVerified: boolean("email_verified").notNull(),
image: text("image"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
});
export const session = mysqlTable("session", {
id: varchar("id", { length: 36 }).primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: varchar("token", { length: 255 }).notNull().unique(),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: varchar("user_id", { length: 36 })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const account = mysqlTable("account", {
id: varchar("id", { length: 36 }).primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: varchar("user_id", { length: 36 })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
});
export const verification = mysqlTable("verification", {
id: varchar("id", { length: 36 }).primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at"),
updatedAt: timestamp("updated_at"),
});

View File

@@ -0,0 +1,47 @@
import { pgTable, text, timestamp, boolean, serial } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').notNull(),
image: text('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull()
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' })
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull()
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at'),
updatedAt: timestamp('updated_at')
});

View File

@@ -0,0 +1,55 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" }).notNull(),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
token: text("token").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id),
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at", {
mode: "timestamp",
}),
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
mode: "timestamp",
}),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const verification = sqliteTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" }),
updatedAt: integer("updated_at", { mode: "timestamp" }),
});

View File

@@ -0,0 +1,68 @@
import mongoose from 'mongoose';
const { Schema, model } = mongoose;
const userSchema = new Schema(
{
_id: { type: String },
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
emailVerified: { type: Boolean, required: true },
image: { type: String },
createdAt: { type: Date, required: true },
updatedAt: { type: Date, required: true },
},
{ collection: 'user' }
);
const sessionSchema = new Schema(
{
_id: { type: String },
expiresAt: { type: Date, required: true },
token: { type: String, required: true, unique: true },
createdAt: { type: Date, required: true },
updatedAt: { type: Date, required: true },
ipAddress: { type: String },
userAgent: { type: String },
userId: { type: String, ref: 'User', required: true },
},
{ collection: 'session' }
);
const accountSchema = new Schema(
{
_id: { type: String },
accountId: { type: String, required: true },
providerId: { type: String, required: true },
userId: { type: String, ref: 'User', required: true },
accessToken: { type: String },
refreshToken: { type: String },
idToken: { type: String },
accessTokenExpiresAt: { type: Date },
refreshTokenExpiresAt: { type: Date },
scope: { type: String },
password: { type: String },
createdAt: { type: Date, required: true },
updatedAt: { type: Date, required: true },
},
{ collection: 'account' }
);
const verificationSchema = new Schema(
{
_id: { type: String },
identifier: { type: String, required: true },
value: { type: String, required: true },
expiresAt: { type: Date, required: true },
createdAt: { type: Date },
updatedAt: { type: Date },
},
{ collection: 'verification' }
);
const User = model('User', userSchema);
const Session = model('Session', sessionSchema);
const Account = model('Account', accountSchema);
const Verification = model('Verification', verificationSchema);
export { User, Session, Account, Verification };

View File

@@ -0,0 +1,59 @@
model User {
id String @id @map("_id")
name String
email String
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
sessions Session[]
accounts Account[]
@@unique([email])
@@map("user")
}
model Session {
id String @id @map("_id")
expiresAt DateTime
token String
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@map("session")
}
model Account {
id String @id @map("_id")
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
@@map("account")
}
model Verification {
id String @id @map("_id")
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}

View File

@@ -0,0 +1,59 @@
model User {
id String @id
name String @db.Text
email String
emailVerified Boolean
image String? @db.Text
createdAt DateTime
updatedAt DateTime
sessions Session[]
accounts Account[]
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime
updatedAt DateTime
ipAddress String? @db.Text
userAgent String? @db.Text
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@map("session")
}
model Account {
id String @id
accountId String @db.Text
providerId String @db.Text
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String? @db.Text
refreshToken String? @db.Text
idToken String? @db.Text
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String? @db.Text
password String? @db.Text
createdAt DateTime
updatedAt DateTime
@@map("account")
}
model Verification {
id String @id
identifier String @db.Text
value String @db.Text
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}

View File

@@ -0,0 +1,59 @@
model User {
id String @id @map("_id")
name String
email String
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
sessions Session[]
accounts Account[]
@@unique([email])
@@map("user")
}
model Session {
id String @id @map("_id")
expiresAt DateTime
token String
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@map("session")
}
model Account {
id String @id @map("_id")
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
@@map("account")
}
model Verification {
id String @id @map("_id")
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}

View File

@@ -0,0 +1,59 @@
model User {
id String @id @map("_id")
name String
email String
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
sessions Session[]
accounts Account[]
@@unique([email])
@@map("user")
}
model Session {
id String @id @map("_id")
expiresAt DateTime
token String
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@map("session")
}
model Account {
id String @id @map("_id")
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
@@map("account")
}
model Verification {
id String @id @map("_id")
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@map("verification")
}

View File

@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth.handler);

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import z from 'zod'
const {$authClient} = useNuxtApp()
import type { FormSubmitEvent } from '#ui/types'
const emit = defineEmits(['switchToSignUp'])
const toast = useToast()
const loading = ref(false)
const schema = z.object({
email: z.email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
type Schema = z.output<typeof schema>
const state = reactive({
email: '',
password: '',
})
async function onSubmit (event: FormSubmitEvent<Schema>) {
loading.value = true
try {
await $authClient.signIn.email(
{
email: event.data.email,
password: event.data.password,
},
{
onSuccess: () => {
toast.add({ title: 'Sign in successful' })
navigateTo('/dashboard', { replace: true })
},
onError: (error) => {
toast.add({ title: 'Sign in failed', description: error.error.message })
},
},
)
} catch (error: any) {
toast.add({ title: 'An unexpected error occurred', description: error.message || 'Please try again.' })
} finally {
loading.value = false
}
}
</script>
<template>
<div class="mx-auto w-full mt-10 max-w-md p-6">
<h1 class="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<UFormField label="Email" name="email">
<UInput v-model="state.email" type="email" class="w-full" />
</UFormField>
<UFormField label="Password" name="password">
<UInput v-model="state.password" type="password" class="w-full" />
</UFormField>
<UButton type="submit" block :loading="loading">
Sign In
</UButton>
</UForm>
<div class="mt-4 text-center">
<UButton
variant="link"
@click="$emit('switchToSignUp')"
class="text-primary hover:text-primary-dark"
>
Need an account? Sign Up
</UButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import z from 'zod'
import type { FormSubmitEvent } from '#ui/types'
const {$authClient} = useNuxtApp()
const emit = defineEmits(['switchToSignIn'])
const toast = useToast()
const loading = ref(false)
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
type Schema = z.output<typeof schema>
const state = reactive({
name: '',
email: '',
password: '',
})
async function onSubmit (event: FormSubmitEvent<Schema>) {
loading.value = true
try {
await $authClient.signUp.email(
{
name: event.data.name,
email: event.data.email,
password: event.data.password,
},
{
onSuccess: () => {
toast.add({ title: 'Sign up successful' })
navigateTo('/dashboard', { replace: true })
},
onError: (error) => {
toast.add({ title: 'Sign up failed', description: error.error.message })
},
},
)
} catch (error: any) {
toast.add({ title: 'An unexpected error occurred', description: error.message || 'Please try again.' })
} finally {
loading.value = false
}
}
</script>
<template>
<div class="mx-auto w-full mt-10 max-w-md p-6">
<h1 class="mb-6 text-center text-3xl font-bold">Create Account</h1>
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<UFormField label="Name" name="name">
<UInput v-model="state.name" class="w-full" />
</UFormField>
<UFormField label="Email" name="email">
<UInput v-model="state.email" type="email" class="w-full" />
</UFormField>
<UFormField label="Password" name="password">
<UInput v-model="state.password" type="password" class="w-full" />
</UFormField>
<UButton type="submit" block :loading="loading">
Sign Up
</UButton>
</UForm>
<div class="mt-4 text-center">
<UButton
variant="link"
@click="$emit('switchToSignIn')"
class="text-primary hover:text-primary-dark"
>
Already have an account? Sign In
</UButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
const {$authClient} = useNuxtApp()
const session = $authClient.useSession()
const toast = useToast()
const handleSignOut = async () => {
try {
await $authClient.signOut({
fetchOptions: {
onSuccess: async () => {
toast.add({ title: 'Signed out successfully' })
await navigateTo('/', { replace: true, external: true })
},
onError: (error) => {
toast.add({ title: 'Sign out failed', description: error?.error?.message || 'Unknown error'})
}
},
})
} catch (error: any) {
toast.add({ title: 'An unexpected error occurred during sign out', description: error.message || 'Please try again.'})
}
}
</script>
<template>
<div>
<USkeleton v-if="session.isPending" class="h-9 w-24" />
<UButton v-else-if="!session.data" variant="outline" to="/login">
Sign In
</UButton>
<UButton
v-else
variant="solid"
icon="i-lucide-log-out"
label="Sign out"
@click="handleSignOut()"
/>
</div>
</template>

View File

@@ -0,0 +1,12 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
if (import.meta.server) return
const { $authClient } = useNuxtApp()
const session = $authClient.useSession()
if (session.value.isPending || !session.value) {
if (to.path === "/dashboard") {
return navigateTo("/login");
}
}
});

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
{{#if (eq api "orpc")}}
import { useQuery } from '@tanstack/vue-query'
{{/if}}
const {$authClient} = useNuxtApp()
definePageMeta({
middleware: ['auth']
})
const { $orpc } = useNuxtApp()
const session = $authClient.useSession()
{{#if (eq api "orpc")}}
const privateData = useQuery($orpc.privateData.queryOptions())
{{/if}}
</script>
<template>
<div class="container mx-auto p-4">
<h1 class="text-2xl font-bold mb-4">Dashboard</h1>
<div v-if="session?.data?.user">
<p class="mb-2">Welcome \{{ session.data.user.name }}</p>
</div>
{{#if (eq api "orpc")}}
<div v-if="privateData.status.value === 'pending'">Loading private data...</div>
<div v-else-if="privateData.status.value === 'error'">Error loading private data: \{{ privateData.error.value?.message }}</div>
<p v-else-if="privateData.data.value">Private Data: \{{ privateData.data.value.message }}</p>
{{/if}}
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
const { $authClient } = useNuxtApp();
import SignInForm from "~/components/SignInForm.vue";
import SignUpForm from "~/components/SignUpForm.vue";
const session = $authClient.useSession();
const showSignIn = ref(true);
watchEffect(() => {
if (!session?.value.isPending && session?.value.data) {
navigateTo("/dashboard", { replace: true });
}
});
</script>
<template>
<div>
<Loader v-if="session.isPending" />
<div v-else-if="!session.data">
<SignInForm v-if="showSignIn" @switch-to-sign-up="showSignIn = false" />
<SignUpForm v-else @switch-to-sign-in="showSignIn = true" />
</div>
</div>
</template>

View File

@@ -0,0 +1,16 @@
import { createAuthClient } from "better-auth/vue";
export default defineNuxtPlugin(nuxtApp => {
const config = useRuntimeConfig()
const serverUrl = config.public.serverURL
const authClient = createAuthClient({
baseURL: serverUrl
})
return {
provide: {
authClient: authClient
}
}
})

View File

@@ -0,0 +1,10 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL:
{{#if (includes frontend "next")}}
process.env.NEXT_PUBLIC_SERVER_URL,
{{else}}
import.meta.env.VITE_SERVER_URL,
{{/if}}
});

View File

@@ -0,0 +1,47 @@
"use client"
import { authClient } from "@/lib/auth-client";
{{#if (eq api "orpc")}}
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { useQuery } from "@tanstack/react-query";
import { trpc } from "@/utils/trpc";
{{/if}}
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function Dashboard() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
{{#if (eq api "orpc")}}
const privateData = useQuery(orpc.privateData.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}
const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}}
useEffect(() => {
if (!session && !isPending) {
router.push("/login");
}
}, [session, isPending]);
if (isPending) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome {session?.user.name}</p>
{{#if (eq api "orpc")}}
<p>privateData: {privateData.data?.message}</p>
{{/if}}
{{#if (eq api "trpc")}}
<p>privateData: {privateData.data?.message}</p>
{{/if}}
</div>
);
}

View File

@@ -0,0 +1,16 @@
"use client"
import SignInForm from "@/components/sign-in-form";
import SignUpForm from "@/components/sign-up-form";
import { useState } from "react";
export default function LoginPage() {
const [showSignIn, setShowSignIn] = useState(false);
return showSignIn ? (
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
) : (
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
);
}

View File

@@ -0,0 +1,135 @@
import { authClient } from "@/lib/auth-client";
import { useForm } from "@tanstack/react-form";
import { toast } from "sonner";
import z from "zod";
import Loader from "./loader";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { useRouter } from "next/navigation";
export default function SignInForm({
onSwitchToSignUp,
}: {
onSwitchToSignUp: () => void;
}) {
const router = useRouter()
const { isPending } = authClient.useSession();
const form = useForm({
defaultValues: {
email: "",
password: "",
},
onSubmit: async ({ value }) => {
await authClient.signIn.email(
{
email: value.email,
password: value.password,
},
{
onSuccess: () => {
router.push("/dashboard")
toast.success("Sign in successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
},
);
},
validators: {
onSubmit: z.object({
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
}),
},
});
if (isPending) {
return <Loader />;
}
return (
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<Button
type="submit"
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Submitting..." : "Sign In"}
</Button>
)}
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<Button
variant="link"
onClick={onSwitchToSignUp}
className="text-indigo-600 hover:text-indigo-800"
>
Need an account? Sign Up
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { authClient } from "@/lib/auth-client";
import { useForm } from "@tanstack/react-form";
import { toast } from "sonner";
import z from "zod";
import Loader from "./loader";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { useRouter } from "next/navigation";
export default function SignUpForm({
onSwitchToSignIn,
}: {
onSwitchToSignIn: () => void;
}) {
const router = useRouter();
const { isPending } = authClient.useSession();
const form = useForm({
defaultValues: {
email: "",
password: "",
name: "",
},
onSubmit: async ({ value }) => {
await authClient.signUp.email(
{
email: value.email,
password: value.password,
name: value.name,
},
{
onSuccess: () => {
router.push("/dashboard");
toast.success("Sign up successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
},
);
},
validators: {
onSubmit: z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
}),
},
});
if (isPending) {
return <Loader />;
}
return (
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">Create Account</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<div>
<form.Field name="name">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Name</Label>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<Button
type="submit"
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Submitting..." : "Sign Up"}
</Button>
)}
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<Button
variant="link"
onClick={onSwitchToSignIn}
className="text-indigo-600 hover:text-indigo-800"
>
Already have an account? Sign In
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,60 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { authClient } from "@/lib/auth-client";
import { Button } from "./ui/button";
import { Skeleton } from "./ui/skeleton";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function UserMenu() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
if (isPending) {
return <Skeleton className="h-9 w-24" />;
}
if (!session) {
return (
<Button variant="outline" asChild>
<Link href="/login">Sign In</Link>
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{session.user.name}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>{session.user.email}</DropdownMenuItem>
<DropdownMenuItem asChild>
<Button
variant="destructive"
className="w-full"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/");
},
},
});
}}
>
Sign Out
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,135 @@
import { authClient } from "@/lib/auth-client";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import z from "zod";
import Loader from "./loader";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export default function SignInForm({
onSwitchToSignUp,
}: {
onSwitchToSignUp: () => void;
}) {
const navigate = useNavigate();
const { isPending } = authClient.useSession();
const form = useForm({
defaultValues: {
email: "",
password: "",
},
onSubmit: async ({ value }) => {
await authClient.signIn.email(
{
email: value.email,
password: value.password,
},
{
onSuccess: () => {
navigate("/dashboard");
toast.success("Sign in successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
}
);
},
validators: {
onSubmit: z.object({
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
}),
},
});
if (isPending) {
return <Loader />;
}
return (
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<Button
type="submit"
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Submitting..." : "Sign In"}
</Button>
)}
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<Button
variant="link"
onClick={onSwitchToSignUp}
className="text-indigo-600 hover:text-indigo-800"
>
Need an account? Sign Up
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { authClient } from "@/lib/auth-client";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import z from "zod";
import Loader from "./loader";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export default function SignUpForm({
onSwitchToSignIn,
}: {
onSwitchToSignIn: () => void;
}) {
const navigate = useNavigate();
const { isPending } = authClient.useSession();
const form = useForm({
defaultValues: {
email: "",
password: "",
name: "",
},
onSubmit: async ({ value }) => {
await authClient.signUp.email(
{
email: value.email,
password: value.password,
name: value.name,
},
{
onSuccess: () => {
navigate("/dashboard");
toast.success("Sign up successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
}
);
},
validators: {
onSubmit: z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
}),
},
});
if (isPending) {
return <Loader />;
}
return (
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">Create Account</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<div>
<form.Field name="name">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Name</Label>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<Button
type="submit"
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Submitting..." : "Sign Up"}
</Button>
)}
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<Button
variant="link"
onClick={onSwitchToSignIn}
className="text-indigo-600 hover:text-indigo-800"
>
Already have an account? Sign In
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { authClient } from "@/lib/auth-client";
import { useNavigate } from "react-router";
import { Button } from "./ui/button";
import { Skeleton } from "./ui/skeleton";
import { Link } from "react-router";
export default function UserMenu() {
const navigate = useNavigate();
const { data: session, isPending } = authClient.useSession();
if (isPending) {
return <Skeleton className="h-9 w-24" />;
}
if (!session) {
return (
<Button variant="outline" asChild>
<Link to="/login">Sign In</Link>
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{session.user.name}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>{session.user.email}</DropdownMenuItem>
<DropdownMenuItem asChild>
<Button
variant="destructive"
className="w-full"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate("/");
},
},
});
}}
>
Sign Out
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,44 @@
import { authClient } from "@/lib/auth-client";
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc";
{{/if}}
{{#if ( or (eq api "orpc") (eq api "trpc"))}}
import { useQuery } from "@tanstack/react-query";
{{/if}}
import { useEffect } from "react";
import { useNavigate } from "react-router";
export default function Dashboard() {
const { data: session, isPending } = authClient.useSession();
const navigate = useNavigate();
{{#if (eq api "orpc")}}
const privateData = useQuery(orpc.privateData.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}
const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}}
useEffect(() => {
if (!session && !isPending) {
navigate("/login");
}
}, [session, isPending]);
if (isPending) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome {session?.user.name}</p>
{{#if ( or (eq api "orpc") (eq api "trpc"))}}
<p>privateData: {privateData.data?.message}</p>
{{/if}}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import SignInForm from "@/components/sign-in-form";
import SignUpForm from "@/components/sign-up-form";
import { useState } from "react";
export default function Login() {
const [showSignIn, setShowSignIn] = useState(false);
return showSignIn ? (
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
) : (
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
);
}

View File

@@ -0,0 +1,139 @@
import { authClient } from "@/lib/auth-client";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import Loader from "./loader";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export default function SignInForm({
onSwitchToSignUp,
}: {
onSwitchToSignUp: () => void;
}) {
const navigate = useNavigate({
from: "/",
});
const { isPending } = authClient.useSession();
const form = useForm({
defaultValues: {
email: "",
password: "",
},
onSubmit: async ({ value }) => {
await authClient.signIn.email(
{
email: value.email,
password: value.password,
},
{
onSuccess: () => {
navigate({
to: "/dashboard",
});
toast.success("Sign in successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
},
);
},
validators: {
onSubmit: z.object({
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
}),
},
});
if (isPending) {
return <Loader />;
}
return (
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<Button
type="submit"
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Submitting..." : "Sign In"}
</Button>
)}
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<Button
variant="link"
onClick={onSwitchToSignUp}
className="text-indigo-600 hover:text-indigo-800"
>
Need an account? Sign Up
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { authClient } from "@/lib/auth-client";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import Loader from "./loader";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export default function SignUpForm({
onSwitchToSignIn,
}: {
onSwitchToSignIn: () => void;
}) {
const navigate = useNavigate({
from: "/",
});
const { isPending } = authClient.useSession();
const form = useForm({
defaultValues: {
email: "",
password: "",
name: "",
},
onSubmit: async ({ value }) => {
await authClient.signUp.email(
{
email: value.email,
password: value.password,
name: value.name,
},
{
onSuccess: () => {
navigate({
to: "/dashboard",
});
toast.success("Sign up successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
},
);
},
validators: {
onSubmit: z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
}),
},
});
if (isPending) {
return <Loader />;
}
return (
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">Create Account</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<div>
<form.Field name="name">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Name</Label>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<Button
type="submit"
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Submitting..." : "Sign Up"}
</Button>
)}
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<Button
variant="link"
onClick={onSwitchToSignIn}
className="text-indigo-600 hover:text-indigo-800"
>
Already have an account? Sign In
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { authClient } from "@/lib/auth-client";
import { useNavigate } from "@tanstack/react-router";
import { Button } from "./ui/button";
import { Skeleton } from "./ui/skeleton";
import { Link } from "@tanstack/react-router";
export default function UserMenu() {
const navigate = useNavigate();
const { data: session, isPending } = authClient.useSession();
if (isPending) {
return <Skeleton className="h-9 w-24" />;
}
if (!session) {
return (
<Button variant="outline" asChild>
<Link to="/login">Sign In</Link>
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{session.user.name}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>{session.user.email}</DropdownMenuItem>
<DropdownMenuItem asChild>
<Button
variant="destructive"
className="w-full"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate({
to: "/",
});
},
},
});
}}
>
Sign Out
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,51 @@
import { authClient } from "@/lib/auth-client";
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc";
{{/if}}
{{#if ( or (eq api "orpc") (eq api "trpc"))}}
import { useQuery } from "@tanstack/react-query";
{{/if}}
import { createFileRoute } from "@tanstack/react-router";
import { useEffect } from "react";
export const Route = createFileRoute("/dashboard")({
component: RouteComponent,
});
function RouteComponent() {
const { data: session, isPending } = authClient.useSession();
const navigate = Route.useNavigate();
{{#if (eq api "orpc")}}
const privateData = useQuery(orpc.privateData.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}
const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}}
useEffect(() => {
if (!session && !isPending) {
navigate({
to: "/login",
});
}
}, [session, isPending]);
if (isPending) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome {session?.user.name}</p>
{{#if ( or (eq api "orpc") (eq api "trpc"))}}
<p>privateData: {privateData.data?.message}</p>
{{/if}}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import SignInForm from "@/components/sign-in-form";
import SignUpForm from "@/components/sign-up-form";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createFileRoute("/login")({
component: RouteComponent,
});
function RouteComponent() {
const [showSignIn, setShowSignIn] = useState(false);
return showSignIn ? (
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
) : (
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
);
}

View File

@@ -0,0 +1,139 @@
import { authClient } from "@/lib/auth-client";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import Loader from "./loader";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export default function SignInForm({
onSwitchToSignUp,
}: {
onSwitchToSignUp: () => void;
}) {
const navigate = useNavigate({
from: "/",
});
const { isPending } = authClient.useSession();
const form = useForm({
defaultValues: {
email: "",
password: "",
},
onSubmit: async ({ value }) => {
await authClient.signIn.email(
{
email: value.email,
password: value.password,
},
{
onSuccess: () => {
navigate({
to: "/dashboard",
});
toast.success("Sign in successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
},
);
},
validators: {
onSubmit: z.object({
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
}),
},
});
if (isPending) {
return <Loader />;
}
return (
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<Button
type="submit"
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Submitting..." : "Sign In"}
</Button>
)}
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<Button
variant="link"
onClick={onSwitchToSignUp}
className="text-indigo-600 hover:text-indigo-800"
>
Need an account? Sign Up
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { authClient } from "@/lib/auth-client";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import Loader from "./loader";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export default function SignUpForm({
onSwitchToSignIn,
}: {
onSwitchToSignIn: () => void;
}) {
const navigate = useNavigate({
from: "/",
});
const { isPending } = authClient.useSession();
const form = useForm({
defaultValues: {
email: "",
password: "",
name: "",
},
onSubmit: async ({ value }) => {
await authClient.signUp.email(
{
email: value.email,
password: value.password,
name: value.name,
},
{
onSuccess: () => {
navigate({
to: "/dashboard",
});
toast.success("Sign up successful");
},
onError: (error) => {
toast.error(error.error.message || error.error.statusText);
},
},
);
},
validators: {
onSubmit: z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
}),
},
});
if (isPending) {
return <Loader />;
}
return (
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">Create Account</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<div>
<form.Field name="name">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Name</Label>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Password</Label>
<Input
id={field.name}
name={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors.map((error) => (
<p key={error?.message} className="text-red-500">
{error?.message}
</p>
))}
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<Button
type="submit"
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Submitting..." : "Sign Up"}
</Button>
)}
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<Button
variant="link"
onClick={onSwitchToSignIn}
className="text-indigo-600 hover:text-indigo-800"
>
Already have an account? Sign In
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { authClient } from "@/lib/auth-client";
import { useNavigate } from "@tanstack/react-router";
import { Button } from "./ui/button";
import { Skeleton } from "./ui/skeleton";
import { Link } from "@tanstack/react-router";
export default function UserMenu() {
const navigate = useNavigate();
const { data: session, isPending } = authClient.useSession();
if (isPending) {
return <Skeleton className="h-9 w-24" />;
}
if (!session) {
return (
<Button variant="outline" asChild>
<Link to="/login">Sign In</Link>
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{session.user.name}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>{session.user.email}</DropdownMenuItem>
<DropdownMenuItem asChild>
<Button
variant="destructive"
className="w-full"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate({
to: "/",
});
},
},
});
}}
>
Sign Out
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,54 @@
import { authClient } from "@/lib/auth-client";
{{#if (eq api "trpc")}}
import { useTRPC } from "@/utils/trpc";
import { useQuery } from "@tanstack/react-query";
{{/if}}
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
import { useQuery } from "@tanstack/react-query";
{{/if}}
import { createFileRoute } from "@tanstack/react-router";
import { useEffect } from "react";
export const Route = createFileRoute("/dashboard")({
component: RouteComponent,
});
function RouteComponent() {
const navigate = Route.useNavigate();
{{#if (eq api "trpc")}}
const trpc = useTRPC();
{{/if}}
{{#if (eq api "orpc")}}
{{/if}}
const { data: session, isPending } = authClient.useSession();
{{#if (eq api "trpc")}}
const privateData = useQuery(trpc.privateData.queryOptions());
{{/if}}
{{#if (eq api "orpc")}}
const privateData = useQuery(orpc.privateData.queryOptions());
{{/if}}
useEffect(() => {
if (!session && !isPending) {
navigate({
to: "/login",
});
}
}, [session, isPending]);
if (isPending) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome {session?.user.name}</p>
{{#if ( or (eq api "orpc") (eq api "trpc"))}}
<p>privateData: {privateData.data?.message}</p>
{{/if}}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import SignInForm from "@/components/sign-in-form";
import SignUpForm from "@/components/sign-up-form";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createFileRoute("/login")({
component: RouteComponent,
});
function RouteComponent() {
const [showSignIn, setShowSignIn] = useState(false);
return showSignIn ? (
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
) : (
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
);
}

View File

@@ -0,0 +1,132 @@
import { authClient } from "@/lib/auth-client";
import { createForm } from "@tanstack/solid-form";
import { useNavigate } from "@tanstack/solid-router";
import z from "zod";
import { For } from "solid-js";
export default function SignInForm({
onSwitchToSignUp,
}: {
onSwitchToSignUp: () => void;
}) {
const navigate = useNavigate({
from: "/",
});
const form = createForm(() => ({
defaultValues: {
email: "",
password: "",
},
onSubmit: async ({ value }) => {
await authClient.signIn.email(
{
email: value.email,
password: value.password,
},
{
onSuccess: () => {
navigate({
to: "/dashboard",
});
console.log("Sign in successful");
},
onError: (error) => {
console.error(error.error.message);
},
},
);
},
validators: {
onSubmit: z.object({
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
}),
},
}));
return (
<div class="mx-auto w-full mt-10 max-w-md p-6">
<h1 class="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
class="space-y-4"
>
<div>
<form.Field name="email">
{(field) => (
<div class="space-y-2">
<label for={field().name}>Email</label>
<input
id={field().name}
name={field().name}
type="email"
value={field().state.value}
onBlur={field().handleBlur}
onInput={(e) => field().handleChange(e.currentTarget.value)}
class="w-full rounded border p-2"
/>
<For each={field().state.meta.errors}>
{(error) => (
<p class="text-sm text-red-600">{error?.message}</p>
)}
</For>
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div class="space-y-2">
<label for={field().name}>Password</label>
<input
id={field().name}
name={field().name}
type="password"
value={field().state.value}
onBlur={field().handleBlur}
onInput={(e) => field().handleChange(e.currentTarget.value)}
class="w-full rounded border p-2"
/>
<For each={field().state.meta.errors}>
{(error) => (
<p class="text-sm text-red-600">{error?.message}</p>
)}
</For>
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<button
type="submit"
class="w-full rounded bg-indigo-600 p-2 text-white hover:bg-indigo-700 disabled:opacity-50"
disabled={!state().canSubmit || state().isSubmitting}
>
{state().isSubmitting ? "Submitting..." : "Sign In"}
</button>
)}
</form.Subscribe>
</form>
<div class="mt-4 text-center">
<button
type="button"
onClick={onSwitchToSignUp}
class="text-sm text-indigo-600 hover:text-indigo-800 hover:underline"
>
Need an account? Sign Up
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { authClient } from "@/lib/auth-client";
import { createForm } from "@tanstack/solid-form";
import { useNavigate } from "@tanstack/solid-router";
import z from "zod";
import { For } from "solid-js";
export default function SignUpForm({
onSwitchToSignIn,
}: {
onSwitchToSignIn: () => void;
}) {
const navigate = useNavigate({
from: "/",
});
const form = createForm(() => ({
defaultValues: {
email: "",
password: "",
name: "",
},
onSubmit: async ({ value }) => {
await authClient.signUp.email(
{
email: value.email,
password: value.password,
name: value.name,
},
{
onSuccess: () => {
navigate({
to: "/dashboard",
});
console.log("Sign up successful");
},
onError: (error) => {
console.error(error.error.message);
},
},
);
},
validators: {
onSubmit: z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
}),
},
}));
return (
<div class="mx-auto w-full mt-10 max-w-md p-6">
<h1 class="mb-6 text-center text-3xl font-bold">Create Account</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
class="space-y-4"
>
<div>
<form.Field name="name">
{(field) => (
<div class="space-y-2">
<label for={field().name}>Name</label>
<input
id={field().name}
name={field().name}
value={field().state.value}
onBlur={field().handleBlur}
onInput={(e) => field().handleChange(e.currentTarget.value)}
class="w-full rounded border p-2"
/>
<For each={field().state.meta.errors}>
{(error) => (
<p class="text-sm text-red-600">{error?.message}</p>
)}
</For>
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="email">
{(field) => (
<div class="space-y-2">
<label for={field().name}>Email</label>
<input
id={field().name}
name={field().name}
type="email"
value={field().state.value}
onBlur={field().handleBlur}
onInput={(e) => field().handleChange(e.currentTarget.value)}
class="w-full rounded border p-2"
/>
<For each={field().state.meta.errors}>
{(error) => (
<p class="text-sm text-red-600">{error?.message}</p>
)}
</For>
</div>
)}
</form.Field>
</div>
<div>
<form.Field name="password">
{(field) => (
<div class="space-y-2">
<label for={field().name}>Password</label>
<input
id={field().name}
name={field().name}
type="password"
value={field().state.value}
onBlur={field().handleBlur}
onInput={(e) => field().handleChange(e.currentTarget.value)}
class="w-full rounded border p-2"
/>
<For each={field().state.meta.errors}>
{(error) => (
<p class="text-sm text-red-600">{error?.message}</p>
)}
</For>
</div>
)}
</form.Field>
</div>
<form.Subscribe>
{(state) => (
<button
type="submit"
class="w-full rounded bg-indigo-600 p-2 text-white hover:bg-indigo-700 disabled:opacity-50"
disabled={!state().canSubmit || state().isSubmitting}
>
{state().isSubmitting ? "Submitting..." : "Sign Up"}
</button>
)}
</form.Subscribe>
</form>
<div class="mt-4 text-center">
<button
type="button"
onClick={onSwitchToSignIn}
class="text-sm text-indigo-600 hover:text-indigo-800 hover:underline"
>
Already have an account? Sign In
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { authClient } from "@/lib/auth-client";
import { useNavigate, Link } from "@tanstack/solid-router";
import { createSignal, Show } from "solid-js";
export default function UserMenu() {
const navigate = useNavigate();
const session = authClient.useSession();
const [isMenuOpen, setIsMenuOpen] = createSignal(false);
return (
<div class="relative inline-block text-left">
<Show when={session().isPending}>
<div class="h-9 w-24 animate-pulse rounded" />
</Show>
<Show when={!session().isPending && !session().data}>
<Link to="/login" class="inline-block border rounded px-4 text-sm">
Sign In
</Link>
</Show>
<Show when={!session().isPending && session().data}>
<button
type="button"
class="inline-block border rounded px-4 text-sm"
onClick={() => setIsMenuOpen(!isMenuOpen())}
>
{session().data?.user.name}
</button>
<Show when={isMenuOpen()}>
<div class="absolute right-0 mt-2 w-56 rounded p-1 shadow-sm">
<div class="px-4 text-sm">{session().data?.user.email}</div>
<button
type="button"
class="mt-1 w-full border rounded px-4 text-center text-sm"
onClick={() => {
setIsMenuOpen(false);
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate({ to: "/" });
},
},
});
}}
>
Sign Out
</button>
</div>
</Show>
</Show>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { createAuthClient } from "better-auth/solid";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_SERVER_URL,
});

View File

@@ -0,0 +1,44 @@
import { authClient } from "@/lib/auth-client";
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
import { useQuery } from "@tanstack/solid-query";
{{/if}}
import { createFileRoute } from "@tanstack/solid-router";
import { createEffect, Show } from "solid-js";
export const Route = createFileRoute("/dashboard")({
component: RouteComponent,
});
function RouteComponent() {
const session = authClient.useSession();
const navigate = Route.useNavigate();
{{#if (eq api "orpc")}}
const privateData = useQuery(() => orpc.privateData.queryOptions());
{{/if}}
createEffect(() => {
if (!session().data && !session().isPending) {
navigate({
to: "/login",
});
}
});
return (
<div>
<Show when={session().isPending}>
<div>Loading...</div>
</Show>
<Show when={!session().isPending && session().data}>
<h1>Dashboard</h1>
<p>Welcome {session().data?.user.name}</p>
{{#if (eq api "orpc")}}
<p>privateData: {privateData.data?.message}</p>
{{/if}}
</Show>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import SignInForm from "@/components/sign-in-form";
import SignUpForm from "@/components/sign-up-form";
import { createFileRoute } from "@tanstack/solid-router";
import { createSignal, Match, Switch } from "solid-js";
export const Route = createFileRoute("/login")({
component: RouteComponent,
});
function RouteComponent() {
const [showSignIn, setShowSignIn] = createSignal(false);
return (
<Switch>
<Match when={showSignIn()}>
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
</Match>
<Match when={!showSignIn()}>
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
</Match>
</Switch>
);
}

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { createForm } from '@tanstack/svelte-form';
import z from 'zod';
import { authClient } from '$lib/auth-client';
import { goto } from '$app/navigation';
let { switchToSignUp } = $props<{ switchToSignUp: () => void }>();
const validationSchema = z.object({
email: z.email('Invalid email address'),
password: z.string().min(1, 'Password is required'),
});
const form = createForm(() => ({
defaultValues: { email: '', password: '' },
onSubmit: async ({ value }) => {
await authClient.signIn.email(
{ email: value.email, password: value.password },
{
onSuccess: () => goto('/dashboard'),
onError: (error) => {
console.log(error.error.message || 'Sign in failed. Please try again.');
},
}
);
},
validators: {
onSubmit: validationSchema,
},
}));
</script>
<div class="mx-auto mt-10 w-full max-w-md p-6">
<h1 class="mb-6 text-center font-bold text-3xl">Welcome Back</h1>
<form
class="space-y-4"
onsubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field name="email">
{#snippet children(field)}
<div class="space-y-1">
<label for={field.name}>Email</label>
<input
id={field.name}
name={field.name}
type="email"
class="w-full border"
onblur={field.handleBlur}
value={field.state.value}
oninput={(e: Event) => {
const target = e.target as HTMLInputElement
field.handleChange(target.value)
}} />
{#if field.state.meta.isTouched}
{#each field.state.meta.errors as error}
<p class="text-sm text-red-500" role="alert">{error}</p>
{/each}
{/if}
</div>
{/snippet}
</form.Field>
<form.Field name="password">
{#snippet children(field)}
<div class="space-y-1">
<label for={field.name}>Password</label>
<input
id={field.name}
name={field.name}
type="password"
class="w-full border"
onblur={field.handleBlur}
value={field.state.value}
oninput={(e: Event) => {
const target = e.target as HTMLInputElement
field.handleChange(target.value)
}}
/>
{#if field.state.meta.isTouched}
{#each field.state.meta.errors as error}
<p class="text-sm text-red-500" role="alert">{error}</p>
{/each}
{/if}
</div>
{/snippet}
</form.Field>
<form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}>
{#snippet children(state)}
<button type="submit" class="w-full" disabled={!state.canSubmit || state.isSubmitting}>
{state.isSubmitting ? 'Submitting...' : 'Sign In'}
</button>
{/snippet}
</form.Subscribe>
</form>
<div class="mt-4 text-center">
<button type="button" class="text-indigo-600 hover:text-indigo-800" onclick={switchToSignUp}>
Need an account? Sign Up
</button>
</div>
</div>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import { createForm } from '@tanstack/svelte-form';
import z from 'zod';
import { authClient } from '$lib/auth-client';
import { goto } from '$app/navigation';
let { switchToSignIn } = $props<{ switchToSignIn: () => void }>();
const validationSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
const form = createForm(() => ({
defaultValues: { name: '', email: '', password: '' },
onSubmit: async ({ value }) => {
await authClient.signUp.email(
{
email: value.email,
password: value.password,
name: value.name,
},
{
onSuccess: () => {
goto('/dashboard');
},
onError: (error) => {
console.log(error.error.message || 'Sign up failed. Please try again.');
},
}
);
},
validators: {
onSubmit: validationSchema,
},
}));
</script>
<div class="mx-auto mt-10 w-full max-w-md p-6">
<h1 class="mb-6 text-center font-bold text-3xl">Create Account</h1>
<form
id="form"
class="space-y-4"
onsubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field name="name">
{#snippet children(field)}
<div class="space-y-1">
<label for={field.name}>Name</label>
<input
id={field.name}
name={field.name}
class="w-full border"
onblur={field.handleBlur}
value={field.state.value}
oninput={(e: Event) => {
const target = e.target as HTMLInputElement
field.handleChange(target.value)
}}
/>
{#if field.state.meta.isTouched}
{#each field.state.meta.errors as error}
<p class="text-sm text-red-500" role="alert">{error}</p>
{/each}
{/if}
</div>
{/snippet}
</form.Field>
<form.Field name="email">
{#snippet children(field)}
<div class="space-y-1">
<label for={field.name}>Email</label>
<input
id={field.name}
name={field.name}
type="email"
class="w-full border"
onblur={field.handleBlur}
value={field.state.value}
oninput={(e: Event) => {
const target = e.target as HTMLInputElement
field.handleChange(target.value)
}}
/>
{#if field.state.meta.isTouched}
{#each field.state.meta.errors as error}
<p class="text-sm text-red-500" role="alert">{error}</p>
{/each}
{/if}
</div>
{/snippet}
</form.Field>
<form.Field name="password">
{#snippet children(field)}
<div class="space-y-1">
<label for={field.name}>Password</label>
<input
id={field.name}
name={field.name}
type="password"
class="w-full border"
onblur={field.handleBlur}
value={field.state.value}
oninput={(e: Event) => {
const target = e.target as HTMLInputElement
field.handleChange(target.value)
}}
/>
{#if field.state.meta.errors}
{#each field.state.meta.errors as error}
<p class="text-sm text-red-500" role="alert">{error}</p>
{/each}
{/if}
</div>
{/snippet}
</form.Field>
<form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}>
{#snippet children(state)}
<button type="submit" class="w-full" disabled={!state.canSubmit || state.isSubmitting}>
{state.isSubmitting ? 'Submitting...' : 'Sign Up'}
</button>
{/snippet}
</form.Subscribe>
</form>
<div class="mt-4 text-center">
<button type="button" class="text-indigo-600 hover:text-indigo-800" onclick={switchToSignIn}>
Already have an account? Sign In
</button>
</div>
</div>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { authClient } from '$lib/auth-client';
import { goto } from '$app/navigation';
const sessionQuery = authClient.useSession();
async function handleSignOut() {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
goto('/');
},
onError: (error) => {
console.error('Sign out failed:', error);
}
}
});
}
function goToLogin() {
goto('/login');
}
</script>
<div class="relative">
{#if $sessionQuery.isPending}
<div class="h-8 w-24 animate-pulse rounded bg-neutral-700"></div>
{:else if $sessionQuery.data?.user}
{@const user = $sessionQuery.data.user}
<div class="flex items-center gap-3">
<span class="text-sm text-neutral-300 hidden sm:inline" title={user.email}>
{user.name || user.email?.split('@')[0] || 'User'}
</span>
<button
onclick={handleSignOut}
class="rounded px-3 py-1 text-sm bg-red-600 hover:bg-red-700 text-white transition-colors"
>
Sign Out
</button>
</div>
{:else}
<div class="flex items-center gap-2">
<button
onclick={goToLogin}
class="rounded px-3 py-1 text-sm bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
>
Sign In
</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,6 @@
import { PUBLIC_SERVER_URL } from "$env/static/public";
import { createAuthClient } from "better-auth/svelte";
export const authClient = createAuthClient({
baseURL: PUBLIC_SERVER_URL,
});

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authClient } from '$lib/auth-client';
{{#if (eq api "orpc")}}
import { orpc } from '$lib/orpc';
import { createQuery } from '@tanstack/svelte-query';
{{/if}}
import { get } from 'svelte/store';
const sessionQuery = authClient.useSession();
{{#if (eq api "orpc")}}
const privateDataQuery = createQuery(orpc.privateData.queryOptions());
{{/if}}
onMount(() => {
const { data: session, isPending } = get(sessionQuery);
if (!session && !isPending) {
goto('/login');
}
});
</script>
{#if $sessionQuery.isPending}
<div>Loading...</div>
{:else if !$sessionQuery.data}
{:else}
<div>
<h1>Dashboard</h1>
<p>Welcome {$sessionQuery.data.user.name}</p>
{{#if (eq api "orpc")}}
<p>privateData: {$privateDataQuery.data?.message}</p>
{{/if}}
</div>
{/if}

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import SignInForm from '../../components/SignInForm.svelte';
import SignUpForm from '../../components/SignUpForm.svelte';
let showSignIn = $state(true);
</script>
{#if showSignIn}
<SignInForm switchToSignUp={() => showSignIn = false} />
{:else}
<SignUpForm switchToSignIn={() => showSignIn = true} />
{/if}