initialize XIVLauncher Remote OTP project with web interface and Docker support

This commit is contained in:
CPTN Cosmo 2026-04-18 16:21:51 +02:00
commit 6ce356ffed
8 changed files with 717 additions and 0 deletions

117
static/app.js Normal file
View file

@ -0,0 +1,117 @@
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');
portInput.addEventListener('input', (e) => {
const port = e.target.value || '4646';
ufwCmd.textContent = `sudo ufw allow ${port}/tcp`;
iptablesCmd.textContent = `sudo iptables -I INPUT -p tcp --dport ${port} -j ACCEPT`;
});
const fetchInstances = async () => {
try {
const res = await fetch('/api/instances');
if (res.status === 401) {
return; // Let browser handle auth
}
const data = await res.json();
renderInstances(data);
} catch (e) {
console.error('Failed to fetch instances:', 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>
<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>
`;
}).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;
try {
const res = await fetch('/api/instances', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name, ip, port, secret })
});
if (res.ok) {
addForm.reset();
document.getElementById('port').value = "4646";
fetchInstances();
} else {
alert('Failed to add instance');
}
} catch (e) {
console.error('Error adding instance', 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.ok) {
fetchInstances();
}
} catch (e) {
console.error('Error deleting instance', e);
}
};
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
fetchInstances();
setInterval(fetchInstances, 5000);
});

79
static/index.html Normal file
View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XIVLauncher Remote OTP</title>
<link rel="stylesheet" href="/static/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="container">
<header>
<div class="logo">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
<h1>XIVLauncher Remote OTP</h1>
</div>
</header>
<main>
<div class="card add-instance-card">
<h2>Add Instance</h2>
<form id="add-form">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" placeholder="e.g. Steam Deck" required>
</div>
<div class="form-row">
<div class="form-group">
<label for="ip">IP Address</label>
<input type="text" id="ip" placeholder="192.168.1.100" required>
</div>
<div class="form-group">
<label for="port">Port</label>
<input type="number" id="port" value="4646" required>
</div>
</div>
<div class="form-group">
<label for="secret">OTP Secret</label>
<input type="password" id="secret" placeholder="Base32 Secret or otpauth:// URL" required>
</div>
<button type="submit" class="btn btn-primary">Add Instance</button>
</form>
</div>
<div class="card firewall-helper-card">
<h2>Firewall Helper</h2>
<p class="helper-text">Run these commands on the machine running XIVLauncher to allow incoming connections on the specified port.</p>
<div class="code-block">
<span class="code-label">UFW</span>
<code id="ufw-cmd">sudo ufw allow 4646/tcp</code>
</div>
<div class="code-block">
<span class="code-label">iptables</span>
<code id="iptables-cmd">sudo iptables -I INPUT -p tcp --dport 4646 -j ACCEPT</code>
</div>
</div>
<div class="card instances-card">
<div class="instances-header">
<h2>Managed Instances</h2>
<button class="btn btn-icon" id="refresh-btn" title="Refresh">
<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="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
</button>
</div>
<div id="instances-list" class="instances-list">
<!-- Instances injected via JS -->
</div>
</div>
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>

265
static/style.css Normal file
View file

@ -0,0 +1,265 @@
:root {
--bg-color: #0f172a;
--card-bg: #1e293b;
--primary: #3b82f6;
--primary-hover: #2563eb;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--border-color: #334155;
--status-online: #10b981;
--status-offline: #ef4444;
--status-sent: #3b82f6;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 2rem;
}
.logo {
display: flex;
align-items: center;
gap: 1rem;
}
.logo svg {
color: var(--primary);
}
.logo h1 {
font-size: 1.5rem;
font-weight: 600;
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.card h2 {
font-size: 1.25rem;
margin-bottom: 1.5rem;
font-weight: 600;
}
.form-group {
margin-bottom: 1rem;
}
.form-row {
display: flex;
gap: 1rem;
}
.form-row .form-group {
flex: 1;
}
label {
display: block;
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
font-weight: 500;
}
input {
width: 100%;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-main);
padding: 0.75rem 1rem;
border-radius: 8px;
font-family: inherit;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
border: none;
transition: background-color 0.2s, transform 0.1s;
font-family: inherit;
font-size: 1rem;
}
.btn-primary {
background-color: var(--primary);
color: white;
width: 100%;
margin-top: 0.5rem;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
.btn-primary:active {
transform: translateY(1px);
}
.btn-icon {
padding: 0.5rem;
background: transparent;
color: var(--text-muted);
border-radius: 6px;
}
.btn-icon:hover {
background-color: rgba(255, 255, 255, 0.1);
color: var(--text-main);
}
.instances-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.instances-header h2 {
margin-bottom: 0;
}
.instances-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.instance-item {
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: transform 0.2s;
}
.instance-item:hover {
transform: translateX(4px);
border-color: rgba(255, 255, 255, 0.2);
}
.instance-info h3 {
font-size: 1rem;
margin-bottom: 0.25rem;
}
.instance-meta {
font-size: 0.875rem;
color: var(--text-muted);
display: flex;
gap: 1rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 999px;
background-color: rgba(255, 255, 255, 0.1);
}
.status-badge::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-badge.status-offline::before { background-color: var(--status-offline); }
.status-badge.status-sent::before { background-color: var(--status-sent); }
.status-badge.status-unknown::before { background-color: var(--text-muted); }
.delete-btn {
color: var(--status-offline);
background: transparent;
border: none;
padding: 0.5rem;
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s;
}
.delete-btn:hover {
background-color: rgba(239, 68, 68, 0.1);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
.helper-text {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.code-block {
background-color: #0f172a;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.75rem;
position: relative;
font-family: monospace;
font-size: 0.875rem;
display: flex;
flex-direction: column;
}
.code-label {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
font-weight: 700;
margin-bottom: 0.25rem;
font-family: 'Inter', sans-serif;
}
.code-block code {
color: #34d399;
}