فهرست منبع

Ask for a role before sending a team invitation (#564)

The Add person dialog started on No role, so an invitation sent without touching the field made an account that signs in to the No access screen. The field now starts empty and Invite is refused, in words, until a role is chosen. No role is still offered and says what the invited person will meet.

Also corrects the Polish label on the Invite button.
Bernt Christian Egeland 1 روز پیش
والد
کامیت
d2cc84df5c

+ 5 - 0
e2e/specs/auth/roles.spec.ts

@@ -49,6 +49,11 @@ test.describe('a member with no role', () => {
     }).toPass({ timeout: 30_000 })
     }).toPass({ timeout: 30_000 })
     await ownerPage.getByText('Someone in the office').click()
     await ownerPage.getByText('Someone in the office').click()
     await ownerPage.locator('#member-email').fill(COLLEAGUE)
     await ownerPage.locator('#member-email').fill(COLLEAGUE)
+    // No role is a choice the owner has to make: the form starts with none
+    // chosen and will not send the invitation until one is.
+    const dialog = ownerPage.getByRole('dialog').filter({ has: ownerPage.locator('#member-email') })
+    await dialog.getByRole('combobox').click()
+    await ownerPage.getByRole('option', { name: 'No role', exact: true }).click()
     await ownerPage.getByRole('button', { name: 'Invite', exact: true }).click()
     await ownerPage.getByRole('button', { name: 'Invite', exact: true }).click()
     await expect(ownerPage.getByText(COLLEAGUE).first()).toBeVisible({ timeout: 30_000 })
     await expect(ownerPage.getByText(COLLEAGUE).first()).toBeVisible({ timeout: 30_000 })
     await owner.close()
     await owner.close()

+ 6 - 0
e2e/specs/auth/sign-up.spec.ts

