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
246 lines
11 KiB
HTML
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>
|