Преглед изворни кода

Tidy up account linking, role assignment and the Meta webhook (#386)

* Tidy up account linking, the sign-in limiter, role assignment, the Meta webhook and board bookings

- Google sign-in joins a password account only once that account has verified its address.
- The auth route counts callers by address alone.
- Handing out or taking away the built-in admin role is the owner's, on the role picker too.
- The Meta WhatsApp webhook reads a delivery only when it can check the signature.
- A job booked from the board takes only the workshop's own technician and bay.

Unit tests for each rule, and end-to-end coverage in the security, tenancy and cloud specs.

* Name the technician in the same lookup that checks it, and mock it in the scheduling test
Bernt Christian Egeland пре 2 недеља
родитељ
комит
1575ccf7a1
42 измењених фајлова са 903 додато и 76 уклоњено
  1. 71 0
      e2e/specs/auth/tenancy.spec.ts
  2. 37 5
      e2e/specs/cloud/google-sign-in.spec.ts
  3. 203 0
      e2e/specs/security/role-standing.spec.ts
  4. 163 0
      e2e/specs/security/whatsapp-webhook.spec.ts
  5. 130 0
      e2e/support/db.ts
  6. 2 1
      messages/de/auth.json
  7. 1 1
      messages/de/integrations.json
  8. 2 1
      messages/en/auth.json
  9. 1 1
      messages/en/integrations.json
  10. 2 1
      messages/es/auth.json
  11. 1 1
      messages/es/integrations.json
  12. 2 1
      messages/fr/auth.json
  13. 1 1
      messages/fr/integrations.json
  14. 2 1
      messages/it/auth.json
  15. 1 1
      messages/it/integrations.json
  16. 2 1
      messages/lt/auth.json
  17. 1 1
      messages/lt/integrations.json
  18. 2 1
      messages/nb/auth.json
  19. 1 1
      messages/nb/integrations.json
  20. 2 1
      messages/nl/auth.json
  21. 1 1
      messages/nl/integrations.json
  22. 2 1
      messages/pl/auth.json
  23. 1 1
      messages/pl/integrations.json
  24. 2 1
      messages/pt-BR/auth.json
  25. 1 1
      messages/pt-BR/integrations.json
  26. 2 1
      messages/ru/auth.json
  27. 1 1
      messages/ru/integrations.json
  28. 2 1
      messages/tr/auth.json
  29. 1 1
      messages/tr/integrations.json
  30. 58 1
      src/__tests__/features/team/invitation-rules.test.ts
  31. 3 0
      src/__tests__/features/workorders/create-workorder.test.ts
  32. 58 0
      src/__tests__/lib/auth-rate-limit.test.ts
  33. 14 0
      src/__tests__/lib/whatsapp-adapters.test.ts
  34. 7 1
      src/app/(public)/auth/sign-in/sign-in-form.tsx
  35. 2 17
      src/app/api/public/auth/[...all]/route.ts
  36. 5 2
      src/features/team/Actions/assignRole.ts
  37. 32 0
      src/features/team/Lib/invitationRules.ts
  38. 19 9
      src/features/vehicles/Lib/createDraftRecord.ts
  39. 3 1
      src/integrations/messaging/catalog.ts
  40. 33 0
      src/lib/auth-rate-limit.ts
  41. 14 9
      src/lib/auth.ts
  42. 15 7
      src/lib/whatsapp/adapters/meta.ts

+ 71 - 0
e2e/specs/auth/tenancy.spec.ts

@@ -1,6 +1,12 @@
 import { expect, type Page, test } from '@playwright/test'
 import { attach } from '../../support/attachments'
 import {
+  deleteTechnicians,
+  deleteWorkBays,
+  insertTechnician,
+  insertWorkBay,
+  jobAssignment,
+  jobCount,
   latestAttachmentUrl,
   organizationIdFor,
   seededTenantFixtures,
@@ -180,3 +186,68 @@ test.describe('a second workshop', () => {
     expect(response.headers()['content-type']).toContain('application/pdf')
   })
 })
+
+/**
+ * A job booked from the work board names the technician and the bay it was
+ * dropped on, by id, in the URL that opens the new job. Those ids have to be
+ * the workshop's own: the technician lookup used to write the id it was given
+ * even when it found nothing, so a job could point at another workshop's
+ * technician, and the bay was never looked up at all.
+ */
+test.describe('a job booked onto a technician and a bay', () => {
+  const made = { technicians: [] as string[], bays: [] as string[] }
+  let theirs = { technicianId: '', workBayId: '' }
+  let ours = { technicianId: '', workBayId: '' }
+
+  test.beforeAll(async () => {
+    const outsiderOrg = await organizationIdFor(OUTSIDER)
+    theirs = {
+      technicianId: await insertTechnician(outsiderOrg, `E2E Their Tech ${stamp}`),
+      workBayId: await insertWorkBay(outsiderOrg, `E2E Their Bay ${stamp}`),
+    }
+    ours = {
+      technicianId: await insertTechnician(seeded.organizationId, `E2E Own Tech ${stamp}`),
+      workBayId: await insertWorkBay(seeded.organizationId, `E2E Own Bay ${stamp}`),
+    }
+    made.technicians.push(theirs.technicianId, ours.technicianId)
+    made.bays.push(theirs.workBayId, ours.workBayId)
+  })
+
+  test.afterAll(async () => {
+    await deleteTechnicians(made.technicians)
+    await deleteWorkBays(made.bays)
+  })
+
+  test('is refused when either belongs to the other workshop', async ({ browser }) => {
+    const owner = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+    const before = await jobCount(seeded.vehicleId)
+    const newJob = `/vehicles/${seeded.vehicleId}/service/new`
+
+    await owner.goto(`${newJob}?boardTech=${theirs.technicianId}&boardBay=${ours.workBayId}`)
+    await expect(owner.getByText('Technician not found')).toBeVisible()
+    await expect(owner).toHaveURL(/\/service\/new/)
+
+    await owner.goto(`${newJob}?boardTech=${ours.technicianId}&boardBay=${theirs.workBayId}`)
+    await expect(owner.getByText('Work bay not found')).toBeVisible()
+    await expect(owner).toHaveURL(/\/service\/new/)
+
+    expect(await jobCount(seeded.vehicleId), 'no job was made').toBe(before)
+    await owner.close()
+  })
+
+  test('opens on the workshop’s own technician and bay', async ({ browser }) => {
+    const owner = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+    await owner.goto(
+      `/vehicles/${seeded.vehicleId}/service/new?boardTech=${ours.technicianId}&boardBay=${ours.workBayId}`
+    )
+    await owner.waitForURL(/\/service\/(?!new)[^/]+$/, { timeout: 30_000 })
+
+    const jobId = new URL(owner.url()).pathname.split('/').pop() as string
+    expect(await jobAssignment(jobId)).toEqual({
+      id: jobId,
+      technicianId: ours.technicianId,
+      workBayId: ours.workBayId,
+    })
+    await owner.close()
+  })
+})

+ 37 - 5
e2e/specs/cloud/google-sign-in.spec.ts

@@ -7,7 +7,7 @@ import {
   routeGoogleToStandin,
   signUpWithPassword,
 } from '../../support/cloud'
