skopix 2.0.94 → 2.0.95
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/cli/commands/agent.js +24 -6
- package/cli/commands/dashboard.js +197 -37
- package/cli/index.js +1 -0
- package/core/auth.js +99 -0
- package/core/db.js +137 -1
- package/package.json +1 -1
- package/web/app/index.html +100 -0
- package/web/login.html +155 -224
package/cli/commands/agent.js
CHANGED
|
@@ -93,6 +93,7 @@ async function promptAndAuth(serverUrl, secretKey) {
|
|
|
93
93
|
export async function agentCommand(options) {
|
|
94
94
|
const serverUrl = (options.server || process.env.SKOPIX_SERVER_URL || '').replace(/\/$/, '');
|
|
95
95
|
const secretKey = options.key || process.env.SKOPIX_SECRET_KEY;
|
|
96
|
+
const apiKey = options.apiKey || process.env.SKOPIX_AGENT_API_KEY;
|
|
96
97
|
const agentName = options.name || os.hostname();
|
|
97
98
|
const machine = os.hostname() + ' (' + os.platform() + ')';
|
|
98
99
|
const agentId = crypto.randomUUID();
|
|
@@ -120,13 +121,30 @@ export async function agentCommand(options) {
|
|
|
120
121
|
let userName = agentName;
|
|
121
122
|
|
|
122
123
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
// API key takes priority — no interactive prompt needed
|
|
125
|
+
if (apiKey) {
|
|
126
|
+
const res = await fetch(serverUrl + '/api/agent/auth-apikey', {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json', 'x-skopix-key': secretKey, 'Authorization': 'Bearer ' + apiKey },
|
|
129
|
+
body: JSON.stringify({}),
|
|
130
|
+
});
|
|
131
|
+
if (res.ok) {
|
|
132
|
+
const data = await res.json();
|
|
133
|
+
userId = data.userId;
|
|
134
|
+
userName = data.name || agentName;
|
|
135
|
+
console.log(chalk.green(' ✔ Authenticated via API key as ') + chalk.white(userName));
|
|
136
|
+
} else {
|
|
137
|
+
throw new Error('API key authentication failed');
|
|
138
|
+
}
|
|
128
139
|
} else {
|
|
129
|
-
|
|
140
|
+
const authData = await promptAndAuth(serverUrl, secretKey);
|
|
141
|
+
userId = authData.userId;
|
|
142
|
+
userName = authData.name || agentName;
|
|
143
|
+
if (userId) {
|
|
144
|
+
console.log(chalk.green(' ✔ Authenticated as ') + chalk.white(userName));
|
|
145
|
+
} else {
|
|
146
|
+
console.log(chalk.cyan(' ◆ Solo mode — no user auth needed'));
|
|
147
|
+
}
|
|
130
148
|
}
|
|
131
149
|
} catch (err) {
|
|
132
150
|
console.log(chalk.yellow(' ⚠ Could not authenticate: ' + err.message));
|
|
@@ -160,7 +160,6 @@ export async function dashboardCommand(options) {
|
|
|
160
160
|
return;
|
|
161
161
|
}
|
|
162
162
|
const user = teamMode.db.getUserByEmail(email.trim().toLowerCase());
|
|
163
|
-
// Use generic error to avoid leaking which emails are registered
|
|
164
163
|
const fail = () => sendJSON(res, 401, { error: 'Invalid email or password' });
|
|
165
164
|
if (!user) { fail(); return; }
|
|
166
165
|
if (user.status !== 'active') {
|
|
@@ -170,47 +169,145 @@ export async function dashboardCommand(options) {
|
|
|
170
169
|
const ok = await teamMode.auth.verifyPassword(password, user.password_hash);
|
|
171
170
|
if (!ok) { fail(); return; }
|
|
172
171
|
|
|
173
|
-
//
|
|
172
|
+
// If MFA is enabled, create a pending session and require TOTP
|
|
173
|
+
if (user.mfa_enabled && user.mfa_secret) {
|
|
174
|
+
const pendingToken = teamMode.auth.generateSessionToken();
|
|
175
|
+
teamMode.db.createMfaPending({
|
|
176
|
+
token: pendingToken,
|
|
177
|
+
userId: user.id,
|
|
178
|
+
ipAddress: req.socket.remoteAddress,
|
|
179
|
+
userAgent: req.headers['user-agent'] || null,
|
|
180
|
+
});
|
|
181
|
+
sendJSON(res, 200, { mfaRequired: true, pendingToken });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// If MFA not enrolled yet — force enrollment
|
|
186
|
+
if (!user.mfa_enabled) {
|
|
187
|
+
const pendingToken = teamMode.auth.generateSessionToken();
|
|
188
|
+
teamMode.db.createMfaPending({
|
|
189
|
+
token: pendingToken,
|
|
190
|
+
userId: user.id,
|
|
191
|
+
ipAddress: req.socket.remoteAddress,
|
|
192
|
+
userAgent: req.headers['user-agent'] || null,
|
|
193
|
+
});
|
|
194
|
+
sendJSON(res, 200, { mfaEnrollRequired: true, pendingToken });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Create session (should not reach here normally)
|
|
174
199
|
const token = teamMode.auth.generateSessionToken();
|
|
175
|
-
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
176
|
-
teamMode.db.createWebSession({
|
|
177
|
-
token,
|
|
178
|
-
userId: user.id,
|
|
179
|
-
expiresAt,
|
|
180
|
-
ipAddress: req.socket.remoteAddress,
|
|
181
|
-
userAgent: req.headers['user-agent'] || null,
|
|
182
|
-
});
|
|
200
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
201
|
+
teamMode.db.createWebSession({ token, userId: user.id, expiresAt, ipAddress: req.socket.remoteAddress, userAgent: req.headers['user-agent'] || null });
|
|
183
202
|
teamMode.db.updateUserLastLogin(user.id);
|
|
184
|
-
teamMode.db.logAudit({
|
|
185
|
-
userId: user.id,
|
|
186
|
-
action: 'user.login',
|
|
187
|
-
targetType: 'user',
|
|
188
|
-
targetId: user.id,
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
// Set HTTP-only cookie
|
|
192
|
-
// Note: SameSite=Lax allows the cookie to flow on same-site navigations.
|
|
193
|
-
// We don't set Secure unless the request was HTTPS (so localhost still works).
|
|
203
|
+
teamMode.db.logAudit({ userId: user.id, action: 'user.login', targetType: 'user', targetId: user.id });
|
|
194
204
|
const isHttps = req.headers['x-forwarded-proto'] === 'https' || req.connection.encrypted === true;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
'Path=/',
|
|
198
|
-
'HttpOnly',
|
|
199
|
-
'SameSite=Lax',
|
|
200
|
-
`Max-Age=${30 * 24 * 60 * 60}`,
|
|
201
|
-
...(isHttps ? ['Secure'] : []),
|
|
202
|
-
];
|
|
203
|
-
res.setHeader('Set-Cookie', cookieAttrs.join('; '));
|
|
204
|
-
sendJSON(res, 200, {
|
|
205
|
-
ok: true,
|
|
206
|
-
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
207
|
-
});
|
|
205
|
+
res.setHeader('Set-Cookie', [`skopix_session=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${30 * 24 * 60 * 60}`, ...(isHttps ? ['Secure'] : [])].join('; '));
|
|
206
|
+
sendJSON(res, 200, { ok: true, user: { id: user.id, email: user.email, name: user.name, role: user.role } });
|
|
208
207
|
} catch (err) {
|
|
209
208
|
sendJSON(res, 500, { error: err.message });
|
|
210
209
|
}
|
|
211
210
|
return;
|
|
212
211
|
}
|
|
213
212
|
|
|
213
|
+
// ─── MFA: VERIFY TOTP (after password OK) ────────────────────────────
|
|
214
|
+
if (pathname === '/api/auth/mfa-verify' && method === 'POST') {
|
|
215
|
+
try {
|
|
216
|
+
const { pendingToken, totpCode } = JSON.parse(await readBody(req));
|
|
217
|
+
if (!pendingToken || !totpCode) { sendJSON(res, 400, { error: 'Missing fields' }); return; }
|
|
218
|
+
const pending = teamMode.db.getMfaPending(pendingToken);
|
|
219
|
+
if (!pending) { sendJSON(res, 401, { error: 'Session expired. Please sign in again.' }); return; }
|
|
220
|
+
const user = teamMode.db.getUserById(pending.user_id);
|
|
221
|
+
if (!user || user.status !== 'active') { sendJSON(res, 401, { error: 'Account not found or disabled.' }); return; }
|
|
222
|
+
if (!teamMode.auth.verifyTotp(user.mfa_secret, totpCode)) {
|
|
223
|
+
sendJSON(res, 401, { error: 'Invalid authentication code. Please try again.' }); return;
|
|
224
|
+
}
|
|
225
|
+
// TOTP OK — create real session
|
|
226
|
+
teamMode.db.deleteMfaPending(pendingToken);
|
|
227
|
+
const token = teamMode.auth.generateSessionToken();
|
|
228
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
229
|
+
teamMode.db.createWebSession({ token, userId: user.id, expiresAt, ipAddress: req.socket.remoteAddress, userAgent: req.headers['user-agent'] || null });
|
|
230
|
+
teamMode.db.updateUserLastLogin(user.id);
|
|
231
|
+
teamMode.db.logAudit({ userId: user.id, action: 'user.login', targetType: 'user', targetId: user.id, metadata: { mfa: true } });
|
|
232
|
+
const isHttps = req.headers['x-forwarded-proto'] === 'https' || req.connection.encrypted === true;
|
|
233
|
+
res.setHeader('Set-Cookie', [`skopix_session=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${30 * 24 * 60 * 60}`, ...(isHttps ? ['Secure'] : [])].join('; '));
|
|
234
|
+
sendJSON(res, 200, { ok: true, user: { id: user.id, email: user.email, name: user.name, role: user.role } });
|
|
235
|
+
} catch (err) { sendJSON(res, 500, { error: err.message }); }
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── MFA: START ENROLLMENT (get QR code) ─────────────────────────────
|
|
240
|
+
if (pathname === '/api/auth/mfa-enroll' && method === 'POST') {
|
|
241
|
+
try {
|
|
242
|
+
const { pendingToken } = JSON.parse(await readBody(req));
|
|
243
|
+
if (!pendingToken) { sendJSON(res, 400, { error: 'Missing pendingToken' }); return; }
|
|
244
|
+
const pending = teamMode.db.getMfaPending(pendingToken);
|
|
245
|
+
if (!pending) { sendJSON(res, 401, { error: 'Session expired. Please sign in again.' }); return; }
|
|
246
|
+
const user = teamMode.db.getUserById(pending.user_id);
|
|
247
|
+
if (!user) { sendJSON(res, 401, { error: 'User not found' }); return; }
|
|
248
|
+
// Generate new TOTP secret
|
|
249
|
+
const secret = teamMode.auth.generateTotpSecret();
|
|
250
|
+
teamMode.db.setMfaSecret(user.id, secret);
|
|
251
|
+
const otpauthUrl = teamMode.auth.getTotpQrUrl(secret, user.email);
|
|
252
|
+
// QR code as URL using Google Charts API (no server-side dependency)
|
|
253
|
+
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(otpauthUrl)}`;
|
|
254
|
+
sendJSON(res, 200, { secret, otpauthUrl, qrUrl });
|
|
255
|
+
} catch (err) { sendJSON(res, 500, { error: err.message }); }
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── MFA: CONFIRM ENROLLMENT ──────────────────────────────────────────
|
|
260
|
+
if (pathname === '/api/auth/mfa-enroll-confirm' && method === 'POST') {
|
|
261
|
+
try {
|
|
262
|
+
const { pendingToken, totpCode } = JSON.parse(await readBody(req));
|
|
263
|
+
if (!pendingToken || !totpCode) { sendJSON(res, 400, { error: 'Missing fields' }); return; }
|
|
264
|
+
const pending = teamMode.db.getMfaPending(pendingToken);
|
|
265
|
+
if (!pending) { sendJSON(res, 401, { error: 'Session expired. Please sign in again.' }); return; }
|
|
266
|
+
const user = teamMode.db.getUserById(pending.user_id);
|
|
267
|
+
if (!user) { sendJSON(res, 401, { error: 'User not found' }); return; }
|
|
268
|
+
if (!teamMode.auth.verifyTotp(user.mfa_secret, totpCode)) {
|
|
269
|
+
sendJSON(res, 401, { error: 'Invalid code. Check your authenticator app and try again.' }); return;
|
|
270
|
+
}
|
|
271
|
+
// Enable MFA
|
|
272
|
+
teamMode.db.enableMfa(user.id);
|
|
273
|
+
teamMode.db.deleteMfaPending(pendingToken);
|
|
274
|
+
// Create real session
|
|
275
|
+
const token = teamMode.auth.generateSessionToken();
|
|
276
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
277
|
+
teamMode.db.createWebSession({ token, userId: user.id, expiresAt, ipAddress: req.socket.remoteAddress, userAgent: req.headers['user-agent'] || null });
|
|
278
|
+
teamMode.db.updateUserLastLogin(user.id);
|
|
279
|
+
teamMode.db.logAudit({ userId: user.id, action: 'user.mfa_enrolled', targetType: 'user', targetId: user.id });
|
|
280
|
+
const isHttps = req.headers['x-forwarded-proto'] === 'https' || req.connection.encrypted === true;
|
|
281
|
+
res.setHeader('Set-Cookie', [`skopix_session=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${30 * 24 * 60 * 60}`, ...(isHttps ? ['Secure'] : [])].join('; '));
|
|
282
|
+
sendJSON(res, 200, { ok: true, user: { id: user.id, email: user.email, name: user.name, role: user.role } });
|
|
283
|
+
} catch (err) { sendJSON(res, 500, { error: err.message }); }
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── MFA: ADMIN RESET (removes MFA for a user) ───────────────────────
|
|
288
|
+
if (pathname.match(/^\/api\/users\/[^/]+\/mfa-reset$/) && method === 'POST') {
|
|
289
|
+
if (!currentUser || currentUser.role !== 'admin') { sendJSON(res, 403, { error: 'Admin only' }); return; }
|
|
290
|
+
const userId = pathname.split('/')[3];
|
|
291
|
+
teamMode.db.disableMfa(userId);
|
|
292
|
+
teamMode.db.logAudit({ userId: currentUser.id, action: 'user.mfa_reset', targetType: 'user', targetId: userId });
|
|
293
|
+
sendJSON(res, 200, { ok: true });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── MFA: USER DISABLE (from settings, requires current TOTP) ────────
|
|
298
|
+
if (pathname === '/api/auth/mfa-disable' && method === 'POST') {
|
|
299
|
+
if (!currentUser) { sendJSON(res, 401, { error: 'Not authenticated' }); return; }
|
|
300
|
+
const { totpCode } = JSON.parse(await readBody(req));
|
|
301
|
+
const user = teamMode.db.getUserById(currentUser.id);
|
|
302
|
+
if (!teamMode.auth.verifyTotp(user.mfa_secret, totpCode)) {
|
|
303
|
+
sendJSON(res, 401, { error: 'Invalid authentication code.' }); return;
|
|
304
|
+
}
|
|
305
|
+
teamMode.db.disableMfa(currentUser.id);
|
|
306
|
+
teamMode.db.logAudit({ userId: currentUser.id, action: 'user.mfa_disabled', targetType: 'user', targetId: currentUser.id });
|
|
307
|
+
sendJSON(res, 200, { ok: true });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
214
311
|
// ─── AUTH: LOGOUT ────────────────────────────────────────────────
|
|
215
312
|
if (pathname === '/api/auth/logout' && method === 'POST') {
|
|
216
313
|
const token = parseCookie(req.headers.cookie || '', 'skopix_session');
|
|
@@ -503,6 +600,36 @@ export async function dashboardCommand(options) {
|
|
|
503
600
|
return;
|
|
504
601
|
}
|
|
505
602
|
|
|
603
|
+
// ─── API KEYS ─────────────────────────────────────────────────────
|
|
604
|
+
if (pathname === '/api/api-keys' && method === 'GET') {
|
|
605
|
+
if (!currentUser) { sendJSON(res, 401, { error: 'Not authenticated' }); return; }
|
|
606
|
+
sendJSON(res, 200, teamMode.db.listApiKeys(currentUser.id));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (pathname === '/api/api-keys' && method === 'POST') {
|
|
610
|
+
if (!currentUser) { sendJSON(res, 401, { error: 'Not authenticated' }); return; }
|
|
611
|
+
const { name } = JSON.parse(await readBody(req));
|
|
612
|
+
if (!name || typeof name !== 'string') { sendJSON(res, 400, { error: 'Name required' }); return; }
|
|
613
|
+
const rawKey = teamMode.auth.generateApiKey();
|
|
614
|
+
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
|
615
|
+
const keyPrefix = rawKey.slice(0, 12) + '...';
|
|
616
|
+
const id = teamMode.auth.generateApiKeyId();
|
|
617
|
+
teamMode.db.createApiKey({ id, userId: currentUser.id, name, keyHash, keyPrefix });
|
|
618
|
+
teamMode.db.logAudit({ userId: currentUser.id, action: 'api_key.created', targetType: 'api_key', targetId: id, metadata: { name } });
|
|
619
|
+
// Return raw key only once
|
|
620
|
+
sendJSON(res, 200, { id, name, keyPrefix, key: rawKey, createdAt: new Date().toISOString() });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (pathname.match(/^\/api\/api-keys\/[^/]+$/) && method === 'DELETE') {
|
|
624
|
+
if (!currentUser) { sendJSON(res, 401, { error: 'Not authenticated' }); return; }
|
|
625
|
+
const keyId = pathname.split('/')[3];
|
|
626
|
+
const deleted = teamMode.db.deleteApiKey(keyId, currentUser.id);
|
|
627
|
+
if (!deleted) { sendJSON(res, 404, { error: 'Not found' }); return; }
|
|
628
|
+
teamMode.db.logAudit({ userId: currentUser.id, action: 'api_key.deleted', targetType: 'api_key', targetId: keyId });
|
|
629
|
+
sendJSON(res, 200, { deleted: true });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
506
633
|
// ─── ACTIVE SESSIONS (admin only) ────────────────────────────────
|
|
507
634
|
if (pathname === '/api/sessions/active' && method === 'GET') {
|
|
508
635
|
if (!currentUser || !teamMode.auth.canManageUsers(currentUser.role)) {
|
|
@@ -880,7 +1007,22 @@ export async function dashboardCommand(options) {
|
|
|
880
1007
|
|
|
881
1008
|
// ─── RUN ───────────────────────────────────────────────────────────
|
|
882
1009
|
// ─── RECORDER ENDPOINTS ──────────────────────────────────────────────────
|
|
883
|
-
// POST /api/agent/auth — agents
|
|
1010
|
+
// POST /api/agent/auth-apikey — agents authenticate via API key
|
|
1011
|
+
if (pathname === '/api/agent/auth-apikey' && method === 'POST') {
|
|
1012
|
+
const authHeader = req.headers['authorization'] || '';
|
|
1013
|
+
if (!authHeader.startsWith('Bearer sk_live_')) {
|
|
1014
|
+
sendJSON(res, 401, { error: 'API key required in Authorization header' }); return;
|
|
1015
|
+
}
|
|
1016
|
+
const apiKey = authHeader.slice(7);
|
|
1017
|
+
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
|
1018
|
+
const keyRow = teamMode.db.getApiKeyByHash(keyHash);
|
|
1019
|
+
if (!keyRow) { sendJSON(res, 401, { error: 'Invalid or expired API key' }); return; }
|
|
1020
|
+
const user = teamMode.db.getUserById(keyRow.user_id);
|
|
1021
|
+
if (!user || user.status !== 'active') { sendJSON(res, 401, { error: 'User not found or disabled' }); return; }
|
|
1022
|
+
teamMode.db.touchApiKey(keyRow.id);
|
|
1023
|
+
sendJSON(res, 200, { userId: user.id, name: user.name, email: user.email });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
884
1026
|
// Returns { userId, name } so the agent can register with the correct userId
|
|
885
1027
|
// Uses same secret key as header auth (already validated above)
|
|
886
1028
|
if (pathname === '/api/agent/auth' && method === 'POST') {
|
|
@@ -2390,13 +2532,27 @@ function parseCookie(cookieHeader, name) {
|
|
|
2390
2532
|
// Pass `tm` (the teamMode object) so this works without globals.
|
|
2391
2533
|
function resolveCurrentUser(req, tm) {
|
|
2392
2534
|
if (!tm) return null;
|
|
2535
|
+
|
|
2536
|
+
// Check for API key in Authorization header (for agents)
|
|
2537
|
+
const authHeader = req.headers['authorization'] || '';
|
|
2538
|
+
if (authHeader.startsWith('Bearer sk_live_')) {
|
|
2539
|
+
const apiKey = authHeader.slice(7);
|
|
2540
|
+
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
|
2541
|
+
const keyRow = tm.db.getApiKeyByHash(keyHash);
|
|
2542
|
+
if (!keyRow) return null;
|
|
2543
|
+
const user = tm.db.getUserById(keyRow.user_id);
|
|
2544
|
+
if (!user || user.status !== 'active') return null;
|
|
2545
|
+
tm.db.touchApiKey(keyRow.id);
|
|
2546
|
+
return { user, apiKeyId: keyRow.id };
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
// Check session cookie
|
|
2393
2550
|
const token = parseCookie(req.headers.cookie || '', 'skopix_session');
|
|
2394
2551
|
if (!token) return null;
|
|
2395
2552
|
const session = tm.db.getWebSession(token);
|
|
2396
2553
|
if (!session) return null;
|
|
2397
2554
|
const user = tm.db.getUserById(session.user_id);
|
|
2398
2555
|
if (!user || user.status !== 'active') return null;
|
|
2399
|
-
// Touch the session so active users don't expire prematurely
|
|
2400
2556
|
tm.db.touchWebSession(token);
|
|
2401
2557
|
return { user, sessionToken: token };
|
|
2402
2558
|
}
|
|
@@ -2411,8 +2567,12 @@ function isPublicPath(pathname) {
|
|
|
2411
2567
|
'/api/setup',
|
|
2412
2568
|
'/api/auth/login',
|
|
2413
2569
|
'/api/auth/logout',
|
|
2414
|
-
'/api/auth/me',
|
|
2415
|
-
'/api/
|
|
2570
|
+
'/api/auth/me',
|
|
2571
|
+
'/api/auth/mfa-verify',
|
|
2572
|
+
'/api/auth/mfa-enroll',
|
|
2573
|
+
'/api/auth/mfa-enroll-confirm',
|
|
2574
|
+
'/api/agent/auth',
|
|
2575
|
+
'/api/agent/auth-apikey',
|
|
2416
2576
|
]);
|
|
2417
2577
|
if (publicApis.has(pathname)) return true;
|
|
2418
2578
|
// Public invite endpoints: GET invite details + accept invite (no auth needed)
|
package/cli/index.js
CHANGED
|
@@ -83,6 +83,7 @@ program
|
|
|
83
83
|
.requiredOption('-s, --server <url>', 'Skopix server URL (e.g. http://192.168.1.45:9000)')
|
|
84
84
|
.requiredOption('-k, --key <key>', 'Secret key (same SKOPIX_SECRET_KEY as the server)')
|
|
85
85
|
.option('-n, --name <name>', 'Agent display name (defaults to hostname)')
|
|
86
|
+
.option('--api-key <key>', 'API key for agent auth (skips MFA, recommended for automated agents)')
|
|
86
87
|
.action(agentCommand);
|
|
87
88
|
|
|
88
89
|
// Short alias: "skopix start" — starts dashboard + agent in one command
|
package/core/auth.js
CHANGED
|
@@ -96,6 +96,105 @@ export function decryptSecret(stored) {
|
|
|
96
96
|
return decrypted.toString('utf8');
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// ─── TOTP (RFC 6238) — no external dependencies ──────────────────────────────
|
|
100
|
+
// Base32 alphabet used by TOTP secrets
|
|
101
|
+
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
102
|
+
|
|
103
|
+
export function generateTotpSecret() {
|
|
104
|
+
// 20 random bytes → base32 encoded = 32-char secret
|
|
105
|
+
const bytes = crypto.randomBytes(20);
|
|
106
|
+
let result = '';
|
|
107
|
+
let buffer = 0, bitsLeft = 0;
|
|
108
|
+
for (const byte of bytes) {
|
|
109
|
+
buffer = (buffer << 8) | byte;
|
|
110
|
+
bitsLeft += 8;
|
|
111
|
+
while (bitsLeft >= 5) {
|
|
112
|
+
result += BASE32_CHARS[(buffer >> (bitsLeft - 5)) & 31];
|
|
113
|
+
bitsLeft -= 5;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (bitsLeft > 0) result += BASE32_CHARS[(buffer << (5 - bitsLeft)) & 31];
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _base32Decode(str) {
|
|
121
|
+
const cleaned = str.toUpperCase().replace(/[^A-Z2-7]/g, '');
|
|
122
|
+
const bytes = [];
|
|
123
|
+
let buffer = 0, bitsLeft = 0;
|
|
124
|
+
for (const char of cleaned) {
|
|
125
|
+
const val = BASE32_CHARS.indexOf(char);
|
|
126
|
+
if (val === -1) continue;
|
|
127
|
+
buffer = (buffer << 5) | val;
|
|
128
|
+
bitsLeft += 5;
|
|
129
|
+
if (bitsLeft >= 8) {
|
|
130
|
+
bytes.push((buffer >> (bitsLeft - 8)) & 255);
|
|
131
|
+
bitsLeft -= 8;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return Buffer.from(bytes);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _hotp(secret, counter) {
|
|
138
|
+
const key = _base32Decode(secret);
|
|
139
|
+
// Counter as 8-byte big-endian buffer
|
|
140
|
+
const counterBuf = Buffer.alloc(8);
|
|
141
|
+
const hi = Math.floor(counter / 0x100000000);
|
|
142
|
+
const lo = counter >>> 0;
|
|
143
|
+
counterBuf.writeUInt32BE(hi, 0);
|
|
144
|
+
counterBuf.writeUInt32BE(lo, 4);
|
|
145
|
+
const hmac = crypto.createHmac('sha1', key).update(counterBuf).digest();
|
|
146
|
+
const offset = hmac[hmac.length - 1] & 0xf;
|
|
147
|
+
const code = ((hmac[offset] & 0x7f) << 24) |
|
|
148
|
+
((hmac[offset + 1] & 0xff) << 16) |
|
|
149
|
+
((hmac[offset + 2] & 0xff) << 8) |
|
|
150
|
+
(hmac[offset + 3] & 0xff);
|
|
151
|
+
return String(code % 1000000).padStart(6, '0');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getTotpToken(secret, time) {
|
|
155
|
+
const t = Math.floor((time || Date.now()) / 1000 / 30);
|
|
156
|
+
return _hotp(secret, t);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function verifyTotp(secret, token) {
|
|
160
|
+
if (!secret || !token || typeof token !== 'string') return false;
|
|
161
|
+
const cleaned = token.replace(/\s/g, '');
|
|
162
|
+
if (!/^\d{6}$/.test(cleaned)) return false;
|
|
163
|
+
const t = Math.floor(Date.now() / 1000 / 30);
|
|
164
|
+
// Check current window ±1 to allow for clock drift
|
|
165
|
+
for (const offset of [-1, 0, 1]) {
|
|
166
|
+
if (_hotp(secret, t + offset) === cleaned) return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function getTotpQrUrl(secret, email, issuer = 'Skopix') {
|
|
172
|
+
const label = encodeURIComponent(`${issuer}:${email}`);
|
|
173
|
+
const params = new URLSearchParams({
|
|
174
|
+
secret,
|
|
175
|
+
issuer,
|
|
176
|
+
algorithm: 'SHA1',
|
|
177
|
+
digits: '6',
|
|
178
|
+
period: '30',
|
|
179
|
+
});
|
|
180
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── API KEY GENERATION ───────────────────────────────────────────────────────
|
|
184
|
+
export function generateApiKey() {
|
|
185
|
+
// Format: sk_live_<32 random hex chars>
|
|
186
|
+
const raw = 'sk_live_' + crypto.randomBytes(24).toString('hex');
|
|
187
|
+
return raw;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function hashApiKey(key) {
|
|
191
|
+
return crypto.createHash('sha256').update(key).digest('hex');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function generateApiKeyId() {
|
|
195
|
+
return 'ak_' + crypto.randomBytes(8).toString('hex');
|
|
196
|
+
}
|
|
197
|
+
|
|
99
198
|
// ─── EMAIL VALIDATION ────────────────────────────────────────────────────────
|
|
100
199
|
export function isValidEmail(email) {
|
|
101
200
|
if (typeof email !== 'string') return false;
|
package/core/db.js
CHANGED
|
@@ -57,6 +57,56 @@ export function getDbPath() {
|
|
|
57
57
|
// Versioned schema migrations. To add a new one, append to the array.
|
|
58
58
|
// Existing migrations are immutable once shipped.
|
|
59
59
|
const MIGRATIONS = [
|
|
60
|
+
{
|
|
61
|
+
version: 3,
|
|
62
|
+
name: 'mfa totp',
|
|
63
|
+
up: (db) => {
|
|
64
|
+
db.exec(`
|
|
65
|
+
ALTER TABLE users ADD COLUMN mfa_secret TEXT;
|
|
66
|
+
ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0;
|
|
67
|
+
ALTER TABLE users ADD COLUMN mfa_enrolled_at TEXT;
|
|
68
|
+
`);
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
version: 4,
|
|
73
|
+
name: 'api keys',
|
|
74
|
+
up: (db) => {
|
|
75
|
+
db.exec(`
|
|
76
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
77
|
+
id TEXT PRIMARY KEY,
|
|
78
|
+
user_id TEXT NOT NULL,
|
|
79
|
+
name TEXT NOT NULL,
|
|
80
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
81
|
+
key_prefix TEXT NOT NULL,
|
|
82
|
+
created_at TEXT NOT NULL,
|
|
83
|
+
last_used_at TEXT,
|
|
84
|
+
expires_at TEXT,
|
|
85
|
+
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
86
|
+
);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
|
|
89
|
+
`);
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
version: 5,
|
|
94
|
+
name: 'mfa pending sessions',
|
|
95
|
+
up: (db) => {
|
|
96
|
+
db.exec(`
|
|
97
|
+
CREATE TABLE IF NOT EXISTS mfa_pending (
|
|
98
|
+
token TEXT PRIMARY KEY,
|
|
99
|
+
user_id TEXT NOT NULL,
|
|
100
|
+
created_at TEXT NOT NULL,
|
|
101
|
+
expires_at TEXT NOT NULL,
|
|
102
|
+
ip_address TEXT,
|
|
103
|
+
user_agent TEXT,
|
|
104
|
+
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
105
|
+
);
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_mfa_pending_user ON mfa_pending(user_id);
|
|
107
|
+
`);
|
|
108
|
+
},
|
|
109
|
+
},
|
|
60
110
|
{
|
|
61
111
|
version: 2,
|
|
62
112
|
name: 'password reset tokens',
|
|
@@ -185,7 +235,7 @@ export function getUserByEmail(email) {
|
|
|
185
235
|
|
|
186
236
|
export function listUsers() {
|
|
187
237
|
if (!_db) return [];
|
|
188
|
-
return _db.prepare('SELECT id, email, name, role, status, created_at, last_login_at FROM users ORDER BY created_at ASC').all();
|
|
238
|
+
return _db.prepare('SELECT id, email, name, role, status, created_at, last_login_at, mfa_enabled FROM users ORDER BY created_at ASC').all();
|
|
189
239
|
}
|
|
190
240
|
|
|
191
241
|
export function createUser({ id, email, name, passwordHash, role, status }) {
|
|
@@ -501,3 +551,89 @@ export function revokeAllUserSessions(userId) {
|
|
|
501
551
|
const result = _db.prepare('DELETE FROM web_sessions WHERE user_id = ?').run(userId);
|
|
502
552
|
return result.changes;
|
|
503
553
|
}
|
|
554
|
+
|
|
555
|
+
// ─── MFA (TOTP) ──────────────────────────────────────────────────────────────
|
|
556
|
+
export function setMfaSecret(userId, secret) {
|
|
557
|
+
if (!_db) throw new Error('DB not ready');
|
|
558
|
+
const now = new Date().toISOString();
|
|
559
|
+
_db.prepare('UPDATE users SET mfa_secret = ?, mfa_enabled = 0, updated_at = ? WHERE id = ?').run(secret, now, userId);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function enableMfa(userId) {
|
|
563
|
+
if (!_db) throw new Error('DB not ready');
|
|
564
|
+
const now = new Date().toISOString();
|
|
565
|
+
_db.prepare('UPDATE users SET mfa_enabled = 1, mfa_enrolled_at = ?, updated_at = ? WHERE id = ?').run(now, now, userId);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function disableMfa(userId) {
|
|
569
|
+
if (!_db) throw new Error('DB not ready');
|
|
570
|
+
const now = new Date().toISOString();
|
|
571
|
+
_db.prepare('UPDATE users SET mfa_secret = NULL, mfa_enabled = 0, mfa_enrolled_at = NULL, updated_at = ? WHERE id = ?').run(now, userId);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// MFA pending sessions — created after password OK, before TOTP verified
|
|
575
|
+
export function createMfaPending({ token, userId, ipAddress, userAgent }) {
|
|
576
|
+
if (!_db) throw new Error('DB not ready');
|
|
577
|
+
const now = new Date().toISOString();
|
|
578
|
+
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); // 10 mins
|
|
579
|
+
_db.prepare(`
|
|
580
|
+
INSERT INTO mfa_pending (token, user_id, created_at, expires_at, ip_address, user_agent)
|
|
581
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
582
|
+
`).run(token, userId, now, expiresAt, ipAddress || null, userAgent || null);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function getMfaPending(token) {
|
|
586
|
+
if (!_db) return null;
|
|
587
|
+
const row = _db.prepare('SELECT * FROM mfa_pending WHERE token = ?').get(token);
|
|
588
|
+
if (!row) return null;
|
|
589
|
+
if (new Date(row.expires_at) < new Date()) {
|
|
590
|
+
_db.prepare('DELETE FROM mfa_pending WHERE token = ?').run(token);
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
return row;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function deleteMfaPending(token) {
|
|
597
|
+
if (!_db) return;
|
|
598
|
+
_db.prepare('DELETE FROM mfa_pending WHERE token = ?').run(token);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ─── API KEYS ─────────────────────────────────────────────────────────────────
|
|
602
|
+
export function createApiKey({ id, userId, name, keyHash, keyPrefix, expiresAt }) {
|
|
603
|
+
if (!_db) throw new Error('DB not ready');
|
|
604
|
+
const now = new Date().toISOString();
|
|
605
|
+
_db.prepare(`
|
|
606
|
+
INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, created_at, expires_at)
|
|
607
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
608
|
+
`).run(id, userId, name, keyHash, keyPrefix, now, expiresAt || null);
|
|
609
|
+
return getApiKeyById(id);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function getApiKeyById(id) {
|
|
613
|
+
if (!_db) return null;
|
|
614
|
+
return _db.prepare('SELECT * FROM api_keys WHERE id = ?').get(id);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export function getApiKeyByHash(keyHash) {
|
|
618
|
+
if (!_db) return null;
|
|
619
|
+
const row = _db.prepare('SELECT * FROM api_keys WHERE key_hash = ?').get(keyHash);
|
|
620
|
+
if (!row) return null;
|
|
621
|
+
if (row.expires_at && new Date(row.expires_at) < new Date()) return null;
|
|
622
|
+
return row;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function listApiKeys(userId) {
|
|
626
|
+
if (!_db) return [];
|
|
627
|
+
return _db.prepare('SELECT id, user_id, name, key_prefix, created_at, last_used_at, expires_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(userId);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export function deleteApiKey(id, userId) {
|
|
631
|
+
if (!_db) throw new Error('DB not ready');
|
|
632
|
+
const result = _db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?').run(id, userId);
|
|
633
|
+
return result.changes > 0;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function touchApiKey(id) {
|
|
637
|
+
if (!_db) return;
|
|
638
|
+
_db.prepare('UPDATE api_keys SET last_used_at = ? WHERE id = ?').run(new Date().toISOString(), id);
|
|
639
|
+
}
|
package/package.json
CHANGED
package/web/app/index.html
CHANGED
|
@@ -1968,6 +1968,31 @@ body.viewer-mode .saved-test-row { cursor: default !important; }
|
|
|
1968
1968
|
</div>
|
|
1969
1969
|
</div>
|
|
1970
1970
|
|
|
1971
|
+
<!-- AGENT API KEYS -->
|
|
1972
|
+
<div class="card" style="margin-bottom:20px">
|
|
1973
|
+
<div class="card-header">
|
|
1974
|
+
<div class="card-title">Agent API keys</div>
|
|
1975
|
+
<div style="font-family:var(--mono);font-size:11px;color:var(--muted)">Use these to connect agents without MFA</div>
|
|
1976
|
+
</div>
|
|
1977
|
+
<div class="card-body" style="padding:22px 24px">
|
|
1978
|
+
<p style="font-family:var(--mono);font-size:12px;color:var(--muted);line-height:1.6;margin-bottom:20px">
|
|
1979
|
+
API keys allow agents to connect to this dashboard without requiring MFA. Use the <code style="color:var(--cyan)">--api-key</code> flag when starting an agent. The key is only shown once — copy it immediately.
|
|
1980
|
+
</p>
|
|
1981
|
+
<div id="api-keys-list" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px"></div>
|
|
1982
|
+
<div style="display:flex;gap:8px">
|
|
1983
|
+
<input class="form-input" id="new-api-key-name" type="text" placeholder="Key name (e.g. My Mac Agent)" style="flex:1" onkeydown="if(event.key==='Enter')createApiKey()">
|
|
1984
|
+
<button class="btn btn-primary" onclick="createApiKey()">Generate key</button>
|
|
1985
|
+
</div>
|
|
1986
|
+
<div id="new-api-key-reveal" style="display:none;margin-top:14px;padding:14px;background:rgba(16,185,129,0.08);border:1px solid rgba(16,185,129,0.3);border-radius:8px">
|
|
1987
|
+
<div style="font-family:var(--mono);font-size:11px;color:var(--green);margin-bottom:8px">✓ Key generated — copy it now, it won't be shown again</div>
|
|
1988
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
1989
|
+
<code id="new-api-key-value" style="font-family:var(--mono);font-size:12px;color:var(--white);flex:1;word-break:break-all"></code>
|
|
1990
|
+
<button class="btn btn-ghost" style="flex-shrink:0" onclick="copyToClipboard(document.getElementById('new-api-key-value').textContent);showToast('Copied!')">Copy</button>
|
|
1991
|
+
</div>
|
|
1992
|
+
</div>
|
|
1993
|
+
</div>
|
|
1994
|
+
</div>
|
|
1995
|
+
|
|
1971
1996
|
<!-- API KEYS & TOKENS -->
|
|
1972
1997
|
<div class="card" style="margin-bottom:20px">
|
|
1973
1998
|
<div class="card-header">
|
|
@@ -4978,6 +5003,7 @@ switchView = function(name) {
|
|
|
4978
5003
|
if (name === 'my-settings') {
|
|
4979
5004
|
fetchMySecrets();
|
|
4980
5005
|
loadOllamaConfig();
|
|
5006
|
+
fetchApiKeys();
|
|
4981
5007
|
}
|
|
4982
5008
|
if (name === 'audit') {
|
|
4983
5009
|
fetchAuditLog();
|
|
@@ -5000,6 +5026,75 @@ const SECRET_DEFINITIONS = {
|
|
|
5000
5026
|
|
|
5001
5027
|
let _myCurrentSecrets = []; // [{ key, updatedAt }]
|
|
5002
5028
|
|
|
5029
|
+
// ── AGENT API KEYS ────────────────────────────────────────────────────────────
|
|
5030
|
+
async function fetchApiKeys() {
|
|
5031
|
+
try {
|
|
5032
|
+
const res = await fetch(API_BASE + '/api/api-keys');
|
|
5033
|
+
if (!res.ok) return;
|
|
5034
|
+
const keys = await res.json();
|
|
5035
|
+
renderApiKeys(keys);
|
|
5036
|
+
} catch {}
|
|
5037
|
+
}
|
|
5038
|
+
|
|
5039
|
+
function renderApiKeys(keys) {
|
|
5040
|
+
const container = document.getElementById('api-keys-list');
|
|
5041
|
+
if (!container) return;
|
|
5042
|
+
if (!keys.length) {
|
|
5043
|
+
container.innerHTML = '<div style="font-family:var(--mono);font-size:12px;color:var(--muted2)">No API keys yet.</div>';
|
|
5044
|
+
return;
|
|
5045
|
+
}
|
|
5046
|
+
container.innerHTML = keys.map(k => `
|
|
5047
|
+
<div style="display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--surface2);border-radius:8px;border:1px solid var(--border)">
|
|
5048
|
+
<div style="flex:1">
|
|
5049
|
+
<div style="font-size:13px;color:var(--text)">${escapeHtml(k.name)}</div>
|
|
5050
|
+
<div style="font-family:var(--mono);font-size:11px;color:var(--muted);margin-top:2px">
|
|
5051
|
+
${escapeHtml(k.key_prefix)} · Created ${formatDateTime(k.created_at)}
|
|
5052
|
+
${k.last_used_at ? ' · Last used ' + formatDateTime(k.last_used_at) : ' · Never used'}
|
|
5053
|
+
</div>
|
|
5054
|
+
</div>
|
|
5055
|
+
<button class="btn btn-ghost" style="color:var(--red);flex-shrink:0" onclick="deleteApiKey('${escapeAttr(k.id)}','${escapeAttr(k.name)}')">Revoke</button>
|
|
5056
|
+
</div>`).join('');
|
|
5057
|
+
}
|
|
5058
|
+
|
|
5059
|
+
async function createApiKey() {
|
|
5060
|
+
const name = document.getElementById('new-api-key-name')?.value?.trim();
|
|
5061
|
+
if (!name) { showToast('Enter a name for this key'); return; }
|
|
5062
|
+
try {
|
|
5063
|
+
const res = await fetch(API_BASE + '/api/api-keys', {
|
|
5064
|
+
method: 'POST',
|
|
5065
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5066
|
+
body: JSON.stringify({ name }),
|
|
5067
|
+
});
|
|
5068
|
+
if (!res.ok) { showToast('Failed to create key'); return; }
|
|
5069
|
+
const data = await res.json();
|
|
5070
|
+
document.getElementById('new-api-key-name').value = '';
|
|
5071
|
+
document.getElementById('new-api-key-value').textContent = data.key;
|
|
5072
|
+
document.getElementById('new-api-key-reveal').style.display = 'block';
|
|
5073
|
+
fetchApiKeys();
|
|
5074
|
+
} catch (err) { showToast('Error: ' + err.message); }
|
|
5075
|
+
}
|
|
5076
|
+
|
|
5077
|
+
async function deleteApiKey(id, name) {
|
|
5078
|
+
showConfirm('Revoke API key?', `Revoke "${name}"? Any agents using this key will disconnect.`, async () => {
|
|
5079
|
+
await fetch(API_BASE + '/api/api-keys/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
5080
|
+
fetchApiKeys();
|
|
5081
|
+
showToast('API key revoked');
|
|
5082
|
+
});
|
|
5083
|
+
}
|
|
5084
|
+
|
|
5085
|
+
// ── ADMIN MFA RESET ───────────────────────────────────────────────────────────
|
|
5086
|
+
async function adminResetMfa(userId, name) {
|
|
5087
|
+
showConfirm('Reset MFA?', `Reset MFA for ${name}? They will be required to re-enroll on their next login.`, async () => {
|
|
5088
|
+
const res = await fetch(API_BASE + '/api/users/' + encodeURIComponent(userId) + '/mfa-reset', { method: 'POST' });
|
|
5089
|
+
if (res.ok) {
|
|
5090
|
+
showToast('MFA reset — ' + name + ' will re-enroll on next login');
|
|
5091
|
+
fetchUsersAndInvites();
|
|
5092
|
+
} else {
|
|
5093
|
+
showToast('Failed to reset MFA');
|
|
5094
|
+
}
|
|
5095
|
+
});
|
|
5096
|
+
}
|
|
5097
|
+
|
|
5003
5098
|
async function fetchMySecrets() {
|
|
5004
5099
|
try {
|
|
5005
5100
|
const res = await fetch(API_BASE + '/api/user/secrets');
|
|
@@ -5390,6 +5485,9 @@ function renderUsers(users) {
|
|
|
5390
5485
|
const isMe = me && u.id === me.id;
|
|
5391
5486
|
const lastLogin = u.last_login_at ? formatDateTime(u.last_login_at) : 'Never';
|
|
5392
5487
|
const created = u.created_at ? formatDateTime(u.created_at) : '';
|
|
5488
|
+
const mfaBadge = u.mfa_enabled
|
|
5489
|
+
? '<span style="font-family:var(--mono);font-size:9px;letter-spacing:0.1em;padding:2px 6px;border-radius:4px;background:rgba(16,185,129,0.15);color:var(--green);text-transform:uppercase">MFA ✓</span>'
|
|
5490
|
+
: '<span style="font-family:var(--mono);font-size:9px;letter-spacing:0.1em;padding:2px 6px;border-radius:4px;background:rgba(239,68,68,0.15);color:var(--red);text-transform:uppercase">MFA pending</span>';
|
|
5393
5491
|
return `
|
|
5394
5492
|
<div class="saved-test-row" style="cursor:default">
|
|
5395
5493
|
<div style="display:flex;align-items:center;gap:14px;flex:1;min-width:0">
|
|
@@ -5399,6 +5497,7 @@ function renderUsers(users) {
|
|
|
5399
5497
|
<span>${escapeHtml(u.name)}</span>
|
|
5400
5498
|
${isMe ? '<span style="font-family:var(--mono);font-size:9px;letter-spacing:0.1em;padding:2px 6px;border-radius:4px;background:var(--cyan-dim);color:var(--cyan);text-transform:uppercase">You</span>' : ''}
|
|
5401
5499
|
${u.status === 'disabled' ? '<span style="font-family:var(--mono);font-size:9px;letter-spacing:0.1em;padding:2px 6px;border-radius:4px;background:rgba(245,158,11,0.15);color:var(--yellow);text-transform:uppercase">Disabled</span>' : ''}
|
|
5500
|
+
${mfaBadge}
|
|
5402
5501
|
</div>
|
|
5403
5502
|
<div style="font-family:var(--mono);font-size:11px;color:var(--muted);display:flex;gap:10px;flex-wrap:wrap;margin-top:3px">
|
|
5404
5503
|
<span>${escapeHtml(u.email)}</span>
|
|
@@ -5416,6 +5515,7 @@ function renderUsers(users) {
|
|
|
5416
5515
|
</select>
|
|
5417
5516
|
${!isMe ? `
|
|
5418
5517
|
${u.status === 'active' ? `
|
|
5518
|
+
${u.mfa_enabled ? `<button class="btn btn-ghost" onclick="adminResetMfa('${escapeAttr(u.id)}', '${escapeAttr(u.name || u.email)}')" title="Reset MFA — forces re-enrollment on next login" style="color:var(--yellow)">Reset MFA</button>` : ''}
|
|
5419
5519
|
<button class="btn btn-ghost" onclick="generatePasswordReset('${escapeAttr(u.id)}', '${escapeAttr(u.name || u.email)}')" title="Generate a password reset link for this user">Reset password</button>
|
|
5420
5520
|
<button class="btn btn-ghost" onclick="forceLogout('${escapeAttr(u.id)}', '${escapeAttr(u.name || u.email)}')" title="Revoke all active sessions for this user">Force logout</button>
|
|
5421
5521
|
<button class="btn btn-ghost" onclick="toggleUserStatus('${escapeAttr(u.id)}', 'disabled')" title="Disable - they won't be able to log in">Disable</button>
|
package/web/login.html
CHANGED
|
@@ -8,264 +8,195 @@
|
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@500;700&display=swap" rel="stylesheet">
|
|
10
10
|
<style>
|
|
11
|
-
:root
|
|
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
|
-
align-items: center;
|
|
37
|
-
justify-content: center;
|
|
38
|
-
padding: 24px;
|
|
39
|
-
position: relative;
|
|
40
|
-
overflow-x: hidden;
|
|
41
|
-
}
|
|
42
|
-
body::before {
|
|
43
|
-
content: '';
|
|
44
|
-
position: fixed;
|
|
45
|
-
inset: 0;
|
|
46
|
-
background-image:
|
|
47
|
-
linear-gradient(rgba(0,212,255,0.03) 1px, transparent 1px),
|
|
48
|
-
linear-gradient(90deg, rgba(0,212,255,0.03) 1px, transparent 1px);
|
|
49
|
-
background-size: 60px 60px;
|
|
50
|
-
pointer-events: none;
|
|
51
|
-
z-index: 0;
|
|
52
|
-
}
|
|
53
|
-
body::after {
|
|
54
|
-
content: '';
|
|
55
|
-
position: fixed;
|
|
56
|
-
inset: 0;
|
|
57
|
-
background: radial-gradient(circle at 50% 30%, rgba(0,212,255,0.08) 0%, transparent 60%);
|
|
58
|
-
pointer-events: none;
|
|
59
|
-
z-index: 0;
|
|
60
|
-
}
|
|
61
|
-
.wrap {
|
|
62
|
-
position: relative;
|
|
63
|
-
z-index: 1;
|
|
64
|
-
max-width: 440px;
|
|
65
|
-
width: 100%;
|
|
66
|
-
}
|
|
67
|
-
.brand {
|
|
68
|
-
font-family: var(--mono);
|
|
69
|
-
font-size: 13px;
|
|
70
|
-
font-weight: 500;
|
|
71
|
-
color: var(--cyan);
|
|
72
|
-
letter-spacing: 0.2em;
|
|
73
|
-
text-align: center;
|
|
74
|
-
margin-bottom: 12px;
|
|
75
|
-
}
|
|
76
|
-
.title {
|
|
77
|
-
font-family: var(--display);
|
|
78
|
-
font-weight: 700;
|
|
79
|
-
font-size: 36px;
|
|
80
|
-
color: var(--white);
|
|
81
|
-
text-align: center;
|
|
82
|
-
margin-bottom: 12px;
|
|
83
|
-
line-height: 1.1;
|
|
84
|
-
}
|
|
85
|
-
.sub {
|
|
86
|
-
font-family: var(--mono);
|
|
87
|
-
font-size: 13px;
|
|
88
|
-
color: var(--muted);
|
|
89
|
-
text-align: center;
|
|
90
|
-
margin-bottom: 36px;
|
|
91
|
-
line-height: 1.6;
|
|
92
|
-
}
|
|
93
|
-
.card {
|
|
94
|
-
background: var(--surface);
|
|
95
|
-
border: 1px solid var(--border);
|
|
96
|
-
border-radius: 16px;
|
|
97
|
-
padding: 32px;
|
|
98
|
-
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
|
99
|
-
}
|
|
100
|
-
.field { margin-bottom: 18px; }
|
|
101
|
-
.label {
|
|
102
|
-
display: block;
|
|
103
|
-
font-family: var(--mono);
|
|
104
|
-
font-size: 11px;
|
|
105
|
-
letter-spacing: 0.1em;
|
|
106
|
-
color: var(--muted);
|
|
107
|
-
text-transform: uppercase;
|
|
108
|
-
margin-bottom: 8px;
|
|
109
|
-
}
|
|
110
|
-
.input {
|
|
111
|
-
width: 100%;
|
|
112
|
-
padding: 12px 14px;
|
|
113
|
-
background: var(--surface2);
|
|
114
|
-
border: 1px solid var(--border);
|
|
115
|
-
border-radius: 8px;
|
|
116
|
-
color: var(--white);
|
|
117
|
-
font-family: var(--mono);
|
|
118
|
-
font-size: 14px;
|
|
119
|
-
outline: none;
|
|
120
|
-
transition: border-color 0.2s;
|
|
121
|
-
}
|
|
122
|
-
.input:focus {
|
|
123
|
-
border-color: var(--cyan);
|
|
124
|
-
box-shadow: 0 0 0 3px var(--cyan-dim);
|
|
125
|
-
}
|
|
126
|
-
.btn {
|
|
127
|
-
width: 100%;
|
|
128
|
-
padding: 14px 24px;
|
|
129
|
-
background: var(--cyan);
|
|
130
|
-
color: var(--bg);
|
|
131
|
-
border: none;
|
|
132
|
-
border-radius: 8px;
|
|
133
|
-
font-family: var(--mono);
|
|
134
|
-
font-size: 13px;
|
|
135
|
-
font-weight: 500;
|
|
136
|
-
letter-spacing: 0.05em;
|
|
137
|
-
cursor: pointer;
|
|
138
|
-
transition: all 0.2s;
|
|
139
|
-
margin-top: 8px;
|
|
140
|
-
text-transform: uppercase;
|
|
141
|
-
}
|
|
142
|
-
.btn:hover { background: #00b8e0; box-shadow: 0 0 30px rgba(0,212,255,0.3); }
|
|
143
|
-
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
144
|
-
.alert {
|
|
145
|
-
padding: 12px 14px;
|
|
146
|
-
border-radius: 8px;
|
|
147
|
-
font-family: var(--mono);
|
|
148
|
-
font-size: 12px;
|
|
149
|
-
margin-bottom: 16px;
|
|
150
|
-
line-height: 1.5;
|
|
151
|
-
}
|
|
152
|
-
.alert.error {
|
|
153
|
-
background: rgba(239,68,68,0.1);
|
|
154
|
-
border: 1px solid rgba(239,68,68,0.3);
|
|
155
|
-
color: var(--red);
|
|
156
|
-
}
|
|
157
|
-
.alert.success {
|
|
158
|
-
background: rgba(16,185,129,0.1);
|
|
159
|
-
border: 1px solid rgba(16,185,129,0.3);
|
|
160
|
-
color: var(--green);
|
|
161
|
-
}
|
|
162
|
-
.note {
|
|
163
|
-
font-family: var(--mono);
|
|
164
|
-
font-size: 11px;
|
|
165
|
-
color: var(--muted);
|
|
166
|
-
text-align: center;
|
|
167
|
-
margin-top: 24px;
|
|
168
|
-
line-height: 1.6;
|
|
169
|
-
}
|
|
170
|
-
.hidden { display: none; }
|
|
11
|
+
:root{--bg:#080810;--surface:#0d0d1a;--surface2:#12121f;--border:rgba(255,255,255,0.06);--cyan:#00d4ff;--cyan-dim:rgba(0,212,255,0.12);--red:#ef4444;--green:#10b981;--text:#e8eaf0;--muted:#5a6180;--white:#ffffff;--mono:'DM Mono',monospace;--display:'Syne',sans-serif;}
|
|
12
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
|
13
|
+
body{background:var(--bg);color:var(--text);font-family:var(--display);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;position:relative;overflow-x:hidden;}
|
|
14
|
+
body::before{content:'';position:fixed;inset:0;background-image:linear-gradient(rgba(0,212,255,0.03) 1px,transparent 1px),linear-gradient(90deg,rgba(0,212,255,0.03) 1px,transparent 1px);background-size:60px 60px;pointer-events:none;z-index:0;}
|
|
15
|
+
body::after{content:'';position:fixed;inset:0;background:radial-gradient(circle at 50% 30%,rgba(0,212,255,0.08) 0%,transparent 60%);pointer-events:none;z-index:0;}
|
|
16
|
+
.wrap{position:relative;z-index:1;max-width:440px;width:100%;}
|
|
17
|
+
.brand{font-family:var(--mono);font-size:13px;font-weight:500;color:var(--cyan);letter-spacing:0.2em;text-align:center;margin-bottom:12px;}
|
|
18
|
+
.title{font-family:var(--display);font-weight:700;font-size:36px;color:var(--white);text-align:center;margin-bottom:12px;line-height:1.1;}
|
|
19
|
+
.sub{font-family:var(--mono);font-size:13px;color:var(--muted);text-align:center;margin-bottom:36px;line-height:1.6;}
|
|
20
|
+
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:32px;box-shadow:0 20px 60px rgba(0,0,0,0.4);}
|
|
21
|
+
.field{margin-bottom:18px;}
|
|
22
|
+
.label{display:block;font-family:var(--mono);font-size:11px;letter-spacing:0.1em;color:var(--muted);text-transform:uppercase;margin-bottom:8px;}
|
|
23
|
+
.input{width:100%;padding:12px 14px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;color:var(--white);font-family:var(--mono);font-size:14px;outline:none;transition:border-color 0.2s;}
|
|
24
|
+
.input:focus{border-color:var(--cyan);box-shadow:0 0 0 3px var(--cyan-dim);}
|
|
25
|
+
.btn{width:100%;padding:14px 24px;background:var(--cyan);color:var(--bg);border:none;border-radius:8px;font-family:var(--mono);font-size:13px;font-weight:500;letter-spacing:0.05em;cursor:pointer;transition:all 0.2s;margin-top:8px;text-transform:uppercase;}
|
|
26
|
+
.btn:hover{background:#00b8e0;box-shadow:0 0 30px rgba(0,212,255,0.3);}
|
|
27
|
+
.btn:disabled{opacity:0.5;cursor:not-allowed;}
|
|
28
|
+
.alert{padding:12px 14px;border-radius:8px;font-family:var(--mono);font-size:12px;margin-bottom:16px;line-height:1.5;}
|
|
29
|
+
.alert.error{background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);color:var(--red);}
|
|
30
|
+
.alert.success{background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);color:var(--green);}
|
|
31
|
+
.note{font-family:var(--mono);font-size:11px;color:var(--muted);text-align:center;margin-top:24px;line-height:1.6;}
|
|
32
|
+
.hidden{display:none;}
|
|
33
|
+
.back-btn{width:100%;margin-top:10px;padding:10px;background:none;border:none;color:var(--muted);font-family:var(--mono);font-size:12px;cursor:pointer;}
|
|
34
|
+
.back-btn:hover{color:var(--text);}
|
|
35
|
+
.secret-box{text-align:center;margin-bottom:20px;padding:10px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);}
|
|
171
36
|
</style>
|
|
172
37
|
</head>
|
|
173
38
|
<body>
|
|
174
39
|
<div class="wrap">
|
|
175
40
|
<div class="brand">SKOPIX</div>
|
|
176
|
-
<h1 class="title">Sign in.</h1>
|
|
177
|
-
<p class="sub">Welcome back. Enter your email and password.</p>
|
|
41
|
+
<h1 class="title" id="page-title">Sign in.</h1>
|
|
42
|
+
<p class="sub" id="page-sub">Welcome back. Enter your email and password.</p>
|
|
178
43
|
|
|
179
44
|
<div class="card">
|
|
180
45
|
<div id="alert" class="alert hidden"></div>
|
|
181
46
|
|
|
182
|
-
<
|
|
183
|
-
<
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
47
|
+
<div id="step-login">
|
|
48
|
+
<form id="login-form">
|
|
49
|
+
<div class="field">
|
|
50
|
+
<label class="label" for="email">Email address</label>
|
|
51
|
+
<input class="input" id="email" name="email" type="email" autocomplete="email" required autofocus placeholder="you@yourcompany.com">
|
|
52
|
+
</div>
|
|
53
|
+
<div class="field">
|
|
54
|
+
<label class="label" for="password">Password</label>
|
|
55
|
+
<input class="input" id="password" name="password" type="password" autocomplete="current-password" required placeholder="Your password">
|
|
56
|
+
</div>
|
|
57
|
+
<button id="submit-btn" class="btn" type="submit">Sign in →</button>
|
|
58
|
+
</form>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div id="step-mfa-verify" style="display:none">
|
|
62
|
+
<form id="mfa-verify-form">
|
|
63
|
+
<div class="field">
|
|
64
|
+
<label class="label" for="totp-code">Authentication code</label>
|
|
65
|
+
<input class="input" id="totp-code" name="totp-code" type="text" inputmode="numeric" maxlength="6" placeholder="000000" autocomplete="one-time-code">
|
|
66
|
+
<p style="font-family:var(--mono);font-size:11px;color:var(--muted);margin-top:8px;line-height:1.5">Open your authenticator app (Google Authenticator, Authy, 1Password etc.) and enter the 6-digit code.</p>
|
|
67
|
+
</div>
|
|
68
|
+
<button id="mfa-verify-btn" class="btn" type="submit">Verify →</button>
|
|
69
|
+
<button type="button" class="back-btn" onclick="backToLogin()">← Back to sign in</button>
|
|
70
|
+
</form>
|
|
71
|
+
</div>
|
|
187
72
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
<
|
|
191
|
-
|
|
73
|
+
<div id="step-mfa-enroll" style="display:none">
|
|
74
|
+
<form id="mfa-enroll-form">
|
|
75
|
+
<div style="text-align:center;margin-bottom:16px">
|
|
76
|
+
<img id="qr-code-img" src="" alt="QR Code" style="width:180px;height:180px;border-radius:8px;background:#fff;padding:8px">
|
|
77
|
+
</div>
|
|
78
|
+
<p style="font-family:var(--mono);font-size:11px;color:var(--muted);text-align:center;margin-bottom:6px">Can't scan? Enter this code manually in your authenticator app:</p>
|
|
79
|
+
<div class="secret-box">
|
|
80
|
+
<code id="secret-text" style="font-family:var(--mono);font-size:11px;color:var(--cyan);letter-spacing:0.12em;word-break:break-all"></code>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="field">
|
|
83
|
+
<label class="label" for="enroll-code">Enter 6-digit code to confirm setup</label>
|
|
84
|
+
<input class="input" id="enroll-code" name="enroll-code" type="text" inputmode="numeric" maxlength="6" placeholder="000000" autocomplete="one-time-code">
|
|
85
|
+
</div>
|
|
86
|
+
<button id="enroll-btn" class="btn" type="submit">Confirm & sign in →</button>
|
|
87
|
+
<button type="button" class="back-btn" onclick="backToLogin()">← Back to sign in</button>
|
|
88
|
+
</form>
|
|
89
|
+
</div>
|
|
192
90
|
|
|
193
|
-
<button id="submit-btn" class="btn" type="submit">Sign in →</button>
|
|
194
|
-
</form>
|
|
195
91
|
</div>
|
|
196
92
|
|
|
197
|
-
<p class="note">
|
|
198
|
-
Need an account? Ask your Skopix admin for an invite.
|
|
199
|
-
</p>
|
|
93
|
+
<p class="note">Need an account? Ask your Skopix admin for an invite.</p>
|
|
200
94
|
</div>
|
|
201
95
|
|
|
202
96
|
<script>
|
|
203
|
-
const form = document.getElementById('login-form');
|
|
204
97
|
const alertEl = document.getElementById('alert');
|
|
205
98
|
const submitBtn = document.getElementById('submit-btn');
|
|
99
|
+
let pendingToken = null;
|
|
206
100
|
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
|
|
101
|
+
function showStep(step) {
|
|
102
|
+
['login','mfa-verify','mfa-enroll'].forEach(function(s) {
|
|
103
|
+
document.getElementById('step-' + s).style.display = s === step ? 'block' : 'none';
|
|
104
|
+
});
|
|
105
|
+
alertEl.classList.add('hidden');
|
|
106
|
+
var titles = {login:'Sign in.', 'mfa-verify':'Two-factor auth.', 'mfa-enroll':'Set up MFA.'};
|
|
107
|
+
var subs = {login:'Welcome back. Enter your email and password.', 'mfa-verify':'Enter the 6-digit code from your authenticator app.', 'mfa-enroll':'Scan the QR code with your authenticator app to enable MFA.'};
|
|
108
|
+
document.getElementById('page-title').textContent = titles[step] || 'Sign in.';
|
|
109
|
+
document.getElementById('page-sub').textContent = subs[step] || '';
|
|
110
|
+
setTimeout(function() {
|
|
111
|
+
var focusMap = {login:'email', 'mfa-verify':'totp-code', 'mfa-enroll':'enroll-code'};
|
|
112
|
+
var el = document.getElementById(focusMap[step]);
|
|
113
|
+
if (el) el.focus();
|
|
114
|
+
}, 100);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function showAlert(msg, kind) {
|
|
118
|
+
alertEl.textContent = msg;
|
|
119
|
+
alertEl.className = 'alert ' + (kind || 'error');
|
|
210
120
|
alertEl.classList.remove('hidden');
|
|
211
121
|
}
|
|
212
122
|
|
|
213
|
-
// On load: if already signed in, redirect to dashboard. If team mode is off,
|
|
214
|
-
// no point being here either.
|
|
215
123
|
async function checkStatus() {
|
|
216
124
|
try {
|
|
217
|
-
|
|
218
|
-
if (!
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
window.location.href = '/setup';
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
// Try /api/auth/me - if logged in, skip to dashboard
|
|
228
|
-
const meRes = await fetch('/api/auth/me');
|
|
229
|
-
if (meRes.ok) {
|
|
230
|
-
window.location.href = '/app/';
|
|
231
|
-
}
|
|
232
|
-
} catch {}
|
|
125
|
+
var r = await fetch('/api/team/status');
|
|
126
|
+
if (!r.ok) { window.location.href = '/app/'; return; }
|
|
127
|
+
var s = await r.json();
|
|
128
|
+
if (s.needsSetup) { window.location.href = '/setup'; return; }
|
|
129
|
+
var me = await fetch('/api/auth/me');
|
|
130
|
+
if (me.ok) { window.location.href = '/app/'; }
|
|
131
|
+
} catch(e) {}
|
|
233
132
|
}
|
|
234
133
|
checkStatus();
|
|
235
134
|
|
|
236
|
-
form.addEventListener('submit', async (e)
|
|
135
|
+
document.getElementById('login-form').addEventListener('submit', async function(e) {
|
|
237
136
|
e.preventDefault();
|
|
238
137
|
alertEl.classList.add('hidden');
|
|
138
|
+
var email = document.getElementById('email').value.trim();
|
|
139
|
+
var password = document.getElementById('password').value;
|
|
140
|
+
submitBtn.disabled = true; submitBtn.textContent = 'Signing in...';
|
|
141
|
+
try {
|
|
142
|
+
var res = await fetch('/api/auth/login', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,password:password})});
|
|
143
|
+
var data = await res.json();
|
|
144
|
+
if (!res.ok) { showAlert(data.error || 'Sign in failed.'); submitBtn.disabled=false; submitBtn.textContent='Sign in \u2192'; return; }
|
|
145
|
+
pendingToken = data.pendingToken;
|
|
146
|
+
if (data.mfaRequired) {
|
|
147
|
+
showStep('mfa-verify');
|
|
148
|
+
} else if (data.mfaEnrollRequired) {
|
|
149
|
+
await startEnrollment();
|
|
150
|
+
showStep('mfa-enroll');
|
|
151
|
+
} else if (data.ok) {
|
|
152
|
+
showAlert('Signed in. Redirecting...', 'success');
|
|
153
|
+
setTimeout(function(){ window.location.href='/app/'; }, 600);
|
|
154
|
+
}
|
|
155
|
+
} catch(err) { showAlert('Network error: ' + err.message); }
|
|
156
|
+
submitBtn.disabled=false; submitBtn.textContent='Sign in \u2192';
|
|
157
|
+
});
|
|
239
158
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
159
|
+
document.getElementById('mfa-verify-form').addEventListener('submit', async function(e) {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
alertEl.classList.add('hidden');
|
|
162
|
+
var code = document.getElementById('totp-code').value.replace(/[^0-9]/g,'');
|
|
163
|
+
var btn = document.getElementById('mfa-verify-btn');
|
|
164
|
+
btn.disabled=true; btn.textContent='Verifying...';
|
|
246
165
|
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
});
|
|
252
|
-
|
|
166
|
+
var res = await fetch('/api/auth/mfa-verify', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pendingToken:pendingToken,totpCode:code})});
|
|
167
|
+
var data = await res.json();
|
|
168
|
+
if (!res.ok) { showAlert(data.error||'Invalid code. Try again.'); document.getElementById('totp-code').value=''; document.getElementById('totp-code').focus(); btn.disabled=false; btn.textContent='Verify \u2192'; return; }
|
|
169
|
+
showAlert('Signed in. Redirecting...','success');
|
|
170
|
+
setTimeout(function(){ window.location.href='/app/'; }, 600);
|
|
171
|
+
} catch(err) { showAlert('Network error: '+err.message); btn.disabled=false; btn.textContent='Verify \u2192'; }
|
|
172
|
+
});
|
|
253
173
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
174
|
+
async function startEnrollment() {
|
|
175
|
+
try {
|
|
176
|
+
var res = await fetch('/api/auth/mfa-enroll', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pendingToken:pendingToken})});
|
|
177
|
+
var data = await res.json();
|
|
178
|
+
if (!res.ok) { showAlert(data.error||'Enrollment failed.'); return; }
|
|
179
|
+
document.getElementById('qr-code-img').src = data.qrUrl;
|
|
180
|
+
document.getElementById('secret-text').textContent = data.secret;
|
|
181
|
+
} catch(err) { showAlert('Network error: '+err.message); }
|
|
182
|
+
}
|
|
260
183
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
184
|
+
document.getElementById('mfa-enroll-form').addEventListener('submit', async function(e) {
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
alertEl.classList.add('hidden');
|
|
187
|
+
var code = document.getElementById('enroll-code').value.replace(/[^0-9]/g,'');
|
|
188
|
+
var btn = document.getElementById('enroll-btn');
|
|
189
|
+
btn.disabled=true; btn.textContent='Confirming...';
|
|
190
|
+
try {
|
|
191
|
+
var res = await fetch('/api/auth/mfa-enroll-confirm', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pendingToken:pendingToken,totpCode:code})});
|
|
192
|
+
var data = await res.json();
|
|
193
|
+
if (!res.ok) { showAlert(data.error||'Invalid code. Check your app.'); document.getElementById('enroll-code').value=''; document.getElementById('enroll-code').focus(); btn.disabled=false; btn.textContent='Confirm & sign in \u2192'; return; }
|
|
194
|
+
showAlert('MFA enabled! Signing in...','success');
|
|
195
|
+
setTimeout(function(){ window.location.href='/app/'; }, 800);
|
|
196
|
+
} catch(err) { showAlert('Network error: '+err.message); btn.disabled=false; btn.textContent='Confirm & sign in \u2192'; }
|
|
268
197
|
});
|
|
198
|
+
|
|
199
|
+
function backToLogin() { pendingToken=null; showStep('login'); }
|
|
269
200
|
</script>
|
|
270
201
|
</body>
|
|
271
202
|
</html>
|