mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
feat: items #9-13 — password change, webmail SSO, DKIM live, file manager security, cache busting
#9 auth.php: add self-service change-password action (current+new+confirm) accounts.php: fix admin change-password — accept account_id, fetch username for chpasswd (was using int ID), add Auth::require('admin') guard user.js: add Change Password page + navItem + submitChangePassword() #10 EmailManager: store AES-256-CBC enc_password alongside SHA512-CRYPT hash webmail.php: rewrite login-url to use webmail_sso_tokens table novacpx-sso.php: Roundcube SSO bridge (validate token, decrypt, autosubmit) Migration 005: add enc_password column + webmail_sso_tokens table #11 opendkim: installed, configured (/etc/opendkim.conf, signing.table, key.table, trusted.hosts), socket at /var/spool/postfix/opendkim/, Postfix milter wired, service enabled+running, key generation verified #12 files.php: fix safe_path() for non-existent paths (write/mkdir), add safe_path_new() helper using parent-dir realpath check, fix delete guard (block deleting account root dirs), fix rename destination, clamp chmod to 0777 #13 nova.js: api() handles network errors, 429 rate-limit with retry-after, non-JSON responses (PHP fatal pages) — graceful error instead of throw admin/user/reseller index.php: filemtime-based cache-busting on all assets Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,14 +8,31 @@ window.Nova = (() => {
|
||||
const { method = 'GET', body, params } = opts;
|
||||
let url = `/api/${endpoint}/${action}`;
|
||||
if (params) url += '?' + new URLSearchParams(params);
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Nova.api network error [${endpoint}/${action}]:`, e);
|
||||
return { success: false, message: 'Network error — check your connection' };
|
||||
}
|
||||
if (res.status === 401) { location.href = '/?redirect=' + encodeURIComponent(location.pathname); return null; }
|
||||
return res.json();
|
||||
if (res.status === 429) {
|
||||
const reset = res.headers.get('X-RateLimit-Reset');
|
||||
const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
|
||||
return { success: false, message: `Rate limited — try again in ${wait}s` };
|
||||
}
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
console.error(`Nova.api non-JSON from [${endpoint}/${action}] (HTTP ${res.status}):`, text.slice(0, 500));
|
||||
return { success: false, message: `Server error (HTTP ${res.status}) — see browser console` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,6 +69,7 @@ const userPages = {
|
||||
files,
|
||||
stats: statsPage,
|
||||
backups,
|
||||
'change-password': changePasswordPage,
|
||||
};
|
||||
|
||||
/* ── Dashboard ───────────────────────────────────────────────────────────── */
|
||||
@@ -761,17 +762,18 @@ window.createBackup = () => Nova.toast('Backup request submitted — you will be
|
||||
|
||||
/* ── Navigation ─────────────────────────────────────────────────────────── */
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: 'ni-dashboard' },
|
||||
{ id: 'domains', label: 'Domains', icon: 'ni-domains' },
|
||||
{ id: 'email', label: 'Email', icon: 'ni-email' },
|
||||
{ id: 'databases', label: 'Databases', icon: 'ni-databases' },
|
||||
{ id: 'ftp', label: 'FTP', icon: 'ni-ftp' },
|
||||
{ id: 'ssl', label: 'SSL / TLS', icon: 'ni-ssl' },
|
||||
{ id: 'php', label: 'PHP', icon: 'ni-php' },
|
||||
{ id: 'cron', label: 'Cron Jobs', icon: 'ni-cron' },
|
||||
{ id: 'files', label: 'File Manager', icon: 'ni-files' },
|
||||
{ id: 'stats', label: 'Statistics', icon: 'ni-stats' },
|
||||
{ id: 'backups', label: 'Backups', icon: 'ni-backups' },
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: 'ni-dashboard' },
|
||||
{ id: 'domains', label: 'Domains', icon: 'ni-domains' },
|
||||
{ id: 'email', label: 'Email', icon: 'ni-email' },
|
||||
{ id: 'databases', label: 'Databases', icon: 'ni-databases' },
|
||||
{ id: 'ftp', label: 'FTP', icon: 'ni-ftp' },
|
||||
{ id: 'ssl', label: 'SSL / TLS', icon: 'ni-ssl' },
|
||||
{ id: 'php', label: 'PHP', icon: 'ni-php' },
|
||||
{ id: 'cron', label: 'Cron Jobs', icon: 'ni-cron' },
|
||||
{ id: 'files', label: 'File Manager', icon: 'ni-files' },
|
||||
{ id: 'stats', label: 'Statistics', icon: 'ni-stats' },
|
||||
{ id: 'backups', label: 'Backups', icon: 'ni-backups' },
|
||||
{ id: 'change-password', label: 'Change Password', icon: 'ni-lock' },
|
||||
];
|
||||
|
||||
let _activePage = 'dashboard';
|
||||
@@ -795,6 +797,50 @@ window.userNav = (page) => {
|
||||
if (userPages[page]) userPages[page](content);
|
||||
};
|
||||
|
||||
/* ── Change Password ─────────────────────────────────────────────────────── */
|
||||
async function changePasswordPage(el) {
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h2 class="page-title">Change Password</h2></div>
|
||||
<div class="card" style="max-width:480px">
|
||||
<div class="card-header"><span class="card-title">Update Your Password</span></div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Current Password</label>
|
||||
<input type="password" id="cp-current" class="form-control" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">New Password <span style="color:var(--muted);font-size:.8rem">(min 8 chars)</span></label>
|
||||
<input type="password" id="cp-new" class="form-control" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<input type="password" id="cp-confirm" class="form-control" autocomplete="new-password">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="submitChangePassword()">Update Password</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
window.submitChangePassword = async () => {
|
||||
const current = document.getElementById('cp-current')?.value;
|
||||
const newPass = document.getElementById('cp-new')?.value;
|
||||
const confirm = document.getElementById('cp-confirm')?.value;
|
||||
if (!current || !newPass || !confirm) { Nova.toast('All fields required', 'error'); return; }
|
||||
if (newPass !== confirm) { Nova.toast('New passwords do not match', 'error'); return; }
|
||||
const res = await Nova.api('auth', 'change-password', {
|
||||
method: 'POST',
|
||||
body: { current_password: current, new_password: newPass, confirm_password: confirm },
|
||||
});
|
||||
if (res?.success) {
|
||||
Nova.toast('Password updated successfully', 'success');
|
||||
document.getElementById('cp-current').value = '';
|
||||
document.getElementById('cp-new').value = '';
|
||||
document.getElementById('cp-confirm').value = '';
|
||||
} else {
|
||||
Nova.toast(res?.message || 'Failed to update password', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Boot ────────────────────────────────────────────────────────────────── */
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const ok = await initUser();
|
||||
|
||||
Reference in New Issue
Block a user