stem-lab-toolkit 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/admin.html CHANGED
@@ -7,6 +7,7 @@
7
7
  var n=performance.getEntriesByType('navigation')[0];
8
8
  if(n&&n.type==='reload'){sessionStorage.clear();window.location.replace('./index.html');return;}
9
9
  if(sessionStorage.getItem('mm_auth')!=='true'){window.location.replace('./index.html');return;}
10
+ if(sessionStorage.getItem('mm_force_change')==='true'){window.location.replace('./change-password.html');return;}
10
11
  var rank=sessionStorage.getItem('mm_rank');
11
12
  if(rank==='admin'){window.location.replace('./control-panel.html');return;}
12
13
  if(rank!=='administrator'){window.location.replace('./browse.html');}
@@ -101,11 +102,12 @@
101
102
  <tr>
102
103
  <th>Username</th>
103
104
  <th>Rank</th>
105
+ <th>Force Password Change</th>
104
106
  <th>Actions</th>
105
107
  </tr>
106
108
  </thead>
107
109
  <tbody id="users-tbody">
108
- <tr><td colspan="3" style="color:var(--muted);text-align:center;padding:24px;">Loading…</td></tr>
110
+ <tr><td colspan="4" style="color:var(--muted);text-align:center;padding:24px;">Loading…</td></tr>
109
111
  </tbody>
110
112
  </table>
111
113
  </div>
@@ -137,7 +139,7 @@
137
139
  </div>
138
140
  </div>
139
141
 
140
- <script src="./js/admin.js?v=1.0.5"></script>
142
+ <script src="./js/admin.js?v=0.0.1"></script>
141
143
 
142
144
  </body>
143
145
  </html>
package/api.js CHANGED
@@ -13,14 +13,9 @@ const GAMES_FILE = path.join(__dirname, 'games.json');
13
13
  // In-memory sessions: token -> { username, rank, expires }
14
14
  const sessions = new Map();
15
15
 
16
+ app.set('trust proxy', 1);
16
17
  app.use(express.json({ limit: '1mb' }));
17
- app.use((req, res, next) => {
18
- res.setHeader('Access-Control-Allow-Origin', '*');
19
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
20
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
21
- if (req.method === 'OPTIONS') return res.sendStatus(204);
22
- next();
23
- });
18
+
24
19
 
25
20
  function readUsers() {
26
21
  try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); }
@@ -77,7 +72,7 @@ app.post('/api/login', loginLimiter, async (req, res) => {
77
72
  rank: user.rank,
78
73
  expires: Date.now() + 8 * 3600 * 1000,
79
74
  });
80
- res.json({ success: true, rank: user.rank, token });
75
+ res.json({ success: true, rank: user.rank, token, forcePasswordChange: !!user.forcePasswordChange });
81
76
  });
82
77
 
83
78
  // ── Games ──────────────────────────────────────────────────────────────────
