feat: #38 account settings page (user panel); #39 better default index template

#38 — User panel Account > Settings page: account info, resource usage
gauges, PHP config (version/memory/upload/exec), quick links to SSL/2FA/password.

#39 — AccountManager: dark-themed modern default index.html on account
creation; supports custom HTML template from admin Server Options
(saved as default_index_template setting, {domain}/{username} placeholders).
Admin Server Options: new card to set/reset the custom template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP9Q4kfCAYAjJnsbHBrViZ
This commit is contained in:
2026-06-22 04:21:58 +00:00
parent 5a2e81e754
commit 6f494e96fd
3 changed files with 155 additions and 3 deletions
+12 -2
View File
@@ -33,8 +33,18 @@ class AccountManager {
self::shell("sudo chmod 750 {$homeDir}");
self::shell("sudo chmod 775 {$docRoot}");
// Default index page (write as root via sudo tee)
$html = "<html><body style='font-family:sans-serif;text-align:center;padding:4rem'><h1>Welcome to {$domain}</h1><p>Hosted by NovaCPX</p></body></html>";
// Default index page — use custom template from settings if set, else built-in
$customTpl = null;
try {
$db2 = DB::getInstance();
$tplRow = $db2->fetchOne("SELECT value FROM settings WHERE key='default_index_template'");
$customTpl = $tplRow ? trim($tplRow['value']) : null;
} catch (Throwable $e) {}
$html = $customTpl
? str_replace(['{domain}', '{username}'], [$domain, $username], $customTpl)
: "<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Welcome to {$domain}</title>\n<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f1117;color:#e2e4f0;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}.wrap{padding:3rem 2rem}.domain{font-size:2rem;font-weight:700;background:linear-gradient(135deg,#6366f1,#0ea5e9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-bottom:1rem}.sub{color:#8b90a8;font-size:1rem;margin-bottom:2rem}.badge{display:inline-block;padding:.4rem 1rem;border:1px solid #2e3350;border-radius:6px;font-size:.8rem;color:#8b90a8}</style>\n</head>\n<body><div class=\"wrap\">\n<div class=\"domain\">{$domain}</div>\n<p class=\"sub\">Your website is ready. Upload your files to get started.</p>\n<span class=\"badge\">Hosted by NovaCPX</span>\n</div></body></html>";
self::shell("sudo tee " . escapeshellarg("{$docRoot}/index.html") . " > /dev/null << 'HTMLEOF'\n{$html}\nHTMLEOF");
// Wrap all DB writes in a transaction so partial failures leave no orphans
+21 -1
View File
@@ -4534,7 +4534,21 @@ async function serverOptions() {
<button class="btn btn-primary btn-sm" onclick="soSaveNS()">Save Nameservers</button>
<div id="so-ns-results" style="margin-top:1rem"></div>
</div>
</div>`;
</div>
<!-- Default Index Page Template (#39) -->
<div class="card" style="grid-column:1/-1">
<div class="card-header"><span class="card-title">Default Index Page Template</span></div>
<div class="card-body">
<p class="text-muted" style="font-size:.85rem;margin-bottom:.75rem">
HTML shown when a new hosting account is created. Use <code>{domain}</code> and <code>{username}</code> as placeholders. Leave blank to use the built-in styled template.
</p>
<textarea id="so-index-tpl" class="form-control" rows="6" style="font-family:monospace;font-size:.82rem;margin-bottom:.75rem"
placeholder="Leave blank for built-in template...">${Nova.escHtml(opts.default_index_template||'')}</textarea>
<button class="btn btn-primary btn-sm" onclick="soSaveIndexTemplate()">Save Template</button>
<button class="btn btn-ghost btn-sm" style="margin-left:.5rem" onclick="document.getElementById('so-index-tpl').value=''">Reset to Default</button>
</div>
</div>`;
}
window.soSave = (key, inputId, label) => {
@@ -4605,6 +4619,12 @@ window.soSaveNS = async () => {
Nova.toast('Nameservers saved', 'success');
};
window.soSaveIndexTemplate = async () => {
const tpl = document.getElementById('so-index-tpl')?.value || '';
const res = await Nova.api('system','save-option',{method:'POST',body:{key:'default_index_template',value:tpl}});
Nova.toast(res?.success ? 'Default template saved' : 'Save failed', res?.success?'success':'error');
};
window.soCheckNS = async () => {
const tc = document.getElementById('so-ns-results');
if (!tc) return;
+122
View File
@@ -970,6 +970,8 @@ const navGroups = [
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="9" width="4" height="4"/><rect x="7" y="9" width="4" height="4"/><rect x="12" y="9" width="4" height="4"/><rect x="7" y="4" width="4" height="4"/><path d="M22 11c0 5-3.9 9-10 9-8 0-10-7-10-7"/></svg>' },
]},
{ label: 'Account', items: [
{ id: 'account-settings', label: 'Settings',
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' },
{ id: 'change-password', label: 'Change Password',
svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
]},
@@ -1010,6 +1012,7 @@ window.userNav = (page) => {
const allItems = navGroups.flatMap(g => g.items);
// Extra pages not in nav groups
const extraPages = {
'account-settings': accountSettings,
'subdomains': userSubdomains,
'parked-domains': userParked,
};
@@ -1393,3 +1396,122 @@ window.submitParked = async () => {
if (res?.success) { Nova.toast(res.message,'success'); document.querySelector('.modal-overlay')?.remove(); loadParkedList(); }
else Nova.toast(res?.message||'Failed','error');
};
// ══ #38 ACCOUNT SETTINGS PAGE ═════════════════════════════════════════════
async function accountSettings(el) {
Nova.loadPage('account-settings', 'Account Settings', `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.25rem" id="acct-settings-grid">
<div class="loading" style="grid-column:1/-1">Loading</div>
</div>`);
const [usageRes, pkgRes, acctRes] = await Promise.all([
Nova.api('accounts', 'usage'),
Nova.api('packages', 'my'),
Nova.api('accounts', 'me'),
]);
const u = usageRes?.data || {};
const p = pkgRes?.data || {};
const a = acctRes?.data || {};
const el2 = document.getElementById('acct-settings-grid');
if (!el2) return;
el2.innerHTML = `
<!-- Account Info -->
<div class="card" style="grid-column:1/-1">
<div class="card-header"><h3 class="card-title">Account Information</h3></div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.75rem;padding:.75rem 0">
${[
['Username', a.username||'—'],
['Primary Domain', a.domain||'—'],
['Package', p.name||'Default'],
['Status', a.status ? Nova.badge(a.status, a.status==='active'?'green':'yellow') : '—'],
['PHP Version', a.php_version||'8.3'],
['Created', (a.created_at||'').split('T')[0]||'—'],
].map(([k,v])=>`<div style="background:var(--bg3);border-radius:6px;padding:.75rem">
<div style="font-size:.7rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:.25rem">${k}</div>
<div style="font-weight:600;font-size:.9rem">${v}</div>
</div>`).join('')}
</div>
</div>
<!-- Resource Usage -->
<div class="card">
<div class="card-header"><h3 class="card-title">Resource Usage</h3></div>
${[
['Disk', u.disk_used_mb||0, u.disk_limit_mb, 'MB'],
['Email', u.email_count||0, u.email_limit, 'accounts'],
['Databases', u.db_count||0, u.db_limit, 'databases'],
['FTP', u.ftp_count||0, u.ftp_limit, 'accounts'],
['Domains', u.domain_count||0, u.domain_limit, 'domains'],
].map(([name,used,limit,unit])=>{
const pct = limit>0 ? Math.min(100,Math.round(used/limit*100)) : 0;
const col = pct>90?'var(--red)':pct>70?'var(--yellow)':'var(--primary)';
return `<div style="margin-bottom:.75rem">
<div style="display:flex;justify-content:space-between;font-size:.8rem;margin-bottom:.3rem">
<span>${name}</span>
<span style="color:var(--text-muted)">${used}${limit>0?` / ${limit} ${unit}`:` ${unit}`}</span>
</div>
${limit>0?`<div style="height:5px;background:var(--bg3);border-radius:3px">
<div style="height:100%;width:${pct}%;background:${col};border-radius:3px;transition:width .3s"></div>
</div>`:''}
</div>`;
}).join('')}
</div>
<!-- PHP Settings -->
<div class="card">
<div class="card-header"><h3 class="card-title">PHP Configuration</h3></div>
<div style="display:grid;gap:.75rem;padding:.25rem 0">
<div>
<label class="form-label">PHP Version</label>
<select id="as-php-ver" class="form-control form-control-sm">
${['8.3','8.2','8.1','8.0','7.4'].map(v=>`<option value="${v}"${(a.php_version||'8.3')===v?' selected':''}>${v}</option>`).join('')}
</select>
</div>
<div><label class="form-label">Memory Limit</label>
<select id="as-mem" class="form-control form-control-sm">
${['128M','256M','512M','1G'].map(v=>`<option value="${v}"${v==='256M'?' selected':''}>${ v}</option>`).join('')}
</select>
</div>
<div><label class="form-label">Max Upload Size</label>
<select id="as-upload" class="form-control form-control-sm">
${['32M','64M','128M','256M'].map(v=>`<option value="${v}"${v==='64M'?' selected':''}>${ v}</option>`).join('')}
</select>
</div>
<div><label class="form-label">Max Execution Time (sec)</label>
<select id="as-exec" class="form-control form-control-sm">
${['30','60','120','300'].map(v=>`<option value="${v}"${v==='30'?' selected':''}>${ v}s</option>`).join('')}
</select>
</div>
<button class="btn btn-primary btn-sm" onclick="saveAccountPHP()">Save PHP Settings</button>
</div>
</div>
<!-- Security & Access -->
<div class="card" style="grid-column:1/-1">
<div class="card-header"><h3 class="card-title">Security & Access</h3></div>
<div style="display:flex;gap:1rem;flex-wrap:wrap">
<button class="btn btn-sm" onclick="userNav('change-password')">🔑 Change Password</button>
<button class="btn btn-sm" onclick="userNav('ssl')">🔒 SSL / TLS Certificates</button>
<button class="btn btn-sm" onclick="userNav('twofa')">📱 Two-Factor Auth</button>
</div>
</div>
`;
}
window.accountSettings = accountSettings;
window.saveAccountPHP = async () => {
const body = {
php_version: document.getElementById('as-php-ver')?.value,
memory_limit: document.getElementById('as-mem')?.value,
upload_max_filesize: document.getElementById('as-upload')?.value,
post_max_size: document.getElementById('as-upload')?.value,
max_execution_time: parseInt(document.getElementById('as-exec')?.value),
};
Nova.loading('Saving…');
const res = await Nova.api('php', 'update-config', { method: 'POST', body });
Nova.loadingDone();
if (res?.success) Nova.toast('PHP settings saved', 'success');
else Nova.toast(res?.message || 'Failed', 'error');
};