Files
code-crispies/src/auth.js
Michael Czechowski cf0d2cba51 feat: add lesson difficulty indicators and improve mobile sidebar
- Add computeLessonDifficulty function to determine lesson difficulty
  based on selector complexity (easy/medium/hard)
- Display difficulty badge with bar indicator in lesson title row
- Add mobile navigation links (CSS, HTML, Tailwind) to sidebar
- Add mobile auth trigger button in sidebar
- Redesign settings section with card layout and native toggles
- Add difficulty translations for all 6 languages
- Fix module pill overflow on narrow screens
2026-01-16 21:47:47 +01:00

527 lines
16 KiB
JavaScript

import { t, applyTranslations } from "./i18n.js";
// Analytics tracking helper
function track(eventName, eventData = {}) {
if (typeof umami !== "undefined" && umami.track) {
umami.track(eventName, eventData);
}
}
let currentUser = null;
let oauthHandled = false;
let lessonEngineRef = null;
let authModule = null;
let progressModule = null;
let supabaseAvailable = false;
/**
* Check for OAuth callback tokens in URL hash BEFORE router runs.
* Call this before initializing the router.
* @returns {Promise<boolean>} true if OAuth callback was detected and handled
*/
export async function handleOAuthCallback() {
const hash = window.location.hash;
// Check if hash contains OAuth tokens (access_token, error, etc.)
if (!hash.includes("access_token") && !hash.includes("error_description") && !hash.includes("refresh_token")) {
return false;
}
try {
const supabaseModule = await import("./supabase.js");
if (!supabaseModule.isConfigured) {
return false;
}
// Parse tokens from hash
const params = new URLSearchParams(hash.substring(1));
const accessToken = params.get("access_token");
const refreshToken = params.get("refresh_token");
if (accessToken && refreshToken) {
// Explicitly set the session with tokens from URL
const { data, error } = await supabaseModule.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken
});
if (!error && data?.session) {
oauthHandled = true;
const provider = data.session.user?.app_metadata?.provider || "oauth";
track("auth_login", { method: provider });
}
}
// Restore the original route (saved before OAuth redirect)
const savedRoute = localStorage.getItem("codeCrispies.oauthReturnRoute");
if (savedRoute) {
localStorage.removeItem("codeCrispies.oauthReturnRoute");
window.history.replaceState(null, "", window.location.pathname + savedRoute);
} else {
// No saved route - just clear the hash
window.history.replaceState(null, "", window.location.pathname);
}
return true;
} catch (e) {
console.error("[Auth] OAuth callback handling failed:", e.message);
return false;
}
}
/**
* Initialize the auth system
* @param {Object} engine - The LessonEngine instance
*/
export async function initAuth(engine) {
lessonEngineRef = engine;
// Try to load Supabase - if not configured, auth is disabled
try {
const supabaseModule = await import("./supabase.js");
// Check if Supabase is configured via environment variables
if (!supabaseModule.isConfigured) {
console.log("Supabase not configured - auth disabled");
hideAuthUI();
return;
}
authModule = supabaseModule.auth;
progressModule = supabaseModule.progressDB;
supabaseAvailable = true;
} catch (e) {
console.log("Supabase not available - auth disabled:", e.message);
hideAuthUI();
return;
}
// Listen for auth changes
authModule.onAuthStateChange((event, session) => {
if (event === "SIGNED_IN" && session?.user) {
handleLogin(session.user);
} else if (event === "SIGNED_OUT") {
handleLogout();
}
});
// Check initial session
try {
const { data } = await authModule.getSession();
if (data?.session?.user) handleLogin(data.session.user);
} catch (e) {
// Session check failed - continue without auth
}
// Attach form handlers
setupAuthForms();
}
function hideAuthUI() {
document.getElementById("auth-trigger-header")?.classList.add("hidden");
document.querySelector(".sidebar-auth-box")?.classList.add("hidden");
}
async function handleLogin(user) {
currentUser = user;
updateAuthUI(user);
if (!progressModule) return;
// Load cloud progress
const { data } = await progressModule.load(user.id);
if (data) {
// Merge with localStorage (cloud wins for conflicts)
mergeProgress(data);
} else {
// First login: upload localStorage to cloud
await syncToCloud();
}
}
function handleLogout() {
currentUser = null;
updateAuthUI(null);
// Keep localStorage progress, just disconnect from cloud
}
function updateAuthUI(user) {
// Header elements
const authTriggerHeader = document.getElementById("auth-trigger-header");
const userEmailHeader = document.getElementById("user-email-header");
// Sidebar elements
const authTriggerSidebar = document.getElementById("auth-trigger-sidebar");
const authTriggerMobile = document.getElementById("auth-trigger-mobile");
const userMenuSidebar = document.getElementById("user-menu-sidebar");
const userEmailSidebar = document.getElementById("user-email-sidebar");
const sidebarHint = document.querySelector(".sidebar-auth-hint");
if (user) {
authTriggerHeader?.classList.add("hidden");
userEmailHeader?.classList.remove("hidden");
authTriggerSidebar?.classList.add("hidden");
authTriggerMobile?.classList.add("hidden");
userMenuSidebar?.classList.remove("hidden");
sidebarHint?.classList.add("hidden");
if (userEmailHeader) userEmailHeader.textContent = user.email;
if (userEmailSidebar) userEmailSidebar.textContent = user.email;
} else {
authTriggerHeader?.classList.remove("hidden");
userEmailHeader?.classList.add("hidden");
authTriggerSidebar?.classList.remove("hidden");
authTriggerMobile?.classList.remove("hidden");
userMenuSidebar?.classList.add("hidden");
sidebarHint?.classList.remove("hidden");
}
}
export async function syncToCloud() {
if (!currentUser || !progressModule) return;
const progress = JSON.parse(
localStorage.getItem("codeCrispies.progress") || "{}"
);
const userCodeEntries = JSON.parse(
localStorage.getItem("codeCrispies.userCode") || "[]"
);
const userCode = Object.fromEntries(userCodeEntries);
const settings = JSON.parse(
localStorage.getItem("codeCrispies.settings") || "{}"
);
const language = localStorage.getItem("codeCrispies.language") || "en";
await progressModule.save(currentUser.id, progress, userCode, settings, language);
}
function mergeProgress(cloudData) {
// Update localStorage with cloud data
localStorage.setItem(
"codeCrispies.progress",
JSON.stringify(cloudData.progress)
);
localStorage.setItem(
"codeCrispies.userCode",
JSON.stringify(Object.entries(cloudData.user_code))
);
localStorage.setItem(
"codeCrispies.settings",
JSON.stringify(cloudData.settings)
);
localStorage.setItem("codeCrispies.language", cloudData.language);
// Reload engine state
if (lessonEngineRef) {
lessonEngineRef.loadUserProgress();
lessonEngineRef.loadUserCodeFromStorage();
}
}
export function isLoggedIn() {
return supabaseAvailable && currentUser !== null;
}
export function getCurrentUser() {
return currentUser;
}
// Debounce utility
function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
// Export debounced sync for use by LessonEngine
export const debouncedSyncToCloud = debounce(() => syncToCloud(), 2000);
function setupAuthForms() {
const authDialog = document.getElementById("auth-dialog");
const loginForm = document.getElementById("login-form");
const signupForm = document.getElementById("signup-form");
const resetForm = document.getElementById("reset-form");
// Form submissions
loginForm?.addEventListener("submit", handleLoginSubmit);
signupForm?.addEventListener("submit", handleSignupSubmit);
resetForm?.addEventListener("submit", handleResetSubmit);
// Form switchers
document
.getElementById("show-signup")
?.addEventListener("click", () => switchForm("signup"));
document
.getElementById("show-login")
?.addEventListener("click", () => switchForm("login"));
document
.getElementById("show-reset")
?.addEventListener("click", () => switchForm("reset"));
// Dialog triggers (header, sidebar, and mobile)
document
.getElementById("auth-trigger-header")
?.addEventListener("click", () => {
authDialog?.showModal();
});
document
.getElementById("auth-trigger-sidebar")
?.addEventListener("click", () => {
authDialog?.showModal();
});
document
.getElementById("auth-trigger-mobile")
?.addEventListener("click", () => {
authDialog?.showModal();
});
// Logout button (sidebar only)
document
.getElementById("logout-btn-sidebar")
?.addEventListener("click", async () => {
await authModule?.signOut();
track("auth_logout");
});
// Delete account button and dialog
const deleteDialog = document.getElementById("delete-account-dialog");
document
.getElementById("delete-account-btn")
?.addEventListener("click", () => {
deleteDialog?.showModal();
});
document
.getElementById("cancel-delete")
?.addEventListener("click", () => {
deleteDialog?.close();
});
document
.getElementById("delete-dialog-close")
?.addEventListener("click", () => {
deleteDialog?.close();
});
deleteDialog?.addEventListener("click", (e) => {
if (e.target === deleteDialog) deleteDialog.close();
});
document
.getElementById("confirm-delete")
?.addEventListener("click", async () => {
const errorEl = document.getElementById("delete-account-error");
const confirmBtn = document.getElementById("confirm-delete");
confirmBtn.disabled = true;
const { error } = await authModule.deleteAccount();
if (error) {
errorEl.textContent = error.message;
errorEl.classList.remove("hidden");
confirmBtn.disabled = false;
} else {
errorEl.classList.add("hidden");
deleteDialog.close();
track("auth_delete_account");
// Sign out and clear local state
await authModule.signOut();
}
});
// OAuth buttons - save current route before redirect
document.getElementById("google-login")?.addEventListener("click", async () => {
// Save current route to restore after OAuth
const currentHash = window.location.hash;
if (currentHash && !currentHash.includes("access_token")) {
localStorage.setItem("codeCrispies.oauthReturnRoute", currentHash);
}
const { error } = await authModule?.signInWithGoogle() ?? { error: null };
if (error) {
showOAuthError(error.message);
}
});
document.getElementById("github-login")?.addEventListener("click", async () => {
// Save current route to restore after OAuth
const currentHash = window.location.hash;
if (currentHash && !currentHash.includes("access_token")) {
localStorage.setItem("codeCrispies.oauthReturnRoute", currentHash);
}
const { error } = await authModule?.signInWithGitHub() ?? { error: null };
if (error) {
showOAuthError(error.message);
}
});
// Close dialog on backdrop click
authDialog?.addEventListener("click", (e) => {
if (e.target === authDialog) authDialog.close();
});
// Close button
authDialog?.querySelector(".close-dialog")?.addEventListener("click", () => {
authDialog.close();
});
}
async function handleLoginSubmit(e) {
e.preventDefault();
const email = document.getElementById("login-email").value;
const password = document.getElementById("login-password").value;
const errorEl = document.getElementById("login-error");
const submitBtn = e.target.querySelector('button[type="submit"]');
// Disable button while processing
submitBtn.disabled = true;
const { error } = await authModule.signIn(email, password);
submitBtn.disabled = false;
if (error) {
errorEl.textContent = error.message;
errorEl.classList.remove("hidden");
} else {
errorEl.classList.add("hidden");
document.getElementById("auth-dialog").close();
track("auth_login", { method: "email" });
}
}
async function handleSignupSubmit(e) {
e.preventDefault();
const email = document.getElementById("signup-email").value;
const password = document.getElementById("signup-password").value;
const confirm = document.getElementById("signup-confirm").value;
const errorEl = document.getElementById("signup-error");
const submitBtn = e.target.querySelector('button[type="submit"]');
if (password !== confirm) {
errorEl.textContent = t("authPasswordMismatch") || "Passwords do not match";
errorEl.classList.remove("hidden");
return;
}
// Disable button while processing
submitBtn.disabled = true;
const { error } = await authModule.signUp(email, password);
submitBtn.disabled = false;
if (error) {
errorEl.textContent = error.message;
errorEl.classList.remove("hidden");
document.getElementById("signup-success")?.classList.add("hidden");
} else {
errorEl.classList.add("hidden");
// Show success message
const successEl = document.getElementById("signup-success");
successEl?.classList.remove("hidden");
// Hide the form fields and button
e.target.querySelectorAll(".form-field, button[type='submit']").forEach(el => {
el.classList.add("hidden");
});
track("auth_signup", { method: "email" });
}
}
async function handleResetSubmit(e) {
e.preventDefault();
const email = document.getElementById("reset-email").value;
const errorEl = document.getElementById("reset-error");
const successEl = document.getElementById("reset-success");
const submitBtn = e.target.querySelector('button[type="submit"]');
// Disable button while processing
submitBtn.disabled = true;
const { error } = await authModule.resetPassword(email);
submitBtn.disabled = false;
if (error) {
errorEl.textContent = error.message;
errorEl.classList.remove("hidden");
successEl.classList.add("hidden");
} else {
errorEl.classList.add("hidden");
successEl.classList.remove("hidden");
}
}
function showOAuthError(message) {
// Show error in the currently visible form's error element
const loginError = document.getElementById("login-error");
const signupError = document.getElementById("signup-error");
// Use whichever form is visible
const errorEl = !document.getElementById("login-form")?.classList.contains("hidden")
? loginError
: signupError;
if (errorEl) {
errorEl.textContent = message;
errorEl.classList.remove("hidden");
}
}
function switchForm(formName) {
const loginForm = document.getElementById("login-form");
const signupForm = document.getElementById("signup-form");
const resetForm = document.getElementById("reset-form");
const showSignup = document.getElementById("show-signup");
const showLogin = document.getElementById("show-login");
const showReset = document.getElementById("show-reset");
const titleEl = document.getElementById("auth-dialog-title");
const socialSection = document.querySelector(".auth-social");
// Hide all forms
loginForm?.classList.add("hidden");
signupForm?.classList.add("hidden");
resetForm?.classList.add("hidden");
// Show the selected form
if (formName === "login") {
loginForm?.classList.remove("hidden");
showSignup?.classList.remove("hidden");
showLogin?.classList.add("hidden");
showReset?.classList.remove("hidden");
socialSection?.classList.remove("hidden");
if (titleEl) titleEl.setAttribute("data-i18n", "authLogin");
} else if (formName === "signup") {
signupForm?.classList.remove("hidden");
// Reset signup form to initial state (in case it was showing success)
signupForm?.querySelectorAll(".form-field, button[type='submit']").forEach(el => {
el.classList.remove("hidden");
});
signupForm?.reset();
showSignup?.classList.add("hidden");
showLogin?.classList.remove("hidden");
showReset?.classList.add("hidden");
socialSection?.classList.remove("hidden");
if (titleEl) titleEl.setAttribute("data-i18n", "authSignUp");
} else if (formName === "reset") {
resetForm?.classList.remove("hidden");
showSignup?.classList.add("hidden");
showLogin?.classList.remove("hidden");
showReset?.classList.add("hidden");
socialSection?.classList.add("hidden");
if (titleEl) titleEl.setAttribute("data-i18n", "authResetPassword");
}
// Clear error messages
document.getElementById("login-error")?.classList.add("hidden");
document.getElementById("signup-error")?.classList.add("hidden");
document.getElementById("reset-error")?.classList.add("hidden");
document.getElementById("reset-success")?.classList.add("hidden");
// Apply translations to updated elements
applyTranslations();
}