intial files
This commit is contained in:
37
packages/auth/package.json
Normal file
37
packages/auth/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
packages/auth/src/native/auth-client.ts
Normal file
23
packages/auth/src/native/auth-client.ts
Normal 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 }),
|
||||
],
|
||||
});
|
||||
16
packages/auth/src/native/auth-provider.tsx
Normal file
16
packages/auth/src/native/auth-provider.tsx
Normal 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>
|
||||
);
|
||||
62
packages/auth/src/native/hooks.ts
Normal file
62
packages/auth/src/native/hooks.ts
Normal 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 };
|
||||
4
packages/auth/src/native/index.ts
Normal file
4
packages/auth/src/native/index.ts
Normal 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";
|
||||
107
packages/auth/src/native/login-form.tsx
Normal file
107
packages/auth/src/native/login-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
138
packages/auth/src/native/signup-form.tsx
Normal file
138
packages/auth/src/native/signup-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/auth/src/server/index.ts
Normal file
1
packages/auth/src/server/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { authComponent, createAuth } from "@code/backend/convex/auth";
|
||||
38
packages/auth/src/shared/forms.ts
Normal file
38
packages/auth/src/shared/forms.ts
Normal 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;
|
||||
};
|
||||
8
packages/auth/src/web/auth-client.ts
Normal file
8
packages/auth/src/web/auth-client.ts
Normal 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()],
|
||||
});
|
||||
14
packages/auth/src/web/auth-provider.tsx
Normal file
14
packages/auth/src/web/auth-provider.tsx
Normal 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>
|
||||
);
|
||||
51
packages/auth/src/web/hooks.ts
Normal file
51
packages/auth/src/web/hooks.ts
Normal 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 },
|
||||
});
|
||||
4
packages/auth/src/web/index.ts
Normal file
4
packages/auth/src/web/index.ts
Normal 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";
|
||||
114
packages/auth/src/web/login-form.tsx
Normal file
114
packages/auth/src/web/login-form.tsx
Normal 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't have an account? <a href={signUpHref}>Sign up</a>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
147
packages/auth/src/web/signup-form.tsx
Normal file
147
packages/auth/src/web/signup-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
10
packages/auth/tsconfig.json
Normal file
10
packages/auth/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user