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 tus globals y ahí está.

Hoy funciona el CMS entero: el asistente de configuración inicial, el login con «olvidé mi contraseña», 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.

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, salvo /admin/forgot y /admin/reset/:token (más abajo)
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
Sin sesión, en /admin/forgot o /admin/reset/:token Las pantallas de recuperar la contraseña. Un enlace que no sirve responde 404 con la salida de pedir otro
Con sesión, en /admin/forgot o /admin/reset/:token Lleva al dashboard, como el login. El enlace del correo no se gasta
Sin usuarios, en /admin/forgot o /admin/reset/:token Lleva al asistente: no hay cuenta que recuperar
Con sesión, ruta inexistente 404 dentro del panel
Con usuarios, en /admin/setup 404. El asistente terminó su trabajo
Con sesión, pero access.admin no la deja entrar La pantalla «Sin autorización», con 403: sin barra lateral ni nombres de colección, solo un botón para cerrar sesión

El panel no tiene una política propia: pregunta a la misma función que la API REST y la API local, con la sesión de quien mira. Sin access, deja hacer lo que siempre. Con él, esconde lo que la operación va a rechazar, y la operación sigue rechazándolo aunque alguien fabrique el envío a mano.

Dónde Qué cambia
La puerta access.admin, en la colección de las cuentas, decide quién entra. Sin esa función entra toda sesión
Ajustes del sitio access.settings, en la colección de las cuentas, decide quién cambia /admin/settings; sin esa función, solo el rol de admin.firstUserRole. Quien no puede cambiarlos ve la pantalla con los campos en solo lectura y sin «Guardar». Ver Quién puede cambiarlos
Barra lateral y escritorio Una colección cuyo read es «nada» para la persona desaparece, y su URL responde la vista 404 del panel, como una colección que no existe. Los recuentos los hace la propia persona: cuentan lo que ve. Un global que no puede leer tampoco tiene fila ni tarjeta
Listado Sin «Crear» donde create es falso. El borrado en bloque solo se ofrece si delete no es false; con una condición se ofrece y decide la operación fila a fila. Las columnas y los filtros solo ofrecen campos legibles
Documento Si update es falso para este documento —o una condición lo deja fuera—, el formulario sale de solo lectura: campos deshabilitados, sin «Guardar» ni zona de borrado. Sin delete para este documento, no hay zona de borrado
Campos Uno que la persona no puede leer no se pinta. Uno que puede leer y no escribir sale deshabilitado, y no viaja en el envío
Un global Igual, con su update. Uno que la persona no puede leer da la vista 404, y no sale en la barra lateral ni en el escritorio
Medios Un almacén de ficheros del proyecto que la persona no puede leer no es baldosa ni carpeta. La biblioteca ssd es siempre pública

Un envío rechazado por permisos no es una página de error: se vuelve a pintar el formulario de solo lectura con el aviso «No tienes permiso…» y lo guardado. Si una condición de update o de delete deja fuera el documento, el panel lo sabe —acaba de leerlo— y avisa de que no tienes permiso, no de que alguien lo haya borrado. En un borrado en bloque, si se rechazan todas las filas es ese aviso, y si solo algunas es un borrado parcial.

La pantalla «Sin autorización» no lleva JavaScript: su botón de cerrar sesión es un formulario nativo. Y una cuenta que la ve no puede escribir nada: es el único envío que se le acepta.

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.

El panel tiene tres temas: claro, oscuro y automático, que sigue al sistema operativo y cambia con él sin recargar. Se elige en la pantalla de cuenta y vale al instante: no hay botón de guardar, y el menú de tu cuenta ya no lleva ningún conmutador.

La elección vive solo en el localStorage del navegador: ni cookie, ni columna en users, ni endpoint. Por eso el servidor no la conoce y no viaja de un dispositivo a otro. Lo que se guarda es light o dark; automático es la ausencia de la clave, y cualquier otro valor guardado se lee como automático. Si el navegador no deja escribir —con las cookies bloqueadas, por ejemplo—, el selector aplica la elección a esa página y lo dice: la siguiente no la recordará.

