UNPKG

nuxt-supabase-team-auth

Version:

Drop-in Nuxt 3 module for team-based authentication with Supabase

291 lines (288 loc) 9.77 kB
import { defineNuxtModule, createResolver, installModule, addImports, addComponentsDir } from '@nuxt/kit'; import { defu } from 'defu'; const module = defineNuxtModule({ meta: { name: "nuxt-supabase-team-auth", configKey: "teamAuth", compatibility: { nuxt: "^3.0.0" } }, defaults: { redirectTo: "/dashboard", loginPage: "/signin", // Use /signin as default, can be overridden defaultProtection: "public", // Default to public (opt-in protection) publicRoutes: [], // Additional public routes beyond auth pages protectedRoutes: ["/dashboard"], // Default protected routes emailTemplates: {}, socialProviders: { google: { enabled: true // Default enabled for backward compatibility } // Future providers will be added here when implemented } }, async setup(options, nuxt) { const resolver = createResolver(import.meta.url); await installModule("@nuxt/ui"); nuxt.options.build = nuxt.options.build || {}; nuxt.options.build.transpile = nuxt.options.build.transpile || []; const supabaseUrl = options.supabaseUrl || process.env.SUPABASE_URL; const supabaseKey = options.supabaseKey || process.env.SUPABASE_ANON_KEY; const serviceKey = process.env.SUPABASE_SERVICE_KEY; if (!supabaseUrl) { throw new Error(`[nuxt-supabase-team-auth] Missing Supabase URL. Please set SUPABASE_URL environment variable or configure supabaseUrl in module options.`); } if (!supabaseKey) { throw new Error(`[nuxt-supabase-team-auth] Missing Supabase anon key. Please set SUPABASE_ANON_KEY environment variable or configure supabaseKey in module options.`); } if (!serviceKey && nuxt.options.dev) { console.warn(`[nuxt-supabase-team-auth] Warning: No service role key found. Server-side operations may not work. Please set SUPABASE_SERVICE_KEY environment variable.`); } nuxt.options.runtimeConfig = defu(nuxt.options.runtimeConfig, { supabaseUrl, supabaseKey, supabaseServiceKey: serviceKey }); nuxt.options.runtimeConfig.public = defu(nuxt.options.runtimeConfig.public, { supabaseUrl, supabaseKey }); const authRoutes = [ "/signup", "/signin", "/login", "/accept-invite", "/auth/confirm", // Allow email confirmations "/auth/forgot-password", "/auth/callback", // Allow OAuth callbacks "/auth/reset-password" ]; let excludePaths; if (options.defaultProtection === "public") { excludePaths = []; } else { excludePaths = [ "/", // Home page public by default ...authRoutes, // Auth routes always public ...options.publicRoutes || [] // Additional public routes ]; } const supabaseConfig = { url: supabaseUrl, key: supabaseKey, useSsrCookies: true, clientOptions: { auth: { flowType: "implicit", detectSessionInUrl: true } } }; let redirectOptions; if (options.defaultProtection === "public") { redirectOptions = { login: false, // Disable automatic login redirects callback: "/auth/callback", // Still handle OAuth callbacks exclude: ["/**"] // Exclude everything from Supabase auth redirects }; } else { redirectOptions = { login: options.loginPage || "/signin", callback: "/auth/callback", exclude: excludePaths }; } supabaseConfig.redirectOptions = redirectOptions; const runtimeConfig = { url: supabaseUrl, key: supabaseKey, redirectOptions: { login: redirectOptions.login, callback: redirectOptions.callback, exclude: redirectOptions.exclude } }; nuxt.options.supabase = defu(nuxt.options.supabase || {}, supabaseConfig); nuxt.options.runtimeConfig.public.supabase = defu( nuxt.options.runtimeConfig.public.supabase || {}, runtimeConfig ); nuxt.hook("modules:before", () => { nuxt.options.supabase = defu(nuxt.options.supabase || {}, supabaseConfig); }); const supabaseModuleOptions = { url: supabaseUrl, key: supabaseKey, // Hybrid approach: implicit flow for invitations + SSR cookies for persistence // This solves page reload issues while maintaining invitation functionality // // Key benefits: // - Page reload maintains authentication (no redirect to login) // - Server-side rendering has access to user sessions // - Invitation system continues to work with inviteUserByEmail // - All existing functionality preserved useSsrCookies: true, clientOptions: { auth: { flowType: "implicit", // Required for inviteUserByEmail compatibility detectSessionInUrl: true // parse #access_token automatically } } }; supabaseModuleOptions.redirectOptions = redirectOptions; await installModule("@nuxtjs/supabase", supabaseModuleOptions); nuxt.options.runtimeConfig.public.teamAuth = defu( nuxt.options.runtimeConfig.public.teamAuth || {}, { supabaseUrl, supabaseKey, redirectTo: options.redirectTo, loginPage: options.loginPage, defaultProtection: options.defaultProtection, protectedRoutes: options.protectedRoutes, publicRoutes: options.publicRoutes, socialProviders: options.socialProviders, passwordPolicy: options.passwordPolicy } ); addImports([ { name: "useTeamAuth", from: resolver.resolve("./runtime/composables/useTeamAuth") }, { name: "useSession", from: resolver.resolve("./runtime/composables/useSession") }, { name: "useTeamAuthConfig", from: resolver.resolve("./runtime/composables/useTeamAuthConfig") }, { name: "usePasswordPolicy", from: resolver.resolve("./runtime/composables/usePasswordPolicy") } // Note: useSupabaseClient, useSupabaseSession, useSupabaseUser are NOT exported // They're internal wrappers used only within our module's runtime code // Consumer apps should use the composables from @nuxtjs/supabase directly ]); addComponentsDir({ path: resolver.resolve("./runtime/components"), prefix: "", transpile: true, // Enable transpilation for module components pathPrefix: false, // Don't use path-based prefixes global: true // Register components globally }); if (!nuxt.options.build.transpile.includes(resolver.resolve("./runtime"))) { nuxt.options.build.transpile.push(resolver.resolve("./runtime")); } nuxt.hook("app:resolve", (app) => { app.middleware.push(...[ { name: "require-auth", path: resolver.resolve("./runtime/middleware/require-auth"), global: false }, { name: "require-team", path: resolver.resolve("./runtime/middleware/require-team"), global: false }, { name: "require-role", path: resolver.resolve("./runtime/middleware/require-role"), global: false }, { name: "redirect-authenticated", path: resolver.resolve("./runtime/middleware/redirect-authenticated"), global: false }, { name: "impersonation", path: resolver.resolve("./runtime/middleware/impersonation"), global: false } ]); }); nuxt.options.css = nuxt.options.css || []; nuxt.options.css.push(resolver.resolve("./runtime/assets/css/components.css")); nuxt.options.nitro = nuxt.options.nitro || {}; nuxt.options.nitro.routeRules = nuxt.options.nitro.routeRules || {}; nuxt.options.nitro.routeRules["/auth/confirm"] = { ssr: false }; nuxt.hook("nitro:config", (nitroConfig) => { nitroConfig.handlers = nitroConfig.handlers || []; const apiEndpoints = [ "accept-invite", "delete-user", "get-pending-invitations", "impersonate", "invite-member", "process-invitation", "revoke-invitation", "signup-with-team", "stop-impersonation", "transfer-ownership" ]; apiEndpoints.forEach((endpoint) => { nitroConfig.handlers.push({ route: `/api/${endpoint}`, method: "post", handler: resolver.resolve(`./runtime/server/api/${endpoint}.post`) }); }); nitroConfig.handlers.push({ route: "/api/auth/sync-session", method: "post", handler: resolver.resolve("./runtime/server/api/auth/sync-session.post") }); }); nuxt.hook("pages:extend", (pages) => { pages.push({ name: "auth-callback", path: "/auth/callback", file: resolver.resolve("./runtime/pages/auth/callback.vue") }); pages.push({ name: "auth-confirm", path: "/auth/confirm", file: resolver.resolve("./runtime/pages/auth/confirm.vue") }); pages.push({ name: "auth-forgot-password", path: "/auth/forgot-password", file: resolver.resolve("./runtime/pages/auth/forgot-password.vue") }); pages.push({ name: "accept-invite", path: "/accept-invite", file: resolver.resolve("./runtime/pages/accept-invite.vue") }); }); nuxt.hook("prepare:types", (options2) => { options2.references.push({ path: resolver.resolve("./runtime/types/index.d.ts") }); }); } }); export { module as default };