Hostityourself/server/templates/users.html
Claude c113b098e1
refactor: extract HTML/CSS/JS from ui.rs into template files
Move all inline markup out of ui.rs into server/templates/:
  styles.css       — shared stylesheet
  index.html       — dashboard page
  app_detail.html  — app detail page
  users.html       — users admin page

Templates are embedded at compile time via include_str! and rendered
with simple str::replace("{{placeholder}}", value) calls. JS/CSS
braces no longer need escaping, making the templates editable with
normal syntax highlighting.

https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
2026-03-24 13:03:10 +00:00

246 lines
11 KiB
HTML

<nav>
<h1><a href="/" style="color:inherit">HIY</a> / Users</h1>
<span style="display:flex;gap:16px;align-items:center">
<a href="/" class="muted" style="font-size:0.82rem">dashboard</a>
<a href="/logout" class="muted" style="font-size:0.82rem">logout</a>
</span>
</nav>
<div class="card">
<h2 style="margin-bottom:16px">Add user</h2>
<div class="row" id="add-user-form">
<input type="text" id="new-username" placeholder="username" style="max-width:200px">
<input type="password" id="new-password" placeholder="password" style="max-width:200px">
<label style="display:flex;align-items:center;gap:6px;margin:0;color:#e2e8f0">
<input type="checkbox" id="new-is-admin"> Admin
</label>
<button class="primary" onclick="addUser()">Add user</button>
</div>
<p id="add-user-error" style="color:#f87171;margin-top:8px;display:none"></p>
</div>
<div class="card">
<h2 style="margin-bottom:16px">Users</h2>
<div id="users-list"><p class="muted">Loading…</p></div>
</div>
<script>
const APP_OPTS = `{{app_opts}}`;
async function load() {
const res = await fetch('/api/users');
const users = await res.json();
const el = document.getElementById('users-list');
if (!users.length) { el.innerHTML = '<p class="muted">No users yet.</p>'; return; }
el.innerHTML = users.map(u => `
<div class="card" style="margin-bottom:12px;padding:16px">
<div class="row" style="justify-content:space-between;margin-bottom:10px">
<div>
<strong>${u.username}</strong>
${u.is_admin ? '<span class="badge badge-success" style="margin-left:6px">admin</span>' : ''}
<span class="muted" style="margin-left:10px;font-size:0.8rem">${u.created_at.slice(0,10)}</span>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button onclick="toggleAdmin('${u.id}','${u.username}',${u.is_admin})">
${u.is_admin ? 'Remove admin' : 'Make admin'}
</button>
<button onclick="changePassword('${u.id}','${u.username}')">Change password</button>
<button class="danger" onclick="deleteUser('${u.id}','${u.username}')">Delete</button>
</div>
</div>
<div>
<span class="muted" style="font-size:0.78rem">App access:</span>
${u.apps.length ? u.apps.map(a => `
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
${a}
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px" onclick="revokeApp('${u.id}','${a}')">✕</a>
</span>`).join('') : '<span class="muted" style="font-size:0.82rem"> none</span>'}
${APP_OPTS ? `
<span style="margin-left:8px">
<select id="grant-${u.id}" style="background:#0f172a;color:#e2e8f0;border:1px solid #334155;
padding:3px 8px;border-radius:6px;font-family:monospace;font-size:0.82rem">
<option value="">+ grant app…</option>
${APP_OPTS}
</select>
<button onclick="grantApp('${u.id}')" style="margin-left:4px">Grant</button>
</span>` : ''}
</div>
<div id="ssh-keys-${u.id}" style="margin-top:12px">
<span class="muted" style="font-size:0.78rem">SSH keys:</span>
<span class="muted" style="font-size:0.82rem"> loading…</span>
</div>
<div class="row" style="margin-top:8px;gap:6px">
<input type="text" id="key-label-${u.id}" placeholder="label (e.g. laptop)"
style="max-width:140px;font-size:0.82rem;padding:4px 8px">
<input type="text" id="key-value-${u.id}" placeholder="ssh-ed25519 AAAA…"
style="flex:1;font-size:0.82rem;padding:4px 8px">
<button onclick="addSshKey('${u.id}')" style="font-size:0.82rem">Add key</button>
</div>
<div id="api-keys-${u.id}" style="margin-top:12px">
<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span>
<span class="muted" style="font-size:0.82rem"> loading…</span>
</div>
<div class="row" style="margin-top:8px;gap:6px">
<input type="text" id="apikey-label-${u.id}" placeholder="label (e.g. laptop)"
style="max-width:140px;font-size:0.82rem;padding:4px 8px">
<button onclick="generateApiKey('${u.id}')" style="font-size:0.82rem">Generate key</button>
</div>
<div id="apikey-reveal-${u.id}" style="display:none;margin-top:8px;padding:10px;
background:#0f172a;border:1px solid #334155;border-radius:6px">
<div style="font-size:0.78rem;color:#94a3b8;margin-bottom:4px">
Copy this key now — it will not be shown again.
</div>
<code id="apikey-value-${u.id}" style="font-size:0.82rem;color:#a3e635;word-break:break-all"></code>
<br><br>
<div style="font-size:0.78rem;color:#94a3b8">Use it to push:</div>
<code style="font-size:0.78rem;color:#e2e8f0">
git remote add hiy http://hiy:<span id="apikey-hint-${u.id}"></span>@<span class="domain-hint"></span>/git/YOUR_APP<br>
git push hiy main
</code>
</div>
</div>`).join('');
document.querySelectorAll('.domain-hint').forEach(el => el.textContent = location.host);
// Load SSH and API keys for each user asynchronously.
for (const u of users) { loadSshKeys(u.id); loadApiKeys(u.id); }
}
async function addUser() {
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const is_admin = document.getElementById('new-is-admin').checked;
const err = document.getElementById('add-user-error');
if (!username || !password) { err.textContent = 'Username and password required.'; err.style.display=''; return; }
const res = await fetch('/api/users', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({username, password, is_admin})
});
if (res.ok) {
document.getElementById('new-username').value='';
document.getElementById('new-password').value='';
document.getElementById('new-is-admin').checked=false;
err.style.display='none';
load();
} else {
const d = await res.json();
err.textContent = d.error || 'Error creating user.';
err.style.display = '';
}
}
async function deleteUser(id, name) {
if (!confirm('Delete user ' + name + '?')) return;
await fetch('/api/users/' + id, {method:'DELETE'});
load();
}
async function toggleAdmin(id, name, currentlyAdmin) {
await fetch('/api/users/' + id, {
method:'PUT', headers:{'Content-Type':'application/json'},
body: JSON.stringify({is_admin: !currentlyAdmin})
});
load();
}
async function changePassword(id, name) {
const pw = prompt('New password for ' + name + ':');
if (!pw) return;
await fetch('/api/users/' + id, {
method:'PUT', headers:{'Content-Type':'application/json'},
body: JSON.stringify({password: pw})
});
}
async function grantApp(userId) {
const sel = document.getElementById('grant-' + userId);
const appId = sel.value;
if (!appId) return;
await fetch('/api/users/' + userId + '/apps/' + appId, {method:'POST'});
sel.value = '';
load();
}
async function revokeApp(userId, appId) {
await fetch('/api/users/' + userId + '/apps/' + appId, {method:'DELETE'});
load();
}
async function loadApiKeys(userId) {
const res = await fetch('/api/users/' + userId + '/api-keys');
const keys = await res.json();
const el = document.getElementById('api-keys-' + userId);
if (!el) return;
if (!keys.length) {
el.innerHTML = '<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span> <span class="muted" style="font-size:0.82rem">none</span>';
} else {
el.innerHTML = '<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span> ' +
keys.map(k => `
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
${k.label}
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px"
onclick="revokeApiKey('${k.id}','${userId}')">✕</a>
</span>`).join('');
}
}
async function generateApiKey(userId) {
const label = document.getElementById('apikey-label-' + userId).value.trim();
if (!label) { alert('Enter a label first.'); return; }
const res = await fetch('/api/users/' + userId + '/api-keys', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({label})
});
if (!res.ok) return;
const data = await res.json();
document.getElementById('apikey-label-' + userId).value = '';
document.getElementById('apikey-value-' + userId).textContent = data.key;
document.getElementById('apikey-hint-' + userId).textContent = data.key;
document.getElementById('apikey-reveal-' + userId).style.display = '';
loadApiKeys(userId);
}
async function revokeApiKey(keyId, userId) {
await fetch('/api/api-keys/' + keyId, {method: 'DELETE'});
document.getElementById('apikey-reveal-' + userId).style.display = 'none';
loadApiKeys(userId);
}
async function loadSshKeys(userId) {
const res = await fetch('/api/users/' + userId + '/ssh-keys');
const keys = await res.json();
const el = document.getElementById('ssh-keys-' + userId);
if (!el) return;
if (!keys.length) {
el.innerHTML = '<span class="muted" style="font-size:0.78rem">SSH keys:</span> <span class="muted" style="font-size:0.82rem">none</span>';
} else {
el.innerHTML = '<span class="muted" style="font-size:0.78rem">SSH keys:</span> ' +
keys.map(k => `
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
${k.label}
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px"
onclick="removeSshKey('${k.id}','${userId}')">✕</a>
</span>`).join('');
}
}
async function addSshKey(userId) {
const label = document.getElementById('key-label-' + userId).value.trim();
const publicKey = document.getElementById('key-value-' + userId).value.trim();
if (!label || !publicKey) return;
await fetch('/api/users/' + userId + '/ssh-keys', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({label, public_key: publicKey})
});
document.getElementById('key-label-' + userId).value = '';
document.getElementById('key-value-' + userId).value = '';
loadSshKeys(userId);
}
async function removeSshKey(keyId, userId) {
await fetch('/api/ssh-keys/' + keyId, {method: 'DELETE'});
loadSshKeys(userId);
}
load();
</script>