Files

639 lines
26 KiB
Plaintext

<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin(['admin', 'app_sso_admin']);
var messagingTypes = {};
var messagingPlugins = [];
$(document).ready(function() {
loadConf();
loadProxyConf();
loadTos();
loadMessagingPlugins();
loadApps();
});
async function loadConf() {
try {
const data = await app.api.get('conf');
// Populate SMTP
if (data.smtp) {
$('#smtp-host').val(data.smtp.host || '');
$('#smtp-port').val(data.smtp.port || 587);
$('#smtp-user').val(data.smtp.user || '');
$('#smtp-pass').val(data.smtp.pass || '');
$('#smtp-from').val(data.smtp.from || '');
$('#smtp-secure').prop('checked', !!data.smtp.secure);
}
// Populate OAuth
if (data.oauth) {
$('#oauth-issuer').val(data.oauth.issuer || '');
$('#oauth-jwtsecret').val(data.oauth.jwtSecret || '');
if (data.oauth.token_lifetime) {
$('#oauth-token-access').val(data.oauth.token_lifetime.access_token || 3600);
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
}
}
// Populate SMS (VoIP.ms)
if (data.voipms) {
$('#voipms-username').val(data.voipms.username || '');
$('#voipms-did').val(data.voipms.did || '');
$('#voipms-password').val(data.voipms.password || '');
}
} catch (error) {
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
}
}
async function saveConf() {
const btn = $('#btn-save');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = {
smtp: {
host: $('#smtp-host').val(),
port: parseInt($('#smtp-port').val(), 10) || 587,
user: $('#smtp-user').val(),
pass: $('#smtp-pass').val(),
from: $('#smtp-from').val(),
secure: $('#smtp-secure').is(':checked')
},
oauth: {
issuer: $('#oauth-issuer').val(),
jwtSecret: $('#oauth-jwtsecret').val(),
token_lifetime: {
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
}
},
voipms: {
username: $('#voipms-username').val(),
did: $('#voipms-did').val(),
password: $('#voipms-password').val()
}
};
try {
await app.api.post('conf', payload);
app.messages.toast('Configuration saved successfully!', 'success');
} catch (error) {
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
} finally {
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Configuration');
}
}
async function sendTestEmail() {
const to = $('#test-email-to').val().trim();
if (!to) {
app.messages.toast('Please enter a recipient email address', 'warning');
return;
}
const btn = $('#btn-test-email');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try {
const payload = {
smtp: {
host: $('#smtp-host').val(),
port: parseInt($('#smtp-port').val(), 10) || 587,
user: $('#smtp-user').val(),
pass: $('#smtp-pass').val(),
from: $('#smtp-from').val(),
secure: $('#smtp-secure').is(':checked')
}
};
await app.api.post('conf', payload);
const result = await app.api.post('conf/test-email', { to });
app.messages.toast(result.message || 'Test email sent!', 'success');
$('#test-email-to').val('');
} catch (error) {
app.messages.toast('Failed to send test email: ' + (error.message || 'Unknown error'), 'danger');
} finally {
btn.prop('disabled', false).html(originalHtml);
}
}
async function sendTestSms() {
const to = $('#test-sms-to').val().trim();
if (!to) {
app.messages.toast('Please enter a recipient phone number', 'warning');
return;
}
const btn = $('#btn-test-sms');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try {
const payload = {
voipms: {
username: $('#voipms-username').val(),
did: $('#voipms-did').val(),
password: $('#voipms-password').val()
}
};
await app.api.post('conf', payload);
const result = await app.api.post('conf/test-sms', { to });
app.messages.toast(result.message || 'Test SMS sent!', 'success');
$('#test-sms-to').val('');
} catch (error) {
app.messages.toast('Failed to send test SMS: ' + (error.message || 'Unknown error'), 'danger');
} finally {
btn.prop('disabled', false).html(originalHtml);
}
}
function togglePassword(id) {
const el = document.getElementById(id);
if (el.type === 'password') {
el.type = 'text';
} else {
el.type = 'password';
}
}
async function loadProxyConf() {
try {
const data = await app.api.get('conf/proxy');
if (data.oidc) {
$('#proxy-issuer').val(data.oidc.issuer || '');
$('#proxy-client-id').val(data.oidc.clientId || '');
$('#proxy-client-secret').val(data.oidc.clientSecret || '');
}
if (data.ldap) {
$('#proxy-ldap-bindpass').val(data.ldap.bindPassword || '');
}
} catch (error) {
console.error('Failed to load Proxy conf:', error);
}
}
async function saveProxyConf() {
const btn = $('#btn-save-proxy');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = {
oidc: {
issuer: $('#proxy-issuer').val(),
clientId: $('#proxy-client-id').val(),
clientSecret: $('#proxy-client-secret').val()
},
ldap: {
bindPassword: $('#proxy-ldap-bindpass').val()
}
};
try {
await app.api.post('conf/proxy', payload);
app.messages.toast('Proxy configuration saved securely to OpenBao!', 'success');
} catch (error) {
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
} finally {
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Proxy Secrets');
}
}
async function loadTos() {
try {
const tos = await app.tos.get();
if (tos && tos.content) {
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
}
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
// ── Messaging Plugins ──────────────────────────────────────────────
function loadMessagingPlugins() {
app.api.get('plugins/types', function(err, res) {
if (!err && res && res.results) {
(res.results || []).forEach(t => { messagingTypes[t.type] = t; });
}
app.api.get('plugins', function(err, res) {
if (err) return;
messagingPlugins = (res.results || []).filter(p => p.category === 'messaging');
renderMessagingPlugins();
});
});
}
function renderMessagingPlugins() {
const $list = $('#messaging-plugins-list').empty();
if (messagingPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-4"><i class="fas fa-plug text-black-50 fs-2 mb-2"></i><br>No messaging plugins configured.</div>');
return;
}
messagingPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
const card = `
<div class="card mb-3 border shadow-sm">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
<button class="btn btn-sm btn-outline-primary" onclick="togglePlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePlugin('${p.id}')"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
`;
$list.append(card);
});
}
async function togglePlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
await app.api.post(`plugins/${id}/${endpoint}`, {});
app.messages.toast(`Plugin ${state ? 'loaded' : 'unloaded'} successfully`, 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
}
}
async function deletePlugin(id) {
const ok = await app.messages.confirm('Are you sure you want to delete this plugin instance?');
if (!ok) return;
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Plugin deleted', 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
}
}
async function mintApp() {
const errorEl = document.getElementById('app-error');
errorEl.classList.add('d-none');
const name = document.getElementById('app-name-input').value.trim();
if (!name) {
errorEl.textContent = 'App name is required';
errorEl.classList.remove('d-none');
return;
}
try {
const res = await fetch('/api/vault/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() },
body: JSON.stringify({ name })
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${text}`);
}
const result = await res.json();
document.getElementById('app-token').textContent = result.token;
document.getElementById('app-result-card').classList.remove('d-none');
loadApps();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
async function loadApps() {
const $list = document.getElementById('apps-list');
if (!$list) return;
$list.innerHTML = '<div class="text-muted small p-2">Loading apps…</div>';
try {
const res = await fetch('/api/vault/apps', {
headers: { 'auth-token': app.auth.getToken() }
});
if (!res.ok) { $list.innerHTML = '<div class="text-danger small p-2">Failed to load app tokens.</div>'; return; }
const { apps = [] } = await res.json();
if (!apps.length) { $list.innerHTML = '<div class="text-muted small p-2">No external app tokens minted yet.</div>'; return; }
$list.innerHTML = '<div class="list-group list-group-flush">' + apps.map(a => {
const ok = !a.lastError;
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
return `<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
</div>
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
</div>`;
}).join('') + '</div>';
} catch (err) {
$list.innerHTML = '<div class="text-danger small p-2">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
}
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied to clipboard', 'success'));
}
</script>
<div class="container mt-4">
<div class="row">
<div class="col-12">
<div class="card shadow">
<!-- Header with Sub-Nav Tabs matching directory.ejs -->
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<ul class="nav nav-tabs card-header-tabs" id="confTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#pane-oauth" type="button" role="tab">
<i class="fas fa-key text-success me-1"></i> OAuth & JWT
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#pane-smtp" type="button" role="tab">
<i class="fas fa-envelope text-primary me-1"></i> Email (SMTP)
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#pane-sms" type="button" role="tab">
<i class="fas fa-comment-sms text-info me-1"></i> SMS & Messaging
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#pane-proxy" type="button" role="tab">
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="apps-tab" data-bs-toggle="tab" data-bs-target="#pane-apps" type="button" role="tab">
<i class="fas fa-key text-warning me-1"></i> App Tokens
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
</button>
</li>
</ul>
<div>
<button class="btn btn-sm btn-outline-secondary me-1" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
<button id="btn-save" class="btn btn-sm btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
</div>
</div>
<div class="card-body p-4">
<div class="tab-content" id="confTabContent">
<!-- OAuth & JWT Tab -->
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
<p class="text-muted small">Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.</p>
<div class="mb-3">
<label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">JWT Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Access Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-access" placeholder="3600">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh" placeholder="2592000">
</div>
</div>
</div>
<!-- SMTP Tab -->
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
<p class="text-muted small">System mail server credentials for password resets, notifications, and verification emails.</p>
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label fw-semibold">SMTP Host</label>
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Port</label>
<input type="number" class="form-control" id="smtp-port" placeholder="587">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">From Address</label>
<input type="text" class="form-control" id="smtp-from" placeholder="noreply@example.com">
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label fw-semibold" for="smtp-secure">Use Secure TLS Connection</label>
</div>
<div class="p-3 bg-light rounded border">
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-primary me-2"></i> Send Test Email</h6>
<div class="input-group">
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
<button id="btn-test-email" class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
<i class="fas fa-paper-plane me-1"></i> Send Test Email
</button>
</div>
<div class="form-text">Saves current SMTP config and sends a test message.</div>
</div>
</div>
<!-- SMS & Messaging Tab -->
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
<p class="text-muted small">Configure VoIP.ms API credentials for delivering SMS 2FA codes.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">API Username</label>
<input type="text" class="form-control" id="voipms-username">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">DID Sender Number</label>
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">API Password</label>
<div class="input-group">
<input type="password" class="form-control" id="voipms-password" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
</div>
</div>
<div class="p-3 bg-light rounded border mb-4">
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-info me-2"></i> Send Test SMS</h6>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button id="btn-test-sms" class="btn btn-outline-info" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane me-1"></i> Send Test SMS
</button>
</div>
</div>
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold"><i class="fas fa-plug text-primary me-2"></i> Messaging Plugins & Webhooks</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadMessagingPlugins()"><i class="fas fa-rotate"></i> Refresh</button>
</div>
<div id="messaging-plugins-list"></div>
</div>
<!-- Proxy Secrets Tab -->
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
<p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
<h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
<div class="mb-3">
<label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Client ID</label>
<input type="text" class="form-control" id="proxy-client-id">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Client Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
</div>
</div>
</div>
<h6 class="fw-bold text-dark mt-4 mb-2">LDAP Bind Account</h6>
<div class="mb-3">
<label class="form-label fw-semibold">Proxy Bind Password</label>
<div class="input-group">
<input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button>
</div>
</div>
<button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
</div>
<!-- External App Tokens Tab -->
<div class="tab-pane fade" id="pane-apps" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-key text-warning me-2"></i> External App Tokens (OpenBao)</h5>
<p class="text-muted small">Mint scoped OpenBao tokens for external microservices, scripts, and third-party tools (scoped to <code>secret/apps/&lt;name&gt;/*</code>).</p>
<div class="row g-4">
<div class="col-md-5">
<div class="card border shadow-sm">
<div class="card-header bg-light py-2"><h6 class="mb-0 fw-bold"><i class="fas fa-plus me-1"></i> Mint New App Token</h6></div>
<div class="card-body p-3">
<p class="text-muted small">Mints a periodic OpenBao token. The token will be displayed <strong>once</strong>.</p>
<div class="mb-3">
<label class="form-label fw-semibold">App Name</label>
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. build-agent">
<div class="form-text">Use lowercase letters, numbers, and hyphens.</div>
</div>
<button class="btn btn-primary btn-sm" onclick="mintApp()"><i class="fas fa-key me-1"></i> Mint Token</button>
<div class="alert alert-danger d-none mt-3 mb-0" id="app-error"></div>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card border shadow-sm d-none mb-3" id="app-result-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0 fw-bold text-success"><i class="fas fa-check-circle me-1"></i> Generated Token</h6>
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy me-1"></i> Copy</button>
</div>
<div class="card-body p-3">
<p class="small text-muted mb-2">Include this token in HTTP header <code>X-Vault-Token</code>:</p>
<pre id="app-token" class="bg-dark text-light p-3 rounded small font-monospace mb-0 select-all"></pre>
</div>
</div>
<div class="card border shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0 fw-bold"><i class="fa-solid fa-list me-1"></i> Active App Tokens</h6>
<button class="btn btn-sm btn-outline-secondary" onclick="loadApps()"><i class="fas fa-rotate me-1"></i> Refresh</button>
</div>
<div class="card-body p-0" id="apps-list">
<div class="text-muted small p-3">Loading apps…</div>
</div>
</div>
</div>
</div>
</div>
<!-- Terms of Service Tab -->
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
<span class="small text-muted" id="tos-meta"></span>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Terms Content (Markdown)</label>
<textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label fw-semibold" for="tos-reset-acceptance">Require all users to re-accept these terms upon next login</label>
</div>
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>