├── .env.example ├── .gitignore ├── .npmrc ├── DEPLOYMENT.md ├── README.md ├── app ├── app.config.ts ├── app.vue ├── assets │ └── css │ │ └── index.css ├── components │ ├── Callout.vue │ ├── CreateTeamModal.vue │ ├── CrossedDiv.vue │ ├── CurrentTeam.vue │ ├── Footer.vue │ ├── ImpersonationBanner.vue │ ├── TeamsMenu.vue │ └── UserMenu.vue ├── composables │ ├── auth.ts │ ├── useImpersonation.ts │ └── useOrgs.ts ├── layouts │ ├── auth.vue │ └── default.vue ├── middleware │ ├── admin.ts │ ├── auth.ts │ ├── guest.ts │ └── onboarding.ts ├── pages │ ├── app.vue │ ├── app │ │ ├── admin.vue │ │ ├── notes.vue │ │ ├── teams.vue │ │ └── user.vue │ ├── index.vue │ └── onboarding.vue └── plugins │ └── auth.client.ts ├── eslint.config.js ├── nuxt.config.ts ├── package.json ├── pnpm-lock.yaml ├── public ├── favicon.ico └── og.png ├── renovate.json ├── server ├── api │ ├── [...auth].ts │ ├── migrate.ts │ ├── notes │ │ ├── [id] │ │ │ └── index.delete.ts │ │ ├── index.get.ts │ │ └── index.post.ts │ └── test.ts ├── database │ ├── drizzle.config.ts │ ├── migrations │ │ ├── 0000_talented_peter_parker.sql │ │ └── meta │ │ │ ├── 0000_snapshot.json │ │ │ └── _journal.json │ └── schema │ │ ├── auth.ts │ │ └── index.ts ├── tsconfig.json └── utils │ ├── auth.ts │ ├── drizzle.ts │ └── team.ts ├── shared └── types │ └── organizations.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | BETTER_AUTH_SECRET= 2 | 3 | GITHUB_CLIENT_ID= 4 | GITHUB_CLIENT_SECRET= -------------------------------------------------------------------------------- /.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 2 | auto-install-peers=true 3 | ignore-workspace-root-check=true 4 | -------------------------------------------------------------------------------- /DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Deployment Guide 2 | 3 | ## Environment Variables 4 | 5 | Copy these variables to your `.env` file for local development or add them to your hosting platform: 6 | 7 | ```bash 8 | # Database Configuration (PostgreSQL) 9 | DATABASE_URL="postgresql://username:password@host:port/database_name?sslmode=require" 10 | 11 | # Better Auth Configuration 12 | BETTER_AUTH_SECRET="your-secret-key-here" 13 | BETTER_AUTH_URL="http://localhost:3000" # Change to your production URL when deploying 14 | 15 | # GitHub OAuth (optional - for social login) 16 | GITHUB_CLIENT_ID="your-github-client-id" 17 | GITHUB_CLIENT_SECRET="your-github-client-secret" 18 | 19 | # Nuxt UI Pro License (optional - only required for production) 20 | NUXT_UI_PRO_LICENSE="your-license-key" 21 | ``` 22 | 23 | ## Authentication Schema Management 24 | 25 | This project uses Better Auth with custom database integration. When you modify the authentication configuration or need to generate the auth schema: 26 | 27 | ```bash 28 | # Generate Better Auth schema from configuration 29 | pnpm auth:schema 30 | ``` 31 | 32 | This command: 33 | - Reads the Better Auth configuration from `server/utils/auth.ts` 34 | - Generates the corresponding database schema in `server/database/schema/auth.ts` 35 | - Automatically applies ESLint fixes to the generated code 36 | 37 | **When to run this command:** 38 | - After modifying Better Auth plugins or configuration 39 | - When setting up the project for the first time 40 | - Before creating new database migrations 41 | - When integrating auth tables with custom tables 42 | 43 | ## Deploy to Vercel 44 | 45 | ### Step 1: Deploy to Vercel 46 | 47 | 1. Click the "Deploy with Vercel" button in the README 48 | 2. Connect your GitHub repository 49 | 3. Configure the required environment variables (you can add them later) 50 | 4. Deploy the application 51 | 52 | ### Step 2: Set up PostgreSQL Database 53 | 54 | **Option 1: Neon Database (Recommended)** 55 | 56 | 1. Go to your Vercel dashboard 57 | 2. Navigate to your project 58 | 3. Click on the "Integrations" tab 59 | 4. Search for "Neon" and install the **Neon Database** integration 60 | 5. Follow the setup wizard to create a new database 61 | 6. Copy the PostgreSQL connection string provided by Neon 62 | 63 | **Option 2: Other PostgreSQL providers** 64 | 65 | You can use any PostgreSQL provider like Railway, Supabase, or your own PostgreSQL instance. Just make sure to get a valid PostgreSQL connection string. 66 | 67 | ### Step 3: Configure Environment Variables 68 | 69 | 1. In your Vercel project settings, go to "Environment Variables" 70 | 2. Add the following variables: 71 | - `DATABASE_URL`: Your PostgreSQL connection string 72 | - `BETTER_AUTH_SECRET`: Generate a random string (use `openssl rand -base64 32`) 73 | - `BETTER_AUTH_URL`: Your Vercel deployment URL (e.g., `https://your-app.vercel.app`) 74 | - `GITHUB_CLIENT_ID` & `GITHUB_CLIENT_SECRET`: (optional) Your GitHub OAuth credentials 75 | - `NUXT_UI_PRO_LICENSE`: (optional) Your Nuxt UI Pro license 76 | 77 | ### Step 4: Run Database Migrations 78 | 79 | After deployment, visit `https://your-app.vercel.app/api/migrate` to run the database migrations. 80 | 81 | ## Troubleshooting 82 | 83 | ### Migration Issues 84 | - Ensure `DATABASE_URL` is correctly set 85 | - Visit `/api/migrate` endpoint after deployment 86 | - Check logs for migration errors 87 | 88 | ### Authentication Issues 89 | - Verify `BETTER_AUTH_SECRET` is set and consistent 90 | - Check `BETTER_AUTH_URL` matches your deployment URL 91 | - Ensure GitHub OAuth credentials are correct (if using) 92 | 93 | ### Database Connection Issues 94 | - Verify database URL format includes `?sslmode=require` for SSL connections 95 | - Check if database allows connections from your deployment platform 96 | - Ensure database user has proper permissions -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![social-preview](./public/og.png) 2 | 3 | > **⚠️** This repo is a fork of https://github.com/atinux/nuxthub-better-auth 4 | 5 | # Nuxt x BetterAuth 6 | 7 | A demo of using [BetterAuth](https://better-auth.com) with Nuxt and PostgreSQL. This template is designed to be deployed anywhere, with specific instructions for Vercel + Neon Database. 8 | 9 | https://better-auth.hrcd.fr 10 | 11 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FHugoRCD%2Fnuxt-better-auth&env=DATABASE_URL,BETTER_AUTH_SECRET,BETTER_AUTH_URL&envDescription=Required%20environment%20variables&envLink=https%3A%2F%2Fgithub.com%2FHugoRCD%2Fnuxt-better-auth%23environment-variables) 12 | 13 | # TODO 14 | 15 | - [x] OAuth not working (redirects to login page) 16 | - [ ] Automatic migration 17 | - [x] Connect notes with organizations 18 | - [x] Default team on login 19 | - [ ] Bug on inpersonate 20 | - [x] Account change bug → Remain on other account's team 21 | - [ ] Fix typescript user.role 22 | - [x] Refresh bug in prod (disconnects user briefly) 23 | - [x] Delete account 24 | - [ ] Delete user default team when deleting account 25 | - [x] Onboarding 26 | 27 | ## Features 28 | 29 | - [BetterAuth](https://better-auth.com) for authentication with organizations support 30 | - Server-Side rendering with Nuxt 31 | - PostgreSQL database with [Drizzle ORM](https://orm.drizzle.team/) 32 | - `useAuth()` Vue composable for easy authentication 33 | - `serverAuth()` composable for accessing Better Auth instance on the server 34 | - Deploy anywhere (Vercel, Netlify, Cloudflare, self-hosted) 35 | - TypeScript support 36 | 37 | ## Setup 38 | 39 | Make sure to install the dependencies with [pnpm](https://pnpm.io/installation#using-corepack): 40 | 41 | ```bash 42 | pnpm install 43 | ``` 44 | 45 | Copy the `.env.example` file to `.env` and update the variables with your own values. 46 | 47 | ### Environment Variables 48 | 49 | - `DATABASE_URL`: Your PostgreSQL connection string (use Neon Database for Vercel deployment) 50 | - `BETTER_AUTH_SECRET`: A random string used by Better Auth for encryption and generating hashes 51 | - `BETTER_AUTH_URL`: Your application URL (set to production URL when deploying) 52 | - `GITHUB_CLIENT_ID` & `GITHUB_CLIENT_SECRET`: GitHub OAuth credentials (optional, see [create an OAuth application](https://github.com/settings/applications/new)) 53 | - `NUXT_UI_PRO_LICENSE`: Your Nuxt UI Pro license key (only required for production), purchase [here](https://ui.nuxt.com/pro) 54 | 55 | ### Database Setup 56 | 57 | #### Option 1: Vercel + Neon Database (Recommended) 58 | 59 | 1. Deploy to Vercel using the deploy button above 60 | 2. In your Vercel dashboard, go to the Integrations tab 61 | 3. Install the **Neon Database** integration from the marketplace 62 | 4. Follow the setup to create a new database and get your `DATABASE_URL` 63 | 5. Add the `DATABASE_URL` to your Vercel environment variables 64 | 65 | #### Option 2: Local Development with PostgreSQL 66 | 67 | 1. Install PostgreSQL locally or use a service like [Railway](https://railway.app) or [Supabase](https://supabase.com) 68 | 2. Create a database and get your connection string 69 | 3. Set the `DATABASE_URL` in your `.env` file 70 | 71 | ## Development Server 72 | 73 | Start the development server on `http://localhost:3000`: 74 | 75 | ```bash 76 | pnpm dev 77 | ``` 78 | 79 | ## Database Migrations 80 | 81 | Generate migration files when you modify the schema: 82 | 83 | ```bash 84 | pnpm db:generate 85 | ``` 86 | 87 | Run migrations to update your database: 88 | 89 | ```bash 90 | pnpm db:migrate 91 | ``` 92 | 93 | For development, you can also push schema changes directly: 94 | 95 | ```bash 96 | pnpm db:push 97 | ``` 98 | 99 | ## Production 100 | 101 | Build the application for production: 102 | 103 | ```bash 104 | pnpm build 105 | ``` 106 | 107 | ## Deploy 108 | 109 | ### Deploy to Vercel (Recommended) 110 | 111 | 1. Click the "Deploy with Vercel" button above 112 | 2. Connect your GitHub repository 113 | 3. Add the required environment variables 114 | 4. Install the Neon Database integration for your database 115 | 5. Deploy! 116 | 117 | After deployment, visit the `/api/migrate` endpoint to run database migrations. 118 | 119 | ### Database Migrations 120 | 121 | Right now, we don't automatically run migrations on deployment. You can manually run them by visiting the `/api/migrate` endpoint after deploying. 122 | -------------------------------------------------------------------------------- /app/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | ui: { 3 | colors: { 4 | neutral: 'neutral' 5 | }, 6 | input: { 7 | slots: { 8 | root: 'w-full' 9 | } 10 | }, 11 | button: { 12 | defaultVariants: { 13 | size: 'sm' 14 | } 15 | } 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /app/assets/css/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "@nuxt/ui-pro"; 3 | 4 | @theme { 5 | --font-sans: 'Geist', sans-serif; 6 | --font-mono: 'Jetbrains Mono', monospace; 7 | } 8 | 9 | :root { 10 | --ui-primary: black; 11 | 12 | --ui-radius: 0 13 | 14 | ::selection { 15 | color: #282a30; 16 | background-color: #c8c8c8; 17 | } 18 | } 19 | 20 | .dark { 21 | --ui-primary: white; 22 | --ui-bg: #0d0d0d; 23 | 24 | ::selection { 25 | color: #ffffff; 26 | background-color: #474747; 27 | } 28 | } 29 | 30 | .bg-stripes { 31 | @apply w-full [background-size:4px_4px]; 32 | @apply dark:[background-image:linear-gradient(-45deg,var(--color-neutral-700)_12.50%,transparent_12.50%,transparent_50%,var(--color-neutral-700)_50%,var(--color-neutral-700)_62.50%,transparent_62.50%,transparent_100%)]; 33 | @apply not-dark:[background-image:linear-gradient(-45deg,var(--color-neutral-200)_12.50%,transparent_12.50%,transparent_50%,var(--color-neutral-200)_50%,var(--color-neutral-200)_62.50%,transparent_62.50%,transparent_100%)]; 34 | } 35 | 36 | .cross { 37 | @apply before:absolute after:absolute; 38 | @apply before:top-[-4px] before:bg-inverted before:content-[''] before:w-[1px] before:h-[9px]; 39 | @apply after:left-[-4px] after:bg-inverted after:content-[''] after:w-[9px] after:h-[1px]; 40 | } 41 | -------------------------------------------------------------------------------- /app/components/Callout.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /app/components/CreateTeamModal.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 42 | -------------------------------------------------------------------------------- /app/components/CrossedDiv.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 23 | -------------------------------------------------------------------------------- /app/components/CurrentTeam.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | 18 | 21 | -------------------------------------------------------------------------------- /app/components/Footer.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 57 | -------------------------------------------------------------------------------- /app/components/ImpersonationBanner.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /app/components/TeamsMenu.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 87 | -------------------------------------------------------------------------------- /app/components/UserMenu.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 98 | -------------------------------------------------------------------------------- /app/composables/auth.ts: -------------------------------------------------------------------------------- 1 | import { defu } from 'defu' 2 | import { createAuthClient } from 'better-auth/client' 3 | import type { 4 | InferSessionFromClient, 5 | InferUserFromClient, 6 | ClientOptions, 7 | } from 'better-auth/client' 8 | import type { RouteLocationRaw } from 'vue-router' 9 | import { adminClient, organizationClient } from 'better-auth/client/plugins' 10 | 11 | interface RuntimeAuthConfig { 12 | redirectUserTo: RouteLocationRaw | string 13 | redirectGuestTo: RouteLocationRaw | string 14 | } 15 | 16 | export function useAuth() { 17 | const url = useRequestURL() 18 | const headers = import.meta.server ? useRequestHeaders() : undefined 19 | 20 | const client = createAuthClient({ 21 | baseURL: url.origin, 22 | fetchOptions: { 23 | headers, 24 | }, 25 | plugins: [adminClient(), organizationClient()] 26 | }) 27 | 28 | const options = defu(useRuntimeConfig().public.auth as Partial, { 29 | redirectUserTo: '/app/user', 30 | redirectGuestTo: '/', 31 | }) 32 | const session = useState | null>('auth:session', () => null) 33 | const user = useState | null>('auth:user', () => null) 34 | const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false) 35 | const activeOrganizationId = useCookie('activeOrganizationId') 36 | 37 | const fetchSession = async () => { 38 | if (sessionFetching.value) return 39 | sessionFetching.value = true 40 | try { 41 | const { data } = await client.getSession({ 42 | fetchOptions: { 43 | headers, 44 | }, 45 | }) 46 | session.value = data?.session || null 47 | user.value = data?.user || null 48 | return data 49 | } catch (error) { 50 | console.error('Error fetching session:', error) 51 | session.value = null 52 | user.value = null 53 | } finally { 54 | sessionFetching.value = false 55 | } 56 | } 57 | 58 | if (import.meta.client) { 59 | client.$store.listen('$sessionSignal', async (signal) => { 60 | if (!signal) return 61 | await fetchSession() 62 | }) 63 | } 64 | 65 | return { 66 | session, 67 | user, 68 | loggedIn: computed(() => !!session.value), 69 | signIn: client.signIn, 70 | signUp: client.signUp, 71 | async signOut({ redirectTo }: { redirectTo?: RouteLocationRaw } = {}) { 72 | const { clearState } = useOrgs() 73 | if (!user.value) { 74 | clearState() 75 | await navigateTo('/') 76 | return 77 | } 78 | const res = await client.signOut() 79 | session.value = null 80 | user.value = null 81 | activeOrganizationId.value = null 82 | clearState() 83 | if (redirectTo) { 84 | await navigateTo(redirectTo) 85 | } 86 | return res 87 | }, 88 | options, 89 | fetchSession, 90 | client, 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/composables/useImpersonation.ts: -------------------------------------------------------------------------------- 1 | import type { User } from 'better-auth' 2 | 3 | export const useImpersonation = () => { 4 | const { client, session } = useAuth() 5 | const toast = useToast() 6 | 7 | const impersonatedUser = useState('impersonated-user', () => null) 8 | const isImpersonating = computed(() => !!impersonatedUser.value) 9 | 10 | onMounted(async () => { 11 | if (session.value?.impersonatedBy) { 12 | await stopImpersonation() 13 | } 14 | }) 15 | 16 | async function startImpersonation(user: User) { 17 | try { 18 | await client.admin.impersonateUser({ userId: user.id }) 19 | impersonatedUser.value = user 20 | toast.add({ 21 | title: 'Success', 22 | description: 'Impersonation started successfully', 23 | color: 'success', 24 | }) 25 | } catch (error) { 26 | toast.add({ 27 | title: 'Error', 28 | description: 'Failed to start impersonation', 29 | color: 'error', 30 | }) 31 | } 32 | } 33 | 34 | async function stopImpersonation() { 35 | try { 36 | await client.admin.stopImpersonating() 37 | impersonatedUser.value = null 38 | toast.add({ 39 | title: 'Success', 40 | description: 'Impersonation stopped successfully', 41 | color: 'success', 42 | }) 43 | } catch (error) { 44 | toast.add({ 45 | title: 'Error', 46 | description: 'Failed to stop impersonation', 47 | color: 'error', 48 | }) 49 | } 50 | } 51 | 52 | return { 53 | isImpersonating, 54 | impersonatedUser, 55 | startImpersonation, 56 | stopImpersonation, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/composables/useOrgs.ts: -------------------------------------------------------------------------------- 1 | import type { FormSubmitEvent } from '@nuxt/ui' 2 | import type { Organization, Member } from 'better-auth/plugins' 3 | 4 | export const useCurrentOrganization = () => { 5 | return useState('organization', () => null) 6 | } 7 | 8 | export function useOrgs() { 9 | const { client } = useAuth() 10 | const organization = useCurrentOrganization() 11 | const activeOrganizationId = useCookie('activeOrganizationId') 12 | const toast = useToast() 13 | 14 | const organizations = useState('organizations', () => []) 15 | const isLoading = useState('orgs-loading', () => false) 16 | const hasOrganizations = computed(() => organizations.value && organizations.value.length > 0) 17 | 18 | async function getFullOrganization(orgId?: string) { 19 | if (!orgId) { 20 | const { data, error } = await client.organization.getFullOrganization() 21 | if (error) { 22 | toast.add({ 23 | title: 'Failed to fetch organization', 24 | color: 'error' 25 | }) 26 | } 27 | return data 28 | } 29 | const { data, error } = await client.organization.getFullOrganization({ 30 | query: { organizationId: orgId } 31 | }) 32 | if (error) { 33 | toast.add({ 34 | title: 'Failed to fetch organization', 35 | color: 'error' 36 | }) 37 | } 38 | return data 39 | } 40 | 41 | async function fetchOrganizations() { 42 | if (isLoading.value) return organizations.value 43 | 44 | isLoading.value = true 45 | try { 46 | const { data, error } = await client.organization.list() 47 | 48 | if (error) { 49 | toast.add({ 50 | title: 'Failed to fetch organizations', 51 | color: 'error' 52 | }) 53 | return organizations.value 54 | } 55 | 56 | const fullOrgs = await Promise.all( 57 | data!.map((org) => getFullOrganization(org.id)) 58 | ) as FullOrganization[] 59 | 60 | organizations.value = fullOrgs 61 | 62 | if (!activeOrganizationId.value && fullOrgs.length > 0) { 63 | const [firstOrg] = fullOrgs 64 | if (firstOrg) { 65 | activeOrganizationId.value = firstOrg.id 66 | console.log(`Auto-selecting first organization: ${firstOrg.name}`) 67 | } 68 | } 69 | 70 | return fullOrgs 71 | } finally { 72 | isLoading.value = false 73 | } 74 | } 75 | 76 | async function fetchCurrentOrganization() { 77 | if (!activeOrganizationId.value) return null 78 | organization.value = await getFullOrganization(activeOrganizationId.value) 79 | return organization.value 80 | } 81 | 82 | async function selectTeam(id: string, options: { showToast?: boolean } = {}) { 83 | const { showToast = true } = options 84 | const { data, error } = await client.organization.setActive({ 85 | organizationId: id 86 | }) 87 | activeOrganizationId.value = id 88 | await fetchCurrentOrganization() 89 | if (showToast) { 90 | toast.add({ 91 | title: 'Team selected', 92 | color: 'success' 93 | }) 94 | } 95 | } 96 | 97 | async function checkSlug(slug: string) { 98 | const { error } = await client.organization.checkSlug({ 99 | slug 100 | }) 101 | if (error?.code === 'SLUG_IS_TAKEN') { 102 | toast.add({ 103 | title: 'Slug is taken', 104 | color: 'error' 105 | }) 106 | return false 107 | } 108 | return true 109 | } 110 | 111 | async function createTeam(event: FormSubmitEvent, options: { showToast?: boolean } = {}) { 112 | const { showToast = true } = options 113 | const isSlugAvailable = await checkSlug(event.data.slug) 114 | if (!isSlugAvailable) return 115 | const { data, error } = await client.organization.create({ 116 | name: event.data.name, 117 | slug: event.data.slug, 118 | logo: event.data.logo 119 | }) 120 | if (error) { 121 | toast.add({ 122 | title: 'Failed to create team', 123 | color: 'error' 124 | }) 125 | return false 126 | } 127 | 128 | await fetchOrganizations() 129 | if (data) { 130 | await selectTeam(data.id, { showToast: false }) 131 | } 132 | 133 | if (showToast) { 134 | toast.add({ 135 | title: 'Team created', 136 | color: 'success' 137 | }) 138 | } 139 | return true 140 | } 141 | 142 | async function deleteTeam(id: string, options: { showToast?: boolean } = {}) { 143 | const { showToast = true } = options 144 | const { data, error } = await client.organization.delete({ 145 | organizationId: id 146 | }) 147 | if (error) { 148 | toast.add({ 149 | title: 'Failed to delete team', 150 | color: 'error' 151 | }) 152 | } 153 | if (showToast) { 154 | toast.add({ 155 | title: 'Team deleted', 156 | color: 'success' 157 | }) 158 | } 159 | await fetchOrganizations() 160 | } 161 | 162 | function clearState() { 163 | activeOrganizationId.value = null 164 | organizations.value = [] 165 | organization.value = null 166 | } 167 | 168 | return { 169 | organization, 170 | organizations, 171 | isLoading, 172 | hasOrganizations, 173 | fetchOrganizations, 174 | fetchCurrentOrganization, 175 | getFullOrganization, 176 | selectTeam, 177 | createTeam, 178 | deleteTeam, 179 | clearState 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /app/layouts/auth.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 102 | -------------------------------------------------------------------------------- /app/middleware/admin.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(() => { 2 | const toast = useToast() 3 | const { loggedIn, user, options } = useAuth() 4 | 5 | if (!loggedIn.value) { 6 | return navigateTo(options.redirectGuestTo || '/') 7 | } 8 | 9 | if ((user.value as any)?.role !== 'admin') { 10 | toast.add({ 11 | title: 'You are not authorized to access this page', 12 | color: 'error' 13 | }) 14 | return navigateTo('/app/user') 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /app/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(() => { 2 | const { loggedIn, options } = useAuth() 3 | 4 | if (!loggedIn.value) { 5 | return navigateTo(options.redirectGuestTo || '/') 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /app/middleware/guest.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(() => { 2 | const { loggedIn, options } = useAuth() 3 | 4 | if (loggedIn.value) { 5 | return navigateTo(options.redirectUserTo || '/app/user') 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /app/middleware/onboarding.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async (to) => { 2 | const { loggedIn, options } = useAuth() 3 | 4 | if (!loggedIn.value) { 5 | return navigateTo(options.redirectGuestTo || '/') 6 | } 7 | 8 | if (to.path !== '/onboarding') { 9 | const { organizations, isLoading, fetchOrganizations } = useOrgs() 10 | 11 | if (organizations.value.length === 0 && !isLoading.value) { 12 | await fetchOrganizations() 13 | } 14 | 15 | if (!organizations.value || organizations.value.length === 0) { 16 | console.log('User needs onboarding, redirecting...') 17 | return navigateTo('/onboarding') 18 | } 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /app/pages/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /app/pages/app/admin.vue: -------------------------------------------------------------------------------- 1 | 89 | 90 | 147 | -------------------------------------------------------------------------------- /app/pages/app/notes.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 146 | -------------------------------------------------------------------------------- /app/pages/app/teams.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 44 | -------------------------------------------------------------------------------- /app/pages/app/user.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 210 | -------------------------------------------------------------------------------- /app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 137 | 138 | 185 | -------------------------------------------------------------------------------- /app/pages/onboarding.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 120 | -------------------------------------------------------------------------------- /app/plugins/auth.client.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin(async () => { 2 | const { session, fetchSession } = useAuth() 3 | 4 | if (!session.value && import.meta.client) { 5 | await fetchSession() 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { createConfig } from "@hrcd/eslint-config" 2 | 3 | export default createConfig() -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | modules: ['@nuxt/ui-pro'], 4 | 5 | devtools: { enabled: true }, 6 | 7 | runtimeConfig: { 8 | public: { 9 | auth: { 10 | redirectUserTo: '/app/user', 11 | redirectGuestTo: '/', 12 | }, 13 | }, 14 | }, 15 | 16 | nitro: { 17 | experimental: { 18 | asyncContext: true 19 | }, 20 | routeRules: { 21 | '/app/**': { 22 | ssr: false 23 | } 24 | } 25 | }, 26 | 27 | $production: { 28 | nitro: { 29 | storage: { 30 | auth: { 31 | driver: 'redis', 32 | url: process.env.REDIS_URL 33 | } 34 | } 35 | } 36 | }, 37 | 38 | $development: { 39 | nitro: { 40 | storage: { 41 | auth: { 42 | driver: 'memory' 43 | } 44 | } 45 | } 46 | }, 47 | 48 | css: ['~/assets/css/index.css'], 49 | 50 | future: { compatibilityVersion: 4 }, 51 | 52 | compatibilityDate: '2025-05-13', 53 | }) 54 | -------------------------------------------------------------------------------- /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 | "postinstall": "nuxt prepare", 10 | "lint": "eslint .", 11 | "lint:fix": "eslint . --fix", 12 | "auth:schema": "npx @better-auth/cli generate --config server/utils/auth.ts --output server/database/schema/auth.ts -y && eslint . --fix", 13 | "db:generate": "drizzle-kit generate --config ./server/database/drizzle.config.ts", 14 | "db:migrate": "drizzle-kit migrate --config ./server/database/drizzle.config.ts", 15 | "db:push": "drizzle-kit push --config ./server/database/drizzle.config.ts" 16 | }, 17 | "dependencies": { 18 | "@iconify-json/heroicons": "^1.2.2", 19 | "@iconify-json/lucide": "^1.2.57", 20 | "@iconify-json/simple-icons": "^1.2.42", 21 | "@nuxt/ui-pro": "^3.2.0", 22 | "@shelve/cli": "^4.1.6", 23 | "@vueuse/core": "^13.5.0", 24 | "better-auth": "^1.2.12", 25 | "drizzle-orm": "^0.44.2", 26 | "ioredis": "^5.6.1", 27 | "nuxt": "^3.17.6", 28 | "pg": "^8.16.3", 29 | "vue": "^3.5.17", 30 | "vue-router": "^4.5.1", 31 | "zod": "^3.25.76" 32 | }, 33 | "devDependencies": { 34 | "@hrcd/eslint-config": "^3.0.3", 35 | "drizzle-kit": "^0.31.4", 36 | "eslint": "^9.30.1", 37 | "typescript": "^5.8.3", 38 | "vue-tsc": "^3.0.1", 39 | "wrangler": "^4.24.3" 40 | }, 41 | "packageManager": "pnpm@10.13.1" 42 | } 43 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HugoRCD/nuxt-better-auth/82783eb16473f36ecaf812de89f562b41af46018/public/favicon.ico -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HugoRCD/nuxt-better-auth/82783eb16473f36ecaf812de89f562b41af46018/public/og.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>hugorcd/renovate-config" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /server/api/[...auth].ts: -------------------------------------------------------------------------------- 1 | export default eventHandler(event => serverAuth().handler(toWebRequest(event))) 2 | -------------------------------------------------------------------------------- /server/api/migrate.ts: -------------------------------------------------------------------------------- 1 | import { getMigrations } from 'better-auth/db' 2 | 3 | export default eventHandler(async () => { 4 | const auth = serverAuth() 5 | const { toBeCreated, toBeAdded, runMigrations } = await getMigrations(auth.options) 6 | if (!toBeCreated.length && !toBeAdded.length) { 7 | return 'No migrations to run' 8 | } 9 | await runMigrations() 10 | return 'Database migrations ran successfully' 11 | }) 12 | -------------------------------------------------------------------------------- /server/api/notes/[id]/index.delete.ts: -------------------------------------------------------------------------------- 1 | import { eq, and } from 'drizzle-orm' 2 | import { notes } from '../../../database/schema' 3 | 4 | export default eventHandler(async (event) => { 5 | const { user, team } = await requireTeam(event) 6 | const id = parseInt(getRouterParam(event, 'id') as string) 7 | 8 | if (!id) { 9 | throw createError({ 10 | statusCode: 400, 11 | statusMessage: 'Invalid note ID' 12 | }) 13 | } 14 | 15 | const deletedNote = await useDrizzle() 16 | .delete(notes) 17 | .where( 18 | and( 19 | eq(notes.id, id), 20 | eq(notes.organizationId, team.id), 21 | eq(notes.userId, user.id) 22 | ) 23 | ) 24 | .returning() 25 | 26 | if (!deletedNote.length) { 27 | throw createError({ 28 | statusCode: 404, 29 | statusMessage: 'Note not found or access denied' 30 | }) 31 | } 32 | 33 | return { success: true } 34 | }) 35 | -------------------------------------------------------------------------------- /server/api/notes/index.get.ts: -------------------------------------------------------------------------------- 1 | export default eventHandler(async (event) => { 2 | const { team } = await requireTeam(event) 3 | 4 | const notesWithUser = await useDrizzle().query.notes.findMany({ 5 | where: (notes, { eq }) => eq(notes.organizationId, team.id), 6 | with: { 7 | user: true 8 | }, 9 | orderBy: (notes, { desc }) => desc(notes.createdAt) 10 | }) 11 | 12 | return notesWithUser 13 | }) 14 | -------------------------------------------------------------------------------- /server/api/notes/index.post.ts: -------------------------------------------------------------------------------- 1 | import { notes } from '../../database/schema' 2 | 3 | export default eventHandler(async (event) => { 4 | const { user, team } = await requireTeam(event) 5 | const { title, content } = await readBody(event) 6 | 7 | const note = await useDrizzle().insert(notes).values({ 8 | title, 9 | content, 10 | userId: user.id, 11 | organizationId: team.id, 12 | createdAt: new Date(), 13 | updatedAt: new Date() 14 | }).returning() 15 | 16 | return note[0] 17 | }) 18 | -------------------------------------------------------------------------------- /server/api/test.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from 'h3' 2 | 3 | export default defineEventHandler(async (event: H3Event) => { 4 | const auth = serverAuth() 5 | const session = await auth.api.listSessions({ 6 | // @ts-expect-error - getHeaders is not typed correctly 7 | headers: getHeaders(event) 8 | }) 9 | return session 10 | }) 11 | -------------------------------------------------------------------------------- /server/database/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit' 2 | 3 | export default defineConfig({ 4 | dialect: 'postgresql', 5 | schema: './server/database/schema/index.ts', 6 | out: './server/database/migrations', 7 | dbCredentials: { 8 | url: process.env.DATABASE_URL! 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /server/database/migrations/0000_talented_peter_parker.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "notes" ( 2 | "id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "notes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), 3 | "title" text NOT NULL, 4 | "content" text NOT NULL, 5 | "done" boolean DEFAULT false NOT NULL, 6 | "user_id" text NOT NULL, 7 | "organization_id" text NOT NULL, 8 | "created_at" timestamp NOT NULL, 9 | "updated_at" timestamp NOT NULL 10 | ); 11 | --> statement-breakpoint 12 | CREATE TABLE "account" ( 13 | "id" text PRIMARY KEY NOT NULL, 14 | "account_id" text NOT NULL, 15 | "provider_id" text NOT NULL, 16 | "user_id" text NOT NULL, 17 | "access_token" text, 18 | "refresh_token" text, 19 | "id_token" text, 20 | "access_token_expires_at" timestamp, 21 | "refresh_token_expires_at" timestamp, 22 | "scope" text, 23 | "password" text, 24 | "created_at" timestamp NOT NULL, 25 | "updated_at" timestamp NOT NULL 26 | ); 27 | --> statement-breakpoint 28 | CREATE TABLE "invitation" ( 29 | "id" text PRIMARY KEY NOT NULL, 30 | "organization_id" text NOT NULL, 31 | "email" text NOT NULL, 32 | "role" text, 33 | "status" text DEFAULT 'pending' NOT NULL, 34 | "expires_at" timestamp NOT NULL, 35 | "inviter_id" text NOT NULL 36 | ); 37 | --> statement-breakpoint 38 | CREATE TABLE "member" ( 39 | "id" text PRIMARY KEY NOT NULL, 40 | "organization_id" text NOT NULL, 41 | "user_id" text NOT NULL, 42 | "role" text DEFAULT 'member' NOT NULL, 43 | "created_at" timestamp NOT NULL 44 | ); 45 | --> statement-breakpoint 46 | CREATE TABLE "organization" ( 47 | "id" text PRIMARY KEY NOT NULL, 48 | "name" text NOT NULL, 49 | "slug" text, 50 | "logo" text, 51 | "created_at" timestamp NOT NULL, 52 | "metadata" text, 53 | CONSTRAINT "organization_slug_unique" UNIQUE("slug") 54 | ); 55 | --> statement-breakpoint 56 | CREATE TABLE "user" ( 57 | "id" text PRIMARY KEY NOT NULL, 58 | "name" text NOT NULL, 59 | "email" text NOT NULL, 60 | "email_verified" boolean NOT NULL, 61 | "image" text, 62 | "created_at" timestamp NOT NULL, 63 | "updated_at" timestamp NOT NULL, 64 | "is_anonymous" boolean, 65 | "role" text, 66 | "banned" boolean, 67 | "ban_reason" text, 68 | "ban_expires" timestamp, 69 | CONSTRAINT "user_email_unique" UNIQUE("email") 70 | ); 71 | --> statement-breakpoint 72 | CREATE TABLE "verification" ( 73 | "id" text PRIMARY KEY NOT NULL, 74 | "identifier" text NOT NULL, 75 | "value" text NOT NULL, 76 | "expires_at" timestamp NOT NULL, 77 | "created_at" timestamp, 78 | "updated_at" timestamp 79 | ); 80 | --> statement-breakpoint 81 | ALTER TABLE "notes" ADD CONSTRAINT "notes_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 82 | ALTER TABLE "notes" ADD CONSTRAINT "notes_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 83 | ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 84 | ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 85 | ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 86 | ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 87 | ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; -------------------------------------------------------------------------------- /server/database/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "4ddb6eb0-def6-4fb3-a6cb-a539d72beb82", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.notes": { 8 | "name": "notes", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "bigint", 14 | "primaryKey": true, 15 | "notNull": true, 16 | "identity": { 17 | "type": "always", 18 | "name": "notes_id_seq", 19 | "schema": "public", 20 | "increment": "1", 21 | "startWith": "1", 22 | "minValue": "1", 23 | "maxValue": "9223372036854775807", 24 | "cache": "1", 25 | "cycle": false 26 | } 27 | }, 28 | "title": { 29 | "name": "title", 30 | "type": "text", 31 | "primaryKey": false, 32 | "notNull": true 33 | }, 34 | "content": { 35 | "name": "content", 36 | "type": "text", 37 | "primaryKey": false, 38 | "notNull": true 39 | }, 40 | "done": { 41 | "name": "done", 42 | "type": "boolean", 43 | "primaryKey": false, 44 | "notNull": true, 45 | "default": false 46 | }, 47 | "user_id": { 48 | "name": "user_id", 49 | "type": "text", 50 | "primaryKey": false, 51 | "notNull": true 52 | }, 53 | "organization_id": { 54 | "name": "organization_id", 55 | "type": "text", 56 | "primaryKey": false, 57 | "notNull": true 58 | }, 59 | "created_at": { 60 | "name": "created_at", 61 | "type": "timestamp", 62 | "primaryKey": false, 63 | "notNull": true 64 | }, 65 | "updated_at": { 66 | "name": "updated_at", 67 | "type": "timestamp", 68 | "primaryKey": false, 69 | "notNull": true 70 | } 71 | }, 72 | "indexes": {}, 73 | "foreignKeys": { 74 | "notes_user_id_user_id_fk": { 75 | "name": "notes_user_id_user_id_fk", 76 | "tableFrom": "notes", 77 | "tableTo": "user", 78 | "columnsFrom": [ 79 | "user_id" 80 | ], 81 | "columnsTo": [ 82 | "id" 83 | ], 84 | "onDelete": "cascade", 85 | "onUpdate": "no action" 86 | }, 87 | "notes_organization_id_organization_id_fk": { 88 | "name": "notes_organization_id_organization_id_fk", 89 | "tableFrom": "notes", 90 | "tableTo": "organization", 91 | "columnsFrom": [ 92 | "organization_id" 93 | ], 94 | "columnsTo": [ 95 | "id" 96 | ], 97 | "onDelete": "cascade", 98 | "onUpdate": "no action" 99 | } 100 | }, 101 | "compositePrimaryKeys": {}, 102 | "uniqueConstraints": {}, 103 | "policies": {}, 104 | "checkConstraints": {}, 105 | "isRLSEnabled": false 106 | }, 107 | "public.account": { 108 | "name": "account", 109 | "schema": "", 110 | "columns": { 111 | "id": { 112 | "name": "id", 113 | "type": "text", 114 | "primaryKey": true, 115 | "notNull": true 116 | }, 117 | "account_id": { 118 | "name": "account_id", 119 | "type": "text", 120 | "primaryKey": false, 121 | "notNull": true 122 | }, 123 | "provider_id": { 124 | "name": "provider_id", 125 | "type": "text", 126 | "primaryKey": false, 127 | "notNull": true 128 | }, 129 | "user_id": { 130 | "name": "user_id", 131 | "type": "text", 132 | "primaryKey": false, 133 | "notNull": true 134 | }, 135 | "access_token": { 136 | "name": "access_token", 137 | "type": "text", 138 | "primaryKey": false, 139 | "notNull": false 140 | }, 141 | "refresh_token": { 142 | "name": "refresh_token", 143 | "type": "text", 144 | "primaryKey": false, 145 | "notNull": false 146 | }, 147 | "id_token": { 148 | "name": "id_token", 149 | "type": "text", 150 | "primaryKey": false, 151 | "notNull": false 152 | }, 153 | "access_token_expires_at": { 154 | "name": "access_token_expires_at", 155 | "type": "timestamp", 156 | "primaryKey": false, 157 | "notNull": false 158 | }, 159 | "refresh_token_expires_at": { 160 | "name": "refresh_token_expires_at", 161 | "type": "timestamp", 162 | "primaryKey": false, 163 | "notNull": false 164 | }, 165 | "scope": { 166 | "name": "scope", 167 | "type": "text", 168 | "primaryKey": false, 169 | "notNull": false 170 | }, 171 | "password": { 172 | "name": "password", 173 | "type": "text", 174 | "primaryKey": false, 175 | "notNull": false 176 | }, 177 | "created_at": { 178 | "name": "created_at", 179 | "type": "timestamp", 180 | "primaryKey": false, 181 | "notNull": true 182 | }, 183 | "updated_at": { 184 | "name": "updated_at", 185 | "type": "timestamp", 186 | "primaryKey": false, 187 | "notNull": true 188 | } 189 | }, 190 | "indexes": {}, 191 | "foreignKeys": { 192 | "account_user_id_user_id_fk": { 193 | "name": "account_user_id_user_id_fk", 194 | "tableFrom": "account", 195 | "tableTo": "user", 196 | "columnsFrom": [ 197 | "user_id" 198 | ], 199 | "columnsTo": [ 200 | "id" 201 | ], 202 | "onDelete": "cascade", 203 | "onUpdate": "no action" 204 | } 205 | }, 206 | "compositePrimaryKeys": {}, 207 | "uniqueConstraints": {}, 208 | "policies": {}, 209 | "checkConstraints": {}, 210 | "isRLSEnabled": false 211 | }, 212 | "public.invitation": { 213 | "name": "invitation", 214 | "schema": "", 215 | "columns": { 216 | "id": { 217 | "name": "id", 218 | "type": "text", 219 | "primaryKey": true, 220 | "notNull": true 221 | }, 222 | "organization_id": { 223 | "name": "organization_id", 224 | "type": "text", 225 | "primaryKey": false, 226 | "notNull": true 227 | }, 228 | "email": { 229 | "name": "email", 230 | "type": "text", 231 | "primaryKey": false, 232 | "notNull": true 233 | }, 234 | "role": { 235 | "name": "role", 236 | "type": "text", 237 | "primaryKey": false, 238 | "notNull": false 239 | }, 240 | "status": { 241 | "name": "status", 242 | "type": "text", 243 | "primaryKey": false, 244 | "notNull": true, 245 | "default": "'pending'" 246 | }, 247 | "expires_at": { 248 | "name": "expires_at", 249 | "type": "timestamp", 250 | "primaryKey": false, 251 | "notNull": true 252 | }, 253 | "inviter_id": { 254 | "name": "inviter_id", 255 | "type": "text", 256 | "primaryKey": false, 257 | "notNull": true 258 | } 259 | }, 260 | "indexes": {}, 261 | "foreignKeys": { 262 | "invitation_organization_id_organization_id_fk": { 263 | "name": "invitation_organization_id_organization_id_fk", 264 | "tableFrom": "invitation", 265 | "tableTo": "organization", 266 | "columnsFrom": [ 267 | "organization_id" 268 | ], 269 | "columnsTo": [ 270 | "id" 271 | ], 272 | "onDelete": "cascade", 273 | "onUpdate": "no action" 274 | }, 275 | "invitation_inviter_id_user_id_fk": { 276 | "name": "invitation_inviter_id_user_id_fk", 277 | "tableFrom": "invitation", 278 | "tableTo": "user", 279 | "columnsFrom": [ 280 | "inviter_id" 281 | ], 282 | "columnsTo": [ 283 | "id" 284 | ], 285 | "onDelete": "cascade", 286 | "onUpdate": "no action" 287 | } 288 | }, 289 | "compositePrimaryKeys": {}, 290 | "uniqueConstraints": {}, 291 | "policies": {}, 292 | "checkConstraints": {}, 293 | "isRLSEnabled": false 294 | }, 295 | "public.member": { 296 | "name": "member", 297 | "schema": "", 298 | "columns": { 299 | "id": { 300 | "name": "id", 301 | "type": "text", 302 | "primaryKey": true, 303 | "notNull": true 304 | }, 305 | "organization_id": { 306 | "name": "organization_id", 307 | "type": "text", 308 | "primaryKey": false, 309 | "notNull": true 310 | }, 311 | "user_id": { 312 | "name": "user_id", 313 | "type": "text", 314 | "primaryKey": false, 315 | "notNull": true 316 | }, 317 | "role": { 318 | "name": "role", 319 | "type": "text", 320 | "primaryKey": false, 321 | "notNull": true, 322 | "default": "'member'" 323 | }, 324 | "created_at": { 325 | "name": "created_at", 326 | "type": "timestamp", 327 | "primaryKey": false, 328 | "notNull": true 329 | } 330 | }, 331 | "indexes": {}, 332 | "foreignKeys": { 333 | "member_organization_id_organization_id_fk": { 334 | "name": "member_organization_id_organization_id_fk", 335 | "tableFrom": "member", 336 | "tableTo": "organization", 337 | "columnsFrom": [ 338 | "organization_id" 339 | ], 340 | "columnsTo": [ 341 | "id" 342 | ], 343 | "onDelete": "cascade", 344 | "onUpdate": "no action" 345 | }, 346 | "member_user_id_user_id_fk": { 347 | "name": "member_user_id_user_id_fk", 348 | "tableFrom": "member", 349 | "tableTo": "user", 350 | "columnsFrom": [ 351 | "user_id" 352 | ], 353 | "columnsTo": [ 354 | "id" 355 | ], 356 | "onDelete": "cascade", 357 | "onUpdate": "no action" 358 | } 359 | }, 360 | "compositePrimaryKeys": {}, 361 | "uniqueConstraints": {}, 362 | "policies": {}, 363 | "checkConstraints": {}, 364 | "isRLSEnabled": false 365 | }, 366 | "public.organization": { 367 | "name": "organization", 368 | "schema": "", 369 | "columns": { 370 | "id": { 371 | "name": "id", 372 | "type": "text", 373 | "primaryKey": true, 374 | "notNull": true 375 | }, 376 | "name": { 377 | "name": "name", 378 | "type": "text", 379 | "primaryKey": false, 380 | "notNull": true 381 | }, 382 | "slug": { 383 | "name": "slug", 384 | "type": "text", 385 | "primaryKey": false, 386 | "notNull": false 387 | }, 388 | "logo": { 389 | "name": "logo", 390 | "type": "text", 391 | "primaryKey": false, 392 | "notNull": false 393 | }, 394 | "created_at": { 395 | "name": "created_at", 396 | "type": "timestamp", 397 | "primaryKey": false, 398 | "notNull": true 399 | }, 400 | "metadata": { 401 | "name": "metadata", 402 | "type": "text", 403 | "primaryKey": false, 404 | "notNull": false 405 | } 406 | }, 407 | "indexes": {}, 408 | "foreignKeys": {}, 409 | "compositePrimaryKeys": {}, 410 | "uniqueConstraints": { 411 | "organization_slug_unique": { 412 | "name": "organization_slug_unique", 413 | "nullsNotDistinct": false, 414 | "columns": [ 415 | "slug" 416 | ] 417 | } 418 | }, 419 | "policies": {}, 420 | "checkConstraints": {}, 421 | "isRLSEnabled": false 422 | }, 423 | "public.user": { 424 | "name": "user", 425 | "schema": "", 426 | "columns": { 427 | "id": { 428 | "name": "id", 429 | "type": "text", 430 | "primaryKey": true, 431 | "notNull": true 432 | }, 433 | "name": { 434 | "name": "name", 435 | "type": "text", 436 | "primaryKey": false, 437 | "notNull": true 438 | }, 439 | "email": { 440 | "name": "email", 441 | "type": "text", 442 | "primaryKey": false, 443 | "notNull": true 444 | }, 445 | "email_verified": { 446 | "name": "email_verified", 447 | "type": "boolean", 448 | "primaryKey": false, 449 | "notNull": true 450 | }, 451 | "image": { 452 | "name": "image", 453 | "type": "text", 454 | "primaryKey": false, 455 | "notNull": false 456 | }, 457 | "created_at": { 458 | "name": "created_at", 459 | "type": "timestamp", 460 | "primaryKey": false, 461 | "notNull": true 462 | }, 463 | "updated_at": { 464 | "name": "updated_at", 465 | "type": "timestamp", 466 | "primaryKey": false, 467 | "notNull": true 468 | }, 469 | "is_anonymous": { 470 | "name": "is_anonymous", 471 | "type": "boolean", 472 | "primaryKey": false, 473 | "notNull": false 474 | }, 475 | "role": { 476 | "name": "role", 477 | "type": "text", 478 | "primaryKey": false, 479 | "notNull": false 480 | }, 481 | "banned": { 482 | "name": "banned", 483 | "type": "boolean", 484 | "primaryKey": false, 485 | "notNull": false 486 | }, 487 | "ban_reason": { 488 | "name": "ban_reason", 489 | "type": "text", 490 | "primaryKey": false, 491 | "notNull": false 492 | }, 493 | "ban_expires": { 494 | "name": "ban_expires", 495 | "type": "timestamp", 496 | "primaryKey": false, 497 | "notNull": false 498 | } 499 | }, 500 | "indexes": {}, 501 | "foreignKeys": {}, 502 | "compositePrimaryKeys": {}, 503 | "uniqueConstraints": { 504 | "user_email_unique": { 505 | "name": "user_email_unique", 506 | "nullsNotDistinct": false, 507 | "columns": [ 508 | "email" 509 | ] 510 | } 511 | }, 512 | "policies": {}, 513 | "checkConstraints": {}, 514 | "isRLSEnabled": false 515 | }, 516 | "public.verification": { 517 | "name": "verification", 518 | "schema": "", 519 | "columns": { 520 | "id": { 521 | "name": "id", 522 | "type": "text", 523 | "primaryKey": true, 524 | "notNull": true 525 | }, 526 | "identifier": { 527 | "name": "identifier", 528 | "type": "text", 529 | "primaryKey": false, 530 | "notNull": true 531 | }, 532 | "value": { 533 | "name": "value", 534 | "type": "text", 535 | "primaryKey": false, 536 | "notNull": true 537 | }, 538 | "expires_at": { 539 | "name": "expires_at", 540 | "type": "timestamp", 541 | "primaryKey": false, 542 | "notNull": true 543 | }, 544 | "created_at": { 545 | "name": "created_at", 546 | "type": "timestamp", 547 | "primaryKey": false, 548 | "notNull": false 549 | }, 550 | "updated_at": { 551 | "name": "updated_at", 552 | "type": "timestamp", 553 | "primaryKey": false, 554 | "notNull": false 555 | } 556 | }, 557 | "indexes": {}, 558 | "foreignKeys": {}, 559 | "compositePrimaryKeys": {}, 560 | "uniqueConstraints": {}, 561 | "policies": {}, 562 | "checkConstraints": {}, 563 | "isRLSEnabled": false 564 | } 565 | }, 566 | "enums": {}, 567 | "schemas": {}, 568 | "sequences": {}, 569 | "roles": {}, 570 | "policies": {}, 571 | "views": {}, 572 | "_meta": { 573 | "columns": {}, 574 | "schemas": {}, 575 | "tables": {} 576 | } 577 | } -------------------------------------------------------------------------------- /server/database/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1751398340105, 9 | "tag": "0000_talented_peter_parker", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /server/database/schema/auth.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core' 2 | 3 | export const user = pgTable('user', { 4 | id: text('id').primaryKey(), 5 | name: text('name').notNull(), 6 | email: text('email').notNull().unique(), 7 | emailVerified: boolean('email_verified').$defaultFn(() => false).notNull(), 8 | image: text('image'), 9 | createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull(), 10 | updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull(), 11 | isAnonymous: boolean('is_anonymous'), 12 | role: text('role'), 13 | banned: boolean('banned'), 14 | banReason: text('ban_reason'), 15 | banExpires: timestamp('ban_expires') 16 | }) 17 | 18 | export const account = pgTable('account', { 19 | id: text('id').primaryKey(), 20 | accountId: text('account_id').notNull(), 21 | providerId: text('provider_id').notNull(), 22 | userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), 23 | accessToken: text('access_token'), 24 | refreshToken: text('refresh_token'), 25 | idToken: text('id_token'), 26 | accessTokenExpiresAt: timestamp('access_token_expires_at'), 27 | refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), 28 | scope: text('scope'), 29 | password: text('password'), 30 | createdAt: timestamp('created_at').notNull(), 31 | updatedAt: timestamp('updated_at').notNull() 32 | }) 33 | 34 | export const verification = pgTable('verification', { 35 | id: text('id').primaryKey(), 36 | identifier: text('identifier').notNull(), 37 | value: text('value').notNull(), 38 | expiresAt: timestamp('expires_at').notNull(), 39 | createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()), 40 | updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date()) 41 | }) 42 | 43 | export const organization = pgTable('organization', { 44 | id: text('id').primaryKey(), 45 | name: text('name').notNull(), 46 | slug: text('slug').unique(), 47 | logo: text('logo'), 48 | createdAt: timestamp('created_at').notNull(), 49 | metadata: text('metadata') 50 | }) 51 | 52 | export const member = pgTable('member', { 53 | id: text('id').primaryKey(), 54 | organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }), 55 | userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), 56 | role: text('role').default('member').notNull(), 57 | createdAt: timestamp('created_at').notNull() 58 | }) 59 | 60 | export const invitation = pgTable('invitation', { 61 | id: text('id').primaryKey(), 62 | organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }), 63 | email: text('email').notNull(), 64 | role: text('role'), 65 | status: text('status').default('pending').notNull(), 66 | expiresAt: timestamp('expires_at').notNull(), 67 | inviterId: text('inviter_id').notNull().references(() => user.id, { onDelete: 'cascade' }) 68 | }) 69 | -------------------------------------------------------------------------------- /server/database/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, text, timestamp, boolean, bigint } from 'drizzle-orm/pg-core' 2 | import { relations } from 'drizzle-orm' 3 | 4 | import { user, organization, member } from './auth' 5 | 6 | export * from './auth' 7 | 8 | export const notes = pgTable('notes', { 9 | id: bigint({ mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(), 10 | title: text('title').notNull(), 11 | content: text('content').notNull(), 12 | done: boolean('done').notNull().default(false), 13 | userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), 14 | organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }), 15 | createdAt: timestamp('created_at').notNull(), 16 | updatedAt: timestamp('updated_at').notNull(), 17 | }) 18 | 19 | export const notesRelations = relations(notes, ({ one }) => ({ 20 | user: one(user, { 21 | fields: [notes.userId], 22 | references: [user.id] 23 | }) 24 | })) 25 | 26 | export const memberRelations = relations(member, ({ one }) => ({ 27 | organization: one(organization, { 28 | fields: [member.organizationId], 29 | references: [organization.id] 30 | }) 31 | })) 32 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /server/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from 'better-auth' 2 | import { anonymous, admin, organization } from 'better-auth/plugins' 3 | import { drizzleAdapter } from 'better-auth/adapters/drizzle' 4 | import { sql, eq, and, ne } from 'drizzle-orm' 5 | import * as schema from '../database/schema' 6 | import { useDrizzle } from './drizzle' 7 | 8 | let _auth: ReturnType 9 | 10 | export function serverAuth() { 11 | if (!_auth) { 12 | _auth = betterAuth({ 13 | database: drizzleAdapter( 14 | useDrizzle(), 15 | { 16 | provider: 'pg', 17 | schema 18 | } 19 | ), 20 | secondaryStorage: { 21 | get: async (key) => { 22 | return await useStorage('auth').getItemRaw(`_auth:${key}`) 23 | }, 24 | set: async (key, value, ttl) => { 25 | return await useStorage('auth').setItem(`_auth:${key}`, value, { ttl }) 26 | }, 27 | delete: async (key) => { 28 | await useStorage('auth').removeItem(`_auth:${key}`) 29 | } 30 | }, 31 | baseURL: getBaseURL(), 32 | emailAndPassword: { 33 | enabled: true, 34 | }, 35 | socialProviders: { 36 | github: { 37 | clientId: process.env.GITHUB_CLIENT_ID!, 38 | clientSecret: process.env.GITHUB_CLIENT_SECRET!, 39 | }, 40 | }, 41 | account: { 42 | accountLinking: { 43 | enabled: true, 44 | }, 45 | }, 46 | user: { 47 | deleteUser: { 48 | enabled: true 49 | }, 50 | }, 51 | plugins: [anonymous(), admin(), organization()], 52 | databaseHooks: { 53 | session: { 54 | create: { 55 | before: (session) => { 56 | const event = useEvent() 57 | const activeOrganizationId = getCookie(event, 'activeOrganizationId') 58 | 59 | return Promise.resolve({ 60 | data: { 61 | ...session, 62 | activeOrganizationId 63 | } 64 | }) 65 | } 66 | }, 67 | }, 68 | }, 69 | }) 70 | } 71 | return _auth 72 | } 73 | 74 | function getBaseURL() { 75 | let baseURL = process.env.BETTER_AUTH_URL 76 | if (!baseURL) { 77 | try { 78 | baseURL = getRequestURL(useEvent()).origin 79 | } catch (e) { /* empty */ } 80 | } 81 | return baseURL 82 | } 83 | 84 | _auth = serverAuth() 85 | 86 | export const auth = _auth! 87 | -------------------------------------------------------------------------------- /server/utils/drizzle.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/node-postgres' 2 | 3 | import * as schema from '../database/schema' 4 | 5 | export { sql, eq, and, or } from 'drizzle-orm' 6 | 7 | export function useDrizzle() { 8 | return drizzle({ 9 | connection: { 10 | connectionString: process.env.DATABASE_URL 11 | }, 12 | schema 13 | }) 14 | } 15 | 16 | export const tables = schema 17 | 18 | export type Note = typeof schema.notes.$inferSelect 19 | export type User = typeof schema.user.$inferSelect 20 | export type Organization = typeof schema.organization.$inferSelect 21 | -------------------------------------------------------------------------------- /server/utils/team.ts: -------------------------------------------------------------------------------- 1 | import { eq, and } from 'drizzle-orm' 2 | import type { H3Event } from 'h3' 3 | import { Organization } from './drizzle' 4 | 5 | export async function requireTeam(event: H3Event): Promise<{ 6 | user: { 7 | id: string; 8 | name: string; 9 | emailVerified: boolean; 10 | email: string; 11 | createdAt: Date; 12 | updatedAt: Date; 13 | image?: string | null | undefined | undefined; 14 | } 15 | team: { 16 | organization: Organization 17 | } 18 | }> { 19 | const sessionData = await serverAuth().api.getSession({ 20 | headers: getHeaders(event) as any 21 | }) 22 | 23 | if (!sessionData?.session) { 24 | throw createError({ 25 | statusCode: 401, 26 | statusMessage: 'Unauthorized' 27 | }) 28 | } 29 | 30 | const { user } = sessionData 31 | const activeOrganizationId = getCookie(event, 'activeOrganizationId') 32 | 33 | if (!activeOrganizationId) { 34 | throw createError({ 35 | statusCode: 400, 36 | statusMessage: 'No team selected' 37 | }) 38 | } 39 | 40 | const db = useDrizzle() 41 | 42 | const member = await db.query.member.findFirst({ 43 | where: (member, { eq }) => and( 44 | eq(member.organizationId, activeOrganizationId), 45 | eq(member.userId, user.id) 46 | ), 47 | with: { 48 | organization: true 49 | } 50 | }) 51 | 52 | if (!member) { 53 | throw createError({ 54 | statusCode: 404, 55 | statusMessage: 'Team not found' 56 | }) 57 | } 58 | 59 | return { 60 | user, 61 | team: member.organization 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /shared/types/organizations.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod' 2 | import type { InvitationStatus } from 'better-auth/plugins' 3 | 4 | export const createTeamSchema = z.object({ 5 | name: z.string().min(1, 'Team name is required'), 6 | slug: z.string().min(1, 'Team slug is required'), 7 | logo: z.string().optional() 8 | }) 9 | 10 | export type CreateTeamSchema = z.output 11 | 12 | export type FullOrganization = { 13 | members: { 14 | id: string; 15 | organizationId: string; 16 | role: 'admin' | 'member' | 'owner'; 17 | createdAt: Date; 18 | userId: string; 19 | user: { 20 | email: string; 21 | name: string; 22 | image?: string; 23 | }; 24 | }[]; 25 | invitations: { 26 | id: string; 27 | organizationId: string; 28 | email: string; 29 | role: 'admin' | 'member' | 'owner'; 30 | status: InvitationStatus; 31 | inviterId: string; 32 | expiresAt: Date; 33 | }[]; 34 | } & { 35 | id: string; 36 | name: string; 37 | createdAt: Date; 38 | slug: string; 39 | metadata?: any; 40 | logo?: string | null | undefined; 41 | } 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json", 3 | } 4 | --------------------------------------------------------------------------------