UNPKG

@knowcode/doc-builder

Version:

Reusable documentation builder for markdown-based sites with Vercel deployment support

355 lines (307 loc) 13.1 kB
const { createClient } = require('@supabase/supabase-js'); /** * Supabase Authentication Module for @knowcode/doc-builder * * This module provides secure authentication functionality using Supabase's * built-in auth system. It replaces the insecure basic auth implementation. */ class SupabaseAuth { constructor(config) { if (!config.supabaseUrl || !config.supabaseAnonKey) { throw new Error('Supabase URL and anonymous key are required for authentication'); } this.config = config; this.supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, { auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true } }); } /** * Generate client-side auth script for inclusion in HTML pages */ generateAuthScript(fullConfig) { return ` /** * Supabase Authentication for Documentation Site * Generated by @knowcode/doc-builder */ (function() { 'use strict'; // Skip auth check on login and logout pages const currentPage = window.location.pathname; if (currentPage === '/login.html' || currentPage === '/logout.html' || currentPage.includes('login') || currentPage.includes('logout')) { return; } // Initialize Supabase client const { createClient } = supabase; const supabaseClient = createClient('${this.config.supabaseUrl}', '${this.config.supabaseAnonKey}', { auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true } }); // Check authentication and site access async function checkAuth() { try { // Check authentication mode const isGlobalAuthEnabled = ${fullConfig.features?.authentication === 'supabase'}; const isPrivateDirectoryAuthEnabled = ${fullConfig.features?.privateDirectoryAuth === true}; // Check if current page is in private directory const currentPath = window.location.pathname; const isPrivatePage = currentPath.includes('/private/'); // Get current user session const { data: { user }, error: userError } = await supabaseClient.auth.getUser(); if (userError || !user) { // Redirect if global auth is enabled OR if we're on a private page with private auth enabled if (isGlobalAuthEnabled || (isPrivateDirectoryAuthEnabled && isPrivatePage)) { redirectToLogin(); } else { // Public page (no global auth and either no private auth or not on private page) document.body.classList.add('authenticated'); // Use same class to show body updateAuthButton(false); } return; } // Check if user has access to this site (using domain) const { data: access, error: accessError } = await supabaseClient .from('docbuilder_access') .select('*') .eq('user_id', user.id) .eq('domain', window.location.host) .single(); if (accessError || !access) { if (isGlobalAuthEnabled || (isPrivateDirectoryAuthEnabled && isPrivatePage)) { showAccessDenied(); } else { // Public page, show it but don't grant private navigation access document.body.classList.add('authenticated'); // Show body content updateAuthButton(false); } return; } // User is authenticated and has domain access - grant appropriate access console.log('User authenticated and authorized'); document.body.classList.add('authenticated'); // Grant private navigation access if private directory auth is enabled if (isPrivateDirectoryAuthEnabled || isGlobalAuthEnabled) { document.body.classList.add('has-private-access'); } updateAuthButton(true); } catch (error) { console.error('Auth check failed:', error); const isPrivatePage = window.location.pathname.includes('/private/'); if (isGlobalAuthEnabled || (isPrivateDirectoryAuthEnabled && isPrivatePage)) { redirectToLogin(); } else { // Public page, show it anyway document.body.classList.add('authenticated'); updateAuthButton(false); } } } // Redirect to login page function redirectToLogin() { const currentUrl = window.location.pathname + window.location.search; const loginUrl = '/login.html' + (currentUrl !== '/' ? '?redirect=' + encodeURIComponent(currentUrl) : ''); window.location.href = loginUrl; } // Show access denied message function showAccessDenied() { document.body.classList.add('authenticated'); // Show the body document.body.innerHTML = \` <div style="display: flex; justify-content: center; align-items: center; height: 100vh; font-family: Inter, sans-serif;"> <div style="text-align: center; max-width: 400px;"> <h1 style="color: #ef4444; margin-bottom: 1rem;">Access Denied</h1> <p style="color: #6b7280; margin-bottom: 2rem;">You don't have permission to view this documentation site.</p> <a href="/login.html" style="background: #3b82f6; color: white; padding: 0.75rem 1.5rem; border-radius: 0.5rem; text-decoration: none;">Try Different Account</a> </div> </div> \`; } // Function to update auth button function updateAuthButton(isAuthenticated) { const authBtn = document.querySelector('.auth-btn'); if (authBtn) { const icon = authBtn.querySelector('i'); if (icon) { if (isAuthenticated) { icon.className = 'fas fa-sign-out-alt'; authBtn.title = 'Logout'; authBtn.href = '/logout.html'; } else { icon.className = 'fas fa-sign-in-alt'; authBtn.title = 'Login'; authBtn.href = '/login.html'; } } } } // Add auth button functionality document.addEventListener('DOMContentLoaded', function() { const authBtn = document.querySelector('.auth-btn'); if (authBtn) { authBtn.addEventListener('click', async function(e) { // Check if we're logged in const { data: { user } } = await supabaseClient.auth.getUser(); if (user) { // Logged in - sign out e.preventDefault(); await supabaseClient.auth.signOut(); window.location.href = '/logout.html'; } // If not logged in, normal navigation to login page will occur }); } }); // Run auth check checkAuth(); })(); `; } /** * Generate login page HTML */ generateLoginPage(config) { const siteName = config.siteName || 'Documentation'; return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Login - ${siteName}</title> <link rel="stylesheet" href="css/notion-style.css"> <script src="https://unpkg.com/@supabase/supabase-js@2"></script> </head> <body class="auth-page"> <div class="auth-container"> <div class="auth-box"> <h1>Login to ${siteName}</h1> <form id="login-form"> <div class="form-group"> <label for="email">Email</label> <input type="email" id="email" name="email" required> </div> <div class="form-group"> <label for="password">Password</label> <input type="password" id="password" name="password" required> </div> <button type="submit" class="auth-button">Login</button> </form> <div id="error-message" class="error-message"></div> <div class="auth-links"> <a href="#" id="forgot-password">Forgot Password?</a> </div> </div> </div> <script> // Initialize Supabase const { createClient } = supabase; const supabaseClient = createClient('${this.config.supabaseUrl}', '${this.config.supabaseAnonKey}'); // Handle login form document.getElementById('login-form').addEventListener('submit', async function(e) { e.preventDefault(); const email = document.getElementById('email').value; const password = document.getElementById('password').value; const errorDiv = document.getElementById('error-message'); try { // Sign in with Supabase const { data, error } = await supabaseClient.auth.signInWithPassword({ email: email, password: password }); if (error) throw error; // Check if user has access to this site (using domain) const { data: access, error: accessError } = await supabaseClient .from('docbuilder_access') .select('*') .eq('user_id', data.user.id) .eq('domain', window.location.host) .single(); if (accessError || !access) { await supabaseClient.auth.signOut(); throw new Error('You do not have access to this documentation site'); } // Redirect to requested page const params = new URLSearchParams(window.location.search); const redirect = params.get('redirect') || '/'; window.location.href = redirect; } catch (error) { errorDiv.textContent = error.message; errorDiv.style.display = 'block'; } }); // Handle forgot password document.getElementById('forgot-password').addEventListener('click', async function(e) { e.preventDefault(); const email = document.getElementById('email').value; if (!email) { alert('Please enter your email address first'); return; } try { const { error } = await supabaseClient.auth.resetPasswordForEmail(email, { redirectTo: window.location.origin + '/login.html' }); if (error) throw error; alert('Password reset email sent! Check your inbox.'); } catch (error) { alert('Error sending reset email: ' + error.message); } }); </script> </body> </html>`; } /** * Generate logout page HTML */ generateLogoutPage(config) { const siteName = config.siteName || 'Documentation'; return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Logged Out - ${siteName}</title> <link rel="stylesheet" href="css/notion-style.css"> </head> <body class="auth-page"> <div class="auth-container"> <div class="auth-box"> <h1>You have been logged out</h1> <p>Thank you for using ${siteName}.</p> <a href="login.html" class="auth-button">Login Again</a> </div> </div> </body> </html>`; } /** * Validate Supabase configuration */ static validateConfig(config) { const errors = []; // Only validate if at least one credential is provided // This allows the auth UI to show even without full configuration const hasAnyCredential = config.auth.supabaseUrl || config.auth.supabaseAnonKey; if (hasAnyCredential) { if (!config.auth.supabaseUrl) { errors.push('auth.supabaseUrl is required'); } if (!config.auth.supabaseAnonKey) { errors.push('auth.supabaseAnonKey is required'); } // Validate URL format if (config.auth.supabaseUrl && !config.auth.supabaseUrl.match(/^https:\/\/\w+\.supabase\.co$/)) { errors.push('auth.supabaseUrl must be a valid Supabase URL (https://xxx.supabase.co)'); } } return errors; } } module.exports = SupabaseAuth;