- Add tech user management (JSON-backed CRUD with PIN auth) - Dual login: tabbed Tech Login (username+PIN) / Admin Login (NC credentials) - Admin panel: tappable user list with detail modal (enable/disable, reset PIN, reset NC password, delete) - Auto-provision Nextcloud accounts for tech users - Admin guard: tech users redirected away from admin panel - New data volume for persistent tech_users.json storage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
375 lines
15 KiB
JavaScript
375 lines
15 KiB
JavaScript
// NextSnap - Admin Panel Logic
|
||
'use strict';
|
||
|
||
const Admin = {
|
||
techUsers: [],
|
||
_activeUser: null,
|
||
|
||
async init() {
|
||
this.setupEventListeners();
|
||
await this.loadTechUsers();
|
||
},
|
||
|
||
setupEventListeners() {
|
||
document.getElementById('add-tech-user-form').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
this.createTechUser();
|
||
});
|
||
|
||
// User detail modal buttons
|
||
document.getElementById('user-modal-close').addEventListener('click', () => this.closeModal('user-modal'));
|
||
document.getElementById('user-modal-toggle-btn').addEventListener('click', () => this._handleToggle());
|
||
document.getElementById('user-modal-pin-btn').addEventListener('click', () => this._handlePinReset());
|
||
document.getElementById('user-modal-resetpw-btn').addEventListener('click', () => this._handleResetNCPassword());
|
||
document.getElementById('user-modal-delete-btn').addEventListener('click', () => this._handleDelete());
|
||
|
||
document.getElementById('user-modal-pw-toggle').addEventListener('click', () => this._toggleModalPassword());
|
||
document.getElementById('user-modal-pw-copy').addEventListener('click', () => this._copyModalPassword());
|
||
|
||
// PIN modal
|
||
document.getElementById('pin-modal-cancel').addEventListener('click', () => this.closeModal('pin-modal'));
|
||
document.getElementById('confirm-pin-reset').addEventListener('click', () => this._submitPinReset());
|
||
|
||
// Confirm modal
|
||
document.getElementById('confirm-modal-cancel').addEventListener('click', () => this.closeModal('confirm-modal'));
|
||
},
|
||
|
||
// ── Load & Render ────────────────────────────────────────────────────
|
||
|
||
async loadTechUsers() {
|
||
const list = document.getElementById('tech-user-list');
|
||
const loading = document.getElementById('tech-loading-msg');
|
||
const error = document.getElementById('tech-error-msg');
|
||
|
||
loading.style.display = 'block';
|
||
error.style.display = 'none';
|
||
list.innerHTML = '';
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/tech-users');
|
||
if (!response.ok) {
|
||
const err = await response.json();
|
||
throw new Error(err.error || 'Failed to load tech users');
|
||
}
|
||
|
||
const data = await response.json();
|
||
this.techUsers = data.tech_users || [];
|
||
loading.style.display = 'none';
|
||
|
||
if (this.techUsers.length === 0) {
|
||
list.innerHTML = '<div class="user-list-empty">No tech users yet</div>';
|
||
return;
|
||
}
|
||
|
||
this.techUsers.forEach(user => {
|
||
list.appendChild(this._createUserItem(user));
|
||
});
|
||
|
||
} catch (err) {
|
||
console.error('Error loading tech users:', err);
|
||
loading.style.display = 'none';
|
||
error.textContent = err.message;
|
||
error.style.display = 'block';
|
||
}
|
||
},
|
||
|
||
_createUserItem(user) {
|
||
const item = document.createElement('div');
|
||
item.className = 'user-list-item';
|
||
const u = this.escapeHtml(user.username);
|
||
const dn = user.display_name && user.display_name !== user.username
|
||
? this.escapeHtml(user.display_name) : '';
|
||
|
||
item.innerHTML = `
|
||
<div class="user-info">
|
||
<span class="user-id">${u}</span>
|
||
${dn ? `<span class="user-displayname">${dn}</span>` : ''}
|
||
</div>
|
||
<div class="user-right">
|
||
<span class="badge ${user.enabled ? 'badge-success' : 'badge-danger'}">
|
||
${user.enabled ? 'Enabled' : 'Disabled'}
|
||
</span>
|
||
<span class="chevron">›</span>
|
||
</div>
|
||
`;
|
||
item.addEventListener('click', () => this.showUserModal(user.username));
|
||
return item;
|
||
},
|
||
|
||
// ── User Detail Modal ────────────────────────────────────────────────
|
||
|
||
showUserModal(username) {
|
||
const user = this.techUsers.find(u => u.username === username);
|
||
if (!user) return;
|
||
this._activeUser = user;
|
||
|
||
document.getElementById('user-modal-title').textContent = user.username;
|
||
const dn = user.display_name && user.display_name !== user.username ? user.display_name : '';
|
||
document.getElementById('user-modal-displayname').textContent = dn;
|
||
|
||
// Password
|
||
const pwEl = document.getElementById('user-modal-pw');
|
||
pwEl.dataset.pw = user.nc_password;
|
||
pwEl.dataset.masked = 'true';
|
||
pwEl.textContent = '\u2022'.repeat(12);
|
||
document.getElementById('user-modal-pw-toggle').textContent = 'Show';
|
||
|
||
// Status
|
||
const statusEl = document.getElementById('user-modal-status');
|
||
statusEl.textContent = user.enabled ? 'Enabled' : 'Disabled';
|
||
statusEl.className = 'badge ' + (user.enabled ? 'badge-success' : 'badge-danger');
|
||
|
||
// Toggle button
|
||
const toggleBtn = document.getElementById('user-modal-toggle-btn');
|
||
if (user.enabled) {
|
||
toggleBtn.textContent = 'Disable User';
|
||
toggleBtn.className = 'btn btn-block btn-warning-outline';
|
||
} else {
|
||
toggleBtn.textContent = 'Enable User';
|
||
toggleBtn.className = 'btn btn-block btn-success-outline';
|
||
}
|
||
|
||
document.getElementById('user-modal').style.display = 'flex';
|
||
},
|
||
|
||
_toggleModalPassword() {
|
||
const pwEl = document.getElementById('user-modal-pw');
|
||
const btn = document.getElementById('user-modal-pw-toggle');
|
||
if (pwEl.dataset.masked === 'true') {
|
||
pwEl.textContent = pwEl.dataset.pw;
|
||
pwEl.dataset.masked = 'false';
|
||
btn.textContent = 'Hide';
|
||
} else {
|
||
pwEl.textContent = '\u2022'.repeat(12);
|
||
pwEl.dataset.masked = 'true';
|
||
btn.textContent = 'Show';
|
||
}
|
||
},
|
||
|
||
_copyModalPassword() {
|
||
const pw = document.getElementById('user-modal-pw').dataset.pw;
|
||
navigator.clipboard.writeText(pw).then(() => {
|
||
this.showToast('Password copied', 'success');
|
||
}).catch(() => {
|
||
this.showToast('Copy failed', 'error');
|
||
});
|
||
},
|
||
|
||
// ── Actions from User Modal ──────────────────────────────────────────
|
||
|
||
async _handleToggle() {
|
||
const user = this._activeUser;
|
||
if (!user) return;
|
||
const enable = !user.enabled;
|
||
|
||
try {
|
||
const response = await fetch(`/api/admin/tech-users/${user.username}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: enable })
|
||
});
|
||
if (!response.ok) {
|
||
const err = await response.json();
|
||
throw new Error(err.error || 'Failed');
|
||
}
|
||
this.closeModal('user-modal');
|
||
this.showToast(`User "${user.username}" ${enable ? 'enabled' : 'disabled'}`, 'success');
|
||
this.loadTechUsers();
|
||
} catch (err) {
|
||
this.showToast(err.message, 'error');
|
||
}
|
||
},
|
||
|
||
_handlePinReset() {
|
||
const user = this._activeUser;
|
||
if (!user) return;
|
||
this.closeModal('user-modal');
|
||
document.getElementById('pin-modal-username').textContent = user.username;
|
||
document.getElementById('reset-pin').value = '';
|
||
document.getElementById('reset-pin-confirm').value = '';
|
||
document.getElementById('pin-modal-error').style.display = 'none';
|
||
document.getElementById('pin-modal').style.display = 'flex';
|
||
},
|
||
|
||
async _submitPinReset() {
|
||
const user = this._activeUser;
|
||
if (!user) return;
|
||
const pin = document.getElementById('reset-pin').value;
|
||
const pinConfirm = document.getElementById('reset-pin-confirm').value;
|
||
const errorEl = document.getElementById('pin-modal-error');
|
||
errorEl.style.display = 'none';
|
||
|
||
if (!pin || pin.length < 4) {
|
||
errorEl.textContent = 'PIN must be at least 4 digits';
|
||
errorEl.style.display = 'block';
|
||
return;
|
||
}
|
||
if (pin !== pinConfirm) {
|
||
errorEl.textContent = 'PINs do not match';
|
||
errorEl.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/admin/tech-users/${user.username}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ pin })
|
||
});
|
||
if (!response.ok) {
|
||
const err = await response.json();
|
||
throw new Error(err.error || 'Failed to reset PIN');
|
||
}
|
||
this.closeModal('pin-modal');
|
||
this.showToast(`PIN reset for "${user.username}"`, 'success');
|
||
} catch (err) {
|
||
errorEl.textContent = err.message;
|
||
errorEl.style.display = 'block';
|
||
}
|
||
},
|
||
|
||
_handleResetNCPassword() {
|
||
const user = this._activeUser;
|
||
if (!user) return;
|
||
this.closeModal('user-modal');
|
||
this._showConfirm(
|
||
'Reset NC Password?',
|
||
`Generate a new Nextcloud password for "${user.username}"? They will need the new password to sync.`,
|
||
async () => {
|
||
try {
|
||
const response = await fetch(`/api/admin/tech-users/${user.username}/reset-password`, {
|
||
method: 'POST'
|
||
});
|
||
const result = await response.json();
|
||
if (!response.ok) throw new Error(result.error || 'Failed');
|
||
this.closeModal('confirm-modal');
|
||
this.showToast(`NC password reset for "${user.username}"`, 'success');
|
||
this.loadTechUsers();
|
||
} catch (err) {
|
||
document.getElementById('confirm-modal-error').textContent = err.message;
|
||
document.getElementById('confirm-modal-error').style.display = 'block';
|
||
}
|
||
}
|
||
);
|
||
},
|
||
|
||
_handleDelete() {
|
||
const user = this._activeUser;
|
||
if (!user) return;
|
||
this.closeModal('user-modal');
|
||
this._showConfirm(
|
||
'Delete Tech User?',
|
||
`Delete "${user.username}"? Their Nextcloud account will be disabled.`,
|
||
async () => {
|
||
try {
|
||
const response = await fetch(`/api/admin/tech-users/${user.username}`, {
|
||
method: 'DELETE'
|
||
});
|
||
if (!response.ok) {
|
||
const err = await response.json();
|
||
throw new Error(err.error || 'Failed');
|
||
}
|
||
this.closeModal('confirm-modal');
|
||
this.showToast(`Tech user "${user.username}" deleted`, 'success');
|
||
this.loadTechUsers();
|
||
} catch (err) {
|
||
document.getElementById('confirm-modal-error').textContent = err.message;
|
||
document.getElementById('confirm-modal-error').style.display = 'block';
|
||
}
|
||
}
|
||
);
|
||
},
|
||
|
||
// ── Generic Confirm Modal ────────────────────────────────────────────
|
||
|
||
_showConfirm(title, message, onConfirm) {
|
||
document.getElementById('confirm-modal-title').textContent = title;
|
||
document.getElementById('confirm-modal-msg').textContent = message;
|
||
document.getElementById('confirm-modal-error').style.display = 'none';
|
||
document.getElementById('confirm-modal-ok').onclick = onConfirm;
|
||
document.getElementById('confirm-modal').style.display = 'flex';
|
||
},
|
||
|
||
// ── Create Tech User ─────────────────────────────────────────────────
|
||
|
||
async createTechUser() {
|
||
const submitBtn = document.getElementById('tech-submit-btn');
|
||
const formError = document.getElementById('tech-form-error');
|
||
const formSuccess = document.getElementById('tech-form-success');
|
||
|
||
const username = document.getElementById('new-tech-username').value.trim();
|
||
const displayName = document.getElementById('new-tech-displayname').value.trim();
|
||
const pin = document.getElementById('new-tech-pin').value;
|
||
const pinConfirm = document.getElementById('new-tech-pin-confirm').value;
|
||
|
||
formError.style.display = 'none';
|
||
formSuccess.style.display = 'none';
|
||
|
||
if (!username || !pin) {
|
||
formError.textContent = 'Username and PIN are required';
|
||
formError.style.display = 'block';
|
||
return;
|
||
}
|
||
if (pin.length < 4) {
|
||
formError.textContent = 'PIN must be at least 4 digits';
|
||
formError.style.display = 'block';
|
||
return;
|
||
}
|
||
if (pin !== pinConfirm) {
|
||
formError.textContent = 'PINs do not match';
|
||
formError.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Creating...';
|
||
|
||
try {
|
||
const response = await fetch('/api/admin/tech-users', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, display_name: displayName, pin })
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (!response.ok) throw new Error(result.error || 'Failed to create tech user');
|
||
|
||
formSuccess.textContent = `Tech user "${username}" created!`;
|
||
formSuccess.style.display = 'block';
|
||
|
||
document.getElementById('add-tech-user-form').reset();
|
||
this.loadTechUsers();
|
||
|
||
} catch (err) {
|
||
console.error('Error creating tech user:', err);
|
||
formError.textContent = err.message;
|
||
formError.style.display = 'block';
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Create Tech User';
|
||
}
|
||
},
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────
|
||
|
||
closeModal(id) {
|
||
document.getElementById(id).style.display = 'none';
|
||
},
|
||
|
||
showToast(message, type) {
|
||
const toast = document.getElementById('toast');
|
||
toast.textContent = message;
|
||
toast.className = `toast ${type}`;
|
||
toast.style.display = 'block';
|
||
setTimeout(() => { toast.style.display = 'none'; }, 3000);
|
||
},
|
||
|
||
escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
};
|
||
|
||
window.Admin = Admin;
|