-import { personWithEmail } from '../../support/db'
+import { markEmailVerified, personWithEmail } from '../../support/db'
 import { settle } from '../../support/hydration'
 
 /**
@@ -20,10 +20,14 @@ import { settle } from '../../support/hydration'
  *
  * The rule most worth a test is account linking. A Google sign-in whose
  * address matches an existing password account is attached to that account,
- * which is what a returning customer expects. But an address Google has not
- * verified proves nothing about who is signing in: anyone can create a Google
- * account with somebody else's address on it. Attached to the account that
- * owns the address, that is a way into another person's workshop.
+ * which is what a returning customer expects. But it takes proof from both
+ * sides. An address Google has not verified proves nothing about who is
+ * signing in: anyone can create a Google account with somebody else's address
+ * on it. And a password account nobody verified proves nothing about who made
+ * it: anyone can sign up with somebody else's address and a password of their
+ * own, and joining Google to that account would sign its real owner into a
+ * stranger's account. Either way round, that is a way into another person's
+ * workshop.
  */
 
 // Signing up and onboarding a workshop in a hook takes longer than a test.
@@ -36,6 +40,7 @@ const stamp = Date.now()
 const NEWCOMER = `e2e-google-new-${stamp}@example.com`
 const RETURNING = `e2e-google-password-${stamp}@example.com`
 const OWNER = `e2e-google-owner-${stamp}@example.com`
+const SQUATTED = `e2e-google-squatted-${stamp}@example.com`
 const PASSWORD = `E2e-pass-${stamp}`
 
 test.beforeAll(async ({ browser }) => {
@@ -53,6 +58,16 @@ test.beforeAll(async ({ browser }) => {
     await completeOnboarding(page, workshop, { sampleData: false })
     await context.close()
   }
+  // The returning person clicked the link in their verification mail. The
+  // e2e database has no verification wall, so this is that click.
+  await markEmailVerified(RETURNING)
+
+  // And a password account somebody opened under an address that is not
+  // theirs, never verified, and left at onboarding.
+  const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const page = await context.newPage()
+  await signUpWithPassword(page, { name: 'Not the owner', email: SQUATTED, password: PASSWORD })
+  await context.close()
 })
 
 test.beforeEach(async ({ context }) => {
@@ -126,6 +141,23 @@ test.describe('Google sign-in', () => {
     expect(person.providers).toEqual(['credential', 'google'])
   })
 
+  test('does not join a password account whose address was never verified', async ({ page }) => {
+    // Google vouches for the person at the keyboard; the password account
+    // under that address was made by somebody else. Joining the two would
+    // sign the real owner of the address into the stranger's account.
+    await registerGoogleAccount({ email: SQUATTED, emailVerified: true })
+    await continueWithGoogle(page, '/auth/sign-in', SQUATTED)
+
+    await page.waitForURL(/\/auth\/sign-in\?error=account_not_linked/, { timeout: 30_000 })
+    await expect(page.getByText(/address has not been verified yet/)).toBeVisible()
+    await expect(page.locator('#email'), 'the password form is the way in').toBeVisible()
+
+    const person = await personWithEmail(SQUATTED)
+    expect(person.users, 'no second person was made either').toBe(1)
+    expect(person.providers, 'the password account stands alone').toEqual(['credential'])
+    expect(person.emailVerified).toBe(false)
+  })
+
   test('does not hand an account to a Google address nobody verified', async ({ page }) => {
     // Somebody made a Google account with the owner's address on it. Google
     // says so: the address is not verified.

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

@@ -0,0 +1,203 @@
+import { expect, type Browser, type Page, test } from '@playwright/test'
+import {
+  createAdminRole,
+  createRoleWithEveryPermission,
+  deleteRoles,
+  membershipOf,
+  ownerOrganizationId,
+  setMembership,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { linkIn, waitForMail } from '../../support/mail'
+
+/**
+ * Who may make somebody an admin.
+ *
+ * Inviting as admin is the owner's alone, and so is changing a member's
+ * built-in role on the team page. The role picker's own action asked only
+ * for admin standing, which a custom role can carry, so a person with such a
+ * role could make themself, or anyone, a built-in admin, and from there edit
+ * roles and remove members. The team page never offered them the picker; the
+ * action behind it is what a browser can call, and what this file calls.
+ *
+ * The action's id is not written down anywhere a test could read it, so it is
+ * taken from the request the owner's own click makes, and replayed with the
+ * arguments of the caller's choosing, as a person in the browser could.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+// Everybody starts as a stranger with no session.
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const stamp = Date.now()
+const COLLEAGUE = `e2e-standing-${stamp}@example.com`
+const PEER = `e2e-peer-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+
+const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100'
+
+let organizationId = ''
+let adminRoleId = ''
+let plainRoleId = ''
+/** The `Next-Action` id of assignRole, learnt from the owner's click. */
+let assignRoleAction = ''
+
+async function signIn(page: Page, email: string) {
+  await page.goto('/auth/sign-in')
+  await page.locator('#email').fill(email)
+  await page.locator('#password').fill(PASSWORD)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+  await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+}
+
+/** The owner invites `email`, who signs up from the mail and lands in the workshop. */
+async function joinTeam(browser: Browser, email: string, name: string) {
+  const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+  const page = await owner.newPage()
+  await page.goto('/settings/team')
+  await settle(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Add', exact: true }).first().click()
+    await expect(page.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.getByText('Someone in the office').click()
+  await page.locator('#member-email').fill(email)
+  await page.getByRole('button', { name: 'Invite', exact: true }).click()
+  await expect(page.getByText(email).first()).toBeVisible({ timeout: 30_000 })
+  await owner.close()
+
+  const invitation = await waitForMail(email)
+  const person = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const signup = await person.newPage()
+  await signup.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
+  await signup.locator('#name').fill(name)
+  await signup.locator('#email').fill(email)
+  await signup.locator('#password').fill(PASSWORD)
+  await signup.locator('#terms').click()
+  await signup.getByRole('button', { name: /create account/i }).click()
+  await signup.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), {
+    timeout: 30_000,
+  })
+  await person.close()
+}
+
+/**
+ * Calls assignRole the way the page does: a POST to the page it lives on,
+ * with the action id in the `Next-Action` header and the arguments as the
+ * body. What the page would do with the answer does not matter here; the
+ * membership is read back from the database.
+ */
+async function callAssignRole(
+  page: Page,
+  args: { memberId: string; role: 'admin' | 'member'; roleId: string | null }
+) {
+  return page.request.post(`${baseURL}/settings/team`, {
+    headers: {
+      'next-action': assignRoleAction,
+      'content-type': 'text/plain;charset=UTF-8',
+      accept: 'text/x-component',
+      origin: baseURL,
+    },
+    data: JSON.stringify([args]),
+  })
+}
+
+test.beforeAll(async ({ browser }) => {
+  organizationId = await ownerOrganizationId()
+  await joinTeam(browser, COLLEAGUE, 'E2E Standing Colleague')
+  await joinTeam(browser, PEER, 'E2E Peer')
+  adminRoleId = await createAdminRole(organizationId, `E2E Admin Switch ${stamp}`)
+  plainRoleId = await createRoleWithEveryPermission(organizationId, `E2E Plain ${stamp}`)
+})
+
+test.afterAll(async () => {
+  // Two more ordinary members without a role, and the roles gone.
+  if (organizationId) {
+    for (const email of [COLLEAGUE, PEER]) {
+      await setMembership(email, organizationId, { roleId: null, role: 'member' })
+    }
+  }
+  await deleteRoles([adminRoleId, plainRoleId].filter(Boolean))
+})
+
+test.describe('the role picker’s action', () => {
+  test('is learnt from the owner giving the peer a role', async ({ browser }) => {
+    const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    const page = await owner.newPage()
+    await page.goto('/settings/team')
+    await settle(page)
+
+    // Members are bordered cards, one per person, each with its own picker.
+    const row = page.locator('div.rounded-lg.border').filter({ hasText: PEER })
+    const action = page.waitForRequest((request) => Boolean(request.headers()['next-action']))
+    await row.getByRole('combobox').click()
+    await page.getByRole('option', { name: `E2E Plain ${stamp}`, exact: true }).click()
+    assignRoleAction = (await action).headers()['next-action']
+    expect(assignRoleAction).toBeTruthy()
+
+    await expect
+      .poll(async () => (await membershipOf(PEER, organizationId)).roleId)
+      .toBe(plainRoleId)
+    await owner.close()
+  })
+
+  test('does not let a custom-role admin make themself or a peer a built-in admin', async ({
+    page,
+  }) => {
+    await setMembership(COLLEAGUE, organizationId, { roleId: adminRoleId, role: 'member' })
+    await signIn(page, COLLEAGUE)
+    const self = await membershipOf(COLLEAGUE, organizationId)
+    const peer = await membershipOf(PEER, organizationId)
+
+    await callAssignRole(page, { memberId: self.id, role: 'admin', roleId: null })
+    await callAssignRole(page, { memberId: peer.id, role: 'admin', roleId: null })
+
+    expect((await membershipOf(COLLEAGUE, organizationId)).role).toBe('member')
+    expect(await membershipOf(PEER, organizationId)).toEqual(peer)
+
+    // The action itself still works for them: a role that grants nothing on
+    // its own is theirs to hand out.
+    await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: null })
+    expect((await membershipOf(PEER, organizationId)).roleId).toBeNull()
+    await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: plainRoleId })
+    expect((await membershipOf(PEER, organizationId)).roleId).toBe(plainRoleId)
+  })
+
+  test('does not let a built-in admin make a peer a built-in admin, or unmake one', async ({
+    page,
+  }) => {
+    await setMembership(COLLEAGUE, organizationId, { roleId: null, role: 'admin' })
+    await signIn(page, COLLEAGUE)
+    const peer = await membershipOf(PEER, organizationId)
+
+    await callAssignRole(page, { memberId: peer.id, role: 'admin', roleId: null })
+    expect((await membershipOf(PEER, organizationId)).role).toBe('member')
+
+    // Made an admin by the owner, the peer is out of the colleague's reach.
+    await setMembership(PEER, organizationId, { roleId: null, role: 'admin' })
+    await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: plainRoleId })
+    expect(await membershipOf(PEER, organizationId)).toEqual({
+      id: peer.id,
+      role: 'admin',
+      roleId: null,
+    })
+  })
+
+  test('lets the owner do both', async ({ browser }) => {
+    const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    const page = await owner.newPage()
+    await page.goto('/settings/team')
+    const peer = await membershipOf(PEER, organizationId)
+
+    await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: plainRoleId })
+    expect(await membershipOf(PEER, organizationId)).toEqual({
+      id: peer.id,
+      role: 'member',
+      roleId: plainRoleId,
+    })
+    await callAssignRole(page, { memberId: peer.id, role: 'admin', roleId: null })
+    expect((await membershipOf(PEER, organizationId)).role).toBe('admin')
+    await owner.close()
+  })
+})

