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,133 +2,169 @@
'use strict';
const Auth = {
currentTab: 'tech',
init() {
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', this.handleLogin.bind(this));
// Add input validation
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
if (usernameInput) {
usernameInput.addEventListener('blur', () => this.validateUsername());
usernameInput.addEventListener('input', () => this.clearFieldError('username'));
}
if (passwordInput) {
passwordInput.addEventListener('input', () => this.clearFieldError('password'));
}
// Tab switching
document.querySelectorAll('.login-tab').forEach(tab => {
tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
});
// Tech login form
const techForm = document.getElementById('tech-login-form');
if (techForm) {
techForm.addEventListener('submit', (e) => this.handleTechLogin(e));
}
},
validateUsername() {
const username = document.getElementById('username').value.trim();
const usernameError = document.getElementById('username-error');
const usernameInput = document.getElementById('username');
if (!username) {
usernameError.textContent = 'Username is required';
usernameInput.classList.add('error');
return false;
// Admin login form
const adminForm = document.getElementById('admin-login-form');
if (adminForm) {
adminForm.addEventListener('submit', (e) => this.handleAdminLogin(e));
}
if (username.length < 2) {
usernameError.textContent = 'Username must be at least 2 characters';
usernameInput.classList.add('error');
return false;
// Clear field errors on input
document.querySelectorAll('.form-input').forEach(input => {
input.addEventListener('input', () => {
const errorEl = document.getElementById(input.id + '-error');
if (errorEl) errorEl.textContent = '';
input.classList.remove('error');
});
});
},
switchTab(tab) {
this.currentTab = tab;
// Update tab buttons
document.querySelectorAll('.login-tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === tab);
});
// Show/hide forms
const techForm = document.getElementById('tech-login-form');
const adminForm = document.getElementById('admin-login-form');
const helpText = document.getElementById('login-help');
if (tab === 'tech') {
techForm.style.display = '';
adminForm.style.display = 'none';
helpText.innerHTML = '<strong>Tip:</strong> Use your username and PIN to login';
const field = document.getElementById('tech-username');
if (field) field.focus();
} else {
techForm.style.display = 'none';
adminForm.style.display = '';
helpText.innerHTML = '<strong>Tip:</strong> Use your Nextcloud admin credentials';
const field = document.getElementById('admin-username');
if (field) field.focus();
}
usernameError.textContent = '';
usernameInput.classList.remove('error');
return true;
// Clear errors on tab switch
document.querySelectorAll('.error-message').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.field-error').forEach(el => el.textContent = '');
document.querySelectorAll('.form-input').forEach(el => el.classList.remove('error'));
},
validatePassword() {
const password = document.getElementById('password').value;
const passwordError = document.getElementById('password-error');
const passwordInput = document.getElementById('password');
if (!password) {
passwordError.textContent = 'Password is required';
passwordInput.classList.add('error');
return false;
}
passwordError.textContent = '';
passwordInput.classList.remove('error');
return true;
_setLoading(btn, loading) {
const text = btn.querySelector('.btn-text');
const spinner = btn.querySelector('.btn-loading');
btn.disabled = loading;
text.classList.toggle('hidden', loading);
spinner.classList.toggle('hidden', !loading);
},
clearFieldError(field) {
const errorElement = document.getElementById(`${field}-error`);
const inputElement = document.getElementById(field);
if (errorElement) errorElement.textContent = '';
if (inputElement) inputElement.classList.remove('error');
},
async handleLogin(event) {
async handleTechLogin(event) {
event.preventDefault();
// Validate fields
const usernameValid = this.validateUsername();
const passwordValid = this.validatePassword();
if (!usernameValid || !passwordValid) {
const username = document.getElementById('tech-username').value.trim();
const pin = document.getElementById('tech-pin').value;
const errorMessage = document.getElementById('tech-error-message');
const btn = document.getElementById('tech-login-btn');
if (!username) {
document.getElementById('tech-username-error').textContent = 'Username is required';
document.getElementById('tech-username').classList.add('error');
return;
}
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const errorMessage = document.getElementById('error-message');
const loginBtn = document.getElementById('login-btn');
const loginBtnText = document.getElementById('login-btn-text');
const loginBtnLoading = document.getElementById('login-btn-loading');
// Clear previous error
if (!pin) {
document.getElementById('tech-pin-error').textContent = 'PIN is required';
document.getElementById('tech-pin').classList.add('error');
return;
}
errorMessage.classList.add('hidden');
errorMessage.textContent = '';
// Disable button and show loading state
loginBtn.disabled = true;
loginBtnText.classList.add('hidden');
loginBtnLoading.classList.remove('hidden');
this._setLoading(btn, true);
try {
const response = await fetch('/api/auth/login', {
const response = await fetch('/api/auth/login/tech', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, pin })
});
const data = await response.json();
if (response.ok && data.success) {
// Login successful - redirect to capture page
window.location.href = '/capture';
} else {
// Login failed - show error
const errorText = data.error || 'Login failed. Please check your credentials and try again.';
errorMessage.textContent = errorText;
errorMessage.textContent = data.error || 'Login failed. Please check your credentials.';
errorMessage.classList.remove('hidden');
// Focus back on username for retry
document.getElementById('username').focus();
}
} catch (error) {
console.error('Login error:', error);
errorMessage.textContent = 'Network error. Please check your connection and try again.';
errorMessage.classList.remove('hidden');
} finally {
// Re-enable button and restore normal state
loginBtn.disabled = false;
loginBtnText.classList.remove('hidden');
loginBtnLoading.classList.add('hidden');
this._setLoading(btn, false);
}
},
async handleAdminLogin(event) {
event.preventDefault();
const username = document.getElementById('admin-username').value.trim();
const password = document.getElementById('admin-password').value;
const errorMessage = document.getElementById('admin-error-message');
const btn = document.getElementById('admin-login-btn');
if (!username) {
document.getElementById('admin-username-error').textContent = 'Username is required';
document.getElementById('admin-username').classList.add('error');
return;
}
if (!password) {
document.getElementById('admin-password-error').textContent = 'Password is required';
document.getElementById('admin-password').classList.add('error');
return;
}
errorMessage.classList.add('hidden');
this._setLoading(btn, true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok && data.success) {
window.location.href = '/capture';
} else {
errorMessage.textContent = data.error || 'Login failed. Please check your credentials.';
errorMessage.classList.remove('hidden');
}
} catch (error) {
console.error('Login error:', error);
errorMessage.textContent = 'Network error. Please check your connection and try again.';
errorMessage.classList.remove('hidden');
} finally {
this._setLoading(btn, false);
}
},
async checkStatus() {
try {
const response = await fetch('/api/auth/status');
@@ -139,14 +175,13 @@ const Auth = {
return null;
}
},
async logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/';
} catch (error) {
console.error('Logout error:', error);
// Redirect anyway
window.location.href = '/';
}
}