291 lines
14 KiB
HTML
291 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Users — {{ brand_name }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page" style="overflow-y:auto">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:24px">
|
|
<h1 style="margin:0">User Management</h1>
|
|
<button class="btn btn-primary" onclick="openCreateModal()">+ Add User</button>
|
|
</div>
|
|
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Username</th>
|
|
<th>Email</th>
|
|
<th>Role</th>
|
|
<th>Status</th>
|
|
<th>MFA</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="users-tbody">
|
|
<tr><td colspan="7" style="color:var(--text-dim);text-align:center;padding:24px">Loading…</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create/Edit user modal -->
|
|
<div class="modal-overlay" id="user-modal" style="display:none">
|
|
<div class="modal" style="max-width:420px;width:100%">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
|
|
<h3 style="margin:0;color:var(--text)" id="modal-title">Add User</h3>
|
|
<button onclick="closeUserModal()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:20px;line-height:1;padding:2px 6px;border-radius:4px;transition:color 0.15s" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--text-dim)'">×</button>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Username</label>
|
|
<input type="text" id="f-username" class="form-input" autocomplete="off">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Email</label>
|
|
<input type="email" id="f-email" class="form-input" autocomplete="off" placeholder="user@example.com">
|
|
</div>
|
|
<div class="form-group" id="pw-field">
|
|
<label id="pw-label">Password</label>
|
|
<input type="password" id="f-password" class="form-input" autocomplete="new-password" placeholder="min 8 characters">
|
|
</div>
|
|
<div class="form-group" id="pw-confirm-field">
|
|
<label>Confirm password</label>
|
|
<input type="password" id="f-confirm" class="form-input" autocomplete="new-password">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Role</label>
|
|
<select id="f-role" class="form-input">
|
|
<option value="user">User</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group" id="active-field" style="display:none">
|
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text)">
|
|
<input type="checkbox" id="f-active"> Active
|
|
</label>
|
|
</div>
|
|
<div class="form-group" id="admin-keys-field" style="display:none">
|
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text)">
|
|
<input type="checkbox" id="f-admin-keys"> Allow access to admin API keys
|
|
</label>
|
|
<div style="font-size:11px;color:var(--text-dim);margin-top:4px;margin-left:22px">
|
|
Grants full access to Anthropic, OpenRouter, and OpenAI using the system API keys.
|
|
</div>
|
|
</div>
|
|
<div id="modal-error" style="display:none;color:var(--red);font-size:12px;margin-bottom:12px;padding:8px 10px;background:rgba(224,82,82,0.08);border-radius:var(--radius);border:1px solid rgba(224,82,82,0.2)"></div>
|
|
|
|
<div class="modal-buttons">
|
|
<button class="btn btn-ghost" onclick="closeUserModal()">Cancel</button>
|
|
<button class="btn btn-primary" id="modal-save-btn" onclick="saveUser()">Create</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Change own password modal -->
|
|
<div class="modal-overlay" id="pw-modal" style="display:none">
|
|
<div class="modal" style="max-width:380px;width:100%">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
|
|
<h3 style="margin:0;color:var(--text)">Change Password</h3>
|
|
<button onclick="document.getElementById('pw-modal').style.display='none'" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:20px;line-height:1;padding:2px 6px;border-radius:4px;transition:color 0.15s" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--text-dim)'">×</button>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Current password</label>
|
|
<input type="password" id="pw-current" class="form-input" autocomplete="current-password">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>New password</label>
|
|
<input type="password" id="pw-new" class="form-input" autocomplete="new-password" placeholder="min 8 characters">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Confirm new password</label>
|
|
<input type="password" id="pw-new2" class="form-input" autocomplete="new-password">
|
|
</div>
|
|
<div id="pw-modal-error" style="display:none;color:var(--red);font-size:12px;margin-bottom:12px;padding:8px 10px;background:rgba(224,82,82,0.08);border-radius:var(--radius);border:1px solid rgba(224,82,82,0.2)"></div>
|
|
|
|
<div class="modal-buttons">
|
|
<button class="btn btn-ghost" onclick="document.getElementById('pw-modal').style.display='none'">Cancel</button>
|
|
<button class="btn btn-primary" onclick="changeOwnPassword()">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
const ME = {{ current_user.id | tojson }};
|
|
let _editUserId = null;
|
|
const _usersMap = {};
|
|
|
|
const _JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
}
|
|
|
|
async function loadUsers() {
|
|
const tbody = document.getElementById('users-tbody');
|
|
try {
|
|
const resp = await fetch('/api/users');
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--red);text-align:center;padding:24px">Error ${resp.status}: ${esc(err.detail || resp.statusText)}</td></tr>`;
|
|
return;
|
|
}
|
|
const users = await resp.json();
|
|
if (!Array.isArray(users) || !users.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" style="color:var(--text-dim);text-align:center;padding:24px">No users.</td></tr>';
|
|
return;
|
|
}
|
|
users.forEach(u => { _usersMap[u.id] = u; });
|
|
tbody.innerHTML = users.map(u => `
|
|
<tr>
|
|
<td style="font-weight:500">${esc(u.username)}${u.id === ME ? ' <span class="badge badge-blue" style="font-size:10px">you</span>' : ''}</td>
|
|
<td style="color:var(--text-dim)">${esc(u.email || '—')}</td>
|
|
<td><span class="badge ${u.role === 'admin' ? 'badge-blue' : ''}" style="${u.role !== 'admin' ? 'background:var(--bg3);color:var(--text-dim)' : ''}">${esc(u.role)}</span></td>
|
|
<td><span class="status-dot${u.is_active ? '' : ' offline'}" style="margin-right:6px"></span>${u.is_active ? 'Active' : 'Inactive'}</td>
|
|
<td>${u.mfa_enabled ? '<span class="badge badge-green" style="font-size:11px">On</span>' : '<span style="color:var(--text-dim)">—</span>'}</td>
|
|
<td style="color:var(--text-dim)">${formatDateShort(u.created_at)}</td>
|
|
<td style="display:flex;gap:6px;align-items:center">
|
|
<button class="btn btn-ghost" style="padding:4px 10px;font-size:12px" onclick="openEditModal('${esc(u.id)}')">Edit</button>
|
|
${u.id !== ME
|
|
? `<button class="btn btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser('${esc(u.id)}','${esc(u.username)}')">Delete</button>`
|
|
: `<button class="btn btn-ghost" style="padding:4px 10px;font-size:12px" onclick="openPwModal()">Change password</button>`}
|
|
${u.mfa_enabled && u.id !== ME ? `<button class="btn btn-ghost" style="padding:4px 10px;font-size:12px;color:var(--yellow)" onclick="clearUserMfa('${esc(u.id)}','${esc(u.username)}')">Clear MFA</button>` : ''}
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (e) {
|
|
tbody.innerHTML = '<tr><td colspan="7" style="color:var(--red);text-align:center;padding:24px">Failed to load users.</td></tr>';
|
|
}
|
|
}
|
|
|
|
function openCreateModal() {
|
|
_editUserId = null;
|
|
document.getElementById('modal-title').textContent = 'Add User';
|
|
document.getElementById('modal-save-btn').textContent = 'Create';
|
|
document.getElementById('f-username').value = '';
|
|
document.getElementById('f-email').value = '';
|
|
document.getElementById('f-password').value = '';
|
|
document.getElementById('f-confirm').value = '';
|
|
document.getElementById('f-role').value = 'user';
|
|
document.getElementById('f-username').disabled = false;
|
|
document.getElementById('pw-field').style.display = '';
|
|
document.getElementById('pw-label').textContent = 'Password';
|
|
document.getElementById('pw-confirm-field').style.display = '';
|
|
document.getElementById('active-field').style.display = 'none';
|
|
document.getElementById('admin-keys-field').style.display = 'none';
|
|
document.getElementById('modal-error').style.display = 'none';
|
|
document.getElementById('user-modal').style.display = 'flex';
|
|
document.getElementById('f-username').focus();
|
|
}
|
|
|
|
function openEditModal(userId) {
|
|
const u = _usersMap[userId];
|
|
if (!u) return;
|
|
_editUserId = u.id;
|
|
document.getElementById('modal-title').textContent = 'Edit User';
|
|
document.getElementById('modal-save-btn').textContent = 'Save';
|
|
document.getElementById('f-username').value = u.username;
|
|
document.getElementById('f-username').disabled = true;
|
|
document.getElementById('f-email').value = u.email || '';
|
|
document.getElementById('f-password').value = '';
|
|
document.getElementById('f-confirm').value = '';
|
|
document.getElementById('f-role').value = u.role;
|
|
document.getElementById('pw-field').style.display = '';
|
|
document.getElementById('pw-label').textContent = 'New password (leave blank to keep)';
|
|
document.getElementById('pw-confirm-field').style.display = '';
|
|
document.getElementById('active-field').style.display = '';
|
|
document.getElementById('f-active').checked = u.is_active;
|
|
document.getElementById('admin-keys-field').style.display = u.role === 'admin' ? 'none' : '';
|
|
document.getElementById('f-admin-keys').checked = false;
|
|
if (u.role !== 'admin') {
|
|
fetch(`/api/users/${userId}/admin-keys`)
|
|
.then(r => r.json())
|
|
.then(d => { document.getElementById('f-admin-keys').checked = d.enabled; })
|
|
.catch(() => {});
|
|
}
|
|
document.getElementById('modal-error').style.display = 'none';
|
|
document.getElementById('user-modal').style.display = 'flex';
|
|
}
|
|
|
|
function closeUserModal() {
|
|
document.getElementById('user-modal').style.display = 'none';
|
|
}
|
|
|
|
function showModalErr(msg) {
|
|
const el = document.getElementById('modal-error');
|
|
el.textContent = msg;
|
|
el.style.display = '';
|
|
}
|
|
|
|
async function saveUser() {
|
|
document.getElementById('modal-error').style.display = 'none';
|
|
if (_editUserId) {
|
|
const body = { role: document.getElementById('f-role').value, is_active: document.getElementById('f-active').checked, email: document.getElementById('f-email').value.trim() };
|
|
const pw = document.getElementById('f-password').value;
|
|
if (pw) {
|
|
if (pw !== document.getElementById('f-confirm').value) { showModalErr('Passwords do not match.'); return; }
|
|
if (pw.length < 8) { showModalErr('Password must be at least 8 characters.'); return; }
|
|
body.password = pw;
|
|
}
|
|
const resp = await fetch(`/api/users/${_editUserId}`, { method: 'PUT', headers: _JSON_HEADERS, body: JSON.stringify(body) });
|
|
if (!resp.ok) { const d = await resp.json(); showModalErr(d.detail || 'Error saving user.'); return; }
|
|
// Save admin key access flag (only meaningful for non-admin users)
|
|
if (document.getElementById('admin-keys-field').style.display !== 'none') {
|
|
await fetch(`/api/users/${_editUserId}/admin-keys`, {
|
|
method: 'POST', headers: _JSON_HEADERS,
|
|
body: JSON.stringify({ enabled: document.getElementById('f-admin-keys').checked }),
|
|
});
|
|
}
|
|
} else {
|
|
const username = document.getElementById('f-username').value.trim();
|
|
const email = document.getElementById('f-email').value.trim();
|
|
const password = document.getElementById('f-password').value;
|
|
const confirm = document.getElementById('f-confirm').value;
|
|
if (!username) { showModalErr('Username is required.'); return; }
|
|
if (!email || !email.includes('@')) { showModalErr('A valid email address is required.'); return; }
|
|
if (password.length < 8) { showModalErr('Password must be at least 8 characters.'); return; }
|
|
if (password !== confirm) { showModalErr('Passwords do not match.'); return; }
|
|
const body = { username, email, password, role: document.getElementById('f-role').value };
|
|
const resp = await fetch('/api/users', { method: 'POST', headers: _JSON_HEADERS, body: JSON.stringify(body) });
|
|
if (!resp.ok) { const d = await resp.json(); showModalErr(d.detail || 'Error creating user.'); return; }
|
|
}
|
|
closeUserModal();
|
|
loadUsers();
|
|
}
|
|
|
|
async function deleteUser(id, name) {
|
|
if (!confirm(`Delete user "${name}"? This cannot be undone.`)) return;
|
|
const resp = await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
|
if (!resp.ok) { alert('Failed to delete user.'); return; }
|
|
loadUsers();
|
|
}
|
|
|
|
function openPwModal() {
|
|
document.getElementById('pw-current').value = '';
|
|
document.getElementById('pw-new').value = '';
|
|
document.getElementById('pw-new2').value = '';
|
|
document.getElementById('pw-modal-error').style.display = 'none';
|
|
document.getElementById('pw-modal').style.display = 'flex';
|
|
document.getElementById('pw-current').focus();
|
|
}
|
|
|
|
async function changeOwnPassword() {
|
|
const current = document.getElementById('pw-current').value;
|
|
const pw = document.getElementById('pw-new').value;
|
|
const pw2 = document.getElementById('pw-new2').value;
|
|
const errEl = document.getElementById('pw-modal-error');
|
|
errEl.style.display = 'none';
|
|
if (pw.length < 8) { errEl.textContent = 'Password must be at least 8 characters.'; errEl.style.display = ''; return; }
|
|
if (pw !== pw2) { errEl.textContent = 'Passwords do not match.'; errEl.style.display = ''; return; }
|
|
const resp = await fetch('/api/users/me/password', { method: 'POST', headers: _JSON_HEADERS, body: JSON.stringify({ current_password: current, new_password: pw }) });
|
|
if (!resp.ok) { const d = await resp.json(); errEl.textContent = d.detail || 'Error changing password.'; errEl.style.display = ''; return; }
|
|
document.getElementById('pw-modal').style.display = 'none';
|
|
showFlash('Password changed.');
|
|
}
|
|
|
|
loadUsers();
|
|
</script>
|
|
{% endblock %}
|