From f509296e27942ffc429f2142b502b477c6d4ce36 Mon Sep 17 00:00:00 2001 From: grootbro Date: Mon, 7 Sep 2026 18:12:28 +0700 Subject: [PATCH 1/4] feat(org): let admins change member roles from the Members page Adds updateMemberRole with last-admin protection and role controls in the Edit member modal so org:admin / org:member is no longer display-only. --- .../components/settings/members/columns.tsx | 2 +- apps/start/src/modals/edit-member.tsx | 100 +++++++--- .../trpc/src/routers/organization.test.ts | 187 ++++++++++++++++++ packages/trpc/src/routers/organization.ts | 53 +++++ packages/validation/src/index.ts | 6 + 5 files changed, 324 insertions(+), 24 deletions(-) create mode 100644 packages/trpc/src/routers/organization.test.ts diff --git a/apps/start/src/components/settings/members/columns.tsx b/apps/start/src/components/settings/members/columns.tsx index 915725665..35fb264ec 100644 --- a/apps/start/src/components/settings/members/columns.tsx +++ b/apps/start/src/components/settings/members/columns.tsx @@ -112,7 +112,7 @@ export function useColumns() { pushModal('EditMember', row.original); }} > - Edit access + Edit member (toGrants); + const [role, setRole] = useState( + member.role === 'org:admin' ? 'org:admin' : 'org:member', + ); const projectsQuery = useQuery( trpc.project.list.queryOptions({ organizationId: member.organizationId }), ); - const mutation = useMutation( + const invalidateMembers = () => + queryClient.invalidateQueries(trpc.organization.members.pathFilter()); + + const updateAccess = useMutation( trpc.organization.updateMemberAccess.mutationOptions({ onError(error) { handleError(error); setAccess(toGrants()); }, - onSuccess() { - toast.success('Access updated'); - // Refresh members list so access column reflects changes - queryClient.invalidateQueries(trpc.organization.members.pathFilter()); - popModal(); + }), + ); + + const updateRole = useMutation( + trpc.organization.updateMemberRole.mutationOptions({ + onError(error) { + handleError(error); + setRole(member.role === 'org:admin' ? 'org:admin' : 'org:member'); }, }), ); const projects = projectsQuery.data ?? []; + const isPending = updateAccess.isPending || updateRole.isPending; + const roleChanged = role !== member.role; + + const memberName = member.user + ? [member.user.firstName, member.user.lastName].filter(Boolean).join(' ') + : null; + + const onSave = async () => { + if (!member.user) { + return; + } + + try { + if (roleChanged) { + await updateRole.mutateAsync({ + userId: member.user.id, + organizationId: member.organizationId, + role, + }); + } + + await updateAccess.mutateAsync({ + userId: member.user.id, + organizationId: member.organizationId, + access, + }); + + toast.success('Member updated'); + invalidateMembers(); + popModal(); + } catch { + // Errors are handled by each mutation's onError. + } + }; return (
+
+ + setRole(value as OrgRole)} + className="mt-2 flex gap-4" + > +
+ + +
+
+ + +
+
+
+ popModal()}> Cancel - diff --git a/packages/trpc/src/routers/organization.test.ts b/packages/trpc/src/routers/organization.test.ts new file mode 100644 index 000000000..dd29236d0 --- /dev/null +++ b/packages/trpc/src/routers/organization.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + dbMock, + getOrganizationAccessMock, + getOrganizationsMock, + getOrganizationByIdMock, + getMembersMock, + getInvitesMock, + getInviteByIdMock, + connectUserToOrganizationMock, +} = vi.hoisted(() => ({ + dbMock: { + member: { + findFirst: vi.fn(), + count: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + projectAccess: { + deleteMany: vi.fn(), + createMany: vi.fn(), + }, + organization: { + update: vi.fn(), + }, + project: { + updateMany: vi.fn(), + }, + invite: { + findFirst: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + findUniqueOrThrow: vi.fn(), + }, + user: { + findFirst: vi.fn(), + }, + $transaction: vi.fn((ops: unknown) => + Array.isArray(ops) ? Promise.all(ops) : ops, + ), + }, + getOrganizationAccessMock: vi.fn(), + getOrganizationsMock: vi.fn(), + getOrganizationByIdMock: vi.fn(), + getMembersMock: vi.fn(), + getInvitesMock: vi.fn(), + getInviteByIdMock: vi.fn(), + connectUserToOrganizationMock: vi.fn(), +})); + +vi.mock('@openpanel/db', () => ({ + db: dbMock, + getOrganizationAccess: getOrganizationAccessMock, + getOrganizations: getOrganizationsMock, + getOrganizationById: getOrganizationByIdMock, + getMembers: getMembersMock, + getInvites: getInvitesMock, + getInviteById: getInviteByIdMock, + connectUserToOrganization: connectUserToOrganizationMock, + getProjectAccess: vi.fn(), + getClientAccess: vi.fn(), + getProjectById: vi.fn(), + canWriteProject: vi.fn(), + runWithAlsSession: (_sessionId: string | null, fn: () => unknown) => fn(), +})); + +vi.mock('@openpanel/email', () => ({ + sendEmail: vi.fn(), +})); + +vi.mock('@openpanel/common/server', () => ({ + generateSecureId: vi.fn(() => 'invite_test'), +})); + +const { organizationRouter } = await import('./organization'); + +const ORG_ID = 'org-1'; +const ADMIN_ID = 'admin-1'; +const MEMBER_USER_ID = 'user-2'; +const MEMBER_ROW = { + id: 'member-row-2', + userId: MEMBER_USER_ID, + organizationId: ORG_ID, + role: 'org:member' as const, + email: 'member@example.com', +}; + +const adminCaller = () => + organizationRouter.createCaller({ + session: { userId: ADMIN_ID, session: { id: 'session-1' } }, + req: { log: { info: vi.fn(), error: vi.fn() } }, + res: {}, + setCookie: vi.fn(), + cookies: {}, + } as never); + +beforeEach(() => { + vi.clearAllMocks(); + getOrganizationAccessMock.mockResolvedValue({ role: 'org:admin' }); +}); + +describe('organization.updateMemberRole', () => { + it('promotes a member to org:admin', async () => { + dbMock.member.findFirst.mockResolvedValue(MEMBER_ROW); + dbMock.member.update.mockResolvedValue({ + ...MEMBER_ROW, + role: 'org:admin', + }); + + const result = await adminCaller().updateMemberRole({ + organizationId: ORG_ID, + userId: MEMBER_USER_ID, + role: 'org:admin', + }); + + expect(dbMock.member.update).toHaveBeenCalledWith({ + where: { id: MEMBER_ROW.id }, + data: { role: 'org:admin' }, + }); + expect(result.role).toBe('org:admin'); + }); + + it('rejects a plain org member from changing roles', async () => { + getOrganizationAccessMock.mockResolvedValue({ role: 'org:member' }); + + await expect( + adminCaller().updateMemberRole({ + organizationId: ORG_ID, + userId: MEMBER_USER_ID, + role: 'org:admin', + }), + ).rejects.toThrow('You do not have access'); + }); + + it('rejects updating your own role', async () => { + await expect( + adminCaller().updateMemberRole({ + organizationId: ORG_ID, + userId: ADMIN_ID, + role: 'org:member', + }), + ).rejects.toThrow('You cannot update your own role'); + }); + + it('refuses to demote the last organization admin', async () => { + dbMock.member.findFirst.mockResolvedValue({ + ...MEMBER_ROW, + userId: 'other-admin', + role: 'org:admin', + }); + dbMock.member.count.mockResolvedValue(1); + + await expect( + adminCaller().updateMemberRole({ + organizationId: ORG_ID, + userId: 'other-admin', + role: 'org:member', + }), + ).rejects.toThrow('last organization admin'); + + expect(dbMock.member.update).not.toHaveBeenCalled(); + }); + + it('demotes an admin when another admin remains', async () => { + dbMock.member.findFirst.mockResolvedValue({ + ...MEMBER_ROW, + userId: 'other-admin', + role: 'org:admin', + }); + dbMock.member.count.mockResolvedValue(2); + dbMock.member.update.mockResolvedValue({ + ...MEMBER_ROW, + userId: 'other-admin', + role: 'org:member', + }); + + const result = await adminCaller().updateMemberRole({ + organizationId: ORG_ID, + userId: 'other-admin', + role: 'org:member', + }); + + expect(result.role).toBe('org:member'); + expect(dbMock.member.update).toHaveBeenCalled(); + }); +}); diff --git a/packages/trpc/src/routers/organization.ts b/packages/trpc/src/routers/organization.ts index 806f3f447..ade5f9010 100644 --- a/packages/trpc/src/routers/organization.ts +++ b/packages/trpc/src/routers/organization.ts @@ -13,6 +13,7 @@ import { zEditOrganization, zInviteUser, zUpdateMemberAccess, + zUpdateMemberRole, } from '@openpanel/validation'; import { generateSecureId } from '@openpanel/common/server'; @@ -364,6 +365,58 @@ export const organizationRouter = createTRPCRouter({ ]); }), + updateMemberRole: protectedProcedure + .input(zUpdateMemberRole) + .mutation(async ({ input, ctx }) => { + if (input.userId === ctx.session.userId) { + throw new TRPCForbiddenError('You cannot update your own role'); + } + + const access = await getOrganizationAccess({ + userId: ctx.session.userId, + organizationId: input.organizationId, + }); + + if (access?.role !== 'org:admin') { + throw new TRPCForbiddenError('You do not have access to this project'); + } + + const member = await db.member.findFirst({ + where: { + userId: input.userId, + organizationId: input.organizationId, + }, + }); + + if (!member) { + throw new TRPCBadRequestError('Member not found'); + } + + if (member.role === 'org:admin' && input.role === 'org:member') { + const adminCount = await db.member.count({ + where: { + organizationId: input.organizationId, + role: 'org:admin', + }, + }); + + if (adminCount <= 1) { + throw new TRPCBadRequestError( + 'Cannot demote the last organization admin', + ); + } + } + + return db.member.update({ + where: { + id: member.id, + }, + data: { + role: input.role, + }, + }); + }), + members: protectedProcedure .input(z.object({ organizationId: z.string() })) .query(async ({ input, ctx }) => { diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index c4aec23fa..8ac4c895d 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -347,6 +347,12 @@ export const zUpdateMemberAccess = z.object({ access: z.array(zProjectAccessGrant), }); +export const zUpdateMemberRole = z.object({ + userId: z.string(), + organizationId: z.string(), + role: z.enum(['org:admin', 'org:member']), +}); + export const zShareOverview = z.object({ organizationId: z.string(), projectId: z.string(), From d9c3f559277fdfd258a4bd8d536f25d5b7869c04 Mon Sep 17 00:00:00 2001 From: grootbro Date: Mon, 7 Sep 2026 21:13:22 +0700 Subject: [PATCH 2/4] fix(org): atomic member updates and clear access cache Combine role + project access in one mutation, run last-admin checks in a serializable transaction, and invalidate getOrganizationAccess after role changes. --- apps/start/src/modals/edit-member.tsx | 69 +++------ .../trpc/src/routers/organization.test.ts | 82 +++++++++- packages/trpc/src/routers/organization.ts | 144 +++++++++++++++--- packages/validation/src/index.ts | 7 + 4 files changed, 228 insertions(+), 74 deletions(-) diff --git a/apps/start/src/modals/edit-member.tsx b/apps/start/src/modals/edit-member.tsx index 153d4fdf1..9212f7f46 100644 --- a/apps/start/src/modals/edit-member.tsx +++ b/apps/start/src/modals/edit-member.tsx @@ -39,68 +39,30 @@ export default function EditMember(member: EditMemberProps) { trpc.project.list.queryOptions({ organizationId: member.organizationId }), ); - const invalidateMembers = () => - queryClient.invalidateQueries(trpc.organization.members.pathFilter()); - - const updateAccess = useMutation( - trpc.organization.updateMemberAccess.mutationOptions({ + const mutation = useMutation( + trpc.organization.updateMember.mutationOptions({ onError(error) { handleError(error); setAccess(toGrants()); - }, - }), - ); - - const updateRole = useMutation( - trpc.organization.updateMemberRole.mutationOptions({ - onError(error) { - handleError(error); setRole(member.role === 'org:admin' ? 'org:admin' : 'org:member'); }, + onSuccess() { + toast.success('Member updated'); + queryClient.invalidateQueries(trpc.organization.members.pathFilter()); + popModal(); + }, }), ); const projects = projectsQuery.data ?? []; - const isPending = updateAccess.isPending || updateRole.isPending; - const roleChanged = role !== member.role; const memberName = member.user ? [member.user.firstName, member.user.lastName].filter(Boolean).join(' ') : null; - const onSave = async () => { - if (!member.user) { - return; - } - - try { - if (roleChanged) { - await updateRole.mutateAsync({ - userId: member.user.id, - organizationId: member.organizationId, - role, - }); - } - - await updateAccess.mutateAsync({ - userId: member.user.id, - organizationId: member.organizationId, - access, - }); - - toast.success('Member updated'); - invalidateMembers(); - popModal(); - } catch { - // Errors are handled by each mutation's onError. - } - }; - return ( - +
@@ -135,7 +97,20 @@ export default function EditMember(member: EditMemberProps) { - diff --git a/packages/trpc/src/routers/organization.test.ts b/packages/trpc/src/routers/organization.test.ts index dd29236d0..f4408639c 100644 --- a/packages/trpc/src/routers/organization.test.ts +++ b/packages/trpc/src/routers/organization.test.ts @@ -36,9 +36,12 @@ const { user: { findFirst: vi.fn(), }, - $transaction: vi.fn((ops: unknown) => - Array.isArray(ops) ? Promise.all(ops) : ops, - ), + $transaction: vi.fn(async (ops: unknown, _opts?: unknown) => { + if (typeof ops === 'function') { + return (ops as (tx: typeof dbMock) => unknown)(dbMock); + } + return Array.isArray(ops) ? Promise.all(ops) : ops; + }), }, getOrganizationAccessMock: vi.fn(), getOrganizationsMock: vi.fn(), @@ -49,6 +52,8 @@ const { connectUserToOrganizationMock: vi.fn(), })); +getOrganizationAccessMock.clear = vi.fn().mockResolvedValue(1); + vi.mock('@openpanel/db', () => ({ db: dbMock, getOrganizationAccess: getOrganizationAccessMock, @@ -184,4 +189,75 @@ describe('organization.updateMemberRole', () => { expect(result.role).toBe('org:member'); expect(dbMock.member.update).toHaveBeenCalled(); }); + + it('clears cached organization access for the updated member', async () => { + dbMock.member.findFirst.mockResolvedValue(MEMBER_ROW); + dbMock.member.update.mockResolvedValue({ + ...MEMBER_ROW, + role: 'org:admin', + }); + + await adminCaller().updateMemberRole({ + organizationId: ORG_ID, + userId: MEMBER_USER_ID, + role: 'org:admin', + }); + + expect(getOrganizationAccessMock.clear).toHaveBeenCalledWith({ + userId: MEMBER_USER_ID, + organizationId: ORG_ID, + }); + }); + + it('runs the last-admin check and role update in one transaction', async () => { + dbMock.member.findFirst.mockResolvedValue({ + ...MEMBER_ROW, + userId: 'other-admin', + role: 'org:admin', + }); + dbMock.member.count.mockResolvedValue(2); + dbMock.member.update.mockResolvedValue({ + ...MEMBER_ROW, + userId: 'other-admin', + role: 'org:member', + }); + + await adminCaller().updateMemberRole({ + organizationId: ORG_ID, + userId: 'other-admin', + role: 'org:member', + }); + + expect(dbMock.$transaction).toHaveBeenCalled(); + const txArg = dbMock.$transaction.mock.calls[0]?.[0]; + expect(typeof txArg).toBe('function'); + }); +}); + +describe('organization.updateMember', () => { + it('updates role and project access in one transaction', async () => { + dbMock.member.findFirst.mockResolvedValue(MEMBER_ROW); + dbMock.member.update.mockResolvedValue({ + ...MEMBER_ROW, + role: 'org:admin', + }); + dbMock.projectAccess.deleteMany.mockResolvedValue({ count: 0 }); + dbMock.projectAccess.createMany.mockResolvedValue({ count: 1 }); + + await adminCaller().updateMember({ + organizationId: ORG_ID, + userId: MEMBER_USER_ID, + role: 'org:admin', + access: [{ projectId: 'proj-1', level: 'write' }], + }); + + expect(dbMock.$transaction).toHaveBeenCalled(); + expect(dbMock.member.update).toHaveBeenCalled(); + expect(dbMock.projectAccess.deleteMany).toHaveBeenCalled(); + expect(dbMock.projectAccess.createMany).toHaveBeenCalled(); + expect(getOrganizationAccessMock.clear).toHaveBeenCalledWith({ + userId: MEMBER_USER_ID, + organizationId: ORG_ID, + }); + }); }); diff --git a/packages/trpc/src/routers/organization.ts b/packages/trpc/src/routers/organization.ts index ade5f9010..b470426d4 100644 --- a/packages/trpc/src/routers/organization.ts +++ b/packages/trpc/src/routers/organization.ts @@ -12,6 +12,7 @@ import { import { zEditOrganization, zInviteUser, + zUpdateMember, zUpdateMemberAccess, zUpdateMemberRole, } from '@openpanel/validation'; @@ -381,40 +382,135 @@ export const organizationRouter = createTRPCRouter({ throw new TRPCForbiddenError('You do not have access to this project'); } - const member = await db.member.findFirst({ - where: { - userId: input.userId, - organizationId: input.organizationId, + const updated = await db.$transaction( + async (tx) => { + const member = await tx.member.findFirst({ + where: { + userId: input.userId, + organizationId: input.organizationId, + }, + }); + + if (!member) { + throw new TRPCBadRequestError('Member not found'); + } + + if (member.role === 'org:admin' && input.role === 'org:member') { + const adminCount = await tx.member.count({ + where: { + organizationId: input.organizationId, + role: 'org:admin', + }, + }); + + if (adminCount <= 1) { + throw new TRPCBadRequestError( + 'Cannot demote the last organization admin', + ); + } + } + + return tx.member.update({ + where: { + id: member.id, + }, + data: { + role: input.role, + }, + }); }, + { isolationLevel: 'Serializable' }, + ); + + await getOrganizationAccess.clear({ + userId: input.userId, + organizationId: input.organizationId, }); - if (!member) { - throw new TRPCBadRequestError('Member not found'); + return updated; + }), + + /** Role + project access in one transaction (Members edit modal). */ + updateMember: protectedProcedure + .input(zUpdateMember) + .mutation(async ({ input, ctx }) => { + if (input.userId === ctx.session.userId) { + throw new TRPCForbiddenError('You cannot update your own membership'); } - if (member.role === 'org:admin' && input.role === 'org:member') { - const adminCount = await db.member.count({ - where: { - organizationId: input.organizationId, - role: 'org:admin', - }, - }); + const access = await getOrganizationAccess({ + userId: ctx.session.userId, + organizationId: input.organizationId, + }); - if (adminCount <= 1) { - throw new TRPCBadRequestError( - 'Cannot demote the last organization admin', - ); - } + if (access?.role !== 'org:admin') { + throw new TRPCForbiddenError('You do not have access to this project'); } - return db.member.update({ - where: { - id: member.id, - }, - data: { - role: input.role, + const updated = await db.$transaction( + async (tx) => { + const member = await tx.member.findFirst({ + where: { + userId: input.userId, + organizationId: input.organizationId, + }, + }); + + if (!member) { + throw new TRPCBadRequestError('Member not found'); + } + + if (member.role === 'org:admin' && input.role === 'org:member') { + const adminCount = await tx.member.count({ + where: { + organizationId: input.organizationId, + role: 'org:admin', + }, + }); + + if (adminCount <= 1) { + throw new TRPCBadRequestError( + 'Cannot demote the last organization admin', + ); + } + } + + const memberRow = await tx.member.update({ + where: { + id: member.id, + }, + data: { + role: input.role, + }, + }); + + await tx.projectAccess.deleteMany({ + where: { + userId: input.userId, + organizationId: input.organizationId, + }, + }); + + await tx.projectAccess.createMany({ + data: input.access.map((grant) => ({ + userId: input.userId, + organizationId: input.organizationId, + projectId: grant.projectId, + level: grant.level, + })), + }); + + return memberRow; }, + { isolationLevel: 'Serializable' }, + ); + + await getOrganizationAccess.clear({ + userId: input.userId, + organizationId: input.organizationId, }); + + return updated; }), members: protectedProcedure diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 8ac4c895d..196520164 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -353,6 +353,13 @@ export const zUpdateMemberRole = z.object({ role: z.enum(['org:admin', 'org:member']), }); +export const zUpdateMember = z.object({ + userId: z.string(), + organizationId: z.string(), + role: z.enum(['org:admin', 'org:member']), + access: z.array(zProjectAccessGrant), +}); + export const zShareOverview = z.object({ organizationId: z.string(), projectId: z.string(), From 8fa408f0ec2ff129c209b9144c00672a1fbae23a Mon Sep 17 00:00:00 2001 From: grootbro Date: Tue, 8 Sep 2026 01:41:33 +0700 Subject: [PATCH 3/4] fix(org): hide self-edit and assert tx client in member tests Members table no longer offers Edit for the current user. Role/access tests use a distinct transaction mock so mutations must run inside $transaction. --- .../components/settings/members/columns.tsx | 33 ++++--- .../trpc/src/routers/organization.test.ts | 97 ++++++++++++------- 2 files changed, 80 insertions(+), 50 deletions(-) diff --git a/apps/start/src/components/settings/members/columns.tsx b/apps/start/src/components/settings/members/columns.tsx index 35fb264ec..a90e5ff10 100644 --- a/apps/start/src/components/settings/members/columns.tsx +++ b/apps/start/src/components/settings/members/columns.tsx @@ -1,17 +1,19 @@ -import { TooltipComplete } from '@/components/tooltip-complete'; -import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; -import { useTRPC } from '@/integrations/trpc/react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { ColumnDef } from '@tanstack/react-table'; -import { toast } from 'sonner'; - import { ColumnCreatedAt } from '@/components/column-created-at'; import { Badge } from '@/components/ui/badge'; import { createActionColumn } from '@/components/ui/data-table/data-table-helpers'; +import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; +import { useTRPC } from '@/integrations/trpc/react'; import { pushModal } from '@/modals'; import type { IServiceMember } from '@openpanel/db'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouteContext } from '@tanstack/react-router'; +import type { ColumnDef } from '@tanstack/react-table'; +import { toast } from 'sonner'; export function useColumns() { + const { session } = useRouteContext({ strict: false }); + const currentUserId = session?.userId; + const columns: ColumnDef[] = [ { accessorKey: 'user', @@ -87,6 +89,7 @@ export function useColumns() { createActionColumn(({ row }) => { const queryClient = useQueryClient(); const trpc = useTRPC(); + const isSelf = !!currentUserId && row.original.userId === currentUserId; const revoke = useMutation( trpc.organization.removeMember.mutationOptions({ onSuccess() { @@ -107,13 +110,15 @@ export function useColumns() { return ( <> - { - pushModal('EditMember', row.original); - }} - > - Edit member - + {!isSelf && ( + { + pushModal('EditMember', row.original); + }} + > + Edit member + + )} { diff --git a/packages/trpc/src/routers/organization.test.ts b/packages/trpc/src/routers/organization.test.ts index f4408639c..5082a0282 100644 --- a/packages/trpc/src/routers/organization.test.ts +++ b/packages/trpc/src/routers/organization.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { dbMock, + txMock, getOrganizationAccessMock, getOrganizationsMock, getOrganizationByIdMock, @@ -9,8 +10,21 @@ const { getInvitesMock, getInviteByIdMock, connectUserToOrganizationMock, -} = vi.hoisted(() => ({ - dbMock: { +} = vi.hoisted(() => { + const txMock = { + member: { + findFirst: vi.fn(), + count: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + projectAccess: { + deleteMany: vi.fn(), + createMany: vi.fn(), + }, + }; + + const dbMock = { member: { findFirst: vi.fn(), count: vi.fn(), @@ -38,19 +52,24 @@ const { }, $transaction: vi.fn(async (ops: unknown, _opts?: unknown) => { if (typeof ops === 'function') { - return (ops as (tx: typeof dbMock) => unknown)(dbMock); + return (ops as (tx: typeof txMock) => unknown)(txMock); } return Array.isArray(ops) ? Promise.all(ops) : ops; }), - }, - getOrganizationAccessMock: vi.fn(), - getOrganizationsMock: vi.fn(), - getOrganizationByIdMock: vi.fn(), - getMembersMock: vi.fn(), - getInvitesMock: vi.fn(), - getInviteByIdMock: vi.fn(), - connectUserToOrganizationMock: vi.fn(), -})); + }; + + return { + dbMock, + txMock, + getOrganizationAccessMock: vi.fn(), + getOrganizationsMock: vi.fn(), + getOrganizationByIdMock: vi.fn(), + getMembersMock: vi.fn(), + getInvitesMock: vi.fn(), + getInviteByIdMock: vi.fn(), + connectUserToOrganizationMock: vi.fn(), + }; +}); getOrganizationAccessMock.clear = vi.fn().mockResolvedValue(1); @@ -107,8 +126,8 @@ beforeEach(() => { describe('organization.updateMemberRole', () => { it('promotes a member to org:admin', async () => { - dbMock.member.findFirst.mockResolvedValue(MEMBER_ROW); - dbMock.member.update.mockResolvedValue({ + txMock.member.findFirst.mockResolvedValue(MEMBER_ROW); + txMock.member.update.mockResolvedValue({ ...MEMBER_ROW, role: 'org:admin', }); @@ -119,10 +138,11 @@ describe('organization.updateMemberRole', () => { role: 'org:admin', }); - expect(dbMock.member.update).toHaveBeenCalledWith({ + expect(txMock.member.update).toHaveBeenCalledWith({ where: { id: MEMBER_ROW.id }, data: { role: 'org:admin' }, }); + expect(dbMock.member.update).not.toHaveBeenCalled(); expect(result.role).toBe('org:admin'); }); @@ -149,12 +169,12 @@ describe('organization.updateMemberRole', () => { }); it('refuses to demote the last organization admin', async () => { - dbMock.member.findFirst.mockResolvedValue({ + txMock.member.findFirst.mockResolvedValue({ ...MEMBER_ROW, userId: 'other-admin', role: 'org:admin', }); - dbMock.member.count.mockResolvedValue(1); + txMock.member.count.mockResolvedValue(1); await expect( adminCaller().updateMemberRole({ @@ -164,17 +184,17 @@ describe('organization.updateMemberRole', () => { }), ).rejects.toThrow('last organization admin'); - expect(dbMock.member.update).not.toHaveBeenCalled(); + expect(txMock.member.update).not.toHaveBeenCalled(); }); it('demotes an admin when another admin remains', async () => { - dbMock.member.findFirst.mockResolvedValue({ + txMock.member.findFirst.mockResolvedValue({ ...MEMBER_ROW, userId: 'other-admin', role: 'org:admin', }); - dbMock.member.count.mockResolvedValue(2); - dbMock.member.update.mockResolvedValue({ + txMock.member.count.mockResolvedValue(2); + txMock.member.update.mockResolvedValue({ ...MEMBER_ROW, userId: 'other-admin', role: 'org:member', @@ -187,12 +207,13 @@ describe('organization.updateMemberRole', () => { }); expect(result.role).toBe('org:member'); - expect(dbMock.member.update).toHaveBeenCalled(); + expect(txMock.member.update).toHaveBeenCalled(); + expect(dbMock.member.update).not.toHaveBeenCalled(); }); it('clears cached organization access for the updated member', async () => { - dbMock.member.findFirst.mockResolvedValue(MEMBER_ROW); - dbMock.member.update.mockResolvedValue({ + txMock.member.findFirst.mockResolvedValue(MEMBER_ROW); + txMock.member.update.mockResolvedValue({ ...MEMBER_ROW, role: 'org:admin', }); @@ -210,13 +231,13 @@ describe('organization.updateMemberRole', () => { }); it('runs the last-admin check and role update in one transaction', async () => { - dbMock.member.findFirst.mockResolvedValue({ + txMock.member.findFirst.mockResolvedValue({ ...MEMBER_ROW, userId: 'other-admin', role: 'org:admin', }); - dbMock.member.count.mockResolvedValue(2); - dbMock.member.update.mockResolvedValue({ + txMock.member.count.mockResolvedValue(2); + txMock.member.update.mockResolvedValue({ ...MEMBER_ROW, userId: 'other-admin', role: 'org:member', @@ -229,20 +250,22 @@ describe('organization.updateMemberRole', () => { }); expect(dbMock.$transaction).toHaveBeenCalled(); - const txArg = dbMock.$transaction.mock.calls[0]?.[0]; - expect(typeof txArg).toBe('function'); + expect(txMock.member.count).toHaveBeenCalled(); + expect(txMock.member.update).toHaveBeenCalled(); + expect(dbMock.member.count).not.toHaveBeenCalled(); + expect(dbMock.member.update).not.toHaveBeenCalled(); }); }); describe('organization.updateMember', () => { it('updates role and project access in one transaction', async () => { - dbMock.member.findFirst.mockResolvedValue(MEMBER_ROW); - dbMock.member.update.mockResolvedValue({ + txMock.member.findFirst.mockResolvedValue(MEMBER_ROW); + txMock.member.update.mockResolvedValue({ ...MEMBER_ROW, role: 'org:admin', }); - dbMock.projectAccess.deleteMany.mockResolvedValue({ count: 0 }); - dbMock.projectAccess.createMany.mockResolvedValue({ count: 1 }); + txMock.projectAccess.deleteMany.mockResolvedValue({ count: 0 }); + txMock.projectAccess.createMany.mockResolvedValue({ count: 1 }); await adminCaller().updateMember({ organizationId: ORG_ID, @@ -252,9 +275,11 @@ describe('organization.updateMember', () => { }); expect(dbMock.$transaction).toHaveBeenCalled(); - expect(dbMock.member.update).toHaveBeenCalled(); - expect(dbMock.projectAccess.deleteMany).toHaveBeenCalled(); - expect(dbMock.projectAccess.createMany).toHaveBeenCalled(); + expect(txMock.member.update).toHaveBeenCalled(); + expect(txMock.projectAccess.deleteMany).toHaveBeenCalled(); + expect(txMock.projectAccess.createMany).toHaveBeenCalled(); + expect(dbMock.member.update).not.toHaveBeenCalled(); + expect(dbMock.projectAccess.deleteMany).not.toHaveBeenCalled(); expect(getOrganizationAccessMock.clear).toHaveBeenCalledWith({ userId: MEMBER_USER_ID, organizationId: ORG_ID, From 8781d896428b60636334fa29397ef3b23fc16a98 Mon Sep 17 00:00:00 2001 From: grootbro Date: Sat, 12 Sep 2026 15:24:48 +0700 Subject: [PATCH 4/4] fix(a11y): label the organization role radio group --- apps/start/src/modals/edit-member.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/start/src/modals/edit-member.tsx b/apps/start/src/modals/edit-member.tsx index 9212f7f46..32a8101f8 100644 --- a/apps/start/src/modals/edit-member.tsx +++ b/apps/start/src/modals/edit-member.tsx @@ -66,8 +66,9 @@ export default function EditMember(member: EditMemberProps) {
- + setRole(value as OrgRole)} className="mt-2 flex gap-4"