bug fix user manual account broken

This commit is contained in:
kai
2026-02-27 17:06:38 -05:00
parent 2737f6e43d
commit 81373c5dd7
16 changed files with 661 additions and 387 deletions

View File

@@ -0,0 +1,8 @@
# Secrets
tests/e2e/credentials.json
# Playwright
test-results/
playwright-report/
blob-report/
playwright/.cache/

View File

@@ -1,7 +1,7 @@
import { injectStyles } from './modules/styles.js';
import { renderLayout, updateNavState } from './modules/ui.js?v=3';
import { getSession } from './modules/state.js?v=2';
import { openModal, closeModal, handleToggle, handleAuth, handleLogout, setMode, verifySession, signInWithSocial, supabase } from './modules/auth.js?v=3';
import { renderLayout, updateNavState } from './modules/ui.js';
import { getSession } from './modules/state.js';
import { openModal, closeModal, handleToggle, handleAuth, handleLogout, setMode, verifySession, signInWithSocial, supabase } from './modules/auth.js';
import { openProfileModal, closeProfileModal, handleProfileUpdate, geocodeAndSetCoordinates, checkAndAutoUpdateLocation, setupProfileDeleteEvents } from './modules/profile.js';
import { setupCameraEvents } from './modules/camera.js';
import { getBasePath } from './modules/config.js';
@@ -35,38 +35,24 @@ import { getBasePath } from './modules/config.js';
// Initialize profile events
setupProfileDeleteEvents();
// 2.6 Check for Supabase Session & Verify
const checkProfile = async (session) => {
if (!session || window.location.pathname.includes('profile_setup')) return;
const { data: profile } = await supabase
.from('profiles')
.select('display_name, institution, location')
.eq('id', session.user.id)
.single();
const hasName = profile && profile.display_name;
const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
const hasLoc = profile && profile.location;
if (!hasName || !hasInst || !hasLoc) {
window.location.href = getBasePath() + '/profile_setup.html';
}
};
supabase.auth.getSession().then(({ data: { session } }) => {
if (session) {
localStorage.setItem("tinytorch_token", session.access_token);
if (session.refresh_token) localStorage.setItem("tinytorch_refresh_token", session.refresh_token);
if (session.user) localStorage.setItem("tinytorch_user", JSON.stringify(session.user));
// Clean URL hash if present (Supabase puts tokens there)
if (window.location.hash && window.location.hash.includes('access_token')) {
window.history.replaceState({}, document.title, window.location.pathname + window.location.search);
// Clean URL of tokens/code (Supabase puts tokens in hash or code in query)
if ((window.location.hash && window.location.hash.includes('access_token')) ||
(window.location.search && window.location.search.includes('code='))) {
// Remove sensitive parameters while preserving non-sensitive ones
const url = new URL(window.location);
url.hash = '';
url.searchParams.delete('code');
url.searchParams.delete('type');
window.history.replaceState({}, document.title, url.toString());
}
updateNavState();
checkProfile(session);
}
// 3. Verify Session (Async)
verifySession();
@@ -82,7 +68,6 @@ import { getBasePath } from './modules/config.js';
if (session.refresh_token) localStorage.setItem("tinytorch_refresh_token", session.refresh_token);
if (session.user) localStorage.setItem("tinytorch_user", JSON.stringify(session.user));
updateNavState();
checkProfile(session);
} else if (event === 'SIGNED_OUT') {
localStorage.removeItem("tinytorch_token");
updateNavState();
@@ -171,10 +156,13 @@ import { getBasePath } from './modules/config.js';
const action = params.get('action');
if (action === 'login') {
localStorage.removeItem("tinytorch_token");
localStorage.removeItem("tinytorch_refresh_token");
localStorage.removeItem("tinytorch_user");
updateNavState();
const { isLoggedIn } = getSession();
if (!isLoggedIn) {
localStorage.removeItem("tinytorch_token");
localStorage.removeItem("tinytorch_refresh_token");
localStorage.removeItem("tinytorch_user");
updateNavState();
}
openModal('login');
} else if (action === 'profile') {
const { isLoggedIn } = getSession();

View File

@@ -289,15 +289,18 @@
<div class="legend-row">
<span class="legend-dot"></span>
<span id="member-count">0 Members</span>
<span id="member-count">0 Members on Map</span>
</div>
<div class="legend-row">
<span class="legend-dot" style="background-color: #2ecc71; border-color: #27ae60;"></span>
<span id="no-location-count">0 without Location (Lost at Sea)</span>
</div>
<div class="legend-row">
<span class="legend-dot-institution"></span>
<span id="institution-count">0 Institutions</span>
</div>
<div class="legend-row">
<span class="legend-dot" style="background-color: #2ecc71; border-color: #27ae60;"></span>
<span>Members with Unknown Location</span>
<div class="legend-row" style="margin-top: 5px; border-top: 1px dashed #eee; padding-top: 8px; font-style: italic; color: #888;">
Note: Members without a location set in their profile are placed at the Sea Station.
</div>
</div>
@@ -307,7 +310,6 @@
<svg id="globe-svg"></svg>
<script type="module">
import { initCloud, updateCloudUsers, animateCloud } from './modules/cloud.js';
import './app.js'; // Keep existing app logic
const width = window.innerWidth;
@@ -317,7 +319,8 @@
const config = {
speed: 0.3,
verticalTilt: -20,
scale: Math.min(width, height) / 2.5
scale: Math.min(width, height) / 2.5,
lostStationCoords: [-135, -35] // Point in the Pacific
};
// Major cities for rough location mapping
@@ -345,7 +348,9 @@
{lat: 22.5726, lng: 88.3639, name: "Kolkata"}
];
let totalMemberCount = 0;
let locations = [];
let lostUsers = [];
let institutions = [];
const API_URL = "https://zrvmjrxhokwwmjacyhpq.supabase.co/functions/v1/search-profiles";
@@ -372,11 +377,9 @@
const graticuleGroup = svg.append('g');
const mapGroup = svg.append('g');
const institutionGroup = svg.append('g');
const lostStationGroup = svg.append('g');
const markerGroup = svg.append('g');
// Initialize Cloud Module
initCloud(svg, width, height, tooltip);
// 1. Globe Background
globeGroup.append("path")
.datum({type: "Sphere"})
@@ -393,9 +396,9 @@
// 3. Load World Data
d3.json('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
.then(worldData => {
mapGroup.selectAll(".country")
.data(topojson.feature(worldData, worldData.objects.countries).features)
.enter().append("path")
// Optimized: Render countries as a single path
mapGroup.append("path")
.datum(topojson.feature(worldData, worldData.objects.countries))
.attr("class", "country")
.attr("d", path);
@@ -403,119 +406,77 @@
fetchProfiles();
});
// ... (Geocoding helpers omitted for brevity, logic preserved) ...
// Re-implementing simplified helpers for completeness
const geocodeCache = {};
let lastGeocodingTime = 0;
const GEOCODING_DELAY = 1100;
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
async function getCoords(locationStr) {
if (!locationStr || locationStr === "unknown") return getRoughCoords("unknown");
if (geocodeCache[locationStr]) return geocodeCache[locationStr];
try {
const now = Date.now();
if (now - lastGeocodingTime < GEOCODING_DELAY) await sleep(GEOCODING_DELAY - (now - lastGeocodingTime));
lastGeocodingTime = Date.now();
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(locationStr)}&format=json&limit=1`;
const response = await fetch(url, { headers: { 'User-Agent': 'TinyTorch Globe' } });
const data = await response.json();
if (data && data.length > 0) {
const coords = { latitude: parseFloat(data[0].lat), longitude: parseFloat(data[0].lon) };
geocodeCache[locationStr] = coords;
return coords;
}
} catch (e) {}
return getRoughCoords(locationStr);
}
function getRoughCoords(str) {
if (!str) str = "unknown";
let hash = 0;
for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); }
const index = Math.abs(hash) % CITIES.length;
const city = CITIES[index];
return {
latitude: city.lat + (Math.random() - 0.5) * 5,
longitude: city.lng + (Math.random() - 0.5) * 5
};
}
// API Fetch Logic
async function fetchProfiles(query = "") {
try {
const response = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: query, page: 0, limit: 20 })
body: JSON.stringify({ query: query, page: 0, limit: 1000 })
});
if (!response.ok) throw new Error("API Error");
const data = await response.json();
if (Array.isArray(data) && data.length > 0) {
if (query) locations = [];
totalMemberCount = data.length;
let mapUsers = [];
let tempLostUsers = [];
locations = data.map(p => {
const roughCoords = (p.latitude && p.longitude)
? { latitude: p.latitude, longitude: p.longitude }
: getRoughCoords(p.location || p.institution || p.username);
data.forEach(p => {
const lat = parseFloat(p.latitude);
const lng = parseFloat(p.longitude);
const isLostLabel = p.location === "Lost at Sea 🌊";
const hasNoLocation = !p.location || p.location === "unknown";
const hasNoCoords = isNaN(lat) || isNaN(lng) || (Math.abs(lat) < 0.001 && Math.abs(lng) < 0.001);
const displayName = (p.display_name && p.display_name.trim()) ? p.display_name.trim() : (p.username ? p.username.split('@')[0].trim() : 'Anonymous');
return {
latitude: roughCoords.latitude,
longitude: roughCoords.longitude,
const userObj = {
user: p.username,
displayName: displayName,
completed: "Member",
institution: Array.isArray(p.institution) ? p.institution.join(", ") : (p.institution || "Independent"),
location: p.location || null
};
if (isLostLabel || (hasNoLocation && hasNoCoords)) {
tempLostUsers.push(userObj);
} else if (!hasNoCoords) {
mapUsers.push({
...userObj,
latitude: lat + (Math.random() - 0.5) * 0.2,
longitude: lng + (Math.random() - 0.5) * 0.2
});
} else {
tempLostUsers.push(userObj);
}
});
locations = mapUsers;
lostUsers = tempLostUsers;
renderMarkers();
renderInstitutions();
data.forEach(async (p, index) => {
const locationString = p.location || p.institution;
if (locationString && !(p.latitude && p.longitude)) {
const coords = await getCoords(locationString);
if (locations[index]) {
locations[index].latitude = coords.latitude;
locations[index].longitude = coords.longitude;
updateMarkerPositions();
updateInstitutionPositions();
}
}
});
renderLostStation();
} else {
if (query) locations = [];
if (query) { locations = []; lostUsers = []; totalMemberCount = 0; }
}
} catch (e) {
locations = [];
lostUsers = [];
totalMemberCount = 0;
}
renderMarkers();
renderInstitutions();
renderLostStation();
if (query) updateSearchDropdown(query);
}
// Render Markers
function renderMarkers() {
// Split Users
const globeUsers = locations.filter(d => d.location !== "Lost at Sea 🌊");
const cloudUsers = locations.filter(d => d.location === "Lost at Sea 🌊");
// Update Cloud
updateCloudUsers(cloudUsers);
// Render Globe Markers
const markers = markerGroup.selectAll('circle.marker')
.data(globeUsers, d => d.user);
.data(locations, d => d.user);
markers.exit().remove();
@@ -533,12 +494,41 @@
d3.select(this).attr("r", 4);
tooltip.style("opacity", 0);
})
.style('opacity', 0)
.transition().duration(300).style('opacity', 1);
document.getElementById('member-count').textContent = `${locations.length + 10} Members`;
document.getElementById('member-count').textContent = `${locations.length} Members on Map`;
document.getElementById('no-location-count').textContent = `${lostUsers.length} without Location (Lost at Sea)`;
updateMarkerPositions();
}
function renderLostStation() {
const station = lostStationGroup.selectAll('g.lost-station')
.data(lostUsers.length > 0 ? [{count: lostUsers.length}] : []);
station.exit().remove();
const enter = station.enter()
.append('g')
.attr('class', 'lost-station')
.style('cursor', 'pointer')
.on("mouseover", function(event, d) {
isHovering = true;
tooltip.html(`<h3>🌊 Sea Station</h3><div class="info-row"><span class="highlight">${d.count}</span> Members drifting here...</div><div class="info-row" style="font-size:10px; color:#888;">(Update location in profile to be found)</div>`);
tooltip.style("left", (event.pageX + 15) + "px").style("top", (event.pageY - 15) + "px").style("opacity", 1);
})
.on("mouseout", function() {
isHovering = false;
tooltip.style("opacity", 0);
});
// Buoy/Station Visual
enter.append('circle').attr('r', 12).attr('fill', 'rgba(46, 204, 113, 0.2)').attr('stroke', '#2ecc71').attr('stroke-width', 1).attr('stroke-dasharray', '2,2');
enter.append('circle').attr('r', 4).attr('fill', '#2ecc71').attr('stroke', '#fff').attr('stroke-width', 1);
updateLostStationPosition();
}
function showTooltip(event, d) {
tooltip.html(`
<h3>${d.displayName}</h3>
@@ -601,9 +591,12 @@
function updateMarkerPositions() {
const markers = markerGroup.selectAll('circle.marker');
if (markers.empty()) return;
const invCenter = projection.invert(center);
markers.each(function(d) {
const coordinate = [d.longitude, d.latitude];
const gdistance = d3.geoDistance(coordinate, projection.invert(center));
const gdistance = d3.geoDistance(coordinate, invCenter);
if (gdistance < 1.57) {
const pos = projection(coordinate);
d3.select(this).attr('cx', pos[0]).attr('cy', pos[1]).style('display', 'block');
@@ -613,12 +606,31 @@
});
}
function updateLostStationPosition() {
const station = lostStationGroup.selectAll('g.lost-station');
if (station.empty()) return;
const invCenter = projection.invert(center);
const coordinate = config.lostStationCoords;
const gdistance = d3.geoDistance(coordinate, invCenter);
if (gdistance < 1.57) {
const pos = projection(coordinate);
station.attr('transform', `translate(${pos[0]},${pos[1]})`).style('display', 'block');
} else {
station.style('display', 'none');
}
}
function updateInstitutionPositions() {
const instMarkers = institutionGroup.selectAll('g.institution-marker');
if (instMarkers.empty()) return;
const invCenter = projection.invert(center);
instMarkers.each(function(d) {
const coordinate = [d.longitude, d.latitude];
const gdistance = d3.geoDistance(coordinate, projection.invert(center));
const gdistance = d3.geoDistance(coordinate, invCenter);
if (gdistance < 1.57) {
const pos = projection(coordinate);
d3.select(this).attr('transform', `translate(${pos[0]},${pos[1]})`).style('display', 'block');
@@ -631,7 +643,6 @@
// Animation
function startAnimation() {
d3.timer(function () {
animateCloud(); // Always animate cloud
if (!isDragging && !isPaused && !isHovering) {
currentRotation[0] += config.speed;
projection.rotate(currentRotation);
@@ -641,14 +652,14 @@
}
function redraw() {
graticuleGroup.selectAll("path").attr("d", path);
mapGroup.selectAll("path").attr("d", path);
globeGroup.selectAll("path").attr("d", path);
graticuleGroup.select("path").attr("d", path);
mapGroup.select("path").attr("d", path);
updateMarkerPositions();
updateInstitutionPositions();
updateLostStationPosition();
}
// Input Handling (Drag, Resize, Search - Simplified for restore)
// Input Handling
const drag = d3.drag()
.on("start", () => { isDragging = true; })
.on("drag", (event) => {
@@ -662,7 +673,6 @@
document.getElementById('rotation-toggle').addEventListener('click', () => { isPaused = !isPaused; });
// ... (Search logic would go here, kept largely as is in previous logic) ...
const searchInput = document.getElementById('user-search');
const searchDropdown = document.getElementById('search-dropdown');
let debounceTimer;
@@ -702,19 +712,10 @@
svg.attr('width', w).attr('height', h);
projection.translate([w/2, h/2]).scale(Math.min(w, h) / 2.5);
center[0] = w/2; center[1] = h/2;
redraw();
});
</script>
// Handle Window Resize
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
svg.attr('width', w).attr('height', h);
projection.translate([w/2, h/2]).scale(Math.min(w, h) / 2.5);
center[0] = w/2;
center[1] = h/2;
// Update static globe background
globeGroup.select("path").attr("d", path);
redraw();
});

View File

@@ -1,11 +1,8 @@
import { NETLIFY_URL, SUPABASE_URL, SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, getBasePath } from './config.js';
import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
import { updateNavState } from './ui.js?v=2';
import { updateNavState } from './ui.js';
import { closeProfileModal, openProfileModal } from './profile.js';
import { getSession, forceLogin, clearSession } from './state.js?v=2';
import { getSession, forceLogin, clearSession, supabase } from './state.js';
// Initialize Supabase Client
const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY);
export { supabase };
export async function signInWithSocial(provider) {
@@ -267,20 +264,51 @@ export async function handleAuth(e) {
authSubmit.innerHTML = '<div class="spinner"></div>';
try {
let endpoint, body;
if (currentMode === 'forgot') {
endpoint = '/api/auth/reset-password';
body = { email };
} else {
endpoint = currentMode === 'login' ? '/api/auth/login' : '/api/auth/signup';
body = {
// Direct Supabase flow for Signup
if (currentMode === 'signup') {
const redirectUrl = window.location.origin + basePath + '/index.html?action=login&confirmed_email=true';
console.log("Requesting signup with redirect to:", redirectUrl);
const { error } = await supabase.auth.signUp({
email,
password,
redirect_to: window.location.origin + basePath + '/index.html?action=login&confirmed_email=true'
};
options: {
emailRedirectTo: redirectUrl
}
});
if (error) throw error;
closeModal();
showMessageModal(
'Check your Email',
'If you don\'t already have an account, we have sent you an email. Please check your inbox to confirm your signup.'
);
return;
}
// Direct Supabase flow for Password Reset
if (currentMode === 'forgot') {
const redirectUrl = window.location.origin + basePath + '/index.html?action=reset-password';
console.log("Requesting password reset with redirect to:", redirectUrl);
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: redirectUrl
});
if (error) throw error;
showMessageModal('Reset Link Sent', 'If an account exists, a reset link has been sent.');
setMode('login');
return;
}
// Keep existing API flow for Login
const endpoint = '/api/auth/login';
const body = {
email,
password,
redirect_to: window.location.origin + basePath + '/index.html?action=login&confirmed_email=true'
};
const url = `${NETLIFY_URL}${endpoint}`;
const response = await fetch(url, {
@@ -294,72 +322,60 @@ export async function handleAuth(e) {
const data = await response.json();
if (currentMode === 'forgot') {
if (response.ok) {
showMessageModal('Reset Link Sent', data.message || 'If an account exists, a reset link has been sent.');
setMode('login');
} else {
throw new Error(data.error || 'Failed to send reset link');
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
if (data.access_token) {
localStorage.setItem("tinytorch_token", data.access_token);
if (data.refresh_token) localStorage.setItem("tinytorch_refresh_token", data.refresh_token);
localStorage.setItem("tinytorch_user", JSON.stringify(data.user));
// Sync Supabase Client so it doesn't trigger SIGNED_OUT
if (data.refresh_token) {
const { error: sessionError } = await supabase.auth.setSession({
access_token: data.access_token,
refresh_token: data.refresh_token
});
if (sessionError) {
console.error("Supabase setSession error during login:", sessionError);
}
} else {
if (!response.ok) {
throw new Error(data.error || (currentMode === 'login' ? 'Login failed' : 'Signup failed'));
}
if (currentMode === 'login') {
if (data.access_token) {
localStorage.setItem("tinytorch_token", data.access_token);
if (data.refresh_token) localStorage.setItem("tinytorch_refresh_token", data.refresh_token);
localStorage.setItem("tinytorch_user", JSON.stringify(data.user));
updateNavState();
// Sync Supabase Client so it doesn't trigger SIGNED_OUT
if (data.refresh_token) {
const { error: sessionError } = await supabase.auth.setSession({
access_token: data.access_token,
refresh_token: data.refresh_token
});
if (sessionError) {
console.error("Supabase setSession error during login:", sessionError);
}
}
// Check Profile Completeness immediately
const { data: profile } = await supabase
.from('profiles')
.select('display_name, institution, location')
.eq('id', data.user.id)
.single();
updateNavState();
const hasName = profile && profile.display_name;
const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
const hasLoc = profile && profile.location;
// Check Profile Completeness immediately
const { data: profile } = await supabase
.from('profiles')
.select('display_name, institution, location')
.eq('id', data.user.id)
.single();
const params = new URLSearchParams(window.location.search);
const nextParam = params.get('next');
closeModal(); // Always close modal on success
const hasName = profile && profile.display_name;
const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
const hasLoc = profile && profile.location;
if (nextParam) {
// Ensure nextParam is a clean path if it was encoded
const cleanNext = decodeURIComponent(nextParam).split('?')[0];
window.location.href = cleanNext;
return;
}
if (!hasName || !hasInst || !hasLoc) {
window.location.href = basePath + '/profile_setup.html';
return;
}
if (!hasName || !hasInst || !hasLoc) {
window.location.href = basePath + '/profile_setup.html';
return;
}
const params = new URLSearchParams(window.location.search);
if (params.get('action') === 'profile') {
closeModal();
openProfileModal();
} else {
window.location.href = basePath + '/dashboard.html';
}
}
if (params.get('action') === 'profile') {
openProfileModal();
} else {
// Signup Success - Show Message Modal
// We close the auth modal first so it doesn't overlap
closeModal();
showMessageModal(
'Check your Email',
'If you don\'t already have an account, we have sent you an email. Please check your inbox to confirm your signup.',
() => {
window.location.href = basePath + '/dashboard.html';
}
);
window.location.href = basePath + '/dashboard.html';
}
}

View File

@@ -1,8 +1,17 @@
// --- CANDLE ANIMATION MODULE ---
const activeLoops = new Set();
export function initCandle(canvasId) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
// Prevent multiple loops on the same canvas
if (activeLoops.has(canvasId)) {
console.log(`Candle loop already running for ${canvasId}, skipping init.`);
return;
}
activeLoops.add(canvasId);
const ctx = canvas.getContext("2d");
// Coordinate system setup:

View File

@@ -1,81 +1,92 @@
import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
import { SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, SUPABASE_URL, getBasePath } from './config.js';
import { clearSession, supabase } from './state.js';
const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY);
const LOGIN_PAGE = getBasePath() + '/index.html';
const SETUP_PAGE = getBasePath() + '/profile_setup.html';
const DASHBOARD_PAGE = getBasePath() + '/dashboard.html';
(async function guard() {
// 0. HYDRATE SESSION (Fix for Direct Email Login)
const path = window.location.pathname;
// Enhanced path checking for landing page
const isOnIndex = path.endsWith('/') || path.endsWith('index.html') || path === getBasePath() || path === getBasePath() + '/';
const isOnSetupPage = path.includes('profile_setup.html');
const isPublicPage = isOnIndex || path.includes('login') || path.includes('about') || path.includes('contact');
const isProtected = !isPublicPage;
// 0. Get Session
const storedToken = localStorage.getItem("tinytorch_token");
const storedRefresh = localStorage.getItem("tinytorch_refresh_token");
if (storedToken && storedRefresh) {
await supabase.auth.setSession({
access_token: storedToken,
refresh_token: storedRefresh
});
try {
await supabase.auth.setSession({
access_token: storedToken,
refresh_token: storedRefresh
});
} catch (e) {
console.warn("Guard: setSession failed", e);
}
}
// 1. Check Session (Supabase Client)
const { data: { session } } = await supabase.auth.getSession();
let profile = null;
if (session) {
// 2a. Fetch Profile via Client
const { data } = await supabase
.from('profiles')
.select('display_name, institution, location')
.eq('id', session.user.id)
.single();
profile = data;
} else {
// 1b. Fallback: Check Token Manually
if (!storedToken) {
// No session, no token -> Redirect
if (!window.location.pathname.includes('index') && !window.location.pathname.includes('login') && !window.location.pathname.includes('about')) {
window.location.href = LOGIN_PAGE + '?action=login&next=' + encodeURIComponent(window.location.pathname);
}
return;
}
// Have token, verify via API
try {
const res = await fetch(`${SUPABASE_URL}/get-profile-details`, {
headers: { 'Authorization': `Bearer ${storedToken}` }
});
if (!res.ok) {
throw new Error("Token invalid");
}
const data = await res.json();
profile = data.profile; // API returns { profile: {...}, completed_modules: [...] }
} catch (e) {
console.warn("Guard: Token validation failed", e);
if (!window.location.pathname.includes('index') && !window.location.pathname.includes('login')) {
window.location.href = LOGIN_PAGE + '?action=login&next=' + encodeURIComponent(window.location.pathname);
}
return;
}
if (!session && isProtected) {
console.log("🚧 No session on protected page. Redirecting to login...");
window.location.href = LOGIN_PAGE + '?action=login&next=' + encodeURIComponent(path);
return;
}
// 3. The Rules
// Must have ALL three: Name, Institution, Location
if (!session) return; // Public page, no session, we are fine.
// 1. Fetch Profile with Timeout
let profile = null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
// Use the API for profile details
const res = await fetch(`${SUPABASE_URL}/get-profile-details`, {
headers: { 'Authorization': `Bearer ${session.access_token}` },
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
profile = data.profile;
} else if (res.status === 401 || res.status === 404) {
// 401: Token expired, 404: User deleted from DB
console.warn(`Guard: Session invalid or account deleted (${res.status}). Purging...`);
await clearSession();
if (isProtected) {
window.location.href = LOGIN_PAGE + '?action=login';
} else {
// If on a public page, just reload to clear UI state
window.location.reload();
}
return;
}
} catch (e) {
console.warn("Guard: Profile fetch failed or timed out", e);
}
// 2. The Rules
const hasName = profile && profile.display_name;
const hasInst = profile && profile.institution && (Array.isArray(profile.institution) ? profile.institution.length > 0 : !!profile.institution);
const hasLoc = profile && profile.location;
const isComplete = hasName && hasInst && hasLoc;
const isOnSetupPage = window.location.pathname.includes('profile_setup');
if (!isComplete && !isOnSetupPage) {
console.log("🚧 Profile incomplete. Redirecting to setup...");
window.location.href = SETUP_PAGE;
}
else if (isComplete && isOnSetupPage) {
// If they are done but try to visit setup, send them to dashboard
window.location.href = getBasePath() + '/dashboard.html';
if (isComplete) {
if (isOnSetupPage) {
console.log("✅ Profile complete. Moving to dashboard...");
window.location.href = DASHBOARD_PAGE;
}
} else {
if (!isOnSetupPage && isProtected) {
console.log("🚧 Profile incomplete. Redirecting to setup...");
window.location.href = SETUP_PAGE;
}
}
})();

View File

@@ -1,5 +1,5 @@
import { SUPABASE_URL, NETLIFY_URL, getBasePath } from './config.js';
import { forceLogin, getSession } from './state.js?v=2';
import { forceLogin, getSession, clearSession } from './state.js';
import { initCandle } from './candle.js';
export async function geocodeAndSetCoordinates(location) {
@@ -124,9 +124,7 @@ export function setupProfileDeleteEvents() {
}
alert("Your account has been deleted.");
localStorage.removeItem("tinytorch_token");
localStorage.removeItem("tinytorch_refresh_token");
localStorage.removeItem("tinytorch_user");
await clearSession();
window.location.href = getBasePath() + '/index.html';
} catch (error) {
console.error("Delete account error:", error);

View File

@@ -1,4 +1,16 @@
import { getBasePath } from './config.js';
import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
import { SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY } from './config.js';
const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, {
auth: {
flowType: 'pkce', // Prefer PKCE for security, or keep 'implicit' if standard for this app
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true
}
});
export { supabase };
// State Management
export function getSession() {
@@ -12,10 +24,46 @@ export function getSession() {
return { token, email, isLoggedIn: !!token };
}
export function clearSession() {
export async function clearSession() {
console.log("🧹 Aggressively clearing session and cookies...");
try {
await supabase.auth.signOut();
} catch (e) {
console.warn("Supabase signOut error during clearSession:", e);
}
// 1. Explicitly remove our own keys
localStorage.removeItem("tinytorch_token");
localStorage.removeItem("tinytorch_refresh_token");
localStorage.removeItem("tinytorch_user");
sessionStorage.removeItem("tinytorch_location_checked");
// 2. Clear all Supabase and auth-related keys from localStorage
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (
key.includes("supabase") ||
key.includes("auth-token") ||
key.startsWith("sb-")
)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(k => localStorage.removeItem(k));
// 3. Clear all auth-related cookies
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
// Clear for all common paths and subdomains
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=" + window.location.hostname;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
}
console.log("✨ Session cleared.");
}
export function forceLogin() {

View File

@@ -1,5 +1,5 @@
import { getBasePath, NETLIFY_URL } from './config.js';
import { getSession } from './state.js?v=2';
import { getSession } from './state.js';
export function updateNavState() {
const { isLoggedIn, email: userEmail } = getSession();

View File

@@ -0,0 +1,78 @@
{
"name": "tinytorch-community-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tinytorch-community-tests",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.42.0"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "tinytorch-community-tests",
"version": "1.0.0",
"description": "E2E tests for Tiny Torch Community",
"scripts": {
"test": "npx playwright test",
"test:ui": "npx playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.42.0"
}
}

View File

@@ -0,0 +1,21 @@
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:8000/community/',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@@ -307,9 +307,10 @@
</script>
<script type="module">
import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js/+esm';
import { SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY, SUPABASE_URL, getBasePath } from './modules/config.js';
import { initCandle } from './modules/candle.js';
import { supabase } from './modules/state.js';
import './modules/guard.js'; // Guard handles access control and completeness redirects
// Initialize Candle
initCandle('candleCanvas');
@@ -380,65 +381,48 @@
const depthSegments = 100;
const geometry = new THREE.PlaneGeometry(width, depth, widthSegments, depthSegments);
geometry.rotateX(-Math.PI / 2);
const count = geometry.attributes.position.count;
geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(count * 3), 3));
const material = new THREE.MeshBasicMaterial({ vertexColors: true, wireframe: true, transparent: true, opacity: 0.4 });
const terrain = new THREE.Mesh(geometry, material);
const terrainMat = new THREE.ShaderMaterial({
vertexShader: document.getElementById('terrain-vs').textContent,
fragmentShader: document.getElementById('terrain-fs').textContent,
uniforms: {
uTime: { value: 0 },
uNoiseScale: { value: 0.15 },
uHeightScale: { value: 4.0 }
},
wireframe: true,
transparent: true
});
const terrain = new THREE.Mesh(geometry, terrainMat);
scene.add(terrain);
const solidMat = new THREE.MeshBasicMaterial({ color: 0xf5f5f0, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
// Solid base layer
const solidMat = new THREE.ShaderMaterial({
vertexShader: document.getElementById('terrain-vs').textContent,
fragmentShader: document.getElementById('terrain-fs').textContent,
uniforms: {
uTime: { value: 0 },
uNoiseScale: { value: 0.15 },
uHeightScale: { value: 4.0 }
},
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1
});
const terrainSolid = new THREE.Mesh(geometry, solidMat);
scene.add(terrainSolid);
const noiseScale = 0.15;
const heightScale = 4.0;
const positionAttribute = geometry.attributes.position;
const colorAttribute = geometry.attributes.color;
const originalPositions = positionAttribute.array.slice();
const colorWater = new THREE.Color(0x3498db);
const colorSand = new THREE.Color(0xf1c40f);
const colorGrass = new THREE.Color(0x2ecc71);
const colorRock = new THREE.Color(0xe74c3c);
function getColorForHeight(h) {
let finalColor = new THREE.Color();
if (h < -0.2) finalColor.copy(colorWater);
else if (h < 0.1) finalColor.lerpColors(colorWater, colorSand, (h + 0.2) / 0.3);
else if (h < 0.6) finalColor.lerpColors(colorSand, colorGrass, (h - 0.1) / 0.5);
else finalColor.lerpColors(colorGrass, colorRock, Math.min((h - 0.6) / 0.9, 1));
return finalColor;
}
function updateScene() {
skyMesh.material.uniforms.uTime.value += 0.01;
const time = skyMesh.material.uniforms.uTime.value;
const flyOverY = time * 2; // Simpler flyover
for (let i = 0; i < count; i++) {
const ix = i * 3;
const iz = i * 3 + 2;
const x = originalPositions[ix];
const z = originalPositions[iz];
const vZ = z - flyOverY;
const noiseVal = Noise.perlin2(x * noiseScale, vZ * noiseScale);
let height = noiseVal * heightScale;
if (height < -1) height = -1;
positionAttribute.setY(i, height);
const c = getColorForHeight(noiseVal);
c.multiplyScalar(0.9);
colorAttribute.setXYZ(i, c.r, c.g, c.b);
}
positionAttribute.needsUpdate = true;
colorAttribute.needsUpdate = true;
}
function animate() {
requestAnimationFrame(animate);
updateScene();
const time = performance.now() * 0.001;
skyMesh.material.uniforms.uTime.value = time;
terrain.material.uniforms.uTime.value = time;
terrainSolid.material.uniforms.uTime.value = time;
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
@@ -447,17 +431,7 @@
// --- AUTH & PROFILE LOGIC ---
const supabase = createClient(SUPABASE_PROJECT_URL, SUPABASE_ANON_KEY);
// --- 0. HYDRATE SESSION ---
const storedToken = localStorage.getItem("tinytorch_token");
const storedRefresh = localStorage.getItem("tinytorch_refresh_token");
if (storedToken && storedRefresh) {
await supabase.auth.setSession({
access_token: storedToken,
refresh_token: storedRefresh
});
}
// supabase is imported from state.js above
// --- 1. AUTOCOMPLETE LOGIC ---
const locInput = document.getElementById('locationInput');
@@ -491,63 +465,27 @@
}, 300);
});
// --- 2. PRE-FILL LOGIC (WITH FALLBACK) ---
async function loadUser() {
const { data: { user } } = await supabase.auth.getUser();
let userProfile = null;
let userId = null;
// --- 2. PRE-FILL LOGIC ---
async function loadUser(user) {
if (!user) return;
if(!user) {
// Fallback: Check Token Manually if getUser failed (e.g. missing refresh token)
if (storedToken) {
try {
// Fetch details directly to confirm token and get user ID
const res = await fetch(`${SUPABASE_URL}/get-profile-details`, {
headers: { 'Authorization': `Bearer ${storedToken}` }
});
if (res.ok) {
const data = await res.json();
// Construct a minimal user object or just use the profile data
// We need userId to query 'profiles' or we can just use the data returned
userProfile = data.profile;
userId = data.profile.id; // Assuming profile has ID, or we get it from token decoding (complicated without lib)
// Actually, get-profile-details returns the profile! We can use it directly.
} else {
// Token invalid
window.location.href = getBasePath() + '/index.html';
return;
}
} catch(e) {
window.location.href = getBasePath() + '/index.html';
return;
}
} else {
window.location.href = getBasePath() + '/index.html';
return;
}
} else {
userId = user.id;
}
// 1. Try to grab name from Google/GitHub metadata (if available from getUser)
if(user && user.user_metadata) {
// 1. Try to grab name from Google/GitHub metadata
if(user.user_metadata) {
if(user.user_metadata.full_name) {
document.getElementById('fullName').value = user.user_metadata.full_name;
const parts = user.user_metadata.full_name.split(' ');
if(parts.length > 0) document.getElementById('displayName').value = parts[0];
if(parts.length > 0 && !document.getElementById('displayName').value) {
document.getElementById('displayName').value = parts[0];
}
}
if(user.user_metadata.name && !document.getElementById('fullName').value) {
document.getElementById('fullName').value = user.user_metadata.name;
}
}
// 2. Load Profile Data (Either from fallback or DB)
if (userProfile) {
populateForm(userProfile);
} else if (userId) {
const { data: profile } = await supabase.from('profiles').select('*').eq('id', userId).single();
if(profile) populateForm(profile);
}
// 2. Load Profile Data from DB
const { data: profile } = await supabase.from('profiles').select('*').eq('id', user.id).single();
if(profile) populateForm(profile);
}
function populateForm(profile) {
@@ -572,7 +510,17 @@
}
}
loadUser();
// Listen for auth state to trigger load
supabase.auth.onAuthStateChange((event, session) => {
if (session && session.user) {
loadUser(session.user);
}
});
// Initial load check
supabase.auth.getUser().then(({ data: { user } }) => {
if (user) loadUser(user);
});
// --- 3. SUBMIT LOGIC ---
document.getElementById('setupForm').addEventListener('submit', async (e) => {
@@ -582,8 +530,11 @@
btn.disabled = true;
const { data: { session } } = await supabase.auth.getSession();
// Use stored token if session is missing (Fallback)
const tokenToUse = session ? session.access_token : storedToken;
if (!session) {
alert("Session lost. Please log in again.");
window.location.href = getBasePath() + '/index.html?action=login';
return;
}
const instVal = document.getElementById('institution').value.trim();
const institutions = instVal ? [instVal] : ["Independent"];
@@ -595,7 +546,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tokenToUse}`
'Authorization': `Bearer ${session.access_token}`
},
body: JSON.stringify({
display_name: document.getElementById('displayName').value,
@@ -612,6 +563,8 @@
});
if(!res.ok) throw new Error("Save failed");
// Success! Guard will now see profile as complete and allow dashboard access
window.location.href = getBasePath() + '/dashboard.html';
} catch(err) {

View File

@@ -0,0 +1,77 @@
const { test, expect } = require('@playwright/test');
test.describe('Authentication Flow', () => {
test('Landing page should be accessible without login', async ({ page }) => {
await page.goto('index.html');
await expect(page).toHaveTitle(/AI History Landscape|The Tiny Torch/);
// Check if login button is visible
const authBtn = page.locator('#authBtn');
await expect(authBtn).toBeVisible();
});
test('Protected page (dashboard) should redirect to index with login action', async ({ page }) => {
await page.goto('dashboard.html');
// Guard should redirect to index.html?action=login&next=...
await expect(page).toHaveURL(/index\.html\?action=login/);
// Auth modal should be active
const authOverlay = page.locator('#authOverlay');
await expect(authOverlay).toHaveClass(/active/);
});
test('Manual Email Login - UI Interaction', async ({ page }) => {
await page.goto('index.html?action=login');
// Switch to login mode if it defaults to signup
const toggle = page.locator('#authToggle');
const title = page.locator('#authTitle');
// Wait for modal to be ready
await expect(page.locator('#authOverlay')).toHaveClass(/active/);
if ((await title.innerText()) === 'Create Account') {
await toggle.click();
}
await expect(title).toHaveText('Login');
// Fill in credentials (using dummy ones for UI test)
await page.fill('#authEmail', 'test@example.com');
await page.fill('#authPassword', 'password123');
// Click login
const loginBtn = page.locator('#authSubmit');
await expect(loginBtn).toHaveText('Login');
});
test('Logout should clear session and redirect to index', async ({ page }) => {
// Manually set a mock session
await page.goto('index.html');
await page.evaluate(() => {
localStorage.setItem('tinytorch_token', 'mock-token');
localStorage.setItem('tinytorch_user', JSON.stringify({ email: 'test@example.com' }));
});
await page.reload();
// Open profile/logout modal
await page.click('#authBtn');
// Listen for dialog (confirm logout)
page.on('dialog', dialog => dialog.accept());
const logoutBtn = page.locator('#profileLogoutBtn');
await expect(logoutBtn).toBeVisible();
// Wait for navigation after clicking logout
await Promise.all([
page.waitForURL(/index\.html/),
logoutBtn.click()
]);
// Verify session is completely purged
const token = await page.evaluate(() => localStorage.getItem('tinytorch_token'));
expect(token).toBeNull();
});
});

View File

@@ -0,0 +1,6 @@
{
"testUser": {
"email": "user@example.com",
"password": "password123"
}
}

View File

@@ -0,0 +1,48 @@
const { test, expect } = require('@playwright/test');
const { testUser } = require('./credentials.json');
test.describe('User Account Lifecycle', () => {
test('Login and Navigate Profile', async ({ page }) => {
// 1. Navigate to login
await page.goto('index.html?action=login');
// Ensure we are in login mode
const title = page.locator('#authTitle');
if ((await title.innerText()) === 'Create Account') {
await page.click('#authToggle');
}
// 2. Perform Login
await page.fill('#authEmail', testUser.email);
await page.fill('#authPassword', testUser.password);
// We expect a redirect after login (to dashboard or profile_setup)
await Promise.all([
page.waitForURL(/dashboard\.html|profile_setup\.html/),
page.click('#authSubmit')
]);
console.log('✅ Logged in successfully');
// 3. Verify we can open the profile modal
// Note: If redirect went to profile_setup.html, authBtn might already be active
await page.click('#authBtn');
const profileOverlay = page.locator('#profileOverlay');
await expect(profileOverlay).toHaveClass(/active/);
// 4. Check if the display name is loaded correctly
const displayNameInput = page.locator('#profileDisplayName');
await expect(displayNameInput).not.toHaveValue('');
console.log('✅ Profile data loaded correctly');
// 5. Navigate to Dashboard
await page.goto('dashboard.html');
await expect(page).toHaveURL(/dashboard\.html/);
console.log('✅ Navigation verified');
});
});