@@ -49,6 +49,12 @@ test('an invited colleague signs up straight into the workshop', async ({ page,
   await ownerPage.getByRole('button', { name: 'Add', exact: true }).click()
   await ownerPage.getByRole('button', { name: 'Add', exact: true }).click()
   await ownerPage.getByText('Someone in the office').click()
   await ownerPage.getByText('Someone in the office').click()
   await ownerPage.locator('#member-email').fill(invitee)
   await ownerPage.locator('#member-email').fill(invitee)
+  // The form will not send an invitation with no role chosen. This spec is
+  // about the sign-up, so the colleague gets the one choice every workshop
+  // has, whatever roles it has made.
+  const dialog = ownerPage.getByRole('dialog').filter({ has: ownerPage.locator('#member-email') })
+  await dialog.getByRole('combobox').click()
+  await ownerPage.getByRole('option', { name: 'No role', exact: true }).click()
   await ownerPage.getByRole('button', { name: 'Invite', exact: true }).click()
   await ownerPage.getByRole('button', { name: 'Invite', exact: true }).click()
   await expect(ownerPage.getByText(invitee).first()).toBeVisible()
   await expect(ownerPage.getByText(invitee).first()).toBeVisible()
   await owner.close()
   await owner.close()

+ 7 - 8
e2e/specs/security/admin-only.spec.ts

@@ -52,10 +52,11 @@ async function signIn(page: Page, email: string, password: string) {
 
 
 /**
 /**
  * Opens the team page's Add dialog and sends an invitation to `email` as
  * Opens the team page's Add dialog and sends an invitation to `email` as
- * "someone in the office", optionally as an Admin. The dialog is left open so
- * the caller can read what it said.
+ * "someone in the office", as an Admin or with no role. The form sends
+ * nothing until a role is chosen, so one always is. The dialog is left open
+ * so the caller can read what it said.
  */
  */
-async function invite(page: Page, email: string, role?: 'Admin') {
+async function invite(page: Page, email: string, role: 'Admin' | 'No role' = 'No role') {
   await page.goto('/settings/team')
   await page.goto('/settings/team')
   await settle(page)
   await settle(page)
   await expect(async () => {
   await expect(async () => {
@@ -64,11 +65,9 @@ async function invite(page: Page, email: string, role?: 'Admin') {
   }).toPass({ timeout: 30_000 })
   }).toPass({ timeout: 30_000 })
   await page.getByText('Someone in the office').click()
   await page.getByText('Someone in the office').click()
   await page.locator('#member-email').fill(email)
   await page.locator('#member-email').fill(email)
-  if (role) {
-    const dialog = page.getByRole('dialog').filter({ has: page.locator('#member-email') })
-    await dialog.getByRole('combobox').click()
-    await page.getByRole('option', { name: role, exact: true }).click()
-  }
+  const dialog = page.getByRole('dialog').filter({ has: page.locator('#member-email') })
+  await dialog.getByRole('combobox').click()
+  await page.getByRole('option', { name: role, exact: true }).click()
   await page.getByRole('button', { name: 'Invite', exact: true }).click()
   await page.getByRole('button', { name: 'Invite', exact: true }).click()
 }
 }
 
 

+ 5 - 0
e2e/specs/security/role-standing.spec.ts

@@ -63,6 +63,11 @@ async function joinTeam(browser: Browser, email: string, name: string) {
   }).toPass({ timeout: 30_000 })
   }).toPass({ timeout: 30_000 })
   await page.getByText('Someone in the office').click()
   await page.getByText('Someone in the office').click()
   await page.locator('#member-email').fill(email)
   await page.locator('#member-email').fill(email)
+  // The form sends nothing until a role is chosen; these two start with none
+  // and are given theirs through the database below.
+  const dialog = page.getByRole('dialog').filter({ has: page.locator('#member-email') })
+  await dialog.getByRole('combobox').click()
+  await page.getByRole('option', { name: 'No role', exact: true }).click()
   await page.getByRole('button', { name: 'Invite', exact: true }).click()
   await page.getByRole('button', { name: 'Invite', exact: true }).click()
   await expect(page.getByText(email).first()).toBeVisible({ timeout: 30_000 })
   await expect(page.getByText(email).first()).toBeVisible({ timeout: 30_000 })
   await owner.close()
   await owner.close()

+ 3 - 0
messages/de/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} erscheint nicht mehr auf der Werkstatttafel und kann keinen Aufträgen mehr zugewiesen werden. Bereits erfasste Arbeit und gestempelte Stunden bleiben genau so, wie sie sind.",
     "removeBoardOnlyBody": "{name} erscheint nicht mehr auf der Werkstatttafel und kann keinen Aufträgen mehr zugewiesen werden. Bereits erfasste Arbeit und gestempelte Stunden bleiben genau so, wie sie sind.",
     "removeBoardOnlyDone": "{name} ist von der Tafel",
     "removeBoardOnlyDone": "{name} ist von der Tafel",
     "noRole": "Keine Rolle",
     "noRole": "Keine Rolle",
+    "chooseRole": "Rolle auswählen",
+    "roleRequired": "Wählen Sie vor dem Einladen eine Rolle. Ohne Rolle kann sich die Person anmelden, aber nichts öffnen.",
+    "noRoleInviteWarning": "Die Person kann sich anmelden, aber nichts öffnen, bis ein Inhaber oder Administrator ihr eine Rolle gibt.",
     "worksOnJobs": "Arbeitet an Aufträgen",
     "worksOnJobs": "Arbeitet an Aufträgen",
     "createDefaultRoles": "Standardrollen anlegen",
     "createDefaultRoles": "Standardrollen anlegen",
     "defaultRolesCreated": "Standardrollen angelegt",
     "defaultRolesCreated": "Standardrollen angelegt",

+ 3 - 0
messages/en/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} stops appearing on the work board and can no longer be put on jobs. Work already recorded against them, and the hours they clocked, stay exactly as they are.",
     "removeBoardOnlyBody": "{name} stops appearing on the work board and can no longer be put on jobs. Work already recorded against them, and the hours they clocked, stay exactly as they are.",
     "removeBoardOnlyDone": "{name} is off the board",
     "removeBoardOnlyDone": "{name} is off the board",
     "noRole": "No role",
     "noRole": "No role",
+    "chooseRole": "Choose a role",
+    "roleRequired": "Choose a role before inviting. Someone with no role can sign in but cannot open anything.",
+    "noRoleInviteWarning": "They can sign in, but they cannot open anything until an owner or an admin gives them a role.",
     "worksOnJobs": "Works on jobs",
     "worksOnJobs": "Works on jobs",
     "createDefaultRoles": "Create the standard roles",
     "createDefaultRoles": "Create the standard roles",
     "defaultRolesCreated": "Standard roles created",
     "defaultRolesCreated": "Standard roles created",