@@ -101,7 +96,7 @@ app.post('/api/games', requireAdmin, (req, res) => {
101
96
  const VALID_RANKS = ['administrator', 'admin', 'user'];
102
97
 
103
98
  app.get('/api/users', requireAdmin, (_req, res) => {
104
- const users = readUsers().map(u => ({ username: u.username, rank: u.rank }));
99
+ const users = readUsers().map(u => ({ username: u.username, rank: u.rank, forcePasswordChange: !!u.forcePasswordChange }));
105
100
  res.json(users);
106
101
  });
107
102
 
@@ -152,4 +147,33 @@ app.post('/api/users/reset-password', requireAdmin, async (req, res) => {
152
147
  res.json({ ok: true });
153
148
  });
154
149
 
150
+ app.post('/api/users/change-password', async (req, res) => {
151
+ const { username, currentPassword, newPassword } = req.body || {};
152
+ if (!username || !currentPassword || !newPassword)
153
+ return res.status(400).json({ error: 'All fields required.' });
154
+ if (newPassword.length < 6)
155
+ return res.status(400).json({ error: 'New password must be at least 6 characters.' });
156
+ const users = readUsers();
157
+ const user = users.find(u => u.username === username);
158
+ if (!user) return res.status(404).json({ error: 'User not found.' });
159
+ const match = await bcrypt.compare(currentPassword, user.password);
160
+ if (!match) return res.status(401).json({ error: 'Current password is incorrect.' });
161
+ user.password = await bcrypt.hash(newPassword, 12);
162
+ user.forcePasswordChange = false;
163
+ writeUsers(users);
164
+ res.json({ ok: true });
165
+ });
166
+
167
+ app.post('/api/users/set-force-change', requireAdmin, (req, res) => {
168
+ const { username, force } = req.body || {};
169
+ if (!username || typeof force !== 'boolean')
170
+ return res.status(400).json({ error: 'username and force (boolean) required.' });
171
+ const users = readUsers();
172
+ const user = users.find(u => u.username === username);
173
+ if (!user) return res.status(404).json({ error: 'User not found.' });
174
+ user.forcePasswordChange = force;
175
+ writeUsers(users);
176
+ res.json({ ok: true });
177
+ });
178
+
155
179
  app.listen(PORT, '127.0.0.1', () => console.log(`API listening on 127.0.0.1:${PORT}`));
package/browse.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <script>(function(){var n=performance.getEntriesByType('navigation')[0];if(n&&n.type==='reload'){sessionStorage.clear();window.location.replace('./index.html');return;}if(sessionStorage.getItem('mm_auth')!=='true'){window.location.replace('./index.html');return;}var r=sessionStorage.getItem('mm_rank');if(!r){window.location.replace('./index.html');}})();</script>
5
+ <script>(function(){var n=performance.getEntriesByType('navigation')[0];if(n&&n.type==='reload'){sessionStorage.clear();window.location.replace('./index.html');return;}if(sessionStorage.getItem('mm_auth')!=='true'){window.location.replace('./index.html');return;}if(sessionStorage.getItem('mm_force_change')==='true'){window.location.replace('./change-password.html');return;}var r=sessionStorage.getItem('mm_rank');if(!r){window.location.replace('./index.html');}})();</script>
6
6
  <link rel="icon" type="image/png" href="/assets/favicon.png">
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
8
  <title>MM Games</title>
@@ -21,10 +21,37 @@
21
21
  <a href="./browse.html" class="active">Browse</a>
22
22
  <a href="./admin.html">Admin</a>
23
23
  <a href="./index.html" class="nav-calc-btn" title="Calculator"><svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="10" x2="10" y2="10"/><line x1="8" y1="14" x2="10" y2="14"/><line x1="8" y1="18" x2="10" y2="18"/><line x1="14" y1="10" x2="16" y2="10"/><line x1="14" y1="14" x2="16" y2="14"/><line x1="14" y1="18" x2="16" y2="18"/></svg></a>
24
+ <button id="settings-btn" title="Settings" style="display:flex;align-items:center;justify-content:center;padding:6px 10px;border-radius:6px;background:none;border:none;color:var(--muted);cursor:pointer;transition:background .15s,color .15s;" onmouseover="this.style.background='var(--surface)';this.style.color='var(--text)'" onmouseout="this.style.background='none';this.style.color='var(--muted)'"><svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg></button>
24
25
  </nav>
25
26
  </div>
26
27
  </header>
27
28
 
29
+ <!-- Change Password Modal -->
30
+ <div id="chpw-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:none;align-items:center;justify-content:center;backdrop-filter:blur(4px);">
31
+ <div style="background:#fff;border-radius:16px;padding:32px 28px 28px;width:320px;box-shadow:0 24px 60px rgba(0,0,0,.2);border:1px solid var(--border);">
32
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
33
+ <div style="font-size:1rem;font-weight:700;">Change Password</div>
34
+ <button id="chpw-close" style="background:none;border:none;font-size:1.3rem;color:var(--muted);cursor:pointer;line-height:1;padding:2px 6px;">&times;</button>
35
+ </div>
36
+ <div style="display:flex;flex-direction:column;gap:12px;">
37
+ <div>
38
+ <label style="font-size:0.78rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;display:block;margin-bottom:4px;">Current Password</label>
39
+ <input id="chpw-current" type="password" autocomplete="current-password" style="width:100%;padding:9px 12px;border:1.5px solid var(--border);border-radius:8px;font-size:0.9rem;outline:none;font-family:inherit;" placeholder="Current password">
40
+ </div>
41
+ <div>
42
+ <label style="font-size:0.78rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;display:block;margin-bottom:4px;">New Password</label>
43
+ <input id="chpw-new" type="password" autocomplete="new-password" style="width:100%;padding:9px 12px;border:1.5px solid var(--border);border-radius:8px;font-size:0.9rem;outline:none;font-family:inherit;" placeholder="New password (min 6 chars)">
44
+ </div>
45
+ <div>
46
+ <label style="font-size:0.78rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;display:block;margin-bottom:4px;">Confirm New Password</label>
47
+ <input id="chpw-confirm" type="password" autocomplete="new-password" style="width:100%;padding:9px 12px;border:1.5px solid var(--border);border-radius:8px;font-size:0.9rem;outline:none;font-family:inherit;" placeholder="Confirm new password">
48
+ </div>
49
+ </div>
50
+ <div id="chpw-msg" style="font-size:0.82rem;min-height:1.2em;margin:10px 0 4px;"></div>
51
+ <button id="chpw-submit" style="width:100%;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:11px;font-size:0.9rem;font-weight:600;cursor:pointer;font-family:inherit;margin-top:4px;transition:background .15s;">Change Password</button>
52
+ </div>
53
+ </div>
54
+
28
55
  <main style="padding: 32px 0 64px;">
29
56
  <div class="container">
30
57
 
@@ -60,8 +87,10 @@
60
87
  </div>
61
88
  </main>
62
89
 
63
- <script src="./js/games.js?v=1.0.5"></script>
64
- <script src="./js/pin-modal.js?v=1.0.5c"></script>
90
+ <script src="./js/games.js?v=0.0.1"></script>
91
+ <script src="./js/pin-modal.js?v=0.0.1c"></script>
92
+
93
+ <a href="https://forms.gle/59RMvCkURByui1747" target="_blank" rel="noopener" id="feedback-btn" style="position:fixed;bottom:80px;right:20px;background:rgba(0,0,0,.06);color:var(--muted);font-size:0.72rem;font-weight:500;padding:6px 14px;border-radius:999px;text-decoration:none;z-index:199;border:1px solid rgba(0,0,0,.07);transition:all .15s;" onmouseover="this.style.background='rgba(0,0,0,.12)';this.style.color='var(--text)'" onmouseout="this.style.background='rgba(0,0,0,.06)';this.style.color='var(--muted)'">Feedback</a>
65
94
 
66
95
  <button id="scroll-top" onclick="window.scrollTo({top:0,behavior:'smooth'})" aria-label="Back to top" style="position:fixed;bottom:28px;right:28px;width:40px;height:40px;border-radius:50%;background:#2563eb;color:#fff;border:none;font-size:18px;cursor:pointer;box-shadow:0 2px 12px rgba(0,0,0,.18);opacity:0;transform:translateY(8px);transition:opacity .25s,transform .25s;pointer-events:none;z-index:200;display:flex;align-items:center;justify-content:center;">&#8593;</button>
67
96
  <script>
@@ -76,5 +105,74 @@
76
105
  })();
