From de25d47e0f0d6039725b487726b6feecb9476d42 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Thu, 22 Oct 2026 22:10:51 +0200 Subject: [PATCH 1/3] fix(frontend): say nothing declares a role the server does not know --- frontend/src/i18n/errorTemplates.ts | 4 ++-- frontend/src/languages/es-ES.json | 2 +- frontend/src/test/users-route.test.tsx | 33 ++++++++++++++++++++++++-- languages/alphone.pot | 8 +++---- languages/es-ES.po | 8 +++---- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/frontend/src/i18n/errorTemplates.ts b/frontend/src/i18n/errorTemplates.ts index 41558fb3..331d5353 100644 --- a/frontend/src/i18n/errorTemplates.ts +++ b/frontend/src/i18n/errorTemplates.ts @@ -62,9 +62,9 @@ export function errorTemplates(): Record { self_disable_refused: __('You cannot disable your own account.', DOMAIN), self_role_refused: __('You cannot change your own role.', DOMAIN), last_privileged_refused: __('Somebody must be able to manage users.', DOMAIN), - role_beyond_reach: __('That role holds more than your own, or it is not one this deployment names.', DOMAIN), + role_beyond_reach: __('That role holds more than your own, or nothing declares it.', DOMAIN), role_changed: __('The role of that account just changed. Reload the page and try again.', DOMAIN), - role_unknown: __('That role is not one this deployment names.', DOMAIN), + role_unknown: __('Nothing declares that role.', DOMAIN), } } diff --git a/frontend/src/languages/es-ES.json b/frontend/src/languages/es-ES.json index 098cf22a..914f1040 100644 --- a/frontend/src/languages/es-ES.json +++ b/frontend/src/languages/es-ES.json @@ -1 +1 @@ -{"":{"lang":"es","plural-forms":"nplurals=3; plural=((n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2));"},"\"%(name)s\" disabled.":["«%(name)s» se ha desactivado."],"\"%(name)s\" enabled.":["«%(name)s» se ha reactivado."],"\"%(name)s\" revoked.":["«%(name)s» revocado."],"%s token could not be revoked.":["No se ha podido revocar %s token.","No se han podido revocar %s de tokens.","No se han podido revocar %s tokens."],"%s token revoked.":["%s token revocado.","%s de tokens revocados.","%s tokens revocados."],"%s user could not be disabled.":["No se ha podido desactivar %s usuario.","No se han podido desactivar %s de usuarios.","No se han podido desactivar %s usuarios."],"%s user could not be enabled.":["No se ha podido reactivar %s usuario.","No se han podido reactivar %s de usuarios.","No se han podido reactivar %s usuarios."],"%s user disabled.":["%s usuario desactivado.","%s de usuarios desactivados.","%s usuarios desactivados."],"%s user enabled.":["%s usuario reactivado.","%s de usuarios reactivados.","%s usuarios reactivados."],"30 days":["30 días"],"90 days":["90 días"],"A contact needs a name.":["Un contacto necesita un nombre."],"A lifetime is zero days or more.":["Una duración es de cero días o más."],"A lifetime runs to %(maxDays)s days at most.":["Una duración llega como mucho a %(maxDays)s días."],"A name runs to %(max)s characters at most.":["Un nombre tiene como mucho %(max)s caracteres."],"A password runs to %(max)s characters at most.":["Una contraseña tiene como mucho %(max)s caracteres."],"A password runs to %(min)s characters at least.":["Una contraseña tiene al menos %(min)s caracteres."],"A task needs a title.":["Una tarea necesita un título."],"A token needs a name.":["Un token necesita un nombre."],"A year":["Un año"],"API tokens":["Tokens de API"],"Activation link":["Enlace de activación"],"Add a task above, or open a contact and create one from their page.":["Añade una tarea arriba, o abre un contacto y crea una desde su página."],"Add identity":["Añadir identidad"],"Add one with New contact.":["Añade uno con Contacto nuevo."],"Add one with New token.":["Añade uno con Token nuevo."],"Add one with New user.":["Añade uno con Usuario nuevo."],"Add task":["Añadir tarea"],"AlphOne cannot write to that channel.":["AlphOne no puede escribir en ese canal."],"AlphOne does not speak that language yet.":["AlphOne todavía no habla ese idioma."],"AlphOne sends no webhook to an internal address the operator has not allowed.":["AlphOne no envía webhooks a una dirección interna que el operador no haya permitido."],"Already on contact %(name)s.":["Ya está en el contacto %(name)s."],"An account needs a name.":["Una cuenta necesita un nombre."],"An event needs the source it came from.":["Un evento necesita la fuente de la que viene."],"Another account already holds that address.":["Ya hay otra cuenta que tiene esa dirección."],"Another contact already holds that address.":["Ya hay otro contacto que tiene esa dirección."],"Areas":["Áreas"],"Ask for between %(min)s and %(max)s at a time.":["Pide entre %(min)s y %(max)s a la vez."],"Ask for tasks by one filter at a time.":["Pide las tareas con un solo filtro a la vez."],"Avatar":["Avatar"],"Cancel":["Cancelar"],"Change role":["Cambiar el rol"],"Change role…":["Cambiar el rol…"],"Channel":["Canal"],"Channels":["Canales"],"Choose at least one area the token reaches.":["Elige al menos un área a la que llegue el token."],"Choose at least one event to send.":["Elige al menos un evento que enviar."],"Choose the channel this address belongs to.":["Elige el canal al que pertenece esta dirección."],"Choose the role of %(name)s.":["Elige el rol de %(name)s."],"Complete":["Completar"],"Contact":["Contacto"],"Contact added.":["Contacto añadido."],"Contact tasks":["Tareas del contacto"],"Contacts":["Contactos"],"Contacts could not be loaded.":["No se han podido cargar los contactos."],"Copied.":["Copiado."],"Copy it by hand.":["Cópialo a mano."],"Copy secret":["Copiar el secreto"],"Copy the secret now, it is never shown again.":["Copia el secreto ahora, no se vuelve a mostrar nunca."],"Create contact":["Crear contacto"],"Create task":["Crear tarea"],"Create token":["Crear token"],"Created":["Creado"],"Created %(date)s":["Creado el %(date)s"],"Disable":["Desactivar"],"Dismiss":["Descartar"],"Done":["Hecho"],"Done (%(count)s)":["Hechas (%(count)s)"],"Done tasks":["Tareas hechas"],"Due %(date)s":["Vence el %(date)s"],"Due date":["Fecha de vencimiento"],"Email":["Correo electrónico"],"Enable":["Activar"],"Expires":["Caduca"],"Identities":["Identidades"],"Identity":["Identidad"],"Identity added.":["Identidad añadida."],"Identity removed.":["Identidad quitada."],"Invitation sent to \"%(name)s\".":["Invitación enviada a «%(name)s»."],"Invitation sent.":["Invitación enviada."],"Label":["Etiqueta"],"Language":["Idioma"],"Last used":["Último uso"],"Link a contact":["Vincular un contacto"],"Load more":["Cargar más"],"Load more done":["Cargar más hechas"],"Load more overdue":["Cargar más vencidas"],"Load more tasks":["Cargar más tareas"],"Loading contact…":["Cargando el contacto…"],"Loading tasks…":["Cargando las tareas…"],"Loading task…":["Cargando la tarea…"],"Loading…":["Cargando…"],"Manage the people you work with and how to reach them.":["Gestiona las personas con las que trabajas y cómo contactar con ellas."],"Manage the tokens your programs sign in with.":["Gestiona los tokens con los que entran tus programas."],"Manage who can sign in and what they can do.":["Gestiona quién puede entrar y qué puede hacer."],"Name":["Nombre"],"Name updated.":["Nombre actualizado."],"Navigation":["Navegación"],"Never expires":["No caduca nunca"],"Never used":["Sin usar"],"New contact":["Contacto nuevo"],"New task":["Tarea nueva"],"New token":["Token nuevo"],"New user":["Usuario nuevo"],"Next day":["Día siguiente"],"No API tokens yet.":["Todavía no hay tokens de API."],"No contacts found.":["No se ha encontrado ningún contacto."],"No contacts yet.":["Todavía no hay contactos."],"No identities yet.":["Todavía no hay identidades."],"No mail server is configured. Deliver the activation link by hand.":["No hay servidor de correo configurado. Entrega el enlace de activación a mano."],"No open tasks.":["No hay tareas abiertas."],"No tokens found.":["No se ha encontrado ningún token."],"No users found.":["No se ha encontrado ningún usuario."],"No users yet.":["Todavía no hay usuarios."],"Nothing due today.":["No hay nada que venza hoy."],"Open tasks":["Tareas abiertas"],"Overdue":["Vencidas"],"Overdue tasks":["Tareas vencidas"],"Overdue tasks could not be loaded.":["No se han podido cargar las tareas vencidas."],"Postpone":["Posponer"],"Previous day":["Día anterior"],"Priority":["Prioridad"],"Read %(area)s":["Leer %(area)s"],"Reading the languages":["Leyendo los idiomas"],"Remove %(identifier)s":["Quitar %(identifier)s"],"Remove %(name)s":["Quitar a %(name)s"],"Rename":["Renombrar"],"Rename…":["Renombrar…"],"Reopen":["Reabrir"],"Resend invitation":["Reenviar invitación"],"Resend invitation…":["Reenviar invitación…"],"Revoke":["Revocar"],"Revoke \"%(name)s\"? This cannot be undone.":["¿Revocar «%(name)s»? Esto no se puede deshacer."],"Revoke %s token? This cannot be undone.":["¿Revocar %s token? Esto no se puede deshacer.","¿Revocar %s de tokens? Esto no se puede deshacer.","¿Revocar %s tokens? Esto no se puede deshacer."],"Revoke…":["Revocar…"],"Role":["Rol"],"Role changed for \"%(name)s\".":["Rol de «%(name)s» cambiado."],"Save":["Guardar"],"Scopes":["Ámbitos"],"Search contacts…":["Buscar contactos…"],"Search tokens…":["Buscar tokens…"],"Search users…":["Buscar usuarios…"],"Send a new invitation to \"%(name)s\" (%(email)s)? The link sent before stops working.":["¿Enviar una invitación nueva a «%(name)s» (%(email)s)? El enlace anterior dejará de funcionar."],"Send invitation":["Enviar invitación"],"Sign in to go on.":["Inicia sesión para continuar."],"Somebody must be able to manage users.":["Alguien tiene que poder gestionar los usuarios."],"Something went wrong.":["Algo ha ido mal."],"Something went wrong. Try again.":["Algo ha ido mal. Inténtalo de nuevo."],"Status":["Estado"],"Task":["Tarea"],"Task added for %(date)s.":["Tarea añadida para el %(date)s."],"Task added.":["Tarea añadida."],"Task completed.":["Tarea completada."],"Task moved to %(date)s.":["Tarea movida al %(date)s."],"Task reopened.":["Tarea reabierta."],"Task saved.":["Tarea guardada."],"Task title":["Título de la tarea"],"Tasks":["Tareas"],"Tasks could not be loaded.":["No se han podido cargar las tareas."],"That account no longer exists.":["Esa cuenta ya no existe."],"That address is no longer on this contact.":["Esa dirección ya no está en este contacto."],"That area is not one AlphOne offers.":["Esa área no es una de las que AlphOne ofrece."],"That contact no longer exists.":["Ese contacto ya no existe."],"That email and password do not match an account.":["Ese correo y esa contraseña no coinciden con ninguna cuenta."],"That event name is not one AlphOne knows.":["Ese nombre de evento no es uno de los que AlphOne conoce."],"That page marker is not one AlphOne issued.":["Ese marcador de página no es uno de los que AlphOne ha emitido."],"That priority is not one AlphOne offers.":["Esa prioridad no es una de las que AlphOne ofrece."],"That role holds more than your own, or it is not one this deployment names.":["Ese rol abarca más que el tuyo, o no es uno de los que este despliegue nombra."],"That role is not one this deployment names.":["Ese rol no es uno de los que este despliegue nombra."],"That status is not one AlphOne offers.":["Ese estado no es uno de los que AlphOne ofrece."],"That task no longer exists.":["Esa tarea ya no existe."],"That token no longer exists.":["Ese token ya no existe."],"That value is not in the form AlphOne expects.":["Ese valor no tiene la forma que AlphOne espera."],"That webhook no longer exists.":["Ese webhook ya no existe."],"The choice could not be saved.":["No se ha podido guardar la elección."],"The contact could not be created.":["No se ha podido crear el contacto."],"The contact could not be loaded.":["No se ha podido cargar el contacto."],"The identity could not be added.":["No se ha podido añadir la identidad."],"The identity could not be removed.":["No se ha podido quitar la identidad."],"The invitation could not be sent.":["No se ha podido enviar la invitación."],"The language changes when the page next loads.":["El idioma cambia la próxima vez que se cargue la página."],"The languages could not be read.":["No se han podido leer los idiomas."],"The name could not be updated.":["No se ha podido actualizar el nombre."],"The role could not be changed.":["No se ha podido cambiar el rol."],"The role of that account just changed. Reload the page and try again.":["El rol de esa cuenta acaba de cambiar. Recarga la página e inténtalo de nuevo."],"The task could not be added.":["No se ha podido añadir la tarea."],"The task could not be created.":["No se ha podido crear la tarea."],"The task could not be loaded.":["No se ha podido cargar la tarea."],"The task could not be saved.":["No se ha podido guardar la tarea."],"The task could not be updated.":["No se ha podido actualizar la tarea."],"The token could not be created.":["No se ha podido crear el token."],"The token could not be revoked.":["No se ha podido revocar el token."],"The user could not be disabled.":["No se ha podido desactivar el usuario."],"The user could not be enabled.":["No se ha podido reactivar el usuario."],"This link is no longer valid. Ask for a new one.":["Este enlace ya no es válido. Pide uno nuevo."],"This request came from a page on another site, so it was refused. Open the admin on this site and try again.":["Esta petición vino de una página de otro sitio, así que se rechazó. Abre la administración en este sitio e inténtalo de nuevo."],"This token does not reach %(scope)s.":["Este token no alcanza %(scope)s."],"This workspace has been deactivated. Contact your operator.":["Este espacio de trabajo ha sido desactivado. Contacta con tu operador."],"Time":["Hora"],"Title":["Título"],"Today":["Hoy"],"Tokens could not be loaded.":["No se han podido cargar los tokens."],"Too many attempts. Wait %(retryAfter)s seconds and try again.":["Demasiados intentos. Espera %(retryAfter)s segundos e inténtalo de nuevo."],"Undo":["Deshacer"],"User sections":["Secciones de usuarios"],"Users":["Usuarios"],"Users could not be loaded.":["No se han podido cargar los usuarios."],"Value":["Valor"],"Write %(area)s":["Escribir %(area)s"],"Write a scope as an area, a colon, then read or write.":["Escribe un ámbito como un área, dos puntos y luego read o write."],"Write the address as a full URL.":["Escribe la dirección como una URL completa."],"Write the address as a full email address.":["Escribe la dirección como una dirección de correo electrónico completa."],"Write the address itself.":["Escribe la dirección."],"You cannot change your own role.":["No puedes cambiar tu propio rol."],"You cannot disable your own account.":["No puedes desactivar tu propia cuenta."],"Your role does not allow %(capability)s.":["Tu rol no permite %(capability)s."],"admin section\u0004API tokens":["Tokens de API"],"admin section\u0004Contacts":["Contactos"],"admin section\u0004Language":["Idioma"],"admin section\u0004Tasks":["Tareas"],"contact channel\u0004Email":["Correo electrónico"],"contact channel\u0004Phone":["Teléfono"],"day heading\u0004%(weekday)s, %(date)s":["%(weekday)s, %(date)s"],"role\u0004Administrator":["Administrador"],"role\u0004Member":["Miembro"],"role\u0004No role":["Sin rol"],"task priority\u0004High":["Alta"],"task priority\u0004Normal":["Normal"],"toast action\u0004View":["Ver"],"token area\u0004contacts":["contactos"],"token area\u0004events":["eventos"],"token area\u0004meta":["metadatos"],"token area\u0004tasks":["tareas"],"token area\u0004users":["usuarios"],"token area\u0004webhooks":["webhooks"],"token expiry\u0004Expired":["Caducado"],"token lifetime\u0004Never":["Nunca"],"user status\u0004Active":["Activo"],"user status\u0004Disabled":["Desactivado"],"user status\u0004Invited":["Invitado"],"v%(version)s":["v%(version)s"]} +{"":{"lang":"es","plural-forms":"nplurals=3; plural=((n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2));"},"\"%(name)s\" disabled.":["«%(name)s» se ha desactivado."],"\"%(name)s\" enabled.":["«%(name)s» se ha reactivado."],"\"%(name)s\" revoked.":["«%(name)s» revocado."],"%s token could not be revoked.":["No se ha podido revocar %s token.","No se han podido revocar %s de tokens.","No se han podido revocar %s tokens."],"%s token revoked.":["%s token revocado.","%s de tokens revocados.","%s tokens revocados."],"%s user could not be disabled.":["No se ha podido desactivar %s usuario.","No se han podido desactivar %s de usuarios.","No se han podido desactivar %s usuarios."],"%s user could not be enabled.":["No se ha podido reactivar %s usuario.","No se han podido reactivar %s de usuarios.","No se han podido reactivar %s usuarios."],"%s user disabled.":["%s usuario desactivado.","%s de usuarios desactivados.","%s usuarios desactivados."],"%s user enabled.":["%s usuario reactivado.","%s de usuarios reactivados.","%s usuarios reactivados."],"30 days":["30 días"],"90 days":["90 días"],"A contact needs a name.":["Un contacto necesita un nombre."],"A lifetime is zero days or more.":["Una duración es de cero días o más."],"A lifetime runs to %(maxDays)s days at most.":["Una duración llega como mucho a %(maxDays)s días."],"A name runs to %(max)s characters at most.":["Un nombre tiene como mucho %(max)s caracteres."],"A password runs to %(max)s characters at most.":["Una contraseña tiene como mucho %(max)s caracteres."],"A password runs to %(min)s characters at least.":["Una contraseña tiene al menos %(min)s caracteres."],"A task needs a title.":["Una tarea necesita un título."],"A token needs a name.":["Un token necesita un nombre."],"A year":["Un año"],"API tokens":["Tokens de API"],"Activation link":["Enlace de activación"],"Add a task above, or open a contact and create one from their page.":["Añade una tarea arriba, o abre un contacto y crea una desde su página."],"Add identity":["Añadir identidad"],"Add one with New contact.":["Añade uno con Contacto nuevo."],"Add one with New token.":["Añade uno con Token nuevo."],"Add one with New user.":["Añade uno con Usuario nuevo."],"Add task":["Añadir tarea"],"AlphOne cannot write to that channel.":["AlphOne no puede escribir en ese canal."],"AlphOne does not speak that language yet.":["AlphOne todavía no habla ese idioma."],"AlphOne sends no webhook to an internal address the operator has not allowed.":["AlphOne no envía webhooks a una dirección interna que el operador no haya permitido."],"Already on contact %(name)s.":["Ya está en el contacto %(name)s."],"An account needs a name.":["Una cuenta necesita un nombre."],"An event needs the source it came from.":["Un evento necesita la fuente de la que viene."],"Another account already holds that address.":["Ya hay otra cuenta que tiene esa dirección."],"Another contact already holds that address.":["Ya hay otro contacto que tiene esa dirección."],"Areas":["Áreas"],"Ask for between %(min)s and %(max)s at a time.":["Pide entre %(min)s y %(max)s a la vez."],"Ask for tasks by one filter at a time.":["Pide las tareas con un solo filtro a la vez."],"Avatar":["Avatar"],"Cancel":["Cancelar"],"Change role":["Cambiar el rol"],"Change role…":["Cambiar el rol…"],"Channel":["Canal"],"Channels":["Canales"],"Choose at least one area the token reaches.":["Elige al menos un área a la que llegue el token."],"Choose at least one event to send.":["Elige al menos un evento que enviar."],"Choose the channel this address belongs to.":["Elige el canal al que pertenece esta dirección."],"Choose the role of %(name)s.":["Elige el rol de %(name)s."],"Complete":["Completar"],"Contact":["Contacto"],"Contact added.":["Contacto añadido."],"Contact tasks":["Tareas del contacto"],"Contacts":["Contactos"],"Contacts could not be loaded.":["No se han podido cargar los contactos."],"Copied.":["Copiado."],"Copy it by hand.":["Cópialo a mano."],"Copy secret":["Copiar el secreto"],"Copy the secret now, it is never shown again.":["Copia el secreto ahora, no se vuelve a mostrar nunca."],"Create contact":["Crear contacto"],"Create task":["Crear tarea"],"Create token":["Crear token"],"Created":["Creado"],"Created %(date)s":["Creado el %(date)s"],"Disable":["Desactivar"],"Dismiss":["Descartar"],"Done":["Hecho"],"Done (%(count)s)":["Hechas (%(count)s)"],"Done tasks":["Tareas hechas"],"Due %(date)s":["Vence el %(date)s"],"Due date":["Fecha de vencimiento"],"Email":["Correo electrónico"],"Enable":["Activar"],"Expires":["Caduca"],"Identities":["Identidades"],"Identity":["Identidad"],"Identity added.":["Identidad añadida."],"Identity removed.":["Identidad quitada."],"Invitation sent to \"%(name)s\".":["Invitación enviada a «%(name)s»."],"Invitation sent.":["Invitación enviada."],"Label":["Etiqueta"],"Language":["Idioma"],"Last used":["Último uso"],"Link a contact":["Vincular un contacto"],"Load more":["Cargar más"],"Load more done":["Cargar más hechas"],"Load more overdue":["Cargar más vencidas"],"Load more tasks":["Cargar más tareas"],"Loading contact…":["Cargando el contacto…"],"Loading tasks…":["Cargando las tareas…"],"Loading task…":["Cargando la tarea…"],"Loading…":["Cargando…"],"Manage the people you work with and how to reach them.":["Gestiona las personas con las que trabajas y cómo contactar con ellas."],"Manage the tokens your programs sign in with.":["Gestiona los tokens con los que entran tus programas."],"Manage who can sign in and what they can do.":["Gestiona quién puede entrar y qué puede hacer."],"Name":["Nombre"],"Name updated.":["Nombre actualizado."],"Navigation":["Navegación"],"Never expires":["No caduca nunca"],"Never used":["Sin usar"],"New contact":["Contacto nuevo"],"New task":["Tarea nueva"],"New token":["Token nuevo"],"New user":["Usuario nuevo"],"Next day":["Día siguiente"],"No API tokens yet.":["Todavía no hay tokens de API."],"No contacts found.":["No se ha encontrado ningún contacto."],"No contacts yet.":["Todavía no hay contactos."],"No identities yet.":["Todavía no hay identidades."],"No mail server is configured. Deliver the activation link by hand.":["No hay servidor de correo configurado. Entrega el enlace de activación a mano."],"No open tasks.":["No hay tareas abiertas."],"No tokens found.":["No se ha encontrado ningún token."],"No users found.":["No se ha encontrado ningún usuario."],"No users yet.":["Todavía no hay usuarios."],"Nothing declares that role.":["Nada declara ese rol."],"Nothing due today.":["No hay nada que venza hoy."],"Open tasks":["Tareas abiertas"],"Overdue":["Vencidas"],"Overdue tasks":["Tareas vencidas"],"Overdue tasks could not be loaded.":["No se han podido cargar las tareas vencidas."],"Postpone":["Posponer"],"Previous day":["Día anterior"],"Priority":["Prioridad"],"Read %(area)s":["Leer %(area)s"],"Reading the languages":["Leyendo los idiomas"],"Remove %(identifier)s":["Quitar %(identifier)s"],"Remove %(name)s":["Quitar a %(name)s"],"Rename":["Renombrar"],"Rename…":["Renombrar…"],"Reopen":["Reabrir"],"Resend invitation":["Reenviar invitación"],"Resend invitation…":["Reenviar invitación…"],"Revoke":["Revocar"],"Revoke \"%(name)s\"? This cannot be undone.":["¿Revocar «%(name)s»? Esto no se puede deshacer."],"Revoke %s token? This cannot be undone.":["¿Revocar %s token? Esto no se puede deshacer.","¿Revocar %s de tokens? Esto no se puede deshacer.","¿Revocar %s tokens? Esto no se puede deshacer."],"Revoke…":["Revocar…"],"Role":["Rol"],"Role changed for \"%(name)s\".":["Rol de «%(name)s» cambiado."],"Save":["Guardar"],"Scopes":["Ámbitos"],"Search contacts…":["Buscar contactos…"],"Search tokens…":["Buscar tokens…"],"Search users…":["Buscar usuarios…"],"Send a new invitation to \"%(name)s\" (%(email)s)? The link sent before stops working.":["¿Enviar una invitación nueva a «%(name)s» (%(email)s)? El enlace anterior dejará de funcionar."],"Send invitation":["Enviar invitación"],"Sign in to go on.":["Inicia sesión para continuar."],"Somebody must be able to manage users.":["Alguien tiene que poder gestionar los usuarios."],"Something went wrong.":["Algo ha ido mal."],"Something went wrong. Try again.":["Algo ha ido mal. Inténtalo de nuevo."],"Status":["Estado"],"Task":["Tarea"],"Task added for %(date)s.":["Tarea añadida para el %(date)s."],"Task added.":["Tarea añadida."],"Task completed.":["Tarea completada."],"Task moved to %(date)s.":["Tarea movida al %(date)s."],"Task reopened.":["Tarea reabierta."],"Task saved.":["Tarea guardada."],"Task title":["Título de la tarea"],"Tasks":["Tareas"],"Tasks could not be loaded.":["No se han podido cargar las tareas."],"That account no longer exists.":["Esa cuenta ya no existe."],"That address is no longer on this contact.":["Esa dirección ya no está en este contacto."],"That area is not one AlphOne offers.":["Esa área no es una de las que AlphOne ofrece."],"That contact no longer exists.":["Ese contacto ya no existe."],"That email and password do not match an account.":["Ese correo y esa contraseña no coinciden con ninguna cuenta."],"That event name is not one AlphOne knows.":["Ese nombre de evento no es uno de los que AlphOne conoce."],"That page marker is not one AlphOne issued.":["Ese marcador de página no es uno de los que AlphOne ha emitido."],"That priority is not one AlphOne offers.":["Esa prioridad no es una de las que AlphOne ofrece."],"That role holds more than your own, or nothing declares it.":["Ese rol abarca más que el tuyo, o nada lo declara."],"That status is not one AlphOne offers.":["Ese estado no es uno de los que AlphOne ofrece."],"That task no longer exists.":["Esa tarea ya no existe."],"That token no longer exists.":["Ese token ya no existe."],"That value is not in the form AlphOne expects.":["Ese valor no tiene la forma que AlphOne espera."],"That webhook no longer exists.":["Ese webhook ya no existe."],"The choice could not be saved.":["No se ha podido guardar la elección."],"The contact could not be created.":["No se ha podido crear el contacto."],"The contact could not be loaded.":["No se ha podido cargar el contacto."],"The identity could not be added.":["No se ha podido añadir la identidad."],"The identity could not be removed.":["No se ha podido quitar la identidad."],"The invitation could not be sent.":["No se ha podido enviar la invitación."],"The language changes when the page next loads.":["El idioma cambia la próxima vez que se cargue la página."],"The languages could not be read.":["No se han podido leer los idiomas."],"The name could not be updated.":["No se ha podido actualizar el nombre."],"The role could not be changed.":["No se ha podido cambiar el rol."],"The role of that account just changed. Reload the page and try again.":["El rol de esa cuenta acaba de cambiar. Recarga la página e inténtalo de nuevo."],"The task could not be added.":["No se ha podido añadir la tarea."],"The task could not be created.":["No se ha podido crear la tarea."],"The task could not be loaded.":["No se ha podido cargar la tarea."],"The task could not be saved.":["No se ha podido guardar la tarea."],"The task could not be updated.":["No se ha podido actualizar la tarea."],"The token could not be created.":["No se ha podido crear el token."],"The token could not be revoked.":["No se ha podido revocar el token."],"The user could not be disabled.":["No se ha podido desactivar el usuario."],"The user could not be enabled.":["No se ha podido reactivar el usuario."],"This link is no longer valid. Ask for a new one.":["Este enlace ya no es válido. Pide uno nuevo."],"This request came from a page on another site, so it was refused. Open the admin on this site and try again.":["Esta petición vino de una página de otro sitio, así que se rechazó. Abre la administración en este sitio e inténtalo de nuevo."],"This token does not reach %(scope)s.":["Este token no alcanza %(scope)s."],"This workspace has been deactivated. Contact your operator.":["Este espacio de trabajo ha sido desactivado. Contacta con tu operador."],"Time":["Hora"],"Title":["Título"],"Today":["Hoy"],"Tokens could not be loaded.":["No se han podido cargar los tokens."],"Too many attempts. Wait %(retryAfter)s seconds and try again.":["Demasiados intentos. Espera %(retryAfter)s segundos e inténtalo de nuevo."],"Undo":["Deshacer"],"User sections":["Secciones de usuarios"],"Users":["Usuarios"],"Users could not be loaded.":["No se han podido cargar los usuarios."],"Value":["Valor"],"Write %(area)s":["Escribir %(area)s"],"Write a scope as an area, a colon, then read or write.":["Escribe un ámbito como un área, dos puntos y luego read o write."],"Write the address as a full URL.":["Escribe la dirección como una URL completa."],"Write the address as a full email address.":["Escribe la dirección como una dirección de correo electrónico completa."],"Write the address itself.":["Escribe la dirección."],"You cannot change your own role.":["No puedes cambiar tu propio rol."],"You cannot disable your own account.":["No puedes desactivar tu propia cuenta."],"Your role does not allow %(capability)s.":["Tu rol no permite %(capability)s."],"admin section\u0004API tokens":["Tokens de API"],"admin section\u0004Contacts":["Contactos"],"admin section\u0004Language":["Idioma"],"admin section\u0004Tasks":["Tareas"],"contact channel\u0004Email":["Correo electrónico"],"contact channel\u0004Phone":["Teléfono"],"day heading\u0004%(weekday)s, %(date)s":["%(weekday)s, %(date)s"],"role\u0004Administrator":["Administrador"],"role\u0004Member":["Miembro"],"role\u0004No role":["Sin rol"],"task priority\u0004High":["Alta"],"task priority\u0004Normal":["Normal"],"toast action\u0004View":["Ver"],"token area\u0004contacts":["contactos"],"token area\u0004events":["eventos"],"token area\u0004meta":["metadatos"],"token area\u0004tasks":["tareas"],"token area\u0004users":["usuarios"],"token area\u0004webhooks":["webhooks"],"token expiry\u0004Expired":["Caducado"],"token lifetime\u0004Never":["Nunca"],"user status\u0004Active":["Activo"],"user status\u0004Disabled":["Desactivado"],"user status\u0004Invited":["Invitado"],"v%(version)s":["v%(version)s"]} diff --git a/frontend/src/test/users-route.test.tsx b/frontend/src/test/users-route.test.tsx index 22978a51..43bf57f5 100644 --- a/frontend/src/test/users-route.test.tsx +++ b/frontend/src/test/users-route.test.tsx @@ -692,7 +692,7 @@ test('says why an enable was refused when the roles the reader may give went sta await act('Maria Perez', 'Enable') expect(await screen.findByRole('alert')).toHaveTextContent( - 'That role holds more than your own, or it is not one this deployment names.', + 'That role holds more than your own, or nothing declares it.', ) expect(screen.queryByText('"Maria Perez" enabled.')).not.toBeInTheDocument() }) @@ -914,11 +914,40 @@ test('shows why a role could not be changed inside the modal', async () => { await userEvent.click(dialog.getByRole('button', { name: 'Change role' })) expect(await dialog.findByRole('alert')).toHaveTextContent( - 'That role holds more than your own, or it is not one this deployment names.', + 'That role holds more than your own, or nothing declares it.', ) expect(screen.queryByText(/Role changed/)).not.toBeInTheDocument() }) +test('says inside the modal that nothing declares the role the reader picked', async () => { + configureAppErrorText() + holding([signedIn, accountOf('fd', 'Maria Perez')]) + server.use( + graphql.mutation('SetUserRole', () => + HttpResponse.json({ + data: null, + errors: [ + { + message: 'unknown role', + extensions: { code: 'VALIDATION', reason: 'role_unknown', meta: { field: 'role' } }, + }, + ], + }), + ), + ) + renderAt('/users') + + await act('Maria Perez', 'Change role…') + const dialog = within(await screen.findByRole('dialog')) + await userEvent.click(dialog.getByRole('combobox', { name: 'Role' })) + await userEvent.click(await screen.findByRole('option', { name: 'Administrator' })) + await userEvent.click(dialog.getByRole('button', { name: 'Change role' })) + + expect(await dialog.findByRole('alert')).toHaveTextContent('Nothing declares that role.') + expect(screen.queryByText('unknown role')).not.toBeInTheDocument() + expect(screen.queryByText(/Role changed/)).not.toBeInTheDocument() +}) + test('says inside the modal that the role of the account just changed', async () => { configureAppErrorText() holding([signedIn, accountOf('fd', 'Maria Perez')]) diff --git a/languages/alphone.pot b/languages/alphone.pot index f69e2a86..d3ca1e99 100644 --- a/languages/alphone.pot +++ b/languages/alphone.pot @@ -361,6 +361,9 @@ msgstr "" msgid "No users yet." msgstr "" +msgid "Nothing declares that role." +msgstr "" + msgid "Nothing due today." msgstr "" @@ -522,10 +525,7 @@ msgstr "" msgid "That priority is not one AlphOne offers." msgstr "" -msgid "That role holds more than your own, or it is not one this deployment names." -msgstr "" - -msgid "That role is not one this deployment names." +msgid "That role holds more than your own, or nothing declares it." msgstr "" msgid "That status is not one AlphOne offers." diff --git a/languages/es-ES.po b/languages/es-ES.po index f3272bba..304cbb38 100644 --- a/languages/es-ES.po +++ b/languages/es-ES.po @@ -412,12 +412,12 @@ msgid "That priority is not one AlphOne offers." msgstr "Esa prioridad no es una de las que AlphOne ofrece." #, fuzzy -msgid "That role holds more than your own, or it is not one this deployment names." -msgstr "Ese rol abarca más que el tuyo, o no es uno de los que este despliegue nombra." +msgid "That role holds more than your own, or nothing declares it." +msgstr "Ese rol abarca más que el tuyo, o nada lo declara." #, fuzzy -msgid "That role is not one this deployment names." -msgstr "Ese rol no es uno de los que este despliegue nombra." +msgid "Nothing declares that role." +msgstr "Nada declara ese rol." #, fuzzy msgid "That status is not one AlphOne offers." From 97ce2b8648ae982653f36726be8cb817cb21f542 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Thu, 22 Oct 2026 22:10:52 +0200 Subject: [PATCH 2/3] fix(frontend): show why the server refused an invitation under the form --- frontend/src/auth/graphTransport.ts | 29 ++++++++++++-- frontend/src/test/invite-transport.test.ts | 26 +++++++++++-- frontend/src/test/users-new-role.test.tsx | 45 ++++++++++++++++++++++ frontend/src/users/NewUserScreen.tsx | 7 +++- frontend/src/users/roles.ts | 16 ++++++++ 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/frontend/src/auth/graphTransport.ts b/frontend/src/auth/graphTransport.ts index 15da7b94..8df2dc6e 100644 --- a/frontend/src/auth/graphTransport.ts +++ b/frontend/src/auth/graphTransport.ts @@ -9,7 +9,7 @@ import { RateLimitedError, UnauthorizedError, } from '@gopherium/react-auth' -import { ValidationError } from '@gopherium/react-auth/admin' +import { RoleBeyondReachError, RoleUnknownError, ValidationError } from '@gopherium/react-auth/admin' import type { Invitation, NewInvite, @@ -213,18 +213,39 @@ function toInvitation(report: { delivered: boolean; activationLink?: string | nu return { delivered: false, activation_link: report.activationLink } } +/** inviteRefusals maps each reason an invitation is refused for onto the typed error it throws. */ +const inviteRefusals = new Map Error>([ + ['role_unknown', RoleUnknownError], + ['role_beyond_reach', RoleBeyondReachError], +]) + /** - * Refuses an invitation answer by the code the graph carried. + * Refuses an invitation answer by the code and the reason the graph carried. * @param code - The extensions code of the first error. + * @param reason - The stable reason of the first error. * @param message - The message the error carried. */ -function refuseInvitation(code: string | undefined, message: string): void { +function refuseInvitation(code: string | undefined, reason: string | undefined, message: string): void { if (code === 'UNAUTHENTICATED') { throw new UnauthorizedError('session expired') } if (code === 'RATE_LIMITED') { throw new RateLimitedError('too many requests') } + refuseInvitationDetails(code, reason, message) +} + +/** + * Refuses the details of an invitation by the reason and the code the graph carried. + * @param code - The extensions code of the first error. + * @param reason - The stable reason of the first error. + * @param message - The message the error carried. + */ +function refuseInvitationDetails(code: string | undefined, reason: string | undefined, message: string): void { + const refused = inviteRefusals.get(reason ?? '') + if (refused) { + throw new refused(message) + } if (code === 'VALIDATION' || code === 'CONFLICT') { throw new ValidationError(message) } @@ -241,7 +262,7 @@ async function invite(input: NewInvite): Promise { name: input.name, role: input.role ?? null, }) - refuseInvitation(firstCode(result), firstMessage(result, 'invalid invitation details')) + refuseInvitation(firstCode(result), firstReason(result), firstMessage(result, 'invalid invitation details')) if (!result.data) { throw new Error(firstMessage(result, 'inviting failed')) } diff --git a/frontend/src/test/invite-transport.test.ts b/frontend/src/test/invite-transport.test.ts index a6510e9f..271d6773 100644 --- a/frontend/src/test/invite-transport.test.ts +++ b/frontend/src/test/invite-transport.test.ts @@ -2,7 +2,7 @@ import { http, HttpResponse, server } from '@alphone/frontend-sdk/testing' import { InvalidTokenError, RateLimitedError, UnauthorizedError } from '@gopherium/react-auth' -import { ValidationError } from '@gopherium/react-auth/admin' +import { RoleBeyondReachError, RoleUnknownError, ValidationError } from '@gopherium/react-auth/admin' import { expect, test } from 'vitest' import { graphAuthTransport } from '../auth/graphTransport' @@ -65,9 +65,10 @@ test('invite maps the refusals it can meet', async () => { ).rejects.toBeInstanceOf(UnauthorizedError) graphResponds('mutation Invite', graphError('VALIDATION', 'name_required', 'a name is required')) - await expect( - graphAuthTransport.invite({ email: 'maria@example.com', name: '' }), - ).rejects.toBeInstanceOf(ValidationError) + const refused = await graphAuthTransport + .invite({ email: 'maria@example.com', name: '' }) + .then(() => undefined, (error: unknown) => error) + expect(Object.getPrototypeOf(refused)).toBe(ValidationError.prototype) graphResponds('mutation Invite', graphError('RATE_LIMITED')) await expect( @@ -75,6 +76,23 @@ test('invite maps the refusals it can meet', async () => { ).rejects.toBeInstanceOf(RateLimitedError) }) +/** + * Returns what an invitation throws once the graph refuses it with one error. + */ +async function refusedInvitation(code: string, reason: string, message: string): Promise { + graphResponds('mutation Invite', graphError(code, reason, message)) + return graphAuthTransport + .invite({ email: 'maria@example.com', name: 'Maria Perez', role: 'admin' }) + .then(() => undefined, (error: unknown) => error) +} + +test('invite throws the typed error each role reason names', async () => { + expect(await refusedInvitation('VALIDATION', 'role_unknown', 'unknown role')).toBeInstanceOf(RoleUnknownError) + expect( + await refusedInvitation('VALIDATION', 'role_beyond_reach', 'that role is beyond your own'), + ).toBeInstanceOf(RoleBeyondReachError) +}) + test('acceptInvite answers the activated identity', async () => { graphResponds('mutation AcceptInvite', { data: { acceptInvite: { me: maria } } }) diff --git a/frontend/src/test/users-new-role.test.tsx b/frontend/src/test/users-new-role.test.tsx index 2e385cc1..56ad1d99 100644 --- a/frontend/src/test/users-new-role.test.tsx +++ b/frontend/src/test/users-new-role.test.tsx @@ -28,6 +28,13 @@ function invitedRoles() { return asked } +/** + * Refuses every invitation with one graph error carrying the server's own words beside its reason. + */ +function invitationRefused(message: string, extensions: Record) { + server.use(graphql.mutation('Invite', () => HttpResponse.json({ data: null, errors: [{ message, extensions }] }))) +} + /** * Fills the address and the name of the invited account. */ @@ -99,6 +106,44 @@ test('drops the session when the invitation is unauthorized', async () => { await waitFor(() => expect(client.getQueryData(sessionQueryKey)).toBeNull()) }) +test.each([ + { + refused: 'a role nobody declares', + message: 'unknown role', + extensions: { code: 'VALIDATION', reason: 'role_unknown', meta: { field: 'role' } }, + sentence: 'Nothing declares that role.', + }, + { + refused: 'a role holding more than the reader holds', + message: 'that role is beyond your own', + extensions: { code: 'VALIDATION', reason: 'role_beyond_reach' }, + sentence: 'That role holds more than your own, or nothing declares it.', + }, +])('says under the form why the server refused $refused, never in its raw words', async (refusal) => { + invitationRefused(refusal.message, refusal.extensions) + renderAt('/users/new') + await fillAccount() + + await userEvent.click(screen.getByRole('button', { name: 'Send invitation' })) + + expect(await screen.findByRole('alert')).toHaveTextContent(refusal.sentence) + expect(screen.queryByText(refusal.message)).not.toBeInTheDocument() +}) + +test('says in Spanish under the form that nothing declares the role the invitation named', async () => { + server.use(graphql.query('AppLocale', () => HttpResponse.json({ data: { locale: 'es-ES' } }))) + await startAppLocale() + onTestFinished(() => resetLocale()) + invitationRefused('unknown role', { code: 'VALIDATION', reason: 'role_unknown', meta: { field: 'role' } }) + renderAt('/users/new') + await userEvent.type(await screen.findByLabelText('Correo electrónico'), 'maria@example.com') + await userEvent.type(screen.getByLabelText('Nombre'), 'Maria Perez') + + await userEvent.click(screen.getByRole('button', { name: 'Enviar invitación' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('Nada declara ese rol.') +}) + test('reads the role select and the role it starts at in Spanish', async () => { server.use(graphql.query('AppLocale', () => HttpResponse.json({ data: { locale: 'es-ES' } }))) await startAppLocale() diff --git a/frontend/src/users/NewUserScreen.tsx b/frontend/src/users/NewUserScreen.tsx index 4d540fcf..60af8f45 100644 --- a/frontend/src/users/NewUserScreen.tsx +++ b/frontend/src/users/NewUserScreen.tsx @@ -5,8 +5,9 @@ import { ValidationError, invite, usersQueryKey } from '@gopherium/react-auth/ad import { useMutation, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' +import { appErrorTemplates } from '../i18n/errors' import { RoleSelect } from './RoleSelect' -import { startingRole } from './roles' +import { roleRefusal, startingRole } from './roles' /** * Returns the message shown under the form for a failed invitation. @@ -14,6 +15,10 @@ import { startingRole } from './roles' * @returns The message to show. */ function inviteErrorText(error: unknown): string { + const refused = roleRefusal(error) + if (refused !== undefined) { + return appErrorTemplates()[refused] + } if (error instanceof ValidationError) { return error.message } diff --git a/frontend/src/users/roles.ts b/frontend/src/users/roles.ts index b9400818..a7af19c6 100644 --- a/frontend/src/users/roles.ts +++ b/frontend/src/users/roles.ts @@ -1,6 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { _x } from '@alphone/frontend-sdk' +import { RoleBeyondReachError, RoleUnknownError } from '@gopherium/react-auth/admin' + +/** The error code each turned down role write answers with, by the error it raises. */ +const refusals: [new (...args: never[]) => Error, string][] = [ + [RoleBeyondReachError, 'role_beyond_reach'], + [RoleUnknownError, 'role_unknown'], +] /** * Returns the label each core role reads as, read fresh so the loaded catalogue answers. @@ -34,3 +41,12 @@ const memberRole = 'member' export function startingRole(grantable: string[]): string { return grantable.includes(memberRole) ? memberRole : (grantable.at(-1) ?? memberRole) } + +/** + * Returns the code a turned down role write carries, none for any other failure. + * @param error - The failure the write raised. + * @returns The error code, or nothing. + */ +export function roleRefusal(error: unknown): string | undefined { + return refusals.find(([refused]) => error instanceof refused)?.[1] +} From b9d5ace1ee91f6e6b441ed98af5ca699e1612656 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Thu, 22 Oct 2026 22:10:52 +0200 Subject: [PATCH 3/3] docs(reference): say nothing declares the role in the role_unknown and role_beyond_reach rows --- docs/src/content/docs/reference/graphql-api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index 4b4a46d6..d6bb0243 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -571,8 +571,8 @@ The reasons the core answers with: | `self_disable_refused` | | nobody disables its own account | | `self_role_refused` | | nobody changes its own role | | `last_privileged_refused` | | the last account able to manage users stays | -| `role_beyond_reach` | | the role holds more than the caller does, or the account being enabled holds no role or one the deployment does not name | -| `role_unknown` | `field` | the role is not one the deployment names | +| `role_beyond_reach` | | the role holds more than the caller does, or the account being enabled holds no role or one nothing declares | +| `role_unknown` | `field` | nothing declares the role | | `role_changed` | | someone changed the account's role between the check and the write, and nothing was written | The stock plugins add their own: