/** * Auth — backend-first, localStorage-fallback account system. * * When the Node backend at /api/* is reachable (production deploy with the * Express server running), Auth talks to it via fetch. In dev or offline, * it transparently falls back to localStorage so the app still works. * * The sync surface (Auth.session(), Auth.current(), Auth.isAdmin()) is * preserved by caching the current user / token in localStorage so other * code doesn't have to await anything. */ import { backendReachable, getToken, setToken, request, localFallbackAllowed } from './Api.js'; const SERVER_UNREACHABLE = "Can't reach the server right now — please check your connection and try again in a moment."; const USERS_KEY = 'echo_users_v1'; const SESSION_KEY = 'echo_session_v1'; const CACHED_ME_KEY = 'echo_me_v1'; const ADMIN_EMAILS = new Set(['admin@echo.app', 'superadmin@echo.app']); // ============================================================ // LocalStorage fallback (used when backend isn't reachable) // ============================================================ function hash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(16); } function readUsers() { try { return JSON.parse(localStorage.getItem(USERS_KEY) || '{}'); } catch { return {}; } } function writeUsers(u) { localStorage.setItem(USERS_KEY, JSON.stringify(u)); } const SEED_ADMIN = { email: 'admin@echo.app', name: 'Administrator', pwd: 'EchoAdm-2n5y59032058' }; (function seedLocalAdmin() { const users = readUsers(); if (users[SEED_ADMIN.email]) return; users[SEED_ADMIN.email] = { email: SEED_ADMIN.email, name: SEED_ADMIN.name, pwd: hash(SEED_ADMIN.pwd), created: Date.now(), stars: 0, mastered: 0, grade: 1, isAdmin: true, }; writeUsers(users); })(); function cachedMe() { try { return JSON.parse(localStorage.getItem(CACHED_ME_KEY) || 'null'); } catch { return null; } } function setCachedMe(u) { if (u) localStorage.setItem(CACHED_ME_KEY, JSON.stringify(u)); else localStorage.removeItem(CACHED_ME_KEY); } // ============================================================ // Public API // ============================================================ export const Auth = { /** Sync: is there a session? (Either backend token or local email.) */ session() { return getToken() || localStorage.getItem(SESSION_KEY); }, /** Sync: returns the cached current user object. */ current() { const cached = cachedMe(); if (cached) return cached; // Local fallback path const e = localStorage.getItem(SESSION_KEY); if (!e) return null; return readUsers()[e] || null; }, /** Sync: is the current user admin? */ isAdmin() { const me = this.current(); return !!(me && me.isAdmin); }, async register(email, pwd, name) { if (await backendReachable()) { const { token, user } = await request('POST', '/api/register', { email, pwd, name }); setToken(token); setCachedMe(user); return user; } // Backend is the source of truth in production. Only fall back to a // device-local account in genuine local/offline dev — otherwise a transient // outage would trap the account on this browser and break other devices. if (!localFallbackAllowed()) throw new Error(SERVER_UNREACHABLE); // Local fallback (dev / offline only) email = String(email).trim().toLowerCase(); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw new Error('Enter a valid email'); if (!pwd || pwd.length < 6) throw new Error('Password must be 6+ characters'); const u = readUsers(); if (u[email]) throw new Error('Email already registered — try logging in'); u[email] = { email, name: name || email.split('@')[0], pwd: hash(pwd), created: Date.now(), stars: 0, mastered: 0, grade: 1, isAdmin: ADMIN_EMAILS.has(email) }; writeUsers(u); localStorage.setItem(SESSION_KEY, email); setCachedMe(u[email]); return u[email]; }, async login(email, pwd) { if (await backendReachable()) { const { token, user } = await request('POST', '/api/login', { email, pwd }); setToken(token); setCachedMe(user); return user; } // See register(): never silently authenticate against a device-local store // in production, or new devices can never see accounts created elsewhere. if (!localFallbackAllowed()) throw new Error(SERVER_UNREACHABLE); email = String(email).trim().toLowerCase(); const u = readUsers(); if (!u[email]) throw new Error('No account with that email'); if (u[email].pwd !== hash(pwd)) throw new Error('Wrong password'); if (ADMIN_EMAILS.has(email) && !u[email].isAdmin) { u[email].isAdmin = true; writeUsers(u); } localStorage.setItem(SESSION_KEY, email); setCachedMe(u[email]); return u[email]; }, async logout() { try { if (await backendReachable()) await request('POST', '/api/logout'); } catch {} setToken(null); setCachedMe(null); localStorage.removeItem(SESSION_KEY); }, /** Pull the latest /me payload and refresh the local cache. No-op if no session. */ async refreshMe() { if (!this.session()) return null; if (await backendReachable()) { try { const me = await request('GET', '/api/me'); setCachedMe(me); return me; } catch { return this.current(); } } return this.current(); }, async saveProgress({ stars, mastered, grade }) { if (await backendReachable()) { try { await request('POST', '/api/me/progress', { stars, mastered, grade }); } catch {} } const me = cachedMe(); if (me) { setCachedMe({ ...me, stars, mastered, grade }); } const e = localStorage.getItem(SESSION_KEY); if (e) { const u = readUsers(); if (u[e]) { u[e] = { ...u[e], stars, mastered, grade }; writeUsers(u); } } }, /** Fetch the per-word progress array for the current user. Returns [] when * not signed in or backend unreachable — caller falls back to local store. */ async fetchWords() { if (!this.session()) return []; if (!(await backendReachable())) return []; try { const { words } = await request('GET', '/api/me/words'); return Array.isArray(words) ? words : []; } catch { return []; } }, /** Upload per-word progress (batched array). No-op when not signed in. */ async saveWords(words) { if (!Array.isArray(words) || !words.length) return; if (!this.session()) return; if (!(await backendReachable())) return; try { await request('POST', '/api/me/words', { words }); } catch {} }, // ----- Sync helpers used by AdminUI local fallback ----- allUsers() { return Object.values(readUsers()); }, update(patch) { const e = localStorage.getItem(SESSION_KEY); if (!e) return; const u = readUsers(); if (!u[e]) return; u[e] = { ...u[e], ...patch }; writeUsers(u); setCachedMe(u[e]); }, resetPwd(email, newPwd) { email = String(email).trim().toLowerCase(); const u = readUsers(); if (!u[email]) throw new Error('No account with that email'); if (!newPwd || newPwd.length < 6) throw new Error('New password must be 6+ chars'); u[email].pwd = hash(newPwd); writeUsers(u); return true; }, // ----- Admin ops — backend-first, local fallback ----- async adminListUsers() { if (await backendReachable()) return request('GET', '/api/admin/users'); return Object.values(readUsers()).map(u => ({ ...u, online: false })); }, async adminDeleteUser(email) { if (await backendReachable()) return request('DELETE', `/api/admin/users/${encodeURIComponent(email)}`); email = String(email).toLowerCase(); const u = readUsers(); if (email === localStorage.getItem(SESSION_KEY)) throw new Error('Cannot delete the currently logged-in admin'); if (!u[email]) throw new Error('No such user'); delete u[email]; writeUsers(u); }, async adminSetAdmin(email, on) { if (await backendReachable()) return request('POST', `/api/admin/users/${encodeURIComponent(email)}/admin`, { isAdmin: !!on }); email = String(email).toLowerCase(); const u = readUsers(); if (!u[email]) throw new Error('No such user'); u[email].isAdmin = !!on; writeUsers(u); }, async adminResetProgress(email) { if (await backendReachable()) return request('POST', `/api/admin/users/${encodeURIComponent(email)}/reset`); email = String(email).toLowerCase(); const u = readUsers(); if (!u[email]) throw new Error('No such user'); u[email].stars = 0; u[email].mastered = 0; u[email].grade = 1; writeUsers(u); }, async adminCreateUser({ email, pwd, name, isAdmin }) { if (await backendReachable()) return request('POST', '/api/admin/users', { email, pwd, name, isAdmin }); email = String(email).trim().toLowerCase(); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw new Error('Enter a valid email'); if (!pwd || pwd.length < 6) throw new Error('Password must be 6+ characters'); const u = readUsers(); if (u[email]) throw new Error('Email already registered'); u[email] = { email, name: name || email.split('@')[0], pwd: hash(pwd), created: Date.now(), stars: 0, mastered: 0, grade: 1, isAdmin: !!isAdmin }; writeUsers(u); }, async adminExport() { if (await backendReachable()) return JSON.stringify(await request('GET', '/api/admin/export'), null, 2); return JSON.stringify({ users: readUsers(), exportedAt: Date.now() }, null, 2); }, async adminImport(json) { let data; try { data = JSON.parse(json); } catch { throw new Error('Invalid JSON'); } if (await backendReachable()) { // Backend export shape: { users: [...] }; local-fallback shape: { users: {email: u} } const arr = Array.isArray(data.users) ? data.users : data.users && typeof data.users === 'object' ? Object.values(data.users) : null; if (!arr) throw new Error('Missing users array'); const r = await request('POST', '/api/admin/import', { users: arr }); return r.count; } if (!data || typeof data.users !== 'object') throw new Error('Missing "users" object'); writeUsers(data.users); return Object.keys(data.users).length; }, };