+ 3 - 0
messages/es/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} deja de aparecer en el tablero y ya no se le puede asignar trabajos. El trabajo ya registrado y las horas fichadas quedan exactamente igual.",
     "removeBoardOnlyBody": "{name} deja de aparecer en el tablero y ya no se le puede asignar trabajos. El trabajo ya registrado y las horas fichadas quedan exactamente igual.",
     "removeBoardOnlyDone": "{name} está fuera del tablero",
     "removeBoardOnlyDone": "{name} está fuera del tablero",
     "noRole": "Sin rol",
     "noRole": "Sin rol",
+    "chooseRole": "Elija un rol",
+    "roleRequired": "Elija un rol antes de invitar. Sin rol, la persona puede iniciar sesión, pero no puede abrir nada.",
+    "noRoleInviteWarning": "Puede iniciar sesión, pero no podrá abrir nada hasta que un propietario o un administrador le dé un rol.",
     "worksOnJobs": "Trabaja en los coches",
     "worksOnJobs": "Trabaja en los coches",
     "createDefaultRoles": "Crear los roles estándar",
     "createDefaultRoles": "Crear los roles estándar",
     "defaultRolesCreated": "Roles estándar creados",
     "defaultRolesCreated": "Roles estándar creados",

+ 3 - 0
messages/fr/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} n’apparaît plus sur le planning et ne peut plus être affecté à des travaux. Le travail déjà enregistré et les heures pointées restent exactement tels quels.",
     "removeBoardOnlyBody": "{name} n’apparaît plus sur le planning et ne peut plus être affecté à des travaux. Le travail déjà enregistré et les heures pointées restent exactement tels quels.",
     "removeBoardOnlyDone": "{name} est retiré du planning",
     "removeBoardOnlyDone": "{name} est retiré du planning",
     "noRole": "Aucun rôle",
     "noRole": "Aucun rôle",
+    "chooseRole": "Choisissez un rôle",
+    "roleRequired": "Choisissez un rôle avant d’inviter. Sans rôle, la personne peut se connecter mais ne peut rien ouvrir.",
+    "noRoleInviteWarning": "La personne peut se connecter, mais ne pourra rien ouvrir tant qu’un propriétaire ou un administrateur ne lui aura pas donné de rôle.",
     "worksOnJobs": "Travaille sur les véhicules",
     "worksOnJobs": "Travaille sur les véhicules",
     "createDefaultRoles": "Créer les rôles standard",
     "createDefaultRoles": "Créer les rôles standard",
     "defaultRolesCreated": "Rôles standard créés",
     "defaultRolesCreated": "Rôles standard créés",

+ 3 - 0
messages/it/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} non compare più sulla lavagna e non può più essere assegnato ai lavori. Il lavoro già registrato e le ore timbrate restano esattamente come sono.",
     "removeBoardOnlyBody": "{name} non compare più sulla lavagna e non può più essere assegnato ai lavori. Il lavoro già registrato e le ore timbrate restano esattamente come sono.",
     "removeBoardOnlyDone": "{name} è fuori dalla lavagna",
     "removeBoardOnlyDone": "{name} è fuori dalla lavagna",
     "noRole": "Nessun ruolo",
     "noRole": "Nessun ruolo",
+    "chooseRole": "Scegli un ruolo",
+    "roleRequired": "Scegli un ruolo prima di invitare. Senza ruolo, la persona può accedere ma non può aprire nulla.",
+    "noRoleInviteWarning": "Può accedere, ma non potrà aprire nulla finché un titolare o un amministratore non le assegna un ruolo.",
     "worksOnJobs": "Lavora sui veicoli",
     "worksOnJobs": "Lavora sui veicoli",
     "createDefaultRoles": "Crea i ruoli standard",
     "createDefaultRoles": "Crea i ruoli standard",
     "defaultRolesCreated": "Ruoli standard creati",
     "defaultRolesCreated": "Ruoli standard creati",