Un script en línea en el <head> la aplica antes del primer pintado, así que no hay destello al recargar. Deja class="dark" en el <html> cuando el tema resuelto es oscuro y data-theme="light" o data-theme="dark" siempre; el color de los controles nativos (color-scheme) sigue al mismo tema. El script es estático, no lleva nada de la petición, y su hash se exporta como THEME_SCRIPT_HASH desde @kevolution-co/cms: una Content-Security-Policy estricta lo permite en script-src sin 'unsafe-inline'.

La clase .dark que ponga el conmutador de tu sitio no le llega al panel: /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.

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.

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, arriba
Sitio ← el global, detrás de las colecciones sueltas
Autenticación
Users
─────
Ver web · Medios · Ajustes

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

/admin es el escritorio: un resumen con una tarjeta por cada colección y por cada global que la persona puede abrir. Se pinta en el servidor, sin JavaScript, y cada tarjeta es un enlace.

Grupo La tarjeta A dónde lleva
Colecciones Su nombre en plural y cuántos documentos hay El listado de la colección
Globales Su label y, si la declaras, admin.description. Sin contador: un global es un único documento Su pantalla de edición, /admin/globals/<slug>

Las tarjetas salen de la misma lista que la barra lateral, así que valen las mismas reglas:

  • Una colección o un global con admin.hidden conserva su URL y no tiene tarjeta. Los almacenes de ficheros tampoco: su puerta es la biblioteca de medios.
  • Lo que la persona no puede leer no tiene tarjeta, ni fila en la barra, ni rastro en la página. Se decide con la misma función access.read que usa la API, no con una copia. Los recuentos los hace la propia persona, así que una colección con una condición de lectura cuenta solo las filas que ve.
  • Un grupo sin tarjetas no se pinta. Sin ninguna, el escritorio dice «No hay nada que gestionar todavía», lo mismo si tu config no declara nada que si la persona no puede leer nada: no cuenta cuál de las dos cosas pasa.
  • Una sesión que access.admin no deja entrar no llega aquí: ve la pantalla «Sin autorización».

Si el recuento de una colección falla —lo habitual es una colección recién declarada cuya migración todavía no has aplicado— su tarjeta lo dice («No se ha podido contar») y el resto del escritorio sigue funcionando. El motivo queda en el log del servidor.

El escritorio no tiene isla de React: lo pinta el servidor y no añade JavaScript al navegador.

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, email, select y radio. La paginación deja elegir entre 10, 25, 50 y 100 por página.

Y puedes filtrar, con condiciones sueltas o con grupos Y y O. Una condición es un campo, un operador y un valor. 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, y el selector solo ofrece los campos que tu access deja leer. Un hasMany se filtra por alguno de sus valores, con los operadores de su tipo; el de relación con varias colecciones solo ofrece «tiene valor», porque preguntar por un id necesita la colección y un desplegable de operadores no sabe decirla. 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.

Las condiciones sueltas se cumplen todas (Y). Para «esto o esto» añades un grupo «alguna» (O); para condiciones que se cumplan juntas dentro de otro grupo, uno «todas» (Y). «Borradores cuyo título contenga astro o panel» son una condición y un grupo «alguna» con otras dos.

El filtro va en la URL con la misma sintaxis que la API REST (corchetes con índices), así que un filtro del panel se pega tal cual en una llamada a la API y devuelve los mismos documentos:

/admin/collections/posts?where[and][0][status][equals]=draft&where[and][1][or][0][title][contains]=astro&where[and][1][or][1][title][contains]=panel

La URL es canónica: el mismo filtro es siempre la misma dirección, sea cual sea el orden de los parámetros, y los enlaces que pinta el panel (quitar una condición, cambiar un grupo entre «todas» y «alguna») son esa dirección con una sola edición. Un grupo recién creado y aún sin condiciones se escribe where[and][1][or]=: no filtra, y por eso no es una consulta REST válida hasta que lo rellenas.

