stem-lab-toolkit 1.0.1 → 1.0.3
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 +60 -13
- package/api.js +163 -85
- package/assets/favicon.png +0 -0
- package/assets/logo.png +0 -0
- package/browse.html +121 -5
- package/bump.sh +5 -1
- package/change-password.html +168 -0
- package/control-panel.html +885 -0
- package/css/style.css +3 -1
- package/index.html +247 -98
- package/js/admin.js +144 -29
- package/js/games.js +2 -2
- package/js/main.js +14 -95
- package/js/pin-modal.js +10 -95
- package/js/theme.js +20 -0
- package/package.json +7 -2
- package/{game.html → tool.html} +31 -18
- package/users.json +62 -0
- package/version.txt +1 -1
- package/pins.json +0 -1
package/admin.html
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<script>
|
|
6
|
+
(function(){
|
|
7
|
+
var n=performance.getEntriesByType('navigation')[0];
|
|
8
|
+
if(n&&n.type==='reload'){sessionStorage.clear();window.location.replace('./index.html');return;}
|
|
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;}
|
|
11
|
+
var rank=sessionStorage.getItem('mm_rank');
|
|
12
|
+
if(rank==='admin'){window.location.replace('./control-panel.html');return;}
|
|
13
|
+
if(rank!=='administrator'){window.location.replace('./browse.html');}
|
|
14
|
+
})();
|
|
15
|
+
</script>
|
|
16
|
+
<link rel="icon" type="image/png" href="/assets/favicon.png">
|
|
5
17
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
18
|
<title>Admin — MM Games</title>
|
|
7
19
|
<link rel="stylesheet" href="./css/style.css">
|
|
@@ -12,6 +24,11 @@
|
|
|
12
24
|
.overflow-x { overflow-x: auto; }
|
|
13
25
|
.thumb-preview { width: 56px; height: 42px; object-fit: cover; border-radius: 4px; background: var(--surface); }
|
|
14
26
|
.thumb-ph { width: 56px; height: 42px; border-radius: 4px; background: #e0e7ff; display:flex; align-items:center; justify-content:center; font-size:.65rem; font-weight:600; padding:4px; line-height:1.2; overflow:hidden; }
|
|
27
|
+
.rank-badge { font-size: 0.72rem; padding: 2px 8px; border-radius: 20px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
|
28
|
+
.rank-administrator { background: #ede9fe; color: #6d28d9; }
|
|
29
|
+
.rank-admin { background: #fef3c7; color: #92400e; }
|
|
30
|
+
.rank-user { background: #ecfdf5; color: #065f46; }
|
|
31
|
+
.rank-select { font-size: 0.8rem; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; }
|
|
15
32
|
</style>
|
|
16
33
|
</head>
|
|
17
34
|
<body>
|
|
@@ -19,20 +36,21 @@
|
|
|
19
36
|
<header class="site-header">
|
|
20
37
|
<div class="header-inner">
|
|
21
38
|
<a href="./browse.html" class="logo-link">
|
|
22
|
-
<img src="./assets/logo.png" alt="" class="logo-img" onerror="this.style.display='none'">
|
|
23
|
-
<span>MM Games</span
|
|
39
|
+
<img src="./assets/logo.png?v=new" alt="" class="logo-img" onerror="this.style.display='none'">
|
|
40
|
+
<span>MM Games</span>
|
|
24
41
|
</a>
|
|
25
42
|
<nav class="site-nav">
|
|
26
43
|
<a href="./browse.html">Browse</a>
|
|
27
44
|
<a href="./admin.html" class="active">Admin</a>
|
|
28
45
|
<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>
|
|
46
|
+
<button onclick="sessionStorage.clear();window.location.href='./index.html';" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:.8rem;padding:5px 12px;border-radius:8px;cursor:pointer;margin-left:4px;">Logout</button>
|
|
29
47
|
</nav>
|
|
30
48
|
</div>
|
|
31
49
|
</header>
|
|
32
50
|
|
|
33
51
|
<div class="admin-wrap">
|
|
34
52
|
<div class="page-title">Admin Panel</div>
|
|
35
|
-
<div class="page-sub">Manage games and site settings</div>
|
|
53
|
+
<div class="page-sub">Manage games, users, and site settings</div>
|
|
36
54
|
|
|
37
55
|
<!-- Stats -->
|
|
38
56
|
<div class="card">
|
|
@@ -49,23 +67,51 @@
|
|
|
49
67
|
</div>
|
|
50
68
|
</div>
|
|
51
69
|
|
|
52
|
-
<!--
|
|
70
|
+
<!-- User Management -->
|
|
53
71
|
<div class="card">
|
|
54
|
-
<div class="card-title">
|
|
72
|
+
<div class="card-title">User Management</div>
|
|
73
|
+
|
|
74
|
+
<!-- Add user -->
|
|
55
75
|
<div class="form-grid">
|
|
56
76
|
<div class="form-group">
|
|
57
|
-
<label>
|
|
58
|
-
<input type="
|
|
77
|
+
<label>Username</label>
|
|
78
|
+
<input type="text" id="new-username" placeholder="New username" autocomplete="off">
|
|
79
|
+
</div>
|
|
80
|
+
<div class="form-group">
|
|
81
|
+
<label>Password</label>
|
|
82
|
+
<input type="password" id="new-password" placeholder="Password">
|
|
59
83
|
</div>
|
|
60
84
|
<div class="form-group">
|
|
61
|
-
<label>
|
|
62
|
-
<
|
|
85
|
+
<label>Rank</label>
|
|
86
|
+
<select id="new-rank">
|
|
87
|
+
<option value="user">user</option>
|
|
88
|
+
<option value="admin">admin</option>
|
|
89
|
+
<option value="administrator">administrator</option>
|
|
90
|
+
</select>
|
|
63
91
|
</div>
|
|
64
92
|
</div>
|
|
65
93
|
<div style="margin-top:16px; display:flex; gap:10px; align-items:center;">
|
|
66
|
-
<button class="btn btn-primary" id="
|
|
67
|
-
<span class="feedback" id="
|
|
94
|
+
<button class="btn btn-primary" id="add-user-btn">Add User</button>
|
|
95
|
+
<span class="feedback" id="add-user-feedback"></span>
|
|
68
96
|
</div>
|
|
97
|
+
|
|
98
|
+
<!-- Users table -->
|
|
99
|
+
<div class="overflow-x" style="margin-top:28px;">
|
|
100
|
+
<table class="admin-table" id="users-table">
|
|
101
|
+
<thead>
|
|
102
|
+
<tr>
|
|
103
|
+
<th>Username</th>
|
|
104
|
+
<th>Rank</th>
|
|
105
|
+
<th>Force Password Change</th>
|
|
106
|
+
<th>Actions</th>
|
|
107
|
+
</tr>
|
|
108
|
+
</thead>
|
|
109
|
+
<tbody id="users-tbody">
|
|
110
|
+
<tr><td colspan="4" style="color:var(--muted);text-align:center;padding:24px;">Loading…</td></tr>
|
|
111
|
+
</tbody>
|
|
112
|
+
</table>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="feedback" id="users-feedback" style="margin-top:12px;"></div>
|
|
69
115
|
</div>
|
|
70
116
|
|
|
71
117
|
<!-- Games table -->
|
|
@@ -93,6 +139,7 @@
|
|
|
93
139
|
</div>
|
|
94
140
|
</div>
|
|
95
141
|
|
|
96
|
-
<script src="./js/admin.js?v=
|
|
142
|
+
<script src="./js/admin.js?v=1.0.3"></script>
|
|
143
|
+
|
|
97
144
|
</body>
|
|
98
145
|
</html>
|
package/api.js
CHANGED
|
@@ -1,106 +1,184 @@
|
|
|
1
|
-
const
|
|
2
|
-
const
|
|
3
|
-
const
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const bcrypt = require('bcrypt');
|
|
3
|
+
const rateLimit = require('express-rate-limit');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
4
7
|
|
|
5
|
-
const
|
|
6
|
-
const PINS_FILE = path.join(__dirname, 'pins.json');
|
|
8
|
+
const app = express();
|
|
7
9
|
const PORT = 3001;
|
|
10
|
+
const USERS_FILE = path.join(__dirname, 'users.json');
|
|
11
|
+
const GAMES_FILE = path.join(__dirname, 'games.json');
|
|
12
|
+
|
|
13
|
+
// In-memory sessions: token -> { username, rank, expires }
|
|
14
|
+
const sessions = new Map();
|
|
15
|
+
|
|
16
|
+
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
|
+
});
|
|
8
24
|
|
|
25
|
+
function readUsers() {
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); }
|
|
27
|
+
catch { return []; }
|
|
28
|
+
}
|
|
29
|
+
function writeUsers(users) {
|
|
30
|
+
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
|
|
31
|
+
}
|
|
9
32
|
function readGames() {
|
|
10
33
|
try { return JSON.parse(fs.readFileSync(GAMES_FILE, 'utf8')); }
|
|
11
34
|
catch { return []; }
|
|
12
35
|
}
|
|
13
|
-
|
|
14
36
|
function writeGames(games) {
|
|
15
37
|
fs.writeFileSync(GAMES_FILE, JSON.stringify(games, null, 2));
|
|
16
38
|
}
|
|
17
39
|
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
40
|
+
function getSession(req) {
|
|
41
|
+
const auth = (req.headers.authorization || '').replace('Bearer ', '').trim();
|
|
42
|
+
if (!auth) return null;
|
|
43
|
+
const sess = sessions.get(auth);
|
|
44
|
+
if (!sess || sess.expires < Date.now()) { sessions.delete(auth); return null; }
|
|
45
|
+
return sess;
|
|
21
46
|
}
|
|
22
47
|
|
|
23
|
-
function
|
|
24
|
-
|
|
48
|
+
function requireAdmin(req, res, next) {
|
|
49
|
+
const sess = getSession(req);
|
|
50
|
+
if (!sess || sess.rank !== 'administrator') return res.status(403).json({ error: 'Forbidden.' });
|
|
51
|
+
req.sess = sess;
|
|
52
|
+
next();
|
|
25
53
|
}
|
|
26
54
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
55
|
+
const loginLimiter = rateLimit({
|
|
56
|
+
windowMs: 60 * 1000,
|
|
57
|
+
max: 5,
|
|
58
|
+
standardHeaders: true,
|
|
59
|
+
legacyHeaders: false,
|
|
60
|
+
handler: (_req, res) =>
|
|
61
|
+
res.status(429).json({ success: false, error: 'Too many attempts. Try again in 60 seconds.' }),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── Auth ───────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
app.post('/api/login', loginLimiter, async (req, res) => {
|
|
67
|
+
const { username, password } = req.body || {};
|
|
68
|
+
if (!username || !password) return res.status(400).json({ success: false });
|
|
69
|
+
const users = readUsers();
|
|
70
|
+
const user = users.find(u => u.username === username);
|
|
71
|
+
if (!user) return res.status(401).json({ success: false });
|
|
72
|
+
const match = await bcrypt.compare(password, user.password);
|
|
73
|
+
if (!match) return res.status(401).json({ success: false });
|
|
74
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
75
|
+
sessions.set(token, {
|
|
76
|
+
username: user.username,
|
|
77
|
+
rank: user.rank,
|
|
78
|
+
expires: Date.now() + 8 * 3600 * 1000,
|
|
31
79
|
});
|
|
32
|
-
res.
|
|
33
|
-
}
|
|
80
|
+
res.json({ success: true, rank: user.rank, token, forcePasswordChange: !!user.forcePasswordChange });
|
|
81
|
+
});
|
|
34
82
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
83
|
+
// ── Games ──────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
app.get('/api/games', (_req, res) => {
|
|
86
|
+
res.json(readGames().filter(g => g.visible));
|
|
87
|
+
});
|
|
40
88
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
res.writeHead(204, {
|
|
44
|
-
'Access-Control-Allow-Origin': '*',
|
|
45
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
46
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
47
|
-
});
|
|
48
|
-
return res.end();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// GET /api/games — visible games for browse page
|
|
52
|
-
if (req.method === 'GET' && req.url === '/api/games') {
|
|
53
|
-
return json(res, 200, readGames().filter(g => g.visible));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// GET /api/games/all — all games for admin
|
|
57
|
-
if (req.method === 'GET' && req.url === '/api/games/all') {
|
|
58
|
-
return json(res, 200, readGames());
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// POST /api/games — full save (admin only)
|
|
62
|
-
if (req.method === 'POST' && req.url === '/api/games') {
|
|
63
|
-
return parseBody(req, data => {
|
|
64
|
-
if (!data || data.adminPin !== readPins().admin)
|
|
65
|
-
return json(res, 403, { error: 'Unauthorized.' });
|
|
66
|
-
if (!Array.isArray(data.games))
|
|
67
|
-
return json(res, 400, { error: 'Invalid payload.' });
|
|
68
|
-
writeGames(data.games);
|
|
69
|
-
json(res, 200, { ok: true });
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// POST /api/pin — validate PIN, return role
|
|
74
|
-
if (req.method === 'POST' && req.url === '/api/pin') {
|
|
75
|
-
return parseBody(req, data => {
|
|
76
|
-
if (!data) return json(res, 400, { error: 'Bad request.' });
|
|
77
|
-
const pins = readPins();
|
|
78
|
-
if (data.pin === pins.admin) return json(res, 200, { role: 'admin' });
|
|
79
|
-
if (data.pin === pins.user) return json(res, 200, { role: 'user' });
|
|
80
|
-
return json(res, 403, { role: 'none' });
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// POST /api/pins — update PINs (admin only)
|
|
85
|
-
if (req.method === 'POST' && req.url === '/api/pins') {
|
|
86
|
-
return parseBody(req, data => {
|
|
87
|
-
if (!data) return json(res, 400, { error: 'Bad request.' });
|
|
88
|
-
const pins = readPins();
|
|
89
|
-
if (data.adminPin !== pins.admin) return json(res, 403, { error: 'Unauthorized.' });
|
|
90
|
-
const newAdmin = String(data.newAdmin || '').trim();
|
|
91
|
-
const newUser = String(data.newUser || '').trim();
|
|
92
|
-
if (newAdmin && !/^\d{4,8}$/.test(newAdmin)) return json(res, 400, { error: 'Admin PIN must be 4–8 digits.' });
|
|
93
|
-
if (newUser && !/^\d{4,8}$/.test(newUser)) return json(res, 400, { error: 'User PIN must be 4–8 digits.' });
|
|
94
|
-
if (newAdmin) pins.admin = newAdmin;
|
|
95
|
-
if (newUser) pins.user = newUser;
|
|
96
|
-
writePins(pins);
|
|
97
|
-
json(res, 200, { ok: true, pins: { admin: !!newAdmin, user: !!newUser } });
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
json(res, 404, { error: 'Not found.' });
|
|
89
|
+
app.get('/api/games/all', requireAdmin, (_req, res) => {
|
|
90
|
+
res.json(readGames());
|
|
102
91
|
});
|
|
103
92
|
|
|
104
|
-
|
|
105
|
-
|
|
93
|
+
app.post('/api/games', requireAdmin, (req, res) => {
|
|
94
|
+
if (!Array.isArray(req.body.games)) return res.status(400).json({ error: 'Invalid payload.' });
|
|
95
|
+
writeGames(req.body.games);
|
|
96
|
+
res.json({ ok: true });
|
|
106
97
|
});
|
|
98
|
+
|
|
99
|
+
// ── Users ──────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
const VALID_RANKS = ['administrator', 'admin', 'user'];
|
|
102
|
+
|
|
103
|
+
app.get('/api/users', requireAdmin, (_req, res) => {
|
|
104
|
+
const users = readUsers().map(u => ({ username: u.username, rank: u.rank, forcePasswordChange: !!u.forcePasswordChange }));
|
|
105
|
+
res.json(users);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
app.post('/api/users/add', requireAdmin, async (req, res) => {
|
|
109
|
+
const { username, password, rank } = req.body || {};
|
|
110
|
+
if (!username || !password || !VALID_RANKS.includes(rank))
|
|
111
|
+
return res.status(400).json({ error: 'username, password and valid rank required.' });
|
|
112
|
+
const users = readUsers();
|
|
113
|
+
if (users.find(u => u.username === username))
|
|
114
|
+
return res.status(409).json({ error: 'Username already exists.' });
|
|
115
|
+
const hashed = await bcrypt.hash(password, 12);
|
|
116
|
+
users.push({ username, password: hashed, rank });
|
|
117
|
+
writeUsers(users);
|
|
118
|
+
res.json({ ok: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
app.post('/api/users/update', requireAdmin, (req, res) => {
|
|
122
|
+
const { username, rank } = req.body || {};
|
|
123
|
+
if (!username || !VALID_RANKS.includes(rank))
|
|
124
|
+
return res.status(400).json({ error: 'username and valid rank required.' });
|
|
125
|
+
const users = readUsers();
|
|
126
|
+
const user = users.find(u => u.username === username);
|
|
127
|
+
if (!user) return res.status(404).json({ error: 'User not found.' });
|
|
128
|
+
user.rank = rank;
|
|
129
|
+
writeUsers(users);
|
|
130
|
+
res.json({ ok: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
app.post('/api/users/delete', requireAdmin, (req, res) => {
|
|
134
|
+
const { username } = req.body || {};
|
|
135
|
+
if (!username) return res.status(400).json({ error: 'username required.' });
|
|
136
|
+
const users = readUsers();
|
|
137
|
+
const idx = users.findIndex(u => u.username === username);
|
|
138
|
+
if (idx === -1) return res.status(404).json({ error: 'User not found.' });
|
|
139
|
+
users.splice(idx, 1);
|
|
140
|
+
writeUsers(users);
|
|
141
|
+
res.json({ ok: true });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
app.post('/api/users/reset-password', requireAdmin, async (req, res) => {
|
|
145
|
+
const { username, password } = req.body || {};
|
|
146
|
+
if (!username || !password) return res.status(400).json({ error: 'username and password required.' });
|
|
147
|
+
const users = readUsers();
|
|
148
|
+
const user = users.find(u => u.username === username);
|
|
149
|
+
if (!user) return res.status(404).json({ error: 'User not found.' });
|
|
150
|
+
user.password = await bcrypt.hash(password, 12);
|
|
151
|
+
writeUsers(users);
|
|
152
|
+
res.json({ ok: true });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
app.post('/api/users/change-password', async (req, res) => {
|
|
156
|
+
const { username, currentPassword, newPassword } = req.body || {};
|
|
157
|
+
if (!username || !currentPassword || !newPassword)
|
|
158
|
+
return res.status(400).json({ error: 'All fields required.' });
|
|
159
|
+
if (newPassword.length < 6)
|
|
160
|
+
return res.status(400).json({ error: 'New password must be at least 6 characters.' });
|
|
161
|
+
const users = readUsers();
|
|
162
|
+
const user = users.find(u => u.username === username);
|
|
163
|
+
if (!user) return res.status(404).json({ error: 'User not found.' });
|
|
164
|
+
const match = await bcrypt.compare(currentPassword, user.password);
|
|
165
|
+
if (!match) return res.status(401).json({ error: 'Current password is incorrect.' });
|
|
166
|
+
user.password = await bcrypt.hash(newPassword, 12);
|
|
167
|
+
user.forcePasswordChange = false;
|
|
168
|
+
writeUsers(users);
|
|
169
|
+
res.json({ ok: true });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
app.post('/api/users/set-force-change', requireAdmin, (req, res) => {
|
|
173
|
+
const { username, force } = req.body || {};
|
|
174
|
+
if (!username || typeof force !== 'boolean')
|
|
175
|
+
return res.status(400).json({ error: 'username and force (boolean) required.' });
|
|
176
|
+
const users = readUsers();
|
|
177
|
+
const user = users.find(u => u.username === username);
|
|
178
|
+
if (!user) return res.status(404).json({ error: 'User not found.' });
|
|
179
|
+
user.forcePasswordChange = force;
|
|
180
|
+
writeUsers(users);
|
|
181
|
+
res.json({ ok: true });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
app.listen(PORT, '127.0.0.1', () => console.log(`API listening on 127.0.0.1:${PORT}`));
|
|
Binary file
|
package/assets/logo.png
CHANGED
|
Binary file
|
package/browse.html
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
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;}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
|
+
<link rel="icon" type="image/png" href="/assets/favicon.png">
|
|
5
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
8
|
<title>MM Games</title>
|
|
7
9
|
<link rel="stylesheet" href="./css/style.css">
|
|
@@ -11,17 +13,45 @@
|
|
|
11
13
|
<header class="site-header">
|
|
12
14
|
<div class="header-inner">
|
|
13
15
|
<a href="./browse.html" class="logo-link">
|
|
14
|
-
<img src="./assets/logo.png" alt="" class="logo-img" onerror="this.style.display='none'">
|
|
15
|
-
<span>MM Games</span
|
|
16
|
+
<img src="./assets/logo.png?v=new" alt="" class="logo-img" onerror="this.style.display='none'">
|
|
17
|
+
<span>MM Games</span>
|
|
18
|
+
|
|
16
19
|
</a>
|
|
17
20
|
<nav class="site-nav">
|
|
18
21
|
<a href="./browse.html" class="active">Browse</a>
|
|
19
22
|
<a href="./admin.html">Admin</a>
|
|
20
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>
|
|
21
25
|
</nav>
|
|
22
26
|
</div>
|
|
23
27
|
</header>
|
|
24
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;">×</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
|
+
|
|
25
55
|
<main style="padding: 32px 0 64px;">
|
|
26
56
|
<div class="container">
|
|
27
57
|
|
|
@@ -53,10 +83,96 @@
|
|
|
53
83
|
<div class="game-grid" id="game-grid"></div>
|
|
54
84
|
</div>
|
|
55
85
|
|
|
86
|
+
|
|
56
87
|
</div>
|
|
57
88
|
</main>
|
|
58
89
|
|
|
59
|
-
<script src="./js/games.js?v=
|
|
60
|
-
<script src="./js/pin-modal.js?v=
|
|
90
|
+
<script src="./js/games.js?v=1.0.3"></script>
|
|
91
|
+
<script src="./js/pin-modal.js?v=1.0.3c"></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>
|
|
94
|
+
|
|
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;">↑</button>
|
|
96
|
+
<script>
|
|
97
|
+
(function(){
|
|
98
|
+
var btn=document.getElementById('scroll-top');
|
|
99
|
+
window.addEventListener('scroll',function(){
|
|
100
|
+
var show=window.scrollY>300;
|
|
101
|
+
btn.style.opacity=show?'1':'0';
|
|
102
|
+
btn.style.transform=show?'translateY(0)':'translateY(8px)';
|
|
103
|
+
btn.style.pointerEvents=show?'auto':'none';
|
|
104
|
+
},{passive:true});
|
|
105
|
+
})();
|
|
106
|
+
</script>
|
|
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
|
+
|
|
61
177
|
</body>
|
|
62
178
|
</html>
|
package/bump.sh
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
VER=$(cat /var/www/moshelab/version.txt | tr -d '[:space:]')
|
|
3
|
-
|
|
3
|
+
VERNUM="${VER#v}"
|
|
4
|
+
# Update visible version badges (vX.X.X)
|
|
5
|
+
sudo sed -i "s/v[0-9][0-9.]*/$VER/g" /var/www/moshelab/*.html
|
|
6
|
+
# Update script/link cache-buster query params (?v=X.X.X)
|
|
7
|
+
sudo sed -i "s/?v=[0-9][0-9.]*/?v=$VERNUM/g" /var/www/moshelab/*.html
|
|
4
8
|
echo "Version set to $VER"
|