+ 3 - 0
messages/lt/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} nebesirodo darbų lentoje ir jo nebegalima priskirti darbams. Jau užfiksuotas darbas ir pažymėtos valandos lieka lygiai tokie patys.",
     "removeBoardOnlyBody": "{name} nebesirodo darbų lentoje ir jo nebegalima priskirti darbams. Jau užfiksuotas darbas ir pažymėtos valandos lieka lygiai tokie patys.",
     "removeBoardOnlyDone": "{name} pašalintas iš lentos",
     "removeBoardOnlyDone": "{name} pašalintas iš lentos",
     "noRole": "Nėra rolės",
     "noRole": "Nėra rolės",
+    "chooseRole": "Pasirinkite rolę",
+    "roleRequired": "Prieš kviesdami pasirinkite rolę. Be rolės žmogus gali prisijungti, bet nieko negali atidaryti.",
+    "noRoleInviteWarning": "Gali prisijungti, bet nieko negalės atidaryti, kol savininkas arba administratorius nesuteiks rolės.",
     "worksOnJobs": "Dirba su automobiliais",
     "worksOnJobs": "Dirba su automobiliais",
     "createDefaultRoles": "Sukurti standartines roles",
     "createDefaultRoles": "Sukurti standartines roles",
     "defaultRolesCreated": "Standartinės rolės sukurtos",
     "defaultRolesCreated": "Standartinės rolės sukurtos",

+ 3 - 0
messages/nb/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} vises ikke lenger på arbeidstavlen og kan ikke settes på jobber. Arbeid som allerede er registrert på dem, og timene de har stemplet, blir stående nøyaktig som de er.",
     "removeBoardOnlyBody": "{name} vises ikke lenger på arbeidstavlen og kan ikke settes på jobber. Arbeid som allerede er registrert på dem, og timene de har stemplet, blir stående nøyaktig som de er.",
     "removeBoardOnlyDone": "{name} er tatt av tavlen",
     "removeBoardOnlyDone": "{name} er tatt av tavlen",
     "noRole": "Ingen rolle",
     "noRole": "Ingen rolle",
+    "chooseRole": "Velg en rolle",
+    "roleRequired": "Velg en rolle før du inviterer. Uten rolle kan personen logge inn, men ikke åpne noe.",
+    "noRoleInviteWarning": "De kan logge inn, men kan ikke åpne noe før en eier eller administrator gir dem en rolle.",
     "worksOnJobs": "Jobber på biler",
     "worksOnJobs": "Jobber på biler",
     "createDefaultRoles": "Opprett standardrollene",
     "createDefaultRoles": "Opprett standardrollene",
     "defaultRolesCreated": "Standardroller opprettet",
     "defaultRolesCreated": "Standardroller opprettet",

+ 3 - 0
messages/nl/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} verschijnt niet meer op het planbord en kan niet meer op klussen worden gezet. Al vastgelegd werk en geklokte uren blijven precies zoals ze zijn.",
     "removeBoardOnlyBody": "{name} verschijnt niet meer op het planbord en kan niet meer op klussen worden gezet. Al vastgelegd werk en geklokte uren blijven precies zoals ze zijn.",
     "removeBoardOnlyDone": "{name} staat niet meer op het bord",
     "removeBoardOnlyDone": "{name} staat niet meer op het bord",
     "noRole": "Geen rol",
     "noRole": "Geen rol",
+    "chooseRole": "Kies een rol",
+    "roleRequired": "Kies een rol voordat u uitnodigt. Zonder rol kan de persoon inloggen, maar niets openen.",
+    "noRoleInviteWarning": "Ze kunnen inloggen, maar niets openen totdat een eigenaar of beheerder een rol toekent.",
     "worksOnJobs": "Werkt aan klussen",
     "worksOnJobs": "Werkt aan klussen",
     "createDefaultRoles": "Standaardrollen aanmaken",
     "createDefaultRoles": "Standaardrollen aanmaken",
     "defaultRolesCreated": "Standaardrollen aangemaakt",
     "defaultRolesCreated": "Standaardrollen aangemaakt",

+ 4 - 1
messages/pl/settings.json

@@ -402,7 +402,7 @@
     "member": "Czlonek",
     "member": "Czlonek",
     "inviteByEmail": "Zapros przez e-mail",
     "inviteByEmail": "Zapros przez e-mail",
     "inviteEmailPlaceholder": "użytkownik@przyklad.pl",
     "inviteEmailPlaceholder": "użytkownik@przyklad.pl",
