demo: use the default BA sign-in/sign-up components

This commit is contained in:
Bereket Engida
2025-03-04 10:06:15 +03:00
parent 31c974a744
commit 23e3ce296b
2 changed files with 167 additions and 397 deletions

View File

@@ -4,54 +4,28 @@ import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { signIn } from "@/lib/auth-client";
import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
import { Checkbox } from "@/components/ui/checkbox";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { signIn } from "@/lib/auth-client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { z } from "zod";
const signInSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
import { cn } from "@/lib/utils";
export default function SignIn() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [errors, setErrors] = useState({ email: "", password: "" });
const [isValid, setIsValid] = useState(false);
const [touched, setTouched] = useState({ email: false, password: false });
const router = useRouter();
const [loading, setLoading] = useState(false);
useEffect(() => {
const validationResult = signInSchema.safeParse({ email, password });
if (!validationResult.success) {
const newErrors: any = {};
validationResult.error.errors.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
setErrors(newErrors);
setIsValid(false);
} else {
setErrors({ email: "", password: "" });
setIsValid(true);
}
}, [email, password]);
const [rememberMe, setRememberMe] = useState(false);
return (
<Card className="z-50 rounded-md rounded-t-none max-w-md">
<Card className="max-w-md rounded-none">
<CardHeader>
<CardTitle className="text-lg md:text-xl">Sign In</CardTitle>
<CardDescription className="text-xs md:text-sm">
@@ -69,90 +43,59 @@ export default function SignIn() {
required
onChange={(e) => {
setEmail(e.target.value);
setTouched((prev) => ({ ...prev, email: true }));
}}
value={email}
/>
{touched.email && errors.email && (
<p className="text-red-500 text-sm">{errors.email}</p>
)}
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<Link
href="/forget-password"
className="ml-auto inline-block text-sm underline"
>
<Link href="#" className="ml-auto inline-block text-sm underline">
Forgot your password?
</Link>
</div>
<PasswordInput
<Input
id="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setTouched((prev) => ({ ...prev, password: true }));
}}
type="password"
placeholder="password"
autoComplete="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{touched.password && errors.password && (
<p className="text-red-500 text-sm">{errors.password}</p>
)}
</div>
<div className="flex items-center gap-2">
<Checkbox
id="remember"
onClick={() => {
setRememberMe(!rememberMe);
}}
/>
<Label htmlFor="remember">Remember me</Label>
</div>
<Button
type="submit"
className="w-full"
disabled={!isValid || loading}
disabled={loading}
onClick={async () => {
await signIn.email(
{
email: email,
password: password,
callbackURL: "/dashboard",
rememberMe,
},
{
onRequest: () => setLoading(true),
onResponse: () => setLoading(false),
onError: (ctx) => {
toast.error(ctx.error.message);
},
},
);
await signIn.email({ email, password });
}}
>
{loading ? <Loader2 size={16} className="animate-spin" /> : "Login"}
</Button>
<div className="grid grid-cols-4 gap-2">
<div
className={cn(
"w-full gap-2 flex items-center",
"justify-between flex-col",
)}
>
<Button
variant="outline"
className="gap-2"
onClick={async () => {
await signIn.social({
provider: "github",
callbackURL: "/dashboard",
});
}}
>
<GitHubLogoIcon />
</Button>
<Button
variant="outline"
className="gap-2"
onClick={async () => {
await signIn.social({
provider: "discord",
});
}}
>
<DiscordLogoIcon />
</Button>
<Button
variant="outline"
className=" gap-2"
className={cn("w-full gap-2")}
onClick={async () => {
await signIn.social({
provider: "google",
@@ -169,94 +112,28 @@ export default function SignIn() {
<path
fill="#4285F4"
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
/>
></path>
<path
fill="#34A853"
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
/>
></path>
<path
fill="#FBBC05"
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"
/>
></path>
<path
fill="#EB4335"
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
/>
</svg>
</Button>
<Button
variant="outline"
className="gap-2"
onClick={async () => {
const { data } = await signIn.social({
provider: "microsoft",
callbackURL: "/dashboard",
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z"
></path>
</svg>
Sign in with Google
</Button>
<Button
variant="outline"
className="gap-2"
className={cn("w-full gap-2")}
onClick={async () => {
await signIn.social({
provider: "twitch",
callbackURL: "/dashboard",
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M11.64 5.93h1.43v4.28h-1.43m3.93-4.28H17v4.28h-1.43M7 2L3.43 5.57v12.86h4.28V22l3.58-3.57h2.85L20.57 12V2m-1.43 9.29l-2.85 2.85h-2.86l-2.5 2.5v-2.5H7.71V3.43h11.43Z"
></path>
</svg>
</Button>
<Button
variant="outline"
className="gap-2"
onClick={async () => {
await signIn.social({
provider: "facebook",
callbackURL: "/dashboard",
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.3em"
height="1.3em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M22 12c0-5.52-4.48-10-10-10S2 6.48 2 12c0 4.84 3.44 8.87 8 9.8V15H8v-3h2V9.5C10 7.57 11.57 6 13.5 6H16v3h-2c-.55 0-1 .45-1 1v2h3v3h-3v6.95c5.05-.5 9-4.76 9-9.95"
></path>
</svg>
</Button>
<Button
variant="outline"
className="gap-2"
onClick={async () => {
await signIn.social({
provider: "twitter",
provider: "github",
callbackURL: "/dashboard",
});
}}
@@ -265,26 +142,55 @@ export default function SignIn() {
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 14 14"
viewBox="0 0 24 24"
>
<g fill="none">
<g clipPath="url(#primeTwitter0)">
<path
fill="currentColor"
d="M11.025.656h2.147L8.482 6.03L14 13.344H9.68L6.294 8.909l-3.87 4.435H.275l5.016-5.75L0 .657h4.43L7.486 4.71zm-.755 11.4h1.19L3.78 1.877H2.504z"
></path>
</g>
<defs>
<clipPath id="primeTwitter0">
<path fill="#fff" d="M0 0h14v14H0z"></path>
</clipPath>
</defs>
</g>
<path
fill="currentColor"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
></path>
</svg>
Sign in with Github
</Button>
<Button
variant="outline"
className={cn("w-full gap-2")}
onClick={async () => {
await signIn.social({
provider: "microsoft",
callbackURL: "/dashboard",
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z"
></path>
</svg>
Sign in with Microsoft
</Button>
</div>
</div>
</CardContent>
<CardFooter>
<div className="flex justify-center w-full border-t py-4">
<p className="text-center text-xs text-neutral-500">
Powered by{" "}
<Link
href="https://better-auth.com"
className="underline"
target="_blank"
>
<span className="dark:text-orange-200/90">better-auth.</span>
</Link>
</p>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -1,102 +1,46 @@
"use client";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { signUp, signIn } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useState, useEffect, ChangeEvent } from "react";
import { z } from "zod";
import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
import { useState } from "react";
import Image from "next/image";
import { Loader2, X } from "lucide-react";
const signUpSchema = z
.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
image: z
.instanceof(File)
.optional()
.refine((file) => !file || file.type.startsWith("image/"), {
message: "Invalid file type. Only images are allowed.",
path: ["image"],
}),
password: z.string().min(6, "Password must be at least 6 characters"),
passwordConfirmation: z
.string()
.min(6, "Password must be at least 6 characters"),
})
.refine((data) => data.password === data.passwordConfirmation, {
message: "Passwords do not match",
path: ["passwordConfirmation"],
});
type FormDataType = z.infer<typeof signUpSchema>;
type ErrorsType = Partial<Record<keyof FormDataType, string>>;
type TouchedType = Partial<Record<keyof FormDataType, boolean>>;
import { signUp } from "@/lib/auth-client";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
export function SignUp() {
const [formData, setFormData] = useState<FormDataType>({
firstName: "",
lastName: "",
email: "",
password: "",
image: undefined,
passwordConfirmation: "",
});
const [errors, setErrors] = useState<ErrorsType>({});
const [isValid, setIsValid] = useState(false);
const [touched, setTouched] = useState<TouchedType>({});
const [loading, setLoading] = useState(false);
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [image, setImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const router = useRouter();
const [loading, setLoading] = useState(false);
useEffect(() => {
const validationResult = signUpSchema.safeParse(formData);
if (!validationResult.success) {
const newErrors: ErrorsType = {};
validationResult.error.errors.forEach((err) => {
if (err.path[0])
newErrors[err.path[0] as keyof FormDataType] = err.message;
});
setErrors(newErrors);
setIsValid(false);
} else {
setErrors({});
setIsValid(true);
}
}, [formData]);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { id, value } = e.target;
setFormData((prev) => ({ ...prev, [id]: value }));
setTouched((prev) => ({ ...prev, [id]: true }));
};
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
setImage(file);
setFormData((prev) => ({ ...prev, image: file || undefined }));
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImage(file);
const reader = new FileReader();
reader.onload = () => setImagePreview(reader.result as string);
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
} else {
setImagePreview(null);
}
};
return (
<Card className="z-50 rounded-md rounded-t-none max-w-md">
<CardHeader>
@@ -109,28 +53,28 @@ export function SignUp() {
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="firstName">First name</Label>
<Label htmlFor="first-name">First name</Label>
<Input
id="firstName"
id="first-name"
placeholder="Max"
value={formData.firstName}
onChange={handleChange}
required
onChange={(e) => {
setFirstName(e.target.value);
}}
value={firstName}
/>
{touched.firstName && errors.firstName && (
<p className="text-red-500 text-sm">{errors.firstName}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="lastName">Last name</Label>
<Label htmlFor="last-name">Last name</Label>
<Input
id="lastName"
id="last-name"
placeholder="Robinson"
value={formData.lastName}
onChange={handleChange}
required
onChange={(e) => {
setLastName(e.target.value);
}}
value={lastName}
/>
{touched.lastName && errors.lastName && (
<p className="text-red-500 text-sm">{errors.lastName}</p>
)}
</div>
</div>
<div className="grid gap-2">
@@ -139,38 +83,34 @@ export function SignUp() {
id="email"
type="email"
placeholder="m@example.com"
value={formData.email}
onChange={handleChange}
required
onChange={(e) => {
setEmail(e.target.value);
}}
value={email}
/>
{touched.email && errors.email && (
<p className="text-red-500 text-sm">{errors.email}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
<Input
id="password"
value={formData.password}
onChange={handleChange}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
placeholder="Password"
/>
{touched.password && errors.password && (
<p className="text-red-500 text-sm">{errors.password}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="passwordConfirmation">Confirm Password</Label>
<PasswordInput
id="passwordConfirmation"
value={formData.passwordConfirmation}
onChange={handleChange}
<Label htmlFor="password">Confirm Password</Label>
<Input
id="password_confirmation"
type="password"
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
autoComplete="new-password"
placeholder="Confirm Password"
/>
{touched.passwordConfirmation && errors.passwordConfirmation && (
<p className="text-red-500 text-sm">
{errors.passwordConfirmation}
</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="image">Profile Image (optional)</Label>
@@ -185,36 +125,36 @@ export function SignUp() {
/>
</div>
)}
<Input
id="image"
type="file"
accept="image/*"
onChange={handleImageChange}
className="w-full"
/>
{imagePreview && (
<X
className="cursor-pointer"
onClick={() => {
setImage(null);
setImagePreview(null);
}}
<div className="flex items-center gap-2 w-full">
<Input
id="image"
type="file"
accept="image/*"
onChange={handleImageChange}
className="w-full"
/>
)}
{imagePreview && (
<X
className="cursor-pointer"
onClick={() => {
setImage(null);
setImagePreview(null);
}}
/>
)}
</div>
</div>
</div>
<Button
type="submit"
className="w-full"
disabled={!isValid || loading}
disabled={loading}
onClick={async () => {
setLoading(true);
await signUp.email({
email: formData.email,
password: formData.password,
name: `${formData.firstName} ${formData.lastName}`,
image: formData.image
? await convertImageToBase64(formData.image)
: "",
email,
password,
name: `${firstName} ${lastName}`,
image: image ? await convertImageToBase64(image) : "",
callbackURL: "/dashboard",
fetchOptions: {
onResponse: () => {
@@ -231,8 +171,6 @@ export function SignUp() {
},
},
});
setLoading(false);
router.push("/dashboard");
}}
>
{loading ? (
@@ -241,93 +179,19 @@ export function SignUp() {
"Create an account"
)}
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="w-full gap-2"
onClick={async () => {
await signIn.social({
provider: "github",
callbackURL: "/dashboard",
});
}}
>
<GitHubLogoIcon />
</Button>
<Button
variant="outline"
className="w-full gap-2"
onClick={async () => {
await signIn.social({
provider: "discord",
callbackURL: "/dashboard",
});
}}
>
<DiscordLogoIcon />
</Button>
<Button
variant="outline"
className="w-full gap-2"
onClick={async () => {
await signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="0.98em"
height="1em"
viewBox="0 0 256 262"
>
<path
fill="#4285F4"
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
/>
<path
fill="#34A853"
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
/>
<path
fill="#FBBC05"
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"
/>
<path
fill="#EB4335"
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
/>
</svg>
</Button>
<Button
variant="outline"
className="w-full gap-2"
onClick={async () => {
await signIn.social({
provider: "microsoft",
callbackURL: "/dashboard",
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z"
></path>
</svg>
</Button>
</div>
</div>
</CardContent>
<CardFooter>
<div className="flex justify-center w-full border-t py-4">
<p className="text-center text-xs text-neutral-500">
Secured by <span className="text-orange-400">better-auth.</span>
</p>
</div>
</CardFooter>
</Card>
);
}
async function convertImageToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();