diff --git a/demo/nextjs/.gitignore b/demo/nextjs/.gitignore new file mode 100644 index 00000000..26b002aa --- /dev/null +++ b/demo/nextjs/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/demo/nextjs/README.md b/demo/nextjs/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/demo/nextjs/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/demo/nextjs/app/(auth)/sign-in/page.tsx b/demo/nextjs/app/(auth)/sign-in/page.tsx new file mode 100644 index 00000000..5c392dda --- /dev/null +++ b/demo/nextjs/app/(auth)/sign-in/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import SignIn from "@/components/sign-in"; +import { SignUp } from "@/components/sign-up"; +import { Tabs } from "@/components/ui/tabs2"; + +export default function Page() { + return ( +
+ +
+ +
+ , + }, { + title: "Sign Up", + value: "sign-up", + content: , + }]} /> +
+
+
+ ); +} diff --git a/demo/nextjs/app/accept-invitation/[id]/invitation-error.tsx b/demo/nextjs/app/accept-invitation/[id]/invitation-error.tsx new file mode 100644 index 00000000..40422781 --- /dev/null +++ b/demo/nextjs/app/accept-invitation/[id]/invitation-error.tsx @@ -0,0 +1,31 @@ +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { AlertCircle } from "lucide-react" +import Link from "next/link" + +export function InvitationError() { + return ( + + +
+ + Invitation Error +
+ + There was an issue with your invitation. + +
+ +

+ The invitation you're trying to access is either invalid or you don't have the correct permissions. + Please check your email for a valid invitation or contact the person who sent it. +

+
+ + + + + +
+ ) +} \ No newline at end of file diff --git a/demo/nextjs/app/accept-invitation/[id]/page.tsx b/demo/nextjs/app/accept-invitation/[id]/page.tsx new file mode 100644 index 00000000..491c7dc0 --- /dev/null +++ b/demo/nextjs/app/accept-invitation/[id]/page.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { CheckIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Skeleton } from "@/components/ui/skeleton"; +import { client, organization } from "@/lib/auth-client"; +import { InvitationError } from "./invitation-error"; +import { Invitation } from "@/lib/auth-types"; + +export default function InvitationPage({ + params, +}: { + params: { + id: string; + }; +}) { + const router = useRouter() + const [invitationStatus, setInvitationStatus] = useState< + "pending" | "accepted" | "rejected" + >("pending"); + + const handleAccept = async () => { + await organization.acceptInvitation({ + invitationId: params.id, + }).then((res) => { + if (res.error) { + setError(res.error.message || "An error occurred") + } else { + setInvitationStatus("accepted"); + router.push(`/organizations/${invitation?.organizationSlug}`) + } + }) + }; + + const handleReject = async () => { + await organization.rejectInvitation({ + invitationId: params.id + }).then((res) => { + if (res.error) { + setError(res.error.message || "An error occurred") + } else { + setInvitationStatus("rejected"); + } + }) + }; + + const [invitation, setInvitation] = useState<{ + organizationName: string; + organizationSlug: string; + inviterEmail: string; + id: string; + status: "pending" | "accepted" | "rejected" | "canceled"; + email: string; + expiresAt: Date; + organizationId: string; + role: "member" | "admin" | "owner"; + inviterId: string; + } | null>(null); + + const [error, setError] = useState(null); + + useEffect(() => { + client.organization.getActiveInvitation({ + query: { + id: params.id + } + }).then((res) => { + if (res.error) { + setError(res.error.message || "An error occurred") + } else { + setInvitation(res.data) + } + }) + }, []) + + + return ( +
+
+ { + invitation ? + + Organization Invitation + + You've been invited to join an organization + + + + {invitationStatus === "pending" && ( +
+

+ {invitation?.inviterEmail} has invited you + to join {invitation?.organizationName}. +

+

+ This invitation was sent to{" "} + {invitation?.email}. +

+
+ )} + {invitationStatus === "accepted" && ( +
+
+ +
+

+ Welcome to {invitation?.organizationName}! +

+

+ You've successfully joined the organization. We're excited to + have you on board! +

+
+ )} + {invitationStatus === "rejected" && ( +
+
+ +
+

+ Invitation Declined +

+

+ You‘ve declined the invitation to join{" "} + {invitation?.organizationName}. +

+
+ )} +
+ {invitationStatus === "pending" && ( + + + + + )} +
: error ? : + } +
+ ); +} + + +function InvitationSkeleton() { + return ( + + +
+ + +
+ + +
+ +
+ + + +
+
+ + + +
+ ) +} \ No newline at end of file diff --git a/demo/nextjs/app/api/auth/[...all]/route.ts b/demo/nextjs/app/api/auth/[...all]/route.ts new file mode 100644 index 00000000..0cbe3e45 --- /dev/null +++ b/demo/nextjs/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth); \ No newline at end of file diff --git a/demo/nextjs/app/api/v1/[...all]/route.ts b/demo/nextjs/app/api/v1/[...all]/route.ts new file mode 100644 index 00000000..996c8fae --- /dev/null +++ b/demo/nextjs/app/api/v1/[...all]/route.ts @@ -0,0 +1,12 @@ +import { router } from "@/lib/router"; + + +export const handler = router.handler + +export { + handler as GET, + handler as POST, + handler as PUT, + handler as DELETE, + handler as PATCH, +} \ No newline at end of file diff --git a/demo/nextjs/app/dashboard/client.tsx b/demo/nextjs/app/dashboard/client.tsx new file mode 100644 index 00000000..9637a946 --- /dev/null +++ b/demo/nextjs/app/dashboard/client.tsx @@ -0,0 +1,12 @@ +"use client" + + +export function ManageAccount() { + return ( +
+

+ Manage Account +

+
+ ) +} \ No newline at end of file diff --git a/demo/nextjs/app/dashboard/organization-card.tsx b/demo/nextjs/app/dashboard/organization-card.tsx new file mode 100644 index 00000000..e7c64e3b --- /dev/null +++ b/demo/nextjs/app/dashboard/organization-card.tsx @@ -0,0 +1,481 @@ +"use client"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { organization, useActiveOrganization, useListOrganizations, useSession } from "@/lib/auth-client"; +import { ActiveOrganization, Session } from "@/lib/auth-types"; +import { ChevronDownIcon, PlusIcon } from "@radix-ui/react-icons"; +import { Loader2, MailPlus } from "lucide-react"; +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { AnimatePresence, motion } from "framer-motion"; +import CopyButton from "@/components/ui/copy-button"; +import Image from "next/image"; + +export function OrganizationCard(props: { + session: Session | null +}) { + const organizations = useListOrganizations() + const activeOrg = useActiveOrganization() + const [optimisticOrg, setOptimisticOrg] = useState(null) + const [isRevoking, setIsRevoking] = useState([]); + useEffect(() => { + setOptimisticOrg(activeOrg.data) + }, [activeOrg.data]) + + const inviteVariants = { + hidden: { opacity: 0, height: 0 }, + visible: { opacity: 1, height: 'auto' }, + exit: { opacity: 0, height: 0 } + }; + + const { data } = useSession() + const session = data || props.session + + const currentMember = optimisticOrg?.members.find((member) => member.userId === session?.user.id) + + return ( + + + + Organization + +
+ + + +
+

+ + {optimisticOrg?.name || "Personal"} +

+ + +
+
+ + { + organization.setActive(null) + setOptimisticOrg(null) + }}> +

+ Personal +

+
+ { + organizations.data?.map((org) => ( + { + if (org.id === optimisticOrg?.id) { + return + } + organization.setActive(org.id) + setOptimisticOrg({ + members: [], + invitations: [], + ...org + }) + }}> +

+ {org.name} +

+
+ )) + } + +
+
+
+ +
+
+
+ + + + {optimisticOrg?.name?.charAt(0) || "P"} + + +
+