+ 163 - 0
e2e/specs/security/whatsapp-webhook.spec.ts

@@ -0,0 +1,163 @@
+import { expect, test } from '@playwright/test'
+import { createHmac } from 'node:crypto'
+import {
+  deleteInboundWhatsapp,
+  forgetConnections,
+  inboundWhatsappCount,
+  insertConnection,
+  ownerOrganizationId,
+  userIdFor,
+} from '../../support/db'
+import { sealCredentials } from '../../support/webhooks'
+
+/**
+ * A WhatsApp message has to come from Meta.
+ *
+ * The webhook URL names only the workshop's id, which every share link and
+ * public logo carries, so the signature Meta puts on each delivery is the
+ * whole of the proof. The app secret that checks it used to be optional, and
+ * a workshop that left it blank had a webhook anyone could post to: a made-up
+ * message landed in the inbox as if a customer had written it. A delivery
+ * that cannot be checked is not read now.
+ *
+ * The route answers 200 whatever happens, so that Meta does not retry, and
+ * the outcome is read from the database.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const APP_SECRET = `e2e-meta-app-secret-${stamp}`
+const BODY = `E2E WhatsApp inbound ${stamp}`
+
+const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100'
+
+let organizationId = ''
+let webhook = ''
+
+/** What Meta posts for one text message. */
+function delivery(body: string): string {
+  return JSON.stringify({
+    entry: [
+      {
+        changes: [
+          {
+            value: {
+              metadata: { display_phone_number: '15550009999' },
+              contacts: [{ profile: { name: 'E2E Customer' } }],
+              messages: [
+                {
+                  id: `wamid.e2e.${stamp}.${body.length}`,
+                  from: '15551230000',
+                  timestamp: String(Math.floor(Date.now() / 1000)),
+                  type: 'text',
+                  text: { body },
+                },
+              ],
+            },
+          },
+        ],
+      },
+    ],
+  })
+}
+
+function signature(raw: string, secret: string): string {
+  return `sha256=${createHmac('sha256', secret).update(raw).digest('hex')}`
+}
+
+async function plantConnection(withSecret: boolean): Promise<void> {
+  await forgetConnections(['whatsapp-meta'])
+  await insertConnection({
+    organizationId,
+    connectorId: 'whatsapp-meta',
+    credentials: sealCredentials({
+      phoneNumberId: '123456789012345',
+      accessToken: 'e2e-access-token',
+      verifyToken: 'e2e-verify-token',
+      ...(withSecret ? { appSecret: APP_SECRET } : {}),
+    }),
+    settings: { enabled: true, phoneNumber: '+15550009999' },
+    createdById: await userIdFor('demo@torqvoice.com'),
+  })
+}
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  webhook = `${baseURL}/api/webhooks/whatsapp/meta/${organizationId}`
+})
+
+test.afterAll(async () => {
+  await forgetConnections(['whatsapp-meta'])
+  await deleteInboundWhatsapp(organizationId, BODY)
+})
+
+test.describe('a message posted to the Meta webhook', () => {
+  test('is dropped when the workshop has no app secret, even with a signature', async ({
+    request,
+  }) => {
+    await plantConnection(false)
+    const raw = delivery(`${BODY} unguarded`)
+
+    const bare = await request.post(webhook, {
+      data: raw,
+      headers: { 'content-type': 'application/json' },
+    })
+    expect(bare.status(), 'Meta is told not to retry').toBe(200)
+
+    // Whatever the poster signs it with: there is nothing to check it against.
+    const signed = await request.post(webhook, {
+      data: raw,
+      headers: {
+        'content-type': 'application/json',
+        'x-hub-signature-256': signature(raw, 'guess'),
+      },
+    })
+    expect(signed.status()).toBe(200)
+
+    expect(
+      await inboundWhatsappCount(organizationId, `${BODY} unguarded`),
+      'nothing was filed'
+    ).toBe(0)
+  })
+
+  test('is dropped without Meta’s signature once the secret is set', async ({ request }) => {
+    await plantConnection(true)
+    const raw = delivery(`${BODY} unsigned`)
+
+    await request.post(webhook, { data: raw, headers: { 'content-type': 'application/json' } })
+    await request.post(webhook, {
+      data: raw,
+      headers: {
+        'content-type': 'application/json',
+        'x-hub-signature-256': signature(raw, 'wrong'),
+      },
+    })
+    expect(await inboundWhatsappCount(organizationId, `${BODY} unsigned`)).toBe(0)
+  })
+
+  test('is filed once when signed with the app secret', async ({ request }) => {
+    const raw = delivery(`${BODY} genuine`)
+    const response = await request.post(webhook, {
+      data: raw,
+      headers: {
+        'content-type': 'application/json',
+        'x-hub-signature-256': signature(raw, APP_SECRET),
+      },
+    })
+    expect(response.status()).toBe(200)
+    expect(await inboundWhatsappCount(organizationId, `${BODY} genuine`)).toBe(1)
+  })
+
+  test('is dropped when the body was changed after signing', async ({ request }) => {
+    const signedRaw = delivery(`${BODY} original`)
+    await request.post(webhook, {
+      data: delivery(`${BODY} tampered`),
+      headers: {
+        'content-type': 'application/json',
+        'x-hub-signature-256': signature(signedRaw, APP_SECRET),
+      },
+    })
+    expect(await inboundWhatsappCount(organizationId, `${BODY} tampered`)).toBe(0)
+  })
+})

