XIVLauncherRemoteOTP/static/app.js

420 lines
17 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
const addForm = document.getElementById('add-form');
const instancesList = document.getElementById('instances-list');
const refreshBtn = document.getElementById('refresh-btn');
const portInput = document.getElementById('port');
const ufwCmd = document.getElementById('ufw-cmd');
const iptablesCmd = document.getElementById('iptables-cmd');
const loginOverlay = document.getElementById('login-overlay');
const loginForm = document.getElementById('login-form');
const loginError = document.getElementById('login-error');
const logoutBtn = document.getElementById('logout-btn');
const mainNav = document.getElementById('main-nav');
const navAdmin = document.getElementById('nav-admin');
const usersList = document.getElementById('users-list');
const createUserForm = document.getElementById('create-user-form');
const changePasswordForm = document.getElementById('change-password-form');
const profileSuccess = document.getElementById('profile-success');
const helpBtn = document.getElementById('help-btn');
const helpModal = document.getElementById('help-modal');
const addInstanceModal = document.getElementById('add-instance-modal');
const showAddBtn = document.getElementById('show-add-btn');
const showAddUserBtn = document.getElementById('show-add-user-btn');
const addUserModal = document.getElementById('add-user-modal');
const modalTitle = document.querySelector('#add-instance-modal h2');
const secretInput = document.getElementById('secret');
let config = { firewall_host_ip: null };
let currentUser = null;
let editId = null;
let instancesData = [];
const openModal = (modal) => modal.classList.add('active');
const closeModal = (modal) => {
modal.classList.remove('active');
editId = null;
addForm.reset();
modalTitle.textContent = 'Add Instance';
secretInput.placeholder = 'Base32 Secret or otpauth:// URL';
secretInput.required = true;
};
document.querySelectorAll('.btn-close').forEach(btn => {
btn.addEventListener('click', () => {
closeModal(btn.closest('.modal'));
});
});
window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) closeModal(e.target);
});
helpBtn.addEventListener('click', () => openModal(helpModal));
showAddBtn.addEventListener('click', () => {
editId = null;
modalTitle.textContent = 'Add Instance';
secretInput.placeholder = 'Base32 Secret or otpauth:// URL';
secretInput.required = true;
openModal(addInstanceModal);
});
showAddUserBtn.addEventListener('click', () => openModal(addUserModal));
const showLogin = () => {
loginOverlay.style.display = 'flex';
logoutBtn.style.display = 'none';
mainNav.style.display = 'none';
};
const hideLogin = () => {
loginOverlay.style.display = 'none';
logoutBtn.style.display = 'flex';
mainNav.style.display = 'flex';
};
// Tab Switching
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(i => i.classList.remove('active'));
item.classList.add('active');
document.getElementById(`${item.dataset.tab}-section`).classList.add('active');
if (item.dataset.tab === 'admin') fetchUsers();
if (item.dataset.tab === 'instances') fetchInstances();
});
});
const updateFirewallCmds = () => {
if (!ufwCmd || !iptablesCmd) return;
const port = portInput.value || '4646';
const hostIp = config.firewall_host_ip || window.location.hostname;
ufwCmd.textContent = `sudo ufw allow from ${hostIp} to any port ${port} proto tcp`;
iptablesCmd.textContent = `sudo iptables -I INPUT -p tcp -s ${hostIp} --dport ${port} -j ACCEPT`;
};
const fetchConfig = async () => {
try {
const res = await fetch('/api/config');
if (res.status === 401) return showLogin();
if (res.ok) {
config = await res.json();
updateFirewallCmds();
}
} catch (e) {
console.error('Failed to fetch config:', e);
}
};
const fetchInstances = async () => {
try {
const res = await fetch('/api/instances');
if (res.status === 401) return showLogin();
instancesData = await res.json();
renderInstances(instancesData);
} catch (e) {
console.error('Failed to fetch instances:', e);
}
};
const fetchUsers = async () => {
try {
const res = await fetch('/api/users');
if (res.status === 401) return showLogin();
if (res.ok) {
const users = await res.json();
renderUsers(users);
}
} catch (e) {
console.error('Failed to fetch users:', e);
}
};
const renderInstances = (instances) => {
if (instances.length === 0) {
instancesList.innerHTML = '<div class="empty-state">No instances configured yet.</div>';
return;
}
instancesList.innerHTML = instances.map(inst => {
let statusClass = 'status-unknown';
if (inst.last_status === 'Sent') statusClass = 'status-sent';
else if (inst.last_status === 'Offline') statusClass = 'status-offline';
else if (inst.last_status.startsWith('Failed')) statusClass = 'status-offline';
const timeAgo = inst.last_checked ? Math.round((Date.now()/1000 - inst.last_checked)) + 's ago' : 'Never';
return `
<div class="instance-item">
<div class="instance-info">
<h3>${escapeHtml(inst.name)}</h3>
<div class="instance-meta">
<span>${escapeHtml(inst.ip)}:${inst.port}</span>
<span class="status-badge ${statusClass}">${escapeHtml(inst.last_status)}</span>
<span>Last checked: ${timeAgo}</span>
</div>
</div>
<div class="instance-actions">
<button class="btn btn-icon" onclick="editInstance(${inst.id})" title="Edit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</button>
<button class="delete-btn" onclick="deleteInstance(${inst.id})" title="Delete">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
`;
}).join('');
};
const renderUsers = (users) => {
usersList.innerHTML = users.map(user => `
<div class="user-item">
<div class="user-info">
<h3>${escapeHtml(user.username)} ${user.is_admin ? '<span class="status-badge status-sent">Admin</span>' : ''}</h3>
</div>
<div class="user-actions">
<button class="btn btn-icon" onclick="resetUserPassword(${user.id})" title="Reset Password">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3L15.5 7.5z"></path></svg>
</button>
<button class="delete-btn" onclick="deleteUser(${user.id})" title="Delete User">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
</div>
`).join('');
};
addForm.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('name').value;
const ip = document.getElementById('ip').value;
const port = parseInt(document.getElementById('port').value, 10);
const secret = document.getElementById('secret').value;
const url = editId ? `/api/instances/${editId}` : '/api/instances';
const method = editId ? 'PUT' : 'POST';
const body = { name, ip, port };
if (secret) body.secret = secret;
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) {
closeModal(addInstanceModal);
fetchInstances();
} else if (res.status === 401) {
showLogin();
} else {
alert(`Failed to ${editId ? 'update' : 'add'} instance`);
}
} catch (e) {
console.error('Error saving instance', e);
}
});
window.editInstance = (id) => {
const inst = instancesData.find(i => i.id === id);
if (!inst) return;
editId = id;
document.getElementById('name').value = inst.name;
document.getElementById('ip').value = inst.ip;
document.getElementById('port').value = inst.port;
secretInput.value = '';
secretInput.required = false;
secretInput.placeholder = 'Leave blank to keep current secret';
modalTitle.textContent = 'Edit Instance';
openModal(addInstanceModal);
};
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (res.ok) {
const data = await res.json();
currentUser = data.user;
setupUIForUser();
hideLogin();
loginError.style.display = 'none';
document.getElementById('login-password').value = '';
fetchConfig();
fetchInstances();
} else {
loginError.style.display = 'block';
}
} catch (e) {
console.error('Login error', e);
}
});
const setupUIForUser = () => {
if (currentUser && currentUser.is_admin) {
navAdmin.style.display = 'block';
} else {
navAdmin.style.display = 'none';
}
};
logoutBtn.addEventListener('click', async () => {
try {
await fetch('/api/logout');
currentUser = null;
showLogin();
} catch (e) {
console.error('Logout error', e);
}
});
createUserForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('new-username').value;
const password = document.getElementById('new-password').value;
const is_admin = document.getElementById('new-is-admin').checked;
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, is_admin })
});
if (res.ok) {
createUserForm.reset();
closeModal(addUserModal);
fetchUsers();
} else {
const data = await res.json();
alert(data.detail || 'Failed to create user');
}
} catch (e) {
console.error('Error creating user', e);
}
});
changePasswordForm.addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('profile-new-password').value;
try {
const res = await fetch('/api/users/me/password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
changePasswordForm.reset();
profileSuccess.style.display = 'block';
setTimeout(() => profileSuccess.style.display = 'none', 3000);
}
} catch (e) {
console.error('Error changing password', e);
}
});
refreshBtn.addEventListener('click', fetchInstances);
window.deleteInstance = async (id) => {
if (!confirm('Are you sure you want to delete this instance?')) return;
try {
const res = await fetch(`/api/instances/${id}`, { method: 'DELETE' });
if (res.status === 401) return showLogin();
if (res.ok) fetchInstances();
} catch (e) {
console.error('Error deleting instance', e);
}
};
window.deleteUser = async (id) => {
if (!confirm('Are you sure you want to delete this user? All their instances will also be deleted.')) return;
try {
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (res.status === 401) return showLogin();
if (res.ok) fetchUsers();
else {
const data = await res.json();
alert(data.detail || 'Failed to delete user');
}
} catch (e) {
console.error('Error deleting user', e);
}
};
window.resetUserPassword = async (id) => {
const password = prompt('Enter new password for this user:');
if (!password) return;
try {
const res = await fetch(`/api/users/${id}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.status === 401) return showLogin();
if (res.ok) alert('Password updated');
} catch (e) {
console.error('Error resetting password', e);
}
};
window.copyToClipboard = async (elementId) => {
const text = document.getElementById(elementId).textContent;
const btn = event.currentTarget;
try {
await navigator.clipboard.writeText(text);
const originalHtml = btn.innerHTML;
btn.classList.add('copied');
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = originalHtml;
}, 2000);
} catch (err) {
console.error('Failed to copy: ', err);
}
};
portInput.addEventListener('input', updateFirewallCmds);
const checkAuth = async () => {
try {
const res = await fetch('/api/auth/status');
const data = await res.json();
if (data.authenticated) {
currentUser = data.user;
setupUIForUser();
hideLogin();
fetchConfig();
fetchInstances();
} else {
showLogin();
}
} catch (e) {
showLogin();
}
};
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
checkAuth();
setInterval(() => {
if (loginOverlay.style.display === 'none' && document.getElementById('instances-section').classList.contains('active')) {
fetchInstances();
}
}, 5000);
});