+ {optimisticOrg?.name || "Personal"} +

+

+ {optimisticOrg?.members.length || 1} members +

+
+
+
+ + +
+
+

+ Members +

+
+ { + optimisticOrg?.members.map((member) => ( +
+
+ + + + {member.user.name?.charAt(0)} + + +
+

+ {member.user.name} +

+

+ {member.role} +

+
+
+ { + (member.role !== "owner" && (currentMember?.role === "owner" || currentMember?.role === "admin")) && ( + + )} +
+ )) + } + { + !optimisticOrg?.id && ( +
+
+ + + + {session?.user.name?.charAt(0)} + + +
+

+ {session?.user.name} +

+

+ Owner +

+
+
+
+ ) + } +
+
+
+

+ Invites +

+
+ + { + optimisticOrg?.invitations + .filter((invitation) => invitation.status === "pending") + .map((invitation) => ( + +
+

+ {invitation.email} +

+

+ {invitation.role} +

+
+
+ +
+ +
+
+
+ )) + } +
+ { + optimisticOrg?.invitations.length === 0 && ( + + No Active Invitations + + ) + } + { + !optimisticOrg?.id && ( + + ) + } +
+
+
+
+
+
+ { + optimisticOrg?.id && ( + + ) + } +
+
+
+
+ {/* + + */} +
+ ) +} + +function CreateOrganizationDialog() { + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const [isSlugEdited, setIsSlugEdited] = useState(false); + const [logo, setLogo] = useState(null); + + useEffect(() => { + if (!isSlugEdited) { + const generatedSlug = name.trim().toLowerCase().replace(/\s+/g, "-"); + setSlug(generatedSlug); + } + }, [name, isSlugEdited]); + + useEffect(() => { + if (open) { + setName(""); + setSlug(""); + setIsSlugEdited(false); + setLogo(null); + } + }, [open]); + + const handleLogoChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onloadend = () => { + setLogo(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + return ( + + + + + + + + New Organization + + + Create a new organization to collaborate with your team. + + +
+
+ + setName(e.target.value)} + /> +
+
+ + { + setSlug(e.target.value); + setIsSlugEdited(true); + }} + placeholder="Slug" + /> +
+
+ + + {logo && ( +
+ Logo preview +
+ )} +
+
+ + + +
+
+ ) +} + +function InviteMemberDialog({ setOptimisticOrg, optimisticOrg }: { setOptimisticOrg: (org: ActiveOrganization | null) => void, optimisticOrg: ActiveOrganization | null }) { + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [role, setRole] = useState("member"); + const [loading, setLoading] = useState(false); + return ( + + + + + + + + Invite Member + + + Invite a member to your organization. + + +
+ + setEmail(e.target.value)} /> + + +
+ + + + + +
+
+ ) +} \ No newline at end of file diff --git a/demo/nextjs/app/dashboard/page.tsx b/demo/nextjs/app/dashboard/page.tsx new file mode 100644 index 00000000..86d3a187 --- /dev/null +++ b/demo/nextjs/app/dashboard/page.tsx @@ -0,0 +1,28 @@ +import { auth } from "@/lib/auth" +import { headers } from "next/headers" +import { redirect } from "next/navigation" +import UserCard from "./user-card" +import { OrganizationCard } from "./organization-card" + + +export default async function DashboardPage() { + const [session, activeSessions] = await Promise.all([ + await auth.api.getSession({ + headers: headers() + }), + await auth.api.listSessions({ + headers: headers() + }) + ]) + if (!session) { + throw redirect("/sign-in") + } + return ( +
+
+ + +
+
+ ) +} \ No newline at end of file diff --git a/demo/nextjs/app/dashboard/user-card.tsx b/demo/nextjs/app/dashboard/user-card.tsx new file mode 100644 index 00000000..d4849773 --- /dev/null +++ b/demo/nextjs/app/dashboard/user-card.tsx @@ -0,0 +1,531 @@ +"use client"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/ui/password-input"; +import { client, signOut, user, useSession } from "@/lib/auth-client"; +import { Session } from "@/lib/auth-types"; +import { MobileIcon } from "@radix-ui/react-icons"; +import { Edit, Fingerprint, Laptop, Loader2, LogOut, Plus, Trash, X } from "lucide-react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { UAParser } from "ua-parser-js"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + + +export default function UserCard(props: { + session: Session | null; + activeSessions: Session["session"][] +}) { + const router = useRouter(); + const { data, isPending, error } = useSession(props.session); + const [ua, setUa] = useState() + + const session = data || props.session + + useEffect(() => { + setUa(new UAParser(session?.session.userAgent)) + }, [session?.session.userAgent]) + + return ( + + + User + + +
+
+ + + {session?.user.name.charAt(0)} + +
+

+ {session?.user.name} +

+

+ {session?.user.email} +

+
+
+ +
+
+

+ Active Sessions +

+ { + props.activeSessions.filter((session) => session.userAgent).map((session) => { + return ( +
+ { + new UAParser(session.userAgent).getDevice().type === "mobile" ? : + } + {new UAParser(session.userAgent).getOS().name}, {new UAParser(session.userAgent).getBrowser().name} +
+ ) + }) + } +
+
+
+

+ Passkeys +

+
+ + +
+
+
+

+ 2FA Authentication +

+ {session?.user.twoFactorEnabled ? ( + + ) : ( + + )} +
+
+
+ + + + +
+ ); +} + + + +async function convertImageToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + + +function ChangePassword() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const [signOutDevices, setSignOutDevices] = useState(false); + return ( + + + + + + + + Change Password + + + Change your password + + +
+ + setCurrentPassword(e.target.value)} + autoComplete="new-password" + placeholder="Password" /> + + setNewPassword(e.target.value)} + autoComplete="new-password" + placeholder="New Password" /> + + setConfirmPassword(e.target.value)} + autoComplete="new-password" + placeholder="Confirm Password" /> +
+ checked ? setSignOutDevices(true) : setSignOutDevices(false)} /> +

