@agenticmail/enterprise 0.5.293 → 0.5.295
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/dist/chunk-3YLLWCUC.js +1519 -0
- package/dist/chunk-DYARH3NM.js +48 -0
- package/dist/chunk-HGSWCMB7.js +3952 -0
- package/dist/chunk-KWW53O2B.js +48 -0
- package/dist/chunk-LI5SE4WB.js +1519 -0
- package/dist/chunk-ZBZKO37Y.js +3841 -0
- package/dist/cli-agent-BCISHZTV.js +1778 -0
- package/dist/cli-agent-PLR52NZQ.js +1778 -0
- package/dist/cli-recover-LPV6BP5V.js +487 -0
- package/dist/cli-recover-PMJRFJNY.js +487 -0
- package/dist/cli-serve-KVJJPGJM.js +143 -0
- package/dist/cli-serve-TTN3XR4Q.js +143 -0
- package/dist/cli-verify-HYUKQELV.js +149 -0
- package/dist/cli-verify-IWWY34SU.js +149 -0
- package/dist/cli.js +5 -5
- package/dist/dashboard/app.js +64 -1
- package/dist/dashboard/pages/login.js +148 -1
- package/dist/dashboard/pages/users.js +55 -8
- package/dist/factory-NTLTU26R.js +9 -0
- package/dist/factory-QITALRK7.js +9 -0
- package/dist/index.js +3 -3
- package/dist/postgres-65LIV46L.js +766 -0
- package/dist/postgres-PO2XULHX.js +768 -0
- package/dist/server-ADSJRMMF.js +15 -0
- package/dist/server-EZY43BUY.js +15 -0
- package/dist/setup-2VN7D4OT.js +20 -0
- package/dist/setup-BOLXAOX2.js +20 -0
- package/dist/sqlite-C5PV4SCD.js +499 -0
- package/package.json +1 -1
- package/src/admin/routes.ts +29 -0
- package/src/auth/routes.ts +105 -2
- package/src/dashboard/app.js +64 -1
- package/src/dashboard/pages/login.js +148 -1
- package/src/dashboard/pages/users.js +55 -8
- package/src/db/adapter.ts +1 -0
- package/src/db/postgres.ts +3 -1
- package/src/db/sqlite.ts +2 -3
package/src/auth/routes.ts
CHANGED
|
@@ -298,7 +298,8 @@ export function createAuthRoutes(
|
|
|
298
298
|
token,
|
|
299
299
|
refreshToken,
|
|
300
300
|
csrf,
|
|
301
|
-
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
301
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role, totpEnabled: !!user.totpEnabled },
|
|
302
|
+
mustResetPassword: !!user.mustResetPassword,
|
|
302
303
|
});
|
|
303
304
|
});
|
|
304
305
|
|
|
@@ -356,11 +357,113 @@ export function createAuthRoutes(
|
|
|
356
357
|
token,
|
|
357
358
|
refreshToken,
|
|
358
359
|
csrf,
|
|
359
|
-
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
360
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role, totpEnabled: true },
|
|
361
|
+
mustResetPassword: !!user.mustResetPassword,
|
|
360
362
|
...(backupUsed ? { warning: 'Backup code used. You have fewer backup codes remaining.' } : {}),
|
|
361
363
|
});
|
|
362
364
|
});
|
|
363
365
|
|
|
366
|
+
// ─── Self-Service Password Reset (email + 2FA) ─────────
|
|
367
|
+
|
|
368
|
+
auth.post('/reset-password-self', async (c) => {
|
|
369
|
+
const { email, totpCode, newPassword } = await c.req.json();
|
|
370
|
+
if (!email || !newPassword) {
|
|
371
|
+
return c.json({ error: 'Email and new password are required' }, 400);
|
|
372
|
+
}
|
|
373
|
+
if (typeof newPassword !== 'string' || newPassword.length < 8) {
|
|
374
|
+
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const user = await db.getUserByEmail(email);
|
|
378
|
+
if (!user) {
|
|
379
|
+
// Don't reveal whether email exists
|
|
380
|
+
return c.json({ error: 'If this email exists with 2FA enabled, a reset will be processed' }, 200);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// If user has 2FA, require code
|
|
384
|
+
if (user.totpEnabled && user.totpSecret) {
|
|
385
|
+
if (!totpCode) {
|
|
386
|
+
return c.json({ has2fa: true, message: 'Enter your 2FA code to reset password' });
|
|
387
|
+
}
|
|
388
|
+
const valid = await verifyTotp(user.totpSecret, totpCode.replace(/\s/g, ''));
|
|
389
|
+
if (!valid) {
|
|
390
|
+
// Try backup codes
|
|
391
|
+
let backupValid = false;
|
|
392
|
+
if (user.totpBackupCodes) {
|
|
393
|
+
try {
|
|
394
|
+
const { default: bcrypt } = await import('bcryptjs');
|
|
395
|
+
const codes: string[] = JSON.parse(user.totpBackupCodes);
|
|
396
|
+
for (let i = 0; i < codes.length; i++) {
|
|
397
|
+
if (await bcrypt.compare(totpCode.toUpperCase().replace(/\s/g, ''), codes[i])) {
|
|
398
|
+
codes.splice(i, 1);
|
|
399
|
+
await db.updateUser(user.id, { totpBackupCodes: JSON.stringify(codes) } as any);
|
|
400
|
+
backupValid = true;
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch { /* ignore */ }
|
|
405
|
+
}
|
|
406
|
+
if (!backupValid) {
|
|
407
|
+
return c.json({ error: 'Invalid 2FA code' }, 401);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
// No 2FA — cannot self-reset
|
|
412
|
+
return c.json({ no2fa: true, error: 'Two-factor authentication is not enabled on this account. Please contact your organization administrator to reset your password.' }, 403);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Reset password
|
|
416
|
+
const { default: bcrypt } = await import('bcryptjs');
|
|
417
|
+
const passwordHash = await bcrypt.hash(newPassword, 12);
|
|
418
|
+
try {
|
|
419
|
+
await (db as any).pool.query(
|
|
420
|
+
'UPDATE users SET password_hash = $1, must_reset_password = FALSE, updated_at = NOW() WHERE id = $2',
|
|
421
|
+
[passwordHash, user.id]
|
|
422
|
+
);
|
|
423
|
+
} catch {
|
|
424
|
+
const edb = (db as any).db;
|
|
425
|
+
if (edb?.prepare) edb.prepare('UPDATE users SET password_hash = ?, must_reset_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(passwordHash, user.id);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return c.json({ ok: true, message: 'Password reset successfully. You can now sign in.' });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// ─── Force Password Reset (authenticated, must-reset users) ──
|
|
432
|
+
|
|
433
|
+
auth.post('/force-reset-password', async (c) => {
|
|
434
|
+
const token = await extractToken(c);
|
|
435
|
+
if (!token) return c.json({ error: 'Authentication required' }, 401);
|
|
436
|
+
|
|
437
|
+
let userId: string;
|
|
438
|
+
try {
|
|
439
|
+
const { jwtVerify } = await import('jose');
|
|
440
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
441
|
+
const { payload } = await jwtVerify(token, secret);
|
|
442
|
+
userId = payload.sub as string;
|
|
443
|
+
} catch {
|
|
444
|
+
return c.json({ error: 'Invalid session' }, 401);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const { newPassword } = await c.req.json();
|
|
448
|
+
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
|
|
449
|
+
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const { default: bcrypt } = await import('bcryptjs');
|
|
453
|
+
const passwordHash = await bcrypt.hash(newPassword, 12);
|
|
454
|
+
try {
|
|
455
|
+
await (db as any).pool.query(
|
|
456
|
+
'UPDATE users SET password_hash = $1, must_reset_password = FALSE, updated_at = NOW() WHERE id = $2',
|
|
457
|
+
[passwordHash, userId]
|
|
458
|
+
);
|
|
459
|
+
} catch {
|
|
460
|
+
const edb = (db as any).db;
|
|
461
|
+
if (edb?.prepare) edb.prepare('UPDATE users SET password_hash = ?, must_reset_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(passwordHash, userId);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return c.json({ ok: true });
|
|
465
|
+
});
|
|
466
|
+
|
|
364
467
|
// ─── 2FA Setup (authenticated users) ───────────────────
|
|
365
468
|
|
|
366
469
|
auth.post('/2fa/setup', async (c) => {
|
package/src/dashboard/app.js
CHANGED
|
@@ -94,6 +94,12 @@ function App() {
|
|
|
94
94
|
const [user, setUser] = useState(null);
|
|
95
95
|
const [pendingCount, setPendingCount] = useState(0);
|
|
96
96
|
const [permissions, setPermissions] = useState('*'); // '*' = full access, or { pageId: true | ['tab1','tab2'] }
|
|
97
|
+
const [mustResetPassword, setMustResetPassword] = useState(false);
|
|
98
|
+
const [show2faReminder, setShow2faReminder] = useState(false);
|
|
99
|
+
const [forceResetPw, setForceResetPw] = useState('');
|
|
100
|
+
const [forceResetPw2, setForceResetPw2] = useState('');
|
|
101
|
+
const [forceResetLoading, setForceResetLoading] = useState(false);
|
|
102
|
+
const [forceResetError, setForceResetError] = useState('');
|
|
97
103
|
const [needsSetup, setNeedsSetup] = useState(null);
|
|
98
104
|
const [sidebarPinned, setSidebarPinned] = useState(() => localStorage.getItem('em_sidebar_pinned') === 'true');
|
|
99
105
|
const [sidebarHovered, setSidebarHovered] = useState(false);
|
|
@@ -150,7 +156,54 @@ function App() {
|
|
|
150
156
|
|
|
151
157
|
if (!authChecked) return h('div', { style: { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-primary)', color: 'var(--text-muted)' } }, 'Loading...');
|
|
152
158
|
if (needsSetup === true && !authed) return h(OnboardingWizard, { onComplete: () => { setNeedsSetup(false); setAuthed(true); authCall('/me').then(d => { setUser(d.user || d); }).catch(() => {}); } });
|
|
153
|
-
if (!authed) return h(LoginPage, { onLogin: (d) => {
|
|
159
|
+
if (!authed) return h(LoginPage, { onLogin: (d) => {
|
|
160
|
+
setAuthed(true);
|
|
161
|
+
if (d?.user) { setUser(d.user); if (!d.user.totpEnabled) setShow2faReminder(true); }
|
|
162
|
+
if (d?.mustResetPassword) setMustResetPassword(true);
|
|
163
|
+
} });
|
|
164
|
+
|
|
165
|
+
// Force password reset modal
|
|
166
|
+
const doForceReset = async () => {
|
|
167
|
+
if (forceResetPw !== forceResetPw2) { setForceResetError('Passwords do not match'); return; }
|
|
168
|
+
if (forceResetPw.length < 8) { setForceResetError('Password must be at least 8 characters'); return; }
|
|
169
|
+
setForceResetLoading(true); setForceResetError('');
|
|
170
|
+
try {
|
|
171
|
+
await authCall('/force-reset-password', { method: 'POST', body: JSON.stringify({ newPassword: forceResetPw }) });
|
|
172
|
+
setMustResetPassword(false);
|
|
173
|
+
toast('Password updated successfully', 'success');
|
|
174
|
+
} catch (e) { setForceResetError(e.message); }
|
|
175
|
+
setForceResetLoading(false);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
if (mustResetPassword) {
|
|
179
|
+
return h('div', { style: { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-primary)', padding: 20 } },
|
|
180
|
+
h('div', { style: { maxWidth: 420, width: '100%', background: 'var(--bg-secondary)', borderRadius: 12, padding: 32, border: '1px solid var(--border)' } },
|
|
181
|
+
h('div', { style: { textAlign: 'center', marginBottom: 24 } },
|
|
182
|
+
h('div', { style: { width: 48, height: 48, borderRadius: '50%', background: 'var(--warning-soft, rgba(245,158,11,0.1))', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px' } },
|
|
183
|
+
h('svg', { width: 24, height: 24, viewBox: '0 0 24 24', fill: 'none', stroke: 'var(--warning, #f59e0b)', strokeWidth: 2, strokeLinecap: 'round' },
|
|
184
|
+
h('path', { d: 'M12 9v4m0 4h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z' })
|
|
185
|
+
)
|
|
186
|
+
),
|
|
187
|
+
h('h2', { style: { fontSize: 18, fontWeight: 700 } }, 'Password Reset Required'),
|
|
188
|
+
h('p', { style: { color: 'var(--text-muted)', fontSize: 13, marginTop: 4 } }, 'Your administrator created this account with a temporary password. Please set a new password to continue.')
|
|
189
|
+
),
|
|
190
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
|
|
191
|
+
h('div', null,
|
|
192
|
+
h('label', { style: { fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 } }, 'New Password'),
|
|
193
|
+
h('input', { className: 'input', type: 'password', value: forceResetPw, onChange: (e) => setForceResetPw(e.target.value), placeholder: 'Min 8 characters', autoFocus: true })
|
|
194
|
+
),
|
|
195
|
+
h('div', null,
|
|
196
|
+
h('label', { style: { fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 } }, 'Confirm Password'),
|
|
197
|
+
h('input', { className: 'input', type: 'password', value: forceResetPw2, onChange: (e) => setForceResetPw2(e.target.value), placeholder: 'Confirm new password', onKeyDown: (e) => { if (e.key === 'Enter') doForceReset(); } })
|
|
198
|
+
),
|
|
199
|
+
forceResetError && h('div', { style: { color: 'var(--danger)', fontSize: 12 } }, forceResetError),
|
|
200
|
+
h('button', { className: 'btn btn-primary', onClick: doForceReset, disabled: forceResetLoading || !forceResetPw || !forceResetPw2, style: { width: '100%', justifyContent: 'center', marginTop: 4 } },
|
|
201
|
+
forceResetLoading ? 'Updating...' : 'Set New Password'
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
);
|
|
206
|
+
}
|
|
154
207
|
|
|
155
208
|
const nav = [
|
|
156
209
|
{ section: 'Overview', items: [{ id: 'dashboard', icon: I.dashboard, label: 'Dashboard' }] },
|
|
@@ -279,6 +332,16 @@ function App() {
|
|
|
279
332
|
)
|
|
280
333
|
),
|
|
281
334
|
h('div', { className: 'page-content' },
|
|
335
|
+
// 2FA recommendation banner
|
|
336
|
+
show2faReminder && h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px', margin: '0 0 16px', background: 'var(--warning-soft, rgba(245,158,11,0.1))', border: '1px solid var(--warning, #f59e0b)', borderRadius: 8, fontSize: 13 } },
|
|
337
|
+
I.shield(),
|
|
338
|
+
h('div', { style: { flex: 1 } },
|
|
339
|
+
h('strong', null, 'Enable Two-Factor Authentication'),
|
|
340
|
+
h('span', { style: { color: 'var(--text-secondary)', marginLeft: 6 } }, 'Protect your account and enable self-service password reset.')
|
|
341
|
+
),
|
|
342
|
+
h('button', { className: 'btn btn-warning btn-sm', onClick: () => { setPage('settings'); setShow2faReminder(false); history.pushState(null, '', '/dashboard/settings'); } }, 'Set Up 2FA'),
|
|
343
|
+
h('button', { className: 'btn btn-ghost btn-sm', onClick: () => setShow2faReminder(false), style: { padding: '2px 6px', minWidth: 0 } }, '\u00d7')
|
|
344
|
+
),
|
|
282
345
|
selectedAgentId
|
|
283
346
|
? h(AgentDetailPage, { agentId: selectedAgentId, onBack: () => { _setSelectedAgentId(null); _setPage('agents'); history.pushState(null, '', '/dashboard/agents'); } })
|
|
284
347
|
: page === 'agents'
|
|
@@ -20,6 +20,16 @@ export function LoginPage({ onLogin }) {
|
|
|
20
20
|
var [challengeToken, setChallengeToken] = useState('');
|
|
21
21
|
var [totpCode, setTotpCode] = useState('');
|
|
22
22
|
|
|
23
|
+
// Forgot password state
|
|
24
|
+
var [forgotMode, setForgotMode] = useState(false); // show forgot password form
|
|
25
|
+
var [forgotEmail, setForgotEmail] = useState('');
|
|
26
|
+
var [forgotCode, setForgotCode] = useState('');
|
|
27
|
+
var [forgotNewPw, setForgotNewPw] = useState('');
|
|
28
|
+
var [forgotNewPw2, setForgotNewPw2] = useState('');
|
|
29
|
+
var [forgotStep, setForgotStep] = useState('email'); // 'email' | 'code' | 'no2fa' | 'done'
|
|
30
|
+
var [forgotLoading, setForgotLoading] = useState(false);
|
|
31
|
+
var [forgotError, setForgotError] = useState('');
|
|
32
|
+
|
|
23
33
|
useEffect(function() {
|
|
24
34
|
fetch('/auth/sso/providers').then(function(r) { return r.ok ? r.json() : null; }).then(function(d) {
|
|
25
35
|
if (d && d.providers && d.providers.length > 0) setSsoProviders(d.providers);
|
|
@@ -59,6 +69,43 @@ export function LoginPage({ onLogin }) {
|
|
|
59
69
|
setLoading(false);
|
|
60
70
|
};
|
|
61
71
|
|
|
72
|
+
var submitForgotEmail = async function() {
|
|
73
|
+
setForgotLoading(true); setForgotError('');
|
|
74
|
+
try {
|
|
75
|
+
// Check if user has 2FA by attempting reset without code
|
|
76
|
+
var d = await authCall('/reset-password-self', { method: 'POST', body: JSON.stringify({ email: forgotEmail, newPassword: 'check__only__12', totpCode: '' }) });
|
|
77
|
+
if (d.has2fa) { setForgotStep('code'); }
|
|
78
|
+
else if (d.no2fa) { setForgotStep('no2fa'); }
|
|
79
|
+
else { setForgotStep('code'); }
|
|
80
|
+
} catch (err) {
|
|
81
|
+
var msg = err.message || '';
|
|
82
|
+
if (msg.indexOf('not enabled') >= 0 || msg.indexOf('administrator') >= 0) {
|
|
83
|
+
setForgotStep('no2fa');
|
|
84
|
+
} else {
|
|
85
|
+
setForgotStep('code');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
setForgotLoading(false);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
var submitForgotReset = async function() {
|
|
92
|
+
if (forgotNewPw !== forgotNewPw2) { setForgotError('Passwords do not match'); return; }
|
|
93
|
+
if (forgotNewPw.length < 8) { setForgotError('Password must be at least 8 characters'); return; }
|
|
94
|
+
setForgotLoading(true); setForgotError('');
|
|
95
|
+
try {
|
|
96
|
+
var d = await authCall('/reset-password-self', { method: 'POST', body: JSON.stringify({ email: forgotEmail, totpCode: forgotCode, newPassword: forgotNewPw }) });
|
|
97
|
+
if (d.ok) { setForgotStep('done'); }
|
|
98
|
+
else if (d.no2fa) { setForgotStep('no2fa'); setForgotError(d.error); }
|
|
99
|
+
else if (d.error) { setForgotError(d.error); }
|
|
100
|
+
} catch (err) { setForgotError(err.message); }
|
|
101
|
+
setForgotLoading(false);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
var cancelForgot = function() {
|
|
105
|
+
setForgotMode(false); setForgotStep('email'); setForgotEmail(''); setForgotCode('');
|
|
106
|
+
setForgotNewPw(''); setForgotNewPw2(''); setForgotError('');
|
|
107
|
+
};
|
|
108
|
+
|
|
62
109
|
var cancel2fa = function() {
|
|
63
110
|
setNeeds2fa(false);
|
|
64
111
|
setChallengeToken('');
|
|
@@ -110,6 +157,103 @@ export function LoginPage({ onLogin }) {
|
|
|
110
157
|
);
|
|
111
158
|
}
|
|
112
159
|
|
|
160
|
+
// ─── Forgot Password Screen ──────────────────────────
|
|
161
|
+
|
|
162
|
+
if (forgotMode) {
|
|
163
|
+
return h('div', { className: 'login-page', style: _brandBg ? { backgroundImage: 'url(' + _brandBg + ')', backgroundSize: 'cover', backgroundPosition: 'center' } : {} },
|
|
164
|
+
h('div', { className: 'login-card' },
|
|
165
|
+
h('div', { className: 'login-logo' },
|
|
166
|
+
h('img', { src: _brandLogo, alt: 'AgenticMail', style: { width: 48, height: 48, objectFit: 'contain' } }),
|
|
167
|
+
h('h1', null, 'Reset Password'),
|
|
168
|
+
h('p', null, forgotStep === 'email' ? 'Enter your email address' : forgotStep === 'code' ? 'Verify with your authenticator app' : forgotStep === 'done' ? 'Password updated' : 'Contact your administrator')
|
|
169
|
+
),
|
|
170
|
+
|
|
171
|
+
// Step: enter email
|
|
172
|
+
forgotStep === 'email' && h('div', null,
|
|
173
|
+
h('div', { className: 'form-group' },
|
|
174
|
+
h('label', { className: 'form-label' }, 'Email Address'),
|
|
175
|
+
h('input', { className: 'input', type: 'email', value: forgotEmail, onChange: function(e) { setForgotEmail(e.target.value); }, placeholder: 'you@company.com', autoFocus: true })
|
|
176
|
+
),
|
|
177
|
+
forgotError && h('div', { style: { color: 'var(--danger)', fontSize: 13, marginBottom: 12 } }, forgotError),
|
|
178
|
+
h('button', { className: 'btn btn-primary', onClick: submitForgotEmail, disabled: forgotLoading || !forgotEmail, style: { width: '100%', justifyContent: 'center', padding: '8px' } }, forgotLoading ? 'Checking...' : 'Continue'),
|
|
179
|
+
h('div', { style: { textAlign: 'center', marginTop: 16 } },
|
|
180
|
+
h('button', { type: 'button', className: 'btn btn-ghost btn-sm', onClick: cancelForgot }, 'Back to login')
|
|
181
|
+
)
|
|
182
|
+
),
|
|
183
|
+
|
|
184
|
+
// Step: enter 2FA code + new password
|
|
185
|
+
forgotStep === 'code' && h('div', null,
|
|
186
|
+
h('div', { style: { background: 'var(--info-soft, rgba(59,130,246,0.1))', borderRadius: 8, padding: 12, marginBottom: 16, fontSize: 12, color: 'var(--text-secondary)' } },
|
|
187
|
+
'Enter the 6-digit code from your authenticator app (or a backup code) along with your new password.'
|
|
188
|
+
),
|
|
189
|
+
h('div', { className: 'form-group' },
|
|
190
|
+
h('label', { className: 'form-label' }, '2FA Code'),
|
|
191
|
+
h('input', {
|
|
192
|
+
className: 'input', type: 'text', inputMode: 'numeric', autoComplete: 'one-time-code',
|
|
193
|
+
value: forgotCode, onChange: function(e) { setForgotCode(e.target.value.replace(/[^0-9A-Za-z]/g, '').slice(0, 8)); },
|
|
194
|
+
placeholder: '000000', autoFocus: true, maxLength: 8,
|
|
195
|
+
style: { textAlign: 'center', fontSize: 20, letterSpacing: '0.2em', fontFamily: 'var(--font-mono)' }
|
|
196
|
+
})
|
|
197
|
+
),
|
|
198
|
+
h('div', { className: 'form-group' },
|
|
199
|
+
h('label', { className: 'form-label' }, 'New Password'),
|
|
200
|
+
h('input', { className: 'input', type: 'password', value: forgotNewPw, onChange: function(e) { setForgotNewPw(e.target.value); }, placeholder: 'Min 8 characters' })
|
|
201
|
+
),
|
|
202
|
+
h('div', { className: 'form-group' },
|
|
203
|
+
h('label', { className: 'form-label' }, 'Confirm Password'),
|
|
204
|
+
h('input', { className: 'input', type: 'password', value: forgotNewPw2, onChange: function(e) { setForgotNewPw2(e.target.value); }, placeholder: 'Confirm new password' })
|
|
205
|
+
),
|
|
206
|
+
forgotError && h('div', { style: { color: 'var(--danger)', fontSize: 13, marginBottom: 12 } }, forgotError),
|
|
207
|
+
h('button', { className: 'btn btn-primary', onClick: submitForgotReset, disabled: forgotLoading || !forgotCode || !forgotNewPw || !forgotNewPw2, style: { width: '100%', justifyContent: 'center', padding: '8px' } }, forgotLoading ? 'Resetting...' : 'Reset Password'),
|
|
208
|
+
h('div', { style: { textAlign: 'center', marginTop: 16 } },
|
|
209
|
+
h('button', { type: 'button', className: 'btn btn-ghost btn-sm', onClick: cancelForgot }, 'Back to login')
|
|
210
|
+
)
|
|
211
|
+
),
|
|
212
|
+
|
|
213
|
+
// Step: no 2FA — contact admin
|
|
214
|
+
forgotStep === 'no2fa' && h('div', null,
|
|
215
|
+
h('div', { style: { textAlign: 'center', padding: '12px 0' } },
|
|
216
|
+
h('div', { style: { width: 48, height: 48, borderRadius: '50%', background: 'var(--danger-soft, rgba(220,38,38,0.1))', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px' } },
|
|
217
|
+
h('svg', { width: 24, height: 24, viewBox: '0 0 24 24', fill: 'none', stroke: 'var(--danger, #dc2626)', strokeWidth: 2, strokeLinecap: 'round' },
|
|
218
|
+
h('path', { d: 'M12 9v4m0 4h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z' })
|
|
219
|
+
)
|
|
220
|
+
),
|
|
221
|
+
h('h3', { style: { fontSize: 16, fontWeight: 600, marginBottom: 8 } }, 'Cannot Reset Password'),
|
|
222
|
+
h('p', { style: { fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, maxWidth: 320, margin: '0 auto' } },
|
|
223
|
+
'Two-factor authentication is not enabled on this account. Without 2FA, you cannot reset your password yourself.'
|
|
224
|
+
),
|
|
225
|
+
h('div', { style: { marginTop: 16, padding: 12, background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 } },
|
|
226
|
+
h('strong', null, 'What to do:'), h('br', null),
|
|
227
|
+
'Contact your organization administrator and ask them to reset your password from the Users page.'
|
|
228
|
+
),
|
|
229
|
+
h('div', { style: { marginTop: 16, padding: 10, background: 'var(--warning-soft, rgba(245,158,11,0.08))', borderRadius: 8, fontSize: 12, color: 'var(--text-secondary)' } },
|
|
230
|
+
'Tip: Once you regain access, enable 2FA immediately so you can reset your own password in the future.'
|
|
231
|
+
)
|
|
232
|
+
),
|
|
233
|
+
h('div', { style: { textAlign: 'center', marginTop: 16 } },
|
|
234
|
+
h('button', { type: 'button', className: 'btn btn-primary', onClick: cancelForgot, style: { width: '100%', justifyContent: 'center' } }, 'Back to Login')
|
|
235
|
+
)
|
|
236
|
+
),
|
|
237
|
+
|
|
238
|
+
// Step: done
|
|
239
|
+
forgotStep === 'done' && h('div', null,
|
|
240
|
+
h('div', { style: { textAlign: 'center', padding: '12px 0' } },
|
|
241
|
+
h('div', { style: { width: 48, height: 48, borderRadius: '50%', background: 'var(--success-soft, rgba(21,128,61,0.1))', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px' } },
|
|
242
|
+
h('svg', { width: 24, height: 24, viewBox: '0 0 24 24', fill: 'none', stroke: 'var(--success, #15803d)', strokeWidth: 2, strokeLinecap: 'round' },
|
|
243
|
+
h('path', { d: 'M20 6L9 17l-5-5' })
|
|
244
|
+
)
|
|
245
|
+
),
|
|
246
|
+
h('h3', { style: { fontSize: 16, fontWeight: 600, marginBottom: 8 } }, 'Password Reset Successfully'),
|
|
247
|
+
h('p', { style: { fontSize: 13, color: 'var(--text-muted)' } }, 'You can now sign in with your new password.')
|
|
248
|
+
),
|
|
249
|
+
h('div', { style: { textAlign: 'center', marginTop: 16 } },
|
|
250
|
+
h('button', { type: 'button', className: 'btn btn-primary', onClick: cancelForgot, style: { width: '100%', justifyContent: 'center' } }, 'Sign In')
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
113
257
|
// ─── Main Login Screen ────────────────────────────────
|
|
114
258
|
|
|
115
259
|
return h('div', { className: 'login-page', style: _brandBg ? { backgroundImage: 'url(' + _brandBg + ')', backgroundSize: 'cover', backgroundPosition: 'center' } : {} },
|
|
@@ -138,7 +282,10 @@ export function LoginPage({ onLogin }) {
|
|
|
138
282
|
h('input', { className: 'input', type: 'password', value: password, onChange: function(e) { setPassword(e.target.value); }, placeholder: 'Enter password', required: true })
|
|
139
283
|
),
|
|
140
284
|
error && h('div', { style: { color: 'var(--danger)', fontSize: 13, marginBottom: 16 } }, error),
|
|
141
|
-
h('button', { className: 'btn btn-primary', type: 'submit', disabled: loading, style: { width: '100%', justifyContent: 'center', padding: '8px' } }, loading ? 'Signing in...' : 'Sign In')
|
|
285
|
+
h('button', { className: 'btn btn-primary', type: 'submit', disabled: loading, style: { width: '100%', justifyContent: 'center', padding: '8px' } }, loading ? 'Signing in...' : 'Sign In'),
|
|
286
|
+
h('div', { style: { textAlign: 'center', marginTop: 12 } },
|
|
287
|
+
h('button', { type: 'button', className: 'btn btn-ghost btn-sm', onClick: function() { setForgotMode(true); setForgotEmail(email); setError(''); }, style: { fontSize: 12, color: 'var(--text-muted)' } }, 'Forgot Password?')
|
|
288
|
+
)
|
|
142
289
|
),
|
|
143
290
|
|
|
144
291
|
// ── API Key Tab ─────────────────────────────────
|
|
@@ -216,7 +216,7 @@ export function UsersPage() {
|
|
|
216
216
|
var { toast } = useApp();
|
|
217
217
|
var [users, setUsers] = useState([]);
|
|
218
218
|
var [creating, setCreating] = useState(false);
|
|
219
|
-
var [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' });
|
|
219
|
+
var [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer', permissions: '*' });
|
|
220
220
|
var [resetTarget, setResetTarget] = useState(null);
|
|
221
221
|
var [newPassword, setNewPassword] = useState('');
|
|
222
222
|
var [resetting, setResetting] = useState(false);
|
|
@@ -230,10 +230,22 @@ export function UsersPage() {
|
|
|
230
230
|
apiCall('/page-registry').then(function(d) { setPageRegistry(d); }).catch(function() {});
|
|
231
231
|
}, []);
|
|
232
232
|
|
|
233
|
+
var generateCreatePassword = function() {
|
|
234
|
+
var chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%';
|
|
235
|
+
var pw = '';
|
|
236
|
+
for (var i = 0; i < 16; i++) pw += chars[Math.floor(Math.random() * chars.length)];
|
|
237
|
+
setForm(function(f) { return Object.assign({}, f, { password: pw }); });
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
var [showCreatePerms, setShowCreatePerms] = useState(false);
|
|
241
|
+
|
|
233
242
|
var create = async function() {
|
|
234
243
|
try {
|
|
235
|
-
|
|
236
|
-
|
|
244
|
+
var body = { email: form.email, password: form.password, name: form.name, role: form.role };
|
|
245
|
+
if (form.permissions !== '*') body.permissions = form.permissions;
|
|
246
|
+
await apiCall('/users', { method: 'POST', body: JSON.stringify(body) });
|
|
247
|
+
toast('User created. They will be prompted to set a new password on first login.', 'success');
|
|
248
|
+
setCreating(false); setForm({ email: '', password: '', name: '', role: 'viewer', permissions: '*' }); setShowCreatePerms(false); load();
|
|
237
249
|
} catch (e) { toast(e.message, 'error'); }
|
|
238
250
|
};
|
|
239
251
|
|
|
@@ -324,13 +336,48 @@ export function UsersPage() {
|
|
|
324
336
|
),
|
|
325
337
|
|
|
326
338
|
// Create user modal
|
|
327
|
-
creating && h(Modal, { title: 'Add User', onClose: function() { setCreating(false); }, footer: h(Fragment, null, h('button', { className: 'btn btn-secondary', onClick: function() { setCreating(false); } }, 'Cancel'), h('button', { className: 'btn btn-primary', onClick: create, disabled: !form.email || !form.password }, 'Create')) },
|
|
328
|
-
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Name'), h('input', { className: 'input', value: form.name, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { name: e.target.value }); }); } })),
|
|
339
|
+
creating && h(Modal, { title: 'Add User', onClose: function() { setCreating(false); setShowCreatePerms(false); }, width: 520, footer: h(Fragment, null, h('button', { className: 'btn btn-secondary', onClick: function() { setCreating(false); setShowCreatePerms(false); } }, 'Cancel'), h('button', { className: 'btn btn-primary', onClick: create, disabled: !form.email || !form.password }, 'Create User')) },
|
|
340
|
+
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Name'), h('input', { className: 'input', value: form.name, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { name: e.target.value }); }); }, autoFocus: true })),
|
|
329
341
|
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Email *'), h('input', { className: 'input', type: 'email', value: form.email, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { email: e.target.value }); }); } })),
|
|
330
|
-
h('div', { className: 'form-group' },
|
|
342
|
+
h('div', { className: 'form-group' },
|
|
343
|
+
h('label', { className: 'form-label' }, 'Initial Password *'),
|
|
344
|
+
h('div', { style: { display: 'flex', gap: 8 } },
|
|
345
|
+
h('input', { className: 'input', type: 'text', value: form.password, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { password: e.target.value }); }); }, placeholder: 'Min 8 characters', style: { flex: 1, fontFamily: 'var(--font-mono)', fontSize: 13 } }),
|
|
346
|
+
h('button', { type: 'button', className: 'btn btn-secondary btn-sm', onClick: generateCreatePassword, title: 'Generate random password', style: { whiteSpace: 'nowrap' } }, I.refresh(), ' Generate')
|
|
347
|
+
),
|
|
348
|
+
form.password && h('div', { style: { marginTop: 6, padding: 8, background: 'var(--warning-soft, rgba(245,158,11,0.08))', borderRadius: 6, fontSize: 11, color: 'var(--text-secondary)' } },
|
|
349
|
+
'The user will be required to change this password on their first login. Share it securely.'
|
|
350
|
+
)
|
|
351
|
+
),
|
|
331
352
|
h('div', { className: 'form-group' }, h('label', { className: 'form-label' }, 'Role'), h('select', { className: 'input', value: form.role, onChange: function(e) { setForm(function(f) { return Object.assign({}, f, { role: e.target.value }); }); } }, h('option', { value: 'viewer' }, 'Viewer'), h('option', { value: 'member' }, 'Member'), h('option', { value: 'admin' }, 'Admin'), h('option', { value: 'owner' }, 'Owner'))),
|
|
332
|
-
|
|
333
|
-
|
|
353
|
+
// Inline permissions for member/viewer
|
|
354
|
+
(form.role === 'member' || form.role === 'viewer') && h('div', { style: { marginTop: 4 } },
|
|
355
|
+
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
|
|
356
|
+
h('label', { className: 'form-label', style: { marginBottom: 0 } }, 'Page Permissions'),
|
|
357
|
+
h('button', { type: 'button', className: 'btn btn-ghost btn-sm', onClick: function() { setShowCreatePerms(!showCreatePerms); }, style: { fontSize: 11 } }, showCreatePerms ? 'Hide' : 'Customize')
|
|
358
|
+
),
|
|
359
|
+
!showCreatePerms && h('div', { style: { fontSize: 12, color: 'var(--text-muted)', marginTop: 4 } }, 'Full access (default). Click "Customize" to restrict.'),
|
|
360
|
+
showCreatePerms && pageRegistry && h('div', { style: { maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 6, marginTop: 8 } },
|
|
361
|
+
Object.keys(pageRegistry).map(function(pid) {
|
|
362
|
+
var page = pageRegistry[pid];
|
|
363
|
+
var grants = form.permissions === '*' ? null : form.permissions;
|
|
364
|
+
var checked = !grants || (grants && grants[pid]);
|
|
365
|
+
return h('div', { key: pid, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 10px', fontSize: 12, cursor: 'pointer' }, onClick: function() {
|
|
366
|
+
setForm(function(f) {
|
|
367
|
+
var current = f.permissions === '*' ? (function() { var a = {}; Object.keys(pageRegistry).forEach(function(p) { a[p] = true; }); return a; })() : Object.assign({}, f.permissions);
|
|
368
|
+
if (current[pid]) { delete current[pid]; } else { current[pid] = true; }
|
|
369
|
+
if (Object.keys(current).length === Object.keys(pageRegistry).length) return Object.assign({}, f, { permissions: '*' });
|
|
370
|
+
return Object.assign({}, f, { permissions: current });
|
|
371
|
+
});
|
|
372
|
+
} },
|
|
373
|
+
h('input', { type: 'checkbox', checked: checked, readOnly: true, style: { width: 14, height: 14, accentColor: 'var(--primary)' } }),
|
|
374
|
+
h('span', null, page.label)
|
|
375
|
+
);
|
|
376
|
+
})
|
|
377
|
+
)
|
|
378
|
+
),
|
|
379
|
+
(form.role === 'owner' || form.role === 'admin') && h('div', { style: { marginTop: 8, padding: 8, background: 'var(--info-soft)', borderRadius: 'var(--radius)', fontSize: 11, color: 'var(--info)' } },
|
|
380
|
+
'Owner and Admin roles always have full access to all pages.'
|
|
334
381
|
)
|
|
335
382
|
),
|
|
336
383
|
|
package/src/db/adapter.ts
CHANGED
|
@@ -66,6 +66,7 @@ export interface User {
|
|
|
66
66
|
totpEnabled?: boolean; // Whether 2FA is active
|
|
67
67
|
totpBackupCodes?: string; // JSON array of hashed backup codes
|
|
68
68
|
permissions?: any; // '*' or { pageId: true | string[] }
|
|
69
|
+
mustResetPassword?: boolean;
|
|
69
70
|
createdAt: Date;
|
|
70
71
|
updatedAt: Date;
|
|
71
72
|
lastLoginAt?: Date;
|
package/src/db/postgres.ts
CHANGED
|
@@ -190,6 +190,7 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
190
190
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN DEFAULT FALSE;
|
|
191
191
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_backup_codes TEXT;
|
|
192
192
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS permissions JSONB DEFAULT '"*"';
|
|
193
|
+
ALTER TABLE users ADD COLUMN IF NOT EXISTS must_reset_password BOOLEAN DEFAULT FALSE;
|
|
193
194
|
`).catch(() => {});
|
|
194
195
|
await client.query('COMMIT');
|
|
195
196
|
} catch (err) {
|
|
@@ -706,7 +707,8 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
706
707
|
id: r.id, email: r.email, name: r.name, role: r.role,
|
|
707
708
|
passwordHash: r.password_hash, ssoProvider: r.sso_provider, ssoSubject: r.sso_subject,
|
|
708
709
|
totpSecret: r.totp_secret, totpEnabled: !!r.totp_enabled, totpBackupCodes: r.totp_backup_codes,
|
|
709
|
-
permissions: r.permissions != null ? (typeof r.permissions === 'string' ? JSON.parse(r.permissions) : r.permissions) : '*',
|
|
710
|
+
permissions: r.permissions != null ? (typeof r.permissions === 'string' ? (() => { try { return JSON.parse(r.permissions); } catch { return '*'; } })() : r.permissions) : '*',
|
|
711
|
+
mustResetPassword: !!r.must_reset_password,
|
|
710
712
|
createdAt: new Date(r.created_at), updatedAt: new Date(r.updated_at),
|
|
711
713
|
lastLoginAt: r.last_login_at ? new Date(r.last_login_at) : undefined,
|
|
712
714
|
};
|
package/src/db/sqlite.ts
CHANGED
|
@@ -60,9 +60,8 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
60
60
|
`INSERT OR IGNORE INTO company_settings (id, name, subdomain) VALUES ('default', 'My Company', 'my-company')`
|
|
61
61
|
).run();
|
|
62
62
|
// Add permissions column if missing
|
|
63
|
-
try {
|
|
64
|
-
|
|
65
|
-
} catch { /* column already exists */ }
|
|
63
|
+
try { this.db.exec(`ALTER TABLE users ADD COLUMN permissions TEXT DEFAULT '"*"'`); } catch { /* exists */ }
|
|
64
|
+
try { this.db.exec(`ALTER TABLE users ADD COLUMN must_reset_password INTEGER DEFAULT 0`); } catch { /* exists */ }
|
|
66
65
|
});
|
|
67
66
|
tx();
|
|
68
67
|
}
|