UNPKG

nuxt-supabase-team-auth

Version:

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

284 lines (283 loc) 10.9 kB
import { ref, watch } from "vue"; const SESSION_SYNC_EVENTS = { TEAM_CHANGED: "team_changed", ROLE_CHANGED: "role_changed", IMPERSONATION_STARTED: "impersonation_started", IMPERSONATION_STOPPED: "impersonation_stopped", SESSION_CONFLICT: "session_conflict", SESSION_RECOVERY: "session_recovery" }; const STORAGE_KEYS = { TEAM_SESSION_STATE: "team_auth_session_sync", ACTIVE_TABS: "team_auth_active_tabs", IMPERSONATION_LOCK: "team_auth_impersonation_lock" }; export function useSessionSync() { const tabId = ref(generateTabId()); const isActiveTab = ref(true); const lastSyncTime = ref(/* @__PURE__ */ new Date()); const conflictResolution = ref("primary"); function generateTabId() { return `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } function getCurrentSessionState(currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt) { return { currentUser: currentUser.value, currentTeam: currentTeam.value, currentRole: currentRole.value, isImpersonating: isImpersonating.value, impersonationExpiresAt: impersonationExpiresAt.value?.toISOString() || null, lastUpdated: (/* @__PURE__ */ new Date()).toISOString(), tabId: tabId.value }; } function broadcastSessionState(state, eventType) { try { if (typeof window === "undefined") return; const syncData = { event: eventType, state, timestamp: Date.now(), sourceTab: tabId.value }; localStorage.setItem(STORAGE_KEYS.TEAM_SESSION_STATE, JSON.stringify(syncData)); lastSyncTime.value = /* @__PURE__ */ new Date(); setTimeout(() => { localStorage.removeItem(STORAGE_KEYS.TEAM_SESSION_STATE); }, 100); } catch (error) { console.error("Failed to broadcast session state:", error); } } function setupCrossTabListener(currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt, onStateUpdate) { const handleStorageChange = (event) => { if (event.key !== STORAGE_KEYS.TEAM_SESSION_STATE || !event.newValue) { return; } try { const syncData = JSON.parse(event.newValue); if (syncData.sourceTab === tabId.value) { return; } const eventAge = Date.now() - syncData.timestamp; if (eventAge > 5e3) { return; } switch (syncData.event) { case SESSION_SYNC_EVENTS.TEAM_CHANGED: handleTeamChange(syncData.state, currentUser, currentTeam, currentRole); break; case SESSION_SYNC_EVENTS.ROLE_CHANGED: handleRoleChange(syncData.state, currentRole); break; case SESSION_SYNC_EVENTS.IMPERSONATION_STARTED: handleImpersonationStart(syncData.state, isImpersonating, impersonationExpiresAt); break; case SESSION_SYNC_EVENTS.IMPERSONATION_STOPPED: handleImpersonationStop(isImpersonating, impersonationExpiresAt); break; case SESSION_SYNC_EVENTS.SESSION_RECOVERY: handleSessionRecovery(syncData.state, currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt); break; } onStateUpdate(syncData.state, syncData.event); } catch (error) { console.error("Failed to handle cross-tab session sync:", error); } }; window.addEventListener("storage", handleStorageChange); return () => { window.removeEventListener("storage", handleStorageChange); }; } function handleTeamChange(state, currentUser, currentTeam, currentRole) { if (currentUser.value?.id === state.currentUser?.id) { currentTeam.value = state.currentTeam; currentRole.value = state.currentRole; } } function handleRoleChange(state, currentRole) { currentRole.value = state.currentRole; } function handleImpersonationStart(state, isImpersonating, impersonationExpiresAt) { if (isImpersonating.value && state.isImpersonating) { console.warn("Impersonation conflict detected across tabs"); handleImpersonationConflict(state); return; } isImpersonating.value = state.isImpersonating; impersonationExpiresAt.value = state.impersonationExpiresAt ? new Date(state.impersonationExpiresAt) : null; } function handleImpersonationStop(isImpersonating, impersonationExpiresAt) { isImpersonating.value = false; impersonationExpiresAt.value = null; } function handleSessionRecovery(state, currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt) { currentUser.value = state.currentUser; currentTeam.value = state.currentTeam; currentRole.value = state.currentRole; isImpersonating.value = state.isImpersonating; impersonationExpiresAt.value = state.impersonationExpiresAt ? new Date(state.impersonationExpiresAt) : null; } function handleImpersonationConflict(conflictState) { try { const lockData = { tabId: tabId.value, timestamp: Date.now(), action: "resolving_conflict" }; localStorage.setItem(STORAGE_KEYS.IMPERSONATION_LOCK, JSON.stringify(lockData)); const conflictTime = new Date(conflictState.lastUpdated).getTime(); const currentTime = Date.now(); if (conflictTime > currentTime - 1e3) { conflictResolution.value = "secondary"; console.info("Impersonation conflict resolved: deferring to newer session"); } else { conflictResolution.value = "primary"; console.info("Impersonation conflict resolved: maintaining current session"); } setTimeout(() => { localStorage.removeItem(STORAGE_KEYS.IMPERSONATION_LOCK); }, 1e3); } catch (error) { console.error("Failed to resolve impersonation conflict:", error); conflictResolution.value = "conflict"; } } function registerActiveTab() { try { if (typeof window === "undefined") return; const activeTabs = getActiveTabs(); activeTabs.push({ tabId: tabId.value, timestamp: Date.now(), url: window.location.href }); localStorage.setItem(STORAGE_KEYS.ACTIVE_TABS, JSON.stringify(activeTabs)); } catch (error) { console.error("Failed to register active tab:", error); } } function unregisterActiveTab() { try { if (typeof window === "undefined") return; const activeTabs = getActiveTabs().filter((tab) => tab.tabId !== tabId.value); localStorage.setItem(STORAGE_KEYS.ACTIVE_TABS, JSON.stringify(activeTabs)); } catch (error) { console.error("Failed to unregister active tab:", error); } } function getActiveTabs() { try { if (typeof window === "undefined") return []; const stored = localStorage.getItem(STORAGE_KEYS.ACTIVE_TABS); if (!stored) return []; const tabs = JSON.parse(stored); const now = Date.now(); return tabs.filter((tab) => now - tab.timestamp < 5 * 60 * 1e3); } catch (error) { console.error("Failed to get active tabs:", error); return []; } } function isPrimaryTab() { if (typeof window === "undefined") return true; const activeTabs = getActiveTabs(); if (activeTabs.length === 0) return true; activeTabs.sort((a, b) => a.timestamp - b.timestamp); return activeTabs[0].tabId === tabId.value; } function performSessionHealthCheck(currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt) { const issues = []; if (currentUser.value && !currentTeam.value && !isImpersonating.value) { issues.push("User authenticated but no team selected"); } if (currentTeam.value && !currentRole.value) { issues.push("Team selected but no role assigned"); } if (isImpersonating.value && impersonationExpiresAt.value) { if (/* @__PURE__ */ new Date() >= impersonationExpiresAt.value) { issues.push("Impersonation session expired"); } } if (isImpersonating.value && !impersonationExpiresAt.value) { issues.push("Impersonation state without expiration"); } return { isHealthy: issues.length === 0, issues }; } function initializeSessionSync(currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt, onStateUpdate = () => { }) { if (typeof window === "undefined") { return () => { }; } registerActiveTab(); const cleanup = setupCrossTabListener( currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt, onStateUpdate ); const stopWatchers = [ watch(currentTeam, (newTeam, oldTeam) => { if (newTeam?.id !== oldTeam?.id) { const state = getCurrentSessionState(currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt); broadcastSessionState(state, SESSION_SYNC_EVENTS.TEAM_CHANGED); } }), watch(currentRole, (newRole, oldRole) => { if (newRole !== oldRole) { const state = getCurrentSessionState(currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt); broadcastSessionState(state, SESSION_SYNC_EVENTS.ROLE_CHANGED); } }), watch(isImpersonating, (newValue, oldValue) => { if (newValue !== oldValue) { const state = getCurrentSessionState(currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt); if (newValue) { broadcastSessionState(state, SESSION_SYNC_EVENTS.IMPERSONATION_STARTED); } else { broadcastSessionState(state, SESSION_SYNC_EVENTS.IMPERSONATION_STOPPED); } } }) ]; const healthCheckInterval = setInterval(() => { const health = performSessionHealthCheck(currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt); if (!health.isHealthy) { console.warn("Session health issues detected:", health.issues); } }, 6e4); return () => { unregisterActiveTab(); cleanup(); stopWatchers.forEach((stop) => stop()); clearInterval(healthCheckInterval); }; } function triggerSessionRecovery(currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt) { const state = getCurrentSessionState(currentUser, currentTeam, currentRole, isImpersonating, impersonationExpiresAt); broadcastSessionState(state, SESSION_SYNC_EVENTS.SESSION_RECOVERY); } return { // State tabId: tabId.value, isActiveTab, isPrimaryTab: isPrimaryTab(), lastSyncTime, conflictResolution, // Methods initializeSessionSync, broadcastSessionState, triggerSessionRecovery, performSessionHealthCheck, getActiveTabs, // Constants SESSION_SYNC_EVENTS }; }