-    "invite": "Zapros",
+    "invite": "Zaproś",
     "invited": "Czlonek zaproszony pomyślnie.",
     "invited": "Czlonek zaproszony pomyślnie.",
     "userNotFoundTitle": "Użytkownik nie znaleziony",
     "userNotFoundTitle": "Użytkownik nie znaleziony",
     "userNotFoundDescription": "Nie znaleziono konta dla \"{email}\". Wyslac e-mail z zaproszeniem do rejestracji i dolaczenia do zespolu?",
     "userNotFoundDescription": "Nie znaleziono konta dla \"{email}\". Wyslac e-mail z zaproszeniem do rejestracji i dolaczenia do zespolu?",
@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} przestaje pojawiać się na tablicy i nie można go przypisywać do zleceń. Zapisana już praca i zarejestrowane godziny pozostają bez zmian.",
     "removeBoardOnlyBody": "{name} przestaje pojawiać się na tablicy i nie można go przypisywać do zleceń. Zapisana już praca i zarejestrowane godziny pozostają bez zmian.",
     "removeBoardOnlyDone": "{name} zdjęty z tablicy",
     "removeBoardOnlyDone": "{name} zdjęty z tablicy",
     "noRole": "Brak roli",
     "noRole": "Brak roli",
+    "chooseRole": "Wybierz rolę",
+    "roleRequired": "Wybierz rolę przed wysłaniem zaproszenia. Bez roli ta osoba może się zalogować, ale niczego nie otworzy.",
+    "noRoleInviteWarning": "Może się zalogować, ale niczego nie otworzy, dopóki właściciel lub administrator nie nada jej roli.",
     "worksOnJobs": "Pracuje przy autach",
     "worksOnJobs": "Pracuje przy autach",
     "createDefaultRoles": "Utwórz role standardowe",
     "createDefaultRoles": "Utwórz role standardowe",
     "defaultRolesCreated": "Utworzono role standardowe",
     "defaultRolesCreated": "Utworzono role standardowe",

+ 3 - 0
messages/pt-BR/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} deixa de aparecer no quadro e não pode mais ser colocado em serviços. O trabalho já registrado e as horas marcadas ficam exatamente como estão.",
     "removeBoardOnlyBody": "{name} deixa de aparecer no quadro e não pode mais ser colocado em serviços. O trabalho já registrado e as horas marcadas ficam exatamente como estão.",
     "removeBoardOnlyDone": "{name} saiu do quadro",
     "removeBoardOnlyDone": "{name} saiu do quadro",
     "noRole": "Sem função",
     "noRole": "Sem função",
+    "chooseRole": "Escolha uma função",
+    "roleRequired": "Escolha uma função antes de convidar. Sem função, a pessoa consegue entrar, mas não consegue abrir nada.",
+    "noRoleInviteWarning": "A pessoa consegue entrar, mas não abre nada até que o proprietário ou um administrador dê uma função a ela.",
     "worksOnJobs": "Trabalha nos serviços",
     "worksOnJobs": "Trabalha nos serviços",
     "createDefaultRoles": "Criar as funções padrão",
     "createDefaultRoles": "Criar as funções padrão",
     "defaultRolesCreated": "Funções padrão criadas",
     "defaultRolesCreated": "Funções padrão criadas",

+ 3 - 0
messages/pt-PT/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} deixa de aparecer no quadro de trabalho e já não pode ser atribuído a trabalhos. O trabalho já registado e as horas picadas ficam exatamente como estão.",
     "removeBoardOnlyBody": "{name} deixa de aparecer no quadro de trabalho e já não pode ser atribuído a trabalhos. O trabalho já registado e as horas picadas ficam exatamente como estão.",
     "removeBoardOnlyDone": "{name} saiu do quadro",
     "removeBoardOnlyDone": "{name} saiu do quadro",
     "noRole": "Sem função",
     "noRole": "Sem função",
+    "chooseRole": "Escolha uma função",
+    "roleRequired": "Escolha uma função antes de convidar. Sem função, a pessoa consegue iniciar sessão, mas não consegue abrir nada.",
+    "noRoleInviteWarning": "A pessoa consegue iniciar sessão, mas não abre nada até que o proprietário ou um administrador lhe atribua uma função.",
     "worksOnJobs": "Executa trabalhos",
     "worksOnJobs": "Executa trabalhos",
     "createDefaultRoles": "Criar as funções predefinidas",
     "createDefaultRoles": "Criar as funções predefinidas",
     "defaultRolesCreated": "Funções predefinidas criadas",
     "defaultRolesCreated": "Funções predefinidas criadas",