+ 130 - 0
e2e/support/db.ts

@@ -939,3 +939,133 @@ export async function forgetWorkshopSetting(organizationId: string, key: string)
     ])
   )
 }
+
+/** Marks the address verified, as clicking the mail's link would. */
+export async function markEmailVerified(email: string): Promise<void> {
+  await withDb((db) =>
+    db.query(`update users set "emailVerified" = true where lower(email) = lower($1)`, [email])
+  )
+}
+
+export interface MembershipRecord {
+  id: string
+  role: string
+  roleId: string | null
+}
+
+/** A person's membership of a workshop, as stored. */
+export async function membershipOf(
+  email: string,
+  organizationId: string
+): Promise<MembershipRecord> {
+  return withDb(async (db) => {
+    const result = await db.query<MembershipRecord>(
+      `select m.id, m.role, m."roleId" from organization_members m
+         join users u on u.id = m."userId"
+        where lower(u.email) = lower($1) and m."organizationId" = $2`,
+      [email, organizationId]
+    )
+    if (!result.rows[0]) throw new Error(`${email} is not a member of ${organizationId}`)
+    return result.rows[0]
+  })
+}
+
+/** A role that carries the admin switch and nothing else. */
+export async function createAdminRole(organizationId: string, name: string): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into roles (id, name, "isAdmin", "organizationId", "createdAt", "updatedAt")
+       values (gen_random_uuid()::text, $1, true, $2, now(), now()) returning id`,
+      [name, organizationId]
+    )
+    return result.rows[0].id
+  })
+}
+
+export async function deleteRoles(ids: string[]): Promise<void> {
+  if (ids.length === 0) return
+  await withDb((db) => db.query(`delete from roles where id = any($1::text[])`, [ids]))
+}
+
+/** A technician on a workshop's board, made here so the spec owns it. */
+export async function insertTechnician(organizationId: string, name: string): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into technicians (id, name, "organizationId", "createdAt", "updatedAt")
+       values (gen_random_uuid()::text, $1, $2, now(), now()) returning id`,
+      [name, organizationId]
+    )
+    return result.rows[0].id
+  })
+}
+
+export async function insertWorkBay(organizationId: string, name: string): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into work_bays (id, name, "organizationId", "createdAt", "updatedAt")
+       values (gen_random_uuid()::text, $1, $2, now(), now()) returning id`,
+      [name, organizationId]
+    )
+    return result.rows[0].id
+  })
+}
+
+export async function deleteTechnicians(ids: string[]): Promise<void> {
+  if (ids.length === 0) return
+  await withDb((db) => db.query(`delete from technicians where id = any($1::text[])`, [ids]))
+}
+
+export async function deleteWorkBays(ids: string[]): Promise<void> {
+  if (ids.length === 0) return
+  await withDb((db) => db.query(`delete from work_bays where id = any($1::text[])`, [ids]))
+}
+
+export interface JobAssignment {
+  id: string
+  technicianId: string | null
+  workBayId: string | null
+}
+
+/** A job's technician and bay as stored, by its id. */
+export async function jobAssignment(serviceRecordId: string): Promise<JobAssignment> {
+  return withDb(async (db) => {
+    const result = await db.query<JobAssignment>(
+      `select id, "technicianId", "workBayId" from service_records where id = $1`,
+      [serviceRecordId]
+    )
+    if (!result.rows[0]) throw new Error(`no job ${serviceRecordId}`)
+    return result.rows[0]
+  })
+}
+
+/** How many jobs a vehicle has, before and after an attempt to add one. */
+export async function jobCount(vehicleId: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from service_records where "vehicleId" = $1`,
+      [vehicleId]
+    )
+    return Number(result.rows[0].n)
+  })
+}
+
+/** Inbound WhatsApp messages with exactly this body, for a workshop. */
+export async function inboundWhatsappCount(organizationId: string, body: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from whatsapp_messages
+        where "organizationId" = $1 and direction = 'inbound' and body = $2`,
+      [organizationId, body]
+    )
+    return Number(result.rows[0].n)
+  })
+}
+
+export async function deleteInboundWhatsapp(organizationId: string, body: string): Promise<void> {
+  await withDb((db) =>
+    db.query(
+      `delete from whatsapp_messages where "organizationId" = $1 and direction = 'inbound' and body like $2`,
+      [organizationId, `${body}%`]
+    )
+  )
+}

+ 2 - 1
messages/de/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Mit Google fortfahren",
     "orEmail": "oder mit E-Mail fortfahren",
-    "failed": "Die Anmeldung mit Google wurde nicht abgeschlossen. Versuchen Sie es erneut oder nutzen Sie E-Mail und Passwort."
+    "failed": "Die Anmeldung mit Google wurde nicht abgeschlossen. Versuchen Sie es erneut oder nutzen Sie E-Mail und Passwort.",
+    "notLinked": "Es gibt bereits ein Konto mit dieser E-Mail-Adresse, aber die Adresse wurde noch nicht bestätigt, daher konnte Google nicht damit verknüpft werden. Melde dich mit deinem Passwort an; sobald die Adresse bestätigt ist, funktioniert auch die Anmeldung mit Google."
   },
   "forgotPassword": {
     "title": "Passwort zurücksetzen",

+ 1 - 1
messages/de/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 2 - 1
messages/en/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Continue with Google",
     "orEmail": "or continue with email",
-    "failed": "Google sign-in did not complete. Try again, or use your email and password."
+    "failed": "Google sign-in did not complete. Try again, or use your email and password.",
+    "notLinked": "An account with this email already exists but its address has not been verified yet, so Google could not be joined to it. Sign in with your password; once the address is verified, Google sign-in will work too."
   },
   "forgotPassword": {
     "title": "Reset your password",

+ 1 - 1
messages/en/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 2 - 1
messages/es/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Continuar con Google",
     "orEmail": "o continúe con su correo",
-    "failed": "El inicio de sesión con Google no se completó. Inténtelo de nuevo o use su correo y contraseña."
+    "failed": "El inicio de sesión con Google no se completó. Inténtelo de nuevo o use su correo y contraseña.",
+    "notLinked": "Ya existe una cuenta con este correo, pero la dirección aún no se ha verificado, así que no se pudo vincular Google. Inicia sesión con tu contraseña; cuando la dirección esté verificada, el acceso con Google también funcionará."
   },
   "forgotPassword": {
     "title": "Restablecer su contraseña",

+ 1 - 1
messages/es/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 2 - 1
messages/fr/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Continuer avec Google",
     "orEmail": "ou continuer avec l'e-mail",
-    "failed": "La connexion avec Google n'a pas abouti. Réessayez ou utilisez votre e-mail et votre mot de passe."
+    "failed": "La connexion avec Google n'a pas abouti. Réessayez ou utilisez votre e-mail et votre mot de passe.",
+    "notLinked": "Un compte existe déjà avec cet e-mail, mais l'adresse n'a pas encore été vérifiée, donc Google n'a pas pu y être associé. Connectez-vous avec votre mot de passe ; une fois l'adresse vérifiée, la connexion Google fonctionnera aussi."
   },
   "forgotPassword": {
     "title": "Réinitialiser votre mot de passe",

+ 1 - 1
messages/fr/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 2 - 1
messages/it/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Continua con Google",
     "orEmail": "oppure continui con l'email",
-    "failed": "L'accesso con Google non è andato a buon fine. Riprovi oppure usi email e password."
+    "failed": "L'accesso con Google non è andato a buon fine. Riprovi oppure usi email e password.",
+    "notLinked": "Esiste già un account con questa email, ma l'indirizzo non è ancora stato verificato, quindi Google non è stato collegato. Accedi con la password; una volta verificato l'indirizzo, funzionerà anche l'accesso con Google."
   },
   "forgotPassword": {
     "title": "Reimposti la Sua password",

+ 1 - 1
messages/it/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 2 - 1
messages/lt/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Tęsti su „Google“",
     "orEmail": "arba tęskite su el. paštu",
-    "failed": "Prisijungimas per „Google“ nebuvo užbaigtas. Bandykite dar kartą arba naudokite el. paštą ir slaptažodį."
+    "failed": "Prisijungimas per „Google“ nebuvo užbaigtas. Bandykite dar kartą arba naudokite el. paštą ir slaptažodį.",
+    "notLinked": "Paskyra su šiuo el. pašto adresu jau yra, tačiau adresas dar nepatvirtintas, todėl „Google“ nepavyko susieti. Prisijunkite su slaptažodžiu; kai adresas bus patvirtintas, veiks ir prisijungimas per „Google“."
   },
   "forgotPassword": {
     "title": "Atstatyti slaptažodį",

+ 1 - 1
messages/lt/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 2 - 1
messages/nb/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Fortsett med Google",
     "orEmail": "eller fortsett med e-post",
-    "failed": "Innloggingen med Google ble ikke fullført. Prøv igjen, eller bruk e-post og passord."
+    "failed": "Innloggingen med Google ble ikke fullført. Prøv igjen, eller bruk e-post og passord.",
+    "notLinked": "Det finnes allerede en konto med denne e-postadressen, men adressen er ikke bekreftet ennå, så Google kunne ikke kobles til den. Logg inn med passordet ditt; når adressen er bekreftet, virker Google-innlogging også."
   },
   "forgotPassword": {
     "title": "Tilbakestill passordet ditt",

+ 1 - 1
messages/nb/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 2 - 1
messages/nl/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Doorgaan met Google",
     "orEmail": "of ga verder met e-mail",
-    "failed": "Inloggen met Google is niet voltooid. Probeer het opnieuw of gebruik uw e-mailadres en wachtwoord."
+    "failed": "Inloggen met Google is niet voltooid. Probeer het opnieuw of gebruik uw e-mailadres en wachtwoord.",
+    "notLinked": "Er bestaat al een account met dit e-mailadres, maar het adres is nog niet geverifieerd, dus Google kon er niet aan worden gekoppeld. Log in met je wachtwoord; zodra het adres is geverifieerd, werkt inloggen met Google ook."
   },
   "forgotPassword": {
     "title": "Wachtwoord herstellen",

+ 1 - 1
messages/nl/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 2 - 1
messages/pl/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Kontynuuj z Google",
     "orEmail": "lub kontynuuj z e-mailem",
-    "failed": "Logowanie przez Google nie powiodło się. Spróbuj ponownie lub użyj e-maila i hasła."
+    "failed": "Logowanie przez Google nie powiodło się. Spróbuj ponownie lub użyj e-maila i hasła.",
+    "notLinked": "Konto z tym adresem e-mail już istnieje, ale adres nie został jeszcze zweryfikowany, więc nie udało się powiązać Google. Zaloguj się hasłem; gdy adres zostanie zweryfikowany, logowanie przez Google też zadziała."
   },
   "forgotPassword": {
     "title": "Zresetuj hasło",

+ 1 - 1
messages/pl/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 2 - 1
messages/pt-BR/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Continuar com o Google",
     "orEmail": "ou continue com e-mail",
-    "failed": "O login com o Google não foi concluído. Tente de novo ou use seu e-mail e senha."
+    "failed": "O login com o Google não foi concluído. Tente de novo ou use seu e-mail e senha.",
+    "notLinked": "Já existe uma conta com este e-mail, mas o endereço ainda não foi verificado, então o Google não pôde ser vinculado a ela. Entre com sua senha; quando o endereço estiver verificado, o login com Google também funcionará."
   },
   "forgotPassword": {
     "title": "Redefinir sua senha",

+ 1 - 1
messages/pt-BR/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 2 - 1
messages/ru/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Продолжить с Google",
     "orEmail": "или продолжить по электронной почте",
-    "failed": "Вход через Google не завершился. Попробуйте ещё раз или войдите по электронной почте и паролю."
+    "failed": "Вход через Google не завершился. Попробуйте ещё раз или войдите по электронной почте и паролю.",
+    "notLinked": "Аккаунт с этим адресом уже существует, но адрес ещё не подтверждён, поэтому Google не удалось привязать. Войдите с паролем; когда адрес будет подтверждён, вход через Google тоже заработает."
   },
   "forgotPassword": {
     "title": "Сброс пароля",

+ 1 - 1
messages/ru/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 2 - 1
messages/tr/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Google ile devam et",
     "orEmail": "veya e-posta ile devam edin",
-    "failed": "Google ile giriş tamamlanamadı. Tekrar deneyin veya e-posta ve şifrenizi kullanın."
+    "failed": "Google ile giriş tamamlanamadı. Tekrar deneyin veya e-posta ve şifrenizi kullanın.",
+    "notLinked": "Bu e-posta adresiyle zaten bir hesap var ancak adres henüz doğrulanmadığı için Google bağlanamadı. Şifrenizle giriş yapın; adres doğrulandığında Google ile giriş de çalışacaktır."
   },
   "forgotPassword": {
     "title": "Şifrenizi sıfırlayın",

+ 1 - 1
messages/tr/integrations.json

@@ -296,7 +296,7 @@
         "verifyToken": "Webhook verify token",
         "appSecret": "App secret",
         "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "appSecretHelp": "Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers."
       },
       "settings": {
         "enabled": "Send messages on this channel",

+ 58 - 1
src/__tests__/features/team/invitation-rules.test.ts

@@ -63,7 +63,11 @@ vi.mock('@/lib/db', () => ({
 import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { db } from '@/lib/db'
 import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
-import { canInvite, pendingInvitationSelect } from '@/features/team/Lib/invitationRules'
+import {
+  canAssignRole,
+  canInvite,
+  pendingInvitationSelect,
+} from '@/features/team/Lib/invitationRules'
 import { sendInvitation } from '@/features/team/Actions/sendInvitation'
 import { getPendingInvitations } from '@/features/team/Actions/getPendingInvitations'
 import { inviteMember } from '@/features/team/Actions/teamActions'
@@ -249,3 +253,56 @@ describe('acceptInvitation', () => {
     expect(ops).toHaveLength(2)
   })
 })
+
+describe('canAssignRole', () => {
+  const owner = { role: 'owner', isAdmin: true, userId: 'u-owner' }
+  const admin = { role: 'admin', isAdmin: true, userId: 'u-admin' }
+  // A custom role with the admin switch: stored role `member`, admin standing.
+  const roleAdmin = { role: 'member', isAdmin: true, userId: 'u-role-admin' }
+  const member = { role: 'member', isAdmin: false, userId: 'u-member' }
+
+  it('lets an admin hand out a custom role or none', () => {
+    expect(canAssignRole(admin, { userId: 'u-x', role: 'member' }, { role: 'member' })).toEqual({
+      ok: true,
+    })
+    expect(canAssignRole(roleAdmin, { userId: 'u-x', role: 'member' }, { role: 'member' })).toEqual(
+      {
+        ok: true,
+      }
+    )
+  })
+
+  it('reserves the built-in admin role for the owner', () => {
+    expect(canAssignRole(owner, { userId: 'u-x', role: 'member' }, { role: 'admin' })).toEqual({
+      ok: true,
+    })
+    for (const caller of [admin, roleAdmin]) {
+      const decision = canAssignRole(caller, { userId: 'u-x', role: 'member' }, { role: 'admin' })
+      expect(decision).toEqual({ ok: false, reason: 'Only the owner can change who is an admin' })
+    }
+  })
+
+  it('does not let an admin take admin away from a peer either', () => {
+    const decision = canAssignRole(admin, { userId: 'u-x', role: 'admin' }, { role: 'member' })
+    expect(decision.ok).toBe(false)
+    expect(canAssignRole(owner, { userId: 'u-x', role: 'admin' }, { role: 'member' }).ok).toBe(true)
+  })
+
+  it('refuses a change to the caller’s own standing', () => {
+    const decision = canAssignRole(
+      roleAdmin,
+      { userId: 'u-role-admin', role: 'member' },
+      { role: 'admin' }
+    )
+    expect(decision).toEqual({ ok: false, reason: 'You cannot change your own role' })
+  })
+
+  it('refuses the owner as a target, and a caller with no admin standing', () => {
+    expect(canAssignRole(owner, { userId: 'u-owner', role: 'owner' }, { role: 'member' }).ok).toBe(
+      false
+    )
+    expect(canAssignRole(member, { userId: 'u-x', role: 'member' }, { role: 'member' }).ok).toBe(
+      false
+    )
+  })
+})

+ 3 - 0
src/__tests__/features/workorders/create-workorder.test.ts

@@ -726,6 +726,9 @@ describe('createDraftServiceRecord — scheduling', () => {
     vi.mocked(db.appSetting.findMany).mockResolvedValue(SHOP_HOURS)
     vi.mocked(db.organization.findUnique).mockResolvedValue({ name: 'Shop' } as any)
     vi.mocked(db.serviceRecord.findFirst).mockResolvedValue(null)
+    // The technician named in the booking is one of the workshop's own; a
+    // stranger's id is refused before any slot is looked for.
+    vi.mocked(db.technician.findFirst).mockResolvedValue({ name: 'Tech' } as any)
     vi.mocked(db.serviceRecord.findMany).mockResolvedValue([
       {
         id: 'busy',

+ 58 - 0
src/__tests__/lib/auth-rate-limit.test.ts

@@ -0,0 +1,58 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * The sign-in door counts callers by address, whatever they put in the
+ * Authorization header.
+ *
+ * The limiter keys a request on its bearer token when one is present, which
+ * is right for the technician API, where the token is the identity. On the
+ * auth route nobody has a session yet, and the route used to call the
+ * limiter without saying so: a made-up bearer on every request was a fresh
+ * budget for guessing passwords, reset tokens and 2FA codes. The route passes
+ * `anonymous` now, and this holds it to that.
+ */
+
+async function freshLimiter() {
+  vi.resetModules()
+  return (await import('@/lib/auth-rate-limit')).limitAuthRequest
+}
+
+function signIn(headers: Record<string, string> = {}) {
+  return new Request('https://app.torqvoice.com/api/public/auth/sign-in/email', {
+    method: 'POST',
+    headers: { 'x-real-ip': '203.0.113.9', ...headers },
+  })
+}
+
+beforeEach(() => {
+  vi.unstubAllEnvs()
+})
+
+describe('the sign-in budget', () => {
+  it('is spent by address, and a rotating bearer token does not refill it', async () => {
+    const limit = await freshLimiter()
+    const path = '/api/public/auth/sign-in/email'
+
+    for (let n = 1; n <= 10; n++) {
+      const attempt = signIn({ authorization: `Bearer made-up-${n}` })
+      expect(limit(attempt, path), `attempt ${n}`).toBeNull()
+    }
+    // Eleventh try, eleventh invented token, same caller: refused.
+    const eleventh = limit(signIn({ authorization: 'Bearer made-up-11' }), path)
+    expect(eleventh?.status).toBe(429)
+    // And so is one with no header at all; it is the same address.
+    expect(limit(signIn(), path)?.status).toBe(429)
+  })
+
+  it('keeps the tighter budget for sign-up and reset, and a looser one elsewhere', async () => {
+    const limit = await freshLimiter()
+
+    const resets = '/api/public/auth/request-password-reset'
+    for (let n = 1; n <= 5; n++) expect(limit(signIn(), resets)).toBeNull()
+    expect(limit(signIn(), resets)?.status).toBe(429)
+
+    // A different budget for a path that is not on the strict list.
+    const other = '/api/public/auth/get-session'
+    expect(limit(signIn(), other)).toBeNull()
+  })
+})

+ 14 - 0
src/__tests__/lib/whatsapp-adapters.test.ts

@@ -195,6 +195,20 @@ describe('meta webhook', () => {
     ])
   })
 
+  it('refuses a delivery it cannot verify because no app secret is stored', async () => {
+    // The URL names only the workshop's id, which every share link carries,
+    // so a delivery that cannot be checked is not read, however it is signed.
+    const { appSecret: _omitted, ...withoutSecret } = metaContext.credentials
+    const unguarded: WhatsappContext = { ...metaContext, credentials: withoutSecret }
+
+    await expect(metaAdapter.receive(signed(inboundBody), unguarded)).rejects.toThrow(/app secret/i)
+    const unsigned = new Request('https://app.test/api/webhooks/whatsapp/meta/org_1', {
+      method: 'POST',
+      body: inboundBody,
+    })
+    await expect(metaAdapter.receive(unsigned, unguarded)).rejects.toThrow(/app secret/i)
+  })
+
   it('echoes the challenge only when the verify token matches', async () => {
     const url = 'https://app.test/api/webhooks/whatsapp/meta/org_1'
     const ok = await metaAdapter.verify?.(

+ 7 - 1
src/app/(public)/auth/sign-in/sign-in-form.tsx

@@ -38,7 +38,13 @@ function SignInFormInner({
   const [email, setEmail] = useState('')
   const [password, setPassword] = useState('')
   // Better Auth sends a failed Google round-trip back here with ?error=...
-  const [error, setError] = useState(() => (searchParams.get('error') ? tSocial('failed') : ''))
+  // One of them deserves its own words: the address already has a password
+  // account that was never verified, so Google was not joined to it.
+  const [error, setError] = useState(() => {
+    const code = searchParams.get('error')
+    if (!code) return ''
+    return code === 'account_not_linked' ? tSocial('notLinked') : tSocial('failed')
+  })
   const [loading, setLoading] = useState(false)
   const [passkeyLoading, setPasskeyLoading] = useState(false)
   const [googleLoading, setGoogleLoading] = useState(false)

+ 2 - 17
src/app/api/public/auth/[...all]/route.ts

@@ -1,7 +1,7 @@
 import { NextResponse } from 'next/server'
 import { auth } from '@/lib/auth'
 import { isDemoMode } from '@/lib/demo'
-import { rateLimit } from '@/lib/rate-limit'
+import { limitAuthRequest } from '@/lib/auth-rate-limit'
 import { toNextJsHandler } from 'better-auth/next-js'
 import { db } from '@/lib/db'
 import { logAudit } from '@/lib/audit'
@@ -9,18 +9,6 @@ import { explainInvalidOrigin } from '@/lib/auth-origin-hint'
 
 const { POST: authPOST, GET } = toNextJsHandler(auth)
 
-// Path prefixes that need stricter rate limits.
-// Better-auth registers sub-paths like /sign-in/email, /sign-up/email,
-// /two-factor/verify-totp, etc., so we match by prefix.
-const strictPrefixes: { prefix: string; limit: number; windowMs: number }[] = [
-  { prefix: '/api/public/auth/sign-in', limit: 10, windowMs: 60_000 },
-  { prefix: '/api/public/auth/two-factor/verify', limit: 10, windowMs: 60_000 },
-  { prefix: '/api/public/auth/sign-up', limit: 5, windowMs: 60_000 },
-  { prefix: '/api/public/auth/request-password-reset', limit: 5, windowMs: 60_000 },
-  { prefix: '/api/public/auth/reset-password', limit: 5, windowMs: 60_000 },
-  { prefix: '/api/public/auth/passkey', limit: 10, windowMs: 60_000 },
-]
-
 const authAuditPrefixes = [
   '/api/public/auth/sign-in',
   '/api/public/auth/two-factor/verify',
@@ -38,8 +26,6 @@ const demoBlockedPrefixes = [
   '/api/public/auth/passkey',
 ]
 
-const defaultConfig = { limit: 30, windowMs: 60_000 }
-
 function getRequestIp(request: Request): string | null {
   // Same precedence as lib/rate-limit.ts: Cloudflare's header cannot be forged
   // by clients on proxied traffic; the first x-forwarded-for entry can.
@@ -68,8 +54,7 @@ async function POST(request: Request) {
   // page more often still, each load a passkey probe on the same prefix; its
   // server runs with the limiter off. Nothing else sets this variable.
   if (process.env.AUTH_RATE_LIMIT !== 'off') {
-    const config = strictPrefixes.find((p) => pathname.startsWith(p.prefix)) ?? defaultConfig
-    const limited = rateLimit(request, config)
+    const limited = limitAuthRequest(request, pathname)
     if (limited) return limited
   }
 

+ 5 - 2
src/features/team/Actions/assignRole.ts

@@ -3,6 +3,7 @@
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import { assignRoleSchema } from '../Schema/teamSchema'
+import { canAssignRole } from '../Lib/invitationRules'
 import { revalidatePath } from 'next/cache'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { setTechnicianStanding } from '../Lib/technicianStanding'
@@ -14,7 +15,7 @@ import {
 
 export async function assignRole(input: unknown) {
   return withAuth(
-    async ({ organizationId, isAdmin }) => {
+    async ({ userId, organizationId, role: callerRole, isAdmin }) => {
       if (!isAdmin) {
         throw new Error('Only owners and admins can assign roles')
       }
@@ -24,7 +25,9 @@ export async function assignRole(input: unknown) {
         where: { id: data.memberId, organizationId },
       })
       if (!member) throw new Error('Member not found')
-      if (member.role === 'owner') throw new Error('Cannot assign a role to the owner')
+
+      const decision = canAssignRole({ role: callerRole, isAdmin, userId }, member, data)
+      if (!decision.ok) throw new Error(decision.reason)
 
       if (data.roleId) {
         const roleExists = await db.role.findFirst({

+ 32 - 0
src/features/team/Lib/invitationRules.ts

@@ -27,6 +27,38 @@ export function canInvite(caller: InviteCaller, requestedRole: InvitableRole): I
   return { ok: true }
 }
 
+/**
+ * Whether `caller` may change what `target` is, to `requested`.
+ *
+ * The same question as inviting, asked of a person already on the team.
+ * Handing out the built-in admin role is the owner's alone, on both paths:
+ * `updateMemberRole` refuses anybody but the owner, and `assignRole` used to
+ * ask only for admin standing, so a custom role with the admin switch could
+ * make itself, or anyone, a built-in admin and from there edit roles and
+ * remove members. Taking admin away is the owner's call for the same reason:
+ * an admin must not be able to demote a peer.
+ */
+export function canAssignRole(
+  caller: InviteCaller & { userId: string },
+  target: { userId: string; role: string },
+  requested: { role?: string }
+): InviteDecision {
+  if (!caller.isAdmin) {
+    return { ok: false, reason: 'Only owners and admins can assign roles' }
+  }
+  if (target.role === 'owner') {
+    return { ok: false, reason: 'Cannot assign a role to the owner' }
+  }
+  if (target.userId === caller.userId) {
+    return { ok: false, reason: 'You cannot change your own role' }
+  }
+  const touchesAdmin = requested.role === 'admin' || target.role === 'admin'
+  if (touchesAdmin && !CAN_GRANT_ADMIN.has(caller.role)) {
+    return { ok: false, reason: 'Only the owner can change who is an admin' }
+  }
+  return { ok: true }
+}
+
 /**
  * What the team page gets to see about a pending invitation. The token is
  * deliberately absent: it is the credential that lets whoever holds it join

+ 19 - 9
src/features/vehicles/Lib/createDraftRecord.ts

@@ -76,6 +76,25 @@ export async function createDraftRecord(
 
   // Resolve technician: explicit param > default setting by ID > legacy default by name
   let resolvedTechId = opts.technicianId
+  if (resolvedTechId) {
+    // Named by the caller, so it has to be one of this workshop's. Before
+    // this, a lookup that found nothing still wrote the id, and the row
+    // then pointed at another workshop's technician: their board showed the
+    // job, and renaming their technician rewrote its name.
+    const own = await db.technician.findFirst({
+      where: { id: resolvedTechId, organizationId },
+      select: { name: true },
+    })
+    if (!own) throw new Error('Technician not found')
+    techName = own.name
+  }
+  if (opts.workBayId) {
+    const bay = await db.workBay.findFirst({
+      where: { id: opts.workBayId, organizationId },
+      select: { id: true },
+    })
+    if (!bay) throw new Error('Work bay not found')
+  }
   if (!resolvedTechId) {
     const defaultId = settingsMap['workshop.defaultTechnicianId']
     if (defaultId) {
@@ -101,15 +120,6 @@ export async function createDraftRecord(
     }
   }
 
-  // If a technician is resolved (explicit or default), use their name
-  if (resolvedTechId) {
-    const tech = await db.technician.findFirst({
-      where: { id: resolvedTechId, organizationId },
-      select: { name: true },
-    })
-    if (tech) techName = tech.name
-  }
-
   const rawPrefix = settingsMap['workshop.invoicePrefix'] ?? '{year}-'
   const now = new Date()
   const today = zonedParts(now, timeZone)

+ 3 - 1
src/integrations/messaging/catalog.ts

@@ -240,8 +240,10 @@ const WHATSAPP: MessagingProvider[] = [
       }),
       secret('accessToken', whatsappCredentialKey('meta', 'accessToken')),
       text('verifyToken', whatsappCredentialKey('meta', 'verifyToken')),
+      // Required on the form: without it nothing Meta posts can be verified,
+      // so the webhook refuses every delivery. The adapter itself still sends
+      // without one, for a connection made before the form asked.
       secret('appSecret', whatsappCredentialKey('meta', 'appSecret'), {
-        required: false,
         help: 'appSecretHelp',
       }),
       text('apiVersion', whatsappCredentialKey('meta', 'apiVersion'), { required: false }),

+ 33 - 0
src/lib/auth-rate-limit.ts

@@ -0,0 +1,33 @@
+import { rateLimit } from '@/lib/rate-limit'
+
+/**
+ * How often one caller may knock on the sign-in door.
+ *
+ * Better Auth registers sub-paths such as /sign-in/email, /sign-up/email and
+ * /two-factor/verify-totp, so the budgets are matched by prefix. Everything
+ * else on the auth route shares one looser budget.
+ */
+const strictPrefixes: { prefix: string; limit: number; windowMs: number }[] = [
+  { prefix: '/api/public/auth/sign-in', limit: 10, windowMs: 60_000 },
+  { prefix: '/api/public/auth/two-factor/verify', limit: 10, windowMs: 60_000 },
+  { prefix: '/api/public/auth/sign-up', limit: 5, windowMs: 60_000 },
+  { prefix: '/api/public/auth/request-password-reset', limit: 5, windowMs: 60_000 },
+  { prefix: '/api/public/auth/reset-password', limit: 5, windowMs: 60_000 },
+  { prefix: '/api/public/auth/passkey', limit: 10, windowMs: 60_000 },
+]
+
+const defaultConfig = { limit: 30, windowMs: 60_000 }
+
+/**
+ * A 429 when this caller has used up the budget for `pathname`, else null.
+ *
+ * Nobody arrives here with a session: this is where sessions are made. So the
+ * caller is always counted by address (`anonymous`), never by whatever they
+ * put in an Authorization header. Without that, the limiter took any bearer
+ * value as an identity of its own, and a fresh made-up token on every request
+ * was a fresh budget for guessing passwords, reset tokens and 2FA codes.
+ */
+export function limitAuthRequest(request: Request, pathname: string): Response | null {
+  const config = strictPrefixes.find((p) => pathname.startsWith(p.prefix)) ?? defaultConfig
+  return rateLimit(request, { ...config, anonymous: true })
+}

+ 14 - 9
src/lib/auth.ts

@@ -151,19 +151,24 @@ export const auth = betterAuth({
       enabled: true,
       // A Google sign-in whose email matches a password account attaches to
       // that account rather than creating a second person with the same
-      // email, but only when Google says it has verified the address. Google
-      // is deliberately not a trusted provider: better-auth links a trusted
+      // email, but only when both sides have proved the address. Google is
+      // deliberately not a trusted provider: better-auth links a trusted
       // provider's account without looking at email_verified at all, and
       // anyone can create a Google account with somebody else's address on
       // it. Trusted, that signed a stranger into the workshop that owns the
-      // address. e2e/specs/cloud/google-sign-in.spec.ts holds both halves.
+      // address.
       //
-      // The local account may predate email verification and never have
-      // clicked the link. Google's verification of the same address is the
-      // stronger proof, the same proof a password reset mail would rest on,
-      // so it must not block the link. Better Auth marks the local email
-      // verified as part of linking.
-      requireLocalEmailVerified: false,
+      // The local account has to be verified too. Google's word covers the
+      // person now signing in; it says nothing about who made the password
+      // account. Left unverified, that account can be anyone's: sign up with
+      // a stranger's address and a password of your own, and when they later
+      // press "Continue with Google" they are signed into your account and
+      // build their workshop behind a password you hold. So an unverified
+      // password account is not joined; the person is sent back to sign-in
+      // with a message (see sign-in-form.tsx) and gets in with the password,
+      // where verifying the address makes the Google route open up.
+      // e2e/specs/cloud/google-sign-in.spec.ts holds all three halves.
+      requireLocalEmailVerified: true,
     },
   },
   emailAndPassword: {

+ 15 - 7
src/lib/whatsapp/adapters/meta.ts

@@ -148,7 +148,10 @@ export const metaAdapter: WhatsappAdapter = {
       key: 'appSecret',
       label: 'App secret',
       secret: true,
-      help: 'Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended.',
+      // Not `required`: sending needs only the token, and a connection made
+      // before the secret was asked for keeps sending. Receiving is another
+      // matter, see receive().
+      help: 'Meta signs every delivery with it. Until it is entered, messages posted to the webhook are refused, since anyone who learns the URL could otherwise post as your customers.',
     },
     {
       key: 'apiVersion',
@@ -200,13 +203,18 @@ export const metaAdapter: WhatsappAdapter = {
     const raw = await request.text()
 
     // Meta signs every delivery. Checking it is the only thing standing
-    // between the webhook and anyone who learns the URL.
+    // between the webhook and anyone who learns the URL, and the URL names
+    // nothing but the workshop's id, which every share link carries. So a
+    // delivery that cannot be checked is not read: it used to be, whenever
+    // the workshop had left the app secret blank, and a made-up message
+    // then landed in the inbox as if a customer had written it.
     const appSecret = ctx.credentials.appSecret
-    if (appSecret) {
-      const signature = request.headers.get('x-hub-signature-256')
-      if (!isSignatureValid(raw, signature, appSecret)) {
-        throw new Error('Invalid webhook signature')
-      }
+    if (!appSecret) {
+      throw new Error('App secret is not set, so the delivery could not be verified')
+    }
+    const signature = request.headers.get('x-hub-signature-256')
+    if (!isSignatureValid(raw, signature, appSecret)) {
+      throw new Error('Invalid webhook signature')
     }
 
     const payload = JSON.parse(raw) as {