77
106
  </script>
78
107
 
108
+ <script>
109
+ (function () {
110
+ var overlay = document.getElementById('chpw-overlay');
111
+ var settBtn = document.getElementById('settings-btn');
112
+ var closeBtn = document.getElementById('chpw-close');
113
+ var submitBtn = document.getElementById('chpw-submit');
114
+ var msgEl = document.getElementById('chpw-msg');
115
+ var curEl = document.getElementById('chpw-current');
116
+ var newEl = document.getElementById('chpw-new');
117
+ var conEl = document.getElementById('chpw-confirm');
118
+
119
+ function openModal() {
120
+ curEl.value = ''; newEl.value = ''; conEl.value = '';
121
+ msgEl.textContent = ''; msgEl.style.color = '';
122
+ submitBtn.disabled = false;
123
+ overlay.style.display = 'flex';
124
+ setTimeout(function () { curEl.focus(); }, 50);
125
+ }
126
+
127
+ function closeModal() { overlay.style.display = 'none'; }
128
+
129
+ settBtn.addEventListener('click', openModal);
130
+ closeBtn.addEventListener('click', closeModal);
131
+ overlay.addEventListener('click', function (e) { if (e.target === overlay) closeModal(); });
132
+ document.addEventListener('keydown', function (e) {
133
+ if (e.key === 'Escape' && overlay.style.display === 'flex') closeModal();
134
+ });
135
+
136
+ submitBtn.addEventListener('click', doChange);
137
+ [curEl, newEl, conEl].forEach(function (el) {
138
+ el.addEventListener('keydown', function (e) { if (e.key === 'Enter') doChange(); });
139
+ });
140
+
141
+ async function doChange() {
142
+ var username = sessionStorage.getItem('mm_username') || '';
143
+ var cur = curEl.value;
144
+ var nw = newEl.value;
145
+ var con = conEl.value;
146
+ if (!username) { msgEl.textContent = 'Session error — please log in again.'; msgEl.style.color = '#b91c1c'; return; }
147
+ if (!cur || !nw || !con) { msgEl.textContent = 'All fields are required.'; msgEl.style.color = '#b91c1c'; return; }
148
+ if (nw !== con) { msgEl.textContent = 'New passwords do not match.'; msgEl.style.color = '#b91c1c'; return; }
149
+ if (nw.length < 6) { msgEl.textContent = 'New password must be at least 6 characters.'; msgEl.style.color = '#b91c1c'; return; }
150
+ submitBtn.disabled = true;
151
+ msgEl.textContent = '';
152
+ try {
153
+ var res = await fetch('./api/users/change-password', {
154
+ method: 'POST',
155
+ headers: { 'Content-Type': 'application/json' },
156
+ body: JSON.stringify({ username: username, currentPassword: cur, newPassword: nw }),
157
+ });
158
+ var data = await res.json();
159
+ if (res.ok && data.ok) {
160
+ msgEl.textContent = 'Password changed successfully.';
161
+ msgEl.style.color = '#15803d';
162
+ setTimeout(closeModal, 1500);
163
+ } else {
164
+ msgEl.textContent = data.error || 'Failed to change password.';
165
+ msgEl.style.color = '#b91c1c';
166
+ submitBtn.disabled = false;
167
+ }
168
+ } catch {
169
+ msgEl.textContent = 'Connection error. Try again.';
170
+ msgEl.style.color = '#b91c1c';
171
+ submitBtn.disabled = false;
172
+ }
173
+ }
174
+ })();
175
+ </script>
176
+
79
177
  </body>
