intial files

This commit is contained in:
sai karthik
2026-07-23 00:19:53 +05:30
parent 677a355397
commit 74a209a807
45 changed files with 1941 additions and 1016 deletions

View File

@@ -0,0 +1,37 @@
{
"name": "@code/auth",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./native": "./src/native/index.ts",
"./server": "./src/server/index.ts",
"./web": "./src/web/index.ts"
},
"scripts": {
"check-types": "tsc --noEmit"
},
"dependencies": {
"@better-auth/expo": "catalog:",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/ui": "workspace:*",
"@convex-dev/better-auth": "catalog:",
"@tanstack/react-form": "catalog:",
"better-auth": "catalog:",
"convex": "catalog:",
"expo-constants": "~57.0.2",
"expo-secure-store": "~57.0.0",
"heroui-native": "^1.0.5",
"react": "^19.2.3",
"react-native": "0.86.0",
"sonner": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
"@types/react": "~19.2.17",
"vite": "^7.3.6",
"typescript": "^6"
}
}

View File

@@ -0,0 +1,23 @@
import { expoClient } from "@better-auth/expo/client";
import { env } from "@code/env/native";
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import Constants from "expo-constants";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const scheme = Constants.expoConfig?.scheme;
if (typeof scheme !== "string") {
throw new Error("Expo auth requires a string app scheme in app.json");
}
export const authClient = createAuthClient({
baseURL: env.EXPO_PUBLIC_CONVEX_SITE_URL,
plugins: [
convexClient(),
Platform.OS === "web"
? crossDomainClient()
: expoClient({ scheme, storage: SecureStore, storagePrefix: scheme }),
],
});

View File

@@ -0,0 +1,16 @@
import { env } from "@code/env/native";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { ConvexReactClient } from "convex/react";
import type { ReactNode } from "react";
import { authClient } from "./auth-client";
const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, {
unsavedChangesWarning: false,
});
export const NativeAuthProvider = ({ children }: { children: ReactNode }) => (
<ConvexBetterAuthProvider authClient={authClient} client={convex}>
{children}
</ConvexBetterAuthProvider>
);

View File

@@ -0,0 +1,62 @@
import { useForm } from "@tanstack/react-form";
import { useToast } from "heroui-native";
import { getErrorMessage, signInSchema, signUpSchema } from "../shared/forms";
import { authClient } from "./auth-client";
type AuthFormOptions = {
readonly onSuccess?: () => void;
};
export const useNativeSignInForm = ({ onSuccess }: AuthFormOptions = {}) => {
const { toast } = useToast();
return useForm({
defaultValues: { email: "", password: "" },
onSubmit: async ({ formApi, value }) => {
await authClient.signIn.email(
{ email: value.email.trim(), password: value.password },
{
onError: ({ error }) => {
toast.show({ label: error.message || "Failed to sign in", variant: "danger" });
},
onSuccess: () => {
formApi.reset();
toast.show({ label: "Signed in successfully", variant: "success" });
onSuccess?.();
},
},
);
},
validators: { onSubmit: signInSchema },
});
};
export const useNativeSignUpForm = ({ onSuccess }: AuthFormOptions = {}) => {
const { toast } = useToast();
return useForm({
defaultValues: { confirmPassword: "", email: "", name: "", password: "" },
onSubmit: async ({ formApi, value }) => {
await authClient.signUp.email(
{ email: value.email.trim(), name: value.name.trim(), password: value.password },
{
onError: ({ error }) => {
toast.show({
label: error.message || "Failed to create account",
variant: "danger",
});
},
onSuccess: () => {
formApi.reset();
toast.show({ label: "Account created successfully", variant: "success" });
onSuccess?.();
},
},
);
},
validators: { onSubmit: signUpSchema },
});
};
export { getErrorMessage };

View File

@@ -0,0 +1,4 @@
export { authClient } from "./auth-client";
export { NativeAuthProvider } from "./auth-provider";
export { NativeLoginForm } from "./login-form";
export { NativeSignupForm } from "./signup-form";

View File