+ Sign out from other devices +

+
+
+ + + +
+
+ ) +} + + +function EditUserDialog(props: { session: Session | null }) { + const [name, setName] = useState(); + const router = useRouter() + const [image, setImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setImage(file); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + const [open, setOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + return ( + + + + + + + + Edit User + + + Edit user information + + +
+ + { + setName(e.target.value) + }} + /> +
+ +
+ {imagePreview && ( +
+ Profile preview +
+ )} +
+ + {imagePreview && { + setImage(null); + setImagePreview(null); + }} />} +
+
+
+
+ + + +
+
+ ) +} + +function AddPasskey() { + const [isOpen, setIsOpen] = useState(false); + const [passkeyName, setPasskeyName] = useState(""); + const queryClient = useQueryClient() + const [isLoading, setIsLoading] = useState(false); + + const handleAddPasskey = async () => { + if (!passkeyName) { + toast.error("Passkey name is required"); + return; + } + setIsLoading(true); + const res = await client.passkey.addPasskey({ + name: passkeyName, + }); + if (res?.error) { + toast.error(res?.error.message); + } else { + setIsOpen(false); + toast.success("Passkey added successfully. You can now use it to login."); + } + setIsLoading(false); + }; + return ( + + + + + + + Add New Passkey + + Create a new passkey to securely access your account without a + password. + + +
+ + setPasskeyName(e.target.value)} /> +
+ + + +
+
+ ); +} + + +function ListPasskeys() { + const { data, error } = client.useListPasskeys() + const [isOpen, setIsOpen] = useState(false); + const [passkeyName, setPasskeyName] = useState(""); + + const handleAddPasskey = async () => { + if (!passkeyName) { + toast.error("Passkey name is required"); + return; + } + setIsLoading(true); + const res = await client.passkey.addPasskey({ + name: passkeyName, + }); + setIsLoading(false); + if (res?.error) { + toast.error(res?.error.message); + } else { + toast.success("Passkey added successfully. You can now use it to login."); + } + }; + const [isLoading, setIsLoading] = useState(false); + const [isDeletePasskey, setIsDeletePasskey] = useState(false); + return ( + + + + + + + Passkeys + + List of passkeys + + + {data?.length ? ( + + + + Name + + + + {data.map((passkey) => ( + + {passkey.name || "My Passkey"} + + + + + ))} + +
+ ) : ( +

+ No passkeys found +

+ )} + { + !data?.length && ( +
+ +
+ + setPasskeyName(e.target.value)} placeholder="My Passkey" /> +
+ +
+ ) + } + + + +
+
+ ) +} \ No newline at end of file diff --git a/demo/nextjs/app/favicon.ico b/demo/nextjs/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/demo/nextjs/app/favicon.ico differ diff --git a/demo/nextjs/app/features.tsx b/demo/nextjs/app/features.tsx new file mode 100644 index 00000000..baae5bb6 --- /dev/null +++ b/demo/nextjs/app/features.tsx @@ -0,0 +1,99 @@ +"use client"; +import React from "react"; + +import { AnimatePresence, motion } from "framer-motion"; +import { Logo } from "@/components/logo"; + +export function Features() { + return ( + <> +
+ }> + +
+ + ); +} + +const Card = ({ + title, + icon, + children, +}: { + title: string; + icon: React.ReactNode; + children?: React.ReactNode; +}) => { + const [hovered, setHovered] = React.useState(false); + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + className="border border-black/[0.2] group/canvas-card flex items-center justify-center dark:border-white/[0.2] max-w-sm w-full mx-auto p-4 relative h-[18rem]" + > + + + + + + + {hovered && ( + + {children} + + )} + + +
+
+ {icon} +
+

+ {title} +

+
+
+ ); +}; + +const AceternityIcon = () => { + return ( + + + + ); +}; + +export const Icon = ({ className, ...rest }: any) => { + return ( + + + + ); +}; diff --git a/demo/nextjs/app/fonts/GeistMonoVF.woff b/demo/nextjs/app/fonts/GeistMonoVF.woff new file mode 100644 index 00000000..f2ae185c Binary files /dev/null and b/demo/nextjs/app/fonts/GeistMonoVF.woff differ diff --git a/demo/nextjs/app/fonts/GeistVF.woff b/demo/nextjs/app/fonts/GeistVF.woff new file mode 100644 index 00000000..1b62daac Binary files /dev/null and b/demo/nextjs/app/fonts/GeistVF.woff differ diff --git a/demo/nextjs/app/globals.css b/demo/nextjs/app/globals.css new file mode 100644 index 00000000..d013214c --- /dev/null +++ b/demo/nextjs/app/globals.css @@ -0,0 +1,81 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 20 14.3% 4.1%; + --card: 0 0% 100%; + --card-foreground: 20 14.3% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 20 14.3% 4.1%; + --primary: 24 9.8% 10%; + --primary-foreground: 60 9.1% 97.8%; + --secondary: 60 4.8% 95.9%; + --secondary-foreground: 24 9.8% 10%; + --muted: 60 4.8% 95.9%; + --muted-foreground: 25 5.3% 44.7%; + --accent: 60 4.8% 95.9%; + --accent-foreground: 24 9.8% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 20 5.9% 90%; + --input: 20 5.9% 90%; + --ring: 20 14.3% 4.1%; + --radius: 0.3rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + .dark { + --background: 20 14.3% 4.1%; + --foreground: 60 9.1% 97.8%; + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + --primary: 60 9.1% 97.8%; + --primary-foreground: 24 9.8% 10%; + --secondary: 12 6.5% 15.1%; + --secondary-foreground: 60 9.1% 97.8%; + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 60 9.1% 97.8%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 12 6.5% 15.1%; + --input: 12 6.5% 15.1%; + --ring: 24 5.7% 82.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + + +.no-visible-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; + -webkit-overflow-scrolling: touch; +} + +.no-visible-scrollbar::-webkit-scrollbar { + display: none; +} \ No newline at end of file diff --git a/demo/nextjs/app/layout.tsx b/demo/nextjs/app/layout.tsx new file mode 100644 index 00000000..a36422ab --- /dev/null +++ b/demo/nextjs/app/layout.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from "next"; +import localFont from "next/font/local"; +import "./globals.css"; +import { Toaster } from "@/components/ui/sonner"; +import { ThemeProvider } from "@/components/theme-provider"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { Logo } from "@/components/logo"; +import Link from "next/link"; +import { Book } from "lucide-react"; +import { Wrapper, WrapperWithQuery } from "@/components/wrapper"; + +const geistSans = localFont({ + src: "./fonts/GeistVF.woff", + variable: "--font-geist-sans", + weight: "100 900", +}); +const geistMono = localFont({ + src: "./fonts/GeistMonoVF.woff", + variable: "--font-geist-mono", + weight: "100 900", +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + + + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + {children} + + + + + + + ); +} diff --git a/demo/nextjs/app/page.tsx b/demo/nextjs/app/page.tsx new file mode 100644 index 00000000..44cd7641 --- /dev/null +++ b/demo/nextjs/app/page.tsx @@ -0,0 +1,50 @@ +import { Button } from "@/components/ui/button"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; +import Link from "next/link"; + +export default async function Home() { + const session = await auth.api.getSession({ + headers: headers() + }) + + return ( +
+
+
+

+ Better Auth. +

+

+ Official demo to showcase better-auth. features and capabilities.
+ + * All auth related features implemented on this demo are natively supported by better-auth. (btw) + +

+
+ +
+ { + session ? ( +
+ + + +
+ ) : ( + + + + ) + } +
+
+
+ ); +} diff --git a/demo/nextjs/components.json b/demo/nextjs/components.json new file mode 100644 index 00000000..bcec1f91 --- /dev/null +++ b/demo/nextjs/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/demo/nextjs/components/logo.tsx b/demo/nextjs/components/logo.tsx new file mode 100644 index 00000000..d4c0acef --- /dev/null +++ b/demo/nextjs/components/logo.tsx @@ -0,0 +1,24 @@ +import { SVGProps } from "react"; + +export const Logo = (props: SVGProps) => { + return ( + + + + + + + + + ); +}; diff --git a/demo/nextjs/components/sign-in.tsx b/demo/nextjs/components/sign-in.tsx new file mode 100644 index 00000000..9412a7ab --- /dev/null +++ b/demo/nextjs/components/sign-in.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +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 { GitHubLogoIcon } from "@radix-ui/react-icons"; +import { Key, Loader2 } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; + +export default function SignIn() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [rememberMe, setRememberMe] = useState(false); + const router = useRouter(); + const [loading, setLoading] = useState(false) + return ( + + + Sign In + + Enter your email below to login to your account + + + +
+
+ + { + setEmail(e.target.value); + }} + value={email} + /> +
+
+
+ + + Forgot your password? + +
+ setPassword(e.target.value)} + autoComplete="password" + placeholder="Password" + /> +
+
+ { + setRememberMe(!rememberMe); + }} + /> + +
+ + + + + +
+
+ +
+

