diff --git a/apps/native/app/(auth)/_layout.tsx b/apps/native/app/(auth)/_layout.tsx new file mode 100644 index 0000000..5b9eb40 --- /dev/null +++ b/apps/native/app/(auth)/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from "expo-router"; + +export default function AuthLayout() { + return ; +} diff --git a/apps/native/app/(auth)/login.tsx b/apps/native/app/(auth)/login.tsx new file mode 100644 index 0000000..917cacb --- /dev/null +++ b/apps/native/app/(auth)/login.tsx @@ -0,0 +1,24 @@ +import { NativeLoginForm } from "@code/auth/native"; +import { router } from "expo-router"; +import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; + +export default function LoginScreen() { + return ( + + + + router.push("/(auth)/signup")} + onSuccess={() => router.replace("/")} + /> + + + + ); +} diff --git a/apps/native/app/(auth)/signup.tsx b/apps/native/app/(auth)/signup.tsx new file mode 100644 index 0000000..5122015 --- /dev/null +++ b/apps/native/app/(auth)/signup.tsx @@ -0,0 +1,24 @@ +import { NativeSignupForm } from "@code/auth/native"; +import { router } from "expo-router"; +import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native"; + +export default function SignupScreen() { + return ( + + + + router.push("/(auth)/login")} + onSuccess={() => router.replace("/")} + /> + + + + ); +} diff --git a/apps/native/app/(drawer)/index.tsx b/apps/native/app/(drawer)/index.tsx index 7752be5..3ff886d 100644 --- a/apps/native/app/(drawer)/index.tsx +++ b/apps/native/app/(drawer)/index.tsx @@ -1,72 +1,53 @@ +import { authClient } from "@code/auth/native"; import { api } from "@code/backend/convex/_generated/api"; -import { useConvexAuth, useQuery } from "convex/react"; -import { Button, Chip, Separator, Spinner, Surface, useThemeColor } from "heroui-native"; +import { useQuery } from "convex/react"; +import { Button, Spinner, Surface } from "heroui-native"; import { Text, View } from "react-native"; import { Container } from "@/components/container"; -import { SignIn } from "@/components/sign-in"; -import { SignUp } from "@/components/sign-up"; -import { authClient } from "@/lib/auth-client"; export default function Home() { const healthCheck = useQuery(api.healthCheck.get); - const { isAuthenticated } = useConvexAuth(); - const user = useQuery(api.auth.getCurrentUser, isAuthenticated ? {} : "skip"); - const successColor = useThemeColor("success"); - const dangerColor = useThemeColor("danger"); - - const isConnected = healthCheck === "OK"; - const isLoading = healthCheck === undefined; + const user = useQuery(api.auth.getCurrentUser); return ( - - - Better T Stack - - Full-stack TypeScript starter + + Zopu + Your authenticated workspace - {user ? ( - - + + {user === undefined ? ( + + ) : ( + - {user.name} - {user.email} + {user?.name ?? "Signed in"} + {user?.email} - { - authClient.signOut(); - }} - > - Sign Out + void authClient.signOut()} size="sm" variant="danger"> + Sign out - - ) : null} - - API Status + )} + + + + API status - + {healthCheck === undefined - ? "Checking..." + ? "Checking…" : healthCheck === "OK" - ? "Connected to API" - : "API Disconnected"} + ? "Connected to Convex" + : "Convex unavailable"} - {!user && ( - - - - - )} ); } diff --git a/apps/native/app/_layout.tsx b/apps/native/app/_layout.tsx index 28f2abd..d383d08 100644 --- a/apps/native/app/_layout.tsx +++ b/apps/native/app/_layout.tsx @@ -1,44 +1,53 @@ import "@/global.css"; -import { env } from "@code/env/native"; -import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react"; -import { ConvexReactClient } from "convex/react"; +import { NativeAuthProvider } from "@code/auth/native"; +import { useConvexAuth } from "convex/react"; import { Stack } from "expo-router"; -import { HeroUINativeProvider } from "heroui-native"; +import { HeroUINativeProvider, Spinner } from "heroui-native"; +import { View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { AppThemeProvider } from "@/contexts/app-theme-context"; -import { authClient } from "@/lib/auth-client"; -export const unstable_settings = { - initialRouteName: "(drawer)", -}; +function RootNavigator() { + const { isAuthenticated, isLoading } = useConvexAuth(); -const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, { - unsavedChangesWarning: false, -}); + if (isLoading) { + return ( + + + + ); + } -function StackLayout() { return ( - - - + + + + + + + + ); } export default function Layout() { return ( - + - + - + ); } diff --git a/apps/native/components/sign-in.tsx b/apps/native/components/sign-in.tsx deleted file mode 100644 index 513ac9f..0000000 --- a/apps/native/components/sign-in.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useForm } from "@tanstack/react-form"; -import { - Button, - FieldError, - Input, - Label, - Spinner, - Surface, - TextField, - useToast, -} from "heroui-native"; -import { useRef } from "react"; -import { Text, TextInput, View } from "react-native"; -import z from "zod"; - -import { authClient } from "@/lib/auth-client"; - -const signInSchema = z.object({ - email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"), - password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"), -}); - -function getErrorMessage(error: unknown): string | null { - if (!error) return null; - - if (typeof error === "string") { - return error; - } - - if (Array.isArray(error)) { - for (const issue of error) { - const message = getErrorMessage(issue); - if (message) { - return message; - } - } - return null; - } - - if (typeof error === "object" && error !== null) { - const maybeError = error as { message?: unknown }; - if (typeof maybeError.message === "string") { - return maybeError.message; - } - } - - return null; -} - -export function SignIn() { - const passwordInputRef = useRef(null); - const { toast } = useToast(); - - const form = useForm({ - defaultValues: { - email: "", - password: "", - }, - validators: { - onSubmit: signInSchema, - }, - onSubmit: async ({ value, formApi }) => { - await authClient.signIn.email( - { - email: value.email.trim(), - password: value.password, - }, - { - onError(error) { - toast.show({ - variant: "danger", - label: error.error?.message || "Failed to sign in", - }); - }, - onSuccess() { - formApi.reset(); - toast.show({ - variant: "success", - label: "Signed in successfully", - }); - }, - }, - ); - }, - }); - - return ( - - Sign In - - ({ - isSubmitting: state.isSubmitting, - validationError: getErrorMessage(state.errorMap.onSubmit), - })} - > - {({ isSubmitting, validationError }) => { - const formError = validationError; - - return ( - <> - - {formError} - - - - - {(field) => ( - - Email - { - passwordInputRef.current?.focus(); - }} - /> - - )} - - - - {(field) => ( - - Password - - - )} - - - - {isSubmitting ? ( - - ) : ( - Sign In - )} - - - > - ); - }} - - - ); -} diff --git a/apps/native/components/sign-up.tsx b/apps/native/components/sign-up.tsx deleted file mode 100644 index 22ed862..0000000 --- a/apps/native/components/sign-up.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { useForm } from "@tanstack/react-form"; -import { - Button, - FieldError, - Input, - Label, - Spinner, - Surface, - TextField, - useToast, -} from "heroui-native"; -import { useRef } from "react"; -import { Text, TextInput, View } from "react-native"; -import z from "zod"; - -import { authClient } from "@/lib/auth-client"; - -const signUpSchema = z.object({ - name: z.string().trim().min(1, "Name is required").min(2, "Name must be at least 2 characters"), - email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"), - password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"), -}); - -function getErrorMessage(error: unknown): string | null { - if (!error) return null; - - if (typeof error === "string") { - return error; - } - - if (Array.isArray(error)) { - for (const issue of error) { - const message = getErrorMessage(issue); - if (message) { - return message; - } - } - return null; - } - - if (typeof error === "object" && error !== null) { - const maybeError = error as { message?: unknown }; - if (typeof maybeError.message === "string") { - return maybeError.message; - } - } - - return null; -} - -export function SignUp() { - const emailInputRef = useRef(null); - const passwordInputRef = useRef(null); - const { toast } = useToast(); - - const form = useForm({ - defaultValues: { - name: "", - email: "", - password: "", - }, - validators: { - onSubmit: signUpSchema, - }, - onSubmit: async ({ value, formApi }) => { - await authClient.signUp.email( - { - name: value.name.trim(), - email: value.email.trim(), - password: value.password, - }, - { - onError(error) { - toast.show({ - variant: "danger", - label: error.error?.message || "Failed to sign up", - }); - }, - onSuccess() { - formApi.reset(); - toast.show({ - variant: "success", - label: "Account created successfully", - }); - }, - }, - ); - }, - }); - - return ( - - Create Account - - ({ - isSubmitting: state.isSubmitting, - validationError: getErrorMessage(state.errorMap.onSubmit), - })} - > - {({ isSubmitting, validationError }) => { - const formError = validationError; - - return ( - <> - - {formError} - - - - - {(field) => ( - - Name - { - emailInputRef.current?.focus(); - }} - /> - - )} - - - - {(field) => ( - - Email - { - passwordInputRef.current?.focus(); - }} - /> - - )} - - - - {(field) => ( - - Password - - - )} - - - - {isSubmitting ? ( - - ) : ( - Create Account - )} - - - > - ); - }} - - - ); -} diff --git a/apps/native/package.json b/apps/native/package.json index b3204a3..46dc7d1 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -12,24 +12,18 @@ "web": "bun --env-file=../../.env expo start --web" }, "dependencies": { - "@better-auth/expo": "catalog:", + "@code/auth": "workspace:*", "@code/backend": "workspace:*", - "@code/env": "workspace:*", - "@convex-dev/better-auth": "catalog:", "@expo/metro-runtime": "~57.0.2", "@expo/vector-icons": "^15.1.1", "@gorhom/bottom-sheet": "^5.2.14", - "@tanstack/react-form": "catalog:", - "better-auth": "catalog:", "convex": "catalog:", "expo": "~57.0.1", - "expo-constants": "~57.0.2", "expo-font": "~57.0.0", "expo-haptics": "~57.0.0", "expo-linking": "~57.0.1", "expo-network": "~57.0.0", "expo-router": "~57.0.2", - "expo-secure-store": "~57.0.0", "expo-status-bar": "~57.0.0", "expo-web-browser": "~57.0.0", "heroui-native": "^1.0.5", @@ -47,8 +41,7 @@ "tailwind-merge": "catalog:", "tailwind-variants": "^3.2.2", "tailwindcss": "catalog:", - "uniwind": "^1.10.0", - "zod": "catalog:" + "uniwind": "^1.10.0" }, "devDependencies": { "@code/config": "workspace:*", diff --git a/apps/web/package.json b/apps/web/package.json index 8de23af..526e014 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,17 +10,15 @@ "typecheck": "react-router typegen && tsc" }, "dependencies": { + "@code/auth": "workspace:*", "@code/backend": "workspace:*", "@code/env": "workspace:*", "@code/ui": "workspace:*", - "@convex-dev/better-auth": "catalog:", "@flue/react": "1.0.0-beta.9", "@flue/sdk": "1.0.0-beta.9", "@react-router/fs-routes": "^8.1.0", "@react-router/node": "^8.1.0", "@react-router/serve": "^8.1.0", - "@tanstack/react-form": "catalog:", - "better-auth": "catalog:", "convex": "catalog:", "isbot": "^5.1.44", "lucide-react": "catalog:", @@ -29,8 +27,7 @@ "react-dom": "^19.2.7", "react-router": "^8.1.0", "sonner": "catalog:", - "streamdown": "2.5.0", - "zod": "catalog:" + "streamdown": "2.5.0" }, "devDependencies": { "@code/config": "workspace:*", diff --git a/apps/web/src/components/sign-in-form.tsx b/apps/web/src/components/sign-in-form.tsx deleted file mode 100644 index 6ff3013..0000000 --- a/apps/web/src/components/sign-in-form.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { Button } from "@code/ui/components/button"; -import { Input } from "@code/ui/components/input"; -import { Label } from "@code/ui/components/label"; -import { useForm } from "@tanstack/react-form"; -import { useNavigate } from "react-router"; -import { toast } from "sonner"; -import z from "zod"; - -import { authClient } from "@/lib/auth-client"; - -export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: () => void }) { - const navigate = useNavigate(); - - 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"), - }), - }, - }); - - return ( - - Welcome Back - - { - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - className="space-y-4" - > - - - {(field) => ( - - Email - field.handleChange(e.target.value)} - /> - {field.state.meta.errors.map((error, index) => ( - - {error?.message} - - ))} - - )} - - - - - - {(field) => ( - - Password - field.handleChange(e.target.value)} - /> - {field.state.meta.errors.map((error, index) => ( - - {error?.message} - - ))} - - )} - - - - ({ - canSubmit: state.canSubmit, - isSubmitting: state.isSubmitting, - })} - > - {({ canSubmit, isSubmitting }) => ( - - {isSubmitting ? "Submitting..." : "Sign In"} - - )} - - - - - - Need an account? Sign Up - - - - ); -} diff --git a/apps/web/src/components/sign-up-form.tsx b/apps/web/src/components/sign-up-form.tsx deleted file mode 100644 index d998b40..0000000 --- a/apps/web/src/components/sign-up-form.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { Button } from "@code/ui/components/button"; -import { Input } from "@code/ui/components/input"; -import { Label } from "@code/ui/components/label"; -import { useForm } from "@tanstack/react-form"; -import { useNavigate } from "react-router"; -import { toast } from "sonner"; -import z from "zod"; - -import { authClient } from "@/lib/auth-client"; - -export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: () => void }) { - const navigate = useNavigate(); - - 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"), - }), - }, - }); - - return ( - - Create Account - - { - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - className="space-y-4" - > - - - {(field) => ( - - Name - field.handleChange(e.target.value)} - /> - {field.state.meta.errors.map((error, index) => ( - - {error?.message} - - ))} - - )} - - - - - - {(field) => ( - - Email - field.handleChange(e.target.value)} - /> - {field.state.meta.errors.map((error, index) => ( - - {error?.message} - - ))} - - )} - - - - - - {(field) => ( - - Password - field.handleChange(e.target.value)} - /> - {field.state.meta.errors.map((error, index) => ( - - {error?.message} - - ))} - - )} - - - - ({ - canSubmit: state.canSubmit, - isSubmitting: state.isSubmitting, - })} - > - {({ canSubmit, isSubmitting }) => ( - - {isSubmitting ? "Submitting..." : "Sign Up"} - - )} - - - - - - Already have an account? Sign In - - - - ); -} diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx index 7f2b1ad..1191978 100644 --- a/apps/web/src/components/user-menu.tsx +++ b/apps/web/src/components/user-menu.tsx @@ -12,7 +12,7 @@ import { import { useQuery } from "convex/react"; import { useNavigate } from "react-router"; -import { authClient } from "@/lib/auth-client"; +import { authClient } from "@code/auth/web"; export default function UserMenu() { const navigate = useNavigate(); diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index 3691336..d1b39e7 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -1,11 +1,10 @@ import { env } from "@code/env/web"; +import { WebAuthProvider } from "@code/auth/web"; import { Toaster } from "@code/ui/components/sonner"; -import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react"; import { FlueProvider } from "@flue/react"; import "./index.css"; import { createFlueClient } from "@flue/sdk"; -import { ConvexReactClient } from "convex/react"; import { isRouteErrorResponse, Links, @@ -15,7 +14,6 @@ import { ScrollRestoration, } from "react-router"; -import { authClient } from "@/lib/auth-client"; import type { Route } from "./+types/root"; import { ThemeProvider } from "./components/theme-provider"; @@ -87,26 +85,23 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
- {error?.message} -