Echo / src /auth /Auth.js
mlmihjaz
Fix localStorage auth fallback trapping accounts on one device
24ea5ee
Raw
History Blame Contribute Delete
10.3 kB
/**
* 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;
},
};