Ir al contenido

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 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 biblioteca de medios y la pantalla de ajustes. Lo que queda fuera es la pantalla de proveedores sociales, invitar a un segundo usuario y la pantalla de cuenta.

Una instalación cuya tabla users está vacía no tiene login que enseñar, así que cualquier ruta del panel lleva al asistente.

  1. Abres /admin y acabas en /admin/setup.

  2. 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.

  3. Al enviarlo se guardan los ajustes, se crea tu cuenta ya verificada y entras al panel con la sesión abierta.

  4. A partir de ahí /admin/setup responde 404 y /api/auth/sign-up/email responde 403.

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.

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 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 trae un tema de respaldo y no hay nada que configurar.

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.

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.

Así, un config con posts sueltas y una colección de media acaba en esto:

Posts ← suelta, arriba
Autenticación
Users
─────
Escritorio · Medios · Ajustes

Abajo del todo, separadas de las colecciones porque no lo son, van el escritorio, los medios y los ajustes. 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.

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]=astro

Un 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.

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.

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.

Puedes sustituir el componente de un tipo por uno tuyo, desde cms.config.ts:

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.

Una cuadrícula con las miniaturas de tus colecciones upload. 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 sus dimensiones, su tamaño, su URL pública seleccionable y sus campos, editables.

Se suben varios ficheros 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.

/admin/settings edita los seis 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 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.

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.

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.

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.

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
Pantalla de cuenta ❌ Enrutada en /admin/account, sin dibujar
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