feat: organizations CRUD#29396
Conversation
|
Welcome to Cal.diy, @regisstedile! Thanks for opening this pull request. A few things to keep in mind:
A maintainer will review your PR soon. Thanks for contributing! |
|
Regis seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
Adds viewer.organizations tRPC router (getCurrent, create, update)
and settings UI at /settings/organizations/{profile,general}.
- Create org: Team(isOrganization) + OrganizationSettings + Membership
OWNER + Profile + User.organizationId in single transaction
- Update: restricted to OWNER/ADMIN, validates slug uniqueness
- Sidebar shows org nav only when user belongs to an org
- /settings/organizations redirects to /profile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e update) Adds viewer.organizations.listMembers/inviteMember/removeMember/updateMemberRole tRPC procedures and /settings/organizations/members UI page. - listMembers: paginated search via existing MembershipRepository.searchMembers - inviteMember: creates pending Membership + sends team invite email (existing users only) - removeMember: guards against removing OWNER or self - updateMemberRole: Zod-validated to MEMBER|ADMIN only, blocks changing OWNER - Sidebar members link now routes to internal /settings/organizations/members Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds listPendingInvites, acceptInvite, declineInvite tRPC procedures and /settings/organizations/invites UI page. - acceptInvite: sets membership.accepted=true + updates user.organizationId in a single transaction; redirects to org settings after accept - declineInvite: deletes pending membership - Sidebar shows "invites" link under organization section for all users; badge appears when pendingInviteCount > 0 - availableOrganizationSettingsPages expanded to include invites + members Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add /api/trpc/organizations/[trpc].ts endpoint (was missing from ENDPOINTS) - Add "organizations" to tRPC ENDPOINTS so client routes correctly - Remove conflicting next.config.ts redirect /settings/organizations/members → /members - Fix DialogTitle import (doesn't exist in this fork's UI lib, use DialogHeader title prop) - Add E2E tests for all 6 organization flows (create, list members, invite, accept invite, decline invite, remove member) - Fix E2E test: use apiLoginOnNewBrowser for multi-user invitee scenarios - Fix E2E test: use unique slugs to avoid DB state conflicts across runs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- acceptInvite: create Profile + guard against user already in another org - removeMember: delete Profile + clear user.organizationId in transaction - inviteMember: reject invite if invitee already belongs to an org - profile page: fix members link pointing to /teams (now /settings/organizations/members) - E2E acceptInvite: assert user.organizationId and Profile after accept - E2E members list: scope name selector to ul li to avoid strict mode violation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ons merge The organizations feature commits overwrote shared.ts, losing the teams endpoint registration. Re-adds /api/trpc/teams/[trpc].ts and "teams" to ENDPOINTS so viewer.teams.* calls resolve correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
e61ca4f to
5c4c176
Compare
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThis PR adds a complete organization settings feature: Zod schemas and utilities, a viewer organizations TRPC router with handlers for create/update, member listing, invites (list/accept/decline/invite), role updates, and removals; endpoint and API route wiring; settings navigation updates (members, invites, badges); new client pages for profile, general settings, members, and invites; removal of an obsolete redirect; and Playwright E2E tests covering core organization flows. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 11
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@apps/web/app/`(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx:
- Around line 142-144: The hardcoded permission hint paragraph using the
canUpdate flag should be localized: replace the string in the JSX (the <p> that
renders when !canUpdate) with a call to
t('organizations.general.permissionHint') and add an entry
"organizations.general.permissionHint": "Only organization owners and admins can
update these settings." to packages/i18n/locales/en/common.json; ensure the
component imports/uses the i18n hook/function (t) already used elsewhere in this
file and that the key matches the new JSON entry.
In
`@apps/web/app/`(use-page-wrapper)/settings/(settings-layout)/organizations/invites/page.tsx:
- Around line 22-23: The page has hardcoded user-facing strings (header, status,
button labels and toast messages) — replace them with t(...) keys using the
translations hook and add corresponding keys in
packages/i18n/locales/en/common.json. Locate the invites page component
(page.tsx) and replace each literal string (e.g., the header text, status text,
the "You have joined the organization." success toast, and any other messages at
the referenced spots) with t('settings.organizations.invites.<key>') or similar
unique keys, then add those keys and English values into common.json; ensure you
import/use the same translation helper used in the repo (e.g.,
useTranslations/t) and update showToast calls to pass translated text.
In
`@apps/web/app/`(use-page-wrapper)/settings/(settings-layout)/organizations/members/page.tsx:
- Around line 20-23: Replace all hardcoded user-facing strings on this members
page with i18n lookups and add matching keys to
packages/i18n/locales/en/common.json: change the roleOptions labels
("Member","Admin") to use t('...') keys (e.g., t('members.role.member'),
t('members.role.admin') ) and update all other hardcoded texts referenced in the
review (button labels, empty/loading states, confirmation/toast messages at the
indicated spots) to t('...') keys; then add those keys and English values into
common.json. Make sure to import and use the page's translation hook/function
(t) where needed and keep enum values (MembershipRole.MEMBER/ADMIN) unchanged.
Ensure toast calls (e.g., success/error toasts) pass translated strings and that
every replaced literal has a corresponding entry in common.json.
In
`@apps/web/app/`(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx:
- Around line 22-23: Replace hardcoded UI strings in the Organization profile
page with localization keys: change the <h1> text "Organization profile" in
page.tsx to use t("profile_org_title") and update the empty-state copy (the
strings referenced around lines 55-56) to use appropriate t(...) keys (e.g.,
t("profile_org_empty_title") / t("profile_org_empty_description")); then add
these new keys and their English values into
packages/i18n/locales/en/common.json so the translations are available (ensure
keys match exactly the t(...) calls used in the component).
In
`@apps/web/app/`(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx:
- Around line 395-401: The filter that checks tabs uses tab.name (in the block
referencing organizationRequiredKeys, adminRequiredKeys, and the "other_teams"
check) which is brittle because tab.name can be replaced by org display name;
update the comparisons to use a stable identifier such as tab.href (or an
internal key field if present) instead of tab.name—i.e., change the checks in
the function where organizationRequiredKeys.includes(tab.name), tab.name ===
"other_teams", and adminRequiredKeys.includes(tab.name) to use tab.href (or the
dedicated key) so the org/admin tab is matched reliably.
In `@packages/trpc/server/routers/viewer/organizations/acceptInvite.handler.ts`:
- Around line 19-22: The membership lookup in acceptInvite.handler.ts (the
prisma.membership.findUnique call using ctx.user.id and input.teamId) does not
verify the target team is an organization, so update the logic to first load the
team record (e.g., prisma.team.findUnique or include the team in the membership
query) and assert team.type === 'ORGANIZATION' before proceeding to mutate
user.organizationId or marking the invite accepted; if the team is not an
organization, throw a suitable error and abort. Apply the same organization-type
check to the other membership handling block referenced in the comment (the
similar logic around lines 41–57).
- Around line 37-39: Add an explicit guard that throws a deterministic TRPCError
when the request has no authenticated user before any transaction/write logic:
in acceptInvite.handler.ts check if (!user) and throw new TRPCError({ code:
"UNAUTHORIZED", message: "User not authenticated." }) before the existing
organizationId check and again before the transaction block referenced around
lines 51-57; reference the local variable user and the acceptInvite handler's
transaction usage so the write-path never runs with a null user.
In `@packages/trpc/server/routers/viewer/organizations/declineInvite.handler.ts`:
- Around line 15-30: The current two-step flow (prisma.membership.findUnique
then prisma.membership.delete) can race with concurrent acceptance; replace it
with an atomic conditional delete or a transaction that deletes only if accepted
is false and then check the result. Specifically, remove the separate
prisma.membership.findUnique + check and instead perform a conditional delete
(e.g., deleteMany / delete with a where that includes { userId: ctx.user.id,
teamId: input.teamId, accepted: false }) or run both operations in a transaction
and assert the delete affected one row; if the delete affected zero rows, throw
the same TRPCError NOT_FOUND or BAD_REQUEST as appropriate. Ensure you reference
and update the prisma.membership.delete usage and any error throw sites to use
the delete result to decide whether to error.
In `@packages/trpc/server/routers/viewer/organizations/inviteMember.handler.ts`:
- Around line 41-60: The pre-check using prisma.membership.findUnique plus the
subsequent prisma.membership.create is non-atomic and can race; wrap the create
call in a try/catch that handles unique-constraint DB errors
(Prisma.PrismaClientKnownRequestError with code "P2002") and convert them into
the same TRPCError({ code: "CONFLICT", message: ... }) response; to produce the
correct message, on catching P2002 re-query prisma.membership.findUnique for
userId_teamId (or reuse the earlier check if still valid) and throw "User is
already a member." when accepted is true or "Invite already sent." when accepted
is false; keep the MembershipRole.MEMBER default and only treat other errors as
rethrows.
In `@packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts`:
- Around line 23-47: Replace the pre-check + plain membership.delete with an
atomic transaction that prevents removing an OWNER: inside the
prisma.$transaction call, use prisma.membership.deleteMany({ where: { userId:
input.userId, teamId: membership.team.id, role: { not: MembershipRole.OWNER } }
}) as the first operation, then run the profile.deleteMany and user.updateMany;
after the transaction check the deleted count (result[0].count) and if it is 0
throw TRPCError FORBIDDEN/NOT_FOUND as appropriate. This keeps the
owner-protection enforced inside prisma.$transaction and references
prisma.$transaction, prisma.membership.deleteMany (replacing
prisma.membership.delete), and MembershipRole.
In
`@packages/trpc/server/routers/viewer/organizations/updateMemberRole.handler.ts`:
- Around line 19-35: The current code uses prisma.membership.findUnique to check
for MembershipRole.OWNER then separately calls prisma.membership.update, which
is vulnerable to race conditions; replace the two-step check+update with an
atomic conditional update (e.g., use prisma.membership.updateMany or
prisma.membership.update with a where that includes role: { not:
MembershipRole.OWNER }) so the DB only updates if the existing role is not
OWNER, then inspect the returned count/affected rows and throw TRPCError ({
code: "FORBIDDEN", message: "Cannot change the owner's role." }) when no rows
were updated; this ensures the protection around MembershipRole.OWNER (refer to
prisma.membership.findUnique, prisma.membership.update,
prisma.membership.updateMany, and MembershipRole.OWNER) is enforced atomically.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 57acebb7-f6cb-4368-b4d9-f21deed507c9
📒 Files selected for processing (24)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsxapps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsxapps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/invites/page.tsxapps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/members/page.tsxapps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/page.tsxapps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsxapps/web/next.config.tsapps/web/pages/api/trpc/organizations/[trpc].tsapps/web/playwright/settings/organizations.e2e.tspackages/trpc/react/shared.tspackages/trpc/server/routers/viewer/_router.tsxpackages/trpc/server/routers/viewer/organizations/_router.tsxpackages/trpc/server/routers/viewer/organizations/acceptInvite.handler.tspackages/trpc/server/routers/viewer/organizations/create.handler.tspackages/trpc/server/routers/viewer/organizations/declineInvite.handler.tspackages/trpc/server/routers/viewer/organizations/getCurrent.handler.tspackages/trpc/server/routers/viewer/organizations/inviteMember.handler.tspackages/trpc/server/routers/viewer/organizations/listMembers.handler.tspackages/trpc/server/routers/viewer/organizations/listPendingInvites.handler.tspackages/trpc/server/routers/viewer/organizations/organizationUtils.tspackages/trpc/server/routers/viewer/organizations/removeMember.handler.tspackages/trpc/server/routers/viewer/organizations/schema.tspackages/trpc/server/routers/viewer/organizations/update.handler.tspackages/trpc/server/routers/viewer/organizations/updateMemberRole.handler.ts
💤 Files with no reviewable changes (1)
- apps/web/next.config.ts
- Fix race conditions: declineInvite uses atomic deleteMany with accepted=false condition; inviteMember catches P2002 unique violation instead of pre-check; removeMember and updateMemberRole push owner guard inside the DB operation - Add null user guard in acceptInvite before transaction writes - Add i18n keys for all hardcoded UI strings in organization pages - Fix SettingsLayoutAppDirClient to match org tab by href instead of name (tab.name is replaced with org display name, making name-based check brittle) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts (1)
23-30:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftMake member deletion and cleanup a single atomic transaction.
Line 23 deletes membership before Line 42 starts the transaction. If the cleanup transaction fails, the membership is already gone, leaving partial state (e.g., stale
user.organizationId).Suggested fix
- const { count } = await prisma.membership.deleteMany({ - where: { - userId: input.userId, - teamId: membership.team.id, - role: { not: MembershipRole.OWNER }, - }, - }); - - if (count === 0) { - const exists = await prisma.membership.findUnique({ - where: { userId_teamId: { userId: input.userId, teamId: membership.team.id } }, - select: { role: true }, - }); - if (!exists) { - throw new TRPCError({ code: "NOT_FOUND", message: "Member not found." }); - } - throw new TRPCError({ code: "FORBIDDEN", message: "Cannot remove the organization owner." }); - } - - await prisma.$transaction([ - prisma.profile.deleteMany({ - where: { userId: input.userId, organizationId: membership.team.id }, - }), - prisma.user.updateMany({ - where: { id: input.userId, organizationId: membership.team.id }, - data: { organizationId: null }, - }), - ]); + await prisma.$transaction(async (tx) => { + const { count } = await tx.membership.deleteMany({ + where: { + userId: input.userId, + teamId: membership.team.id, + role: { not: MembershipRole.OWNER }, + }, + }); + + if (count === 0) { + const exists = await tx.membership.findUnique({ + where: { userId_teamId: { userId: input.userId, teamId: membership.team.id } }, + select: { role: true }, + }); + if (!exists) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found." }); + throw new TRPCError({ code: "FORBIDDEN", message: "Cannot remove the organization owner." }); + } + + await tx.profile.deleteMany({ + where: { userId: input.userId, organizationId: membership.team.id }, + }); + await tx.user.updateMany({ + where: { id: input.userId, organizationId: membership.team.id }, + data: { organizationId: null }, + }); + });Also applies to: 42-50
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts` around lines 23 - 30, The deletion of the membership (prisma.membership.deleteMany) is executed outside the transaction, causing partial state if subsequent cleanup (e.g., updating user.organizationId) fails; modify the removeMember handler to perform the membership deletion and all related cleanup updates inside a single Prisma transaction (use prisma.$transaction) so the deleteMany call, the user update (clearing organizationId) and any other cleanup steps execute atomically; locate the removeMember handler and replace the standalone prisma.membership.deleteMany and the later cleanup logic with a single prisma.$transaction that runs the delete and the follow-up updates together and returns/validates the affected count within the transaction.apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/members/page.tsx (1)
134-134:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winLocalize the invite-email placeholder text.
"member@example.com"is still hardcoded; switch to a translation key and add it topackages/i18n/locales/en/common.json.As per coding guidelines:
**/*.{ts,tsx,jsx}: Add translations topackages/i18n/locales/en/common.jsonfor all UI strings.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/app/`(use-page-wrapper)/settings/(settings-layout)/organizations/members/page.tsx at line 134, Replace the hardcoded placeholder "member@example.com" in the invite input (the placeholder prop in page.tsx) with a translation key (e.g. use t('inviteEmailPlaceholder') or useTranslations()/i18n hook your app uses) and import/use the app's translation hook in that component; then add "inviteEmailPlaceholder": "member@example.com" to packages/i18n/locales/en/common.json so the string is localized. Ensure the placeholder prop references the translation function result (not a raw string).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/trpc/server/routers/viewer/organizations/inviteMember.handler.ts`:
- Line 66: The joinLink currently built in inviteMember.handler.ts points
invitees to `/settings/organizations/members` which doesn't surface
accept/decline actions; update the joinLink value used when creating the invite
(the joinLink property in the invite creation payload in
inviteMember.handler.ts) to `${WEBAPP_URL}/settings/organizations/invites` so
recipients land on the invites page that shows pending invites and
accept/decline controls.
---
Duplicate comments:
In
`@apps/web/app/`(use-page-wrapper)/settings/(settings-layout)/organizations/members/page.tsx:
- Line 134: Replace the hardcoded placeholder "member@example.com" in the invite
input (the placeholder prop in page.tsx) with a translation key (e.g. use
t('inviteEmailPlaceholder') or useTranslations()/i18n hook your app uses) and
import/use the app's translation hook in that component; then add
"inviteEmailPlaceholder": "member@example.com" to
packages/i18n/locales/en/common.json so the string is localized. Ensure the
placeholder prop references the translation function result (not a raw string).
In `@packages/trpc/server/routers/viewer/organizations/removeMember.handler.ts`:
- Around line 23-30: The deletion of the membership
(prisma.membership.deleteMany) is executed outside the transaction, causing
partial state if subsequent cleanup (e.g., updating user.organizationId) fails;
modify the removeMember handler to perform the membership deletion and all
related cleanup updates inside a single Prisma transaction (use
prisma.$transaction) so the deleteMany call, the user update (clearing
organizationId) and any other cleanup steps execute atomically; locate the
removeMember handler and replace the standalone prisma.membership.deleteMany and
the later cleanup logic with a single prisma.$transaction that runs the delete
and the follow-up updates together and returns/validates the affected count
within the transaction.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 783be998-8ad4-4e4b-b5e7-e11fa562df88
📒 Files selected for processing (13)
apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsxapps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsxapps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/invites/page.tsxapps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/members/page.tsxapps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsxapps/web/pages/api/trpc/teams/[trpc].tspackages/i18n/locales/en/common.jsonpackages/trpc/react/shared.tspackages/trpc/server/routers/viewer/organizations/acceptInvite.handler.tspackages/trpc/server/routers/viewer/organizations/declineInvite.handler.tspackages/trpc/server/routers/viewer/organizations/inviteMember.handler.tspackages/trpc/server/routers/viewer/organizations/removeMember.handler.tspackages/trpc/server/routers/viewer/organizations/updateMemberRole.handler.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- packages/trpc/react/shared.ts
- packages/trpc/server/routers/viewer/organizations/acceptInvite.handler.ts
- apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx
- apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx
- apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx
Invitees should land on /settings/organizations/invites where they can accept or decline. The /members page has no accept/decline action. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Button.variant only accepts "button"|"icon"|"fab". Destructive and minimal are colors, not variants. This caused the Docker build to fail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@coderabbitai resolve |
✅ Actions performedComments resolved. Approval is disabled; enable |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
getCurrent,create,update,listMembers,inviteMember,removeMember,updateMemberRole,listPendingInvites,acceptInvite,declineInvite/api/trpc/organizations/[trpc].tsand registerorganizationsin the ENDPOINTS array/settings/organizations/general,/members,/invites/settings/organizations/membersto/membersTest plan
PLAYWRIGHT_HEADLESS=1 yarn e2e apps/web/playwright/settings/organizations.e2e.ts🤖 Generated with Claude Code