Просмотр исходного кода

Name the role a person is given in the audit log (#539)

Bernt Christian Egeland 1 день назад
Родитель
Сommit
a301a7ee47

+ 18 - 3
src/__tests__/features/team/invitation-rules.test.ts

@@ -210,7 +210,12 @@ describe('inviteMember agrees with sendInvitation', () => {
     const known = await inviteMember({ email: 'new@example.com', role: 'member' })
 
     expect(unknown).toEqual(known)
-    expect(unknown.data).toEqual({ invited: true, email: 'new@example.com', role: 'member' })
+    expect(unknown.data).toEqual({
+      invited: true,
+      email: 'new@example.com',
+      role: 'member',
+      roleLabel: 'member',
+    })
     // The unknown address got an invitation mail; the known one joined directly.
     expect(db.teamInvitation.create).toHaveBeenCalledTimes(1)
     expect(db.organizationMember.create).toHaveBeenCalledTimes(1)
@@ -228,7 +233,12 @@ describe('inviteMember agrees with sendInvitation', () => {
     } as any)
     const result = await inviteMember({ email: 'anna@garage.test', role: 'member' })
 
-    expect(result.data).toEqual({ invited: true, email: 'anna@garage.test', role: 'member' })
+    expect(result.data).toEqual({
+      invited: true,
+      email: 'anna@garage.test',
+      role: 'member',
+      roleLabel: 'member',
+    })
     expect(db.organizationMember.create).not.toHaveBeenCalled()
     expect(db.teamInvitation.create).toHaveBeenCalledTimes(1)
   })
@@ -246,7 +256,12 @@ describe('inviteMember agrees with sendInvitation', () => {
       .mockResolvedValueOnce(null)
     const result = await inviteMember({ email: 'anna@garage.test', role: 'member' })
 
-    expect(result.data).toEqual({ invited: true, email: 'anna@garage.test', role: 'member' })
+    expect(result.data).toEqual({
+      invited: true,
+      email: 'anna@garage.test',
+      role: 'member',
+      roleLabel: 'member',
+    })
     expect(db.organizationMember.create).toHaveBeenCalledTimes(1)
     expect(db.teamInvitation.create).not.toHaveBeenCalled()
   })

+ 190 - 0
src/__tests__/features/team/role-audit.test.ts

@@ -0,0 +1,190 @@
+/**
+ * The audit log says which role a person was given. An invitation with a
+ * custom role used to read "Invited maya@example.com as member", and changing
+ * someone's role on the team page wrote nothing at all.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// The audit line is rendered with next-intl's own translator, which the global mock leaves out.
+vi.unmock('next-intl')
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('next/headers', () => ({
+  headers: vi.fn().mockResolvedValue(new Headers()),
+  cookies: vi.fn().mockResolvedValue({ set: vi.fn(), get: vi.fn() }),
+}))
+vi.mock('@/features/email/Lib/sendTemplatedMail', () => ({
+  sendTemplatedMail: vi.fn().mockResolvedValue({ subject: 'Team invitation' }),
+}))
+vi.mock('@/features/webhooks/Lib/dispatcher', () => ({ dispatchWebhookEvent: vi.fn() }))
+vi.mock('@/features/integrations/Lib/events', () => ({ notifyIntegrations: vi.fn() }))
+vi.mock('@/features/team/Lib/memberSeats', () => ({
+  memberSeats: vi.fn().mockResolvedValue({ full: false, limit: 5 }),
+}))
+vi.mock('@/lib/email-verification-policy', () => ({
+  emailVerificationRequired: vi.fn().mockResolvedValue(false),
+}))
+vi.mock('@/features/team/Lib/technicianStanding', () => ({
+  setTechnicianStanding: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn(), findFirst: vi.fn() },
+    auditLog: { create: vi.fn().mockResolvedValue({}) },
+    teamInvitation: {
+      findFirst: vi.fn(),
+      deleteMany: vi.fn(),
+      create: vi.fn(),
+      delete: vi.fn(),
+    },
+    organizationMember: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
+    role: { findFirst: vi.fn() },
+  },
+}))
+
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { assignRole } from '@/features/team/Actions/assignRole'
+import { inviteMember } from '@/features/team/Actions/teamActions'
+import { sendInvitation } from '@/features/team/Actions/sendInvitation'
+
+const ORG = 'org-a'
+const FRONT_DESK = 'role-front-desk'
+
+/** The English line the audit log shows for the last entry written. */
+async function lastAuditLine() {
+  await vi.waitFor(() => expect(db.auditLog.create).toHaveBeenCalled())
+  const calls = vi.mocked(db.auditLog.create).mock.calls
+  return calls[calls.length - 1][0].data.message
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'owner-1', email: 'owner@example.com' },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never)
+  // Only this workshop's Front desk role exists; any other id is someone else's.
+  vi.mocked(db.role.findFirst).mockImplementation((async (args: {
+    where: { id?: string; organizationId: string }
+  }) =>
+    args.where.id === FRONT_DESK && args.where.organizationId === ORG
+      ? { id: FRONT_DESK, name: 'Front desk' }
+      : null) as never)
+  vi.mocked(db.teamInvitation.findFirst).mockResolvedValue(null)
+  vi.mocked(db.teamInvitation.create).mockResolvedValue({ id: 'inv-1' } as never)
+})
+
+describe('invitations', () => {
+  function asWorkshopOwner() {
+    vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
+      userId: 'owner-1',
+      organizationId: ORG,
+      role: 'owner',
+      organization: { name: 'Riverside Auto' },
+    } as never)
+  }
+
+  it('names the custom role an invitation gives', async () => {
+    asWorkshopOwner()
+    const result = await sendInvitation({
+      email: 'maya@example.com',
+      role: 'member',
+      roleId: FRONT_DESK,
+    })
+    expect(result.success).toBe(true)
+    expect(await lastAuditLine()).toBe('Invited maya@example.com as Front desk')
+  })
+
+  it('names the custom role when the add-person dialog invites by email', async () => {
+    asWorkshopOwner()
+    vi.mocked(db.user.findFirst).mockResolvedValue(null)
+    const result = await inviteMember({
+      email: 'maya@example.com',
+      role: 'member',
+      roleId: FRONT_DESK,
+    })
+    expect(result.success).toBe(true)
+    expect(await lastAuditLine()).toBe('Invited maya@example.com as Front desk')
+  })
+
+  it('still says member when no custom role was picked', async () => {
+    asWorkshopOwner()
+    await sendInvitation({ email: 'maya@example.com', role: 'member' })
+    expect(await lastAuditLine()).toBe('Invited maya@example.com as member')
+  })
+
+  it('names the role when an existing account joins on the spot', async () => {
+    vi.mocked(db.organizationMember.findFirst)
+      .mockResolvedValueOnce({
+        userId: 'owner-1',
+        organizationId: ORG,
+        role: 'owner',
+        organization: { name: 'Riverside Auto' },
+      } as never)
+      .mockResolvedValueOnce(null)
+    vi.mocked(db.user.findFirst).mockResolvedValue({ id: 'maya', emailVerified: true } as never)
+    const result = await inviteMember({
+      email: 'maya@example.com',
+      role: 'member',
+      roleId: FRONT_DESK,
+    })
+    expect(result.success).toBe(true)
+    expect(db.organizationMember.create).toHaveBeenCalled()
+    expect(await lastAuditLine()).toBe('Invited maya@example.com as Front desk')
+  })
+
+  it('only gives a role from this workshop when an existing account joins on the spot', async () => {
+    vi.mocked(db.organizationMember.findFirst)
+      .mockResolvedValueOnce({
+        userId: 'owner-1',
+        organizationId: ORG,
+        role: 'owner',
+        organization: { name: 'Riverside Auto' },
+      } as never)
+      .mockResolvedValueOnce(null)
+    vi.mocked(db.user.findFirst).mockResolvedValue({ id: 'maya', emailVerified: true } as never)
+    const result = await inviteMember({
+      email: 'maya@example.com',
+      role: 'member',
+      roleId: 'role-of-another-workshop',
+    })
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Role not found')
+    expect(db.organizationMember.create).not.toHaveBeenCalled()
+  })
+})
+
+describe('changing a role', () => {
+  beforeEach(() => {
+    vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
+      id: 'member-maya',
+      userId: 'maya',
+      organizationId: ORG,
+      role: 'member',
+      roleId: null,
+    } as never)
+  })
+
+  it('logs the custom role a member is given', async () => {
+    const result = await assignRole({ memberId: 'member-maya', role: 'member', roleId: FRONT_DESK })
+    expect(result.success).toBe(true)
+    expect(await lastAuditLine()).toBe("Changed a member's role to Front desk")
+  })
+
+  it('logs a built-in role too', async () => {
+    const result = await assignRole({ memberId: 'member-maya', role: 'admin', roleId: null })
+    expect(result.success).toBe(true)
+    expect(await lastAuditLine()).toBe("Changed a member's role to admin")
+  })
+})

