diff --git a/apps/start/src/components/settings/members/columns.tsx b/apps/start/src/components/settings/members/columns.tsx index 915725665..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 access - + {!isSelf && ( + { + pushModal('EditMember', row.original); + }} + > + Edit member + + )} { diff --git a/apps/start/src/modals/edit-member.tsx b/apps/start/src/modals/edit-member.tsx index e8c36fdca..32a8101f8 100644 --- a/apps/start/src/modals/edit-member.tsx +++ b/apps/start/src/modals/edit-member.tsx @@ -1,5 +1,7 @@ import { ButtonContainer } from '@/components/button-container'; import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { ProjectAccessGrants } from '@/components/settings/project-access-grants'; import { useTRPC } from '@/integrations/trpc/react'; import { handleError } from '@/integrations/trpc/react'; @@ -13,6 +15,8 @@ import type { IProjectAccessGrant } from '@openpanel/validation'; import { popModal } from '.'; import { ModalContent, ModalHeader } from './Modal/Container'; +type OrgRole = 'org:admin' | 'org:member'; + type EditMemberProps = IServiceMember; export default function EditMember(member: EditMemberProps) { @@ -27,20 +31,23 @@ export default function EditMember(member: EditMemberProps) { })) ?? []; const [access, setAccess] = useState(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( - trpc.organization.updateMemberAccess.mutationOptions({ + trpc.organization.updateMember.mutationOptions({ onError(error) { handleError(error); setAccess(toGrants()); + setRole(member.role === 'org:admin' ? 'org:admin' : 'org:member'); }, onSuccess() { - toast.success('Access updated'); - // Refresh members list so access column reflects changes + toast.success('Member updated'); queryClient.invalidateQueries(trpc.organization.members.pathFilter()); popModal(); }, @@ -49,19 +56,38 @@ export default function EditMember(member: EditMemberProps) { const projects = projectsQuery.data ?? []; + const memberName = member.user + ? [member.user.firstName, member.user.lastName].filter(Boolean).join(' ') + : null; + return ( - +
+
+ + setRole(value as OrgRole)} + className="mt-2 flex gap-4" + > +
+ + +
+
+ + +
+
+
+ diff --git a/packages/trpc/src/routers/organization.test.ts b/packages/trpc/src/routers/organization.test.ts new file mode 100644 index 000000000..5082a0282 --- /dev/null +++ b/packages/trpc/src/routers/organization.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + dbMock, + txMock, + getOrganizationAccessMock, + getOrganizationsMock, + getOrganizationByIdMock, + getMembersMock, + getInvitesMock, + getInviteByIdMock, + connectUserToOrganizationMock, +} = 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(), + 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(async (ops: unknown, _opts?: unknown) => { + if (typeof ops === 'function') { + return (ops as (tx: typeof txMock) => unknown)(txMock); + } + return Array.isArray(ops) ? Promise.all(ops) : ops; + }), + }; + + 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); + +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 () => { + txMock.member.findFirst.mockResolvedValue(MEMBER_ROW); + txMock.member.update.mockResolvedValue({ + ...MEMBER_ROW, + role: 'org:admin', + }); + + const result = await adminCaller().updateMemberRole({ + organizationId: ORG_ID, + userId: MEMBER_USER_ID, + role: 'org:admin', + }); + + 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'); + }); + + 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 () => { + txMock.member.findFirst.mockResolvedValue({ + ...MEMBER_ROW, + userId: 'other-admin', + role: 'org:admin', + }); + txMock.member.count.mockResolvedValue(1); + + await expect( + adminCaller().updateMemberRole({ + organizationId: ORG_ID, + userId: 'other-admin', + role: 'org:member', + }), + ).rejects.toThrow('last organization admin'); + + expect(txMock.member.update).not.toHaveBeenCalled(); + }); + + it('demotes an admin when another admin remains', async () => { + txMock.member.findFirst.mockResolvedValue({ + ...MEMBER_ROW, + userId: 'other-admin', + role: 'org:admin', + }); + txMock.member.count.mockResolvedValue(2); + txMock.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(txMock.member.update).toHaveBeenCalled(); + expect(dbMock.member.update).not.toHaveBeenCalled(); + }); + + it('clears cached organization access for the updated member', async () => { + txMock.member.findFirst.mockResolvedValue(MEMBER_ROW); + txMock.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 () => { + txMock.member.findFirst.mockResolvedValue({ + ...MEMBER_ROW, + userId: 'other-admin', + role: 'org:admin', + }); + txMock.member.count.mockResolvedValue(2); + txMock.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(); + 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 () => { + txMock.member.findFirst.mockResolvedValue(MEMBER_ROW); + txMock.member.update.mockResolvedValue({ + ...MEMBER_ROW, + role: 'org:admin', + }); + txMock.projectAccess.deleteMany.mockResolvedValue({ count: 0 }); + txMock.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(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, + }); + }); +}); diff --git a/packages/trpc/src/routers/organization.ts b/packages/trpc/src/routers/organization.ts index 806f3f447..b470426d4 100644 --- a/packages/trpc/src/routers/organization.ts +++ b/packages/trpc/src/routers/organization.ts @@ -12,7 +12,9 @@ import { import { zEditOrganization, zInviteUser, + zUpdateMember, zUpdateMemberAccess, + zUpdateMemberRole, } from '@openpanel/validation'; import { generateSecureId } from '@openpanel/common/server'; @@ -364,6 +366,153 @@ 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 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, + }); + + 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'); + } + + 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 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 .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..196520164 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -347,6 +347,19 @@ 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 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(),