@@ -0,0 +1,107 @@
import {
Button,
FieldError,
Input,
Label,
Spinner,
Surface,
TextField,
useThemeColor,
} from "heroui-native";
import { useRef } from "react";
import { Text, type TextInput, View } from "react-native";
import { getErrorMessage, useNativeSignInForm } from "./hooks";
type NativeLoginFormProps = {
readonly onNavigateSignUp: () => void;
readonly onSuccess?: () => void;
};
export const NativeLoginForm = ({
onNavigateSignUp,
onSuccess,
}: NativeLoginFormProps) => {
const passwordInputRef = useRef<TextInput>(null);
const form = useNativeSignInForm({ onSuccess });
const foregroundColor = useThemeColor("foreground");
const mutedColor = useThemeColor("muted");
return (
<Surface className="rounded-xl p-5" variant="secondary">
<Text style={{ color: foregroundColor, fontSize: 24, fontWeight: "600", marginBottom: 4 }}>
Welcome back
</Text>
<Text style={{ color: mutedColor, fontSize: 14, marginBottom: 20 }}>
Sign in with your email and password.
</Text>
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
submitError: getErrorMessage(state.errorMap.onSubmit),
})}
>
{({ isSubmitting, submitError }) => (
<View style={{ gap: 16 }}>
<FieldError className="mb-1" isInvalid={!!submitError}>
{submitError}
</FieldError>
<form.Field name="email">
{(field) => (
<TextField>
<Label>Email</Label>
<Input
autoCapitalize="none"
autoComplete="email"
blurOnSubmit={false}
keyboardType="email-address"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
onSubmitEditing={() => passwordInputRef.current?.focus()}
placeholder="you@example.com"
returnKeyType="next"
textContentType="emailAddress"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<TextField>
<Label>Password</Label>
<Input
autoComplete="password"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
onSubmitEditing={() => void form.handleSubmit()}
ref={passwordInputRef}
returnKeyType="go"
secureTextEntry
textContentType="password"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<Button
className="mt-1"
isDisabled={isSubmitting}
onPress={() => void form.handleSubmit()}
>
{isSubmitting ? <Spinner color="default" size="sm" /> : <Button.Label>Sign in</Button.Label>}
</Button>
<Button onPress={onNavigateSignUp} variant="ghost">
<Button.Label>Need an account? Sign up</Button.Label>
</Button>
</View>
)}
</form.Subscribe>
</Surface>
);
};

View File

@@ -0,0 +1,138 @@
import {
Button,
FieldError,
Input,
Label,
Spinner,
Surface,
TextField,
useThemeColor,
} from "heroui-native";
import { Text, View } from "react-native";
import { getErrorMessage, useNativeSignUpForm } from "./hooks";
type NativeSignupFormProps = {
readonly onNavigateSignIn: () => void;
readonly onSuccess?: () => void;
};
export const NativeSignupForm = ({
onNavigateSignIn,
onSuccess,
}: NativeSignupFormProps) => {
const form = useNativeSignUpForm({ onSuccess });
const foregroundColor = useThemeColor("foreground");
const mutedColor = useThemeColor("muted");
return (
<Surface className="rounded-xl p-5" variant="secondary">
<Text style={{ color: foregroundColor, fontSize: 24, fontWeight: "600", marginBottom: 4 }}>
Create an account
</Text>
<Text style={{ color: mutedColor, fontSize: 14, marginBottom: 20 }}>
Use your name, email, and password.
</Text>
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
submitError: getErrorMessage(state.errorMap.onSubmit),
})}
>
{({ isSubmitting, submitError }) => (
<View style={{ gap: 16 }}>
<FieldError className="mb-1" isInvalid={!!submitError}>
{submitError}
</FieldError>
<form.Field name="name">
{(field) => (
<TextField>
<Label>Full name</Label>
<Input
autoCapitalize="words"
autoComplete="name"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="Jane Doe"
textContentType="name"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<TextField>
<Label>Email</Label>
<Input
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="you@example.com"
textContentType="emailAddress"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<TextField>
<Label>Password</Label>
<Input
autoComplete="new-password"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
secureTextEntry
textContentType="newPassword"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<form.Field name="confirmPassword">
{(field) => (
<TextField>
<Label>Confirm password</Label>
<Input
autoComplete="new-password"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
onSubmitEditing={() => void form.handleSubmit()}
returnKeyType="go"
secureTextEntry
textContentType="newPassword"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<Button
className="mt-1"
isDisabled={isSubmitting}
onPress={() => void form.handleSubmit()}
>
{isSubmitting ? (
<Spinner color="default" size="sm" />
) : (
<Button.Label>Create account</Button.Label>
)}
</Button>
<Button onPress={onNavigateSignIn} variant="ghost">
<Button.Label>Already have an account? Sign in</Button.Label>
</Button>
</View>
)}
</form.Subscribe>
</Surface>
);
};

View File

@@ -0,0 +1 @@
export { authComponent, createAuth } from "@code/backend/convex/auth";

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
export 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"),
});
export const signUpSchema = z
.object({
confirmPassword: z.string().min(1, "Confirm your password"),
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
name: z.string().trim().min(2, "Name must be at least 2 characters"),
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
})
.refine(({ confirmPassword, password }) => confirmPassword === password, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
export const 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 && "message" in error) {
const { message } = error as { message?: unknown };
return typeof message === "string" ? message : null;
}
return null;
};

View File

@@ -0,0 +1,8 @@
import { env } from "@code/env/web";
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: env.VITE_CONVEX_SITE_URL,
plugins: [convexClient(), crossDomainClient()],
});

View File

@@ -0,0 +1,14 @@
import { env } from "@code/env/web";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { ConvexReactClient } from "convex/react";
import type { ReactNode } from "react";
import { authClient } from "./auth-client";
const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
export const WebAuthProvider = ({ children }: { children: ReactNode }) => (
<ConvexBetterAuthProvider authClient={authClient} client={convex}>
{children}
</ConvexBetterAuthProvider>
);

View File