Hay topes, y están por D1, que admite 100 variables por sentencia: 20 condiciones en total, 10 grupos y 50 valores entre todas las condiciones (cada elemento de un in cuenta uno; la búsqueda, la paginación y tu política de lectura usan el resto). Al llegar a 20 el panel deja de ofrecer «Añadir». Los grupos tienen un nivel: un grupo dentro de otro grupo no existe todavía.

Un filtro imposible —un campo que no existe o que no puedes leer, un operador que no aplica a ese tipo, un valor que no es del tipo del campo, algo que pasa de los topes— no rompe la página: se ignora, el resto se aplica y el panel avisa de cuántas condiciones ignoró, nunca de cuáles, de modo que un campo que no puedes leer se ve exactamente igual que uno que no existe. Y si la base se niega a ejecutar la consulta, el listado pinta su estado «No se han podido cargar …» (ver Los avisos) con los filtros a la vista para quitar alguno, en vez de una página en blanco contando por qué, que describiría 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. Una sola cosa necesita el navegador: elegir campo y operador al añadir una condición. Sin JavaScript el formulario añade la condición con el campo y el operador con que se pintó (los primeros); para otros, escribe el filtro en la dirección. Quitar condiciones y grupos, cambiar un grupo de «todas» a «alguna» y añadir grupos son enlaces y funcionan igual.

Un listado vacío dice tres cosas distintas según por qué está vacío —no hay documentos todavía, la búsqueda o el filtro no encuentran 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. Una consulta que falla es una cuarta, con su propio aviso, y no se confunde con «no hay resultados».

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 listado pide solo lo que pinta: las columnas y el título del documento. Un array o un blocks que no es columna no se lee (sus tablas hijas, ni una sentencia), y si lo es, su celda dice cuántas filas tiene («3 elementos»). La consecuencia es que, en el listado, el afterRead de la colección recibe el documento con esos campos y no con todos: un hook que calcule algo a partir de un campo que no es columna no lo encontrará ahí. En el editor, en cambio, el documento llega entero.

Creación y edición son la misma pantalla; crear es abrirla con un documento vacío, ya relleno con los defaultValue estático que declara tu config. Las filas del defaultValue de un array o un blocks salen con su id y con el defecto de cada subcampo.

Un defaultValue que es una función no se pinta en el formulario (el navegador no recibe tu función): se calcula en el servidor al guardar. Al crear, un campo con defecto función que dejas en blanco no manda su clave y el alta le aplica el defecto, igual que una llamada a la API que no lo trae; al editar, dejar un campo en blanco lo guarda vacío (null), sin recuperar el defecto. Un defecto estático que borras a propósito al crear también queda vacío.

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.

La estructura que declara el config se respeta, sin una isla aparte: un group es un fieldset con su legend; un point, dos entradas (longitud y latitud); una row coloca sus campos lado a lado (con admin.width repartiendo la línea) y los apila en un móvil; las tabs salen todas apiladas desde el servidor y se convierten en pestañas con teclado al cargar el navegador; y un collapsible es un <details> nativo. Un error en una pestaña la abre y la marca, y uno en un plegado lo abre. El detalle, con sus límites, está en Los contenedores en el panel.

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, y además sale un aviso que cuenta cuántos campos fallan («2 campos con errores»), para que el error se vea aunque el campo esté fuera de pantalla. El formulario no valida en el navegador (novalidate): un campo obligatorio vacío se envía igual y lo rechaza el servidor, así que el mensaje es siempre el mismo con o sin JavaScript.

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. Guardar no cambia esto: el panel no repinta el formulario con la respuesta de create o update —que también pasa por afterRead—, solo usa su id para redirigir, y el editor vuelve a leer el documento.

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.

