Phase D: team management UI + invites - #52
Conversation
🤖 CodeAnt AI — Review Status
|
✅ Deploy Preview for moderaty ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe pull request adds organization management, invitations, membership controls, active-organization switching, SvelteKit routes, user interfaces, and comprehensive server-side tests. ChangesOrganization management
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Member
participant InvitePage
participant acceptInvite
participant Database
participant Session
Member->>InvitePage: open invite token
InvitePage->>Database: load invite preview
Member->>InvitePage: submit join form
InvitePage->>acceptInvite: pass user, session, and token
acceptInvite->>Database: validate and consume invite
acceptInvite->>Database: insert organization membership
acceptInvite->>Session: activate organization
acceptInvite-->>InvitePage: redirect to dashboard
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Comment |
Sequence DiagramThis PR adds team administration with single-use invite links and session-scoped team switching. The diagram shows invite creation, invite acceptance, and how both acceptance and switching update the active team. sequenceDiagram
participant Owner
participant Team Settings
participant Invite Page
participant Org Backend
participant Session
Owner->>Team Settings: Create invite link
Team Settings->>Org Backend: Create single-use invite
Org Backend-->>Team Settings: Return invite token
Invite Page->>Org Backend: Preview invite
Org Backend-->>Invite Page: Return team and role
Invite Page->>Org Backend: Accept invite
Org Backend->>Session: Add member and set active team
User->>Org Backend: Switch active team
Org Backend->>Session: Update active team
Generated by CodeAnt AI |
🏁 CodeAnt Quality Gate ResultsCommit: ✅ Overall Status: PASSEDQuality Gate Details
|
There was a problem hiding this comment.
Review Summary
This PR implements team management UI and invite functionality (Phase D). The implementation is comprehensive with good test coverage (355 tests total, 30 new). However, I've identified a critical security vulnerability that must be fixed before merge.
Critical Issue
Invite Token Reuse Vulnerability (src/lib/server/org.ts:258-261): The acceptInvite function fails to burn invite tokens when the accepting user is already a member. This violates the single-use design and creates a security risk where existing members can reuse the same invite multiple times, and worse, other users can still use the unburned token after an existing member accepts it.
Fix Required
The token burn (line 260) must happen unconditionally for all acceptInvite calls, not just for new memberships. The idempotent behavior (allowing existing members to switch teams via the invite) should be preserved, but every acceptance must mark the token as used to prevent reuse.
Test Coverage Note
The test at line 233 (acceptInvite by an existing member is idempotent and does not burn the token) appears to be testing for the buggy behavior rather than the correct behavior. After fixing the code, this test should be updated to verify the token IS burned even for existing members.
All other aspects of the implementation look solid - proper role-based authorization, defense in depth, and comprehensive error handling.
You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.
| if (!existing) { | ||
| await tx.insert(memberships).values({ userId, orgId: inv.orgId, role }); | ||
| await tx.update(invites).set({ acceptedBy: userId }).where(eq(invites.token, token)); | ||
| } |
There was a problem hiding this comment.
🛑 Security Vulnerability: The invite token is not burned when the user is already a member. This creates a token reuse vulnerability where existing members can repeatedly accept the same invite (line 258-261), and after they're done, a different user can still use the unburned token to join the org. Every acceptInvite call must burn the token regardless of existing membership to maintain single-use semantics and prevent unauthorized reuse.1
| if (!existing) { | |
| await tx.insert(memberships).values({ userId, orgId: inv.orgId, role }); | |
| await tx.update(invites).set({ acceptedBy: userId }).where(eq(invites.token, token)); | |
| } | |
| if (!existing) { | |
| await tx.insert(memberships).values({ userId, orgId: inv.orgId, role }); | |
| } | |
| await tx.update(invites).set({ acceptedBy: userId }).where(eq(invites.token, token)); |
Footnotes
-
CWE-807: Reliance on Untrusted Inputs in a Security Decision - https://cwe.mitre.org/data/definitions/807.html ↩
Not up to standards ⛔🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | |
| Duplication |
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
PR Summary by QodoPhase D: Team management UI, org switching, and single-use invites
AI Description
Diagram
High-Level Assessment
Files changed (12)
|
There was a problem hiding this comment.
Pull Request Overview
Phase D implementation covers the core requirements for team management and invites but introduces critical concurrency risks in 'src/lib/server/org.ts'. Several acceptance criteria—specifically single-use invite enforcement and 'last owner' safeguards—are implemented with non-atomic checks, which could lead to inconsistent state or unauthorized access under load.
Codacy analysis indicates the PR is not up to standards due to a significant increase in complexity (+66 in 'org.ts') and 10 new code clones. A notable UX gap exists where the 'leave' action results in a 404 error rather than redirecting the user to a valid state. These issues should be addressed before merging to ensure system stability and a smooth user experience.
About this PR
- The PR introduces significant technical debt with a high complexity delta and 10 new code clones across server logic and test files. Consider refactoring repetitive test setups and centralizing database query logic in 'src/lib/server/org.ts' to improve long-term maintainability.
Test suggestions
- Creating a shared team correctly assigns the creator as 'owner' and blocks empty/long names.
- Renaming a team is restricted to admin+ and validates the name input.
- Invite links are single-use; the token is burned after a user joins and cannot be reused by another.
- Invite acceptance is idempotent for existing members and does not consume the single-use token.
- Attempting to create or accept an invite for a personal organization is rejected with a 400 error.
- Role hierarchy: Admins can remove members, but only Owners can remove Admins or change roles.
- Last-owner demotion or leaving a team with other members is blocked until a new owner is promoted.
- Switching active organizations via /org/switch updates the session and redirects to the dashboard.
- Public invite preview returns null (404) for unknown tokens to avoid leaking organization existence.
TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback
| * switches), burns the token, and points the session at the new org. One | ||
| * transaction; a burned token can never join a second user. | ||
| */ | ||
| export async function acceptInvite(userId: string, sessionToken: string, token: string): Promise<string> { |
There was a problem hiding this comment.
🔴 HIGH RISK
The 'already accepted' check is not atomic. Move the check for acceptedBy inside the transaction, or use a conditional update (WHERE token = ? AND accepted_by IS NULL) and verify that exactly one row was affected to ensure the invite remains single-use.
| // testdb must be the first app import: it registers the $lib/server/db mock | ||
| // before any module that binds the real database (see its header comment). | ||
| import { postForm, setupTestDb, TEST_OWNER, testDb } from '$lib/server/testdb'; | ||
| import { invites, memberships, organizations, sessions, users } from '$lib/server/db/schema'; |
There was a problem hiding this comment.
🟡 MEDIUM RISK
The 'sessions' import is unused. Only 'invites', 'memberships', 'organizations', and 'users' are used as schema references in this file.
| import { invites, memberships, organizations, sessions, users } from '$lib/server/db/schema'; | |
| import { invites, memberships, organizations, users } from '$lib/server/db/schema'; |
| * (promote a successor first) and for the sole member (delete your account | ||
| * instead — standalone org deletion is a non-goal). | ||
| */ | ||
| export async function leaveOrg(userId: string, sessionToken: string, orgId: string): Promise<void> { |
There was a problem hiding this comment.
🟡 MEDIUM RISK
Move the validation logic (checking for other members/owners) inside the transaction to prevent race conditions that could lead to an organization having no members or owners.
| * owners are allowed); the LAST owner cannot be demoted. Self-demotion of the | ||
| * last owner is blocked by the same rule. | ||
| */ | ||
| export async function setMemberRole(callerUserId: string, orgId: string, targetUserId: string, role: OrgRole): Promise<void> { |
There was a problem hiding this comment.
🟡 MEDIUM RISK
This operation is prone to a race condition. Concurrent demotions could leave an organization without any owners. Perform the owner count check and the role update within a single transaction.
| const targetUserId = String((await request.formData()).get('userId') ?? ''); | ||
| return guard(() => removeMember(user.id, user.orgId, targetUserId)); | ||
| }, | ||
| leave: async ({ locals, cookies }) => { |
There was a problem hiding this comment.
🟡 MEDIUM RISK
Suggestion: Add a redirect (e.g., to /dashboard) after successfully leaving the organization to ensure the user is transitioned to a valid state and avoid a 404 error on the current page.
| .select({ role: invites.role, expiresAt: invites.expiresAt, acceptedBy: invites.acceptedBy, orgName: organizations.name }) | ||
| .from(invites) | ||
| .innerJoin(organizations, eq(invites.orgId, organizations.id)) | ||
| .where(eq(invites.token, token)) | ||
| .get(); | ||
| if (!row) return null; |
There was a problem hiding this comment.
⚪ LOW RISK
Suggestion: The database lookup logic for invites is duplicated between 'previewInvite' (lines 207-211) and 'acceptInvite'. Consider extracting a private helper function called 'getInviteWithOrg' that handles the join between 'invites' and 'organizations' based on the token.
PR Code Suggestions ✨Previous suggestions up to commit
|
| Category | Suggestion | Severity | Generated at (UTC) |
| Race condition |
Concurrent acceptance requests can both redeem a single-use inviteInvite consumption is not atomic: concurrent requests can both pass the src/lib/server/org.ts [259-260] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/server/org.ts
**Line:** 259:260
**Comment:**
*Race Condition: Invite consumption is not atomic: concurrent requests can both pass the pre-transaction `acceptedBy` check, insert memberships for different users, and update the same invite. The update must condition on `acceptedBy IS NULL` and the membership insertion must only succeed when that conditional claim succeeds, otherwise the documented single-use guarantee is violated.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-03 12:09
|
✅
| Major | 2026-08-03 12:09
| |
| Incomplete implementation |
Existing-member acceptance is tested without enforcing that the invite is burned for all later usersThe test explicitly accepts that an invite remains usable after an existing member src/lib/server/org.test.ts [233-246] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/server/org.test.ts
**Line:** 233:246
**Comment:**
*Incomplete Implementation: The test explicitly accepts that an invite remains usable after an existing member accepts it, but invite links are documented as single-use. This assertion leaves the implementation free to let a different user redeem the same token after the first acceptance; the test should verify that any successful acceptance, including an idempotent existing-member acceptance, burns the invite and rejects subsequent users with 410.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-03 12:09
|
Latest suggestions up to commit 06bddf0
| Category | Suggestion | Severity | Generated at (UTC) |
| Security |
Invite acceptance can burn an invite without updating the accepting user's authenticated sessionInvite acceptance commits the membership and burns the token even when Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/server/org.ts
**Line:** 266:266
**Comment:**
*Security: Invite acceptance commits the membership and burns the token even when `sessionToken` is stale, deleted, or belongs to another user, because the session update is neither checked for ownership nor verified to affect a row. A logout or session deletion racing with this request can therefore consume a valid invite without switching the accepting user's session; verify the session row belongs to `userId` and require the update to affect exactly one live session within the transaction.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-03 12:25
|
| Api mismatch |
Signing in from an invite loses the invite URL and prevents automatic return to acceptanceThe sign-in link drops the current invite URL. The login flow always redirects to src/routes/invite/[token]/+page.svelte [42] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/invite/[token]/+page.svelte
**Line:** 42:42
**Comment:**
*Api Mismatch: The sign-in link drops the current invite URL. The login flow always redirects to `/dashboard`, so after completing OAuth the visitor cannot automatically return to this invite and must recover the original token manually, preventing the advertised sign-in-and-return flow. Preserve the invite URL through login and the OAuth callback.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-03 12:25
|
Code Review by Qodo
Context used✅ Compliance rules (platform):
74 rules 1.
|
…ner/leave guards, leave redirect
Review triage — round 1 (fixed in
|
…d session updates, conditional owner guards
Review triage — round 2 (Qodo findings, fixed in
|
There was a problem hiding this comment.
Warning
CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.
Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.
Actionable comments posted: 10
🤖 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 `@src/lib/server/org.test.ts`:
- Around line 253-274: Add a `setMemberRole` test case for an invalid role
value, using the existing raw-caller cast pattern and importing the role type
alongside the current functions. Assert that calling `setMemberRole` with the
unknown role rejects with status 400, covering the guard before any membership
update occurs.
In `@src/lib/server/org.ts`:
- Line 266: Scope every session update by both sessionToken and userId. In
src/lib/server/org.ts lines 266-266, update acceptInvite’s sessions predicate;
in lines 272-276, update switchActiveOrg’s predicate; and in lines 383-386,
extend leaveOrg’s existing and(...) with the userId condition so mismatched
tokens update no rows.
- Around line 318-341: Role-based checks use membership data read outside the
write transaction, allowing concurrent role changes to bypass invariants. In
src/lib/server/org.ts lines 318-341, update setMemberRole to move the target
lookup into db.transaction and use that fresh role for the owner-count check; in
lines 348-358, open a transaction in the member-removal function, re-read the
target membership inside it, perform owner/admin checks there, then delete; in
lines 365-388, re-read the leaver membership inside the transaction and use its
role for the last-owner check instead of outer m.role.
- Around line 135-140: Update requireSharedOrg to explicitly reject an undefined
organization row before checking personalFor, returning the existing 404 error
for missing organizations; preserve the current 400 error for personal
organizations.
In `@src/routes/`(app)/org/+page.server.ts:
- Around line 94-98: Update the remove action in
src/routes/(app)/org/+page.server.ts lines 94-98 to enforce the same route-level
organization-role authorization as rename, invite, revokeInvite, and setRole
before calling removeMember; add coverage in
src/routes/(app)/org/page.server.test.ts lines 146-160 asserting that a
member-role caller removing another member is rejected.
- Around line 86-93: Validate the form role in the setRole action before calling
setMemberRole, accepting only 'owner', 'admin', or 'member' and returning the
same 400 failure behavior used by invite for invalid values; remove reliance on
the unchecked OrgRole cast. In src/routes/(app)/org/page.server.test.ts lines
136-144, add coverage asserting that setRole with an out-of-range role returns a
400 failure.
In `@src/routes/`(app)/org/+page.svelte:
- Around line 109-121: Replace the empty branch of the data.invites each block
with the shared EmptyState component, preserving the “No open invite links.”
message and following the component’s existing usage conventions. Leave the
populated invite rendering unchanged.
- Around line 115-118: The revoke invite button in the form using the
revokeInvite action must identify its specific target, incorporating the
invite’s available identifying value such as its email or token. Preserve the
existing revoke behavior while making each button’s accessible name
distinguishable when multiple invites are listed.
In `@src/routes/invite/`[token]/+page.svelte:
- Around line 33-54: The invite route’s conditional rendering lacks the required
loading and EmptyState representations. Update the page component’s state
handling around the existing invite branches to render a loading skeleton while
data is pending, use EmptyState when the invite is unavailable, and retain
`.error-box` for expired or accepted invites while preserving the current
populated signed-in and signed-out states.
In `@src/routes/invite/`[token]/page.server.test.ts:
- Around line 96-101: Extend the test following the accept action assertion to
query the session identified by sess-1 and assert its activeOrgId is org-1. Keep
the existing membership and invite assertions unchanged, ensuring the test
verifies the session update performed by acceptInvite.
🪄 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: ASSERTIVE
Plan: Pro Plus
Run ID: b9c0c1c2-08d2-403a-bce0-7a43f885bcc5
📒 Files selected for processing (12)
src/lib/server/org.test.tssrc/lib/server/org.tssrc/lib/server/ownership.tssrc/routes/(app)/+layout.sveltesrc/routes/(app)/org/+page.server.tssrc/routes/(app)/org/+page.sveltesrc/routes/(app)/org/page.server.test.tssrc/routes/(app)/org/switch/+server.tssrc/routes/(app)/org/switch/switch.test.tssrc/routes/invite/[token]/+page.server.tssrc/routes/invite/[token]/+page.sveltesrc/routes/invite/[token]/page.server.test.ts
| async function requireSharedOrg(orgId: string): Promise<void> { | ||
| const org = await db.select({ personalFor: organizations.personalFor }).from(organizations).where(eq(organizations.id, orgId)).get(); | ||
| if (org && org.personalFor !== null) { | ||
| throw error(400, "personal teams can't have members — create a shared team to collaborate"); | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win
Fail closed when the organization row is missing.
The guard passes when org is undefined. A missing organization then reaches the invite insert and fails as a raw database error instead of a 404. Reject the missing row explicitly.
♻️ Proposed change
async function requireSharedOrg(orgId: string): Promise<void> {
const org = await db.select({ personalFor: organizations.personalFor }).from(organizations).where(eq(organizations.id, orgId)).get();
- if (org && org.personalFor !== null) {
+ if (!org) throw error(404, 'team not found');
+ if (org.personalFor !== null) {
throw error(400, "personal teams can't have members — create a shared team to collaborate");
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async function requireSharedOrg(orgId: string): Promise<void> { | |
| const org = await db.select({ personalFor: organizations.personalFor }).from(organizations).where(eq(organizations.id, orgId)).get(); | |
| if (org && org.personalFor !== null) { | |
| throw error(400, "personal teams can't have members — create a shared team to collaborate"); | |
| } | |
| } | |
| async function requireSharedOrg(orgId: string): Promise<void> { | |
| const org = await db.select({ personalFor: organizations.personalFor }).from(organizations).where(eq(organizations.id, orgId)).get(); | |
| if (!org) throw error(404, 'team not found'); | |
| if (org.personalFor !== null) { | |
| throw error(400, "personal teams can't have members — create a shared team to collaborate"); | |
| } | |
| } |
🤖 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 `@src/lib/server/org.ts` around lines 135 - 140, Update requireSharedOrg to
explicitly reject an undefined organization row before checking personalFor,
returning the existing 404 error for missing organizations; preserve the current
400 error for personal organizations.
Source: Coding guidelines
| export async function setMemberRole(callerUserId: string, orgId: string, targetUserId: string, role: OrgRole): Promise<void> { | ||
| const caller = await membershipOf(callerUserId, orgId); | ||
| if (!caller) throw error(404, 'team not found'); | ||
| requireRole(asOrgRole(caller.role), 'owner'); | ||
| if (role !== 'owner' && role !== 'admin' && role !== 'member') throw error(400, 'unknown role'); | ||
| const target = await membershipOf(targetUserId, orgId); | ||
| if (!target) throw error(404, 'member not found'); | ||
| // Owner-count check and role update in ONE transaction: two concurrent | ||
| // demotions can never strand the org without an owner (PR #52 review). | ||
| await db.transaction(async (tx) => { | ||
| if (target.role === 'owner' && role !== 'owner') { | ||
| const owners = await tx | ||
| .select({ userId: memberships.userId }) | ||
| .from(memberships) | ||
| .where(and(eq(memberships.orgId, orgId), eq(memberships.role, 'owner'))) | ||
| .all(); | ||
| if (owners.length <= 1) throw error(400, 'the last owner cannot be demoted — promote a teammate to owner first'); | ||
| } | ||
| await tx | ||
| .update(memberships) | ||
| .set({ role }) | ||
| .where(and(eq(memberships.userId, targetUserId), eq(memberships.orgId, orgId))); | ||
| }); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Roles that gate authorization checks are read outside the write transaction. All three functions load a membership role with membershipOf before the write, then use that stale role to decide whether an invariant or authorization check applies. A concurrent role change between the read and the write bypasses the check. Re-read the membership row inside the transaction, and put the write in a transaction where none exists.
src/lib/server/org.ts#L318-L341: move thetargetlookup inside thedb.transactioncallback and gate the owner-count check on the freshly read role.src/lib/server/org.ts#L348-L358: open a transaction, re-read the target membership inside it, apply the owner and admin checks there, then delete.src/lib/server/org.ts#L365-L388: re-read the leaver's membership inside the transaction and use that role for the last-owner check instead of the outerm.role.
📍 Affects 1 file
src/lib/server/org.ts#L318-L341(this comment)src/lib/server/org.ts#L348-L358src/lib/server/org.ts#L365-L388
🤖 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 `@src/lib/server/org.ts` around lines 318 - 341, Role-based checks use
membership data read outside the write transaction, allowing concurrent role
changes to bypass invariants. In src/lib/server/org.ts lines 318-341, update
setMemberRole to move the target lookup into db.transaction and use that fresh
role for the owner-count check; in lines 348-358, open a transaction in the
member-removal function, re-read the target membership inside it, perform
owner/admin checks there, then delete; in lines 365-388, re-read the leaver
membership inside the transaction and use its role for the last-owner check
instead of outer m.role.
| setRole: async ({ request, locals }) => { | ||
| const user = requireUser(locals); | ||
| requireOrgRole(user, 'owner'); | ||
| const form = await request.formData(); | ||
| const targetUserId = String(form.get('userId') ?? ''); | ||
| const role = String(form.get('role') ?? '') as OrgRole; | ||
| return guard(() => setMemberRole(user.id, user.orgId, targetUserId, role)); | ||
| }, |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win
Missing runtime validation for role in setRole, and no test catches it. The setRole action casts the form value directly to OrgRole instead of validating it, unlike the sibling invite action; the test suite has no case that would expose this gap.
src/routes/(app)/org/+page.server.ts#L86-L93: validateroleis one of'owner' | 'admin' | 'member'before callingsetMemberRole, mirroring the check in theinviteaction (Line 77).src/routes/(app)/org/page.server.test.ts#L136-L144: add a test asserting thatactions.setRolewith an out-of-rangerolevalue returns a 400 failure.
📍 Affects 2 files
src/routes/(app)/org/+page.server.ts#L86-L93(this comment)src/routes/(app)/org/page.server.test.ts#L136-L144
🤖 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 `@src/routes/`(app)/org/+page.server.ts around lines 86 - 93, Validate the form
role in the setRole action before calling setMemberRole, accepting only 'owner',
'admin', or 'member' and returning the same 400 failure behavior used by invite
for invalid values; remove reliance on the unchecked OrgRole cast. In
src/routes/(app)/org/page.server.test.ts lines 136-144, add coverage asserting
that setRole with an out-of-range role returns a 400 failure.
| {#if data.invite.expired || data.invite.accepted} | ||
| <p class="error-box" role="alert">This invite link is no longer valid — ask for a new one.</p> | ||
| {:else if !data.signedIn} | ||
| <div class="card"> | ||
| <p> | ||
| You've been invited to join <strong>{data.invite.orgName}</strong> as | ||
| <span class="badge neutral">{data.invite.role}</span>. Sign in with Google, then reopen this link to | ||
| join. | ||
| </p> | ||
| <a class="btn" href="/login">Sign in with Google</a> | ||
| </div> | ||
| {:else} | ||
| <div class="card"> | ||
| <p> | ||
| You've been invited to join <strong>{data.invite.orgName}</strong> as | ||
| <span class="badge neutral">{data.invite.role}</span>. | ||
| </p> | ||
| <form method="POST" use:enhance> | ||
| <button class="btn" type="submit">Join {data.invite.orgName}</button> | ||
| </form> | ||
| </div> | ||
| {/if} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Add the required loading and empty states.
Provide a loading skeleton for this route. Use EmptyState for the unavailable invite state. Keep .error-box for actionable errors.
As per coding guidelines, “Every page must provide loading skeleton, empty state using EmptyState, .error-box error state, and populated state.”
🤖 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 `@src/routes/invite/`[token]/+page.svelte around lines 33 - 54, The invite
route’s conditional rendering lacks the required loading and EmptyState
representations. Update the page component’s state handling around the existing
invite branches to render a loading skeleton while data is pending, use
EmptyState when the invite is unavailable, and retain `.error-box` for expired
or accepted invites while preserving the current populated signed-in and
signed-out states.
Source: Coding guidelines
…ts, EmptyState invites, named revoke buttons
Review triage — round 3 (CodeRabbit, fixed in
|
|
Review triage — round 4 (CodeAnt suggestions)Of CodeAnt's four suggestions, three were fixed in earlier rounds (conditional invite claim, unconditional burn with test, scoped+verified session update). The fourth:
Check-suite notes (no action needed from this PR):
|




User description
Behavior
Phase D of the multi-tenancy plan — team management UI and single-use invite links on top of the Phase A–C tenancy core:
org.tsteam functions (D1):createOrg,renameOrg,createInvite,revokeInvite,previewInvite,acceptInvite,switchActiveOrg,listMembers,listOpenInvites,setMemberRole,removeMember,leaveOrg— all role-gated server-side (route checks are defense-in-depth, not the gate).POST /org/switch(D2): nav team switcher target; 303s to the dashboard./orgteam settings (D3+D4): rename, member roster with role/remove controls, invite link creation/revocation, create-another-team, leave team. Error states via.error-boxform failures (I12); every control labeled and target-named (I13).active_org_idand re-scopes every channel query (Phase C ownership)./invite/[token](D6): public landing; unknown tokens are plain 404s (no existence leak), burned/expired links render "no longer valid", signed-out visitors are asked to sign in and return.Deviations from the plan's verbatim code (reconciliations)
ROLE_RANK/requireRole(already inownership.ts). Single fail-closedrequireRolenow lives inorg.ts;requireOrgRoledelegates. Copy-paste rule.listMembersorders in SQL (createdAt,userId) instead oflocaleCompare— locale-sensitive sorting was already (validly) flagged in the Phase B review.createInvite400s onpersonal_fororgs;acceptInviterefuses any legacy/hand-written invite row pointing at a personal org (Phase C review finding, carried into this phase as planned).listOpenInvitesfails loudly on an unknown invite role (same guard aspreviewInvite) instead of silently filtering rows — "never silent fallbacks".guard()is generic so theinviteaction reuses it instead of duplicating the error-unwrapping try/catch.Verification
npm run check/npm run build/npm run testall green — 355 tests (30 new), including route tests for/org/switch,/orgload+actions, and/invite/[token].acceptedByburn fromacceptInvite→ the single-use test failed; reverted → green..playwright-cli/pr-org-team.png,.playwright-cli/pr-dashboard-acme.png,.playwright-cli/pr-dashboard-side.png(gitignored scratch dir — ask and I'll attach them in a comment).The second-profile invite-accept flow needs a real second Google account — covered instead by route tests (join → 303, membership written with the invite's role, token burned, reuse → 410,
acceptedflag on reload).Note: PR #51 (
chore-drizzle-skill) is unrelated work from a concurrent session and is not part of this phase.CodeAnt-AI Description
Add team management, switching, and single-use invitations
What Changed
Impact
✅ Self-service team administration✅ Single-use, expiring team invites✅ Safer team switching and ownership changes💡 Usage Guide
Checking Your Pull Request
Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.
Talking to CodeAnt AI
Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:
This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.
Example
Preserve Org Learnings with CodeAnt
You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:
This helps CodeAnt AI learn and adapt to your team's coding style and standards.
Example
Retrigger review
Ask CodeAnt AI to review the PR again, by typing:
Check Your Repository Health
To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.