+ Secured by better-auth. +

+
+
+
+ ); +} diff --git a/demo/nextjs/components/sign-up.tsx b/demo/nextjs/components/sign-up.tsx new file mode 100644 index 00000000..6793d670 --- /dev/null +++ b/demo/nextjs/components/sign-up.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/ui/password-input"; +import { GitHubLogoIcon } from "@radix-ui/react-icons"; +import { useState } from "react"; +import { signUp } from "@/lib/auth-client"; +import Image from "next/image"; +import { Loader2, X } from "lucide-react"; +import { toast } from "sonner"; + +export function SignUp() { + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirmation, setPasswordConfirmation] = useState(""); + const [image, setImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setImage(file); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + const [loading, setLoading] = useState(false) + + + return ( + + + Sign Up + + Enter your information to create an account + + + +
+
+
+ + { + setFirstName(e.target.value); + }} + value={firstName} + /> +
+
+ + { + setLastName(e.target.value); + }} + value={lastName} + /> +
+
+
+ + { + setEmail(e.target.value); + }} + value={email} + /> +
+
+ + setPassword(e.target.value)} + autoComplete="new-password" + placeholder="Password" + /> +
+
+ + setPasswordConfirmation(e.target.value)} + autoComplete="new-password" + placeholder="Confirm Password" + /> +
+
+ +
+ {imagePreview && ( +
+ Profile preview +
+ )} +
+ + {imagePreview && { + setImage(null); + setImagePreview(null); + }} />} +
+
+
+ + + +
+
+ +
+