Cada uno de los quince tipos trae su propio control, y estos 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; un botón «Quitar» lo vacía (no aparece si el campo es required). Con hasMany, una lista de documentos
text, textarea Un input con el placeholder del campo; el textarea mide rows líneas (6 por defecto) y no crece con el texto
number Un input numérico cuyo paso es step (o 1 si es integer, o libre). Al salir del campo avisa si el valor no cae en la rejilla, con el mismo mensaje que daría el servidor
email Un input de correo que avisa al salir del campo si no lo es (a@b no vale, a@b.co sí)
date Un calendario, con hora si el campo es datetime, o un selector de mes si es month. Lo ves y lo eliges en tu huso, rotulado; lo que se guarda sigue siendo UTC. Con format, una vista previa bajo el campo con el aspecto que le diste
select Un <select> normal hasta nueve opciones; a partir de diez, un buscador. Si no es obligatorio, un botón «Borrar» deja el campo en null
radio Un grupo de radios nativos en un fieldset con su legend, en vertical u horizontal según layout; las flechas del teclado cambian de opción, y si no es obligatorio lleva el mismo botón «Borrar»
code Un editor de código (CodeMirror 6) con el coloreado del language del campo. Se descarga solo al abrir un formulario que tiene uno; mientras carga, si no llega y sin JavaScript es un área de texto monoespaciada que guarda igual. Tab escribe un tabulador; Esc y luego Tab salen del editor
json El mismo editor, que valida al salir del campo y te dice la línea y la columna del error. Un JSON roto no se propaga y vacío se guarda como null. Con schema, también comprueba que cumple el schema (el validador se descarga solo entonces). Y un botón para reindentar
text, number, select con hasMany Una lista ordenada de valores: filas con su input para text y number, fichas con buscador para select. Se añade, se edita, se quita y se reordena con el puntero o con Alt+↑ / ↓, con minRows, maxRows, min, max y step como los juzga el servidor
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. Con hasMany, una galería
richText Un editor con barra de herramientas, atajos y pegado limpio. Solo se descarga al abrir un documento que lo tiene. Ver la guía del texto enriquecido
array Filas plegables, cada una con su título (admin.rowLabel), su contador de errores y un menú para subir, bajar, duplicar y eliminar, o Alt+↑ y Alt+↓ desde la cabecera, y un asa para arrastrar la fila con el puntero. Ver cómo se edita una lista
blocks Lo mismo que un array, con un menú de catálogo para añadir (o insertar encima y debajo) un bloque de cada tipo, y una pill con el tipo en cada cabecera. El tipo de una fila no se cambia, y uno que el config ya no declara se pinta como «tipo desconocido» sin perderse. Ver el catálogo y el tipo de cada fila

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, email, select y radio— y queda el campo de id.

