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:
@@ -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;
|
||||
|
||||
@@ -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 = '/';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// NextSnap Service Worker
|
||||
// Provides offline-first caching for the app shell
|
||||
|
||||
const CACHE_VERSION = 'nextsnap-v27';
|
||||
const APP_SHELL_CACHE = 'nextsnap-shell-v23';
|
||||
const RUNTIME_CACHE = 'nextsnap-runtime-v23';
|
||||
const CACHE_VERSION = 'nextsnap-v33';
|
||||
const APP_SHELL_CACHE = 'nextsnap-shell-v29';
|
||||
const RUNTIME_CACHE = 'nextsnap-runtime-v29';
|
||||
|
||||
// Offline fallback page with bottom nav bar so user can navigate to cached pages
|
||||
const OFFLINE_PAGE = `<!DOCTYPE html>
|
||||
|
||||
Reference in New Issue
Block a user