Procházet zdrojové kódy

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 před 1 dnem
rodič
revize
d2cc84df5c

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

@@ -49,6 +49,11 @@ test.describe('a member with no role', () => {
     }).toPass({ timeout: 30_000 })
     await ownerPage.getByText('Someone in the office').click()
     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 expect(ownerPage.getByText(COLLEAGUE).first()).toBeVisible({ timeout: 30_000 })
     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.getByText('Someone in the office').click()
   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 expect(ownerPage.getByText(invitee).first()).toBeVisible()
   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
- * "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 settle(page)
   await expect(async () => {
@@ -64,11 +65,9 @@ async function invite(page: Page, email: string, role?: 'Admin') {
   }).toPass({ timeout: 30_000 })
   await page.getByText('Someone in the office').click()
   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()
 }
 

+ 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 })
   await page.getByText('Someone in the office').click()
   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 expect(page.getByText(email).first()).toBeVisible({ timeout: 30_000 })
   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.",
     "removeBoardOnlyDone": "{name} ist von der Tafel",
     "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",
     "createDefaultRoles": "Standardrollen anlegen",
     "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.",
     "removeBoardOnlyDone": "{name} is off the board",
     "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",
     "createDefaultRoles": "Create the standard roles",
     "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.",
     "removeBoardOnlyDone": "{name} está fuera del tablero",
     "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",
     "createDefaultRoles": "Crear los roles estándar",
     "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.",
     "removeBoardOnlyDone": "{name} est retiré du planning",
     "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",
     "createDefaultRoles": "Créer les rôles standard",
     "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.",
     "removeBoardOnlyDone": "{name} è fuori dalla lavagna",
     "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",
     "createDefaultRoles": "Crea i ruoli standard",
     "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.",
     "removeBoardOnlyDone": "{name} pašalintas iš lentos",
     "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",
     "createDefaultRoles": "Sukurti standartines roles",
     "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.",
     "removeBoardOnlyDone": "{name} er tatt av tavlen",
     "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",
     "createDefaultRoles": "Opprett standardrollene",
     "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.",
     "removeBoardOnlyDone": "{name} staat niet meer op het bord",
     "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",
     "createDefaultRoles": "Standaardrollen aanmaken",
     "defaultRolesCreated": "Standaardrollen aangemaakt",

+ 4 - 1
messages/pl/settings.json

@@ -402,7 +402,7 @@
     "member": "Czlonek",
     "inviteByEmail": "Zapros przez e-mail",
     "inviteEmailPlaceholder": "użytkownik@przyklad.pl",
-    "invite": "Zapros",
+    "invite": "Zaproś",
     "invited": "Czlonek zaproszony pomyślnie.",
     "userNotFoundTitle": "Użytkownik nie znaleziony",
     "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.",
     "removeBoardOnlyDone": "{name} zdjęty z tablicy",
     "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",
     "createDefaultRoles": "Utwórz 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.",
     "removeBoardOnlyDone": "{name} saiu do quadro",
     "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",
     "createDefaultRoles": "Criar as funções padrão",
     "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.",
     "removeBoardOnlyDone": "{name} saiu do quadro",
     "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",
     "createDefaultRoles": "Criar as funções predefinidas",
     "defaultRolesCreated": "Funções predefinidas criadas",

+ 3 - 0
messages/ru/settings.json

@@ -558,6 +558,9 @@
     "removeBoardOnlyBody": "{name} больше не появляется на рабочей доске, и его нельзя назначать на заказ-наряды. Уже записанная работа и отмеченные часы остаются ровно такими же.",
     "removeBoardOnlyDone": "{name} убран с доски",
     "noRole": "Без роли",
+    "chooseRole": "Выберите роль",
+    "roleRequired": "Выберите роль перед приглашением. Без роли человек сможет войти, но ничего не сможет открыть.",
+    "noRoleInviteWarning": "Человек сможет войти, но ничего не откроет, пока владелец или администратор не назначит ему роль.",
     "worksOnJobs": "Работает с машинами",
     "createDefaultRoles": "Создать стандартные роли",
     "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.",
     "removeBoardOnlyDone": "{name} panodan kaldırıldı",
     "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",
     "createDefaultRoles": "Standart rolleri oluştur",
     "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) })
 }
 
+/**
+ * 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', () => {
   beforeEach(() => {
     getMemberSeats.mockReset()
@@ -158,14 +170,10 @@ describe('AddPersonDialog, invite address', () => {
     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 () => {
     const field = await openForm()
     fireEvent.change(field, { target: { value: ' kari@gmail.com ' } })
+    chooseRole('Desk')
     fireEvent.click(screen.getByRole('button', { name: 'Invite' }))
 
     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
   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(() => {
     setKind(null)
@@ -242,7 +244,7 @@ export function AddPersonDialog({
     setScanned(false)
     setEmail('')
     setColor(BOARD_COLOURS[0])
-    setRoleValue('none')
+    setRoleValue('')
   }, [])
 
   const close = useCallback(() => {
@@ -314,6 +316,10 @@ export function AddPersonDialog({
         setError(t('team.invalidInviteEmail'))
         return
       }
+      if (!roleValue) {
+        setError(t('team.roleRequired'))
+        return
+      }
       setBusy(true)
       setError(null)
 
@@ -617,17 +623,32 @@ export function AddPersonDialog({
                   if (error) setError(null)
                 }}
                 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')}
                 autoComplete="off"
                 required
               />
             </div>
             <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>
                 <SelectContent>
                   <SelectItem value="admin">{t('team.admin')}</SelectItem>
@@ -670,8 +691,12 @@ export function AddPersonDialog({
                 </div>
               )}
               {/* 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>
 
             {/* Announced, and tied to the address field, because with the