feat(manager): add Nexus Instance Manager for demo orchestration
- Complete Flask-based control panel for multi-tenant POS instances - Dashboard with global stats, system health, and recent demos - Demo provisioning in 1 click with auto-expiration tracking - Tenant management: activate/deactivate, reset data, delete - Health monitoring: PostgreSQL, Redis, disk, memory, systemd services - Migration orchestration UI for running schema updates across all tenants - JWT authentication with manager_users table - Dark theme SPA frontend with real-time search and actions - systemd service file included
This commit is contained in:
479
manager/static/js/manager.js
Normal file
479
manager/static/js/manager.js
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* Nexus Instance Manager — Frontend SPA
|
||||
*/
|
||||
|
||||
const API_BASE = "";
|
||||
let currentToken = localStorage.getItem("manager_token") || "";
|
||||
|
||||
// ─── Router ────────────────────────────────────────────────────────────────
|
||||
const routes = {
|
||||
"#dashboard": "dashboard",
|
||||
"#demos": "demos",
|
||||
"#tenants": "tenants",
|
||||
"#health": "health",
|
||||
"#migrations": "migrations"
|
||||
};
|
||||
|
||||
function navigate() {
|
||||
const hash = window.location.hash || "#dashboard";
|
||||
const page = routes[hash] || "dashboard";
|
||||
|
||||
document.querySelectorAll(".page").forEach(p => p.style.display = "none");
|
||||
document.getElementById(`page-${page}`).style.display = "block";
|
||||
|
||||
document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active"));
|
||||
const nav = document.querySelector(`.nav-item[data-page="${page}"]`);
|
||||
if (nav) nav.classList.add("active");
|
||||
|
||||
const titles = {
|
||||
dashboard: "Dashboard",
|
||||
demos: "Crear Demos",
|
||||
tenants: "Tenants",
|
||||
health: "Salud del Sistema",
|
||||
migrations: "Migraciones"
|
||||
};
|
||||
document.getElementById("page-title").textContent = titles[page] || "Dashboard";
|
||||
|
||||
// Load page data
|
||||
if (page === "dashboard") loadDashboard();
|
||||
if (page === "demos") loadDemos();
|
||||
if (page === "tenants") loadTenants();
|
||||
if (page === "health") loadHealth();
|
||||
if (page === "migrations") loadMigrations();
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", navigate);
|
||||
|
||||
// ─── Auth ──────────────────────────────────────────────────────────────────
|
||||
async function api(url, opts = {}) {
|
||||
const options = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${currentToken}`
|
||||
},
|
||||
...opts
|
||||
};
|
||||
if (opts.body && typeof opts.body !== "string") {
|
||||
options.body = JSON.stringify(opts.body);
|
||||
}
|
||||
const res = await fetch(`${API_BASE}${url}`, options);
|
||||
if (res.status === 401) {
|
||||
logout();
|
||||
return null;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById("login-screen").style.display = "flex";
|
||||
document.getElementById("app").style.display = "none";
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
document.getElementById("login-screen").style.display = "none";
|
||||
document.getElementById("app").style.display = "flex";
|
||||
navigate();
|
||||
}
|
||||
|
||||
async function initAuth() {
|
||||
if (!currentToken) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
const res = await api("/api/auth/me");
|
||||
if (res && res.status === 200) {
|
||||
document.getElementById("user-email").textContent = res.data.user.email;
|
||||
showApp();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("login-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById("login-email").value;
|
||||
const password = document.getElementById("login-password").value;
|
||||
const errEl = document.getElementById("login-error");
|
||||
errEl.style.display = "none";
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
currentToken = data.access_token;
|
||||
localStorage.setItem("manager_token", currentToken);
|
||||
document.getElementById("user-email").textContent = data.user.email;
|
||||
showApp();
|
||||
} else {
|
||||
errEl.textContent = data.error || "Error de autenticación";
|
||||
errEl.style.display = "block";
|
||||
}
|
||||
});
|
||||
|
||||
function logout() {
|
||||
currentToken = "";
|
||||
localStorage.removeItem("manager_token");
|
||||
showLogin();
|
||||
}
|
||||
|
||||
// ─── Dashboard ─────────────────────────────────────────────────────────────
|
||||
async function loadDashboard() {
|
||||
const statsRes = await api("/api/admin/stats");
|
||||
if (statsRes && statsRes.status === 200) {
|
||||
const s = statsRes.data;
|
||||
document.getElementById("stat-total").textContent = s.tenants.total;
|
||||
document.getElementById("stat-active").textContent = s.tenants.active;
|
||||
document.getElementById("stat-demos").textContent = s.tenants.demos;
|
||||
document.getElementById("stat-expiring").textContent = s.tenants.expiring_soon;
|
||||
|
||||
const healthEl = document.getElementById("system-health-summary");
|
||||
healthEl.innerHTML = `
|
||||
<div class="health-item">
|
||||
<span class="health-label">Disco usado</span>
|
||||
<span class="health-value">${s.system.disk_percent}%</span>
|
||||
</div>
|
||||
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.disk_percent}%; background:${getBarColor(s.system.disk_percent)}"></div></div>
|
||||
<div class="health-item" style="margin-top:12px">
|
||||
<span class="health-label">Memoria usada</span>
|
||||
<span class="health-value">${s.system.memory_percent}%</span>
|
||||
</div>
|
||||
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.memory_percent}%; background:${getBarColor(s.system.memory_percent)}"></div></div>
|
||||
<div class="health-item" style="margin-top:12px">
|
||||
<span class="health-label">Disco libre</span>
|
||||
<span class="health-value">${s.system.disk_free_gb} GB</span>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
<span class="health-label">RAM disponible</span>
|
||||
<span class="health-value">${s.system.memory_available_gb} GB</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const tenantsRes = await api("/api/demos");
|
||||
if (tenantsRes && tenantsRes.status === 200) {
|
||||
const tbody = document.getElementById("recent-demos-table");
|
||||
const demos = tenantsRes.data.data.slice(0, 5);
|
||||
tbody.innerHTML = demos.map(d => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(d.name)}</strong></td>
|
||||
<td><code>${escapeHtml(d.subdomain)}</code></td>
|
||||
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
|
||||
<td>${d.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
|
||||
</tr>
|
||||
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos activas</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getBarColor(pct) {
|
||||
if (pct < 60) return "var(--success)";
|
||||
if (pct < 85) return "var(--warning)";
|
||||
return "var(--danger)";
|
||||
}
|
||||
|
||||
// ─── Demos ─────────────────────────────────────────────────────────────────
|
||||
async function loadDemos() {
|
||||
const res = await api("/api/demos");
|
||||
if (!res || res.status !== 200) return;
|
||||
|
||||
const tbody = document.getElementById("demos-table");
|
||||
const demos = res.data.data;
|
||||
tbody.innerHTML = demos.map(d => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(d.name)}</strong></td>
|
||||
<td><a href="https://${escapeHtml(d.subdomain)}.nexusautoparts.com.mx/pos/login" target="_blank" style="color:var(--accent)">${escapeHtml(d.subdomain)}</a></td>
|
||||
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
|
||||
<td>
|
||||
<button class="btn-icon" onclick="resetTenant(${d.id})" title="Resetear"><i class="fas fa-undo"></i></button>
|
||||
<button class="btn-icon" onclick="toggleTenant(${d.id}, ${!d.is_active})" title="${d.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${d.is_active ? "pause" : "play"}"></i></button>
|
||||
<button class="btn-icon" onclick="confirmDelete(${d.id}, '${escapeHtml(d.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos</td></tr>`;
|
||||
}
|
||||
|
||||
document.getElementById("demo-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector("button[type=submit]");
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> Creando...`;
|
||||
btn.disabled = true;
|
||||
|
||||
const payload = {
|
||||
name: document.getElementById("demo-name").value,
|
||||
email: document.getElementById("demo-email").value,
|
||||
days: parseInt(document.getElementById("demo-days").value),
|
||||
pin: document.getElementById("demo-pin").value,
|
||||
subdomain: document.getElementById("demo-subdomain").value || undefined
|
||||
};
|
||||
|
||||
const res = await api("/api/demos", { method: "POST", body: payload });
|
||||
const resultBox = document.getElementById("demo-result");
|
||||
|
||||
if (res && res.status === 201) {
|
||||
const d = res.data.data;
|
||||
resultBox.innerHTML = `
|
||||
<h4><i class="fas fa-check-circle"></i> Demo creada exitosamente</h4>
|
||||
<div class="copy-row"><strong>URL:</strong> <code>${d.access_url}</code> <button class="btn-icon" onclick="copyText('${d.access_url}')"><i class="fas fa-copy"></i></button></div>
|
||||
<div class="copy-row"><strong>Subdominio:</strong> <code>${d.subdomain}</code></div>
|
||||
<div class="copy-row"><strong>PIN Owner:</strong> <code>${d.owner_pin}</code></div>
|
||||
<div class="copy-row"><strong>Expira:</strong> ${new Date(d.expires_at).toLocaleDateString()}</div>
|
||||
`;
|
||||
resultBox.style.display = "block";
|
||||
toast("Demo creada correctamente", "success");
|
||||
document.getElementById("demo-form").reset();
|
||||
loadDemos();
|
||||
} else {
|
||||
toast(res?.data?.error || "Error al crear demo", "error");
|
||||
}
|
||||
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
// ─── Tenants ───────────────────────────────────────────────────────────────
|
||||
async function loadTenants(withStats = false) {
|
||||
const res = await api(`/api/tenants?stats=${withStats}`);
|
||||
if (!res || res.status !== 200) return;
|
||||
|
||||
const tbody = document.getElementById("tenants-table");
|
||||
const tenants = res.data.data;
|
||||
document.getElementById("tenant-count").textContent = tenants.length;
|
||||
|
||||
tbody.innerHTML = tenants.map(t => `
|
||||
<tr>
|
||||
<td>${t.id}</td>
|
||||
<td><strong>${escapeHtml(t.name)}</strong></td>
|
||||
<td><code>${escapeHtml(t.subdomain)}</code></td>
|
||||
<td>${tag(t.plan || "basic", t.plan === "demo" ? "info" : "default")}</td>
|
||||
<td>${t.schema_version || "v0.0"}</td>
|
||||
<td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
|
||||
<td>${formatDate(t.created_at)}</td>
|
||||
<td>
|
||||
<button class="btn-icon" onclick="resetTenant(${t.id})" title="Resetear datos"><i class="fas fa-undo"></i></button>
|
||||
<button class="btn-icon" onclick="toggleTenant(${t.id}, ${!t.is_active})" title="${t.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${t.is_active ? "pause" : "play"}"></i></button>
|
||||
<button class="btn-icon" onclick="confirmDelete(${t.id}, '${escapeHtml(t.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("") || `<tr><td colspan="8" class="text-muted text-center">No hay tenants</td></tr>`;
|
||||
}
|
||||
|
||||
document.getElementById("tenant-search")?.addEventListener("input", (e) => {
|
||||
const term = e.target.value.toLowerCase();
|
||||
document.querySelectorAll("#tenants-table tr").forEach(row => {
|
||||
row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Health ────────────────────────────────────────────────────────────────
|
||||
async function loadHealth() {
|
||||
const res = await api("/api/health");
|
||||
if (!res || res.status !== 200) return;
|
||||
|
||||
const h = res.data;
|
||||
|
||||
// PostgreSQL
|
||||
const pg = h.postgresql;
|
||||
document.getElementById("health-postgresql").innerHTML = pg.status === "ok" ? `
|
||||
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
|
||||
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${pg.version}</span></div>
|
||||
<div class="health-item"><span class="health-label">Master DB</span><span class="health-value">${pg.master_size_mb} MB</span></div>
|
||||
` : renderError(pg.error);
|
||||
|
||||
// Redis
|
||||
const rd = h.redis;
|
||||
document.getElementById("health-redis").innerHTML = rd.status === "ok" ? `
|
||||
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
|
||||
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${rd.version}</span></div>
|
||||
<div class="health-item"><span class="health-label">Memoria</span><span class="health-value">${rd.used_memory_human}</span></div>
|
||||
<div class="health-item"><span class="health-label">Clientes</span><span class="health-value">${rd.connected_clients}</span></div>
|
||||
` : renderError(rd.error);
|
||||
|
||||
// Disk
|
||||
const dk = h.disk;
|
||||
document.getElementById("health-disk").innerHTML = dk.status === "ok" ? `
|
||||
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${dk.total_gb} GB</span></div>
|
||||
<div class="health-item"><span class="health-label">Usado</span><span class="health-value">${dk.used_gb} GB (${dk.percent_used}%)</span></div>
|
||||
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${dk.percent_used}%; background:${getBarColor(dk.percent_used)}"></div></div>
|
||||
<div class="health-item" style="margin-top:12px"><span class="health-label">Libre</span><span class="health-value">${dk.free_gb} GB</span></div>
|
||||
` : renderError(dk.error);
|
||||
|
||||
// Memory
|
||||
const mem = h.memory;
|
||||
document.getElementById("health-memory").innerHTML = mem.status === "ok" ? `
|
||||
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${mem.total_gb} GB</span></div>
|
||||
<div class="health-item"><span class="health-label">Usada</span><span class="health-value">${mem.used_gb} GB (${mem.percent_used}%)</span></div>
|
||||
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${mem.percent_used}%; background:${getBarColor(mem.percent_used)}"></div></div>
|
||||
<div class="health-item" style="margin-top:12px"><span class="health-label">Disponible</span><span class="health-value">${mem.available_gb} GB</span></div>
|
||||
` : renderError(mem.error);
|
||||
|
||||
// Services
|
||||
const svcs = h.services || {};
|
||||
document.getElementById("health-services").innerHTML = Object.entries(svcs).map(([name, s]) => `
|
||||
<div class="health-item">
|
||||
<span class="health-label"><i class="fas fa-${s.active ? "check-circle" : "times-circle"}" style="color:${s.active ? "var(--success)" : "var(--danger)"}; margin-right:6px"></i>${name}</span>
|
||||
<span class="health-value" style="color:${s.active ? "var(--success)" : "var(--danger)"}">${s.state}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
// HTTP
|
||||
const httpChecks = ["pos", "dashboard", "quart"];
|
||||
document.getElementById("health-http").innerHTML = `
|
||||
<div class="grid-3">
|
||||
${httpChecks.map(key => {
|
||||
const svc = h[key];
|
||||
const ok = svc && svc.status === "ok";
|
||||
return `
|
||||
<div class="health-item">
|
||||
<span class="health-label">${key.toUpperCase()}</span>
|
||||
<span class="health-value" style="color:${ok ? "var(--success)" : "var(--danger)"}">
|
||||
${ok ? `HTTP ${svc.http_status}` : (svc.error || "Offline")}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
return `<div class="text-muted" style="padding:20px; text-align:center; color:var(--danger)"><i class="fas fa-exclamation-triangle"></i> ${escapeHtml(msg)}</div>`;
|
||||
}
|
||||
|
||||
// ─── Migrations ────────────────────────────────────────────────────────────
|
||||
async function loadMigrations() {
|
||||
const res = await api("/api/admin/migrations");
|
||||
if (!res || res.status !== 200) return;
|
||||
|
||||
const tbody = document.getElementById("migrations-table");
|
||||
const tenants = res.data.tenants || [];
|
||||
tbody.innerHTML = tenants.map(t => {
|
||||
const needsUpdate = t.version !== (res.data.migrations.slice(-1)[0]?.version || t.version);
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(t.name)}</td>
|
||||
<td><code>${t.db_name}</code></td>
|
||||
<td>${t.version}</td>
|
||||
<td>${needsUpdate ? tag("Pendiente", "warning") : tag("OK", "success")}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay tenants</td></tr>`;
|
||||
}
|
||||
|
||||
async function runAllMigrations() {
|
||||
if (!confirm("¿Ejecutar todas las migraciones pendientes en TODOS los tenants?")) return;
|
||||
|
||||
const logBox = document.getElementById("migration-log");
|
||||
logBox.style.display = "block";
|
||||
logBox.textContent = "Ejecutando migraciones...";
|
||||
|
||||
const res = await api("/api/admin/migrations/run-all", { method: "POST" });
|
||||
if (res && res.status === 200) {
|
||||
logBox.textContent = res.data.log || "Completado";
|
||||
toast("Migraciones ejecutadas", "success");
|
||||
loadMigrations();
|
||||
} else {
|
||||
logBox.textContent = "Error: " + (res?.data?.error || "Unknown");
|
||||
toast("Error en migraciones", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────────────
|
||||
async function toggleTenant(id, active) {
|
||||
const res = await api(`/api/tenants/${id}/toggle`, {
|
||||
method: "POST",
|
||||
body: { active }
|
||||
});
|
||||
if (res && res.status === 200) {
|
||||
toast(active ? "Tenant activado" : "Tenant desactivado", "success");
|
||||
loadTenants();
|
||||
loadDemos();
|
||||
} else {
|
||||
toast(res?.data?.error || "Error", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function resetTenant(id) {
|
||||
if (!confirm("¿Resetear TODOS los datos de negocio de este tenant? Se conservan empleados y configuración.")) return;
|
||||
|
||||
const res = await api(`/api/tenants/${id}/reset`, { method: "POST" });
|
||||
if (res && res.status === 200) {
|
||||
toast("Tenant reseteado", "success");
|
||||
} else {
|
||||
toast(res?.data?.error || "Error al resetear", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id, name) {
|
||||
openModal(
|
||||
"Eliminar Tenant",
|
||||
`¿Eliminar permanentemente <strong>${escapeHtml(name)}</strong>? Esta acción no se puede deshacer. Se borrará la base de datos completa.`,
|
||||
async () => {
|
||||
const res = await api(`/api/tenants/${id}`, { method: "DELETE" });
|
||||
if (res && res.status === 200) {
|
||||
toast("Tenant eliminado", "success");
|
||||
loadTenants();
|
||||
loadDemos();
|
||||
} else {
|
||||
toast(res?.data?.error || "Error al eliminar", "error");
|
||||
}
|
||||
closeModal();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Modal ─────────────────────────────────────────────────────────────────
|
||||
function openModal(title, body, onConfirm) {
|
||||
document.getElementById("modal-title").textContent = title;
|
||||
document.getElementById("modal-body").innerHTML = body;
|
||||
const btn = document.getElementById("modal-confirm-btn");
|
||||
btn.onclick = onConfirm;
|
||||
document.getElementById("modal").style.display = "flex";
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById("modal").style.display = "none";
|
||||
}
|
||||
|
||||
// ─── Toast ─────────────────────────────────────────────────────────────────
|
||||
function toast(message, type = "info") {
|
||||
const container = document.getElementById("toast-container");
|
||||
const el = document.createElement("div");
|
||||
el.className = `toast ${type}`;
|
||||
el.innerHTML = `<i class="fas fa-${type === "success" ? "check-circle" : type === "error" ? "exclamation-circle" : "info-circle"}"></i> ${escapeHtml(message)}`;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => {
|
||||
el.style.opacity = "0";
|
||||
el.style.transform = "translateX(100%)";
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// ─── Utilities ─────────────────────────────────────────────────────────────
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function tag(text, type) {
|
||||
return `<span class="tag tag-${type}">${escapeHtml(text)}</span>`;
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return "-";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("es-MX");
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
navigator.clipboard.writeText(text).then(() => toast("Copiado al portapapeles", "success"));
|
||||
}
|
||||
|
||||
// ─── Init ──────────────────────────────────────────────────────────────────
|
||||
document.addEventListener("DOMContentLoaded", initAuth);
|
||||
Reference in New Issue
Block a user