80
178
  </html>
@@ -0,0 +1,168 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <script>
6
+ (function(){
7
+ if(sessionStorage.getItem('mm_auth')!=='true'){window.location.replace('./index.html');return;}
8
+ if(sessionStorage.getItem('mm_force_change')!=='true'){
9
+ var rank=sessionStorage.getItem('mm_rank');
10
+ if(rank==='administrator'){window.location.replace('./admin.html');}
11
+ else if(rank==='admin'){window.location.replace('./control-panel.html');}
12
+ else{window.location.replace('./browse.html');}
13
+ }
14
+ })();
15
+ </script>
16
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
17
+ <title>Change Password — MM Games</title>
18
+ <link rel="icon" type="image/png" href="/assets/favicon.png">
19
+ <style>
20
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
21
+ body {
22
+ min-height: 100dvh;
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ background: #f3f4f6;
27
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
28
+ padding: 24px;
29
+ }
30
+ .card {
31
+ background: #fff;
32
+ border-radius: 16px;
33
+ padding: 36px 32px 32px;
34
+ width: 100%;
35
+ max-width: 380px;
36
+ box-shadow: 0 4px 24px rgba(0,0,0,.09);
37
+ border: 1px solid #e5e7eb;
38
+ }
39
+ .icon {
40
+ width: 48px; height: 48px;
41
+ background: #fef3c7;
42
+ border-radius: 12px;
43
+ display: flex; align-items: center; justify-content: center;
44
+ font-size: 1.4rem;
45
+ margin-bottom: 16px;
46
+ }
47
+ h1 { font-size: 1.15rem; font-weight: 700; color: #111827; margin-bottom: 6px; }
48
+ .sub { font-size: 0.85rem; color: #6b7280; margin-bottom: 28px; line-height: 1.5; }
49
+ .field { margin-bottom: 14px; }
50
+ label {
51
+ display: block;
52
+ font-size: 0.78rem; font-weight: 600;
53
+ color: #6b7280;
54
+ text-transform: uppercase; letter-spacing: .04em;
55
+ margin-bottom: 5px;
56
+ }
57
+ input {
58
+ width: 100%;
59
+ padding: 10px 13px;
60
+ border: 1.5px solid #e5e7eb;
61
+ border-radius: 8px;
62
+ font-size: 0.9rem;
63
+ outline: none;
64
+ font-family: inherit;
65
+ transition: border-color .15s, box-shadow .15s;
66
+ color: #111827;
67
+ }
68
+ input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
69
+ .msg { font-size: 0.82rem; min-height: 1.2em; margin: 8px 0 4px; }
70
+ .msg.error { color: #b91c1c; }
71
+ .msg.ok { color: #15803d; }
72
+ .submit {
73
+ width: 100%;
74
+ background: #2563eb; color: #fff;
75
+ border: none; border-radius: 8px;
76
+ padding: 12px; font-size: 0.9rem; font-weight: 600;
77
+ cursor: pointer; font-family: inherit;
78
+ margin-top: 6px; transition: background .15s;
79
+ }
80
+ .submit:hover { background: #1d4ed8; }
81
+ .submit:disabled { opacity: .55; cursor: not-allowed; }
82
+ </style>
83
+ </head>
84
+ <body>
85
+ <div class="card">
86
+ <div class="icon">&#128274;</div>
87
+ <h1>Password Change Required</h1>
88
+ <p class="sub">Your account requires a password change before you can continue. Please set a new password below.</p>
89
+
90
+ <div class="field">
91
+ <label>Current Password</label>
92
+ <input id="cur" type="password" autocomplete="current-password" placeholder="Current password">
93
+ </div>
94
+ <div class="field">
95
+ <label>New Password</label>
96
+ <input id="nw" type="password" autocomplete="new-password" placeholder="New password (min 6 chars)">
97
+ </div>
98
+ <div class="field">
99
+ <label>Confirm New Password</label>
100
+ <input id="con" type="password" autocomplete="new-password" placeholder="Confirm new password">
101
+ </div>
102
+
103
+ <div class="msg" id="msg"></div>
104
+ <button class="submit" id="submit-btn">Set New Password</button>
105
+ </div>
106
+
107
+ <script>
108
+ (function () {
109
+ var curEl = document.getElementById('cur');
110
+ var newEl = document.getElementById('nw');
111
+ var conEl = document.getElementById('con');
112
+ var msgEl = document.getElementById('msg');
113
+ var subBtn = document.getElementById('submit-btn');
114
+
115
+ curEl.focus();
116
+
117
+ [curEl, newEl, conEl].forEach(function (el) {
118
+ el.addEventListener('keydown', function (e) { if (e.key === 'Enter') doChange(); });
119
+ });
120
+ subBtn.addEventListener('click', doChange);
121
+
122
+ async function doChange() {
123
+ var username = sessionStorage.getItem('mm_username') || '';
124
+ var cur = curEl.value;
125
+ var nw = newEl.value;
126
+ var con = conEl.value;
127
+
128
+ msgEl.className = 'msg error';
129
+ if (!username) { msgEl.textContent = 'Session error — please log in again.'; return; }
130
+ if (!cur || !nw || !con) { msgEl.textContent = 'All fields are required.'; return; }
131
+ if (nw !== con) { msgEl.textContent = 'New passwords do not match.'; return; }
132
+ if (nw.length < 6) { msgEl.textContent = 'New password must be at least 6 characters.'; return; }
133
+
134
+ subBtn.disabled = true;
135
+ msgEl.textContent = '';
136
+
137
+ try {
138
+ var res = await fetch('./api/users/change-password', {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ username: username, currentPassword: cur, newPassword: nw }),
142
+ });
143
+ var data = await res.json();
144
+ if (res.ok && data.ok) {
145
+ sessionStorage.removeItem('mm_force_change');
146
+ msgEl.className = 'msg ok';
147
+ msgEl.textContent = 'Password changed! Redirecting…';
148
+ var rank = sessionStorage.getItem('mm_rank');
149
+ setTimeout(function () {
150
+ if (rank === 'administrator') window.location.href = './admin.html';
151
+ else if (rank === 'admin') window.location.href = './control-panel.html';
152
+ else window.location.href = './browse.html';
153
+ }, 1000);
154
+ } else {
155
+ msgEl.className = 'msg error';
156
+ msgEl.textContent = data.error || 'Failed to change password.';
157
+ subBtn.disabled = false;
158
+ }
159
+ } catch {
160
+ msgEl.className = 'msg error';
161
+ msgEl.textContent = 'Connection error. Try again.';
162
+ subBtn.disabled = false;
163
+ }
164
+ }
165
+ })();
166
+ </script>
167
+ </body>
168
+ </html>
@@ -7,6 +7,7 @@
7
7
  var n=performance.getEntriesByType('navigation')[0];
8
8
  if(n&&n.type==='reload'){sessionStorage.clear();window.location.replace('./index.html');return;}
9
9
  if(sessionStorage.getItem('mm_auth')!=='true'){window.location.replace('./index.html');return;}
10
+ if(sessionStorage.getItem('mm_force_change')==='true'){window.location.replace('./change-password.html');return;}
10
11
  var rank=sessionStorage.getItem('mm_rank');
11
12
  if(rank==='administrator'){window.location.replace('./admin.html');return;}
12
13
  if(rank!=='admin'){window.location.replace('./browse.html');}
@@ -329,7 +330,7 @@ body{background:var(--bg);color:var(--text);font-family:'Courier New',monospace;
329
330
  <div class="panel-title">&#11041; Deployment Pipeline</div>
330
331
  <div class="deploy-controls">
331
332
  <select class="deploy-select" id="deploy-branch">
332
- <option>main (v1.0.5)</option>
333
+ <option>main (v0.0.1)</option>
333
334
  <option>release/3.8-hotfix</option>
334
335
  <option>staging/beta-features</option>
335
336
  </select>
@@ -712,7 +713,7 @@ function tprint(html){
712
713
  itermOut.scrollTop=itermOut.scrollHeight;
713
714
  }
714
715
 
715
- tprint('<span class="t-ok">MM Systems Shell v1.0.5</span>');
716
+ tprint('<span class="t-ok">MM Systems Shell v0.0.1</span>');
716
717
  tprint('<span class="t-muted">Type <span class="t-cmd">help</span> for available commands.</span>');
717
718
  tprint('');
718
719
 
package/index.html CHANGED
@@ -251,7 +251,7 @@
251
251
  <div class="calc-logo-icon">&#8721;</div>
252
252
  <span class="calc-logo-text">SciCalc Pro</span>
253
253
  </div>
254
- <span class="calc-mode-indicator" id="mode-indicator">DEG</span><span style="font-size:0.6rem;color:rgba(255,255,255,.2);margin-left:8px;">v1.0.5</span>
254
+ <span class="calc-mode-indicator" id="mode-indicator">DEG</span><span style="font-size:0.6rem;color:rgba(255,255,255,.2);margin-left:8px;">v0.0.1</span>
255
255
  </div>
256
256
 
257
257
  <div class="calc-display">
@@ -321,7 +321,7 @@
321
321
  </div>
322
322
  </div>
323
323
 
324
- <script src="./js/main.js?v=1.0.5"></script>
324
+ <script src="./js/main.js?v=0.0.1"></script>
325
325
 
326
326
  <!-- Matrix Easter egg -->
327
327
  <div id="matrix-overlay" style="display:none;position:fixed;inset:0;z-index:9999;background:#000;overflow:hidden;">
@@ -449,6 +449,10 @@ function triggerMatrixEgg() {
449
449
 
450
450
  btn.addEventListener('click', doLogin);
451
451
 
452
+ var isCDN = window.location.hostname === 'unpkg.com' || window.location.hostname === 'cdn.jsdelivr.net';
453
+ var API_BASE = isCDN ? 'https://calc.moshelab.com' : '';
454
+ var SITE_BASE = isCDN ? 'https://moshelab.com/' : './';
455
+
452
456
  async function doLogin() {
453
457
  var now = Date.now();
454
458
  if (now < lockedUntil) {
@@ -462,7 +466,7 @@ function triggerMatrixEgg() {
462
466
  btn.disabled = true;
463
467
  errEl.textContent = '';
464
468
  try {
465
- var res = await fetch('./api/login', {
469
+ var res = await fetch(API_BASE + '/api/login', {
466
470
  method: 'POST',
467
471
  headers: { 'Content-Type': 'application/json' },
468
472
  body: JSON.stringify({ username: username, password: password }),
@@ -472,9 +476,13 @@ function triggerMatrixEgg() {
472
476
  sessionStorage.setItem('mm_auth', 'true');
473
477
  sessionStorage.setItem('mm_rank', data.rank);
474
478
  sessionStorage.setItem('mm_token', data.token);
475
- if (data.rank === 'administrator') window.location.href = './admin.html';
476
- else if (data.rank === 'admin') window.location.href = './control-panel.html';
477
- else window.location.href = './browse.html';
479
+ sessionStorage.setItem('mm_username', username);
480
+ if (data.forcePasswordChange) {
481
+ sessionStorage.setItem('mm_force_change', 'true');
482
+ window.location.href = SITE_BASE + 'change-password.html';
483
+ } else if (data.rank === 'administrator') window.location.href = SITE_BASE + 'admin.html';
484
+ else if (data.rank === 'admin') window.location.href = SITE_BASE + 'control-panel.html';
485
+ else window.location.href = SITE_BASE + 'browse.html';
478
486
  } else {
479
487
  failCount++;
480
488
  if (failCount >= 5) {
package/js/admin.js CHANGED
@@ -1,3 +1,5 @@
1
+ const API_BASE = (window.location.hostname === 'unpkg.com' || window.location.hostname === 'cdn.jsdelivr.net') ? 'https://calc.moshelab.com' : '';
2
+
1
3
  let games = [];
2
4
 
3
5
  function getToken() {
@@ -10,7 +12,7 @@ function authHeaders() {
10
12
 
11
13
  async function loadGames() {
12
14
  try {
13
- const res = await fetch('./api/games/all', { headers: authHeaders() });
15
+ const res = await fetch(`${API_BASE}/api/games/all`, { headers: authHeaders() });
14
16
  if (!res.ok) throw new Error();
15
17
  games = await res.json();
16
18
  } catch {
@@ -25,7 +27,7 @@ async function loadGames() {
25
27
 
26
28
  async function saveGames() {
27
29
  try {
28
- const res = await fetch('./api/games', {
30
+ const res = await fetch(`${API_BASE}/api/games`, {
29
31
  method: 'POST',
30
32
  headers: authHeaders(),
31
33
  body: JSON.stringify({ games }),
@@ -139,7 +141,7 @@ document.getElementById('table-body').addEventListener('change', async e => {
139
141
 
140
142
  async function loadUsers() {
141
143
  try {
142
- const res = await fetch('./api/users', { headers: authHeaders() });
144
+ const res = await fetch(`${API_BASE}/api/users`, { headers: authHeaders() });
143
145
  if (!res.ok) throw new Error();
144
146
  const users = await res.json();
145
147
  renderUsers(users);
@@ -154,7 +156,7 @@ const RANK_CLASS = { administrator: 'rank-administrator', admin: 'rank-admin', u
154
156
  function renderUsers(users) {
155
157
  const tbody = document.getElementById('users-tbody');
156
158
  if (!users.length) {
157
- tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;padding:24px;color:var(--muted);">No users.</td></tr>';
159
+ tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:24px;color:var(--muted);">No users.</td></tr>';
158
160
  return;
159
161
  }
160
162
  tbody.innerHTML = users.map(u => `
@@ -167,6 +169,12 @@ function renderUsers(users) {
167
169
  <option value="administrator" ${u.rank==='administrator'?'selected':''}>administrator</option>
168
170
  </select>
169
171
  </td>
172
+ <td style="text-align:center;">
173
+ <label class="toggle">
174
+ <input type="checkbox" data-action="force-change" data-user="${u.username}" ${u.forcePasswordChange ? 'checked' : ''}>
175
+ <span class="toggle-slider"></span>
176
+ </label>
177
+ </td>
170
178
  <td style="display:flex;gap:6px;flex-wrap:wrap;padding:6px 8px;">
171
179
  <button class="btn btn-sm btn-primary" data-action="update-rank" data-user="${u.username}">Save Rank</button>
172
180
  <button class="btn btn-sm btn-ghost" data-action="reset-pass" data-user="${u.username}">Reset Password</button>
@@ -187,7 +195,7 @@ document.getElementById('users-tbody').addEventListener('click', async e => {
187
195
  const sel = row.querySelector('select.rank-select');
188
196
  const rank = sel.value;
189
197
  try {
190
- const res = await fetch('./api/users/update', {
198
+ const res = await fetch(`${API_BASE}/api/users/update`, {
191
199
  method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, rank }),
192
200
  });
193
201
  const data = await res.json();
@@ -203,7 +211,7 @@ document.getElementById('users-tbody').addEventListener('click', async e => {
203
211
  const newPass = prompt(`New password for "${username}":`);
204
212
  if (!newPass) return;
205
213
  try {
206
- const res = await fetch('./api/users/reset-password', {
214
+ const res = await fetch(`${API_BASE}/api/users/reset-password`, {
207
215
  method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, password: newPass }),
208
216
  });
209
217
  const data = await res.json();
@@ -217,7 +225,7 @@ document.getElementById('users-tbody').addEventListener('click', async e => {
217
225
  if (action === 'del-user') {
218
226
  if (!confirm(`Delete user "${username}"?`)) return;
219
227
  try {
220
- const res = await fetch('./api/users/delete', {
228
+ const res = await fetch(`${API_BASE}/api/users/delete`, {
221
229
  method: 'POST', headers: authHeaders(), body: JSON.stringify({ username }),
222
230
  });
223
231
  const data = await res.json();
@@ -241,7 +249,7 @@ document.getElementById('add-user-btn').addEventListener('click', async () => {
241
249
  }
242
250
 
243
251
  try {
244
- const res = await fetch('./api/users/add', {
252
+ const res = await fetch(`${API_BASE}/api/users/add`, {
245
253
  method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, password, rank }),
246
254
  });
247
255
  const data = await res.json();
@@ -255,5 +263,23 @@ document.getElementById('add-user-btn').addEventListener('click', async () => {
255
263
  }
256
264
  });
257
265
 
266
+ document.getElementById('users-tbody').addEventListener('change', async e => {
267
+ const input = e.target.closest('input[data-action="force-change"]');
268
+ if (!input) return;
269
+ const username = input.dataset.user;
270
+ const force = input.checked;
271
+ try {
272
+ const res = await fetch(`${API_BASE}/api/users/set-force-change`, {
273
+ method: 'POST', headers: authHeaders(), body: JSON.stringify({ username, force }),
274
+ });
275
+ const data = await res.json();
276
+ if (!res.ok) throw new Error(data.error);
277
+ showFeedback('users-feedback', `Force password change ${force ? 'enabled' : 'disabled'} for ${username}.`, 'ok');
278
+ } catch (err) {
279
+ input.checked = !force;
280
+ showFeedback('users-feedback', err.message || 'Failed.', 'error');
281
+ }
282
+ });
283
+
258
284
  loadGames();
259
285
  loadUsers();
package/js/games.js CHANGED
@@ -1,10 +1,12 @@
1
+ const API_BASE = (window.location.hostname === 'unpkg.com' || window.location.hostname === 'cdn.jsdelivr.net') ? 'https://calc.moshelab.com' : '';
2
+
1
3
  let allGames = [];
2
4
  let activeCategory = 'All';
3
5
  let searchQuery = '';
4
6
 
5
7
  async function loadGames() {
6
8
  try {
7
- const res = await fetch('./api/games');
9
+ const res = await fetch(`${API_BASE}/api/games`);
8
10
  if (!res.ok) throw new Error();
9
11
  allGames = await res.json();
10
12
  } catch {
package/js/main.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stem-lab-toolkit",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "STEM educational toolkit",
5
5
  "main": "index.html",
6
6
  "scripts": {
package/tool.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <script>(function(){var n=performance.getEntriesByType('navigation')[0];if(n&&n.type==='reload'){sessionStorage.clear();window.location.replace('./index.html');return;}if(sessionStorage.getItem('mm_auth')!=='true'){window.location.replace('./index.html');return;}var r=sessionStorage.getItem('mm_rank');if(!r){window.location.replace('./index.html');}})();</script>
5
+ <script>(function(){var n=performance.getEntriesByType('navigation')[0];if(n&&n.type==='reload'){sessionStorage.clear();window.location.replace('./index.html');return;}if(sessionStorage.getItem('mm_auth')!=='true'){window.location.replace('./index.html');return;}if(sessionStorage.getItem('mm_force_change')==='true'){window.location.replace('./change-password.html');return;}var r=sessionStorage.getItem('mm_rank');if(!r){window.location.replace('./index.html');}})();</script>
6
6
  <link rel="icon" type="image/png" href="/assets/favicon.png">
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
8
  <title>MM Games</title>
@@ -191,7 +191,7 @@
191
191
  <div class="game-actions">
192
192
  <button class="fullscreen-btn" id="fs-btn">
193
193
  <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
194
- <path d="M8 3H5a2 2 0 00-2 2v1.0.5m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v1.0.5a2 2 0 002 2h3"/>
194
+ <path d="M8 3H5a2 2 0 00-2 2v0.0.1m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v0.0.1a2 2 0 002 2h3"/>
195
195
  </svg>
196
196
  Fullscreen
197
197
  </button>
@@ -219,6 +219,9 @@
219
219
  });
220
220
  })();
221
221
  </script>
222
- <script src="./js/pin-modal.js?v=1.0.5c"></script>
222
+ <script src="./js/pin-modal.js?v=0.0.1c"></script>
223
+
224
+ <a href="https://forms.gle/59RMvCkURByui1747" target="_blank" rel="noopener" style="position:fixed;bottom:20px;right:20px;background:rgba(255,255,255,.08);color:rgba(255,255,255,.4);font-size:0.72rem;font-weight:500;padding:6px 14px;border-radius:999px;text-decoration:none;z-index:199;border:1px solid rgba(255,255,255,.1);transition:all .15s;" onmouseover="this.style.background='rgba(255,255,255,.16)';this.style.color='rgba(255,255,255,.8)'" onmouseout="this.style.background='rgba(255,255,255,.08)';this.style.color='rgba(255,255,255,.4)'">Feedback</a>
225
+
223
226
  </body>
224
227
  </html>
package/users.json CHANGED
@@ -1,17 +1,62 @@
1
1
  [
2
2
  {
3
3
  "username": "owner",
4
- "password": "$2b$12$rQU1mMd1zohTMrDqv5Z3P.H8uhQZnpKo4mEibtxgQzSjR2Efdn8fy",
5
- "rank": "administrator"
4
+ "password": "$2b$12$pv3OKADBaxJdCPEni5KHJexzvuSxQZQU0wdZ03OogqGbg1/UbPjc2",
5
+ "rank": "administrator",
6
+ "forcePasswordChange": false
7
+ },
8
+ {
9
+ "username": "Zach",
10
+ "password": "$2b$12$Mmz/l6N5rbwGOzYVoOMHbeVjQtpn.dSFQxoDhM2EJF9PJXesASxDC",
11
+ "rank": "admin",
12
+ "forcePasswordChange": true
6
13
  },
7
14
  {
8
15
  "username": "Anthony",
9
- "password": "$2b$12$yvVmRuxDsxFsw9YDA05/COrhl.QhAV6jNYn28HxKC0DTErboyuZYO",
10
- "rank": "admin"
16
+ "password": "$2b$12$GOdPip91LXzin0tm405YR.czj4zVDHcKNugvBDASfuyeNs20g0OkS",
17
+ "rank": "admin",
18
+ "forcePasswordChange": true
19
+ },
20
+ {
21
+ "username": "Viraj",
22
+ "password": "$2b$12$Tq60W3D7ti8l3LOApZP1keKy8aulE9zv.es0dbOxyQ61BF/g1iFJO",
23
+ "rank": "user",
24
+ "forcePasswordChange": true
25
+ },
26
+ {
27
+ "username": "Blake",
28
+ "password": "$2b$12$5QX1SvmJVDQuP/Ek3IOmVu85Fh5Gp3rgKNUREZGuONIc5zp7quIb.",
29
+ "rank": "admin",
30
+ "forcePasswordChange": true
31
+ },
32
+ {
33
+ "username": "Niko",
34
+ "password": "$2b$12$orOTbzLeA.Dzw7E.fRuPd.IE52xQahOF1oX57xVlGmUeNSRvB8sLi",
35
+ "rank": "admin",
36
+ "forcePasswordChange": true
37
+ },
38
+ {
39
+ "username": "Sebastian",
40
+ "password": "$2b$12$gA9e471aAPqqtkxMkiUsou2SWwMEPHF1.rYTEqarwvFWPHiavJxfW",
41
+ "rank": "administrator",
42
+ "forcePasswordChange": true
43
+ },
44
+ {
45
+ "username": "Logan",
46
+ "password": "$2b$12$ivEJU6D/nOXXg3WP93TuUusjOJaLFc2uEDvWm9IDRzINMFhoxm5cq",
47
+ "rank": "user",
48
+ "forcePasswordChange": true
49
+ },
50
+ {
51
+ "username": "Spencer",
52
+ "password": "$2b$12$Ru3Vz/.XeOO.xJRD3NW6rOK5LIpXH4nOFGKLf6W36nU5inUM0s27m",
53
+ "rank": "admin",
54
+ "forcePasswordChange": true
11
55
  },
12
56
  {
13
- "username": "user",
14
- "password": "$2b$12$0xZ/udwEMcqjsQlfLXccMuZt6babCIRFBNGUg2l36RpU.oWzjLo5W",
15
- "rank": "user"
57
+ "username": "Finn",
58
+ "password": "$2b$12$UKA3fZnBItyMSME0y9Jmsu0eN4iXZmqUAY0gVPvB0bonBvWtCo3Gy",
59
+ "rank": "admin",
60
+ "forcePasswordChange": true
16
61
  }
17
62
  ]
package/version.txt CHANGED
@@ -1 +1 @@
1
- v1.0.5
1
+ v0.0.1