+ 3 - 0
messages/ru/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} больше не появляется на рабочей доске, и его нельзя назначать на заказ-наряды. Уже записанная работа и отмеченные часы остаются ровно такими же.",
     "removeBoardOnlyBody": "{name} больше не появляется на рабочей доске, и его нельзя назначать на заказ-наряды. Уже записанная работа и отмеченные часы остаются ровно такими же.",
     "removeBoardOnlyDone": "{name} убран с доски",
     "removeBoardOnlyDone": "{name} убран с доски",
     "noRole": "Без роли",
     "noRole": "Без роли",
+    "chooseRole": "Выберите роль",
+    "roleRequired": "Выберите роль перед приглашением. Без роли человек сможет войти, но ничего не сможет открыть.",
+    "noRoleInviteWarning": "Человек сможет войти, но ничего не откроет, пока владелец или администратор не назначит ему роль.",
     "worksOnJobs": "Работает с машинами",
     "worksOnJobs": "Работает с машинами",
     "createDefaultRoles": "Создать стандартные роли",
     "createDefaultRoles": "Создать стандартные роли",
     "defaultRolesCreated": "Стандартные роли созданы",
     "defaultRolesCreated": "Стандартные роли созданы",

+ 3 - 0
messages/tr/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} artık iş panosunda görünmez ve işlere atanamaz. Daha önce kaydedilen işler ve girilen saatler olduğu gibi kalır.",
     "removeBoardOnlyBody": "{name} artık iş panosunda görünmez ve işlere atanamaz. Daha önce kaydedilen işler ve girilen saatler olduğu gibi kalır.",
     "removeBoardOnlyDone": "{name} panodan kaldırıldı",
     "removeBoardOnlyDone": "{name} panodan kaldırıldı",
     "noRole": "Rol yok",
     "noRole": "Rol yok",
+    "chooseRole": "Bir rol seçin",
+    "roleRequired": "Davet etmeden önce bir rol seçin. Rolü olmayan kişi giriş yapabilir ama hiçbir şeyi açamaz.",
+    "noRoleInviteWarning": "Giriş yapabilir ama bir sahip ya da yönetici rol verene kadar hiçbir şeyi açamaz.",
     "worksOnJobs": "Araçlarda çalışır",
     "worksOnJobs": "Araçlarda çalışır",
     "createDefaultRoles": "Standart rolleri oluştur",
     "createDefaultRoles": "Standart rolleri oluştur",
     "defaultRolesCreated": "Standart roller oluşturuldu",
     "defaultRolesCreated": "Standart roller oluşturuldu",

+ 106 - 5
src/__tests__/features/team/add-person-dialog.test.tsx

@@ -55,6 +55,18 @@ function officeChoice() {
   return screen.getByRole('button', { name: new RegExp(OFFICE) })
   return screen.getByRole('button', { name: new RegExp(OFFICE) })
 }
 }
 
 