@@ -0,0 +1,51 @@
import { useForm } from "@tanstack/react-form";
import { toast } from "sonner";
import { signInSchema, signUpSchema } from "../shared/forms";
import { authClient } from "./auth-client";
type AuthFormOptions = {
readonly onSuccess?: () => void;
};
export const useSignInForm = ({ onSuccess }: AuthFormOptions = {}) =>
useForm({
defaultValues: { email: "", password: "" },
onSubmit: async ({ formApi, value }) => {
await authClient.signIn.email(
{ email: value.email.trim(), password: value.password },
{
onError: ({ error }) => {
toast.error(error.message || error.statusText);
},
onSuccess: () => {
formApi.reset();
toast.success("Signed in successfully");
onSuccess?.();
},
},
);
},
validators: { onSubmit: signInSchema },
});
export const useSignUpForm = ({ onSuccess }: AuthFormOptions = {}) =>
useForm({
defaultValues: { confirmPassword: "", email: "", name: "", password: "" },
onSubmit: async ({ formApi, value }) => {
await authClient.signUp.email(
{ email: value.email.trim(), name: value.name.trim(), password: value.password },
{
onError: ({ error }) => {
toast.error(error.message || error.statusText);
},
onSuccess: () => {
formApi.reset();
toast.success("Account created successfully");
onSuccess?.();
},
},
);
},
validators: { onSubmit: signUpSchema },
});

View File

@@ -0,0 +1,4 @@
export { authClient } from "./auth-client";
export { WebAuthProvider } from "./auth-provider";
export { LoginForm } from "./login-form";
export { SignupForm } from "./signup-form";

View File

@@ -0,0 +1,114 @@
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@code/ui/components/field";
import { Input } from "@code/ui/components/input";
import { cn } from "@code/ui/lib/utils";
import type { ComponentProps } from "react";
import { getErrorMessage } from "../shared/forms";
import { useSignInForm } from "./hooks";
type LoginFormProps = ComponentProps<"div"> & {
readonly onSuccess?: () => void;
readonly signUpHref?: string;
};
export const LoginForm = ({
className,
onSuccess,
signUpHref = "/signup",
...props
}: LoginFormProps) => {
const form = useSignInForm({ onSuccess });
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle>Welcome back</CardTitle>
<CardDescription>Enter your email and password to continue.</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<FieldGroup>
<form.Field name="email">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
autoComplete="email"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
placeholder="you@example.com"
type="email"
value={field.state.value}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Password</FieldLabel>
<Input
autoComplete="current-password"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
type="password"
value={field.state.value}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Subscribe
selector={(state) => ({
canSubmit: state.canSubmit,
isSubmitting: state.isSubmitting,
submitError: getErrorMessage(state.errorMap.onSubmit),
})}
>
{({ canSubmit, isSubmitting, submitError }) => (
<Field>
<FieldError>{submitError}</FieldError>
<Button disabled={!canSubmit || isSubmitting} type="submit">
{isSubmitting ? "Signing in…" : "Sign in"}
</Button>
<FieldDescription className="text-center">
Don&apos;t have an account? <a href={signUpHref}>Sign up</a>
</FieldDescription>
</Field>
)}
</form.Subscribe>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,147 @@
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@code/ui/components/field";
import { Input } from "@code/ui/components/input";
import type { ComponentProps } from "react";
import { getErrorMessage } from "../shared/forms";
import { useSignUpForm } from "./hooks";
type SignupFormProps = ComponentProps<typeof Card> & {
readonly onSuccess?: () => void;
readonly signInHref?: string;
};
export const SignupForm = ({
onSuccess,
signInHref = "/login",
...props
}: SignupFormProps) => {
const form = useSignUpForm({ onSuccess });
return (
<Card {...props}>
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>Use your name, email, and password to get started.</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<FieldGroup>
<form.Field name="name">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Full name</FieldLabel>
<Input
autoComplete="name"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
placeholder="Jane Doe"
value={field.state.value}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
autoComplete="email"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
placeholder="you@example.com"
type="email"
value={field.state.value}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Password</FieldLabel>
<Input
autoComplete="new-password"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
type="password"
value={field.state.value}
/>
<FieldDescription>Use at least 8 characters.</FieldDescription>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="confirmPassword">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Confirm password</FieldLabel>
<Input
autoComplete="new-password"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
type="password"
value={field.state.value}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Subscribe
selector={(state) => ({
canSubmit: state.canSubmit,
isSubmitting: state.isSubmitting,
submitError: getErrorMessage(state.errorMap.onSubmit),
})}
>
{({ canSubmit, isSubmitting, submitError }) => (
<Field>
<FieldError>{submitError}</FieldError>
<Button disabled={!canSubmit || isSubmitting} type="submit">
{isSubmitting ? "Creating account…" : "Create account"}
</Button>
<FieldDescription className="text-center">
Already have an account? <a href={signInHref}>Sign in</a>
</FieldDescription>
</Field>
)}
</form.Subscribe>
</FieldGroup>
</form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,10 @@
{
"extends": "@code/config/tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["node", "vite/client"],
"noEmit": true
},
"include": ["src"]
}