Un relationship o un upload con hasMany: true no se pinta como un buscador, sino como una lista en el orden en que se guarda:

  • Añadir. El botón abre el mismo buscador de una relación suelta y, en una galería, la biblioteca de medios filtrada por el accept del campo, donde marcas varios ficheros y los añades de una vez. El buscador no ofrece lo que ya está en la lista —sale «Añadido»—, aunque la API admite repetidos y los que ya hay no se tocan. Con el teclado, teclear e Intro elige el primer resultado que se puede elegir, y las flechas cambian de fila. Si el campo tiene varios destinos, antes eliges la colección, y cada fila lleva el nombre de la suya.
  • Subir ficheros. En una galería también puedes soltar varios ficheros sobre el campo: se suben de uno en uno, en el orden en que los soltaste, y cada uno entra en la lista nada más subirse. Si uno falla, los demás se quedan y el mensaje dice cuál fue. Valen las mismas condiciones que en el campo de un solo fichero (el aviso de arriba).
  • Ordenar. Cada fila trae Subir y Bajar, y con el foco en cualquiera de sus controles, Alt + ↑ o ↓ la mueve: el foco la acompaña y un lector de pantalla oye su nueva posición. También se arrastra con el puntero por su asa (a la izquierda de la fila): una línea marca dónde caería, y el aviso y el foco son los mismos. En una pantalla táctil hay que mantener pulsada el asa 200 ms antes de arrastrar. Soltarla donde estaba, con Esc o fuera de la lista no cambia nada.
  • Quitar. Deja el foco en la fila que ocupa su sitio, y en Añadir si era la última.
  • Límites. El campo no pasa de maxRows (100 si no lo declaras): al llegar, Añadir sigue enfocable pero avisa en vez de abrirse, y un fichero que ya no cabe se rechaza antes de subirlo. minRows y required se anuncian en el recuento («1 elemento · mínimo 2») pero no impiden quitar: quien lo exige es el servidor, al guardar.
  • Lo que ya no se puede ver. Un documento que se borró, un fichero de la carpeta reservada de la biblioteca o uno de una colección que tu access.read te niega se pinta con su id y «No disponible». El valor no cambia hasta que lo quitas tú: el panel nunca decide que un id sobra. Una colección que tu access.read cierra con un «no» rotundo ni se ofrece en el selector; si el permiso depende de la fila, el panel no puede saberlo de antemano, y las filas que no ves salen «No disponible» igual.
  • Errores. Cuando el servidor rechaza un elemento (categories.1), el mensaje sale en su fila y se queda con él aunque lo muevas o quites otro.
  • Sin JavaScript. El valor viaja en un campo oculto con el JSON del array, así que el formulario guarda lo mismo. Con JavaScript, el campo vuelve a ese JSON editable cuando el valor no es un array o cuando el destino no tiene un useAsTitle de texto con el que buscar.

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 selector de color, un mapa o tu propio editor de código sin tocar el paquete.

Los subcampos de un array o de un blocks se pintan con el componente de su tipo, el de serie o el tuyo, y por eso FieldProps lleva tres claves más que solo cuentan ahí:

Clave Qué dice
path Dónde está el campo en el formulario: links.0.url en la primera fila de links, y field.name fuera de una fila. De ahí cuelgan los id del control, de su pista y de su error
nested true dentro de una fila. Su valor viaja en el JSON de la lista y no en un input propio
errors Los mensajes del formulario por ruta (links.1.url). Un editor de listas lee de aquí los de sus filas

Si construyes el control con common(props), ya hace lo correcto: dentro de una fila usa la ruta como id y no lleva name ni required nativo, y dice aria-required. Un name="cover" dentro de una fila sería un segundo campo cover en el envío, y se leería antes que el del primer nivel. Si construyes un id por tu cuenta (un aria-labelledby, un mensaje de error), hazlo con path ?? field.name y no con field.name.

Un array o un blocks tuyo recibe como value el JSON de sus filas, con las celdas como el texto de su input (el formato), y avisa con onChange del JSON nuevo. El formulario envía un solo input con el nombre de la lista, <input type="hidden" {...common(props)} value={props.value} />, y no uno por celda. Una lista dentro de una fila no se pinta: dice que uses un campo json.

Si sustituyes array (o blocks) por un componente tuyo pierdes el editor de filas de serie, y el pie de tu lista recibe en error el mensaje de todas las filas juntas (a · b), no solo el de la lista: el editor de serie reparte cada mensaje en su fila con errors, que tú también recibes, y por eso al pie solo deja el de la lista.

Guardar, borrar, subir un fichero, un error de validación, un límite de intentos: todo lo que el panel tiene que decirte tras una acción sale como un aviso, siempre igual y siempre en el mismo sitio. En pantallas anchas se apila abajo a la derecha y en móvil arriba, para no tapar el botón «Guardar». Se cierra solo a los 4 segundos, con su botón o con Esc, y no corre mientras el puntero o el foco lo tocan. Nunca hay más de cinco: el sexto desplaza al más antiguo. La animación de entrada se omite si tienes prefers-reduced-motion.

