File size: 10,291 Bytes
173e683 24ea5ee 173e683 2846662 173e683 24ea5ee 173e683 24ea5ee 173e683 4e53d9c 173e683 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 | /**
* 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;
},
};
|