nuxt-supabase-team-auth
Version:
Drop-in Nuxt 3 module for team-based authentication with Supabase
291 lines (288 loc) • 9.77 kB
JavaScript
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 };