├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── app ├── app.config.ts ├── app.vue ├── assets │ └── css │ │ └── main.css ├── components │ ├── Auth │ │ ├── Button.vue │ │ └── SocialLogin.vue │ ├── DarkMode.vue │ ├── Dashboard │ │ ├── Note │ │ │ ├── Card.vue │ │ │ └── Media.vue │ │ ├── Page.vue │ │ ├── ProfileSettings.vue │ │ ├── Sidebar.vue │ │ └── UserDropdownMenu.vue │ └── SupersaasBanner.vue ├── middleware │ └── auth.ts └── pages │ ├── dashboard.vue │ ├── dashboard │ ├── index.vue │ ├── notes.vue │ ├── notes │ │ ├── [id].vue │ │ ├── index.vue │ │ └── new.vue │ └── settings.vue │ ├── index.vue │ └── login.vue ├── drizzle.config.ts ├── nuxt.config.ts ├── package.json ├── pnpm-lock.yaml ├── public ├── favicon.ico ├── images │ ├── abstract-shpere.jpg │ ├── ball.jpg │ ├── blocks.jpg │ ├── building.jpg │ ├── christmas.jpg │ ├── cloudy-mountain.jpg │ ├── crowded-city.jpg │ ├── dark.jpg │ ├── egypt.jpg │ ├── furniture.jpg │ ├── golden-waves.jpg │ ├── lake.jpg │ ├── lighthouse.jpg │ ├── meteor.jpg │ ├── moon.jpg │ ├── mountains.jpg │ ├── plants.jpg │ ├── river.jpg │ ├── sunset.jpg │ ├── waves-2.jpg │ ├── waves.jpg │ ├── white-waves.jpg │ ├── windows.jpg │ └── yosemite.jpg ├── logo.png ├── logo.svg ├── ship.svg ├── supersaas-logo-full.png └── supersaas-with-text.png ├── server ├── api │ ├── account │ │ └── update.post.ts │ ├── auth │ │ ├── discord.ts │ │ ├── github.ts │ │ └── google.ts │ └── notes │ │ ├── [id].delete.ts │ │ ├── [id].get.ts │ │ ├── [id].patch.ts │ │ ├── index.get.ts │ │ └── index.post.ts ├── database │ ├── actions │ │ ├── notes.ts │ │ └── users.ts │ ├── migrations │ │ ├── 0000_outgoing_tag.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ └── _journal.json │ └── schema │ │ ├── index.ts │ │ ├── notes.ts │ │ └── users.ts ├── tsconfig.json └── utils │ ├── database.ts │ └── oauth.ts ├── tsconfig.json └── types ├── auth.d.ts └── database.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | .wrangler 8 | dist 9 | 10 | # Node dependencies 11 | node_modules 12 | 13 | # Logs 14 | logs 15 | *.log 16 | 17 | # Misc 18 | .DS_Store 19 | .fleet 20 | .idea 21 | 22 | # Local env files 23 | .env 24 | .env.* 25 | !.env.example 26 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "semi": false, 7 | "printWidth": 80, 8 | "arrowParens": "always", 9 | "plugins": ["prettier-plugin-tailwindcss"] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.css": "tailwindcss" 4 | }, 5 | "editor.quickSuggestions": { 6 | "strings": "on" 7 | }, 8 | "tailwindCSS.classAttributes": ["active-class", "inactive-class"], 9 | "nuxtr.vueFiles.firstTag": "template", 10 | "nuxtr.vueFiles.script.type": "setup", 11 | "nuxtr.vueFiles.script.defaultLanguage": "ts", 12 | "nuxtr.vueFiles.style.addStyleTag": false, 13 | "nuxtr.vueFiles.style.alwaysScoped": true, 14 | "nuxtr.openItemsAfterCreation": true, 15 | "nuxtr.defaultPackageManager": "pnpm", 16 | "eslint.useFlatConfig": true 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Supersaas Lite 2 | 3 | A minimal fullstack starter template deployed on the Edge using [NuxtHub](https://hub.nuxt.com). 4 | 5 | Demo - [https://supersaas-lite.nuxt.dev/](https://supersaas-lite.nuxt.dev/?ref=github-readme-supersaas-lite) 6 | 7 | 8 | ## Features 9 | 10 | - Google, Github & Discord login 11 | - Database setup 12 | - Typescript 13 | - CRUD APIs 14 | - User Settings 15 | - Simple Note app use case 16 | 17 | Supersaas lite is a part of [Supersaas Pro](https://supersaas.dev?ref=supersaas-lite-github-readme) - The fullstack Nuxt 3 template that comes with 18 | 19 | - Auth - Email/Password, Magic Link, One time passwords, Passkeys, Social Auth 20 | - DB - Turso, NuxtHub or Postgres 21 | - Payments - Manage user payment subscriptions using Stripe or Lemonsqueezy 22 | - Emails - send emails with 5 providers to choose from - Sendgrid, Postmark, Resend, Plunk and Mailgun 23 | - File storage - Upload, Delete and manipulate files - AWS S3, Nuxthub, Cloudflare R2 or even local file storage 24 | - Super Admin Mode - Add users, send password reset links, ban/unban and delete users. 25 | 26 | ## Setup 27 | 28 | Make sure to install the dependencies with [pnpm](https://pnpm.io/installation#using-corepack): 29 | 30 | ```bash 31 | pnpm install 32 | ``` 33 | 34 | Set the environment variables 35 | 36 | ```bash 37 | NUXT_OAUTH_GITHUB_CLIENT_ID 38 | NUXT_OAUTH_GITHUB_CLIENT_SECRET 39 | NUXT_OAUTH_GOOGLE_CLIENT_ID 40 | NUXT_OAUTH_GOOGLE_CLIENT_SECRET 41 | NUXT_OAUTH_DISCORD_CLIENT_ID 42 | NUXT_OAUTH_DISCORD_CLIENT_SECRET 43 | NUXT_SESSION_PASSWORD=32_CHAR_ALPHA_NUMERIC_STRING 44 | ``` 45 | 46 | ## Development Server 47 | 48 | Start the development server on `http://localhost:3000`: 49 | 50 | ```bash 51 | pnpm dev 52 | ``` 53 | 54 | ## Production 55 | 56 | Build the application for production: 57 | 58 | ```bash 59 | pnpm build 60 | ``` 61 | 62 | ## Deploy 63 | 64 | 65 | Deploy the application on the Edge with [NuxtHub](https://hub.nuxt.com) on your Cloudflare account: 66 | 67 | ```bash 68 | npx nuxthub deploy 69 | ``` 70 | 71 | Then checkout your server logs, analaytics and more in the [NuxtHub Admin](https://admin.hub.nuxt.com). 72 | 73 | You can also deploy using [Cloudflare Pages CI](https://hub.nuxt.com/docs/getting-started/deploy#cloudflare-pages-ci). 74 | 75 | -------------------------------------------------------------------------------- /app/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | ui: { 3 | icons: { 4 | loading: 'i-lucide-loader', 5 | }, 6 | button: { 7 | slots: { 8 | base: ['cursor-pointer'], 9 | }, 10 | }, 11 | colors: { 12 | primary: 'sky', 13 | neutral: 'zinc', 14 | }, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | -------------------------------------------------------------------------------- /app/assets/css/main.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @import '@nuxt/ui'; 3 | 4 | @theme { 5 | --font-sans: 'Geist Sans', sans-serif; 6 | } 7 | 8 | body { 9 | @apply bg-white dark:bg-zinc-950; 10 | } 11 | -------------------------------------------------------------------------------- /app/components/Auth/Button.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 39 | -------------------------------------------------------------------------------- /app/components/Auth/SocialLogin.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /app/components/DarkMode.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 29 | -------------------------------------------------------------------------------- /app/components/Dashboard/Note/Card.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 34 | -------------------------------------------------------------------------------- /app/components/Dashboard/Note/Media.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 68 | -------------------------------------------------------------------------------- /app/components/Dashboard/Page.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 30 | -------------------------------------------------------------------------------- /app/components/Dashboard/ProfileSettings.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 102 | -------------------------------------------------------------------------------- /app/components/Dashboard/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 89 | 90 | 115 | -------------------------------------------------------------------------------- /app/components/Dashboard/UserDropdownMenu.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 125 | -------------------------------------------------------------------------------- /app/components/SupersaasBanner.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 98 | -------------------------------------------------------------------------------- /app/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { toast } from 'vue-sonner' 2 | export default defineNuxtRouteMiddleware((to, from) => { 3 | const { loggedIn } = useUserSession() 4 | if (!loggedIn.value) { 5 | toast.error('You must be logged in to access the app') 6 | return navigateTo('/login') 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /app/pages/dashboard.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /app/pages/dashboard/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 23 | -------------------------------------------------------------------------------- /app/pages/dashboard/notes.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /app/pages/dashboard/notes/[id].vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 108 | -------------------------------------------------------------------------------- /app/pages/dashboard/notes/index.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 31 | -------------------------------------------------------------------------------- /app/pages/dashboard/notes/new.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 72 | -------------------------------------------------------------------------------- /app/pages/dashboard/settings.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 38 | -------------------------------------------------------------------------------- /app/pages/login.vue: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit' 2 | 3 | export default defineConfig({ 4 | dialect: 'sqlite', 5 | schema: './server/database/schema', 6 | out: './server/database/migrations', 7 | }) 8 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | modules: ['@nuxthub/core', '@nuxt/ui', '@vueuse/nuxt', 'nuxt-auth-utils', 'nuxt-emoji-picker'], 3 | devtools: { enabled: true }, 4 | css: ['~/assets/css/main.css'], 5 | runtimeConfig: { 6 | // @ts-expect-error - Runtime config is not typed 7 | session: { 8 | maxAge: 60 * 60 * 24 * 7, // Session expires after 7 days - change it accordingly 9 | }, 10 | }, 11 | future: { compatibilityVersion: 4 }, 12 | hub: { 13 | database: true, 14 | }, 15 | compatibilityDate: '2024-12-01', 16 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "npx nuxthub preview", 10 | "deploy": "npx nuxthub deploy", 11 | "postinstall": "nuxt prepare", 12 | "lint": "eslint .", 13 | "format": "prettier --write .", 14 | "db:generate": "drizzle-kit generate" 15 | }, 16 | "dependencies": { 17 | "@iconify-json/lucide": "^1.2.17", 18 | "@nuxt/eslint": "^0.7.2", 19 | "@nuxt/ui": "3.0.0-alpha.9", 20 | "@nuxthub/core": "^0.8.7", 21 | "drizzle-orm": "^0.37.0", 22 | "drizzle-zod": "^0.5.1", 23 | "nuxt": "^3.14.1592", 24 | "nuxt-auth-utils": "^0.5.5", 25 | "nuxt-emoji-picker": "1.1.0", 26 | "resend": "^4.0.1", 27 | "use-email": "^0.0.8", 28 | "vue": "^3.5.13", 29 | "vue-router": "^4.5.0", 30 | "vue-sonner": "^1.3.0", 31 | "zod": "^3.23.8" 32 | }, 33 | "devDependencies": { 34 | "@vueuse/core": "^12.0.0", 35 | "@vueuse/nuxt": "^12.0.0", 36 | "drizzle-kit": "^0.29.1", 37 | "prettier": "^3.4.2", 38 | "prettier-plugin-tailwindcss": "^0.6.9", 39 | "typescript": "^5.7.2", 40 | "vue-tsc": "^2.1.10", 41 | "wrangler": "^3.92.0" 42 | }, 43 | "packageManager": "pnpm@9.12.3" 44 | } 45 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/favicon.ico -------------------------------------------------------------------------------- /public/images/abstract-shpere.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/abstract-shpere.jpg -------------------------------------------------------------------------------- /public/images/ball.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/ball.jpg -------------------------------------------------------------------------------- /public/images/blocks.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/blocks.jpg -------------------------------------------------------------------------------- /public/images/building.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/building.jpg -------------------------------------------------------------------------------- /public/images/christmas.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/christmas.jpg -------------------------------------------------------------------------------- /public/images/cloudy-mountain.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/cloudy-mountain.jpg -------------------------------------------------------------------------------- /public/images/crowded-city.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/crowded-city.jpg -------------------------------------------------------------------------------- /public/images/dark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/dark.jpg -------------------------------------------------------------------------------- /public/images/egypt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/egypt.jpg -------------------------------------------------------------------------------- /public/images/furniture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/furniture.jpg -------------------------------------------------------------------------------- /public/images/golden-waves.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/golden-waves.jpg -------------------------------------------------------------------------------- /public/images/lake.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/lake.jpg -------------------------------------------------------------------------------- /public/images/lighthouse.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/lighthouse.jpg -------------------------------------------------------------------------------- /public/images/meteor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/meteor.jpg -------------------------------------------------------------------------------- /public/images/moon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/moon.jpg -------------------------------------------------------------------------------- /public/images/mountains.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/mountains.jpg -------------------------------------------------------------------------------- /public/images/plants.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/plants.jpg -------------------------------------------------------------------------------- /public/images/river.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/river.jpg -------------------------------------------------------------------------------- /public/images/sunset.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/sunset.jpg -------------------------------------------------------------------------------- /public/images/waves-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/waves-2.jpg -------------------------------------------------------------------------------- /public/images/waves.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/waves.jpg -------------------------------------------------------------------------------- /public/images/white-waves.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/white-waves.jpg -------------------------------------------------------------------------------- /public/images/windows.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/windows.jpg -------------------------------------------------------------------------------- /public/images/yosemite.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/images/yosemite.jpg -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/logo.png -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/ship.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/supersaas-logo-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/supersaas-logo-full.png -------------------------------------------------------------------------------- /public/supersaas-with-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SupersaasHQ/essentials-lite/5236efe39406afcfaeae1732126df29f320c86cd/public/supersaas-with-text.png -------------------------------------------------------------------------------- /server/api/account/update.post.ts: -------------------------------------------------------------------------------- 1 | import { updateUser } from '@@/server/database/actions/users' 2 | import { insertUserSchema } from '@@/types/database' 3 | const updateUserSchema = insertUserSchema.partial() 4 | 5 | export default defineEventHandler(async (event) => { 6 | const { user } = await requireUserSession(event) 7 | const { name, avatarUrl } = await readValidatedBody(event, (body) => 8 | updateUserSchema.parse(body), 9 | ) 10 | const updatedUser = await updateUser(user.id, { name, avatarUrl }) 11 | return updatedUser 12 | }) 13 | -------------------------------------------------------------------------------- /server/api/auth/discord.ts: -------------------------------------------------------------------------------- 1 | import { handleOAuthSuccess } from '@@/server/utils/oauth' 2 | 3 | interface DiscordOAuthUser { 4 | id: string 5 | avatar: string 6 | global_name: string 7 | email: string 8 | } 9 | 10 | const mapDiscordUser = (user: DiscordOAuthUser) => ({ 11 | email: user.email, 12 | name: user.global_name, 13 | avatarUrl: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`, 14 | provider: 'discord' as const, 15 | providerUserId: user.id, 16 | }) 17 | 18 | export default defineOAuthDiscordEventHandler({ 19 | config: { emailRequired: true }, 20 | async onSuccess(event, { user }) { 21 | try { 22 | await handleOAuthSuccess(event, mapDiscordUser(user)) 23 | } catch (error) { 24 | throw createError({ 25 | statusCode: 500, 26 | statusMessage: 'Authentication failed', 27 | }) 28 | } 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /server/api/auth/github.ts: -------------------------------------------------------------------------------- 1 | import { handleOAuthSuccess } from '@@/server/utils/oauth' 2 | 3 | interface GitHubOAuthUser { 4 | email: string 5 | name: string 6 | avatar_url: string 7 | id: string 8 | } 9 | 10 | const mapGitHubUser = (user: GitHubOAuthUser) => ({ 11 | email: user.email, 12 | name: user.name, 13 | avatarUrl: user.avatar_url, 14 | provider: 'github' as const, 15 | providerUserId: user.id, 16 | }) 17 | 18 | export default defineOAuthGitHubEventHandler({ 19 | config: { emailRequired: true }, 20 | async onSuccess(event, { user }) { 21 | try { 22 | await handleOAuthSuccess(event, mapGitHubUser(user)) 23 | } catch (error) { 24 | throw createError({ 25 | statusCode: 500, 26 | statusMessage: 'Authentication failed', 27 | }) 28 | } 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /server/api/auth/google.ts: -------------------------------------------------------------------------------- 1 | import { handleOAuthSuccess } from '@@/server/utils/oauth' 2 | 3 | interface GoogleOAuthUser { 4 | sub: string 5 | given_name: string 6 | family_name: string 7 | picture: string 8 | email: string 9 | } 10 | 11 | const mapGoogleUser = (user: GoogleOAuthUser) => ({ 12 | email: user.email, 13 | name: `${user.given_name} ${user.family_name}`.trim(), 14 | avatarUrl: user.picture, 15 | provider: 'google' as const, 16 | providerUserId: user.sub, 17 | }) 18 | 19 | export default defineOAuthGoogleEventHandler({ 20 | async onSuccess(event, { user }) { 21 | try { 22 | await handleOAuthSuccess(event, mapGoogleUser(user)) 23 | } catch (error) { 24 | throw createError({ 25 | statusCode: 500, 26 | statusMessage: 'Authentication failed', 27 | }) 28 | } 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /server/api/notes/[id].delete.ts: -------------------------------------------------------------------------------- 1 | import { deleteNote, findNoteById } from '@@/server/database/actions/notes' 2 | import { z } from 'zod' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const { user } = await requireUserSession(event) 6 | const noteId = getRouterParam(event, 'id') 7 | if (!noteId) { 8 | throw createError({ statusCode: 400, statusMessage: 'Note ID is required' }) 9 | } 10 | 11 | const note = await findNoteById(noteId) 12 | if (!note || note.userId !== user.id) { 13 | throw createError({ statusCode: 404, message: 'Note not found' }) 14 | } 15 | 16 | return deleteNote(noteId) 17 | }) 18 | -------------------------------------------------------------------------------- /server/api/notes/[id].get.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | return 'Hello Nitro' 3 | }) 4 | -------------------------------------------------------------------------------- /server/api/notes/[id].patch.ts: -------------------------------------------------------------------------------- 1 | import { updateNote, findNoteById } from '@@/server/database/actions/notes' 2 | import { z } from 'zod' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const { user } = await requireUserSession(event) 6 | const noteId = getRouterParam(event, 'id') 7 | if (!noteId) { 8 | throw createError({ statusCode: 400, statusMessage: 'Note ID is required' }) 9 | } 10 | const { title, content, image, icon, userId } = await readValidatedBody( 11 | event, 12 | (body) => 13 | z 14 | .object({ 15 | title: z.string().min(1).max(256), 16 | content: z.string().min(1).max(5000), 17 | image: z.string().optional(), 18 | icon: z.string().optional(), 19 | userId: z.string().optional(), 20 | }) 21 | .parse(body), 22 | ) 23 | 24 | const note = await findNoteById(noteId) 25 | if (!note || note.userId !== user.id) { 26 | throw createError({ statusCode: 404, message: 'Note not found' }) 27 | } 28 | return updateNote(noteId, { title, content, image, icon, userId: user.id }) 29 | }) 30 | -------------------------------------------------------------------------------- /server/api/notes/index.get.ts: -------------------------------------------------------------------------------- 1 | import { findNotesByUserId } from '@@/server/database/actions/notes' 2 | 3 | export default defineEventHandler(async (event) => { 4 | const { user } = await requireUserSession(event) 5 | const notes = await findNotesByUserId(user.id) 6 | return notes 7 | }) 8 | -------------------------------------------------------------------------------- /server/api/notes/index.post.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createNote } from '@@/server/database/actions/notes' 3 | import { z } from 'zod' 4 | 5 | export default defineEventHandler(async (event) => { 6 | const { user } = await requireUserSession(event) 7 | const { title, content, image, icon } = await readValidatedBody( 8 | event, 9 | (body) => 10 | z 11 | .object({ 12 | title: z.string().min(1).max(256), 13 | content: z.string().min(1).max(5000), 14 | image: z.string().optional(), 15 | icon: z.string().optional(), 16 | }) 17 | .parse(body), 18 | ) 19 | const note = await createNote({ 20 | title, 21 | content, 22 | image, 23 | icon, 24 | userId: user.id, 25 | }) 26 | return note 27 | }) 28 | -------------------------------------------------------------------------------- /server/database/actions/notes.ts: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm' 2 | import type { Note, InsertNote } from '../../../types/database' 3 | import { useDB, tables } from '@@/server/utils/database' 4 | 5 | export const createNote = async (payload: InsertNote) => { 6 | try { 7 | const record = await useDB() 8 | .insert(tables.notes) 9 | .values({ ...payload }) 10 | .onConflictDoUpdate({ 11 | target: tables.notes.id, 12 | set: payload, 13 | }) 14 | .returning() 15 | .get() 16 | return record 17 | } catch (error) { 18 | console.error(error) 19 | throw new Error('Failed to create note') 20 | } 21 | } 22 | 23 | export const findNoteById = async (id: string) => { 24 | const [record] = await useDB() 25 | .select() 26 | .from(tables.notes) 27 | .where(eq(tables.notes.id, id)) 28 | return record || null 29 | } 30 | 31 | export const updateNote = async (id: string, payload: Partial) => { 32 | const record = await useDB() 33 | .update(tables.notes) 34 | .set(payload) 35 | .where(eq(tables.notes.id, id)) 36 | .returning() 37 | .get() 38 | return record || null 39 | } 40 | 41 | export const deleteNote = async (id: string) => { 42 | const record = await useDB() 43 | .delete(tables.notes) 44 | .where(eq(tables.notes.id, id)) 45 | .returning() 46 | .get() 47 | return record || null 48 | } 49 | 50 | export const findNotesByUserId = async (userId: string) => { 51 | const records = await useDB() 52 | .select() 53 | .from(tables.notes) 54 | .where(eq(tables.notes.userId, userId)) 55 | return records || null 56 | } 57 | -------------------------------------------------------------------------------- /server/database/actions/users.ts: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm' 2 | import type { User, InsertUser } from '../../../types/database' 3 | import { useDB, tables } from '@@/server/utils/database' 4 | 5 | export const findUserByEmail = async (email: string): Promise => { 6 | try { 7 | const [existingUser] = await useDB() 8 | .select() 9 | .from(tables.users) 10 | .where(eq(tables.users.email, email)) 11 | return existingUser || null 12 | } catch (error) { 13 | console.error(error) 14 | return null 15 | } 16 | } 17 | 18 | export const updateLastActiveTimestamp = async ( 19 | userId: string, 20 | ): Promise => { 21 | try { 22 | const record = await useDB() 23 | .update(tables.users) 24 | .set({ lastActive: new Date() }) 25 | .where(eq(tables.users.id, userId)) 26 | .returning() 27 | .get() 28 | return record 29 | } catch (error) { 30 | console.error(error) 31 | throw new Error('Failed to update last active') 32 | } 33 | } 34 | 35 | export const createUser = async (payload: InsertUser) => { 36 | try { 37 | const record = await useDB() 38 | .insert(tables.users) 39 | .values(payload) 40 | .onConflictDoUpdate({ 41 | target: tables.users.email, 42 | set: { 43 | name: payload.name, 44 | avatarUrl: payload.avatarUrl, 45 | }, 46 | }) 47 | .returning() 48 | .get() 49 | return record 50 | } catch (error) { 51 | console.error(error) 52 | throw new Error('Failed to create user with OAuth') 53 | } 54 | } 55 | 56 | export const updateUser = async (userId: string, payload: Partial) => { 57 | try { 58 | const record = await useDB() 59 | .update(tables.users) 60 | .set(payload) 61 | .where(eq(tables.users.id, userId)) 62 | .returning() 63 | .get() 64 | return record 65 | } catch (error) { 66 | console.error(error) 67 | throw new Error('Failed to update user') 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /server/database/migrations/0000_outgoing_tag.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `users` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `email` text NOT NULL, 4 | `name` text NOT NULL, 5 | `avatarUrl` text, 6 | `created_at` integer, 7 | `provider` text, 8 | `provider_user_id` text, 9 | `updated_at` integer, 10 | `last_active` integer 11 | ); 12 | --> statement-breakpoint 13 | CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint 14 | CREATE TABLE `notes` ( 15 | `id` text PRIMARY KEY NOT NULL, 16 | `userId` text NOT NULL, 17 | `title` text NOT NULL, 18 | `content` text NOT NULL, 19 | `image` text, 20 | `icon` text, 21 | `published` integer DEFAULT false NOT NULL, 22 | `created_at` integer, 23 | `updated_at` integer, 24 | FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade 25 | ); 26 | -------------------------------------------------------------------------------- /server/database/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "bcc896bb-cb23-417f-9e94-b5190ce9bf95", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "users": { 8 | "name": "users", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "text", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "email": { 18 | "name": "email", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "name": { 25 | "name": "name", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "avatarUrl": { 32 | "name": "avatarUrl", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false 37 | }, 38 | "created_at": { 39 | "name": "created_at", 40 | "type": "integer", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false 44 | }, 45 | "provider": { 46 | "name": "provider", 47 | "type": "text", 48 | "primaryKey": false, 49 | "notNull": false, 50 | "autoincrement": false 51 | }, 52 | "provider_user_id": { 53 | "name": "provider_user_id", 54 | "type": "text", 55 | "primaryKey": false, 56 | "notNull": false, 57 | "autoincrement": false 58 | }, 59 | "updated_at": { 60 | "name": "updated_at", 61 | "type": "integer", 62 | "primaryKey": false, 63 | "notNull": false, 64 | "autoincrement": false 65 | }, 66 | "last_active": { 67 | "name": "last_active", 68 | "type": "integer", 69 | "primaryKey": false, 70 | "notNull": false, 71 | "autoincrement": false 72 | } 73 | }, 74 | "indexes": { 75 | "users_email_unique": { 76 | "name": "users_email_unique", 77 | "columns": [ 78 | "email" 79 | ], 80 | "isUnique": true 81 | } 82 | }, 83 | "foreignKeys": {}, 84 | "compositePrimaryKeys": {}, 85 | "uniqueConstraints": {}, 86 | "checkConstraints": {} 87 | }, 88 | "notes": { 89 | "name": "notes", 90 | "columns": { 91 | "id": { 92 | "name": "id", 93 | "type": "text", 94 | "primaryKey": true, 95 | "notNull": true, 96 | "autoincrement": false 97 | }, 98 | "userId": { 99 | "name": "userId", 100 | "type": "text", 101 | "primaryKey": false, 102 | "notNull": true, 103 | "autoincrement": false 104 | }, 105 | "title": { 106 | "name": "title", 107 | "type": "text", 108 | "primaryKey": false, 109 | "notNull": true, 110 | "autoincrement": false 111 | }, 112 | "content": { 113 | "name": "content", 114 | "type": "text", 115 | "primaryKey": false, 116 | "notNull": true, 117 | "autoincrement": false 118 | }, 119 | "image": { 120 | "name": "image", 121 | "type": "text", 122 | "primaryKey": false, 123 | "notNull": false, 124 | "autoincrement": false 125 | }, 126 | "icon": { 127 | "name": "icon", 128 | "type": "text", 129 | "primaryKey": false, 130 | "notNull": false, 131 | "autoincrement": false 132 | }, 133 | "published": { 134 | "name": "published", 135 | "type": "integer", 136 | "primaryKey": false, 137 | "notNull": true, 138 | "autoincrement": false, 139 | "default": false 140 | }, 141 | "created_at": { 142 | "name": "created_at", 143 | "type": "integer", 144 | "primaryKey": false, 145 | "notNull": false, 146 | "autoincrement": false 147 | }, 148 | "updated_at": { 149 | "name": "updated_at", 150 | "type": "integer", 151 | "primaryKey": false, 152 | "notNull": false, 153 | "autoincrement": false 154 | } 155 | }, 156 | "indexes": {}, 157 | "foreignKeys": { 158 | "notes_userId_users_id_fk": { 159 | "name": "notes_userId_users_id_fk", 160 | "tableFrom": "notes", 161 | "tableTo": "users", 162 | "columnsFrom": [ 163 | "userId" 164 | ], 165 | "columnsTo": [ 166 | "id" 167 | ], 168 | "onDelete": "cascade", 169 | "onUpdate": "no action" 170 | } 171 | }, 172 | "compositePrimaryKeys": {}, 173 | "uniqueConstraints": {}, 174 | "checkConstraints": {} 175 | } 176 | }, 177 | "views": {}, 178 | "enums": {}, 179 | "_meta": { 180 | "schemas": {}, 181 | "tables": {}, 182 | "columns": {} 183 | }, 184 | "internal": { 185 | "indexes": {} 186 | } 187 | } -------------------------------------------------------------------------------- /server/database/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1733454130379, 9 | "tag": "0000_outgoing_tag", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /server/database/schema/index.ts: -------------------------------------------------------------------------------- 1 | export * from './users' 2 | export * from './notes' 3 | -------------------------------------------------------------------------------- /server/database/schema/notes.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' 3 | import { users } from './users' 4 | 5 | export const notes = sqliteTable('notes', { 6 | id: text('id') 7 | .primaryKey() 8 | .$default(() => nanoid()), 9 | userId: text('userId') 10 | .notNull() 11 | .references(() => users.id, { onDelete: 'cascade' }), 12 | title: text('title').notNull(), 13 | content: text('content').notNull(), 14 | image: text('image'), 15 | icon: text('icon'), 16 | published: integer('published', { mode: 'boolean' }).notNull().default(false), 17 | createdAt: integer('created_at', { mode: 'timestamp' }).$default( 18 | () => new Date(), 19 | ), 20 | updatedAt: integer('updated_at', { mode: 'timestamp' }).$onUpdate( 21 | () => new Date(), 22 | ), 23 | }) 24 | -------------------------------------------------------------------------------- /server/database/schema/users.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' 3 | 4 | export const users = sqliteTable('users', { 5 | id: text('id') 6 | .primaryKey() 7 | .$default(() => nanoid()), 8 | email: text('email').notNull().unique(), 9 | name: text('name').notNull(), 10 | avatarUrl: text('avatarUrl'), 11 | createdAt: integer('created_at', { mode: 'timestamp' }).$default( 12 | () => new Date(), 13 | ), 14 | provider: text('provider'), 15 | providerUserId: text('provider_user_id'), 16 | updatedAt: integer('updated_at', { mode: 'timestamp' }).$onUpdate( 17 | () => new Date(), 18 | ), 19 | lastActive: integer('last_active', { mode: 'timestamp' }).$onUpdate( 20 | () => new Date(), 21 | ), 22 | }) 23 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /server/utils/database.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/d1' 2 | import * as schema from '../database/schema' 3 | 4 | export const tables = schema 5 | 6 | export function useDB() { 7 | return drizzle(hubDatabase(), { schema }) 8 | } 9 | -------------------------------------------------------------------------------- /server/utils/oauth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | findUserByEmail, 3 | createUser, 4 | updateUser, 5 | } from '@@/server/database/actions/users' 6 | 7 | export interface OAuthUserData { 8 | email: string 9 | name: string 10 | avatarUrl: string 11 | provider: 'discord' | 'google' | 'github' 12 | providerUserId: string 13 | } 14 | 15 | export const handleOAuthSuccess = async ( 16 | event: any, 17 | oauthUser: OAuthUserData, 18 | ) => { 19 | // 2. Check if user exists 20 | let dbUser = await findUserByEmail(oauthUser.email) 21 | 22 | // 3. If new user, create user with OAuth data 23 | if (!dbUser) { 24 | dbUser = await createUser({ 25 | email: oauthUser.email, 26 | name: oauthUser.name, 27 | avatarUrl: oauthUser.avatarUrl, 28 | }) 29 | } 30 | 31 | // 4. Update avatar if not set 32 | if (!dbUser.avatarUrl && oauthUser.avatarUrl) { 33 | dbUser = await updateUser(dbUser.id, { 34 | avatarUrl: oauthUser.avatarUrl, 35 | }) 36 | } 37 | 38 | // 8. Set user session and redirect to dashboard 39 | await setUserSession(event, { user: dbUser }) 40 | return sendRedirect(event, '/dashboard') 41 | } 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /types/auth.d.ts: -------------------------------------------------------------------------------- 1 | import type { User as DrizzleUser } from './database' 2 | 3 | declare module '#auth-utils' { 4 | interface User extends Omit {} 5 | } 6 | 7 | export {} 8 | -------------------------------------------------------------------------------- /types/database.ts: -------------------------------------------------------------------------------- 1 | import { tables } from '@@/server/utils/database' 2 | import { createInsertSchema, createSelectSchema } from 'drizzle-zod' 3 | 4 | export type User = typeof tables.users.$inferSelect 5 | export type InsertUser = typeof tables.users.$inferInsert 6 | 7 | export type Note = typeof tables.notes.$inferSelect 8 | export type InsertNote = typeof tables.notes.$inferInsert 9 | 10 | // Zod schemas 11 | export const insertUserSchema = createInsertSchema(tables.users) 12 | export const selectUserSchema = createSelectSchema(tables.users) 13 | 14 | export const insertNoteSchema = createInsertSchema(tables.notes) 15 | export const selectNoteSchema = createSelectSchema(tables.notes) 16 | --------------------------------------------------------------------------------