├── .prettierrc ├── src ├── vite-env.d.ts ├── lib │ └── utils.ts ├── components │ ├── Code.tsx │ ├── SignInMethodDivider.tsx │ ├── ThemeToggle.tsx │ ├── ui │ │ ├── input.tsx │ │ ├── toaster.tsx │ │ ├── toggle.tsx │ │ ├── toggle-group.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── use-toast.ts │ │ ├── toast.tsx │ │ ├── select.tsx │ │ └── dropdown-menu.tsx │ └── UserMenu.tsx ├── Chat │ ├── randomName.ts │ ├── ChatIntro.tsx │ ├── MessageList.tsx │ ├── Message.tsx │ └── Chat.tsx ├── main.tsx ├── App.tsx ├── index.css ├── GetStarted │ ├── ConvexLogo.tsx │ └── GetStartedDialog.tsx ├── Layout.tsx └── SignInForm.tsx ├── postcss.config.js ├── convex ├── auth.config.ts ├── http.ts ├── users.ts ├── _generated │ ├── api.js │ ├── api.d.ts │ ├── dataModel.d.ts │ ├── server.js │ └── server.d.ts ├── tsconfig.json ├── schema.ts ├── lib │ └── permissions.ts ├── auth.ts ├── README.md └── messages.ts ├── tsconfig.json ├── vite.config.ts ├── tsconfig.node.json ├── components.json ├── index.html ├── .gitignore ├── tsconfig.app.json ├── public └── convex.svg ├── .eslintrc.cjs ├── package.json ├── tailwind.config.js ├── setup.mjs └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /convex/auth.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | providers: [ 3 | { 4 | domain: process.env.CONVEX_SITE_URL, 5 | applicationID: "convex", 6 | }, 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /convex/http.ts: -------------------------------------------------------------------------------- 1 | import { httpRouter } from "convex/server"; 2 | import { auth } from "./auth"; 3 | 4 | const http = httpRouter(); 5 | 6 | auth.addHttpRoutes(http); 7 | 8 | export default http; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Code.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export const Code = ({ children }: { children: ReactNode }) => { 4 | return ( 5 | 6 | {children} 7 | 8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /convex/users.ts: -------------------------------------------------------------------------------- 1 | import { getAuthUserId } from "@convex-dev/auth/server"; 2 | import { query } from "./_generated/server"; 3 | 4 | export const viewer = query({ 5 | args: {}, 6 | handler: async (ctx) => { 7 | const userId = await getAuthUserId(ctx); 8 | return userId !== null ? ctx.db.get(userId) : null; 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import path from "path"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "noEmit": true 11 | }, 12 | "include": ["vite.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "stone", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Convex + React + Convex Auth 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Ignored for the template, you probably want to remove it: 27 | package-lock.json -------------------------------------------------------------------------------- /src/Chat/randomName.ts: -------------------------------------------------------------------------------- 1 | const namesList = 2 | "Robert,Linda,Daniel,Anthony,Donald,Paul,Kevin,Brian,Patricia,Jennifer," + 3 | "Elizabeth,William,Richard,Jessica,Lisa,Nancy,Matthew,Ashley,Kimberly," + 4 | "Donna,Kenneth,Melissa"; 5 | const names = namesList.split(","); 6 | 7 | export function randomName(): string { 8 | const picked = names[Math.floor(Math.random() * names.length)]; 9 | return Math.random() > 0.5 ? picked.slice(0, 3) : picked; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/SignInMethodDivider.tsx: -------------------------------------------------------------------------------- 1 | export function SignInMethodDivider() { 2 | return ( 3 |
4 |
5 | 6 |
7 |
8 | 9 | Or continue with 10 | 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/Chat/ChatIntro.tsx: -------------------------------------------------------------------------------- 1 | export function ChatIntro() { 2 | return ( 3 |
4 |
5 |

Chat

6 |

7 | Open this app in multiple browser windows to see the real-time 8 | database in action 9 |

10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { anyApi } from "convex/server"; 12 | 13 | /** 14 | * A utility for referencing Convex functions in your app's API. 15 | * 16 | * Usage: 17 | * ```js 18 | * const myFunctionReference = api.myModule.myFunction; 19 | * ``` 20 | */ 21 | export const api = anyApi; 22 | export const internal = anyApi; 23 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { ConvexAuthProvider } from "@convex-dev/auth/react"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom/client"; 4 | import { ThemeProvider } from "next-themes"; 5 | import { ConvexReactClient } from "convex/react"; 6 | import App from "./App.tsx"; 7 | import "./index.css"; 8 | 9 | const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); 10 | 11 | ReactDOM.createRoot(document.getElementById("root")!).render( 12 | 13 | 14 | 15 | 16 | 17 | 18 | , 19 | ); 20 | -------------------------------------------------------------------------------- /src/Chat/MessageList.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useRef } from "react"; 2 | 3 | export function MessageList({ 4 | messages, 5 | children, 6 | }: { 7 | messages: unknown; 8 | children: ReactNode; 9 | }) { 10 | const messageListRef = useRef(null); 11 | 12 | // Scrolls the list down when new messages 13 | // are received or sent. 14 | useEffect(() => { 15 | if (messageListRef.current) { 16 | messageListRef.current.scrollTo({ 17 | top: messageListRef.current.scrollHeight, 18 | behavior: "smooth", 19 | }); 20 | } 21 | }, [messages]); 22 | return ( 23 |
    27 | {children} 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; 4 | import { DesktopIcon, MoonIcon, SunIcon } from "@radix-ui/react-icons"; 5 | import { useTheme } from "next-themes"; 6 | 7 | export function ThemeToggle() { 8 | const { theme, setTheme } = useTheme(); 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* This TypeScript project config describes the environment that 3 | * Convex functions run in and is used to typecheck them. 4 | * You can modify it, but some settings required to use Convex. 5 | */ 6 | "compilerOptions": { 7 | /* These settings are not required by Convex and can be modified. */ 8 | "allowJs": true, 9 | "strict": true, 10 | "moduleResolution": "Bundler", 11 | "jsx": "react-jsx", 12 | "skipLibCheck": true, 13 | "allowSyntheticDefaultImports": true, 14 | 15 | /* These compiler options are required by Convex */ 16 | "target": "ESNext", 17 | "lib": ["ES2021", "dom"], 18 | "forceConsistentCasingInFileNames": true, 19 | "module": "ESNext", 20 | "isolatedModules": true, 21 | "noEmit": true 22 | }, 23 | "include": ["./**/*"], 24 | "exclude": ["./_generated"] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | 26 | /* Import paths */ 27 | "paths": { 28 | "@/*": ["./src/*"] 29 | } 30 | }, 31 | "include": ["src"] 32 | } 33 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | }, 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Toast, 3 | ToastClose, 4 | ToastDescription, 5 | ToastProvider, 6 | ToastTitle, 7 | ToastViewport, 8 | } from "@/components/ui/toast" 9 | import { useToast } from "@/components/ui/use-toast" 10 | 11 | export function Toaster() { 12 | const { toasts } = useToast() 13 | 14 | return ( 15 | 16 | {toasts.map(function ({ id, title, description, action, ...props }) { 17 | return ( 18 | 19 |
20 | {title && {title}} 21 | {description && ( 22 | {description} 23 | )} 24 |
25 | {action} 26 | 27 |
28 | ) 29 | })} 30 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from "@/Chat/Chat"; 2 | import { ChatIntro } from "@/Chat/ChatIntro"; 3 | import { Layout } from "@/Layout"; 4 | import { SignInForm } from "@/SignInForm"; 5 | import { UserMenu } from "@/components/UserMenu"; 6 | import { Authenticated, Unauthenticated, useQuery } from "convex/react"; 7 | import { api } from "../convex/_generated/api"; 8 | 9 | export default function App() { 10 | const user = useQuery(api.users.viewer); 11 | 12 | // Remove document-related queries until API is generated 13 | return ( 14 | 17 | {user?.name ?? user?.email} 18 | 19 | } 20 | > 21 | <> 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { authTables } from "@convex-dev/auth/server"; 2 | import { defineSchema, defineTable } from "convex/server"; 3 | import { v } from "convex/values"; 4 | 5 | export default defineSchema({ 6 | ...authTables, 7 | /* 8 | * Replace the default users table from authTables so we can add our own fields 9 | * New fields must be optional if all of the OAuth providers don't return them 10 | */ 11 | users: defineTable({ 12 | name: v.optional(v.string()), 13 | image: v.optional(v.string()), 14 | email: v.optional(v.string()), 15 | emailVerificationTime: v.optional(v.float64()), 16 | phone: v.optional(v.string()), 17 | phoneVerificationTime: v.optional(v.float64()), 18 | isAnonymous: v.optional(v.boolean()), 19 | 20 | /* 21 | * must be optional because OAuth providers don't return a role 22 | */ 23 | role: v.optional( 24 | v.union(v.literal("read"), v.literal("write"), v.literal("admin")), 25 | ), 26 | }) 27 | .index("email", ["email"]) 28 | .index("phone", ["phone"]), 29 | messages: defineTable({ 30 | body: v.string(), 31 | userId: v.id("users"), 32 | }).index("by_user", ["userId"]), 33 | }); 34 | -------------------------------------------------------------------------------- /convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | ApiFromModules, 13 | FilterApi, 14 | FunctionReference, 15 | } from "convex/server"; 16 | import type * as auth from "../auth.js"; 17 | import type * as http from "../http.js"; 18 | import type * as lib_permissions from "../lib/permissions.js"; 19 | import type * as messages from "../messages.js"; 20 | import type * as users from "../users.js"; 21 | 22 | /** 23 | * A utility for referencing Convex functions in your app's API. 24 | * 25 | * Usage: 26 | * ```js 27 | * const myFunctionReference = api.myModule.myFunction; 28 | * ``` 29 | */ 30 | declare const fullApi: ApiFromModules<{ 31 | auth: typeof auth; 32 | http: typeof http; 33 | "lib/permissions": typeof lib_permissions; 34 | messages: typeof messages; 35 | users: typeof users; 36 | }>; 37 | export declare const api: FilterApi< 38 | typeof fullApi, 39 | FunctionReference 40 | >; 41 | export declare const internal: FilterApi< 42 | typeof fullApi, 43 | FunctionReference 44 | >; 45 | -------------------------------------------------------------------------------- /src/Chat/Message.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { Id } from "../../convex/_generated/dataModel"; 3 | import { ReactNode } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Trash2 } from "lucide-react"; 6 | 7 | export function Message({ 8 | author, 9 | authorName, 10 | viewer, 11 | messageId, 12 | onDelete, 13 | showDelete, 14 | children, 15 | }: { 16 | author: Id<"users">; 17 | authorName: string; 18 | viewer: Id<"users">; 19 | messageId: Id<"messages">; 20 | onDelete?: (messageId: Id<"messages">) => void; 21 | showDelete?: boolean; 22 | children: ReactNode; 23 | }) { 24 | return ( 25 |
  • 31 |
    32 | {authorName} 33 | {showDelete && ( 34 | 42 | )} 43 |
    44 |

    50 | {children} 51 |

    52 |
  • 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TogglePrimitive from "@radix-ui/react-toggle"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const toggleVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-transparent", 13 | outline: 14 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", 15 | }, 16 | size: { 17 | default: "h-9 px-3", 18 | sm: "h-8 px-2", 19 | lg: "h-10 px-3", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | size: "default", 25 | }, 26 | }, 27 | ); 28 | 29 | const Toggle = React.forwardRef< 30 | React.ElementRef, 31 | React.ComponentPropsWithoutRef & 32 | VariantProps 33 | >(({ className, variant, size, ...props }, ref) => ( 34 | 39 | )); 40 | 41 | Toggle.displayName = TogglePrimitive.Root.displayName; 42 | 43 | export { Toggle, toggleVariants }; 44 | -------------------------------------------------------------------------------- /src/components/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeToggle } from "@/components/ThemeToggle"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuLabel, 8 | DropdownMenuSeparator, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu"; 11 | import { useAuthActions } from "@convex-dev/auth/react"; 12 | import { PersonIcon } from "@radix-ui/react-icons"; 13 | import { ReactNode } from "react"; 14 | 15 | export function UserMenu({ children }: { children: ReactNode }) { 16 | return ( 17 |
    18 | {children} 19 | 20 | 21 | 25 | 26 | 27 | {children} 28 | 29 | 30 | Theme 31 | 32 | 33 | 34 | 35 | 36 |
    37 | ); 38 | } 39 | 40 | function SignOutButton() { 41 | const { signOut } = useAuthActions(); 42 | return ( 43 | void signOut()}>Sign out 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /public/convex.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true, node: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended-type-checked", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: [ 10 | "dist", 11 | ".eslintrc.cjs", 12 | "convex/_generated", 13 | "postcss.config.js", 14 | "tailwind.config.js", 15 | "vite.config.ts", 16 | // shadcn components by default violate some rules 17 | "src/components/ui", 18 | ], 19 | parser: "@typescript-eslint/parser", 20 | parserOptions: { 21 | EXPERIMENTAL_useProjectService: true, 22 | }, 23 | plugins: ["react-refresh"], 24 | rules: { 25 | "react-refresh/only-export-components": [ 26 | "warn", 27 | { allowConstantExport: true }, 28 | ], 29 | 30 | // All of these overrides ease getting into 31 | // TypeScript, and can be removed for stricter 32 | // linting down the line. 33 | 34 | // Only warn on unused variables, and ignore variables starting with `_` 35 | "@typescript-eslint/no-unused-vars": [ 36 | "warn", 37 | { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }, 38 | ], 39 | 40 | // Allow escaping the compiler 41 | "@typescript-eslint/ban-ts-comment": "error", 42 | 43 | // Allow explicit `any`s 44 | "@typescript-eslint/no-explicit-any": "off", 45 | 46 | // START: Allow implicit `any`s 47 | "@typescript-eslint/no-unsafe-argument": "off", 48 | "@typescript-eslint/no-unsafe-assignment": "off", 49 | "@typescript-eslint/no-unsafe-call": "off", 50 | "@typescript-eslint/no-unsafe-member-access": "off", 51 | "@typescript-eslint/no-unsafe-return": "off", 52 | // END: Allow implicit `any`s 53 | 54 | // Allow async functions without await 55 | // for consistency (esp. Convex `handler`s) 56 | "@typescript-eslint/require-await": "off", 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* To change the theme colors, change the values below 6 | or use the "Copy code" button at https://ui.shadcn.com/themes */ 7 | @layer base { 8 | :root { 9 | --background: 0 0% 100%; 10 | --foreground: 20 14.3% 4.1%; 11 | 12 | --card: 0 0% 100%; 13 | --card-foreground: 20 14.3% 4.1%; 14 | 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 20 14.3% 4.1%; 17 | 18 | --primary: 24 9.8% 10%; 19 | --primary-foreground: 60 9.1% 97.8%; 20 | 21 | --secondary: 60 4.8% 95.9%; 22 | --secondary-foreground: 24 9.8% 10%; 23 | 24 | --muted: 60 4.8% 95.9%; 25 | --muted-foreground: 25 5.3% 44.7%; 26 | 27 | --accent: 60 4.8% 95.9%; 28 | --accent-foreground: 24 9.8% 10%; 29 | 30 | --destructive: 0 84.2% 60.2%; 31 | --destructive-foreground: 60 9.1% 97.8%; 32 | 33 | --border: 20 5.9% 90%; 34 | --input: 20 5.9% 90%; 35 | --ring: 20 14.3% 4.1%; 36 | 37 | --radius: 0.5rem; 38 | } 39 | 40 | .dark { 41 | --background: 20 14.3% 4.1%; 42 | --foreground: 60 9.1% 97.8%; 43 | 44 | --card: 20 14.3% 4.1%; 45 | --card-foreground: 60 9.1% 97.8%; 46 | 47 | --popover: 20 14.3% 4.1%; 48 | --popover-foreground: 60 9.1% 97.8%; 49 | 50 | --primary: 60 9.1% 97.8%; 51 | --primary-foreground: 24 9.8% 10%; 52 | 53 | --secondary: 12 6.5% 15.1%; 54 | --secondary-foreground: 60 9.1% 97.8%; 55 | 56 | --muted: 12 6.5% 15.1%; 57 | --muted-foreground: 24 5.4% 63.9%; 58 | 59 | --accent: 12 6.5% 15.1%; 60 | --accent-foreground: 60 9.1% 97.8%; 61 | 62 | --destructive: 0 62.8% 30.6%; 63 | --destructive-foreground: 60 9.1% 97.8%; 64 | 65 | --border: 12 6.5% 15.1%; 66 | --input: 12 6.5% 15.1%; 67 | --ring: 24 5.7% 82.9%; 68 | } 69 | } 70 | 71 | @layer base { 72 | * { 73 | @apply border-border; 74 | } 75 | body { 76 | @apply bg-background text-foreground; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import type { 12 | DataModelFromSchemaDefinition, 13 | DocumentByName, 14 | TableNamesInDataModel, 15 | SystemTableNames, 16 | } from "convex/server"; 17 | import type { GenericId } from "convex/values"; 18 | import schema from "../schema.js"; 19 | 20 | /** 21 | * The names of all of your Convex tables. 22 | */ 23 | export type TableNames = TableNamesInDataModel; 24 | 25 | /** 26 | * The type of a document stored in Convex. 27 | * 28 | * @typeParam TableName - A string literal type of the table name (like "users"). 29 | */ 30 | export type Doc = DocumentByName< 31 | DataModel, 32 | TableName 33 | >; 34 | 35 | /** 36 | * An identifier for a document in Convex. 37 | * 38 | * Convex documents are uniquely identified by their `Id`, which is accessible 39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 40 | * 41 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 42 | * 43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 44 | * strings when type checking. 45 | * 46 | * @typeParam TableName - A string literal type of the table name (like "users"). 47 | */ 48 | export type Id = 49 | GenericId; 50 | 51 | /** 52 | * A type describing your Convex data model. 53 | * 54 | * This type includes information about what tables you have, the type of 55 | * documents stored in those tables, and the indexes defined on them. 56 | * 57 | * This type is used to parameterize methods like `queryGeneric` and 58 | * `mutationGeneric` to make them type-safe. 59 | */ 60 | export type DataModel = DataModelFromSchemaDefinition; 61 | -------------------------------------------------------------------------------- /src/components/ui/toggle-group.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; 3 | import { VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | import { toggleVariants } from "@/components/ui/toggle"; 7 | 8 | const ToggleGroupContext = React.createContext< 9 | VariantProps 10 | >({ 11 | size: "default", 12 | variant: "default", 13 | }); 14 | 15 | const ToggleGroup = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef & 18 | VariantProps 19 | >(({ className, variant, size, children, ...props }, ref) => ( 20 | 25 | 26 | {children} 27 | 28 | 29 | )); 30 | 31 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; 32 | 33 | const ToggleGroupItem = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef & 36 | VariantProps 37 | >(({ className, children, variant, size, ...props }, ref) => { 38 | const context = React.useContext(ToggleGroupContext); 39 | 40 | return ( 41 | 52 | {children} 53 | 54 | ); 55 | }); 56 | 57 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; 58 | 59 | export { ToggleGroup, ToggleGroupItem }; 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "convex-auth-with-role-based-permissions", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "npm-run-all --parallel dev:frontend dev:backend", 8 | "dev:frontend": "vite --open", 9 | "dev:backend": "convex dev", 10 | "predev": "convex dev --until-success && node setup.mjs --once && convex dashboard", 11 | "build": "tsc -b && vite build", 12 | "lint": "tsc && eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 13 | "preview": "vite preview" 14 | }, 15 | "dependencies": { 16 | "@auth/core": "^0.34.1", 17 | "@convex-dev/auth": "^0.0.71", 18 | "@radix-ui/react-dialog": "^1.0.5", 19 | "@radix-ui/react-dropdown-menu": "^2.0.6", 20 | "@radix-ui/react-icons": "^1.3.0", 21 | "@radix-ui/react-select": "^2.1.2", 22 | "@radix-ui/react-slot": "^1.0.2", 23 | "@radix-ui/react-toast": "^1.2.1", 24 | "@radix-ui/react-toggle": "^1.0.3", 25 | "@radix-ui/react-toggle-group": "^1.0.4", 26 | "class-variance-authority": "^0.7.0", 27 | "clsx": "^2.1.1", 28 | "convex": "^1.17.0", 29 | "lucide-react": "^0.462.0", 30 | "next-themes": "^0.3.0", 31 | "react": "^18.3.1", 32 | "react-dom": "^18.3.1", 33 | "tailwind-merge": "^2.3.0", 34 | "tailwindcss-animate": "^1.0.7" 35 | }, 36 | "devDependencies": { 37 | "@types/node": "^20.14.9", 38 | "@types/react": "^18.3.3", 39 | "@types/react-dom": "^18.3.0", 40 | "@typescript-eslint/eslint-plugin": "^7.13.1", 41 | "@typescript-eslint/parser": "^7.13.1", 42 | "@vitejs/plugin-react": "^4.3.1", 43 | "autoprefixer": "^10.4.19", 44 | "dotenv": "^16.4.5", 45 | "eslint": "^8.57.0", 46 | "eslint-plugin-react-hooks": "^4.6.2", 47 | "eslint-plugin-react-refresh": "^0.4.7", 48 | "npm-run-all": "^4.1.5", 49 | "postcss": "^8.4.39", 50 | "prettier": "3.3.2", 51 | "tailwindcss": "^3.4.4", 52 | "typescript": "^5.2.2", 53 | "vite": "^5.3.1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/GetStarted/ConvexLogo.tsx: -------------------------------------------------------------------------------- 1 | export const ConvexLogo = ({ 2 | width, 3 | height, 4 | }: { 5 | width?: number; 6 | height?: number; 7 | }) => ( 8 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /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 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button"; 46 | return ( 47 | 52 | ); 53 | }, 54 | ); 55 | Button.displayName = "Button"; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
    17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
    29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

    41 | )); 42 | CardTitle.displayName = "CardTitle"; 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

    53 | )); 54 | CardDescription.displayName = "CardDescription"; 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

    61 | )); 62 | CardContent.displayName = "CardContent"; 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
    73 | )); 74 | CardFooter.displayName = "CardFooter"; 75 | 76 | export { 77 | Card, 78 | CardHeader, 79 | CardFooter, 80 | CardTitle, 81 | CardDescription, 82 | CardContent, 83 | }; 84 | -------------------------------------------------------------------------------- /src/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { GetStartedDialog } from "@/GetStarted/GetStartedDialog"; 3 | 4 | export function Layout({ 5 | menu, 6 | children, 7 | }: { 8 | menu?: ReactNode; 9 | children: ReactNode; 10 | }) { 11 | return ( 12 |
    13 |
    14 | 36 |
    37 |
    {children}
    38 |
    39 |
    40 | Built with ❤️ at{" "} 41 | Convex. 42 | Powered by Convex,{" "} 43 | Vite,{" "} 44 | React and{" "} 45 | shadcn/ui. 46 |
    47 |
    48 |
    49 | ); 50 | } 51 | 52 | function FooterLink({ href, children }: { href: string; children: ReactNode }) { 53 | return ( 54 | 59 | {children} 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /convex/lib/permissions.ts: -------------------------------------------------------------------------------- 1 | import { Id } from "../_generated/dataModel"; 2 | import { QueryCtx, MutationCtx } from "../_generated/server"; 3 | 4 | /** 5 | * Role type represents the possible permission levels in the system. 6 | * It is derived from the keys of VALID_ROLES. 7 | */ 8 | export type Role = (typeof VALID_ROLES)[keyof typeof VALID_ROLES]; 9 | 10 | /** 11 | * Valid roles in the system, in order of increasing privileges: 12 | * - READ: Can only view data 13 | * - WRITE: Can view and modify data 14 | * - ADMIN: Has full system access 15 | */ 16 | export const VALID_ROLES = { 17 | READ: "read", 18 | WRITE: "write", 19 | ADMIN: "admin", 20 | } as const; 21 | 22 | /** 23 | * Defines the hierarchy of roles using numeric values. 24 | * Higher numbers represent more privileges. 25 | * This allows for easy comparison of role levels using simple numeric comparison. 26 | */ 27 | const roleHierarchy: Record = { 28 | read: 0, 29 | write: 1, 30 | admin: 2, 31 | }; 32 | 33 | /** 34 | * Checks if a user has sufficient permissions for a required role. 35 | * 36 | * @param ctx - The Convex context (works with both Query and Mutation contexts) 37 | * @param userId - The ID of the user to check permissions for 38 | * @param requiredRole - The minimum role level required for the operation 39 | * @returns Promise - True if the user has sufficient permissions, false otherwise 40 | * 41 | * @example 42 | * // Check if a user has write permissions 43 | * const canWrite = await checkPermission(ctx, userId, "write"); 44 | * if (!canWrite) throw new Error("Insufficient permissions"); 45 | */ 46 | export async function checkPermission( 47 | ctx: QueryCtx | MutationCtx, 48 | userId: Id<"users">, 49 | requiredRole: Role, 50 | ): Promise { 51 | const user = await ctx.db.get(userId); 52 | 53 | /* 54 | * If the user doesn't exist, or the role is not valid, return false 55 | * This handles cases where: 56 | * 1. The user ID is invalid or the user was deleted 57 | * 2. The user object doesn't have a role field 58 | * 3. The user's role is not one of the valid roles 59 | */ 60 | if (!user || !user.role || !(user.role in roleHierarchy)) return false; 61 | 62 | // Compare the user's role level against the required role level 63 | return roleHierarchy[user.role] >= roleHierarchy[requiredRole]; 64 | } 65 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: "selector", 4 | content: [ 5 | "./pages/**/*.{ts,tsx}", 6 | "./components/**/*.{ts,tsx}", 7 | "./app/**/*.{ts,tsx}", 8 | "./src/**/*.{ts,tsx}", 9 | ], 10 | safelist: ["dark"], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | sm: "1000px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | }; 79 | -------------------------------------------------------------------------------- /convex/auth.ts: -------------------------------------------------------------------------------- 1 | import GitHub from "@auth/core/providers/github"; 2 | import Resend from "@auth/core/providers/resend"; 3 | import { convexAuth, getAuthUserId } from "@convex-dev/auth/server"; 4 | import { VALID_ROLES } from "./lib/permissions"; 5 | import { query, mutation } from "./_generated/server"; 6 | import { v } from "convex/values"; 7 | 8 | /** 9 | * Configure authentication using Convex's auth system. 10 | * This setup enables: 11 | * - GitHub OAuth authentication 12 | * - Resend email authentication 13 | * 14 | * The exported functions (auth, signIn, signOut, store) can be used 15 | * in your frontend to manage authentication state. 16 | */ 17 | export const { auth, signIn, signOut, store } = convexAuth({ 18 | providers: [GitHub, Resend], 19 | callbacks: { 20 | /** 21 | * This callback runs after a user signs in or updates their auth info. 22 | * We use it to set default permissions for new users. 23 | * 24 | * @param ctx - Convex context for database operations 25 | * @param args - Contains userId and flags for new/existing users 26 | */ 27 | async afterUserCreatedOrUpdated(ctx, args) { 28 | // Skip if this is an existing user update 29 | if (args.existingUserId) return; 30 | 31 | // For new users, set their default role to READ 32 | await ctx.db.patch(args.userId, { 33 | role: VALID_ROLES.READ, 34 | }); 35 | }, 36 | }, 37 | }); 38 | 39 | /** 40 | * Query to get the currently authenticated user's data. 41 | * Returns null if no user is signed in. 42 | * 43 | * @example 44 | * // In your React component: 45 | * const me = useQuery(api.auth.getMe); 46 | * if (!me) return ; 47 | */ 48 | export const getMe = query({ 49 | args: {}, 50 | handler: async (ctx) => { 51 | const userId = await getAuthUserId(ctx); 52 | if (!userId) return null; 53 | 54 | return await ctx.db.get(userId); 55 | }, 56 | }); 57 | 58 | /** 59 | * Mutation to update the current user's role. 60 | * This should typically be restricted to admin users in a real application. 61 | * 62 | * @throws Error if user is not signed in 63 | * 64 | * @example 65 | * // In your React component: 66 | * const updateRole = useMutation(api.auth.updateRole); 67 | * await updateRole({ role: "write" }); 68 | */ 69 | export const updateRole = mutation({ 70 | args: { 71 | role: v.union(v.literal("read"), v.literal("write"), v.literal("admin")), 72 | }, 73 | handler: async (ctx, args) => { 74 | const userId = await getAuthUserId(ctx); 75 | if (!userId) throw new Error("Not signed in"); 76 | await ctx.db.patch(userId, { role: args.role }); 77 | }, 78 | }); 79 | -------------------------------------------------------------------------------- /setup.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This script runs `npx @convex-dev/auth` to help with setting up 3 | * environment variables for Convex Auth. 4 | * 5 | * You can safely delete it and remove it from package.json scripts. 6 | */ 7 | 8 | import fs from "fs"; 9 | import { config as loadEnvFile } from "dotenv"; 10 | import { spawnSync } from "child_process"; 11 | 12 | if (!fs.existsSync(".env.local")) { 13 | // Something is off, skip the script. 14 | process.exit(0); 15 | } 16 | 17 | const config = {}; 18 | loadEnvFile({ path: ".env.local", processEnv: config }); 19 | 20 | const runOnceWorkflow = process.argv.includes("--once"); 21 | 22 | if (runOnceWorkflow && config.SETUP_SCRIPT_RAN !== undefined) { 23 | // The script has already ran once, skip. 24 | process.exit(0); 25 | } 26 | 27 | // The fallback should never be used. 28 | const deploymentName = 29 | config.CONVEX_DEPLOYMENT.split(":").slice(-1)[0] ?? ""; 30 | 31 | const variables = JSON.stringify({ 32 | help: 33 | "This template includes prebuilt sign-in via GitHub OAuth and " + 34 | "magic links via Resend. " + 35 | "This command can help you configure the credentials for these services " + 36 | "via additional Convex environment variables.", 37 | providers: [ 38 | { 39 | name: "GitHub OAuth", 40 | help: 41 | "Create a GitHub OAuth App, follow the instruction here: " + 42 | "https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app\n\n" + 43 | `When you're asked for a callback URL use:\n\n` + 44 | ` https://${deploymentName}.convex.site/api/auth/callback/github`, 45 | variables: [ 46 | { 47 | name: "AUTH_GITHUB_ID", 48 | description: "the Client ID of your GitHub OAuth App", 49 | }, 50 | { 51 | name: "AUTH_GITHUB_SECRET", 52 | description: "the generated client secret", 53 | }, 54 | ], 55 | }, 56 | { 57 | name: "Resend", 58 | help: "Sign up for Resend at https://resend.com/signup. Then create an API Key.", 59 | variables: [ 60 | { 61 | name: "AUTH_RESEND_KEY", 62 | description: "the API Key", 63 | }, 64 | ], 65 | }, 66 | ], 67 | success: 68 | "You're all set. If you need to, you can rerun this command with `node setup.mjs`.", 69 | }); 70 | 71 | console.error( 72 | "You chose Convex Auth as the auth solution. " + 73 | "This command will walk you through setting up " + 74 | "the required Convex environment variables", 75 | ); 76 | 77 | const result = spawnSync( 78 | "npx", 79 | ["@convex-dev/auth", "--variables", variables, "--skip-git-check"], 80 | { stdio: "inherit" }, 81 | ); 82 | 83 | if (runOnceWorkflow) { 84 | fs.writeFileSync(".env.local", `\nSETUP_SCRIPT_RAN=1\n`, { flag: "a" }); 85 | } 86 | 87 | process.exit(result.status); 88 | -------------------------------------------------------------------------------- /convex/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Convex functions directory! 2 | 3 | Write your Convex functions here. 4 | See https://docs.convex.dev/functions for more. 5 | 6 | A query function that takes two arguments looks like: 7 | 8 | ```ts 9 | // functions.js 10 | import { query } from "./_generated/server"; 11 | import { v } from "convex/values"; 12 | 13 | export const myQueryFunction = query({ 14 | // Validators for arguments. 15 | args: { 16 | first: v.number(), 17 | second: v.string(), 18 | }, 19 | 20 | // Function implementation. 21 | handler: async (ctx, args) => { 22 | // Read the database as many times as you need here. 23 | // See https://docs.convex.dev/database/reading-data. 24 | const documents = await ctx.db.query("tablename").collect(); 25 | 26 | // Arguments passed from the client are properties of the args object. 27 | console.log(args.first, args.second); 28 | 29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data, 30 | // remove non-public properties, or create new objects. 31 | return documents; 32 | }, 33 | }); 34 | ``` 35 | 36 | Using this query function in a React component looks like: 37 | 38 | ```ts 39 | const data = useQuery(api.functions.myQueryFunction, { 40 | first: 10, 41 | second: "hello", 42 | }); 43 | ``` 44 | 45 | A mutation function looks like: 46 | 47 | ```ts 48 | // functions.js 49 | import { mutation } from "./_generated/server"; 50 | import { v } from "convex/values"; 51 | 52 | export const myMutationFunction = mutation({ 53 | // Validators for arguments. 54 | args: { 55 | first: v.string(), 56 | second: v.string(), 57 | }, 58 | 59 | // Function implementation. 60 | handler: async (ctx, args) => { 61 | // Insert or modify documents in the database here. 62 | // Mutations can also read from the database like queries. 63 | // See https://docs.convex.dev/database/writing-data. 64 | const message = { body: args.first, author: args.second }; 65 | const id = await ctx.db.insert("messages", message); 66 | 67 | // Optionally, return a value from your mutation. 68 | return await ctx.db.get(id); 69 | }, 70 | }); 71 | ``` 72 | 73 | Using this mutation function in a React component looks like: 74 | 75 | ```ts 76 | const mutation = useMutation(api.functions.myMutationFunction); 77 | function handleButtonPress() { 78 | // fire and forget, the most common way to use mutations 79 | mutation({ first: "Hello!", second: "me" }); 80 | // OR 81 | // use the result once the mutation has completed 82 | mutation({ first: "Hello!", second: "me" }).then((result) => 83 | console.log(result), 84 | ); 85 | } 86 | ``` 87 | 88 | Use the Convex CLI to push your functions to a deployment. See everything 89 | the Convex CLI can do by running `npx convex -h` in your project root 90 | directory. To learn more, launch the docs with `npx convex docs`. 91 | -------------------------------------------------------------------------------- /src/SignInForm.tsx: -------------------------------------------------------------------------------- 1 | import { SignInMethodDivider } from "@/components/SignInMethodDivider"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Input } from "@/components/ui/input"; 4 | import { Toaster } from "@/components/ui/toaster"; 5 | import { useToast } from "@/components/ui/use-toast"; 6 | import { useAuthActions } from "@convex-dev/auth/react"; 7 | import { GitHubLogoIcon } from "@radix-ui/react-icons"; 8 | import { useState } from "react"; 9 | 10 | export function SignInForm() { 11 | const [step, setStep] = useState<"signIn" | "linkSent">("signIn"); 12 | 13 | return ( 14 |
    15 |
    16 | {step === "signIn" ? ( 17 | <> 18 |

    19 | Sign in or create an account 20 |

    21 | 22 | 23 | setStep("linkSent")} /> 24 | 25 | ) : ( 26 | <> 27 |

    28 | Check your email 29 |

    30 |

    A sign-in link has been sent to your email address.

    31 | 38 | 39 | )} 40 |
    41 |
    42 | ); 43 | } 44 | 45 | export function SignInWithGitHub() { 46 | const { signIn } = useAuthActions(); 47 | return ( 48 | 56 | ); 57 | } 58 | 59 | function SignInWithMagicLink({ 60 | handleLinkSent, 61 | }: { 62 | handleLinkSent: () => void; 63 | }) { 64 | const { signIn } = useAuthActions(); 65 | const { toast } = useToast(); 66 | return ( 67 |
    { 70 | event.preventDefault(); 71 | const formData = new FormData(event.currentTarget); 72 | signIn("resend", formData) 73 | .then(handleLinkSent) 74 | .catch((error) => { 75 | console.error(error); 76 | toast({ 77 | title: "Could not send sign-in link", 78 | variant: "destructive", 79 | }); 80 | }); 81 | }} 82 | > 83 | 84 | 85 | 86 | 87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /convex/messages.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { mutation, query } from "./_generated/server"; 3 | import { VALID_ROLES } from "./lib/permissions"; 4 | import { checkPermission } from "./lib/permissions"; 5 | import { getAuthUserId } from "@convex-dev/auth/server"; 6 | 7 | /** 8 | * Query to list the most recent messages with author information. 9 | * Requires READ permission or higher. 10 | * 11 | * @returns Array of messages with author details (name or email) 12 | * @throws Error if user is not signed in or has insufficient permissions 13 | * 14 | * @example 15 | * // In your React component: 16 | * const messages = useQuery(api.messages.list); 17 | * return messages.map(msg => ); 18 | */ 19 | export const list = query({ 20 | args: {}, 21 | handler: async (ctx) => { 22 | // Verify user is authenticated 23 | const userId = await getAuthUserId(ctx); 24 | if (!userId) throw new Error("Not signed in"); 25 | 26 | // Check if user has read permissions 27 | const hasAccess = await checkPermission(ctx, userId, VALID_ROLES.READ); 28 | if (!hasAccess) throw new Error("Insufficient permissions"); 29 | 30 | // Fetch the 100 most recent messages 31 | const messages = await ctx.db.query("messages").order("desc").take(100); 32 | 33 | // Enrich messages with author information 34 | return Promise.all( 35 | messages.map(async (message) => { 36 | const { name, email } = (await ctx.db.get(message.userId))!; 37 | return { ...message, author: name ?? email! }; 38 | }), 39 | ); 40 | }, 41 | }); 42 | 43 | /** 44 | * Mutation to send a new message. 45 | * Requires WRITE permission or higher. 46 | * 47 | * @param body - The text content of the message 48 | * @throws Error if user is not signed in or has insufficient permissions 49 | * 50 | * @example 51 | * // In your React component: 52 | * const sendMessage = useMutation(api.messages.send); 53 | * await sendMessage({ body: "Hello, world!" }); 54 | */ 55 | export const send = mutation({ 56 | args: { body: v.string() }, 57 | handler: async (ctx, { body }) => { 58 | // Verify user is authenticated 59 | const userId = await getAuthUserId(ctx); 60 | if (!userId) throw new Error("Not signed in"); 61 | 62 | // Check if user has write permissions 63 | const hasAccess = await checkPermission(ctx, userId, VALID_ROLES.WRITE); 64 | if (!hasAccess) throw new Error("Insufficient permissions"); 65 | 66 | // Create the new message 67 | await ctx.db.insert("messages", { body, userId }); 68 | }, 69 | }); 70 | 71 | /** 72 | * Mutation to delete a message. 73 | * Requires ADMIN permission. 74 | * 75 | * @param messageId - The ID of the message to delete 76 | * @throws Error if user is not signed in or has insufficient permissions 77 | * 78 | * @example 79 | * // In your React component: 80 | * const deleteMsg = useMutation(api.messages.deleteMessage); 81 | * await deleteMsg({ messageId: message._id }); 82 | */ 83 | export const deleteMessage = mutation({ 84 | args: { messageId: v.id("messages") }, 85 | handler: async (ctx, { messageId }) => { 86 | // Verify user is authenticated 87 | const userId = await getAuthUserId(ctx); 88 | if (!userId) throw new Error("Not signed in"); 89 | 90 | // Check if user has admin permissions 91 | const hasAccess = await checkPermission(ctx, userId, VALID_ROLES.ADMIN); 92 | if (!hasAccess) throw new Error("Insufficient permissions"); 93 | 94 | // Delete the message 95 | await ctx.db.delete(messageId); 96 | }, 97 | }); 98 | -------------------------------------------------------------------------------- /convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | actionGeneric, 13 | httpActionGeneric, 14 | queryGeneric, 15 | mutationGeneric, 16 | internalActionGeneric, 17 | internalMutationGeneric, 18 | internalQueryGeneric, 19 | } from "convex/server"; 20 | 21 | /** 22 | * Define a query in this Convex app's public API. 23 | * 24 | * This function will be allowed to read your Convex database and will be accessible from the client. 25 | * 26 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 27 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 28 | */ 29 | export const query = queryGeneric; 30 | 31 | /** 32 | * Define a query that is only accessible from other Convex functions (but not from the client). 33 | * 34 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 35 | * 36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 38 | */ 39 | export const internalQuery = internalQueryGeneric; 40 | 41 | /** 42 | * Define a mutation in this Convex app's public API. 43 | * 44 | * This function will be allowed to modify your Convex database and will be accessible from the client. 45 | * 46 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 47 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 48 | */ 49 | export const mutation = mutationGeneric; 50 | 51 | /** 52 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 53 | * 54 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 55 | * 56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 58 | */ 59 | export const internalMutation = internalMutationGeneric; 60 | 61 | /** 62 | * Define an action in this Convex app's public API. 63 | * 64 | * An action is a function which can execute any JavaScript code, including non-deterministic 65 | * code and code with side-effects, like calling third-party services. 66 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 67 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 68 | * 69 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 70 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 71 | */ 72 | export const action = actionGeneric; 73 | 74 | /** 75 | * Define an action that is only accessible from other Convex functions (but not from the client). 76 | * 77 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 78 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 79 | */ 80 | export const internalAction = internalActionGeneric; 81 | 82 | /** 83 | * Define a Convex HTTP action. 84 | * 85 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 86 | * as its second. 87 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 88 | */ 89 | export const httpAction = httpActionGeneric; 90 | -------------------------------------------------------------------------------- /src/Chat/Chat.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Input } from "@/components/ui/input"; 5 | import { useMutation, useQuery } from "convex/react"; 6 | import { FormEvent, useState } from "react"; 7 | import { api } from "../../convex/_generated/api"; 8 | import { MessageList } from "@/Chat/MessageList"; 9 | import { Message } from "@/Chat/Message"; 10 | import { Id } from "../../convex/_generated/dataModel"; 11 | import { 12 | Select, 13 | SelectContent, 14 | SelectItem, 15 | SelectTrigger, 16 | SelectValue, 17 | } from "@/components/ui/select"; 18 | 19 | export function Chat({ viewer }: { viewer: Id<"users"> }) { 20 | const [newMessageText, setNewMessageText] = useState(""); 21 | const messages = useQuery(api.messages.list); 22 | const sendMessage = useMutation(api.messages.send); 23 | const deleteMessage = useMutation(api.messages.deleteMessage); 24 | const me = useQuery(api.auth.getMe); 25 | const updateRole = useMutation(api.auth.updateRole); 26 | const [error, setError] = useState(null); 27 | 28 | const handleSubmit = (event: FormEvent) => { 29 | event.preventDefault(); 30 | setError(null); 31 | setNewMessageText(""); 32 | sendMessage({ body: newMessageText }).catch((error) => { 33 | console.error("Failed to send message:", error); 34 | setError("You have the right to party, but not to post."); 35 | }); 36 | }; 37 | 38 | const handleDeleteMessage = (messageId: Id<"messages">) => { 39 | deleteMessage({ messageId }).catch((error) => { 40 | console.error("Failed to delete message:", error); 41 | setError("You have the right to party, but not to delete messages."); 42 | }); 43 | }; 44 | 45 | const handleRoleChange = (newRole: "read" | "write" | "admin") => { 46 | updateRole({ role: newRole }).catch((error) => { 47 | console.error("Failed to update role:", error); 48 | setError("Failed to update role"); 49 | }); 50 | }; 51 | 52 | /** 53 | * Just used for the UI to show the delete button to admins. 54 | * The server-side check is done with checkPermission. 55 | */ 56 | const isAdmin = me?.role === "admin"; 57 | 58 | return ( 59 | <> 60 | 61 | {messages?.map((message) => ( 62 | 71 | {message.body} 72 | 73 | ))} 74 | 75 |
    76 |
    77 | setNewMessageText(event.target.value)} 80 | placeholder="Write a message…" 81 | /> 82 | 85 |
    86 |

    {error || " "}

    87 | 88 |
    89 | Test different roles: 90 | 105 |
    106 |
    107 | 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 3 | import { Cross2Icon } from "@radix-ui/react-icons"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Dialog = DialogPrimitive.Root; 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger; 10 | 11 | const DialogPortal = DialogPrimitive.Portal; 12 | 13 | const DialogClose = DialogPrimitive.Close; 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )); 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )); 52 | DialogContent.displayName = DialogPrimitive.Content.displayName; 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
    65 | ); 66 | DialogHeader.displayName = "DialogHeader"; 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
    79 | ); 80 | DialogFooter.displayName = "DialogFooter"; 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )); 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )); 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogTrigger, 114 | DialogClose, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | }; 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Convex + React (Vite) + Convex Auth app with role-based permissions 2 | 3 | This is a [Convex](https://convex.dev/) project created with [`npm create convex`](https://www.npmjs.com/package/create-convex). 4 | 5 | After the initial setup (<2 minutes) you'll have a working full-stack app using: 6 | 7 | - Convex as your backend (database, server logic) 8 | - [Convex Auth](https://labs.convex.dev/auth) for your authentication implementation 9 | - [React](https://react.dev/) as your frontend (web page interactivity) 10 | - [Vite](https://vitest.dev/) for optimized web hosting 11 | - [Tailwind](https://tailwindcss.com/) and [shadcn/ui](https://ui.shadcn.com/) for building great looking accessible UI fast 12 | 13 | ## Role Based Permissions 14 | 15 | This project implements a hierarchical role-based permission system that controls access to different features of the application. The system is built using Convex and consists of three permission levels: 16 | 17 | - **READ**: Basic access level - can view messages 18 | - **WRITE**: Intermediate access - can view and send messages 19 | - **ADMIN**: Full access - can view, send, and delete messages 20 | 21 | ### Implementation Details 22 | 23 | The permission system is implemented across several files: 24 | 25 | #### 1. Schema Definition (`schema.ts`) 26 | 27 | ```typescript 28 | users: defineTable({ 29 | // ... other fields ... 30 | role: v.optional( 31 | v.union(v.literal("read"), v.literal("write"), v.literal("admin")), 32 | ), 33 | }); 34 | ``` 35 | 36 | #### 2. Permission Management (`lib/permissions.ts`) 37 | 38 | The permissions system uses a numeric hierarchy to determine access levels: 39 | 40 | ```typescript 41 | const roleHierarchy = { 42 | read: 0, 43 | write: 1, 44 | admin: 2, 45 | }; 46 | ``` 47 | 48 | #### 3. Authentication Integration (`auth.ts`) 49 | 50 | New users are automatically assigned the READ role upon registration: 51 | 52 | ```typescript 53 | async afterUserCreatedOrUpdated(ctx, args) { 54 | if (args.existingUserId) return; 55 | await ctx.db.patch(args.userId, { 56 | role: VALID_ROLES.READ, 57 | }); 58 | } 59 | ``` 60 | 61 | #### 4. Usage Example (`messages.ts`) 62 | 63 | The permission system controls access to different operations: 64 | 65 | ```typescript 66 | // Reading messages requires READ access 67 | const hasAccess = await checkPermission(ctx, userId, VALID_ROLES.READ); 68 | 69 | // Sending messages requires WRITE access 70 | const hasAccess = await checkPermission(ctx, userId, VALID_ROLES.WRITE); 71 | 72 | // Deleting messages requires ADMIN access 73 | const hasAccess = await checkPermission(ctx, userId, VALID_ROLES.ADMIN); 74 | ``` 75 | 76 | ### Security Considerations 77 | 78 | - Role checks are performed server-side in Convex functions 79 | - Role updates should be restricted to administrators in production 80 | - The permission system is integrated with the authentication system 81 | - Invalid or missing roles default to no access 82 | 83 | ## Get started 84 | 85 | If you just cloned this codebase and didn't use `npm create convex`, run: 86 | 87 | ``` 88 | npm install 89 | npm run dev 90 | ``` 91 | 92 | If you're reading this README on GitHub and want to use this template, run: 93 | 94 | ``` 95 | npm create convex@latest -- -t react-vite-convexauth-shadcn 96 | ``` 97 | 98 | ## The app 99 | 100 | The app is a basic multi-user chat. Walkthrough of the source code: 101 | 102 | - [convex/auth.ts](./convex/auth.ts) configures the available authentication methods 103 | - [convex/messages.ts](./convex/messages.ts) is the chat backend implementation 104 | - [src/main.tsx](./src/main.tsx) is the frontend entry-point 105 | - [src/App.tsx](./src/App.tsx) determines which UI to show based on the authentication state 106 | - [src/SignInForm.tsx](./src/SignInForm.tsx) implements the sign-in UI 107 | - [src/Chat/Chat.tsx](./src/Chat/Chat.tsx) is the chat frontend 108 | 109 | ## Configuring other authentication methods 110 | 111 | To configure different authentication methods, see [Configuration](https://labs.convex.dev/auth/config) in the Convex Auth docs. 112 | 113 | ## Learn more 114 | 115 | To learn more about developing your project with Convex, check out: 116 | 117 | - The [Tour of Convex](https://docs.convex.dev/get-started) for a thorough introduction to Convex principles. 118 | - The rest of [Convex docs](https://docs.convex.dev/) to learn about all Convex features. 119 | - [Stack](https://stack.convex.dev/) for in-depth articles on advanced topics. 120 | 121 | ## Join the community 122 | 123 | Join thousands of developers building full-stack apps with Convex: 124 | 125 | - Join the [Convex Discord community](https://convex.dev/community) to get help in real-time. 126 | - Follow [Convex on GitHub](https://github.com/get-convex/), star and contribute to the open-source implementation of Convex. 127 | -------------------------------------------------------------------------------- /src/components/ui/use-toast.ts: -------------------------------------------------------------------------------- 1 | // Inspired by react-hot-toast library 2 | import * as React from "react" 3 | 4 | import type { 5 | ToastActionElement, 6 | ToastProps, 7 | } from "@/components/ui/toast" 8 | 9 | const TOAST_LIMIT = 1 10 | const TOAST_REMOVE_DELAY = 1000000 11 | 12 | type ToasterToast = ToastProps & { 13 | id: string 14 | title?: React.ReactNode 15 | description?: React.ReactNode 16 | action?: ToastActionElement 17 | } 18 | 19 | const actionTypes = { 20 | ADD_TOAST: "ADD_TOAST", 21 | UPDATE_TOAST: "UPDATE_TOAST", 22 | DISMISS_TOAST: "DISMISS_TOAST", 23 | REMOVE_TOAST: "REMOVE_TOAST", 24 | } as const 25 | 26 | let count = 0 27 | 28 | function genId() { 29 | count = (count + 1) % Number.MAX_SAFE_INTEGER 30 | return count.toString() 31 | } 32 | 33 | type ActionType = typeof actionTypes 34 | 35 | type Action = 36 | | { 37 | type: ActionType["ADD_TOAST"] 38 | toast: ToasterToast 39 | } 40 | | { 41 | type: ActionType["UPDATE_TOAST"] 42 | toast: Partial 43 | } 44 | | { 45 | type: ActionType["DISMISS_TOAST"] 46 | toastId?: ToasterToast["id"] 47 | } 48 | | { 49 | type: ActionType["REMOVE_TOAST"] 50 | toastId?: ToasterToast["id"] 51 | } 52 | 53 | interface State { 54 | toasts: ToasterToast[] 55 | } 56 | 57 | const toastTimeouts = new Map>() 58 | 59 | const addToRemoveQueue = (toastId: string) => { 60 | if (toastTimeouts.has(toastId)) { 61 | return 62 | } 63 | 64 | const timeout = setTimeout(() => { 65 | toastTimeouts.delete(toastId) 66 | dispatch({ 67 | type: "REMOVE_TOAST", 68 | toastId: toastId, 69 | }) 70 | }, TOAST_REMOVE_DELAY) 71 | 72 | toastTimeouts.set(toastId, timeout) 73 | } 74 | 75 | export const reducer = (state: State, action: Action): State => { 76 | switch (action.type) { 77 | case "ADD_TOAST": 78 | return { 79 | ...state, 80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 81 | } 82 | 83 | case "UPDATE_TOAST": 84 | return { 85 | ...state, 86 | toasts: state.toasts.map((t) => 87 | t.id === action.toast.id ? { ...t, ...action.toast } : t 88 | ), 89 | } 90 | 91 | case "DISMISS_TOAST": { 92 | const { toastId } = action 93 | 94 | // ! Side effects ! - This could be extracted into a dismissToast() action, 95 | // but I'll keep it here for simplicity 96 | if (toastId) { 97 | addToRemoveQueue(toastId) 98 | } else { 99 | state.toasts.forEach((toast) => { 100 | addToRemoveQueue(toast.id) 101 | }) 102 | } 103 | 104 | return { 105 | ...state, 106 | toasts: state.toasts.map((t) => 107 | t.id === toastId || toastId === undefined 108 | ? { 109 | ...t, 110 | open: false, 111 | } 112 | : t 113 | ), 114 | } 115 | } 116 | case "REMOVE_TOAST": 117 | if (action.toastId === undefined) { 118 | return { 119 | ...state, 120 | toasts: [], 121 | } 122 | } 123 | return { 124 | ...state, 125 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 126 | } 127 | } 128 | } 129 | 130 | const listeners: Array<(state: State) => void> = [] 131 | 132 | let memoryState: State = { toasts: [] } 133 | 134 | function dispatch(action: Action) { 135 | memoryState = reducer(memoryState, action) 136 | listeners.forEach((listener) => { 137 | listener(memoryState) 138 | }) 139 | } 140 | 141 | type Toast = Omit 142 | 143 | function toast({ ...props }: Toast) { 144 | const id = genId() 145 | 146 | const update = (props: ToasterToast) => 147 | dispatch({ 148 | type: "UPDATE_TOAST", 149 | toast: { ...props, id }, 150 | }) 151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 152 | 153 | dispatch({ 154 | type: "ADD_TOAST", 155 | toast: { 156 | ...props, 157 | id, 158 | open: true, 159 | onOpenChange: (open) => { 160 | if (!open) dismiss() 161 | }, 162 | }, 163 | }) 164 | 165 | return { 166 | id: id, 167 | dismiss, 168 | update, 169 | } 170 | } 171 | 172 | function useToast() { 173 | const [state, setState] = React.useState(memoryState) 174 | 175 | React.useEffect(() => { 176 | listeners.push(setState) 177 | return () => { 178 | const index = listeners.indexOf(setState) 179 | if (index > -1) { 180 | listeners.splice(index, 1) 181 | } 182 | } 183 | }, [state]) 184 | 185 | return { 186 | ...state, 187 | toast, 188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 189 | } 190 | } 191 | 192 | export { useToast, toast } 193 | -------------------------------------------------------------------------------- /src/GetStarted/GetStartedDialog.tsx: -------------------------------------------------------------------------------- 1 | import { ConvexLogo } from "@/GetStarted/ConvexLogo"; 2 | import { Code } from "@/components/Code"; 3 | import { Button } from "@/components/ui/button"; 4 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 5 | import { 6 | Dialog, 7 | DialogClose, 8 | DialogContent, 9 | DialogFooter, 10 | DialogHeader, 11 | DialogTitle, 12 | DialogTrigger, 13 | } from "@/components/ui/dialog"; 14 | import { 15 | CodeIcon, 16 | ExternalLinkIcon, 17 | MagicWandIcon, 18 | PlayIcon, 19 | StackIcon, 20 | } from "@radix-ui/react-icons"; 21 | import { ReactNode } from "react"; 22 | 23 | export function GetStartedDialog({ children }: { children: ReactNode }) { 24 | return ( 25 | 26 | {children} 27 | 28 | 29 | 30 | Your app powered by 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | function GetStartedContent() { 45 | return ( 46 |
    47 |

    48 | This template is a starting point for building your fullstack web 49 | application. 50 |

    51 |
    52 | 53 | 54 | 55 | Play with the app 56 | 57 | 58 | Close this dialog to see the app in action. 59 | 60 | 61 | 62 | 63 | Inspect your database 64 | 65 | 66 | 67 | The{" "} 68 | 73 | Convex dashboard 74 | {" "} 75 | is already open in another window. 76 | 77 | 78 | 79 | 80 | 81 | 82 | Change the backend 83 | 84 | 85 | 86 | Edit convex/messages.ts to change the backend 87 | functionality. 88 | 89 | 90 | 91 | 92 | 93 | 94 | Change the frontend 95 | 96 | 97 | 98 | Edit src/App.tsx to change your frontend. 99 | 100 | 101 |
    102 |
    103 |

    Helpful resources

    104 |
    105 | 106 | Read comprehensive documentation for all Convex features. 107 | 108 | 109 | Learn about best practices, use cases, and more from a growing 110 | collection of articles, videos, and walkthroughs. 111 | 112 | 113 | Join our developer community to ask questions, trade tips & tricks, 114 | and show off your projects. 115 | 116 | 117 | Get unblocked quickly by searching across the docs, Stack, and 118 | Discord chats. 119 | 120 |
    121 |
    122 |
    123 | ); 124 | } 125 | 126 | function Resource({ 127 | title, 128 | children, 129 | href, 130 | }: { 131 | title: string; 132 | children: ReactNode; 133 | href: string; 134 | }) { 135 | return ( 136 | 149 | ); 150 | } 151 | -------------------------------------------------------------------------------- /src/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Cross2Icon } from "@radix-ui/react-icons" 3 | import * as ToastPrimitives from "@radix-ui/react-toast" 4 | import { cva, type VariantProps } from "class-variance-authority" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ToastProvider = ToastPrimitives.Provider 9 | 10 | const ToastViewport = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 24 | 25 | const toastVariants = cva( 26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 27 | { 28 | variants: { 29 | variant: { 30 | default: "border bg-background text-foreground", 31 | destructive: 32 | "destructive group border-destructive bg-destructive text-destructive-foreground", 33 | }, 34 | }, 35 | defaultVariants: { 36 | variant: "default", 37 | }, 38 | } 39 | ) 40 | 41 | const Toast = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef & 44 | VariantProps 45 | >(({ className, variant, ...props }, ref) => { 46 | return ( 47 | 52 | ) 53 | }) 54 | Toast.displayName = ToastPrimitives.Root.displayName 55 | 56 | const ToastAction = React.forwardRef< 57 | React.ElementRef, 58 | React.ComponentPropsWithoutRef 59 | >(({ className, ...props }, ref) => ( 60 | 68 | )) 69 | ToastAction.displayName = ToastPrimitives.Action.displayName 70 | 71 | const ToastClose = React.forwardRef< 72 | React.ElementRef, 73 | React.ComponentPropsWithoutRef 74 | >(({ className, ...props }, ref) => ( 75 | 84 | 85 | 86 | )) 87 | ToastClose.displayName = ToastPrimitives.Close.displayName 88 | 89 | const ToastTitle = React.forwardRef< 90 | React.ElementRef, 91 | React.ComponentPropsWithoutRef 92 | >(({ className, ...props }, ref) => ( 93 | 98 | )) 99 | ToastTitle.displayName = ToastPrimitives.Title.displayName 100 | 101 | const ToastDescription = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | ToastDescription.displayName = ToastPrimitives.Description.displayName 112 | 113 | type ToastProps = React.ComponentPropsWithoutRef 114 | 115 | type ToastActionElement = React.ReactElement 116 | 117 | export { 118 | type ToastProps, 119 | type ToastActionElement, 120 | ToastProvider, 121 | ToastViewport, 122 | Toast, 123 | ToastTitle, 124 | ToastDescription, 125 | ToastClose, 126 | ToastAction, 127 | } 128 | -------------------------------------------------------------------------------- /convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * To regenerate, run `npx convex dev`. 8 | * @module 9 | */ 10 | 11 | import { 12 | ActionBuilder, 13 | HttpActionBuilder, 14 | MutationBuilder, 15 | QueryBuilder, 16 | GenericActionCtx, 17 | GenericMutationCtx, 18 | GenericQueryCtx, 19 | GenericDatabaseReader, 20 | GenericDatabaseWriter, 21 | } from "convex/server"; 22 | import type { DataModel } from "./dataModel.js"; 23 | 24 | /** 25 | * Define a query in this Convex app's public API. 26 | * 27 | * This function will be allowed to read your Convex database and will be accessible from the client. 28 | * 29 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 30 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 31 | */ 32 | export declare const query: QueryBuilder; 33 | 34 | /** 35 | * Define a query that is only accessible from other Convex functions (but not from the client). 36 | * 37 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 38 | * 39 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 40 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 41 | */ 42 | export declare const internalQuery: QueryBuilder; 43 | 44 | /** 45 | * Define a mutation in this Convex app's public API. 46 | * 47 | * This function will be allowed to modify your Convex database and will be accessible from the client. 48 | * 49 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 50 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 51 | */ 52 | export declare const mutation: MutationBuilder; 53 | 54 | /** 55 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 56 | * 57 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 58 | * 59 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 60 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 61 | */ 62 | export declare const internalMutation: MutationBuilder; 63 | 64 | /** 65 | * Define an action in this Convex app's public API. 66 | * 67 | * An action is a function which can execute any JavaScript code, including non-deterministic 68 | * code and code with side-effects, like calling third-party services. 69 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 70 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 71 | * 72 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 73 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 74 | */ 75 | export declare const action: ActionBuilder; 76 | 77 | /** 78 | * Define an action that is only accessible from other Convex functions (but not from the client). 79 | * 80 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 81 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 82 | */ 83 | export declare const internalAction: ActionBuilder; 84 | 85 | /** 86 | * Define an HTTP action. 87 | * 88 | * This function will be used to respond to HTTP requests received by a Convex 89 | * deployment if the requests matches the path and method where this action 90 | * is routed. Be sure to route your action in `convex/http.js`. 91 | * 92 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 93 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 94 | */ 95 | export declare const httpAction: HttpActionBuilder; 96 | 97 | /** 98 | * A set of services for use within Convex query functions. 99 | * 100 | * The query context is passed as the first argument to any Convex query 101 | * function run on the server. 102 | * 103 | * This differs from the {@link MutationCtx} because all of the services are 104 | * read-only. 105 | */ 106 | export type QueryCtx = GenericQueryCtx; 107 | 108 | /** 109 | * A set of services for use within Convex mutation functions. 110 | * 111 | * The mutation context is passed as the first argument to any Convex mutation 112 | * function run on the server. 113 | */ 114 | export type MutationCtx = GenericMutationCtx; 115 | 116 | /** 117 | * A set of services for use within Convex action functions. 118 | * 119 | * The action context is passed as the first argument to any Convex action 120 | * function run on the server. 121 | */ 122 | export type ActionCtx = GenericActionCtx; 123 | 124 | /** 125 | * An interface to read from the database within Convex query functions. 126 | * 127 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 128 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 129 | * building a query. 130 | */ 131 | export type DatabaseReader = GenericDatabaseReader; 132 | 133 | /** 134 | * An interface to read from and write to the database within Convex mutation 135 | * functions. 136 | * 137 | * Convex guarantees that all writes within a single mutation are 138 | * executed atomically, so you never have to worry about partial writes leaving 139 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 140 | * for the guarantees Convex provides your functions. 141 | */ 142 | export type DatabaseWriter = GenericDatabaseWriter; 143 | -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SelectPrimitive from "@radix-ui/react-select" 3 | import { cn } from "@/lib/utils" 4 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons" 5 | 6 | const Select = SelectPrimitive.Root 7 | 8 | const SelectGroup = SelectPrimitive.Group 9 | 10 | const SelectValue = SelectPrimitive.Value 11 | 12 | const SelectTrigger = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, children, ...props }, ref) => ( 16 | span]:line-clamp-1", 20 | className 21 | )} 22 | {...props} 23 | > 24 | {children} 25 | 26 | 27 | 28 | 29 | )) 30 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 31 | 32 | const SelectScrollUpButton = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, ...props }, ref) => ( 36 | 44 | 45 | 46 | )) 47 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 48 | 49 | const SelectScrollDownButton = React.forwardRef< 50 | React.ElementRef, 51 | React.ComponentPropsWithoutRef 52 | >(({ className, ...props }, ref) => ( 53 | 61 | 62 | 63 | )) 64 | SelectScrollDownButton.displayName = 65 | SelectPrimitive.ScrollDownButton.displayName 66 | 67 | const SelectContent = React.forwardRef< 68 | React.ElementRef, 69 | React.ComponentPropsWithoutRef 70 | >(({ className, children, position = "popper", ...props }, ref) => ( 71 | 72 | 83 | 84 | 91 | {children} 92 | 93 | 94 | 95 | 96 | )) 97 | SelectContent.displayName = SelectPrimitive.Content.displayName 98 | 99 | const SelectLabel = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | SelectLabel.displayName = SelectPrimitive.Label.displayName 110 | 111 | const SelectItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, children, ...props }, ref) => ( 115 | 123 | 124 | 125 | 126 | 127 | 128 | {children} 129 | 130 | )) 131 | SelectItem.displayName = SelectPrimitive.Item.displayName 132 | 133 | const SelectSeparator = React.forwardRef< 134 | React.ElementRef, 135 | React.ComponentPropsWithoutRef 136 | >(({ className, ...props }, ref) => ( 137 | 142 | )) 143 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 144 | 145 | export { 146 | Select, 147 | SelectGroup, 148 | SelectValue, 149 | SelectTrigger, 150 | SelectContent, 151 | SelectLabel, 152 | SelectItem, 153 | SelectSeparator, 154 | SelectScrollUpButton, 155 | SelectScrollDownButton, 156 | } 157 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 3 | import { 4 | CheckIcon, 5 | ChevronRightIcon, 6 | DotFilledIcon, 7 | } from "@radix-ui/react-icons"; 8 | 9 | import { cn } from "@/lib/utils"; 10 | 11 | const DropdownMenu = DropdownMenuPrimitive.Root; 12 | 13 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 14 | 15 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 16 | 17 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 18 | 19 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 20 | 21 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 22 | 23 | const DropdownMenuSubTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef & { 26 | inset?: boolean; 27 | } 28 | >(({ className, inset, children, ...props }, ref) => ( 29 | 38 | {children} 39 | 40 | 41 | )); 42 | DropdownMenuSubTrigger.displayName = 43 | DropdownMenuPrimitive.SubTrigger.displayName; 44 | 45 | const DropdownMenuSubContent = React.forwardRef< 46 | React.ElementRef, 47 | React.ComponentPropsWithoutRef 48 | >(({ className, ...props }, ref) => ( 49 | 57 | )); 58 | DropdownMenuSubContent.displayName = 59 | DropdownMenuPrimitive.SubContent.displayName; 60 | 61 | const DropdownMenuContent = React.forwardRef< 62 | React.ElementRef, 63 | React.ComponentPropsWithoutRef 64 | >(({ className, sideOffset = 4, ...props }, ref) => ( 65 | 66 | 76 | 77 | )); 78 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 79 | 80 | const DropdownMenuItem = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef & { 83 | inset?: boolean; 84 | } 85 | >(({ className, inset, ...props }, ref) => ( 86 | 95 | )); 96 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 97 | 98 | const DropdownMenuCheckboxItem = React.forwardRef< 99 | React.ElementRef, 100 | React.ComponentPropsWithoutRef 101 | >(({ className, children, checked, ...props }, ref) => ( 102 | 111 | 112 | 113 | 114 | 115 | 116 | {children} 117 | 118 | )); 119 | DropdownMenuCheckboxItem.displayName = 120 | DropdownMenuPrimitive.CheckboxItem.displayName; 121 | 122 | const DropdownMenuRadioItem = React.forwardRef< 123 | React.ElementRef, 124 | React.ComponentPropsWithoutRef 125 | >(({ className, children, ...props }, ref) => ( 126 | 134 | 135 | 136 | 137 | 138 | 139 | {children} 140 | 141 | )); 142 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 143 | 144 | const DropdownMenuLabel = React.forwardRef< 145 | React.ElementRef, 146 | React.ComponentPropsWithoutRef & { 147 | inset?: boolean; 148 | } 149 | >(({ className, inset, ...props }, ref) => ( 150 | 159 | )); 160 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 161 | 162 | const DropdownMenuSeparator = React.forwardRef< 163 | React.ElementRef, 164 | React.ComponentPropsWithoutRef 165 | >(({ className, ...props }, ref) => ( 166 | 171 | )); 172 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 173 | 174 | const DropdownMenuShortcut = ({ 175 | className, 176 | ...props 177 | }: React.HTMLAttributes) => { 178 | return ( 179 | 183 | ); 184 | }; 185 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 186 | 187 | export { 188 | DropdownMenu, 189 | DropdownMenuTrigger, 190 | DropdownMenuContent, 191 | DropdownMenuItem, 192 | DropdownMenuCheckboxItem, 193 | DropdownMenuRadioItem, 194 | DropdownMenuLabel, 195 | DropdownMenuSeparator, 196 | DropdownMenuShortcut, 197 | DropdownMenuGroup, 198 | DropdownMenuPortal, 199 | DropdownMenuSub, 200 | DropdownMenuSubContent, 201 | DropdownMenuSubTrigger, 202 | DropdownMenuRadioGroup, 203 | }; 204 | --------------------------------------------------------------------------------