mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-03-11 17:49:25 -05:00
bug fix user manual account broken
This commit is contained in:
8
tinytorch/site/extra/community/.gitignore
vendored
Normal file
8
tinytorch/site/extra/community/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Secrets
|
||||
tests/e2e/credentials.json
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
playwright/.cache/
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
78
tinytorch/site/extra/community/package-lock.json
generated
Normal file
78
tinytorch/site/extra/community/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
tinytorch/site/extra/community/package.json
Normal file
12
tinytorch/site/extra/community/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
tinytorch/site/extra/community/playwright.config.js
Normal file
21
tinytorch/site/extra/community/playwright.config.js
Normal 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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
77
tinytorch/site/extra/community/tests/e2e/auth.spec.js
Normal file
77
tinytorch/site/extra/community/tests/e2e/auth.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"testUser": {
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
}
|
||||
48
tinytorch/site/extra/community/tests/e2e/lifecycle.spec.js
Normal file
48
tinytorch/site/extra/community/tests/e2e/lifecycle.spec.js
Normal 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');
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user