├── .prettierrc
├── src
├── app
│ ├── favicon.ico
│ ├── api
│ │ └── auth
│ │ │ └── [...all]
│ │ │ └── route.ts
│ ├── loading.tsx
│ ├── (main)
│ │ ├── layout.tsx
│ │ ├── admin
│ │ │ ├── loading.tsx
│ │ │ ├── actions.ts
│ │ │ ├── page.tsx
│ │ │ └── delete-application.tsx
│ │ ├── email-verified
│ │ │ └── page.tsx
│ │ ├── profile
│ │ │ ├── logout-everywhere-button.tsx
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx
│ │ │ ├── email-form.tsx
│ │ │ ├── password-form.tsx
│ │ │ └── profile-details-form.tsx
│ │ ├── verify-email
│ │ │ ├── page.tsx
│ │ │ └── resend-verification-button.tsx
│ │ ├── navbar.tsx
│ │ └── dashboard
│ │ │ ├── loading.tsx
│ │ │ └── page.tsx
│ ├── (auth)
│ │ ├── sign-in
│ │ │ ├── page.tsx
│ │ │ └── sign-in-form.tsx
│ │ ├── sign-up
│ │ │ ├── page.tsx
│ │ │ └── sign-up-form.tsx
│ │ ├── layout.tsx
│ │ ├── forgot-password
│ │ │ ├── page.tsx
│ │ │ └── forgot-password-form.tsx
│ │ └── reset-password
│ │ │ ├── page.tsx
│ │ │ └── reset-password-form.tsx
│ ├── forbidden.tsx
│ ├── unauthorized.tsx
│ ├── layout.tsx
│ ├── page.tsx
│ └── globals.css
├── assets
│ ├── better_auth_logo.png
│ └── coding_in_flow_logo.jpg
├── lib
│ ├── utils.ts
│ ├── get-session.ts
│ ├── validation.ts
│ ├── auth-client.ts
│ ├── prisma.ts
│ ├── email.ts
│ └── auth.ts
└── components
│ ├── ui
│ ├── skeleton.tsx
│ ├── sonner.tsx
│ ├── label.tsx
│ ├── input.tsx
│ ├── avatar.tsx
│ ├── checkbox.tsx
│ ├── badge.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── form.tsx
│ └── dropdown-menu.tsx
│ ├── loading-button.tsx
│ ├── user-avatar.tsx
│ ├── icons
│ ├── GitHubIcon.tsx
│ └── GoogleIcon.tsx
│ ├── password-input.tsx
│ ├── mode-toggle.tsx
│ └── user-dropdown.tsx
├── postcss.config.mjs
├── components.json
├── next.config.ts
├── README.md
├── .gitignore
├── tsconfig.json
├── eslint.config.mjs
├── package.json
└── prisma
└── schema.prisma
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-tailwindcss"]
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/better-auth-tutorial/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/src/assets/better_auth_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/better-auth-tutorial/HEAD/src/assets/better_auth_logo.png
--------------------------------------------------------------------------------
/src/assets/coding_in_flow_logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/better-auth-tutorial/HEAD/src/assets/coding_in_flow_logo.jpg
--------------------------------------------------------------------------------
/src/app/api/auth/[...all]/route.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@/lib/auth";
2 | import { toNextJsHandler } from "better-auth/next-js";
3 |
4 | export const { POST, GET } = toNextJsHandler(auth);
5 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/app/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Loader2 } from "lucide-react";
2 |
3 | export default function Loading() {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/get-session.ts:
--------------------------------------------------------------------------------
1 | import { headers } from "next/headers";
2 | import { cache } from "react";
3 | import { auth } from "./auth";
4 |
5 | export const getServerSession = cache(async () => {
6 | console.log("getServerSession");
7 | return await auth.api.getSession({ headers: await headers() });
8 | });
9 |
--------------------------------------------------------------------------------
/src/app/(main)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Navbar } from "./navbar";
2 |
3 | export default async function MainLayout({
4 | children,
5 | }: Readonly<{ children: React.ReactNode }>) {
6 | return (
7 |
8 |
9 | {children}
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/validation.ts:
--------------------------------------------------------------------------------
1 | import z from "zod";
2 |
3 | export const passwordSchema = z
4 | .string()
5 | .min(1, { message: "Password is required" })
6 | .min(8, { message: "Password must be at least 8 characters" })
7 | .regex(/[^A-Za-z0-9]/, {
8 | message: "Password must contain at least one special character",
9 | });
10 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 | return (
5 |
10 | )
11 | }
12 |
13 | export { Skeleton }
14 |
--------------------------------------------------------------------------------
/src/lib/auth-client.ts:
--------------------------------------------------------------------------------
1 | import { inferAdditionalFields } from "better-auth/client/plugins";
2 | import { nextCookies } from "better-auth/next-js";
3 | import { createAuthClient } from "better-auth/react";
4 | import { auth } from "./auth";
5 |
6 | export const authClient = createAuthClient({
7 | plugins: [inferAdditionalFields(), nextCookies()],
8 | });
9 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { SignInForm } from "./sign-in-form";
3 |
4 | export const metadata: Metadata = {
5 | title: "Sign in",
6 | };
7 |
8 | export default function SignIn() {
9 | return (
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { SignUpForm } from "./sign-up-form";
3 |
4 | export const metadata: Metadata = {
5 | title: "Sign up",
6 | };
7 |
8 | export default function SignUp() {
9 | return (
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/(main)/admin/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 |
3 | export default function AdminLoading() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { getServerSession } from "@/lib/get-session";
2 | import { redirect } from "next/navigation";
3 | import { ReactNode } from "react";
4 |
5 | export default async function AuthLayout({
6 | children,
7 | }: {
8 | children: ReactNode;
9 | }) {
10 | const session = await getServerSession();
11 | const user = session?.user;
12 |
13 | if (user) redirect("/dashboard");
14 |
15 | return children;
16 | }
17 |
--------------------------------------------------------------------------------
/src/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@/generated/prisma";
2 | import { withAccelerate } from "@prisma/extension-accelerate";
3 |
4 | const globalForPrisma = global as unknown as {
5 | prisma: PrismaClient;
6 | };
7 |
8 | const prisma =
9 | globalForPrisma.prisma || new PrismaClient().$extends(withAccelerate());
10 |
11 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
12 |
13 | export default prisma;
14 |
--------------------------------------------------------------------------------
/src/lib/email.ts:
--------------------------------------------------------------------------------
1 | import { Resend } from "resend";
2 |
3 | const resend = new Resend(process.env.RESEND_API_KEY);
4 |
5 | interface SendEmailValues {
6 | to: string;
7 | subject: string;
8 | text: string;
9 | }
10 |
11 | export async function sendEmail({ to, subject, text }: SendEmailValues) {
12 | await resend.emails.send({
13 | from: "verification@codinginflow-sample.com",
14 | to,
15 | subject,
16 | text,
17 | });
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/(main)/admin/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { getServerSession } from "@/lib/get-session";
4 | import { forbidden, unauthorized } from "next/navigation";
5 | import { setTimeout } from "node:timers/promises";
6 |
7 | export async function deleteApplication() {
8 | const session = await getServerSession();
9 | const user = session?.user;
10 |
11 | if (!user) unauthorized();
12 |
13 | if (user.role !== "admin") forbidden();
14 |
15 | // Delete app...
16 |
17 | await setTimeout(800);
18 | }
19 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | experimental: {
5 | authInterrupts: true,
6 | },
7 | eslint: {
8 | ignoreDuringBuilds: true,
9 | },
10 | images: {
11 | remotePatterns: [
12 | {
13 | protocol: "https",
14 | hostname: "lh3.googleusercontent.com",
15 | },
16 | {
17 | protocol: "https",
18 | hostname: "avatars.githubusercontent.com",
19 | },
20 | ],
21 | },
22 | };
23 |
24 | export default nextConfig;
25 |
--------------------------------------------------------------------------------
/src/components/loading-button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Loader2 } from "lucide-react";
5 |
6 | interface LoadingButtonProps extends React.ComponentProps {
7 | loading: boolean;
8 | }
9 |
10 | export function LoadingButton({
11 | loading,
12 | disabled,
13 | children,
14 | ...props
15 | }: LoadingButtonProps) {
16 | return (
17 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Better-Auth With Next.js Tutorial
2 |
3 | Learn how to **implement Better-Auth with Next.js 15 and Prisma Postgres** in this YouTube tutorial: https://www.youtube.com/watch?v=w5Emwt3nuV0
4 |
5 | Including:
6 |
7 | - Auth client & database setup
8 | - Email & password login
9 | - Transactional emails with Resend
10 | - OAuth (Google & GitHub)
11 | - Admin role & authorization
12 | - Hooks and custom password validation with Zod
13 | - Updating user profile data
14 | - SSR and caching
15 | - Deployment to Vercel
16 | - and more
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner, ToasterProps } from "sonner"
5 |
6 | const Toaster = ({ ...props }: ToasterProps) => {
7 | const { theme = "system" } = useTheme()
8 |
9 | return (
10 |
22 | )
23 | }
24 |
25 | export { Toaster }
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
43 | /src/generated/prisma
44 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Label({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { FlatCompat } from "@eslint/eslintrc";
2 | import eslintConfigPrettier from "eslint-config-prettier/flat";
3 | import { dirname } from "path";
4 | import { fileURLToPath } from "url";
5 |
6 | const __filename = fileURLToPath(import.meta.url);
7 | const __dirname = dirname(__filename);
8 |
9 | const compat = new FlatCompat({
10 | baseDirectory: __dirname,
11 | });
12 |
13 | const eslintConfig = [
14 | ...compat.extends("next/core-web-vitals", "next/typescript"),
15 | eslintConfigPrettier,
16 | {
17 | ignores: [
18 | "node_modules/**",
19 | ".next/**",
20 | "out/**",
21 | "build/**",
22 | "next-env.d.ts",
23 | ],
24 | },
25 | ];
26 |
27 | export default eslintConfig;
28 |
--------------------------------------------------------------------------------
/src/app/forbidden.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import Link from "next/link";
3 |
4 | export default function ForbiddenPage() {
5 | return (
6 |
7 |
8 |
9 |
403 - Forbidden
10 |
11 | You don't have access to this page.
12 |
13 |
14 |
15 |
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/(auth)/forgot-password/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { ForgotPasswordForm } from "./forgot-password-form";
3 |
4 | export const metadata: Metadata = {
5 | title: "Forgot password",
6 | };
7 |
8 | export default function ForgotPasswordPage() {
9 | return (
10 |
11 |
12 |
13 |
Forgot password
14 |
15 | Enter your email address and we'll send you a link to reset
16 | your password.
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/unauthorized.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import Link from "next/link";
5 | import { usePathname } from "next/navigation";
6 |
7 | export default function UnauthorizedPage() {
8 | const pathname = usePathname();
9 |
10 | return (
11 |
12 |
13 |
14 |
401 - Unauthorized
15 |
Please sign in to continue.
16 |
17 |
18 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/(main)/email-verified/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import type { Metadata } from "next";
3 | import Link from "next/link";
4 |
5 | export const metadata: Metadata = {
6 | title: "Email Verified",
7 | };
8 |
9 | export default function EmailVerifiedPage() {
10 | return (
11 |
12 |
13 |
14 |
Email verified
15 |
16 | Your email has been verified successfully.
17 |
18 |
19 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/user-avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { ComponentProps } from "react";
4 |
5 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
6 | import { cn } from "@/lib/utils";
7 |
8 | export interface UserAvatarProps extends ComponentProps {
9 | name: string;
10 | image: string | null | undefined;
11 | }
12 |
13 | export function UserAvatar({
14 | name,
15 | image,
16 | className,
17 | ...props
18 | }: UserAvatarProps) {
19 | const initials = name
20 | .split(" ")
21 | .filter(Boolean)
22 | .map((part) => part[0])
23 | .join("");
24 |
25 | return (
26 |
27 |
32 | {initials}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/icons/GitHubIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from "react";
2 |
3 | export function GitHubIcon(props: SVGProps) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/(main)/admin/page.tsx:
--------------------------------------------------------------------------------
1 | import { getServerSession } from "@/lib/get-session";
2 | import type { Metadata } from "next";
3 | import { forbidden, unauthorized } from "next/navigation";
4 | import { DeleteApplication } from "./delete-application";
5 |
6 | export const metadata: Metadata = {
7 | title: "Admin",
8 | };
9 |
10 | export default async function AdminPage() {
11 | const session = await getServerSession();
12 | const user = session?.user;
13 |
14 | if (!user) unauthorized();
15 |
16 | if (user.role !== "admin") forbidden();
17 |
18 | return (
19 |
20 |
21 |
22 |
Admin
23 |
24 | You have administrator access.
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export { Input }
22 |
--------------------------------------------------------------------------------
/src/app/(main)/profile/logout-everywhere-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LoadingButton } from "@/components/loading-button";
4 | import { authClient } from "@/lib/auth-client";
5 | import { useRouter } from "next/navigation";
6 | import { useState } from "react";
7 | import { toast } from "sonner";
8 |
9 | export function LogoutEverywhereButton() {
10 | const [loading, setLoading] = useState(false);
11 |
12 | const router = useRouter();
13 |
14 | async function handleLogoutEverywhere() {
15 | setLoading(true);
16 | const { error } = await authClient.revokeSessions();
17 | setLoading(false);
18 |
19 | if (error) {
20 | toast.error(error.message || "Failed to log out everywhere");
21 | } else {
22 | toast.success("Logged out from all devices");
23 | router.push("/sign-in");
24 | }
25 | }
26 |
27 | return (
28 |
34 | Log out everywhere
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/(main)/verify-email/page.tsx:
--------------------------------------------------------------------------------
1 | import { getServerSession } from "@/lib/get-session";
2 | import type { Metadata } from "next";
3 | import { redirect, unauthorized } from "next/navigation";
4 | import { ResendVerificationButton } from "./resend-verification-button";
5 |
6 | export const metadata: Metadata = {
7 | title: "Verify Email",
8 | };
9 |
10 | export default async function VerifyEmailPage() {
11 | const session = await getServerSession();
12 | const user = session?.user;
13 |
14 | if (!user) unauthorized();
15 |
16 | if (user.emailVerified) redirect("/dashboard");
17 |
18 | return (
19 |
20 |
21 |
22 |
Verify your email
23 |
24 | A verification email was sent to your inbox.
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/password-input.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from "@/components/ui/input";
2 | import { cn } from "@/lib/utils";
3 | import { EyeIcon, EyeOffIcon } from "lucide-react";
4 | import { useState } from "react";
5 |
6 | export function PasswordInput({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | const [showPassword, setShowPassword] = useState(false);
11 |
12 | return (
13 |
14 |
19 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster } from "@/components/ui/sonner";
2 | import type { Metadata } from "next";
3 | import { ThemeProvider } from "next-themes";
4 | import { Outfit } from "next/font/google";
5 | import "./globals.css";
6 |
7 | const outfit = Outfit({
8 | variable: "--font-outfit",
9 | subsets: ["latin"],
10 | });
11 |
12 | export const metadata: Metadata = {
13 | title: {
14 | template: "%s | Better-Auth Tutorial",
15 | absolute: "Better-Auth Tutorial by Coding in Flow",
16 | },
17 | description:
18 | "Learn how to handle authentication in Next.js using Better-Auth with this tutorial by Coding in Flow",
19 | };
20 |
21 | export default function RootLayout({
22 | children,
23 | }: Readonly<{
24 | children: React.ReactNode;
25 | }>) {
26 | return (
27 |
28 |
29 |
35 | {children}
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/(main)/navbar.tsx:
--------------------------------------------------------------------------------
1 | import codingInFlowLogo from "@/assets/coding_in_flow_logo.jpg";
2 | import { ModeToggle } from "@/components/mode-toggle";
3 | import { UserDropdown } from "@/components/user-dropdown";
4 | import { getServerSession } from "@/lib/get-session";
5 | import Image from "next/image";
6 | import Link from "next/link";
7 |
8 | export async function Navbar() {
9 | const session = await getServerSession();
10 | const user = session?.user;
11 |
12 | if (!user) return null;
13 |
14 | return (
15 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/(auth)/reset-password/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { ResetPasswordForm } from "./reset-password-form";
3 |
4 | export const metadata: Metadata = {
5 | title: "Reset password",
6 | };
7 |
8 | interface ResetPasswordPageProps {
9 | searchParams: Promise<{ token: string }>;
10 | }
11 |
12 | export default async function ResetPasswordPage({
13 | searchParams,
14 | }: ResetPasswordPageProps) {
15 | const { token } = await searchParams;
16 |
17 | return (
18 |
19 | {token ? (
20 |
21 | ) : (
22 |
23 | Token is missing.
24 |
25 | )}
26 |
27 | );
28 | }
29 |
30 | interface ResetPasswordUIProps {
31 | token: string;
32 | }
33 |
34 | function ResetPasswordUI({ token }: ResetPasswordUIProps) {
35 | return (
36 |
37 |
38 |
Reset password
39 |
Enter your new password below.
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Avatar({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | function AvatarImage({
25 | className,
26 | ...props
27 | }: React.ComponentProps) {
28 | return (
29 |
34 | )
35 | }
36 |
37 | function AvatarFallback({
38 | className,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
50 | )
51 | }
52 |
53 | export { Avatar, AvatarImage, AvatarFallback }
54 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5 | import { CheckIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Checkbox({
10 | className,
11 | ...props
12 | }: React.ComponentProps) {
13 | return (
14 |
22 |
26 |
27 |
28 |
29 | )
30 | }
31 |
32 | export { Checkbox }
33 |
--------------------------------------------------------------------------------
/src/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | DropdownMenu,
6 | DropdownMenuContent,
7 | DropdownMenuItem,
8 | DropdownMenuTrigger,
9 | } from "@/components/ui/dropdown-menu";
10 | import { Moon, Sun } from "lucide-react";
11 | import { useTheme } from "next-themes";
12 |
13 | export function ModeToggle() {
14 | const { setTheme } = useTheme();
15 |
16 | return (
17 |
18 |
19 |
24 |
25 |
26 | setTheme("light")}>
27 | Light
28 |
29 | setTheme("dark")}>
30 | Dark
31 |
32 | setTheme("system")}>
33 | System
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/icons/GoogleIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from "react";
2 |
3 | export function GoogleIcon(props: SVGProps) {
4 | return (
5 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/(main)/profile/page.tsx:
--------------------------------------------------------------------------------
1 | import { getServerSession } from "@/lib/get-session";
2 | import type { Metadata } from "next";
3 | import { unauthorized } from "next/navigation";
4 | import { EmailForm } from "./email-form";
5 | import { LogoutEverywhereButton } from "./logout-everywhere-button";
6 | import { PasswordForm } from "./password-form";
7 | import { ProfileDetailsForm } from "./profile-details-form";
8 |
9 | export const metadata: Metadata = {
10 | title: "Profile",
11 | };
12 |
13 | export default async function ProfilePage() {
14 | const session = await getServerSession();
15 | const user = session?.user;
16 |
17 | if (!user) unauthorized();
18 |
19 | return (
20 |
21 |
22 |
23 |
Profile
24 |
25 | Update your account details, email, and password.
26 |
27 |
28 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/(main)/admin/delete-application.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LoadingButton } from "@/components/loading-button";
4 | import { useTransition } from "react";
5 | import { toast } from "sonner";
6 | import { deleteApplication } from "./actions";
7 |
8 | export function DeleteApplication() {
9 | const [isPending, startTransition] = useTransition();
10 |
11 | async function handleDeleteApplication() {
12 | startTransition(async () => {
13 | try {
14 | await deleteApplication();
15 | toast.success("Application deletion authorized successfully");
16 | } catch (error) {
17 | console.error(error);
18 | toast.error("Something went wrong");
19 | }
20 | });
21 | }
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
Delete Application
29 |
30 | This action will delete the entire application. This cannot be
31 | undone.
32 |
33 |
34 |
40 | Delete Application
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "better-auth-tutorial",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build --turbopack",
8 | "start": "next start",
9 | "lint": "eslint",
10 | "postinstall": "prisma generate"
11 | },
12 | "dependencies": {
13 | "@hookform/resolvers": "^5.2.1",
14 | "@prisma/client": "^6.14.0",
15 | "@prisma/extension-accelerate": "^2.0.2",
16 | "@radix-ui/react-avatar": "^1.1.10",
17 | "@radix-ui/react-checkbox": "^1.3.3",
18 | "@radix-ui/react-dropdown-menu": "^2.1.16",
19 | "@radix-ui/react-label": "^2.1.7",
20 | "@radix-ui/react-slot": "^1.2.3",
21 | "better-auth": "^1.3.7",
22 | "class-variance-authority": "^0.7.1",
23 | "clsx": "^2.1.1",
24 | "date-fns": "^4.1.0",
25 | "lucide-react": "^0.541.0",
26 | "next": "15.5.0",
27 | "next-themes": "^0.4.6",
28 | "react": "19.1.0",
29 | "react-dom": "19.1.0",
30 | "react-hook-form": "^7.62.0",
31 | "resend": "^6.0.1",
32 | "sonner": "^2.0.7",
33 | "tailwind-merge": "^3.3.1",
34 | "zod": "^4.1.0"
35 | },
36 | "devDependencies": {
37 | "@eslint/eslintrc": "^3",
38 | "@tailwindcss/postcss": "^4",
39 | "@types/node": "^20",
40 | "@types/react": "^19",
41 | "@types/react-dom": "^19",
42 | "eslint": "^9",
43 | "eslint-config-next": "15.5.0",
44 | "eslint-config-prettier": "^10.1.8",
45 | "prettier": "^3.6.2",
46 | "prettier-plugin-tailwindcss": "^0.6.14",
47 | "prisma": "^6.14.0",
48 | "tailwindcss": "^4",
49 | "tw-animate-css": "^1.3.7",
50 | "typescript": "^5"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/app/(main)/verify-email/resend-verification-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LoadingButton } from "@/components/loading-button";
4 | import { authClient } from "@/lib/auth-client";
5 | import { useState } from "react";
6 |
7 | interface ResendVerificationButtonProps {
8 | email: string;
9 | }
10 |
11 | export function ResendVerificationButton({
12 | email,
13 | }: ResendVerificationButtonProps) {
14 | const [isLoading, setIsLoading] = useState(false);
15 | const [success, setSuccess] = useState(null);
16 | const [error, setError] = useState(null);
17 |
18 | async function resendVerificationEmail() {
19 | setSuccess(null);
20 | setError(null);
21 | setIsLoading(true);
22 |
23 | const { error } = await authClient.sendVerificationEmail({
24 | email,
25 | callbackURL: "/email-verified",
26 | });
27 |
28 | setIsLoading(false);
29 |
30 | if (error) {
31 | setError(error.message || "Something went wrong");
32 | } else {
33 | setSuccess("Verification email sent successfully");
34 | }
35 | }
36 |
37 | return (
38 |
39 | {success && (
40 |
41 | {success}
42 |
43 | )}
44 | {error && (
45 |
46 | {error}
47 |
48 | )}
49 |
50 |
55 | Resend verification email
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const badgeVariants = cva(
8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14 | secondary:
15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
16 | destructive:
17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
18 | outline:
19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20 | },
21 | },
22 | defaultVariants: {
23 | variant: "default",
24 | },
25 | }
26 | )
27 |
28 | function Badge({
29 | className,
30 | variant,
31 | asChild = false,
32 | ...props
33 | }: React.ComponentProps<"span"> &
34 | VariantProps & { asChild?: boolean }) {
35 | const Comp = asChild ? Slot : "span"
36 |
37 | return (
38 |
43 | )
44 | }
45 |
46 | export { Badge, badgeVariants }
47 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 |
3 | export default function DashboardLoading() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | output = "../src/generated/prisma"
10 | }
11 |
12 | datasource db {
13 | provider = "postgresql"
14 | url = env("DATABASE_URL")
15 | }
16 |
17 | model User {
18 | id String @id
19 | name String
20 | email String
21 | emailVerified Boolean
22 | image String?
23 | role String?
24 | createdAt DateTime
25 | updatedAt DateTime
26 | sessions Session[]
27 | accounts Account[]
28 |
29 | @@unique([email])
30 | @@map("user")
31 | }
32 |
33 | model Session {
34 | id String @id
35 | expiresAt DateTime
36 | token String
37 | createdAt DateTime
38 | updatedAt DateTime
39 | ipAddress String?
40 | userAgent String?
41 | userId String
42 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
43 |
44 | @@unique([token])
45 | @@map("session")
46 | }
47 |
48 | model Account {
49 | id String @id
50 | accountId String
51 | providerId String
52 | userId String
53 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
54 | accessToken String?
55 | refreshToken String?
56 | idToken String?
57 | accessTokenExpiresAt DateTime?
58 | refreshTokenExpiresAt DateTime?
59 | scope String?
60 | password String?
61 | createdAt DateTime
62 | updatedAt DateTime
63 |
64 | @@map("account")
65 | }
66 |
67 | model Verification {
68 | id String @id
69 | identifier String
70 | value String
71 | expiresAt DateTime
72 | createdAt DateTime?
73 | updatedAt DateTime?
74 |
75 | @@map("verification")
76 | }
77 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import betterAuthLogo from "@/assets/better_auth_logo.png";
2 | import codingInFlowLogo from "@/assets/coding_in_flow_logo.jpg";
3 | import { Button } from "@/components/ui/button";
4 | import Image from "next/image";
5 | import Link from "next/link";
6 |
7 | export default function Home() {
8 | return (
9 |
10 |
11 |
12 |
19 | +
20 |
27 |
28 |
29 | Better-Auth Tutorial
30 |
31 |
32 | Learn how to handle authentication in Next.js using Better-Auth with
33 | this tutorial by{" "}
34 |
40 | Coding in Flow
41 |
42 |
43 |
44 |
47 |
50 |
51 |
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<"button"> &
45 | VariantProps & {
46 | asChild?: boolean
47 | }) {
48 | const Comp = asChild ? Slot : "button"
49 |
50 | return (
51 |
56 | )
57 | }
58 |
59 | export { Button, buttonVariants }
60 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Card({ className, ...props }: React.ComponentProps<"div">) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19 | return (
20 |
28 | )
29 | }
30 |
31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32 | return (
33 |
38 | )
39 | }
40 |
41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42 | return (
43 |
48 | )
49 | }
50 |
51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52 | return (
53 |
61 | )
62 | }
63 |
64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65 | return (
66 |
71 | )
72 | }
73 |
74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
75 | return (
76 |
81 | )
82 | }
83 |
84 | export {
85 | Card,
86 | CardHeader,
87 | CardFooter,
88 | CardTitle,
89 | CardAction,
90 | CardDescription,
91 | CardContent,
92 | }
93 |
--------------------------------------------------------------------------------
/src/app/(main)/profile/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 |
3 | export default function Loading() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/user-dropdown.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { User } from "@/lib/auth";
4 | import { authClient } from "@/lib/auth-client";
5 | import { LogOutIcon, ShieldIcon, UserIcon } from "lucide-react";
6 | import Image from "next/image";
7 | import Link from "next/link";
8 | import { useRouter } from "next/navigation";
9 | import { toast } from "sonner";
10 | import { Button } from "./ui/button";
11 | import {
12 | DropdownMenu,
13 | DropdownMenuContent,
14 | DropdownMenuItem,
15 | DropdownMenuLabel,
16 | DropdownMenuSeparator,
17 | DropdownMenuTrigger,
18 | } from "./ui/dropdown-menu";
19 |
20 | interface UserDropdownProps {
21 | user: User;
22 | }
23 |
24 | export function UserDropdown({ user }: UserDropdownProps) {
25 | return (
26 |
27 |
28 |
42 |
43 |
44 | {user.email}
45 |
46 |
47 |
48 | Profile
49 |
50 |
51 | {user.role === "admin" && }
52 |
53 |
54 |
55 | );
56 | }
57 |
58 | function AdminItem() {
59 | return (
60 |
61 |
62 | Admin
63 |
64 |
65 | );
66 | }
67 |
68 | function SignOutItem() {
69 | const router = useRouter();
70 |
71 | async function handleSignOut() {
72 | const { error } = await authClient.signOut();
73 | if (error) {
74 | toast.error(error.message || "Something went wrong");
75 | } else {
76 | toast.success("Signed out successfully");
77 | router.push("/sign-in");
78 | }
79 | }
80 |
81 | return (
82 |
83 | Sign out
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import { betterAuth } from "better-auth";
2 | import { prismaAdapter } from "better-auth/adapters/prisma";
3 | import { APIError, createAuthMiddleware } from "better-auth/api";
4 | import { sendEmail } from "./email";
5 | import prisma from "./prisma";
6 | import { passwordSchema } from "./validation";
7 |
8 | export const auth = betterAuth({
9 | database: prismaAdapter(prisma, {
10 | provider: "postgresql",
11 | }),
12 | socialProviders: {
13 | google: {
14 | clientId: process.env.GOOGLE_CLIENT_ID!,
15 | clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
16 | },
17 | github: {
18 | clientId: process.env.GITHUB_CLIENT_ID!,
19 | clientSecret: process.env.GITHUB_CLIENT_SECRET!,
20 | },
21 | },
22 | emailAndPassword: {
23 | enabled: true,
24 | // requireEmailVerification: true, // Only if you want to block login completely
25 | async sendResetPassword({ user, url }) {
26 | await sendEmail({
27 | to: user.email,
28 | subject: "Reset your password",
29 | text: `Click the link to reset your password: ${url}`,
30 | });
31 | },
32 | },
33 | emailVerification: {
34 | sendOnSignUp: true,
35 | autoSignInAfterVerification: true,
36 | async sendVerificationEmail({ user, url }) {
37 | await sendEmail({
38 | to: user.email,
39 | subject: "Verify your email",
40 | text: `Click the link to verify your email: ${url}`,
41 | });
42 | },
43 | },
44 | user: {
45 | changeEmail: {
46 | enabled: true,
47 | async sendChangeEmailVerification({ user, newEmail, url }) {
48 | await sendEmail({
49 | to: user.email,
50 | subject: "Approve email change",
51 | text: `Your email has been changed to ${newEmail}. Click the link to approve the change: ${url}`,
52 | });
53 | },
54 | },
55 | additionalFields: {
56 | role: {
57 | type: "string",
58 | input: false,
59 | },
60 | },
61 | },
62 | hooks: {
63 | before: createAuthMiddleware(async (ctx) => {
64 | if (
65 | ctx.path === "/sign-up/email" ||
66 | ctx.path === "/reset-password" ||
67 | ctx.path === "/change-password"
68 | ) {
69 | const password = ctx.body.password || ctx.body.newPassword;
70 | const { error } = passwordSchema.safeParse(password);
71 | if (error) {
72 | throw new APIError("BAD_REQUEST", {
73 | message: "Password not strong enough",
74 | });
75 | }
76 | }
77 | }),
78 | },
79 | });
80 |
81 | export type Session = typeof auth.$Infer.Session;
82 | export type User = typeof auth.$Infer.Session.user;
83 |
--------------------------------------------------------------------------------
/src/app/(auth)/forgot-password/forgot-password-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LoadingButton } from "@/components/loading-button";
4 | import { Card, CardContent } from "@/components/ui/card";
5 | import {
6 | Form,
7 | FormControl,
8 | FormField,
9 | FormItem,
10 | FormLabel,
11 | FormMessage,
12 | } from "@/components/ui/form";
13 | import { Input } from "@/components/ui/input";
14 | import { authClient } from "@/lib/auth-client";
15 | import { zodResolver } from "@hookform/resolvers/zod";
16 | import { useState } from "react";
17 | import { useForm } from "react-hook-form";
18 | import { z } from "zod";
19 |
20 | const forgotPasswordSchema = z.object({
21 | email: z.email({ message: "Please enter a valid email" }),
22 | });
23 |
24 | type ForgotPasswordValues = z.infer;
25 |
26 | export function ForgotPasswordForm() {
27 | const [success, setSuccess] = useState(null);
28 | const [error, setError] = useState(null);
29 |
30 | const form = useForm({
31 | resolver: zodResolver(forgotPasswordSchema),
32 | defaultValues: { email: "" },
33 | });
34 |
35 | async function onSubmit({ email }: ForgotPasswordValues) {
36 | setSuccess(null);
37 | setError(null);
38 |
39 | const { error } = await authClient.requestPasswordReset({
40 | email,
41 | redirectTo: "/reset-password",
42 | });
43 |
44 | if (error) {
45 | setError(error.message || "Something went wrong");
46 | } else {
47 | setSuccess(
48 | "If an account exists for this email, we've sent a password reset link.",
49 | );
50 | form.reset();
51 | }
52 | }
53 |
54 | const loading = form.formState.isSubmitting;
55 |
56 | return (
57 |
58 |
59 |
94 |
95 |
96 |
97 | );
98 | }
99 |
--------------------------------------------------------------------------------
/src/app/(main)/profile/email-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LoadingButton } from "@/components/loading-button";
4 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5 | import {
6 | Form,
7 | FormControl,
8 | FormField,
9 | FormItem,
10 | FormLabel,
11 | FormMessage,
12 | } from "@/components/ui/form";
13 | import { Input } from "@/components/ui/input";
14 | import { authClient } from "@/lib/auth-client";
15 | import { zodResolver } from "@hookform/resolvers/zod";
16 | import { useState } from "react";
17 | import { useForm } from "react-hook-form";
18 | import z from "zod";
19 |
20 | export const updateEmailSchema = z.object({
21 | newEmail: z.email({ message: "Enter a valid email" }),
22 | });
23 |
24 | export type UpdateEmailValues = z.infer;
25 |
26 | interface EmailFormProps {
27 | currentEmail: string;
28 | }
29 |
30 | export function EmailForm({ currentEmail }: EmailFormProps) {
31 | const [status, setStatus] = useState(null);
32 | const [error, setError] = useState(null);
33 |
34 | const form = useForm({
35 | resolver: zodResolver(updateEmailSchema),
36 | defaultValues: {
37 | newEmail: currentEmail,
38 | },
39 | });
40 |
41 | async function onSubmit({ newEmail }: UpdateEmailValues) {
42 | setStatus(null);
43 | setError(null);
44 |
45 | const { error } = await authClient.changeEmail({
46 | newEmail,
47 | callbackURL: "/email-verified",
48 | });
49 |
50 | if (error) {
51 | setError(error.message || "Failed to initiate email change");
52 | } else {
53 | setStatus("Verification email sent to your current address");
54 | }
55 | }
56 |
57 | const loading = form.formState.isSubmitting;
58 |
59 | return (
60 |
61 |
62 | Change Email
63 |
64 |
65 |
99 |
100 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/src/app/(auth)/reset-password/reset-password-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LoadingButton } from "@/components/loading-button";
4 | import { PasswordInput } from "@/components/password-input";
5 | import { Card, CardContent } from "@/components/ui/card";
6 | import {
7 | Form,
8 | FormControl,
9 | FormField,
10 | FormItem,
11 | FormLabel,
12 | FormMessage,
13 | } from "@/components/ui/form";
14 | import { authClient } from "@/lib/auth-client";
15 | import { passwordSchema } from "@/lib/validation";
16 | import { zodResolver } from "@hookform/resolvers/zod";
17 | import { useRouter } from "next/navigation";
18 | import { useState } from "react";
19 | import { useForm } from "react-hook-form";
20 | import { z } from "zod";
21 |
22 | const resetPasswordSchema = z.object({
23 | newPassword: passwordSchema,
24 | });
25 |
26 | type ResetPasswordValues = z.infer;
27 |
28 | interface ResetPasswordFormProps {
29 | token: string;
30 | }
31 |
32 | export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
33 | const [success, setSuccess] = useState(null);
34 | const [error, setError] = useState(null);
35 |
36 | const router = useRouter();
37 |
38 | const form = useForm({
39 | resolver: zodResolver(resetPasswordSchema),
40 | defaultValues: { newPassword: "" },
41 | });
42 |
43 | async function onSubmit({ newPassword }: ResetPasswordValues) {
44 | setSuccess(null);
45 | setError(null);
46 |
47 | const { error } = await authClient.resetPassword({
48 | newPassword,
49 | token,
50 | });
51 |
52 | if (error) {
53 | setError(error.message || "Something went wrong");
54 | } else {
55 | setSuccess("Password has been reset. You can now sign in.");
56 | setTimeout(() => router.push("/sign-in"), 3000);
57 | form.reset();
58 | }
59 | }
60 |
61 | const loading = form.formState.isSubmitting;
62 |
63 | return (
64 |
65 |
66 |
101 |
102 |
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/src/app/(main)/profile/password-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LoadingButton } from "@/components/loading-button";
4 | import { PasswordInput } from "@/components/password-input";
5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6 | import {
7 | Form,
8 | FormControl,
9 | FormField,
10 | FormItem,
11 | FormLabel,
12 | FormMessage,
13 | } from "@/components/ui/form";
14 | import { authClient } from "@/lib/auth-client";
15 | import { passwordSchema } from "@/lib/validation";
16 | import { zodResolver } from "@hookform/resolvers/zod";
17 | import { useState } from "react";
18 | import { useForm } from "react-hook-form";
19 | import { z } from "zod";
20 |
21 | const updatePasswordSchema = z.object({
22 | currentPassword: z
23 | .string()
24 | .min(1, { message: "Current password is required" }),
25 | newPassword: passwordSchema,
26 | });
27 |
28 | type UpdatePasswordValues = z.infer;
29 |
30 | export function PasswordForm() {
31 | const [status, setStatus] = useState(null);
32 | const [error, setError] = useState(null);
33 |
34 | const form = useForm({
35 | resolver: zodResolver(updatePasswordSchema),
36 | defaultValues: {
37 | currentPassword: "",
38 | newPassword: "",
39 | },
40 | });
41 |
42 | async function onSubmit({
43 | currentPassword,
44 | newPassword,
45 | }: UpdatePasswordValues) {
46 | setStatus(null);
47 | setError(null);
48 |
49 | const { error } = await authClient.changePassword({
50 | currentPassword,
51 | newPassword,
52 | revokeOtherSessions: true,
53 | });
54 |
55 | if (error) {
56 | setError(error.message || "Failed to change password");
57 | } else {
58 | setStatus("Password changed");
59 | form.reset();
60 | }
61 | }
62 |
63 | const loading = form.formState.isSubmitting;
64 |
65 | return (
66 |
67 |
68 | Change Password
69 |
70 |
71 |
115 |
116 |
117 |
118 | );
119 | }
120 |
--------------------------------------------------------------------------------
/src/app/(main)/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { Badge } from "@/components/ui/badge";
2 | import { Button } from "@/components/ui/button";
3 | import {
4 | Card,
5 | CardContent,
6 | CardDescription,
7 | CardHeader,
8 | CardTitle,
9 | } from "@/components/ui/card";
10 | import { UserAvatar } from "@/components/user-avatar";
11 | import { User } from "@/lib/auth";
12 | import { getServerSession } from "@/lib/get-session";
13 | import { format } from "date-fns";
14 | import { CalendarDaysIcon, MailIcon, ShieldIcon, UserIcon } from "lucide-react";
15 | import type { Metadata } from "next";
16 | import Link from "next/link";
17 | import { unauthorized } from "next/navigation";
18 |
19 | export const metadata: Metadata = {
20 | title: "Dashboard",
21 | };
22 |
23 | export default async function DashboardPage() {
24 | const session = await getServerSession();
25 | const user = session?.user;
26 |
27 | if (!user) unauthorized();
28 |
29 | return (
30 |
31 |
32 |
33 |
Dashboard
34 |
35 | Welcome back! Here's your account overview.
36 |
37 |
38 | {!user.emailVerified &&
}
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | interface ProfileInformationProps {
46 | user: User;
47 | }
48 |
49 | function ProfileInformation({ user }: ProfileInformationProps) {
50 | return (
51 |
52 |
53 |
54 |
55 | Profile Information
56 |
57 |
58 | Your account details and current status
59 |
60 |
61 |
62 |
63 |
64 |
69 | {user.role && (
70 |
71 |
72 | {user.role}
73 |
74 | )}
75 |
76 |
77 |
78 |
79 |
{user.name}
80 |
{user.email}
81 |
82 |
83 |
84 |
85 |
86 | Member Since
87 |
88 |
89 | {format(user.createdAt, "MMMM d, yyyy")}
90 |
91 |
92 |
93 |
94 |
95 |
96 | );
97 | }
98 |
99 | function EmailVerificationAlert() {
100 | return (
101 |
102 |
103 |
104 |
105 |
106 | Please verify your email address to access all features.
107 |
108 |
109 |
112 |
113 |
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { Slot } from "@radix-ui/react-slot"
6 | import {
7 | Controller,
8 | FormProvider,
9 | useFormContext,
10 | useFormState,
11 | type ControllerProps,
12 | type FieldPath,
13 | type FieldValues,
14 | } from "react-hook-form"
15 |
16 | import { cn } from "@/lib/utils"
17 | import { Label } from "@/components/ui/label"
18 |
19 | const Form = FormProvider
20 |
21 | type FormFieldContextValue<
22 | TFieldValues extends FieldValues = FieldValues,
23 | TName extends FieldPath = FieldPath,
24 | > = {
25 | name: TName
26 | }
27 |
28 | const FormFieldContext = React.createContext(
29 | {} as FormFieldContextValue
30 | )
31 |
32 | const FormField = <
33 | TFieldValues extends FieldValues = FieldValues,
34 | TName extends FieldPath = FieldPath,
35 | >({
36 | ...props
37 | }: ControllerProps) => {
38 | return (
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | const useFormField = () => {
46 | const fieldContext = React.useContext(FormFieldContext)
47 | const itemContext = React.useContext(FormItemContext)
48 | const { getFieldState } = useFormContext()
49 | const formState = useFormState({ name: fieldContext.name })
50 | const fieldState = getFieldState(fieldContext.name, formState)
51 |
52 | if (!fieldContext) {
53 | throw new Error("useFormField should be used within ")
54 | }
55 |
56 | const { id } = itemContext
57 |
58 | return {
59 | id,
60 | name: fieldContext.name,
61 | formItemId: `${id}-form-item`,
62 | formDescriptionId: `${id}-form-item-description`,
63 | formMessageId: `${id}-form-item-message`,
64 | ...fieldState,
65 | }
66 | }
67 |
68 | type FormItemContextValue = {
69 | id: string
70 | }
71 |
72 | const FormItemContext = React.createContext(
73 | {} as FormItemContextValue
74 | )
75 |
76 | function FormItem({ className, ...props }: React.ComponentProps<"div">) {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
86 |
87 | )
88 | }
89 |
90 | function FormLabel({
91 | className,
92 | ...props
93 | }: React.ComponentProps) {
94 | const { error, formItemId } = useFormField()
95 |
96 | return (
97 |
104 | )
105 | }
106 |
107 | function FormControl({ ...props }: React.ComponentProps) {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | }
124 |
125 | function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
126 | const { formDescriptionId } = useFormField()
127 |
128 | return (
129 |
135 | )
136 | }
137 |
138 | function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
139 | const { error, formMessageId } = useFormField()
140 | const body = error ? String(error?.message ?? "") : props.children
141 |
142 | if (!body) {
143 | return null
144 | }
145 |
146 | return (
147 |
153 | {body}
154 |
155 | )
156 | }
157 |
158 | export {
159 | useFormField,
160 | Form,
161 | FormItem,
162 | FormLabel,
163 | FormControl,
164 | FormDescription,
165 | FormMessage,
166 | FormField,
167 | }
168 |
--------------------------------------------------------------------------------
/src/app/(main)/profile/profile-details-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LoadingButton } from "@/components/loading-button";
4 | import { Button } from "@/components/ui/button";
5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6 | import {
7 | Form,
8 | FormControl,
9 | FormField,
10 | FormItem,
11 | FormLabel,
12 | FormMessage,
13 | } from "@/components/ui/form";
14 | import { Input } from "@/components/ui/input";
15 | import { UserAvatar } from "@/components/user-avatar";
16 | import { User } from "@/lib/auth";
17 | import { authClient } from "@/lib/auth-client";
18 | import { zodResolver } from "@hookform/resolvers/zod";
19 | import { XIcon } from "lucide-react";
20 | import { useRouter } from "next/navigation";
21 | import { useState } from "react";
22 | import { useForm } from "react-hook-form";
23 | import { z } from "zod";
24 |
25 | const updateProfileSchema = z.object({
26 | name: z.string().trim().min(1, { message: "Name is required" }),
27 | image: z.string().optional().nullable(),
28 | });
29 |
30 | export type UpdateProfileValues = z.infer;
31 |
32 | interface ProfileDetailsFormProps {
33 | user: User;
34 | }
35 |
36 | export function ProfileDetailsForm({ user }: ProfileDetailsFormProps) {
37 | const [status, setStatus] = useState(null);
38 | const [error, setError] = useState(null);
39 |
40 | const router = useRouter();
41 |
42 | const form = useForm({
43 | resolver: zodResolver(updateProfileSchema),
44 | defaultValues: {
45 | name: user.name ?? "",
46 | image: user.image ?? null,
47 | },
48 | });
49 |
50 | async function onSubmit({ name, image }: UpdateProfileValues) {
51 | setStatus(null);
52 | setError(null);
53 |
54 | const { error } = await authClient.updateUser({ name, image });
55 |
56 | if (error) {
57 | setError(error.message || "Failed to update profile");
58 | } else {
59 | setStatus("Profile updated");
60 | router.refresh();
61 | }
62 | }
63 |
64 | function handleImageChange(e: React.ChangeEvent) {
65 | const file = e.target.files?.[0];
66 | if (file) {
67 | const reader = new FileReader();
68 | reader.onloadend = () => {
69 | const base64 = reader.result as string;
70 | form.setValue("image", base64, { shouldDirty: true });
71 | };
72 | reader.readAsDataURL(file);
73 | }
74 | }
75 |
76 | const imagePreview = form.watch("image");
77 |
78 | const loading = form.formState.isSubmitting;
79 |
80 | return (
81 |
82 |
83 | Profile Details
84 |
85 |
86 |
153 |
154 |
155 |
156 | );
157 | }
158 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/sign-up-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LoadingButton } from "@/components/loading-button";
4 | import { PasswordInput } from "@/components/password-input";
5 | import {
6 | Card,
7 | CardContent,
8 | CardDescription,
9 | CardFooter,
10 | CardHeader,
11 | CardTitle,
12 | } from "@/components/ui/card";
13 | import {
14 | Form,
15 | FormControl,
16 | FormField,
17 | FormItem,
18 | FormLabel,
19 | FormMessage,
20 | } from "@/components/ui/form";
21 | import { Input } from "@/components/ui/input";
22 | import { authClient } from "@/lib/auth-client";
23 | import { passwordSchema } from "@/lib/validation";
24 | import { zodResolver } from "@hookform/resolvers/zod";
25 | import Link from "next/link";
26 | import { useRouter } from "next/navigation";
27 | import { useState } from "react";
28 | import { useForm } from "react-hook-form";
29 | import { toast } from "sonner";
30 | import { z } from "zod";
31 |
32 | const signUpSchema = z
33 | .object({
34 | name: z.string().min(1, { message: "Name is required" }),
35 | email: z.email({ message: "Please enter a valid email" }),
36 | password: passwordSchema,
37 | passwordConfirmation: z
38 | .string()
39 | .min(1, { message: "Please confirm password" }),
40 | })
41 | .refine((data) => data.password === data.passwordConfirmation, {
42 | message: "Passwords do not match",
43 | path: ["passwordConfirmation"],
44 | });
45 |
46 | type SignUpValues = z.infer;
47 |
48 | export function SignUpForm() {
49 | const [error, setError] = useState(null);
50 |
51 | const router = useRouter();
52 |
53 | const form = useForm({
54 | resolver: zodResolver(signUpSchema),
55 | defaultValues: {
56 | name: "",
57 | email: "",
58 | password: "",
59 | passwordConfirmation: "",
60 | },
61 | });
62 |
63 | async function onSubmit({ email, password, name }: SignUpValues) {
64 | setError(null);
65 |
66 | const { error } = await authClient.signUp.email({
67 | email,
68 | password,
69 | name,
70 | callbackURL: "/email-verified",
71 | });
72 |
73 | if (error) {
74 | setError(error.message || "Something went wrong");
75 | } else {
76 | toast.success("Signed up successfully");
77 | router.push("/dashboard");
78 | }
79 | }
80 |
81 | const loading = form.formState.isSubmitting;
82 |
83 | return (
84 |
85 |
86 | Sign Up
87 |
88 | Enter your information to create an account
89 |
90 |
91 |
92 |
172 |
173 |
174 |
175 |
176 |
177 | Already have an account?{" "}
178 |
179 | Sign in
180 |
181 |
182 |
183 |
184 |
185 | );
186 | }
187 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/sign-in-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { GitHubIcon } from "@/components/icons/GitHubIcon";
4 | import { GoogleIcon } from "@/components/icons/GoogleIcon";
5 | import { LoadingButton } from "@/components/loading-button";
6 | import { PasswordInput } from "@/components/password-input";
7 | import { Button } from "@/components/ui/button";
8 | import {
9 | Card,
10 | CardContent,
11 | CardDescription,
12 | CardFooter,
13 | CardHeader,
14 | CardTitle,
15 | } from "@/components/ui/card";
16 | import { Checkbox } from "@/components/ui/checkbox";
17 | import {
18 | Form,
19 | FormControl,
20 | FormField,
21 | FormItem,
22 | FormLabel,
23 | FormMessage,
24 | } from "@/components/ui/form";
25 | import { Input } from "@/components/ui/input";
26 | import { authClient } from "@/lib/auth-client";
27 | import { zodResolver } from "@hookform/resolvers/zod";
28 | import Link from "next/link";
29 | import { useRouter, useSearchParams } from "next/navigation";
30 | import { useState } from "react";
31 | import { useForm } from "react-hook-form";
32 | import { toast } from "sonner";
33 | import { z } from "zod";
34 |
35 | const signInSchema = z.object({
36 | email: z.email({ message: "Please enter a valid email" }),
37 | password: z.string().min(1, { message: "Password is required" }),
38 | rememberMe: z.boolean().optional(),
39 | });
40 |
41 | type SignInValues = z.infer;
42 |
43 | export function SignInForm() {
44 | const [loading, setLoading] = useState(false);
45 | const [error, setError] = useState(null);
46 |
47 | const router = useRouter();
48 | const searchParams = useSearchParams();
49 |
50 | const redirect = searchParams.get("redirect");
51 |
52 | const form = useForm({
53 | resolver: zodResolver(signInSchema),
54 | defaultValues: {
55 | email: "",
56 | password: "",
57 | rememberMe: false,
58 | },
59 | });
60 |
61 | async function onSubmit({ email, password, rememberMe }: SignInValues) {
62 | setError(null);
63 | setLoading(true);
64 |
65 | const { error } = await authClient.signIn.email({
66 | email,
67 | password,
68 | rememberMe,
69 | });
70 |
71 | setLoading(false);
72 |
73 | if (error) {
74 | setError(error.message || "Something went wrong");
75 | } else {
76 | toast.success("Signed in successfully");
77 | router.push(redirect ?? "/dashboard");
78 | }
79 | }
80 |
81 | async function handleSocialSignIn(provider: "google" | "github") {
82 | setError(null);
83 | setLoading(true);
84 |
85 | const { error } = await authClient.signIn.social({
86 | provider,
87 | callbackURL: redirect ?? "/dashboard",
88 | });
89 |
90 | setLoading(false);
91 |
92 | if (error) {
93 | setError(error.message || "Something went wrong");
94 | }
95 | }
96 |
97 | return (
98 |
99 |
100 | Sign In
101 |
102 | Enter your email below to login to your account
103 |
104 |
105 |
106 |
202 |
203 |
204 |
205 |
206 |
207 | Don't have an account?{" "}
208 |
209 | Sign up
210 |
211 |
212 |
213 |
214 |
215 | );
216 | }
217 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --color-background: var(--background);
8 | --color-foreground: var(--foreground);
9 | --color-card: var(--card);
10 | --color-card-foreground: var(--card-foreground);
11 | --color-popover: var(--popover);
12 | --color-popover-foreground: var(--popover-foreground);
13 | --color-primary: var(--primary);
14 | --color-primary-foreground: var(--primary-foreground);
15 | --color-secondary: var(--secondary);
16 | --color-secondary-foreground: var(--secondary-foreground);
17 | --color-muted: var(--muted);
18 | --color-muted-foreground: var(--muted-foreground);
19 | --color-accent: var(--accent);
20 | --color-accent-foreground: var(--accent-foreground);
21 | --color-destructive: var(--destructive);
22 | --color-destructive-foreground: var(--destructive-foreground);
23 | --color-border: var(--border);
24 | --color-input: var(--input);
25 | --color-ring: var(--ring);
26 | --color-chart-1: var(--chart-1);
27 | --color-chart-2: var(--chart-2);
28 | --color-chart-3: var(--chart-3);
29 | --color-chart-4: var(--chart-4);
30 | --color-chart-5: var(--chart-5);
31 | --color-sidebar: var(--sidebar);
32 | --color-sidebar-foreground: var(--sidebar-foreground);
33 | --color-sidebar-primary: var(--sidebar-primary);
34 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
35 | --color-sidebar-accent: var(--sidebar-accent);
36 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
37 | --color-sidebar-border: var(--sidebar-border);
38 | --color-sidebar-ring: var(--sidebar-ring);
39 |
40 | --font-sans: var(--font-sans);
41 | --font-mono: var(--font-mono);
42 | --font-serif: var(--font-serif);
43 |
44 | --radius-sm: calc(var(--radius) - 4px);
45 | --radius-md: calc(var(--radius) - 2px);
46 | --radius-lg: var(--radius);
47 | --radius-xl: calc(var(--radius) + 4px);
48 |
49 | --shadow-2xs: var(--shadow-2xs);
50 | --shadow-xs: var(--shadow-xs);
51 | --shadow-sm: var(--shadow-sm);
52 | --shadow: var(--shadow);
53 | --shadow-md: var(--shadow-md);
54 | --shadow-lg: var(--shadow-lg);
55 | --shadow-xl: var(--shadow-xl);
56 | --shadow-2xl: var(--shadow-2xl);
57 | }
58 |
59 | :root {
60 | --background: oklch(0.9711 0.0074 80.7211);
61 | --foreground: oklch(0.3 0.0358 30.2042);
62 | --card: oklch(0.9711 0.0074 80.7211);
63 | --card-foreground: oklch(0.3 0.0358 30.2042);
64 | --popover: oklch(0.9711 0.0074 80.7211);
65 | --popover-foreground: oklch(0.3 0.0358 30.2042);
66 | --primary: oklch(0.5234 0.1347 144.1672);
67 | --primary-foreground: oklch(1 0 0);
68 | --secondary: oklch(0.9571 0.021 147.636);
69 | --secondary-foreground: oklch(0.4254 0.1159 144.3078);
70 | --muted: oklch(0.937 0.0142 74.4218);
71 | --muted-foreground: oklch(0.4495 0.0486 39.211);
72 | --accent: oklch(0.8952 0.0504 146.0366);
73 | --accent-foreground: oklch(0.4254 0.1159 144.3078);
74 | --destructive: oklch(0.5386 0.1937 26.7249);
75 | --destructive-foreground: oklch(1 0 0);
76 | --border: oklch(0.8805 0.0208 74.6428);
77 | --input: oklch(0.8805 0.0208 74.6428);
78 | --ring: oklch(0.5234 0.1347 144.1672);
79 | --chart-1: oklch(0.6731 0.1624 144.2083);
80 | --chart-2: oklch(0.5752 0.1446 144.1813);
81 | --chart-3: oklch(0.5234 0.1347 144.1672);
82 | --chart-4: oklch(0.4254 0.1159 144.3078);
83 | --chart-5: oklch(0.2157 0.0453 145.7256);
84 | --sidebar: oklch(0.937 0.0142 74.4218);
85 | --sidebar-foreground: oklch(0.3 0.0358 30.2042);
86 | --sidebar-primary: oklch(0.5234 0.1347 144.1672);
87 | --sidebar-primary-foreground: oklch(1 0 0);
88 | --sidebar-accent: oklch(0.8952 0.0504 146.0366);
89 | --sidebar-accent-foreground: oklch(0.4254 0.1159 144.3078);
90 | --sidebar-border: oklch(0.8805 0.0208 74.6428);
91 | --sidebar-ring: oklch(0.5234 0.1347 144.1672);
92 | --font-sans: Montserrat, sans-serif;
93 | --font-serif: Merriweather, serif;
94 | --font-mono: Source Code Pro, monospace;
95 | --radius: 0.5rem;
96 | --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
97 | --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
98 | --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
99 | 0 1px 2px -1px hsl(0 0% 0% / 0.1);
100 | --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
101 | --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
102 | 0 2px 4px -1px hsl(0 0% 0% / 0.1);
103 | --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
104 | 0 4px 6px -1px hsl(0 0% 0% / 0.1);
105 | --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
106 | 0 8px 10px -1px hsl(0 0% 0% / 0.1);
107 | --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
108 | --tracking-normal: 0em;
109 | --spacing: 0.25rem;
110 | }
111 |
112 | .dark {
113 | --background: oklch(0.2683 0.0279 150.7681);
114 | --foreground: oklch(0.9423 0.0097 72.6595);
115 | --card: oklch(0.3327 0.0271 146.9867);
116 | --card-foreground: oklch(0.9423 0.0097 72.6595);
117 | --popover: oklch(0.3327 0.0271 146.9867);
118 | --popover-foreground: oklch(0.9423 0.0097 72.6595);
119 | --primary: oklch(0.6731 0.1624 144.2083);
120 | --primary-foreground: oklch(0.2157 0.0453 145.7256);
121 | --secondary: oklch(0.3942 0.0265 142.9926);
122 | --secondary-foreground: oklch(0.897 0.0166 142.5518);
123 | --muted: oklch(0.3327 0.0271 146.9867);
124 | --muted-foreground: oklch(0.8579 0.0174 76.0955);
125 | --accent: oklch(0.5752 0.1446 144.1813);
126 | --accent-foreground: oklch(0.9423 0.0097 72.6595);
127 | --destructive: oklch(0.5386 0.1937 26.7249);
128 | --destructive-foreground: oklch(0.9423 0.0097 72.6595);
129 | --border: oklch(0.3942 0.0265 142.9926);
130 | --input: oklch(0.3942 0.0265 142.9926);
131 | --ring: oklch(0.6731 0.1624 144.2083);
132 | --chart-1: oklch(0.766 0.1179 145.295);
133 | --chart-2: oklch(0.7185 0.1417 144.8887);
134 | --chart-3: oklch(0.6731 0.1624 144.2083);
135 | --chart-4: oklch(0.6291 0.1543 144.2031);
136 | --chart-5: oklch(0.5752 0.1446 144.1813);
137 | --sidebar: oklch(0.2683 0.0279 150.7681);
138 | --sidebar-foreground: oklch(0.9423 0.0097 72.6595);
139 | --sidebar-primary: oklch(0.6731 0.1624 144.2083);
140 | --sidebar-primary-foreground: oklch(0.2157 0.0453 145.7256);
141 | --sidebar-accent: oklch(0.5752 0.1446 144.1813);
142 | --sidebar-accent-foreground: oklch(0.9423 0.0097 72.6595);
143 | --sidebar-border: oklch(0.3942 0.0265 142.9926);
144 | --sidebar-ring: oklch(0.6731 0.1624 144.2083);
145 | --font-sans: Montserrat, sans-serif;
146 | --font-serif: Merriweather, serif;
147 | --font-mono: Source Code Pro, monospace;
148 | --radius: 0.5rem;
149 | --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
150 | --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
151 | --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
152 | 0 1px 2px -1px hsl(0 0% 0% / 0.1);
153 | --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
154 | --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
155 | 0 2px 4px -1px hsl(0 0% 0% / 0.1);
156 | --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
157 | 0 4px 6px -1px hsl(0 0% 0% / 0.1);
158 | --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
159 | 0 8px 10px -1px hsl(0 0% 0% / 0.1);
160 | --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
161 | }
162 |
163 | @layer base {
164 | * {
165 | @apply border-border outline-ring/50;
166 | }
167 | body {
168 | @apply bg-background text-foreground;
169 | }
170 | button,
171 | [role="button"] {
172 | cursor: pointer;
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function DropdownMenu({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DropdownMenuPortal({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function DropdownMenuTrigger({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
31 | )
32 | }
33 |
34 | function DropdownMenuContent({
35 | className,
36 | sideOffset = 4,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
41 |
50 |
51 | )
52 | }
53 |
54 | function DropdownMenuGroup({
55 | ...props
56 | }: React.ComponentProps) {
57 | return (
58 |
59 | )
60 | }
61 |
62 | function DropdownMenuItem({
63 | className,
64 | inset,
65 | variant = "default",
66 | ...props
67 | }: React.ComponentProps & {
68 | inset?: boolean
69 | variant?: "default" | "destructive"
70 | }) {
71 | return (
72 |
82 | )
83 | }
84 |
85 | function DropdownMenuCheckboxItem({
86 | className,
87 | children,
88 | checked,
89 | ...props
90 | }: React.ComponentProps) {
91 | return (
92 |
101 |
102 |
103 |
104 |
105 |
106 | {children}
107 |
108 | )
109 | }
110 |
111 | function DropdownMenuRadioGroup({
112 | ...props
113 | }: React.ComponentProps) {
114 | return (
115 |
119 | )
120 | }
121 |
122 | function DropdownMenuRadioItem({
123 | className,
124 | children,
125 | ...props
126 | }: React.ComponentProps) {
127 | return (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | )
144 | }
145 |
146 | function DropdownMenuLabel({
147 | className,
148 | inset,
149 | ...props
150 | }: React.ComponentProps & {
151 | inset?: boolean
152 | }) {
153 | return (
154 |
163 | )
164 | }
165 |
166 | function DropdownMenuSeparator({
167 | className,
168 | ...props
169 | }: React.ComponentProps) {
170 | return (
171 |
176 | )
177 | }
178 |
179 | function DropdownMenuShortcut({
180 | className,
181 | ...props
182 | }: React.ComponentProps<"span">) {
183 | return (
184 |
192 | )
193 | }
194 |
195 | function DropdownMenuSub({
196 | ...props
197 | }: React.ComponentProps) {
198 | return
199 | }
200 |
201 | function DropdownMenuSubTrigger({
202 | className,
203 | inset,
204 | children,
205 | ...props
206 | }: React.ComponentProps & {
207 | inset?: boolean
208 | }) {
209 | return (
210 |
219 | {children}
220 |
221 |
222 | )
223 | }
224 |
225 | function DropdownMenuSubContent({
226 | className,
227 | ...props
228 | }: React.ComponentProps) {
229 | return (
230 |
238 | )
239 | }
240 |
241 | export {
242 | DropdownMenu,
243 | DropdownMenuPortal,
244 | DropdownMenuTrigger,
245 | DropdownMenuContent,
246 | DropdownMenuGroup,
247 | DropdownMenuLabel,
248 | DropdownMenuItem,
249 | DropdownMenuCheckboxItem,
250 | DropdownMenuRadioGroup,
251 | DropdownMenuRadioItem,
252 | DropdownMenuSeparator,
253 | DropdownMenuShortcut,
254 | DropdownMenuSub,
255 | DropdownMenuSubTrigger,
256 | DropdownMenuSubContent,
257 | }
258 |
--------------------------------------------------------------------------------