Un aviso de error es role="alert"; los demás, role="status", y los lectores de pantalla los anuncian sin quitarte el foco.

El aviso viaja como un código, no como un texto. Tras un POST el panel responde 303 a la misma pantalla con ?notice=<código>, y a veces un número: ?notice=deleted.3 dice «Se han borrado 3 documentos». El texto sale de una lista cerrada que vive en el panel, así que una URL fabricada no escribe nada en tu pantalla: un código que no está en la lista no se pinta, y el texto libre tampoco. Una vez pintado, el navegador quita ?notice= de la barra de direcciones (los demás parámetros, como el filtro o la página, se quedan) y recargar no lo repite.

Los errores no pasan por la URL. Si el servidor rechaza un envío, responde 400 y dibuja la misma pantalla con su aviso y el error junto al campo; no hay redirección que lo lleve. Lo mismo vale para la clave de API que se enseña una sola vez al crearla: ni el valor ni nada que lo delate viaja por la URL, la cookie o un aviso, solo el mensaje de que se ha creado.

Con JavaScript desactivado el aviso sigue ahí, como un recuadro en el flujo de la página, encima del contenido. Sin botón ni cuenta atrás, porque nada podría quitarlo: se va al navegar a otra pantalla.

Cuando el listado de una colección no se puede leer, lo dice en vez de quedarse vacío: «No tienes acceso a …» (403, sin enlace, porque reintentar no lo arregla) o «No se han podido cargar …» (500, con «Reintentar»; el detalle del fallo va al registro del servidor y no a la pantalla). Una colección que ni siquiera existe para quien pregunta sigue siendo un 404: no confirma que esté.

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

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

Esa subida pasa por la biblioteca, así que un SVG es un 415 salvo que lo nombres en media.mimeTypes (ver Ficheros); el favicon: '/mi-logo.svg' de arriba, con una ruta de tu public/, no pasa por ahí y no necesita nada.

Ese borrado va a mejor esfuerzo: si falla, el ajuste se guarda igual y el fichero anterior se queda en .system sin que nada lo nombre. Aparece entonces en la sección Ficheros sueltos de la misma pantalla, con su nombre, su tamaño y su fecha, y Reclamar borra su fila y su objeto de R2. La lista solo enseña lo subido hace más de una hora, para no cruzarse con un guardado en curso: el fichero sustituido casi siempre la tiene ya, así que sale en cuanto el ajuste deja de nombrarlo, y si no, en cuanto la cumple. Lo que un ajuste usa no sale nunca en esa lista, y sin almacenamiento para la biblioteca (sin binding de R2 y sin un perfil default) la sección no aparece. No son los ficheros sueltos de la biblioteca, que son los que no tienen carpeta: estos son de .system, y la biblioteca no los enseña.

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.

La tarjeta del pie muestra tu nombre, tu correo y tu avatar —o tus iniciales, si no tienes uno—, y abre un menú con dos cosas: la pantalla de cuenta 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.

/admin/account tiene cinco bloques y no guarda nada nuevo en la base: todo sale de las tablas que better-auth ya escribía, y el tema ni siquiera pasa por ella.

Tu perfil. El nombre y la imagen se editan y se guardan; al volver, el pie de la barra lateral ya los muestra. La imagen es una dirección http o https de hasta 2048 caracteres (vacía, se quita; no se sube ningún fichero). 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. El rol también se ve y no se toca: lo cambia otra persona con permiso, desde Cuentas de usuarios. El envío solo lleva name e image a better-auth, aunque alguien le añada más campos al formulario.

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—. También revoca todas tus claves de API: las integraciones que las usen dejarán de funcionar hasta que generes otras (el aviso del cambio lo dice, y Claves de API explica el porqué). Si la contraseña actual que escribes no es correcta, no se cambia nada, no se cierra ninguna sesión y las claves siguen vivas. 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 claves de API. Una fila por clave tuya, con su nombre, cómo empieza, cuándo se usó por última vez y cuándo caduca; sin ninguna, la pantalla lo dice. Se genera una con un nombre y un plazo (30, 90, 180 o 365 días), y se regenera o se revoca desde su fila, tras una pantalla de confirmación. La clave en claro se enseña una sola vez, justo al generarla, con un aviso para copiarla ya; después solo se ve su prefijo. Todo son formularios nativos y funciona sin JavaScript. Los detalles —por qué solo con la sesión de cookie, los topes, qué pasa al recargar— están en Claves de API.

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.

