├── .env.sample ├── .eslintrc.js ├── .gitignore ├── .map.ts ├── .npmrc ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── components.json ├── drizzle.config.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── favicon.ico ├── robots.txt └── sitemap.xml ├── src ├── app-config.ts ├── app │ ├── (auth) │ │ ├── reset-password │ │ │ ├── actions.ts │ │ │ └── page.tsx │ │ ├── sign-in │ │ │ ├── actions.ts │ │ │ ├── email │ │ │ │ ├── actions.ts │ │ │ │ └── page.tsx │ │ │ ├── forgot-password │ │ │ │ ├── actions.ts │ │ │ │ └── page.tsx │ │ │ ├── magic-link-form.tsx │ │ │ ├── magic │ │ │ │ ├── error │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── sign-up │ │ │ ├── actions.ts │ │ │ └── page.tsx │ ├── _header │ │ ├── header.tsx │ │ ├── menu-button.tsx │ │ └── mode-toggle.tsx │ ├── api │ │ ├── login │ │ │ ├── github │ │ │ │ ├── callback │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── google │ │ │ │ ├── callback │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── magic │ │ │ │ └── route.ts │ │ │ └── verify-email │ │ │ │ └── route.ts │ │ └── sign-out │ │ │ └── route.ts │ ├── dashboard │ │ └── page.tsx │ ├── error.tsx │ ├── globals.css │ ├── layout.tsx │ ├── maintenance.tsx │ ├── not-found.tsx │ ├── page.tsx │ ├── providers.tsx │ ├── signed-out │ │ └── page.tsx │ ├── theme-provider.tsx │ └── verify-success │ │ └── page.tsx ├── components │ ├── loader-button.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── use-toast.ts ├── data-access │ ├── accounts.ts │ ├── magic-links.ts │ ├── profiles.ts │ ├── reset-tokens.ts │ ├── sessions.ts │ ├── users.ts │ ├── utils.ts │ └── verify-email.ts ├── db │ ├── index.ts │ ├── migrate.ts │ └── schema.ts ├── emails │ ├── magic-link.tsx │ ├── reset-password.tsx │ └── verify-email.tsx ├── env.ts ├── lib │ ├── auth.ts │ ├── email.tsx │ ├── errors.ts │ ├── get-ip.ts │ ├── limiter.ts │ ├── names.ts │ ├── safe-action.ts │ ├── session.ts │ └── utils.ts ├── styles │ ├── common.tsx │ └── icons.tsx ├── types │ └── index.ts └── use-cases │ ├── accounts.ts │ ├── errors.ts │ ├── magic-link.tsx │ ├── types.ts │ └── users.tsx ├── tailwind.config.ts └── tsconfig.json /.env.sample: -------------------------------------------------------------------------------- 1 | DATABASE_URL=file:local.db 2 | DB_AUTH_TOKEN=IF_USING_TURSO 3 | HOST_NAME="http://localhost:3000" 4 | GOOGLE_CLIENT_ID= 5 | GOOGLE_CLIENT_SECRET= 6 | GITHUB_CLIENT_SECRET= 7 | GITHUB_CLIENT_ID= 8 | EMAIL_SERVER_USER=resend 9 | EMAIL_SERVER_PASSWORD=ADD_YOUR_KEY 10 | EMAIL_SERVER_HOST=smtp.resend.com 11 | EMAIL_SERVER_PORT=465 12 | EMAIL_FROM="Your App " 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | extends: ["next/core-web-vitals", "prettier"], 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | db.sqlite 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | .yarn/install-state.gz 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | 40 | # pnpm 41 | pnpm-lock.yaml 42 | 43 | .env 44 | 45 | local.db -------------------------------------------------------------------------------- /.map.ts: -------------------------------------------------------------------------------- 1 | /** Auto-generated **/ 2 | declare const map: Record; 3 | 4 | export { map }; 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/next-drizzle-lucia-sqlite-template/77a60b914662906bcbae5b884fbb67de0b592b6b/.npmrc -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"], 3 | "tailwindFunctions": ["cn"] 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Web Dev Cody 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | This is a Next.js template which includes the following technology 4 | 5 | - next.js (app router) 6 | - drizzle orm 7 | - lucia auth 8 | - sqlite (or turso) 9 | - shadcn 10 | - react hook form 11 | - zsa (server action validation) 12 | - tiered architecture example 13 | - resend emails 14 | 15 | The point of this template is to allow you to setup a project which has a lot of the necessary authentication functionality out of the box, such as 16 | 17 | - google oauth login 18 | - github oauth login 19 | - magic link login 20 | - email / password login 21 | - password reset flow email 22 | - account registration + confirmation emails 23 | 24 | ## Running locally 25 | 26 | 1. `cp .env.sample .env` (fill out the necessary information for email support with resend, and your google + github oauth info, more info below) 27 | 1. `npm i` 28 | 1. `npx drizzle-kit push` (will create your sqlite database) 29 | 1. `npm run dev` 30 | 31 | ### Setting up Google Provider 32 | 33 | If you want google login, you'll need to setup a google project and create some keys: 34 | 35 | 1. https://console.cloud.google.com/apis/credentials 36 | 2. create a new project 37 | 3. setup oauth consent screen 38 | 4. create credentials - oauth client id 39 | 5. for authorized javascript origins 40 | 41 | - http://localhost:3000 42 | - https://your-domain.com 43 | 44 | 6. Authorized redirect URIs 45 | 46 | - http://localhost:3000/api/login/google/callback 47 | - https://your-domain.com/api/login/google/callback 48 | 49 | 7. Set your google id and secret inside of .env 50 | 51 | - **GOOGLE_CLIENT_ID** 52 | - **GOOGLE_CLIENT_SECRET** 53 | 54 | ### Setting up Github Provider 55 | 56 | TODO 57 | 58 | ## Contributing 59 | 60 | I'm hoping to keep this template as slim as possible and only add auth related features. Some future things that might be nice to add in could be MFA, sign out all devices, etc. I'm open to contributions, but please keep that core vision of "just auth related stuff" in mind. 61 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | import { defineConfig } from "drizzle-kit"; 3 | 4 | config({ path: ".env" }); 5 | 6 | export default defineConfig({ 7 | schema: "./src/db/schema.ts", 8 | out: "./migrations", 9 | dialect: "sqlite", 10 | driver: "turso", 11 | dbCredentials: { 12 | url: process.env.DATABASE_URL!, 13 | authToken: process.env.DB_AUTH_TOKEN!, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: "https", 8 | hostname: "*.googleusercontent.com", 9 | port: "", 10 | pathname: "**", 11 | }, 12 | { 13 | protocol: "http", 14 | hostname: "localhost", 15 | port: "3000", 16 | pathname: "**", 17 | }, 18 | ], 19 | }, 20 | }; 21 | 22 | export default nextConfig; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "db:push": "drizzle-kit push --config=drizzle.config.ts", 11 | "db:migrate": "node -r esbuild-register ./src/db/migrate.ts", 12 | "db:generate": "drizzle-kit generate --config=drizzle.config.ts", 13 | "db:studio": "drizzle-kit studio" 14 | }, 15 | "dependencies": { 16 | "@hookform/resolvers": "^3.4.2", 17 | "@libsql/client": "0.6.1", 18 | "@lucia-auth/adapter-drizzle": "^1.0.7", 19 | "@radix-ui/react-avatar": "^1.0.4", 20 | "@radix-ui/react-dropdown-menu": "^2.0.6", 21 | "@radix-ui/react-label": "^2.1.0", 22 | "@radix-ui/react-slot": "^1.1.0", 23 | "@radix-ui/react-toast": "^1.1.5", 24 | "@react-email/components": "0.0.19", 25 | "@t3-oss/env-nextjs": "0.10.1", 26 | "arctic": "1.9.0", 27 | "class-variance-authority": "0.7.0", 28 | "clsx": "2.1.1", 29 | "dotenv": "16.4.5", 30 | "drizzle-orm": "0.30.10", 31 | "lucia": "3.2.0", 32 | "lucide-react": "0.381.0", 33 | "next": "14.2.3", 34 | "next-themes": "0.3.0", 35 | "nextjs-toploader": "1.6.12", 36 | "react": "18.3.1", 37 | "react-dom": "18.3.1", 38 | "react-hook-form": "^7.51.5", 39 | "resend": "3.2.0", 40 | "server-only": "0.0.1", 41 | "tailwind-merge": "2.3.0", 42 | "unique-names-generator": "4.7.1", 43 | "zod": "^3.23.8", 44 | "zsa": "^0.5.0", 45 | "zsa-react": "^0.2.0" 46 | }, 47 | "devDependencies": { 48 | "@tailwindcss/typography": "0.5.13", 49 | "@total-typescript/ts-reset": "^0.5.1", 50 | "@types/node": "20.14.0", 51 | "@types/react": "18.3.3", 52 | "@types/react-dom": "18.3.0", 53 | "drizzle-kit": "0.21.4", 54 | "esbuild-register": "3.5.0", 55 | "eslint": "8.57.0", 56 | "eslint-config-next": "14.2.3", 57 | "postcss": "8.4.38", 58 | "prettier": "^3.3.2", 59 | "prettier-plugin-tailwindcss": "^0.6.5", 60 | "tailwindcss": "3.4.3", 61 | "tailwindcss-animate": "^1.0.7", 62 | "typescript": "5.4.5" 63 | }, 64 | "engines": { 65 | "pnpm": ">=9.0.0 <10.0.0", 66 | "node": ">=20.0.0 <21.0.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/next-drizzle-lucia-sqlite-template/77a60b914662906bcbae5b884fbb67de0b592b6b/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://wdcstarterkit.com/ 5 | weekly 6 | 1.0 7 | 8 | 9 | https://wdcstarterkit.com/terms-of-service 10 | weekly 11 | 0.8 12 | 13 | 14 | https://wdcstarterkit.com/privacy 15 | weekly 16 | 0.4 17 | 18 | -------------------------------------------------------------------------------- /src/app-config.ts: -------------------------------------------------------------------------------- 1 | export const applicationName = "APP"; 2 | 3 | export const afterLoginUrl = "/dashboard"; 4 | -------------------------------------------------------------------------------- /src/app/(auth)/reset-password/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { rateLimitByIp } from "@/lib/limiter"; 4 | import { unauthenticatedAction } from "@/lib/safe-action"; 5 | import { changePasswordUseCase } from "@/use-cases/users"; 6 | import { z } from "zod"; 7 | 8 | export const changePasswordAction = unauthenticatedAction 9 | .createServerAction() 10 | .input( 11 | z.object({ 12 | token: z.string(), 13 | password: z.string().min(8), 14 | }), 15 | ) 16 | .handler(async ({ input: { token, password } }) => { 17 | await rateLimitByIp({ key: "change-password", limit: 2, window: 30000 }); 18 | await changePasswordUseCase(token, password); 19 | }); 20 | -------------------------------------------------------------------------------- /src/app/(auth)/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { z } from "zod"; 4 | 5 | import { Input } from "@/components/ui/input"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import { useForm } from "react-hook-form"; 8 | import { 9 | Form, 10 | FormControl, 11 | FormField, 12 | FormItem, 13 | FormLabel, 14 | FormMessage, 15 | } from "@/components/ui/form"; 16 | import { pageTitleStyles } from "@/styles/common"; 17 | import { cn } from "@/lib/utils"; 18 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 19 | import { Terminal } from "lucide-react"; 20 | import Link from "next/link"; 21 | import { Button } from "@/components/ui/button"; 22 | import { changePasswordAction } from "./actions"; 23 | import { LoaderButton } from "@/components/loader-button"; 24 | import { useServerAction } from "zsa-react"; 25 | 26 | const registrationSchema = z 27 | .object({ 28 | password: z.string().min(8), 29 | token: z.string(), 30 | passwordConfirmation: z.string().min(8), 31 | }) 32 | .refine((data) => data.password === data.passwordConfirmation, { 33 | message: "Passwords don't match", 34 | path: ["passwordConfirmation"], 35 | }); 36 | 37 | export default function ResetPasswordPage({ 38 | searchParams, 39 | }: { 40 | searchParams: { token: string }; 41 | }) { 42 | const form = useForm>({ 43 | resolver: zodResolver(registrationSchema), 44 | defaultValues: { 45 | password: "", 46 | token: searchParams.token, 47 | passwordConfirmation: "", 48 | }, 49 | }); 50 | 51 | const { execute, isPending, isSuccess, error } = 52 | useServerAction(changePasswordAction); 53 | 54 | function onSubmit(values: z.infer) { 55 | execute({ 56 | token: values.token, 57 | password: values.password, 58 | }); 59 | } 60 | 61 | return ( 62 |
63 | {isSuccess && ( 64 | <> 65 |

66 | Password Updated 67 |

68 | 69 | 70 | Password updated 71 | 72 | Your password has been successfully updated. 73 | 74 | 75 | 76 | 79 | 80 | )} 81 | 82 | {!isSuccess && ( 83 | <> 84 |

85 | Change Password 86 |

87 | 88 | {error && ( 89 | 90 | 91 | Uhoh, something went wrong 92 | {error.message} 93 | 94 | )} 95 | 96 |
97 | 98 | ( 102 | 103 | Password 104 | 105 | 111 | 112 | 113 | 114 | )} 115 | /> 116 | 117 | ( 121 | 122 | Confirm Password 123 | 124 | 130 | 131 | 132 | 133 | )} 134 | /> 135 | 136 | 141 | Change Password 142 | 143 | 144 | 145 | 146 | )} 147 |
148 | ); 149 | } 150 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { rateLimitByKey } from "@/lib/limiter"; 4 | import { unauthenticatedAction } from "@/lib/safe-action"; 5 | import { sendMagicLinkUseCase } from "@/use-cases/magic-link"; 6 | import { redirect } from "next/navigation"; 7 | import { z } from "zod"; 8 | 9 | export const signInMagicLinkAction = unauthenticatedAction 10 | .createServerAction() 11 | .input( 12 | z.object({ 13 | email: z.string().email(), 14 | }), 15 | ) 16 | .handler(async ({ input }) => { 17 | await rateLimitByKey({ key: input.email, limit: 1, window: 30000 }); 18 | await sendMagicLinkUseCase(input.email); 19 | redirect("/sign-in/magic"); 20 | }); 21 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/email/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { afterLoginUrl } from "@/app-config"; 4 | import { rateLimitByKey } from "@/lib/limiter"; 5 | import { unauthenticatedAction } from "@/lib/safe-action"; 6 | import { setSession } from "@/lib/session"; 7 | import { signInUseCase } from "@/use-cases/users"; 8 | import { redirect } from "next/navigation"; 9 | import { z } from "zod"; 10 | 11 | export const signInAction = unauthenticatedAction 12 | .createServerAction() 13 | .input( 14 | z.object({ 15 | email: z.string().email(), 16 | password: z.string().min(8), 17 | }), 18 | ) 19 | .handler(async ({ input }) => { 20 | await rateLimitByKey({ key: input.email, limit: 3, window: 10000 }); 21 | const user = await signInUseCase(input.email, input.password); 22 | await setSession(user.id); 23 | redirect(afterLoginUrl); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/email/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { z } from "zod"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { Input } from "@/components/ui/input"; 7 | import { zodResolver } from "@hookform/resolvers/zod"; 8 | import { useForm } from "react-hook-form"; 9 | import { 10 | Form, 11 | FormControl, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage, 16 | } from "@/components/ui/form"; 17 | import { pageTitleStyles } from "@/styles/common"; 18 | import { cn } from "@/lib/utils"; 19 | import { useServerAction } from "zsa-react"; 20 | import Link from "next/link"; 21 | import { useToast } from "@/components/ui/use-toast"; 22 | import { signInAction } from "./actions"; 23 | import { LoaderButton } from "@/components/loader-button"; 24 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 25 | import { Terminal } from "lucide-react"; 26 | 27 | const registrationSchema = z.object({ 28 | email: z.string().email(), 29 | password: z.string().min(8), 30 | }); 31 | 32 | export default function SignInPage() { 33 | const { toast } = useToast(); 34 | 35 | const { execute, isPending, error, reset } = useServerAction(signInAction, { 36 | onError({ err }) { 37 | toast({ 38 | title: "Something went wrong", 39 | description: err.message, 40 | variant: "destructive", 41 | }); 42 | }, 43 | onSuccess() { 44 | toast({ 45 | title: "Let's Go!", 46 | description: "Enjoy your session", 47 | }); 48 | }, 49 | }); 50 | 51 | const form = useForm>({ 52 | resolver: zodResolver(registrationSchema), 53 | defaultValues: { 54 | email: "", 55 | password: "", 56 | }, 57 | }); 58 | 59 | function onSubmit(values: z.infer) { 60 | execute(values); 61 | } 62 | 63 | return ( 64 |
65 |

Sign In

66 | 67 |
68 | 69 | ( 73 | 74 | Email 75 | 76 | 82 | 83 | 84 | 85 | )} 86 | /> 87 | 88 | ( 92 | 93 | Password 94 | 95 | 101 | 102 | 103 | 104 | )} 105 | /> 106 | 107 | {error && ( 108 | 109 | 110 | Uhoh, we couldn't log you in 111 | {error.message} 112 | 113 | )} 114 | 115 | 116 | Sign In 117 | 118 | 119 | 120 | 121 |
122 | 125 |
126 | 127 |
128 |
129 | 130 |
131 |
132 | 133 | Or 134 | 135 |
136 |
137 | 138 | 141 |
142 | ); 143 | } 144 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/forgot-password/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { resetPasswordUseCase } from "@/use-cases/users"; 4 | import { unauthenticatedAction } from "@/lib/safe-action"; 5 | import { z } from "zod"; 6 | import { rateLimitByKey } from "@/lib/limiter"; 7 | 8 | export const resetPasswordAction = unauthenticatedAction 9 | .createServerAction() 10 | .input( 11 | z.object({ 12 | email: z.string().email(), 13 | }), 14 | ) 15 | .handler(async ({ input }) => { 16 | await rateLimitByKey({ key: input.email, limit: 1, window: 30000 }); 17 | await resetPasswordUseCase(input.email); 18 | }); 19 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { z } from "zod"; 4 | 5 | import { Input } from "@/components/ui/input"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import { useForm } from "react-hook-form"; 8 | import { 9 | Form, 10 | FormControl, 11 | FormField, 12 | FormItem, 13 | FormLabel, 14 | FormMessage, 15 | } from "@/components/ui/form"; 16 | import { pageTitleStyles } from "@/styles/common"; 17 | import { cn } from "@/lib/utils"; 18 | import { Terminal } from "lucide-react"; 19 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 20 | import { useServerAction } from "zsa-react"; 21 | import { LoaderButton } from "@/components/loader-button"; 22 | import { resetPasswordAction } from "./actions"; 23 | import { useToast } from "@/components/ui/use-toast"; 24 | 25 | const registrationSchema = z.object({ 26 | email: z.string().email(), 27 | }); 28 | 29 | export default function ForgotPasswordPage() { 30 | const { toast } = useToast(); 31 | 32 | const { execute, isPending, isSuccess } = useServerAction( 33 | resetPasswordAction, 34 | { 35 | onError({ err }) { 36 | toast({ 37 | title: "Something went wrong", 38 | description: err.message, 39 | variant: "destructive", 40 | }); 41 | }, 42 | }, 43 | ); 44 | 45 | const form = useForm>({ 46 | resolver: zodResolver(registrationSchema), 47 | defaultValues: { 48 | email: "", 49 | }, 50 | }); 51 | 52 | function onSubmit(values: z.infer) { 53 | execute(values); 54 | } 55 | 56 | return ( 57 |
58 |

Forgot Password

59 | 60 | {isSuccess && ( 61 | 62 | 63 | Reset link sent 64 | 65 | We have sent you an email with a link to reset your password. 66 | 67 | 68 | )} 69 | 70 |
71 | 72 | ( 76 | 77 | Email 78 | 79 | 85 | 86 | 87 | 88 | )} 89 | /> 90 | 91 | 92 | Send Reset Email 93 | 94 | 95 | 96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/magic-link-form.tsx: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { Input } from "@/components/ui/input"; 4 | import { zodResolver } from "@hookform/resolvers/zod"; 5 | import { useForm } from "react-hook-form"; 6 | import { 7 | Form, 8 | FormControl, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage, 13 | } from "@/components/ui/form"; 14 | import { signInMagicLinkAction } from "./actions"; 15 | import { LoaderButton } from "@/components/loader-button"; 16 | import { useServerAction } from "zsa-react"; 17 | import { useToast } from "@/components/ui/use-toast"; 18 | 19 | const magicLinkSchema = z.object({ 20 | email: z.string().email(), 21 | }); 22 | 23 | export function MagicLinkForm() { 24 | const { toast } = useToast(); 25 | 26 | const { execute, isPending } = useServerAction(signInMagicLinkAction, { 27 | onError({ err }) { 28 | toast({ 29 | title: "Something went wrong", 30 | description: err.message, 31 | variant: "destructive", 32 | }); 33 | }, 34 | }); 35 | 36 | const form = useForm>({ 37 | resolver: zodResolver(magicLinkSchema), 38 | defaultValues: { 39 | email: "", 40 | }, 41 | }); 42 | 43 | function onSubmit(values: z.infer) { 44 | execute(values); 45 | } 46 | 47 | return ( 48 |
49 | 50 | ( 54 | 55 | Email 56 | 57 | 63 | 64 | 65 | 66 | )} 67 | /> 68 | 69 | Sign in with magic link 70 | 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/magic/error/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { pageTitleStyles } from "@/styles/common"; 3 | import Link from "next/link"; 4 | 5 | export default function MagicLinkPage() { 6 | return ( 7 |
8 |

Expired Token

9 |

10 | Sorry, this token was either expired or already used. Please try logging 11 | in again 12 |

13 | 14 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/magic/page.tsx: -------------------------------------------------------------------------------- 1 | import { pageTitleStyles } from "@/styles/common"; 2 | 3 | export default function MagicLinkPage() { 4 | return ( 5 |
6 |

Check your email

7 |

8 | We sent you a magic link to sign in. Click the link in your email to 9 | sign in. 10 |

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Button, buttonVariants } from "@/components/ui/button"; 5 | import { cn } from "@/lib/utils"; 6 | import { btnStyles } from "@/styles/icons"; 7 | import { Mail } from "lucide-react"; 8 | import Link from "next/link"; 9 | import { MagicLinkForm } from "./magic-link-form"; 10 | 11 | export default function SignInPage() { 12 | return ( 13 |
14 |
15 |
16 |

Sign In

17 |

18 | Sign in to your account using one of the options below. 19 |

20 |
21 |
22 | 31 | 32 | Sign in with Google 33 | 34 | 43 | 44 | Sign in with GitHub 45 | 46 | 47 |
48 |
49 | 50 |
51 |
52 | 53 | Or sign in with email 54 | 55 |
56 |
57 | 58 | 59 | 60 |
61 |
62 | 63 |
64 |
65 | 66 | Other options 67 | 68 |
69 |
70 | 71 |
72 | 77 |
78 |
79 |
80 |
81 | ); 82 | } 83 | 84 | function GoogleIcon(props: React.SVGProps) { 85 | return ( 86 | 92 | Google 93 | 94 | 95 | ); 96 | } 97 | 98 | function GithubIcon(props: React.SVGProps) { 99 | return ( 100 | 112 | 113 | 114 | 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-up/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { afterLoginUrl } from "@/app-config"; 4 | import { rateLimitByIp, rateLimitByKey } from "@/lib/limiter"; 5 | import { unauthenticatedAction } from "@/lib/safe-action"; 6 | import { setSession } from "@/lib/session"; 7 | import { registerUserUseCase } from "@/use-cases/users"; 8 | import { redirect } from "next/navigation"; 9 | import { z } from "zod"; 10 | 11 | export const signUpAction = unauthenticatedAction 12 | .createServerAction() 13 | .input( 14 | z.object({ 15 | email: z.string().email(), 16 | password: z.string().min(8), 17 | }), 18 | ) 19 | .handler(async ({ input }) => { 20 | await rateLimitByIp({ key: "register", limit: 3, window: 30000 }); 21 | const user = await registerUserUseCase(input.email, input.password); 22 | await setSession(user.id); 23 | return redirect(afterLoginUrl); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-up/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { z } from "zod"; 4 | 5 | import { Input } from "@/components/ui/input"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import { useForm } from "react-hook-form"; 8 | import { 9 | Form, 10 | FormControl, 11 | FormField, 12 | FormItem, 13 | FormLabel, 14 | FormMessage, 15 | } from "@/components/ui/form"; 16 | import { pageTitleStyles } from "@/styles/common"; 17 | import { cn } from "@/lib/utils"; 18 | import { useServerAction } from "zsa-react"; 19 | import { signUpAction } from "./actions"; 20 | import { LoaderButton } from "@/components/loader-button"; 21 | import { useToast } from "@/components/ui/use-toast"; 22 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 23 | import { Terminal } from "lucide-react"; 24 | 25 | const registrationSchema = z 26 | .object({ 27 | email: z.string().email(), 28 | password: z.string().min(8), 29 | passwordConfirmation: z.string().min(8), 30 | }) 31 | .refine((data) => data.password === data.passwordConfirmation, { 32 | message: "Passwords don't match", 33 | path: ["passwordConfirmation"], 34 | }); 35 | 36 | export default function RegisterPage() { 37 | const { toast } = useToast(); 38 | 39 | const { execute, isPending, error } = useServerAction(signUpAction, { 40 | onError({ err }) { 41 | toast({ 42 | title: "Something went wrong", 43 | description: err.message, 44 | variant: "destructive", 45 | }); 46 | }, 47 | }); 48 | 49 | const form = useForm>({ 50 | resolver: zodResolver(registrationSchema), 51 | defaultValues: { 52 | email: "", 53 | password: "", 54 | passwordConfirmation: "", 55 | }, 56 | }); 57 | 58 | function onSubmit(values: z.infer) { 59 | execute(values); 60 | } 61 | 62 | return ( 63 |
64 |

Sign Up

65 | 66 |
67 | 68 | ( 72 | 73 | Email 74 | 75 | 81 | 82 | 83 | 84 | )} 85 | /> 86 | 87 | ( 91 | 92 | Password 93 | 94 | 100 | 101 | 102 | 103 | )} 104 | /> 105 | 106 | ( 110 | 111 | Confirm Password 112 | 113 | 119 | 120 | 121 | 122 | )} 123 | /> 124 | 125 | {error && ( 126 | 127 | 128 | Uhoh, we couldn't log you in 129 | {error.message} 130 | 131 | )} 132 | 133 | 134 | Register 135 | 136 | 137 | 138 |
139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /src/app/_header/header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Suspense, cache } from "react"; 3 | import { getCurrentUser } from "@/lib/session"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuLabel, 10 | DropdownMenuSeparator, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu"; 13 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 14 | import { LayoutDashboard, Lightbulb, Loader2Icon, LogOut } from "lucide-react"; 15 | import { getUserProfileUseCase } from "@/use-cases/users"; 16 | import { ModeToggle } from "./mode-toggle"; 17 | import { MenuButton } from "./menu-button"; 18 | import { UserId } from "@/types"; 19 | 20 | const profilerLoader = cache(getUserProfileUseCase); 21 | 22 | export async function Header() { 23 | const user = await getCurrentUser(); 24 | 25 | return ( 26 |
27 |
28 |
29 | 30 | 31 |
APP
32 | 33 | 34 |
35 | {user && ( 36 | 45 | )} 46 |
47 |
48 | 49 |
50 | 53 | 54 |
55 | } 56 | > 57 | 58 | 59 |
60 |
61 | 62 | ); 63 | } 64 | 65 | async function ProfileAvatar({ userId }: { userId: number }) { 66 | const profile = await profilerLoader(userId); 67 | 68 | return ( 69 | 70 | 71 | 72 | {profile.displayName?.substring(0, 2).toUpperCase() ?? "AA"} 73 | 74 | 75 | ); 76 | } 77 | 78 | async function HeaderActions() { 79 | const user = await getCurrentUser(); 80 | const isSignedIn = !!user; 81 | 82 | return ( 83 | <> 84 | {isSignedIn ? ( 85 | <> 86 |
87 | 88 |
89 | 90 |
91 | 92 |
93 | 94 | ) : ( 95 | <> 96 | 97 | 98 | 101 | 102 | )} 103 | 104 | ); 105 | } 106 | async function ProfileDropdown({ userId }: { userId: UserId }) { 107 | const profile = await profilerLoader(userId); 108 | return ( 109 | 110 | 111 | 114 | .. 115 | 116 | } 117 | > 118 | 119 | 120 | 121 | 122 | 123 | {profile.displayName} 124 | 125 | 126 | 127 | 128 | Sign Out 129 | 130 | 131 | 132 | 133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /src/app/_header/menu-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuTrigger, 8 | } from "@/components/ui/dropdown-menu"; 9 | import { BookIcon, MenuIcon, SearchIcon, UsersIcon } from "lucide-react"; 10 | import Link from "next/link"; 11 | 12 | export function MenuButton() { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 24 | Your Groups 25 | 26 | 27 | 28 | 32 | Browse Groups 33 | 34 | 35 | 36 | 37 | API Docs 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/_header/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Moon, Sun } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/app/api/login/github/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers"; 2 | import { OAuth2RequestError } from "arctic"; 3 | import { createGithubUserUseCase } from "@/use-cases/users"; 4 | import { getAccountByGithubIdUseCase } from "@/use-cases/accounts"; 5 | import { github } from "@/lib/auth"; 6 | import { afterLoginUrl } from "@/app-config"; 7 | import { setSession } from "@/lib/session"; 8 | 9 | export async function GET(request: Request): Promise { 10 | const url = new URL(request.url); 11 | const code = url.searchParams.get("code"); 12 | const state = url.searchParams.get("state"); 13 | const storedState = cookies().get("github_oauth_state")?.value ?? null; 14 | if (!code || !state || !storedState || state !== storedState) { 15 | return new Response(null, { 16 | status: 400, 17 | }); 18 | } 19 | 20 | try { 21 | const tokens = await github.validateAuthorizationCode(code); 22 | const githubUserResponse = await fetch("https://api.github.com/user", { 23 | headers: { 24 | Authorization: `Bearer ${tokens.accessToken}`, 25 | }, 26 | }); 27 | const githubUser: GitHubUser = await githubUserResponse.json(); 28 | 29 | const existingAccount = await getAccountByGithubIdUseCase(githubUser.id); 30 | 31 | if (existingAccount) { 32 | await setSession(existingAccount.userId); 33 | return new Response(null, { 34 | status: 302, 35 | headers: { 36 | Location: afterLoginUrl, 37 | }, 38 | }); 39 | } 40 | 41 | if (!githubUser.email) { 42 | const githubUserEmailResponse = await fetch( 43 | "https://api.github.com/user/emails", 44 | { 45 | headers: { 46 | Authorization: `Bearer ${tokens.accessToken}`, 47 | }, 48 | }, 49 | ); 50 | const githubUserEmails = await githubUserEmailResponse.json(); 51 | 52 | githubUser.email = getPrimaryEmail(githubUserEmails); 53 | } 54 | 55 | const userId = await createGithubUserUseCase(githubUser); 56 | await setSession(userId); 57 | return new Response(null, { 58 | status: 302, 59 | headers: { 60 | Location: afterLoginUrl, 61 | }, 62 | }); 63 | } catch (e) { 64 | console.error(e); 65 | // the specific error message depends on the provider 66 | if (e instanceof OAuth2RequestError) { 67 | // invalid code 68 | return new Response(null, { 69 | status: 400, 70 | }); 71 | } 72 | return new Response(null, { 73 | status: 500, 74 | }); 75 | } 76 | } 77 | 78 | export interface GitHubUser { 79 | id: string; 80 | login: string; 81 | avatar_url: string; 82 | email: string; 83 | } 84 | 85 | function getPrimaryEmail(emails: Email[]): string { 86 | const primaryEmail = emails.find((email) => email.primary); 87 | return primaryEmail!.email; 88 | } 89 | 90 | interface Email { 91 | email: string; 92 | primary: boolean; 93 | verified: boolean; 94 | visibility: string | null; 95 | } 96 | -------------------------------------------------------------------------------- /src/app/api/login/github/route.ts: -------------------------------------------------------------------------------- 1 | import { github } from "@/lib/auth"; 2 | import { generateState } from "arctic"; 3 | import { cookies } from "next/headers"; 4 | 5 | export async function GET(): Promise { 6 | const state = generateState(); 7 | const url = await github.createAuthorizationURL(state, { 8 | scopes: ["user:email"], 9 | }); 10 | 11 | cookies().set("github_oauth_state", state, { 12 | path: "/", 13 | secure: process.env.NODE_ENV === "production", 14 | httpOnly: true, 15 | maxAge: 60 * 10, 16 | sameSite: "lax", 17 | }); 18 | 19 | return Response.redirect(url); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/api/login/google/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers"; 2 | import { OAuth2RequestError } from "arctic"; 3 | import { googleAuth } from "@/lib/auth"; 4 | import { createGoogleUserUseCase } from "@/use-cases/users"; 5 | import { getAccountByGoogleIdUseCase } from "@/use-cases/accounts"; 6 | import { afterLoginUrl } from "@/app-config"; 7 | import { setSession } from "@/lib/session"; 8 | 9 | export async function GET(request: Request): Promise { 10 | const url = new URL(request.url); 11 | const code = url.searchParams.get("code"); 12 | const state = url.searchParams.get("state"); 13 | const storedState = cookies().get("google_oauth_state")?.value ?? null; 14 | const codeVerifier = cookies().get("google_code_verifier")?.value ?? null; 15 | 16 | if ( 17 | !code || 18 | !state || 19 | !storedState || 20 | state !== storedState || 21 | !codeVerifier 22 | ) { 23 | return new Response(null, { 24 | status: 400, 25 | }); 26 | } 27 | 28 | try { 29 | const tokens = await googleAuth.validateAuthorizationCode( 30 | code, 31 | codeVerifier, 32 | ); 33 | const response = await fetch( 34 | "https://openidconnect.googleapis.com/v1/userinfo", 35 | { 36 | headers: { 37 | Authorization: `Bearer ${tokens.accessToken}`, 38 | }, 39 | }, 40 | ); 41 | const googleUser: GoogleUser = await response.json(); 42 | 43 | const existingAccount = await getAccountByGoogleIdUseCase(googleUser.sub); 44 | 45 | if (existingAccount) { 46 | await setSession(existingAccount.userId); 47 | return new Response(null, { 48 | status: 302, 49 | headers: { 50 | Location: afterLoginUrl, 51 | }, 52 | }); 53 | } 54 | 55 | const userId = await createGoogleUserUseCase(googleUser); 56 | await setSession(userId); 57 | return new Response(null, { 58 | status: 302, 59 | headers: { 60 | Location: afterLoginUrl, 61 | }, 62 | }); 63 | } catch (e) { 64 | // the specific error message depends on the provider 65 | if (e instanceof OAuth2RequestError) { 66 | // invalid code 67 | return new Response(null, { 68 | status: 400, 69 | }); 70 | } 71 | return new Response(null, { 72 | status: 500, 73 | }); 74 | } 75 | } 76 | 77 | export interface GoogleUser { 78 | sub: string; 79 | name: string; 80 | given_name: string; 81 | family_name: string; 82 | picture: string; 83 | email: string; 84 | email_verified: boolean; 85 | locale: string; 86 | } 87 | -------------------------------------------------------------------------------- /src/app/api/login/google/route.ts: -------------------------------------------------------------------------------- 1 | import { googleAuth } from "@/lib/auth"; 2 | import { cookies } from "next/headers"; 3 | import { generateCodeVerifier, generateState } from "arctic"; 4 | 5 | export async function GET(): Promise { 6 | const state = generateState(); 7 | const codeVerifier = generateCodeVerifier(); 8 | const url = await googleAuth.createAuthorizationURL(state, codeVerifier, { 9 | scopes: ["profile", "email"], 10 | }); 11 | 12 | cookies().set("google_oauth_state", state, { 13 | secure: true, 14 | path: "/", 15 | httpOnly: true, 16 | maxAge: 60 * 10, 17 | }); 18 | 19 | cookies().set("google_code_verifier", codeVerifier, { 20 | secure: true, 21 | path: "/", 22 | httpOnly: true, 23 | maxAge: 60 * 10, 24 | }); 25 | 26 | return Response.redirect(url); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/api/login/magic/route.ts: -------------------------------------------------------------------------------- 1 | import { afterLoginUrl } from "@/app-config"; 2 | import { setSession } from "@/lib/session"; 3 | import { loginWithMagicLinkUseCase } from "@/use-cases/magic-link"; 4 | 5 | export const dynamic = "force-dynamic"; 6 | 7 | export async function GET(request: Request): Promise { 8 | try { 9 | const url = new URL(request.url); 10 | const token = url.searchParams.get("token"); 11 | 12 | if (!token) { 13 | return new Response(null, { 14 | status: 302, 15 | headers: { 16 | Location: "/sign-in", 17 | }, 18 | }); 19 | } 20 | 21 | const user = await loginWithMagicLinkUseCase(token); 22 | 23 | await setSession(user.id); 24 | 25 | return new Response(null, { 26 | status: 302, 27 | headers: { 28 | Location: afterLoginUrl, 29 | }, 30 | }); 31 | } catch (err) { 32 | console.error(err); 33 | return new Response(null, { 34 | status: 302, 35 | headers: { 36 | Location: "/sign-in/magic/error", 37 | }, 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/api/login/verify-email/route.ts: -------------------------------------------------------------------------------- 1 | import { verifyEmailUseCase } from "@/use-cases/users"; 2 | 3 | export const dynamic = "force-dynamic"; 4 | 5 | export async function GET(request: Request): Promise { 6 | try { 7 | const url = new URL(request.url); 8 | const token = url.searchParams.get("token"); 9 | 10 | if (!token) { 11 | return new Response(null, { 12 | status: 302, 13 | headers: { 14 | Location: "/sign-in", 15 | }, 16 | }); 17 | } 18 | 19 | await verifyEmailUseCase(token); 20 | 21 | return new Response(null, { 22 | status: 302, 23 | headers: { 24 | Location: "/verify-success", 25 | }, 26 | }); 27 | } catch (err) { 28 | console.error(err); 29 | return new Response(null, { 30 | status: 302, 31 | headers: { 32 | Location: "/sign-in", 33 | }, 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/api/sign-out/route.ts: -------------------------------------------------------------------------------- 1 | import { lucia, validateRequest } from "@/lib/auth"; 2 | import { cookies } from "next/headers"; 3 | import { redirect } from "next/navigation"; 4 | 5 | export async function GET(): Promise { 6 | await new Promise((resolve) => setTimeout(resolve, 1000)); 7 | 8 | const { session } = await validateRequest(); 9 | if (!session) { 10 | redirect("/sign-in"); 11 | } 12 | 13 | await lucia.invalidateSession(session.id); 14 | const sessionCookie = lucia.createBlankSessionCookie(); 15 | cookies().set( 16 | sessionCookie.name, 17 | sessionCookie.value, 18 | sessionCookie.attributes, 19 | ); 20 | redirect("/signed-out"); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "@/lib/session"; 2 | import { redirect } from "next/navigation"; 3 | 4 | export default async function DashboardPage() { 5 | const user = await getCurrentUser(); 6 | if (!user) redirect("/sign-in"); 7 | 8 | return ( 9 |
10 |

Dashboard

11 | 12 |

put your dashboardy stuff here

13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { pageTitleStyles } from "@/styles/common"; 4 | 5 | export default function ErrorPage({ 6 | error, 7 | }: { 8 | error: Error & { digest?: string }; 9 | }) { 10 | return ( 11 |
12 | <> 13 |

Oops! Something went wrong

14 |

{error.message}

15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --success: 123 100% 90%; 32 | --success-foreground: 0 0% 0%; 33 | 34 | --border: 214.3 31.8% 91.4%; 35 | --input: 214.3 31.8% 91.4%; 36 | --ring: 222.2 84% 4.9%; 37 | 38 | --radius: 0.5rem; 39 | } 40 | 41 | .dark { 42 | --background: 222.2 84% 4.9%; 43 | --foreground: 210 40% 98%; 44 | 45 | --card: 222.2 84% 4.9%; 46 | --card-foreground: 210 40% 98%; 47 | 48 | --popover: 222.2 84% 4.9%; 49 | --popover-foreground: 210 40% 98%; 50 | 51 | --primary: 210 40% 98%; 52 | --primary-foreground: 222.2 47.4% 11.2%; 53 | 54 | --secondary: 217.2 32.6% 17.5%; 55 | --secondary-foreground: 210 40% 98%; 56 | 57 | --muted: 217.2 32.6% 17.5%; 58 | --muted-foreground: 215 20.2% 65.1%; 59 | 60 | --accent: 217.2 32.6% 17.5%; 61 | --accent-foreground: 210 40% 98%; 62 | 63 | --destructive: 0 100% 60%; 64 | --destructive-foreground: 0 46% 100%; 65 | 66 | --border: 217.2 32.6% 17.5%; 67 | --input: 217.2 32.6% 17.5%; 68 | --ring: 212.7 26.8% 83.9%; 69 | 70 | --success: 112.7 59.6% 69.8%; 71 | --success-foreground: 0 0% 0%; 72 | } 73 | } 74 | 75 | @layer base { 76 | * { 77 | @apply border-border; 78 | } 79 | 80 | body { 81 | @apply bg-background text-foreground; 82 | font-family: var(--font-libre_franklin), sans-serif; 83 | } 84 | 85 | a { 86 | @apply text-foreground; 87 | } 88 | 89 | h1, 90 | h2, 91 | h3, 92 | h4, 93 | h5, 94 | h6 { 95 | font-family: var(--font-archivo), sans-serif; 96 | @apply text-foreground; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/app/globals.css"; 2 | import type { Metadata } from "next"; 3 | import NextTopLoader from "nextjs-toploader"; 4 | import { cn } from "@/lib/utils"; 5 | import { ReactNode } from "react"; 6 | 7 | import { Archivo } from "next/font/google"; 8 | import { Libre_Franklin } from "next/font/google"; 9 | import { Providers } from "./providers"; 10 | import { Toaster } from "@/components/ui/toaster"; 11 | import { Header } from "./_header/header"; 12 | 13 | const archivo = Archivo({ 14 | subsets: ["latin"], 15 | display: "swap", 16 | variable: "--font-archivo", 17 | }); 18 | const libre_franklin = Libre_Franklin({ 19 | subsets: ["latin"], 20 | display: "swap", 21 | variable: "--font-libre_franklin", 22 | }); 23 | 24 | export const metadata: Metadata = { 25 | title: "WDC Template", 26 | icons: [ 27 | { rel: "icon", type: "image/png", sizes: "48x48", url: "/favicon.ico" }, 28 | ], 29 | keywords: "yolo", 30 | description: "A simple next.js template including drizzle and lucia auth", 31 | }; 32 | 33 | export default async function RootLayout({ 34 | children, 35 | }: Readonly<{ 36 | children: ReactNode; 37 | }>) { 38 | return ( 39 | 40 | 46 | 47 | 48 |
49 |
{children}
50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/maintenance.tsx: -------------------------------------------------------------------------------- 1 | export function Maintenance() { 2 | return ( 3 |
4 |

Maintenance

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return ( 3 |
4 |

Not Found

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "@/lib/session"; 2 | import { redirect } from "next/navigation"; 3 | 4 | export default async function HomePage() { 5 | const user = await getCurrentUser(); 6 | if (user) redirect("/dashboard"); 7 | redirect("/sign-in"); 8 | } 9 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | import { ThemeProvider } from "./theme-provider"; 5 | 6 | export function Providers({ children }: { children: ReactNode }) { 7 | return ( 8 | 14 | {children} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/app/signed-out/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { pageTitleStyles } from "@/styles/common"; 5 | import Link from "next/link"; 6 | import { useRouter } from "next/navigation"; 7 | import { useEffect } from "react"; 8 | 9 | export default function SignedOutPage() { 10 | const router = useRouter(); 11 | useEffect(() => { 12 | router.refresh(); 13 | }, [router]); 14 | 15 | return ( 16 |
17 |

Successfully Signed Out

18 |

19 | You have been successfully signed out. You can now sign in to your 20 | account. 21 |

22 | 23 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/verify-success/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { pageTitleStyles } from "@/styles/common"; 3 | import Link from "next/link"; 4 | 5 | export default function VerifySuccess() { 6 | return ( 7 |
8 |

Email Successfully Verified

9 |

10 | Your email has been successfully verified. You can now sign in to your 11 | account. 12 |

13 | 14 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/loader-button.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2Icon } from "lucide-react"; 2 | import { Button, ButtonProps } from "@/components/ui/button"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | export function LoaderButton({ 6 | children, 7 | isLoading, 8 | className, 9 | ...props 10 | }: ButtonProps & { isLoading: boolean }) { 11 | return ( 12 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | success: 12 | "bg-success text-success-foreground [&>svg]:text-success-foreground", 13 | default: "bg-background text-foreground", 14 | destructive: 15 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 16 | }, 17 | }, 18 | defaultVariants: { 19 | variant: "default", 20 | }, 21 | }, 22 | ); 23 | 24 | const Alert = React.forwardRef< 25 | HTMLDivElement, 26 | React.HTMLAttributes & VariantProps 27 | >(({ className, variant, ...props }, ref) => ( 28 |
34 | )); 35 | Alert.displayName = "Alert"; 36 | 37 | const AlertTitle = React.forwardRef< 38 | HTMLParagraphElement, 39 | React.HTMLAttributes 40 | >(({ className, ...props }, ref) => ( 41 |
46 | )); 47 | AlertTitle.displayName = "AlertTitle"; 48 | 49 | const AlertDescription = React.forwardRef< 50 | HTMLParagraphElement, 51 | React.HTMLAttributes 52 | >(({ className, ...props }, ref) => ( 53 |
58 | )); 59 | AlertDescription.displayName = "AlertDescription"; 60 | 61 | export { Alert, AlertTitle, AlertDescription }; 62 | -------------------------------------------------------------------------------- /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 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export { Avatar, AvatarImage, AvatarFallback }; 51 | -------------------------------------------------------------------------------- /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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | }, 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | }, 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /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 { Check, ChevronRight, Circle } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root; 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean; 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )); 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName; 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )); 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName; 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )); 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean; 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )); 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )); 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName; 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )); 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean; 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )); 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )); 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ); 181 | }; 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | }; 201 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as LabelPrimitive from "@radix-ui/react-label"; 3 | import { Slot } from "@radix-ui/react-slot"; 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form"; 12 | 13 | import { cn } from "@/lib/utils"; 14 | import { Label } from "@/components/ui/label"; 15 | 16 | const Form = FormProvider; 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath, 21 | > = { 22 | name: TName; 23 | }; 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue, 27 | ); 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath, 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext); 44 | const itemContext = React.useContext(FormItemContext); 45 | const { getFieldState, formState } = useFormContext(); 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState); 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within "); 51 | } 52 | 53 | const { id } = itemContext; 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | }; 63 | }; 64 | 65 | type FormItemContextValue = { 66 | id: string; 67 | }; 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue, 71 | ); 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId(); 78 | 79 | return ( 80 | 81 |
82 | 83 | ); 84 | }); 85 | FormItem.displayName = "FormItem"; 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField(); 92 | 93 | return ( 94 |