skopix 2.0.94 → 2.0.96

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.
@@ -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
- const authData = await promptAndAuth(serverUrl, secretKey);
124
- userId = authData.userId;
125
- userName = authData.name || agentName;
126
- if (userId) {
127
- console.log(chalk.green(' Authenticated as ') + chalk.white(userName));
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
- console.log(chalk.cyan(' ◆ Solo mode no user auth needed'));
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
- // Create session
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(); // 30 days
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
- const cookieAttrs = [
196
- `skopix_session=${token}`,
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');
@@ -319,7 +416,7 @@ export async function dashboardCommand(options) {
319
416
  const users = teamMode.db.listUsers().map(u => ({
320
417
  id: u.id, email: u.email, name: u.name,
321
418
  role: u.role, status: u.status,
322
- created_at: u.created_at, last_login_at: u.last_login_at,
419
+ created_at: u.created_at, last_login_at: u.last_login_at, mfa_enabled: u.mfa_enabled,
323
420
  }));
324
421
  sendJSON(res, 200, users);
325
422
  return;
@@ -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 call this to identify themselves by email/password
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', // returns 401 itself, doesn't need to be blocked here
2415
- '/api/agent/auth', // agents authenticate with secret key, not session cookie
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skopix",
3
- "version": "2.0.94",
3
+ "version": "2.0.96",
4
4
  "description": "Browser-based QA tool — record tests by using your app, replay them deterministically, generate Playwright code automatically",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
@@ -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
- --bg: #080810;
13
- --surface: #0d0d1a;
14
- --surface2: #12121f;
15
- --border: rgba(255,255,255,0.06);
16
- --border-bright: rgba(0,212,255,0.2);
17
- --cyan: #00d4ff;
18
- --cyan-dim: rgba(0,212,255,0.12);
19
- --red: #ef4444;
20
- --green: #10b981;
21
- --yellow: #f59e0b;
22
- --text: #e8eaf0;
23
- --muted: #5a6180;
24
- --muted2: #3a3f5c;
25
- --white: #ffffff;
26
- --mono: 'DM Mono', monospace;
27
- --display: 'Syne', sans-serif;
28
- }
29
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
30
- body {
31
- background: var(--bg);
32
- color: var(--text);
33
- font-family: var(--display);
34
- min-height: 100vh;
35
- display: flex;
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
- <form id="login-form">
183
- <div class="field">
184
- <label class="label" for="email">Email address</label>
185
- <input class="input" id="email" name="email" type="email" autocomplete="email" required autofocus placeholder="you@yourcompany.com">
186
- </div>
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 &#8594;</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 &#8594;</button>
69
+ <button type="button" class="back-btn" onclick="backToLogin()">&#8592; Back to sign in</button>
70
+ </form>
71
+ </div>
187
72
 
188
- <div class="field">
189
- <label class="label" for="password">Password</label>
190
- <input class="input" id="password" name="password" type="password" autocomplete="current-password" required placeholder="Your password">
191
- </div>
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 &#38; sign in &#8594;</button>
87
+ <button type="button" class="back-btn" onclick="backToLogin()">&#8592; 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 showAlert(message, kind = 'error') {
208
- alertEl.textContent = message;
209
- alertEl.className = 'alert ' + kind;
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
- const statusRes = await fetch('/api/team/status');
218
- if (!statusRes.ok) {
219
- window.location.href = '/app/';
220
- return;
221
- }
222
- const status = await statusRes.json();
223
- if (status.needsSetup) {
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
- const email = document.getElementById('email').value.trim();
241
- const password = document.getElementById('password').value;
242
-
243
- submitBtn.disabled = true;
244
- submitBtn.textContent = 'Signing in...';
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
- const res = await fetch('/api/auth/login', {
248
- method: 'POST',
249
- headers: { 'Content-Type': 'application/json' },
250
- body: JSON.stringify({ email, password }),
251
- });
252
- const data = await res.json();
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
- if (!res.ok) {
255
- showAlert(data.error || 'Sign in failed.');
256
- submitBtn.disabled = false;
257
- submitBtn.textContent = 'Sign in →';
258
- return;
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
- showAlert('Signed in. Redirecting...', 'success');
262
- setTimeout(() => { window.location.href = '/app/'; }, 600);
263
- } catch (err) {
264
- showAlert('Network error: ' + err.message);
265
- submitBtn.disabled = false;
266
- submitBtn.textContent = 'Sign in →';
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>