@knowcode/doc-builder
Version:
Reusable documentation builder for markdown-based sites with Vercel deployment support
355 lines (307 loc) • 13.1 kB
JavaScript
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 `
<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 `
<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;