+ 16 - 7
src/features/team/Actions/assignRole.ts

@@ -7,6 +7,7 @@ import { canAssignRole } from '../Lib/invitationRules'
 import { revalidatePath } from 'next/cache'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { setTechnicianStanding } from '../Lib/technicianStanding'
+import { customRoleName } from '../Lib/customRoleName'
 import {
   ensureTechnicianRole,
   TECHNICIAN_ROLE_NAME,
@@ -29,12 +30,7 @@ export async function assignRole(input: unknown) {
       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({
-          where: { id: data.roleId, organizationId },
-        })
-        if (!roleExists) throw new Error('Role not found')
-      }
+      const roleName = await customRoleName(organizationId, data.roleId)
 
       /**
        * Technician is one answer to one question, not two settings to keep in
@@ -87,12 +83,25 @@ export async function assignRole(input: unknown) {
 
       revalidatePath('/settings/team')
       revalidatePath('/work-board')
-      return { assigned: true, isTechnician: asTechnician, technicianId: technician }
+      return {
+        assigned: true,
+        isTechnician: asTechnician,
+        technicianId: technician,
+        memberId: member.id,
+        roleLabel: byWord ? TECHNICIAN_ROLE_NAME : (roleName ?? data.role ?? member.role),
+      }
     },
     {
       requiredPermissions: [
         { action: PermissionAction.MANAGE, subject: PermissionSubject.SETTINGS },
       ],
+      audit: ({ result }) => ({
+        action: 'team.updateRole',
+        entity: 'OrganizationMember',
+        entityId: result.memberId,
+        details: { key: 'team_updateRole', params: { role: result.roleLabel } },
+        metadata: { memberId: result.memberId, role: result.roleLabel },
+      }),
     }
   )
 }

+ 6 - 3
src/features/team/Actions/sendInvitation.ts

@@ -27,7 +27,7 @@ export async function sendInvitation(input: unknown) {
       })
       if (!membership) throw new Error("You don't belong to an organization")
 
-      await createAndSendInvitation({
+      const { roleLabel } = await createAndSendInvitation({
         organizationId,
         organizationName: membership.organization.name,
         invitedById: userId,
@@ -37,7 +37,7 @@ export async function sendInvitation(input: unknown) {
       })
 
       revalidatePath('/settings/team')
-      return { invited: true, pending: true, email: data.email, role: data.role }
+      return { invited: true, pending: true, email: data.email, role: data.role, roleLabel }
     },
     {
       requiredPermissions: [
@@ -46,7 +46,10 @@ export async function sendInvitation(input: unknown) {
       audit: ({ result }) => ({
         action: 'team.sendInvitation',
         entity: 'TeamInvitation',
-        details: { key: 'team_sendInvitation', params: { email: result.email, role: result.role } },
+        details: {
+          key: 'team_sendInvitation',
+          params: { email: result.email, role: result.roleLabel },
+        },
         metadata: { email: result.email, role: result.role },
       }),
     }

+ 9 - 4
src/features/team/Actions/teamActions.ts

@@ -24,6 +24,7 @@ import {
 import { demoGuard } from '@/lib/demo'
 import { canInvite } from '../Lib/invitationRules'
 import { createAndSendInvitation } from '../Lib/createInvitation'
+import { customRoleName } from '../Lib/customRoleName'
 import { memberSeats } from '../Lib/memberSeats'
 
 export async function getOrganization() {
@@ -196,7 +197,7 @@ export async function inviteMember(input: unknown) {
       const seatsDirectly =
         !!invitedUser && (invitedUser.emailVerified || !(await emailVerificationRequired()))
       if (!seatsDirectly) {
-        await createAndSendInvitation({
+        const { roleLabel } = await createAndSendInvitation({
           organizationId,
           organizationName: membership.organization.name,
           invitedById: userId,
@@ -205,7 +206,7 @@ export async function inviteMember(input: unknown) {
           roleId: data.roleId,
         })
         revalidatePath('/settings/team')
-        return { invited: true, email: data.email, role: data.role }
+        return { invited: true, email: data.email, role: data.role, roleLabel }
       }
 
       const existingMembership = await db.organizationMember.findFirst({
@@ -214,6 +215,7 @@ export async function inviteMember(input: unknown) {
       if (existingMembership) {
         throw new Error('This person is already on the team')
       }
+      const roleLabel = (await customRoleName(organizationId, data.roleId)) ?? data.role
 
       await db.organizationMember.create({
         data: {
@@ -225,7 +227,7 @@ export async function inviteMember(input: unknown) {
       })
 
       revalidatePath('/settings/team')
-      return { invited: true, email: data.email, role: data.role }
+      return { invited: true, email: data.email, role: data.role, roleLabel }
     },
     {
       requiredPermissions: [
@@ -234,7 +236,10 @@ export async function inviteMember(input: unknown) {
       audit: ({ result }) => ({
         action: 'team.invite',
         entity: 'OrganizationMember',
-        message: `Invited ${result.email} as ${result.role}`,
+        details: {
+          key: 'team_sendInvitation',
+          params: { email: result.email, role: result.roleLabel },
+        },
         metadata: { email: result.email, role: result.role },
       }),
     }

+ 4 - 12
src/features/team/Lib/createInvitation.ts

@@ -1,6 +1,7 @@
 import { randomUUID } from 'crypto'
 import { sendTemplatedMail } from '@/features/email/Lib/sendTemplatedMail'
 import { db } from '@/lib/db'
+import { customRoleName } from './customRoleName'
 import type { InvitableRole } from './invitationRules'
 
 export type CreateInvitationInput = {
@@ -35,16 +36,7 @@ export async function createAndSendInvitation(input: CreateInvitationInput) {
     where: { email, organizationId, status: { not: 'pending' } },
   })
 
-  // Validate and look up the custom role name if roleId is provided.
-  let customRoleName: string | undefined
-  if (roleId) {
-    const customRole = await db.role.findFirst({
-      where: { id: roleId, organizationId },
-      select: { name: true },
-    })
-    if (!customRole) throw new Error('Role not found')
-    customRoleName = customRole.name
-  }
+  const roleLabel = (await customRoleName(organizationId, roleId)) ?? role
 
   const token = randomUUID()
   const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
@@ -66,7 +58,7 @@ export async function createAndSendInvitation(input: CreateInvitationInput) {
       organizationId,
       organizationName,
       email,
-      roleLabel: customRoleName || role,
+      roleLabel,
       token,
       invitedById,
       expiresAt,
@@ -77,7 +69,7 @@ export async function createAndSendInvitation(input: CreateInvitationInput) {
     throw new Error('Failed to send invitation email. Please try again.')
   }
 
-  return invitation
+  return { invitation, roleLabel }
 }
 
 export type InvitationMailInput = {

+ 19 - 0
src/features/team/Lib/customRoleName.ts

@@ -0,0 +1,19 @@
+import { db } from '@/lib/db'
+
+/**
+ * The name of the custom role picked for someone, or undefined when none was.
+ *
+ * The id comes from the browser and can belong to another workshop, so it is
+ * looked up within this one and refused when it is not there. The name is
+ * what the audit log and the invitation mail show, since "member" says
+ * nothing about what a Front desk role lets a person do.
+ */
+export async function customRoleName(organizationId: string, roleId?: string | null) {
+  if (!roleId) return undefined
+  const role = await db.role.findFirst({
+    where: { id: roleId, organizationId },
+    select: { name: true },
+  })
+  if (!role) throw new Error('Role not found')
+  return role.name
+}