+/**
+ * Picks a role by the name a person reads in the list. The list itself opens
+ * in a popper jsdom cannot lay out, so the choice goes through the native
+ * select the form control keeps in step with it.
+ */
+function chooseRole(name: string) {
+  const native = document.querySelector('select')
+  const option = Array.from(native?.options ?? []).find((o) => o.textContent === name)
+  if (!native || !option) throw new Error(`The role list has no "${name}"`)
+  fireEvent.change(native, { target: { value: option.value } })
+}
+
 describe('AddPersonDialog, office seats', () => {
 describe('AddPersonDialog, office seats', () => {
   beforeEach(() => {
   beforeEach(() => {
     getMemberSeats.mockReset()
     getMemberSeats.mockReset()
@@ -158,14 +170,10 @@ describe('AddPersonDialog, invite address', () => {
     expect(screen.queryByText(/Check the email address/)).toBeNull()
     expect(screen.queryByText(/Check the email address/)).toBeNull()
   })
   })
 
 
-  it('shows the role a new person gets, No role until one is picked', async () => {
-    await openForm()
-    expect(screen.getByRole('combobox')).toHaveTextContent('No role')
-  })
-
   it('sends a good address on to the invite action', async () => {
   it('sends a good address on to the invite action', async () => {
     const field = await openForm()
     const field = await openForm()
     fireEvent.change(field, { target: { value: ' kari@gmail.com ' } })
     fireEvent.change(field, { target: { value: ' kari@gmail.com ' } })
+    chooseRole('Desk')
     fireEvent.click(screen.getByRole('button', { name: 'Invite' }))
     fireEvent.click(screen.getByRole('button', { name: 'Invite' }))
 
 
     await waitFor(() =>
     await waitFor(() =>
@@ -175,3 +183,96 @@ describe('AddPersonDialog, invite address', () => {
     )
     )
   })
   })
 })
 })
+
+describe('AddPersonDialog, the role of someone invited', () => {
+  beforeEach(() => {
+    getMemberSeats.mockReset()
+    getMemberSeats.mockResolvedValue({ success: true, data: { used: 1, limit: 5, full: false } })
+    inviteMember.mockReset()
+    inviteMember.mockResolvedValue({ success: true, data: { invited: true } })
+  })
+
+  async function openForm() {
+    renderDialog()
+    await waitFor(() => expect(getMemberSeats).toHaveBeenCalled())
+    fireEvent.click(officeChoice())
+    fireEvent.change(screen.getByLabelText('Invite by Email'), {
+      target: { value: 'kari@gmail.com' },
+    })
+  }
+
+  it('starts with no role chosen, not with No role', async () => {
+    await openForm()
+    expect(screen.getByRole('combobox')).toHaveTextContent('Choose a role')
+    expect(screen.getByRole('combobox')).not.toHaveTextContent('No role')
+  })
+
+  it('does not send an invitation until a role is chosen, and says so', async () => {
+    // The person who joined a workshop and met "No access yet" was invited
+    // by someone who never touched this field
+    await openForm()
+    fireEvent.click(screen.getByRole('button', { name: 'Invite' }))
+
+    const message = await screen.findByRole('alert')
+    expect(message).toHaveTextContent(
+      'Choose a role before inviting. Someone with no role can sign in but cannot open anything.'
+    )
+    const picker = screen.getByRole('combobox')
+    expect(picker).toHaveAttribute('aria-invalid', 'true')
+    expect(picker).toHaveAttribute('aria-describedby', message.id)
+    expect(inviteMember).not.toHaveBeenCalled()
+  })
+
+  it('drops the message once a role is chosen, and invites with that role', async () => {
+    await openForm()
+    fireEvent.click(screen.getByRole('button', { name: 'Invite' }))
+    await screen.findByRole('alert')
+
+    chooseRole('Desk')
+    expect(screen.queryByRole('alert')).toBeNull()
+    expect(screen.getByRole('combobox')).toHaveTextContent('Desk')
+
+    fireEvent.click(screen.getByRole('button', { name: 'Invite' }))
+    await waitFor(() =>
+      expect(inviteMember).toHaveBeenCalledWith({
+        email: 'kari@gmail.com',
+        role: 'member',
+        roleId: 'role-desk',
+      })
+    )
+  })
+
+  it('invites an admin as an admin', async () => {
+    await openForm()
+    chooseRole('Admin')
+    fireEvent.click(screen.getByRole('button', { name: 'Invite' }))
+
+    await waitFor(() =>
+      expect(inviteMember).toHaveBeenCalledWith({
+        email: 'kari@gmail.com',
+        role: 'admin',
+        roleId: undefined,
+      })
+    )
+  })
+
+  it('still invites with no role when that is chosen on purpose, and says what it means', async () => {
+    await openForm()
+    chooseRole('No role')
+    expect(screen.getByRole('combobox')).toHaveTextContent('No role')
+    expect(
+      screen.getByText(
+        'They can sign in, but they cannot open anything until an owner or an admin gives them a role.'
+      )
+    ).toBeInTheDocument()
+
+    fireEvent.click(screen.getByRole('button', { name: 'Invite' }))
+    await waitFor(() =>
+      expect(inviteMember).toHaveBeenCalledWith({
+        email: 'kari@gmail.com',
+        role: 'member',
+        roleId: undefined,
+      })
+    )
+  })
+})

+ 36 - 11
src/features/team/Components/AddPersonDialog.tsx

@@ -226,9 +226,11 @@ export function AddPersonDialog({
 
 
   // Team member
   // Team member
   const [email, setEmail] = useState('')
   const [email, setEmail] = useState('')
-  // "No role" until someone is chosen: the old 'member' default matched none
-  // of the choices, so the box showed nothing while it meant exactly this.
-  const [roleValue, setRoleValue] = useState('none')
+  // Nothing chosen until the person inviting chooses. The box used to start
+  // on "No role", and an invitation sent without touching it made an account
+  // that signs in to the No access screen while the workshop believes it has
+  // a new colleague. "No role" is still there, as a choice made on purpose.
+  const [roleValue, setRoleValue] = useState('')
 
 
   const reset = useCallback(() => {
   const reset = useCallback(() => {
     setKind(null)
     setKind(null)
@@ -242,7 +244,7 @@ export function AddPersonDialog({
     setScanned(false)
     setScanned(false)
     setEmail('')
     setEmail('')
     setColor(BOARD_COLOURS[0])
     setColor(BOARD_COLOURS[0])
-    setRoleValue('none')
+    setRoleValue('')
   }, [])
   }, [])
 
 
   const close = useCallback(() => {
   const close = useCallback(() => {
@@ -314,6 +316,10 @@ export function AddPersonDialog({
         setError(t('team.invalidInviteEmail'))
         setError(t('team.invalidInviteEmail'))
         return
         return
       }
       }
+      if (!roleValue) {
+        setError(t('team.roleRequired'))
+        return
+      }
       setBusy(true)
       setBusy(true)
       setError(null)
       setError(null)
 
 
@@ -617,17 +623,32 @@ export function AddPersonDialog({
                   if (error) setError(null)
                   if (error) setError(null)
                 }}
                 }}
                 aria-invalid={error === t('team.invalidInviteEmail') || undefined}
                 aria-invalid={error === t('team.invalidInviteEmail') || undefined}
-                aria-describedby={error ? 'member-invite-error' : undefined}
+                aria-describedby={
+                  error && error !== t('team.roleRequired') ? 'member-invite-error' : undefined
+                }
                 placeholder={t('team.inviteEmailPlaceholder')}
                 placeholder={t('team.inviteEmailPlaceholder')}
                 autoComplete="off"
                 autoComplete="off"
                 required
                 required
               />
               />
             </div>
             </div>
             <div className="space-y-2">
             <div className="space-y-2">
-              <Label>{t('team.role')}</Label>
-              <Select value={roleValue} onValueChange={setRoleValue}>
-                <SelectTrigger className="w-full">
-                  <SelectValue />
+              <Label htmlFor="member-role">{t('team.role')}</Label>
+              <Select
+                value={roleValue}
+                onValueChange={(value) => {
+                  setRoleValue(value)
+                  if (error) setError(null)
+                }}
+              >
+                <SelectTrigger
+                  id="member-role"
+                  className="w-full"
+                  aria-invalid={error === t('team.roleRequired') || undefined}
+                  aria-describedby={
+                    error === t('team.roleRequired') ? 'member-invite-error' : undefined
+                  }
+                >
+                  <SelectValue placeholder={t('team.chooseRole')} />
                 </SelectTrigger>
                 </SelectTrigger>
                 <SelectContent>
                 <SelectContent>
                   <SelectItem value="admin">{t('team.admin')}</SelectItem>
                   <SelectItem value="admin">{t('team.admin')}</SelectItem>
@@ -670,8 +691,12 @@ export function AddPersonDialog({
                 </div>
                 </div>
               )}
               )}
               {/* Which of the two they should pick, in one line, without the
               {/* Which of the two they should pick, in one line, without the
-                  word "permissions". */}
-              <p className="text-muted-foreground text-xs">{t('team.roleHint')}</p>
+                  word "permissions". Someone who picks "No role" is told what
+                  the invited person will meet, since that is the one choice
+                  that leaves them waiting. */}
+              <p className="text-muted-foreground text-xs">
+                {roleValue === 'none' ? t('team.noRoleInviteWarning') : t('team.roleHint')}
+              </p>
             </div>
             </div>
 
 
             {/* Announced, and tied to the address field, because with the
             {/* Announced, and tied to the address field, because with the