+ Secured by better-auth. +

+
+
+
+ ); +} + + +async function convertImageToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} \ No newline at end of file diff --git a/demo/nextjs/components/theme-provider.tsx b/demo/nextjs/components/theme-provider.tsx new file mode 100644 index 00000000..b0ff2660 --- /dev/null +++ b/demo/nextjs/components/theme-provider.tsx @@ -0,0 +1,9 @@ +"use client"; + +import * as React from "react"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import { type ThemeProviderProps } from "next-themes/dist/types"; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/demo/nextjs/components/theme-toggle.tsx b/demo/nextjs/components/theme-toggle.tsx new file mode 100644 index 00000000..033d2a74 --- /dev/null +++ b/demo/nextjs/components/theme-toggle.tsx @@ -0,0 +1,23 @@ +"use client"; + +import * as React from "react"; +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; + +import { Button } from "@/components/ui/button"; + +export function ThemeToggle() { + const { setTheme, theme } = useTheme(); + + return ( + + ); +} diff --git a/demo/nextjs/components/ui/accordion.tsx b/demo/nextjs/components/ui/accordion.tsx new file mode 100644 index 00000000..8dcf9b6f --- /dev/null +++ b/demo/nextjs/components/ui/accordion.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/demo/nextjs/components/ui/alert-dialog.tsx b/demo/nextjs/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..57760f2e --- /dev/null +++ b/demo/nextjs/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/demo/nextjs/components/ui/alert.tsx b/demo/nextjs/components/ui/alert.tsx new file mode 100644 index 00000000..5afd41d1 --- /dev/null +++ b/demo/nextjs/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/demo/nextjs/components/ui/aspect-ratio.tsx b/demo/nextjs/components/ui/aspect-ratio.tsx new file mode 100644 index 00000000..d6a5226f --- /dev/null +++ b/demo/nextjs/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/demo/nextjs/components/ui/avatar.tsx b/demo/nextjs/components/ui/avatar.tsx new file mode 100644 index 00000000..51e507ba --- /dev/null +++ b/demo/nextjs/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/demo/nextjs/components/ui/badge.tsx b/demo/nextjs/components/ui/badge.tsx new file mode 100644 index 00000000..e87d62bf --- /dev/null +++ b/demo/nextjs/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/demo/nextjs/components/ui/breadcrumb.tsx b/demo/nextjs/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..26560efc --- /dev/null +++ b/demo/nextjs/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" +import { Slot } from "@radix-ui/react-slot" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>