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>
This commit is contained in:
2026-02-08 00:17:26 -06:00
parent ca03f6e143
commit 99fb5ff7e7
13 changed files with 1143 additions and 493 deletions

View File

@@ -2,240 +2,368 @@
'use strict';
const Admin = {
users: [],
techUsers: [],
_activeUser: null,
async init() {
await this.loadUsers();
this.setupEventListeners();
await this.loadTechUsers();
},
setupEventListeners() {
document.getElementById('add-user-form').addEventListener('submit', (e) => {
document.getElementById('add-tech-user-form').addEventListener('submit', (e) => {
e.preventDefault();
this.createUser();
});
document.getElementById('refresh-btn').addEventListener('click', () => {
this.loadUsers();
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'));
},
async loadUsers() {
const userList = document.getElementById('user-list');
const loadingMsg = document.getElementById('loading-msg');
const errorMsg = document.getElementById('error-msg');
loadingMsg.style.display = 'block';
errorMsg.style.display = 'none';
userList.innerHTML = '';
// ── 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/users');
const response = await fetch('/api/admin/tech-users');
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to load users');
const err = await response.json();
throw new Error(err.error || 'Failed to load tech users');
}
const data = await response.json();
this.users = data.users || [];
loadingMsg.style.display = 'none';
if (this.users.length === 0) {
userList.innerHTML = '<tr><td colspan="5" class="empty-state">No users found</td></tr>';
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.users.forEach(user => {
const row = this.createUserRow(user);
userList.appendChild(row);
this.techUsers.forEach(user => {
list.appendChild(this._createUserItem(user));
});
} catch (error) {
console.error('Error loading users:', error);
loadingMsg.style.display = 'none';
errorMsg.textContent = error.message;
errorMsg.style.display = 'block';
} catch (err) {
console.error('Error loading tech users:', err);
loading.style.display = 'none';
error.textContent = err.message;
error.style.display = 'block';
}
},
createUserRow(user) {
const row = document.createElement('tr');
row.innerHTML = `
<td>${this.escapeHtml(user.id)}</td>
<td>${this.escapeHtml(user.displayname || '-')}</td>
<td>${this.escapeHtml(user.email || '-')}</td>
<td>
_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>
</td>
<td>
<div class="action-buttons">
${user.enabled ?
`<button class="btn-action btn-warning" onclick="Admin.disableUser('${user.id}')">Disable</button>` :
`<button class="btn-action btn-success" onclick="Admin.enableUser('${user.id}')">Enable</button>`
}
<button class="btn-action btn-danger" onclick="Admin.confirmDeleteUser('${user.id}')">Delete</button>
</div>
</td>
<span class="chevron"></span>
</div>
`;
return row;
item.addEventListener('click', () => this.showUserModal(user.username));
return item;
},
async createUser() {
const form = document.getElementById('add-user-form');
const submitBtn = document.getElementById('submit-btn');
const formError = document.getElementById('form-error');
const formSuccess = document.getElementById('form-success');
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const email = document.getElementById('new-email').value.trim();
const displayName = document.getElementById('new-displayname').value.trim();
// ── 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 || !password) {
formError.textContent = 'Username and password are required';
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/users', {
const response = await fetch('/api/admin/tech-users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username,
password: password,
email: email || null,
displayName: displayName || null
})
body: JSON.stringify({ username, display_name: displayName, pin })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to create user');
}
formSuccess.textContent = `User "${username}" created successfully!`;
if (!response.ok) throw new Error(result.error || 'Failed to create tech user');
formSuccess.textContent = `Tech user "${username}" created!`;
formSuccess.style.display = 'block';
form.reset();
setTimeout(() => {
this.loadUsers();
}, 1000);
} catch (error) {
console.error('Error creating user:', error);
formError.textContent = error.message;
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 User';
submitBtn.textContent = 'Create Tech User';
}
},
async enableUser(username) {
if (!confirm(`Enable user "${username}"?`)) return;
try {
const response = await fetch(`/api/admin/users/${username}/enable`, {
method: 'PUT'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to enable user');
}
this.showToast(`User "${username}" enabled`, 'success');
this.loadUsers();
} catch (error) {
console.error('Error enabling user:', error);
this.showToast(error.message, 'error');
}
// ── Helpers ──────────────────────────────────────────────────────────
closeModal(id) {
document.getElementById(id).style.display = 'none';
},
async disableUser(username) {
if (!confirm(`Disable user "${username}"?`)) return;
try {
const response = await fetch(`/api/admin/users/${username}/disable`, {
method: 'PUT'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to disable user');
}
this.showToast(`User "${username}" disabled`, 'success');
this.loadUsers();
} catch (error) {
console.error('Error disabling user:', error);
this.showToast(error.message, 'error');
}
},
confirmDeleteUser(username) {
const modal = document.getElementById('delete-modal');
const confirmBtn = document.getElementById('confirm-delete');
document.getElementById('delete-username').textContent = username;
modal.style.display = 'flex';
confirmBtn.onclick = () => {
this.deleteUser(username);
this.hideDeleteModal();
};
},
hideDeleteModal() {
document.getElementById('delete-modal').style.display = 'none';
},
async deleteUser(username) {
try {
const response = await fetch(`/api/admin/users/${username}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete user');
}
this.showToast(`User "${username}" deleted`, 'success');
this.loadUsers();
} catch (error) {
console.error('Error deleting user:', error);
this.showToast(error.message, 'error');
}
},
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);
setTimeout(() => { toast.style.display = 'none'; }, 3000);
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;