Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/graphql-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 25 additions & 4 deletions frontend/src/auth/graphTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, new (message: string) => 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)
}
Expand All @@ -241,7 +262,7 @@ async function invite(input: NewInvite): Promise<Invitation> {
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'))
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/i18n/errorTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ export function errorTemplates(): Record<string, string> {
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),
}
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/languages/es-ES.json

Large diffs are not rendered by default.

26 changes: 22 additions & 4 deletions frontend/src/test/invite-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -65,16 +65,34 @@ 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(
graphAuthTransport.invite({ email: 'maria@example.com', name: 'Maria Perez' }),
).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<unknown> {
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 } } })

Expand Down
45 changes: 45 additions & 0 deletions frontend/src/test/users-new-role.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
server.use(graphql.mutation('Invite', () => HttpResponse.json({ data: null, errors: [{ message, extensions }] })))
}

/**
* Fills the address and the name of the invited account.
*/
Expand Down Expand Up @@ -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()
Expand Down
33 changes: 31 additions & 2 deletions frontend/src/test/users-route.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand Down Expand Up @@ -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')])
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/users/NewUserScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@ 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.
* @param error - The mutation error.
* @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
}
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/users/roles.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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]
}
8 changes: 4 additions & 4 deletions languages/alphone.pot
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,9 @@ msgstr ""
msgid "No users yet."
msgstr ""

msgid "Nothing declares that role."
msgstr ""

msgid "Nothing due today."
msgstr ""

Expand Down Expand Up @@ -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."
Expand Down
8 changes: 4 additions & 4 deletions languages/es-ES.po
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading