Panel de administración
El panel vive en /admin y lo monta la integración: no hay nada que instalar, ni una página que
escribir, ni un componente que importar. Declaras tus colecciones y tus globals y ahí está.
Hoy funciona el CMS entero: el asistente de configuración inicial, el login, el menú construido desde tu config, el tema heredado de tu sitio, el listado de cada colección, el editor de documentos, la pantalla de cada global, la biblioteca de medios, la pantalla de ajustes y la de tu cuenta. Lo que queda fuera es la pantalla de proveedores sociales e invitar a un segundo usuario.
La primera vez
Sección titulada «La primera vez»Una instalación cuya tabla users está vacía no tiene login que enseñar, así que cualquier ruta del
panel lleva al asistente.
-
Abres
/adminy acabas en/admin/setup. -
Un formulario pide el nombre del sitio, su URL pública, el remitente del correo y su dirección de respuesta —los ajustes— más tu nombre, tu email y tu contraseña.
-
Al enviarlo se guardan los ajustes, se crea tu cuenta ya verificada y entras al panel con la sesión abierta.
-
A partir de ahí
/admin/setupresponde404y/api/auth/sign-up/emailresponde403.
El orden importa y no es casual: los ajustes se validan y se escriben antes de crear la cuenta. Al revés, una URL mal tecleada dejaría un administrador creado, el registro cerrado y un asistente que ya no se puede volver a abrir.
Quién entra
Sección titulada «Quién entra»| Situación | Qué pasa |
|---|---|
| Sin usuarios en la base | Todo lleva al asistente |
| Con usuarios, sin sesión | Todo lleva a /admin/login |
| Con usuarios, sin sesión, ruta inexistente | Lleva al login también — no se revela que no existe |
Con sesión, en /admin/login |
Lleva al dashboard |
| Con sesión, ruta inexistente | 404 dentro del panel |
Con usuarios, en /admin/setup |
404. El asistente terminó su trabajo |
El tema es el tuyo
Sección titulada «El tema es el tuyo»El panel no define ni un color propio: todas sus reglas leen las variables de shadcn. Si tú las defines, se pinta con las tuyas.
Como /admin sirve un documento HTML entero, tu hoja de estilos no llega sola: se la señalas con
admin.css. Está explicado en
Integración de Astro.
Y si no la señalas, el panel la busca en el components.json de shadcn. Si tampoco la encuentra ahí, trae
un tema de respaldo y no hay nada que configurar.
Modo oscuro
Sección titulada «Modo oscuro»El panel trae su propio conmutador —está en el menú de tu cuenta, abajo del todo— y guarda la
elección en localStorage. La clase .dark que ponga el conmutador de tu sitio no le llega: /admin
sirve otro documento.
Lo que sí comparten es el localStorage del origen. Si tu conmutador escribe la misma clave, el panel
sigue al sitio; el paquete la exporta como THEME_KEY para que no tengas que copiar la cadena. Cómo
hacerlo, en Integración de Astro.
Un script en línea en el <head> aplica la elección antes del primer pintado, así que no hay
destello blanco al recargar.
El menú sale del config
Sección titulada «El menú sale del config»La barra lateral se construye leyendo tus colecciones, sin que tengas que declarar nada:
{ slug: 'posts', labels: { singular: 'Entrada', plural: 'Entradas' }, admin: { group: 'Catálogo', hidden: false }, fields: [/* … */],}| Opción | Efecto en el menú |
|---|---|
labels.plural |
La etiqueta que se ve |
admin.group |
Agrupa la colección bajo ese epígrafe |
admin.hidden |
La quita del menú. Su URL sigue funcionando |
upload |
La quita también: es la biblioteca, y esa ya está abajo |
No se inventa ningún grupo. Lo que no declara admin.group va suelto y siempre arriba, antes
de cualquier epígrafe, esté donde esté en tu config. Cada grupo sí es un epígrafe que pliega lo que
tiene debajo, y los grupos conservan el orden de tu config, no el alfabético: ese orden es una
decisión que alguien tomó.
La única colección que el CMS clasifica por su cuenta es users, que nace bajo Autenticación: un
usuario no es contenido y no debería sentarse arriba entre las colecciones que escribes tú.
Al pasar el ratón por una colección aparece un menú con dos atajos: verla entera y crear una nueva.
Los globals también salen de ahí
Sección titulada «Los globals también salen de ahí»Cada global visible tiene su propia fila, con su label y su enlace a
/admin/globals/<slug>. Se leen después de las colecciones, así que caen detrás de ellas:
| Opción del global | Efecto en el menú |
|---|---|
label |
La etiqueta que se ve. Sin ella se deriva del slug |
admin.group |
Lo archiva bajo ese epígrafe, detrás de las colecciones que declaran ese mismo grupo |
Sin admin.group |
Al bloque suelto de arriba, detrás de las colecciones sueltas |
admin.hidden |
Lo quita del menú. Su URL sigue funcionando, igual que en una colección |
Un grupo que ninguna colección declara nace el último, detrás de todos los que sí
—«Autenticación» incluido—, porque la barra lee las colecciones antes que los globals; entre ellos
mandan el orden de tu array globals. Y la fila de un global no tiene menú contextual: no hay
nada que crear, así que es solo su enlace.
Así, un config que declara posts y el global site acaba en esto:
Posts ← suelta, arribaSitio ← el global, detrás de las colecciones sueltasAutenticación Users─────Ver web · Medios · AjustesAbajo del todo, separadas de las colecciones porque no lo son, van «Ver web» —que abre la raíz del
sitio, /, en una pestaña nueva, para que el panel siga donde estaba—, los medios y los ajustes.
El escritorio no necesita fila: es la cabecera de la barra, el nombre del sitio, y también la raíz
de la miga de pan. Y al pie, tu cuenta.
La barra se pliega a iconos y recuerda cómo la dejaste en la cookie sidebar_state, así que el
servidor ya la dibuja plegada en la siguiente página.
El listado de una colección
Sección titulada «El listado de una colección»Las columnas salen de admin.defaultColumns, o de los tres primeros campos si no lo declaras. Si
nombras un campo que ya no existe, esa columna simplemente no se pinta: una errata en el config no
tumba la página.
Pulsar una cabecera ordena. El buscador traduce lo que escribes a contains sobre el campo que
declares en admin.useAsTitle — y si ese campo no es de texto no se pinta el buscador, porque
contains solo es legal sobre text, textarea y select. La paginación deja elegir entre 10, 25,
50 y 100 por página.
Y puedes filtrar por campo y operador. Cada tipo ofrece los operadores que tienen sentido para él
—contains en un texto, greater_than en un número o una fecha, in en un select— y nunca uno que
la base rechazaría. El valor lo escribes con el mismo componente que usa el editor: filtrar por autor
te da el mismo buscador de relación, no una caja donde teclear un id.
El filtro va en la URL con la misma sintaxis que la API REST, así que un filtro del panel se pega tal cual en una llamada a la API:
/admin/collections/posts?where[status][equals]=draft&where[title][contains]=astroUn filtro imposible —un campo que no existe, un operador que no aplica a ese tipo, una lista de más de cien valores— se descarta en silencio y el resto se aplica. Nunca es un error: una consulta que revienta en el frontmatter de Astro sería un 500 contando por qué, y eso describe tu esquema a quien no debería verlo.
Todo eso vive en la URL, no en el componente. Un listado ordenado por fecha, buscando «astro» y
filtrado por borradores es un enlace que puedes pegarle a alguien o guardar en marcadores, y sobrevive
a recargar. También significa que ordenar, buscar, filtrar y paginar funcionan con JavaScript
desactivado: son anclas y un formulario GET, no manejadores de eventos.
Un listado vacío dice tres cosas distintas según por qué está vacío —no hay documentos todavía, la búsqueda no encuentra nada, o has pedido una página que no existe— y cada una ofrece la acción que corresponde. Son tres situaciones diferentes y mezclarlas es mentirle a quien las lee.
Para borrar en lote marcas las casillas y confirmas en una pantalla que dice el número exacto y enumera los títulos. Si alguno falla, te lo dice: una tanda a medias nunca se reporta como éxito.
El panel lee por la misma API local que tus plantillas, así que el listado pinta lo que devuelve el
afterRead de la colección, no lo que hay en la fila: un hook que tape un campo lo tapa también
aquí. Ver Hooks.
El editor
Sección titulada «El editor»Creación y edición son la misma pantalla; crear es abrirla con un documento vacío, ya relleno con los
defaultValue que declara tu config.
Los campos se reparten en dos columnas según admin.position: los de 'sidebar' a la derecha, junto
a createdAt, updatedAt y las acciones; el resto a la izquierda. admin.description se pinta como
pista bajo el campo, admin.readOnly lo deshabilita y admin.width: 'half' lo pone a media anchura.
Guardar está deshabilitado mientras no cambies nada, ⌘S / Ctrl+S guarda, y salir con cambios sin
guardar te avisa. Un error de validación del servidor se pinta bajo su campo, con el foco puesto en el
primero que falla y sin perder nada de lo que hubieras escrito.
Borrar exige escribir el título exacto del documento. La comparación la hace el servidor: la del navegador se salta abriendo las herramientas de desarrollo.
El editor abre con lo que devolvió el afterRead, igual que el listado, y al guardar manda solo
los campos declarados: el formulario se arma recorriendo los fields de la colección y no las
entradas que llegaron del navegador. Una clave que un hook añadiera y no sea un
campo declarado no se pinta ni se guarda; en cambio, un valor que un afterRead haya cambiado en un
campo declarado y editable sí se pinta en su input, y si guardas desde ahí se escribe. Un afterRead
que transforma un campo editable es, por tanto, una transformación que el panel puede acabar
persistiendo.
La pantalla de un global
Sección titulada «La pantalla de un global»Es el mismo editor —el mismo formulario, el mismo reparto en dos columnas, el mismo ⌘S, el mismo
aviso al salir sin guardar— con lo que un global no tiene:
| Lo que falta | Por qué |
|---|---|
| La zona de peligro | Un global no se borra: existe mientras esté en el config |
| «Volver al listado» | No hay listado al que volver |
| «Creado» | Solo hay un documento, y la fecha en que nació no dice nada de él |
«Modificado» sí aparece, pero solo después del primer guardado: hasta entonces updatedAt es
null y la pantalla enseña los valores por defecto sin fingir que alguien los guardó.
El aviso de después de guardar tampoco es el de una colección: un global vive en KV, así que dice que el cambio puede tardar hasta un minuto en verse en otros sitios. Y si no hay namespace de KV, la pantalla lo avisa y no pinta el formulario: ofrecer un guardado que va a fallar es peor que no ofrecerlo.
Un componente por tipo de campo
Sección titulada «Un componente por tipo de campo»Cada uno de los nueve tipos trae su propio control, y cinco hacen algo más que un input:
| Tipo | Qué te da |
|---|---|
relationship |
Un buscador que consulta la colección destino por su useAsTitle, con 300 ms de espera entre teclas y las diez primeras coincidencias. Guarda el id, te enseña el título, y abre el documento relacionado en otra pestaña |
date |
Un calendario, con hora si el campo es datetime. Lo ves y lo eliges en tu huso, rotulado; lo que se guarda sigue siendo UTC |
select |
Un <select> normal hasta nueve opciones; a partir de diez, un buscador |
json |
Un área monoespaciada que valida al salir del campo y te dice la línea y la columna del error. Un JSON roto no se propaga. Y un botón para reindentar |
upload |
Miniatura, y un diálogo con tu biblioteca de medios filtrada por el accept del campo. También puedes arrastrar un fichero encima, con una condición: ver abajo |
Si la colección a la que apunta una relación está vacía, en vez de una lista vacía te ofrece crear el
primer documento. Y si su useAsTitle no es de texto, el buscador no aparece —contains solo es
legal sobre text, textarea y select— y queda el campo de id.
Poner el tuyo
Sección titulada «Poner el tuyo»Puedes sustituir el componente de un tipo por uno tuyo, desde cms.config.ts:
export default defineConfig({ admin: { fieldComponents: { json: './src/cms/JsonEditor.tsx#JsonEditor', }, }, collections: [posts],})Es una ruta, no una referencia. La ruta es relativa a la raíz de tu proyecto de Astro, igual que
admin.css, y lleva opcionalmente un #NombreDeLaExportación; sin él se usa la exportación por
defecto. Si la ruta no existe, Astro no arranca y el error te dice cuál era y de qué campo.
Tu componente recibe lo mismo que el de serie —el campo, el valor, un onChange, el error y si está
deshabilitado— y sustituye al original en todas partes: el editor, el panel de detalle de medios,
el formulario de subida y la barra de filtros. Es la vía para meter un editor de código de verdad, un
selector de color o un mapa sin tocar el paquete.
La biblioteca de medios
Sección titulada «La biblioteca de medios»/admin/media abre ssd, el almacén que el paquete aporta sin que declares nada. Es una cuadrícula
de miniaturas con la ruta siempre a la vista, y se recorre por carpetas.
Una carpeta no se crea ni se borra: nace con su primer fichero y desaparece con el último. Para
crear una, escribes su nombre en Carpeta al subir; para vaciarla, borras lo que tiene, y al
borrar el último fichero la pantalla te devuelve a la raíz, porque esa carpeta ya no está. Todo va en
la URL —?folder=<nombre>—, así que la navegación funciona con el botón atrás y un enlace se
comparte tal cual.
Lo que ves en la raíz:
| Baldosa | Qué es |
|---|---|
Las colecciones upload que declaras tú |
Cada una es una carpeta con su slug por nombre. Tiene su propia tabla y sus propias reglas, así que se entra en ella para subir ahí, y su nombre no se puede teclear como carpeta de ssd |
| Las carpetas de la biblioteca | Los folder distintos que hay en ssd, leídos de D1 |
| Los ficheros sueltos | Los que no tienen carpeta |
Las carpetas que empiezan por . son del CMS —.system, donde Ajustes guarda el favicon y el
logo—: no se listan, no se navegan y ?file= no abre un fichero suyo. Se editan desde la
pantalla que las posee, que es Ajustes.
Subir deja los ficheros en la carpeta que estás viendo, sin tener que repetirla; dentro de una
colección tuya la carpeta ni se pregunta, porque es su slug. Se suben varios a la vez, arrastrándolos
o con el selector de siempre. Si uno falla —por ejemplo un fichero de texto renombrado a .png, que
el olfateo de bytes rechaza— se te dice cuál por su nombre, y los demás de la misma tanda sí entran.
Al seleccionar un fichero se abre su panel de detalle en una URL propia (?file=<id>), no en un
diálogo, así que el botón atrás funciona y el enlace se puede compartir. Dentro están su carpeta, sus
dimensiones, su tamaño, su URL pública seleccionable y sus campos, editables — ahí es donde se
escribe el alt, que en la biblioteca es opcional.
Borrar un fichero borra su objeto en R2, salvo que alguien lo esté usando: si un campo upload
apunta a él, la negativa nombra quién —la colección y el documento por su título, y el campo;
o el global y el campo, «Sitio (logo)»— en vez de decir que no y ya. Cambia
esa referencia y vuelve a intentarlo. Con un global la guarda lee KV en el momento del borrado, no
la caché de un minuto, así que un logo recién guardado cuenta aunque KV no tenga claves foráneas.
La pantalla de ajustes
Sección titulada «La pantalla de ajustes»/admin/settings edita los siete ajustes del sitio que viven en KV. Al guardar
te avisa de que los cambios tardan hasta un minuto en propagarse, que es la ventana de KV más la caché
del CMS, no una estimación por lo bajo.
Los proveedores sociales todavía no tienen pantalla, ni lado servidor.
El logo y el favicon
Sección titulada «El logo y el favicon»El panel trae una marca propia y la usa en tres sitios: la baldosa de la cabecera del menú, la
tarjeta del login y del asistente, y el <link rel="icon"> de la página.
En pantalla el dibujo va en currentColor, así que hereda el color de la baldosa —que es el
--sidebar-primary de tu tema— en vez de traer uno fijo que se pelee con él.
Para cambiar el icono de la pestaña, el ajuste favicon:
await Astro.locals.cms.settings.update({ favicon: '/mi-logo.svg' })Vale una ruta de tu sitio o una URL completa. Mientras esté vacío se usa el que trae Kevin CMS, que
viaja dentro del propio JavaScript del panel como un data: URI — el paquete no sirve ficheros
estáticos, así que no hay una URL que pedirle.
Lo normal, sin embargo, es no escribir la URL a mano: /admin/settings tiene un selector de fichero
para el favicon y otro para el ajuste logo, y lo que hacen es subir ese fichero a .system
—la carpeta reservada de la biblioteca— y guardar en el ajuste la URL que
sale, /api/cms/cdn/ssd/<id>/<fichero>. Sustituir uno se lleva por delante el fichero anterior; la
casilla de al lado lo deja vacío otra vez.
logo se guarda y se sirve, y ahí acaba su trabajo por ahora: el panel sigue pintando su propia
marca en la baldosa de la cabecera y en la tarjeta del login. Es el logo de tu empresa, para que
tu sitio lo lea de los ajustes y lo pinte donde le toque.
Tu cuenta
Sección titulada «Tu cuenta»La tarjeta del pie muestra tu nombre, tu correo y tu avatar —o tus iniciales, si no tienes uno—, y abre un menú con tres cosas: la pantalla de cuenta, el conmutador de tema y cerrar sesión.
Cerrar sesión es un formulario de verdad, no una llamada en segundo plano: pasa por el mismo camino que cualquier otra escritura del panel, borra la sesión de la base —no solo la cookie de tu navegador— y te deja en la pantalla de entrada.
La pantalla de cuenta
Sección titulada «La pantalla de cuenta»/admin/account tiene tres bloques y no guarda nada nuevo en la base: todo sale de las tablas que
better-auth ya escribía.
Tu perfil. El nombre se edita y se guarda; al volver, el pie de la barra lateral ya lo muestra. El correo se ve pero no se toca, y la pantalla dice por qué: identifica la cuenta, y cambiarlo obligaría a volver a verificarlo con un flujo que todavía no existe.
Tu contraseña. Pide la actual y la nueva. Un cambio con éxito cierra todas tus demás sesiones
y conserva la tuya —no es una casilla que puedas desmarcar: quien cambia la contraseña porque
sospecha de un acceso ajeno espera exactamente eso—. Si la contraseña actual que escribes no es
correcta, no se cambia nada y no se cierra ninguna sesión. Una cuenta sin contraseña, que es una fila
en users sin su fila en account, no ve este bloque en vez de ver un formulario que fallaría.
Tus sesiones abiertas. Una fila por sesión viva, con la IP, el navegador y cuándo caduca. La sesión desde la que estás mirando aparece marcada como «Esta sesión» y no trae botón de cerrar: para eso está «Cerrar sesión» del menú, que además te lleva a la pantalla de entrada. Cualquier otra se cierra desde su fila y deja de funcionar en su siguiente petición.
Funciona sin JavaScript
Sección titulada «Funciona sin JavaScript»Los formularios del panel son <form method="POST"> de toda la vida, enviados a su propia URL. React
los mejora —validación inmediata, errores sin recargar— pero no es lo que los hace funcionar: con
JavaScript desactivado, el login y el asistente siguen entrando.
Cuando el servidor rechaza un envío responde 400 y vuelve a dibujar el formulario con el error sobre
el campo que lo causó y lo que habías tecleado intacto. La contraseña no: un campo de contraseña
devuelto dentro del HTML acaba en el registro de cada proxy por el que pasa.
Por qué se renderiza en el servidor
Sección titulada «Por qué se renderiza en el servidor»El panel es una página de Astro que lee Astro.locals.cms en su frontmatter, con React solo en las
piezas que necesitan comportamiento. No es una aplicación de una sola página que hable por HTTP con tu
propio servidor.
La razón es que la API local llega ya tipada por lo que genera el codegen, y la API REST no tiene tipos de cliente generados: una SPA tendría que mantener a mano una copia de cada interfaz que ya existe. Y el salto de red no compra nada en una página que el servidor está renderizando de todos modos.
El precio es el aviso de más arriba: por la API local no pasa ningún control de acceso, así que la guarda tiene que ser impecable.
Las dos veces que el navegador sí pide algo
Sección titulada «Las dos veces que el navegador sí pide algo»El panel se diseñó para no hablar por HTTP con tu propio servidor, y sigue siendo así en todas las pantallas menos en dos puntos del editor:
- buscar en el campo
relationship, porque una colección de diez mil filas no cabe en un desplegable y sembrarla desde el servidor no escala; - elegir o arrastrar un fichero en el campo
upload.
Resolverlo navegando —un panel de selección con su propia URL, como el ?file= de Medios— habría
perdido lo que aún no has guardado en el formulario. Esa es la razón entera.
Las peticiones van a tu propia API REST, al mismo origen, así que llevan tu cookie de sesión y CORS nunca entra en juego. Están confinadas a un solo módulo del paquete para que la excepción se pueda auditar de un vistazo, y no hay ninguna otra: el resto del panel sigue sin pedir nada.
Lo que todavía no hay
Sección titulada «Lo que todavía no hay»| Cosa | Estado |
|---|---|
| Proveedores sociales en Ajustes | ❌ No hay ni pantalla ni lado servidor |
| Invitar a un segundo usuario | ❌ El registro queda cerrado tras el primero |
| Barra de progreso al subir | ❌ Exigiría pedir desde el navegador, y el panel no lo hace |
| Roles y permisos | ❌ Toda cuenta con sesión hace de todo |
| Traducir el panel | ❌ Está en español |