Files
nextsnap/app/static/js/admin.js
kamaji 99fb5ff7e7 Add dual user classes: admin + tech users with PIN login
- 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>
2026-02-08 00:17:26 -06:00

375 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;