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;
  },
};