Tu tema. Automático, claro u oscuro: un grupo de tres opciones, con su icono y su nombre, que se maneja con el teclado (las flechas cambian de opción). Eliges y se aplica al instante, sin botón de guardar. La elección se queda en el localStorage de ese navegador —no hay cookie ni columna—, así que no te sigue a otro dispositivo; el resto, incluido qué pasa sin JavaScript, está en Modo oscuro.

/admin/collections/users se lista como cualquier colección. La pantalla de una cuenta ajena añade, bajo el editor del nombre, las acciones de administrarla:

  • Rol, si tu access.update del campo role lo permite (por defecto, no).
  • Suspensión: motivo y días opcionales; una cuenta suspendida enseña hasta cuándo y por qué, y el botón para levantarla. Pide el access.update de banned, banReason y banExpires; sin él, la pantalla enseña el estado y dice qué declarar.
  • Fijar la contraseña: con confirmación; cierra todas sus sesiones y sus enlaces de recuperación, y borra sus claves de API, como restablecer la contraseña: las integraciones de esa cuenta dejan de funcionar hasta que su dueña genere otras (el formulario lo avisa; ver Claves de API).
  • Sesiones: cuántas tiene abiertas y un botón para cerrarlas todas. Se cuenta, no se lista: los tokens de sesión de otra cuenta no salen del servidor.

Todo esto está cerrado hasta que declares access.create y access.update en users: sin ellos la pantalla dice qué declarar en lugar de ofrecer las acciones, y el servidor responde 403. Declararlos enciende el panel, así que escribe quién puede: un update abierto a toda cuenta que inicia sesión deja que una sin rol fije la contraseña de quien administra (ver el aviso de Control de acceso). Quien no puede editar esa cuenta —según el access de users con ella delante— no ve ninguna de las acciones, y un POST a mano responde 403. Escribir por el panel no ejecuta los hooks de la colección users de tu proyecto (ver Autenticación). La propia cuenta no se administra aquí: la pantalla remite a Cuenta. Es un <form method="post"> por acción, sin JavaScript. Los detalles de lo que se comprueba y en qué orden están en Autenticación.

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, y las pantallas de recuperar la contraseña, que ni siquiera llevan isla, siguen funcionando.

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.

Tras un envío que sale bien, el panel redirige (303) con ?notice=<código> y los avisos se pintan en el servidor, en el flujo de la página. No hacen falta ni cookie ni isla para decirte que se guardó.

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: la puerta y la política tienen que estar bien cerradas, porque el panel confía en ellas. Por eso el panel no lee con la API del sistema sino con la de la sesión, y por eso la puerta es una función pura con su tabla de verdad completa.

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 por correo ❌ El alta es manual desde Cuentas de usuarios: tú pones la contraseña inicial. El registro público sigue cerrado tras el primero
Barra de progreso al subir ❌ Exigiría pedir desde el navegador, y el panel no lo hace
Roles y permisos ✅ Con access en el config. Sin él, toda cuenta con sesión hace de todo. Un solo role por cuenta; no hay lista de roles
Traducir el panel ❌ Está en español
Grupos dentro de grupos en el filtro del listado ❌ Un solo nivel de grupos Y/O, con topes de 20 condiciones y 10 grupos
Guardar un filtro como vista ❌ El filtro vive en la URL: guárdala en marcadores