Files
aptool/templates/admin.html
kamaji 67e35c298a Make Nextcloud user and password mandatory for all sites
- NC User and NC Pass are now required fields when adding a site
- Auto-provision Nextcloud user on site creation
- Client-side validation enforces all fields in both Add and Edit forms
- Updated placeholder text to reflect required status
- Sites without NC creds now show Not set badge instead of Global

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:01:18 -06:00

519 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APtool Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 16px;
background: #1a1a2e;
color: #e0e0e0;
}
.container {
max-width: 600px;
margin: 0 auto;
}
/* Header */
.header {
text-align: center;
margin-bottom: 20px;
}
.header h1 {
font-size: 1.5rem;
margin: 0 0 4px;
}
.badge {
display: inline-block;
background: #2563eb;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.header a {
display: block;
margin-top: 8px;
color: #999;
font-size: 0.85rem;
text-decoration: none;
}
.header a:hover {
color: #e0e0e0;
}
/* Status banner */
#status-banner {
display: none;
padding: 12px;
border-radius: 8px;
text-align: center;
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 16px;
}
#status-banner.success {
display: block;
background: #064e3b;
color: #6ee7b7;
}
#status-banner.error {
display: block;
background: #3b1111;
color: #fca5a5;
}
/* Cards */
.card {
background: #16213e;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.card h3 {
margin: 0 0 8px;
font-size: 1rem;
}
.card .info {
font-size: 0.9rem;
color: #aaa;
margin-bottom: 4px;
}
.card .info span {
font-weight: 600;
color: #e0e0e0;
}
/* Status badges */
.nc-badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
margin-left: 4px;
vertical-align: middle;
}
.nc-badge.exists {
background: #064e3b;
color: #6ee7b7;
}
.nc-badge.missing {
background: #3b1111;
color: #fca5a5;
}
.nc-badge.global {
background: #1e3a5f;
color: #93c5fd;
}
/* Buttons */
.btn {
display: inline-block;
padding: 8px 14px;
font-size: 0.85rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
margin-right: 6px;
margin-top: 8px;
}
.btn-blue {
background: #2563eb;
color: #fff;
}
.btn-blue:active { background: #1d4ed8; }
.btn-outline {
background: #16213e;
color: #2563eb;
border: 2px solid #2563eb;
}
.btn-outline:active { background: #1a2744; }
.btn-red {
background: #16213e;
color: #f87171;
border: 2px solid #dc2626;
}
.btn-red:active { background: #2a1111; }
.btn-green {
background: #059669;
color: #fff;
}
.btn-green:active { background: #047857; }
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-gen {
padding: 8px 10px;
font-size: 0.8rem;
white-space: nowrap;
}
.card-actions {
margin-top: 10px;
border-top: 1px solid #2a2a4a;
padding-top: 10px;
}
/* Add form */
.add-form h3 {
margin: 0 0 12px;
font-size: 1rem;
}
.form-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.form-row input {
flex: 1;
padding: 10px;
font-size: 0.9rem;
border: 1px solid #2a2a4a;
border-radius: 6px;
background: #0f3460;
color: #e0e0e0;
}
.form-row input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
/* Edit modal overlay */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
justify-content: center;
align-items: center;
padding: 16px;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: #16213e;
border-radius: 12px;
padding: 24px;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.modal h3 {
margin: 0 0 16px;
font-size: 1.1rem;
}
.modal label {
display: block;
font-weight: 600;
font-size: 0.85rem;
margin-bottom: 4px;
margin-top: 10px;
}
.modal input {
width: 100%;
padding: 10px;
font-size: 0.9rem;
border: 1px solid #2a2a4a;
border-radius: 6px;
background: #0f3460;
color: #e0e0e0;
}
.modal input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
.modal-actions {
margin-top: 16px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
</style>
</head>
<body>
<div class="container">
<div style="text-align:center;color:#999;font-size:0.8rem;margin-bottom:8px;">JCP Wifi Migration 2026</div>
<!-- Header -->
<div class="header">
<h1>APtool</h1>
<span class="badge">Admin</span>
<a href="/admin/logout">Log out</a>
</div>
<!-- Status banner (shown by JS after operations) -->
<div id="status-banner"></div>
<!-- Add Site card -->
<div class="card add-form">
<h3>Add Site</h3>
<div class="form-row">
<input type="text" id="add-site" placeholder="Site (4 digits)" maxlength="4" inputmode="numeric">
<input type="text" id="add-pin" placeholder="PIN" inputmode="numeric">
</div>
<div class="form-row">
<input type="text" id="add-nc-user" placeholder="NC User">
<input type="text" id="add-nc-pass" placeholder="NC Pass">
<button type="button" class="btn btn-outline btn-gen" onclick="document.getElementById('add-nc-pass').value = generatePassword()">Gen</button>
</div>
<button class="btn btn-blue" onclick="addSite()">Add Site</button>
</div>
<!-- Site cards -->
{% for site in sites %}
<div class="card site-card" data-id="{{ site.id }}">
<h3>Site {{ site.id }}</h3>
<div class="info">PIN: <span>{{ site.pin }}</span></div>
<div class="info">
NC User:
{% if site.nc_user %}
<span>{{ site.nc_user }}</span>
{% if site.nc_exists == true %}
<span class="nc-badge exists">Exists</span>
{% elif site.nc_exists == false %}
<span class="nc-badge missing">Not found</span>
{% endif %}
{% else %}
<span class="nc-badge missing">Not set</span>
{% endif %}
</div>
<div class="card-actions">
{% if site.nc_user and site.nc_exists == false %}
<button class="btn btn-green" onclick="provisionUser('{{ site.id }}', this)">Create User</button>
{% endif %}
<button class="btn btn-outline" onclick="openEdit('{{ site.id }}', '{{ site.pin }}', '{{ site.nc_user or '' }}', '{{ site.nc_pass or '' }}')">Edit</button>
<button class="btn btn-red" onclick="deleteSite('{{ site.id }}', this)">Delete</button>
</div>
</div>
{% endfor %}
{% if not sites %}
<div class="card" style="text-align:center;color:#666;">
No sites configured. Add one above.
</div>
{% endif %}
<div style="text-align:center;margin-top:24px;">
<a href="/login" style="color:#666;font-size:0.8rem;text-decoration:none;">Tech Login</a>
</div>
</div>
<!-- Edit Modal -->
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<h3>Edit Site <span id="edit-site-id"></span></h3>
<label for="edit-pin">PIN</label>
<input type="text" id="edit-pin" inputmode="numeric">
<label for="edit-nc-user">NC User</label>
<input type="text" id="edit-nc-user">
<label for="edit-nc-pass">NC Pass</label>
<div style="display:flex;gap:8px;">
<input type="text" id="edit-nc-pass" style="flex:1;">
<button type="button" class="btn btn-outline btn-gen" onclick="document.getElementById('edit-nc-pass').value = generatePassword()">Gen</button>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeEdit()">Cancel</button>
<button class="btn btn-blue" onclick="saveEdit()">Save</button>
</div>
</div>
</div>
<script>
function generatePassword() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const arr = new Uint8Array(13);
crypto.getRandomValues(arr);
return Array.from(arr, b => chars[b % chars.length]).join('');
}
const banner = document.getElementById('status-banner');
function showBanner(msg, type) {
banner.textContent = msg;
banner.className = type; // 'success' or 'error'
setTimeout(() => { banner.className = ''; banner.textContent = ''; }, 4000);
}
// ---- Add Site ----
async function addSite() {
const site = document.getElementById('add-site').value.trim();
const pin = document.getElementById('add-pin').value.trim();
const nc_user = document.getElementById('add-nc-user').value.trim();
const nc_pass = document.getElementById('add-nc-pass').value.trim();
if (!site || !pin || !nc_user || !nc_pass) {
showBanner('All fields are required', 'error');
return;
}
try {
const resp = await fetch('/admin/api/sites', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({site, pin, nc_user, nc_pass})
});
const data = await resp.json();
if (data.ok) {
if (data.nc_msg) showBanner(data.nc_msg, 'success');
setTimeout(() => window.location.reload(), 1500);
} else {
showBanner(data.error || 'Failed to add site', 'error');
}
} catch (err) {
showBanner('Network error', 'error');
}
}
// ---- Delete Site ----
async function deleteSite(id, btn) {
if (!confirm('Delete site ' + id + '? Uploaded files will be preserved.')) return;
btn.disabled = true;
btn.textContent = 'Deleting...';
try {
const resp = await fetch('/admin/api/sites/' + id, {method: 'DELETE'});
const data = await resp.json();
if (data.ok) {
const card = btn.closest('.site-card');
if (card) card.remove();
showBanner('Site ' + id + ' deleted', 'success');
} else {
showBanner(data.error || 'Failed to delete', 'error');
btn.disabled = false;
btn.textContent = 'Delete';
}
} catch (err) {
showBanner('Network error', 'error');
btn.disabled = false;
btn.textContent = 'Delete';
}
}
// ---- Provision NC User ----
async function provisionUser(id, btn) {
btn.disabled = true;
btn.textContent = 'Creating...';
try {
const resp = await fetch('/admin/api/sites/' + id + '/provision', {method: 'POST'});
const data = await resp.json();
if (data.ok) {
showBanner(data.message || 'User created', 'success');
window.location.reload();
} else {
showBanner(data.error || 'Failed to create user', 'error');
btn.disabled = false;
btn.textContent = 'Create User';
}
} catch (err) {
showBanner('Network error', 'error');
btn.disabled = false;
btn.textContent = 'Create User';
}
}
// ---- Edit Modal ----
let editingSiteId = null;
function openEdit(id, pin, nc_user, nc_pass) {
editingSiteId = id;
document.getElementById('edit-site-id').textContent = id;
document.getElementById('edit-pin').value = pin;
document.getElementById('edit-nc-user').value = nc_user;
document.getElementById('edit-nc-pass').value = nc_pass;
document.getElementById('edit-modal').classList.add('active');
}
function closeEdit() {
document.getElementById('edit-modal').classList.remove('active');
editingSiteId = null;
}
async function saveEdit() {
if (!editingSiteId) return;
const pin = document.getElementById('edit-pin').value.trim();
const nc_user = document.getElementById('edit-nc-user').value.trim();
const nc_pass = document.getElementById('edit-nc-pass').value.trim();
if (!pin || !nc_user || !nc_pass) {
showBanner('PIN, NC User, and NC Pass are required', 'error');
return;
}
try {
const resp = await fetch('/admin/api/sites/' + editingSiteId, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pin, nc_user, nc_pass})
});
const data = await resp.json();
if (data.ok) {
window.location.reload();
} else {
showBanner(data.error || 'Failed to save', 'error');
}
} catch (err) {
showBanner('Network error', 'error');
}
}
// Close modal on overlay click
document.getElementById('edit-modal').addEventListener('click', function(e) {
if (e.target === this) closeEdit();
});
</script>
<div style="text-align:center;margin-top:32px;color:#555;font-size:0.7rem;">&copy; 2026 Mack Allison, sdAnywhere LLC (with Claude Code)</div>
</body>
</html>