@jun/soul/admin
Admin
Panel de superadmin: usuarios, organizaciones y cambio de plan.
Que trae#
Un panel funcional para operar la plataforma, con el diseño de tu proyecto. No es un CRUD generico: son las dos pantallas que de verdad se usan a diario.
| Ruta | Que hace |
|---|---|
GET /users | Lista usuarios con busqueda (?q=) y paginacion (?page=). Muestra cuantas organizaciones tiene cada uno. |
GET /orgs | Lista organizaciones con busqueda, filtro por plan y cambio de plan en linea. Muestra miembros y el email del owner. |
POST /orgs/:id/plan | Cambia el plan. Responde 400 si el plan no esta en planNames. |
Montarlo#
import { createAdmin } from "@jun/soul/admin";
import { PLANS } from "./config";
export const admin = createAdmin({
errorPage,
planNames: Object.keys(PLANS),
layout: (c, opts) => shell({ title: opts.title, body: opts.content }),
});
const adminPanel = new Hono<{ Bindings: SoulEnv; Variables: AuthVariables }>();
adminPanel.use("*", auth.requireAdmin());
adminPanel.route("/", admin.routes);
app.route("/admin", adminPanel);
Opciones#
errorPage- Requerido.
planNames: string[]- Planes validos. Un plan fuera de esta lista se rechaza con 400.
layout: (c, {title, content, active}) => string- Envuelve el contenido en tu shell.
activees"admin-users"o"admin-orgs", para marcar el item del menu. basePath?: string- Por defecto
"/admin". Usalo si lo montas en otra ruta. pageSize?: number- Por defecto 25. Es
LIMIT/OFFSETreal, no se lee la tabla entera.
Rendimiento#
Los contadores por fila (organizaciones por usuario, miembros por organizacion, email del owner)
salen de un JOIN agregado con GROUP BY / ROW_NUMBER, no de una subconsulta
por fila. Con cien organizaciones en pantalla la diferencia entre las dos formas es abismal.
Extras#
El modulo tambien exporta un <select> buscable, util cuando la lista de opciones crece:
searchCombobox(opts)- Renderiza el combobox.
COMBOBOX_SCRIPT- El JS que lo acompaña. Inyectalo una vez en la pagina.
Integrado en el menu del proyecto#
Asi se ve cuando el panel comparte la barra lateral con el resto de la aplicacion:
import { createAdmin } from "@jun/soul/admin";
import { shell, sidebarFor } from "./core";
export const admin = createAdmin({
errorPage,
planNames: Object.keys(PLANS),
layout: (c, opts) =>
shell({
title: opts.title,
body: `<div class="soul-layout">
${sidebarFor(c, opts.active)}
<main class="soul-main">${opts.content}</main>
</div>`,
}),
});
En otra ruta y con paginas mas cortas#
export const admin = createAdmin({
errorPage,
planNames: Object.keys(PLANS),
layout: (c, opts) => shell({ title: opts.title, body: opts.content }),
basePath: "/plataforma",
pageSize: 10,
});
const panel = new Hono<{ Bindings: SoulEnv; Variables: AuthVariables }>();
panel.use("*", auth.requireAdmin());
panel.route("/", admin.routes);
app.route("/plataforma", panel);
Redirigir la raiz del panel#
/admin a secas no tiene pagina propia: conviene mandarlo a usuarios.
app.route("/admin", adminPanel);
app.get("/admin", (c) => c.redirect("/admin/users", 302));
Sumar tus propias pantallas al panel#
El panel de soul y el tuyo pueden convivir bajo el mismo guard:
const adminPanel = new Hono<{ Bindings: SoulEnv; Variables: AuthVariables }>();
adminPanel.use("*", auth.requireAdmin());
// Las de soul: /admin/users, /admin/orgs
adminPanel.route("/", admin.routes);
// La tuya: /admin/metricas
adminPanel.get("/metricas", async (c) => {
const { results } = await c.env.DB.prepare(
`SELECT o.name, COUNT(f.id) AS facturas
FROM orgs o LEFT JOIN facturas f ON f.org_id = o.id
GROUP BY o.id ORDER BY facturas DESC LIMIT 20`,
).all();
return c.html(shell({ title: "Metricas", body: tabla(results) }));
});
app.route("/admin", adminPanel);
Combobox buscable#
import { searchCombobox, COMBOBOX_SCRIPT } from "@jun/soul/admin";
const campo = searchCombobox({
name: "product_id",
placeholder: "Buscar producto…",
options: productos.map((p) => ({ value: String(p.id), label: p.title })),
});
return c.html(shell({ title: "Nueva licencia", body: `
<form method="post">${campo}<button type="submit">Crear</button></form>
${COMBOBOX_SCRIPT}
` }));
Probar que el panel esta cerrado#
it("un usuario normal no entra al panel", async () => {
const id = await createTestUser(env.DB, { email: "normal@test.dev" });
const cookie = await sessionCookieFor(env.SESSION_SECRET, id, "normal@test.dev");
const res = await SELF.fetch("http://localhost/admin/users", { headers: { Cookie: cookie } });
expect(res.status).toBe(403);
});
it("un superadmin si entra", async () => {
const id = await createTestUser(env.DB, { email: "root@test.dev", isAdmin: true });
const cookie = await sessionCookieFor(env.SESSION_SECRET, id, "root@test.dev");
const res = await SELF.fetch("http://localhost/admin/users", { headers: { Cookie: cookie } });
expect(res.status).toBe(200);
});