Skip to content

Commit b758a82

Browse files
INQTRclaude
andauthored
feat: add room permissions and ownership system (#150)
* feat: add room permissions and ownership system (#142) Introduce three-tier role system (owner, facilitator, participant) with four configurable permission categories (revealCards, gameFlow, issueManagement, roomSettings), each supporting three levels (everyone, facilitators, owner). - Schema: add ownerId/permissions to rooms, role to roomMemberships - Backend: permission enforcement on all mutations, role management API - Frontend: usePermissions hook, role badges, permission-gated UI - Owner lockdown when owner leaves (explicit leave only) - All new fields optional for full backward compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review feedback for room permissions - Remove duplicate transfer-ownership dialog from !isOpen branch - Fix canRemoveTarget fallback to return false (prevent flash of enabled controls) - Remove race-prone user creation fallback in rooms.create, use requireAuthUser - Add authoritative room.ownerId check in transferOwnership - Eliminate double room fetch by returning room from requireRoomPermission - Replace getEffectivePermissions({} as never) with DEFAULT_PERMISSIONS - Replace raw <select> with shadcn/ui Select for permissions dropdowns - Fix Select closing settings panel by tracking open state via ref - Improve permissions UI: add info tooltips, row descriptions, hover states - Simplify tooltip ternary in issue-item.tsx Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: transfer room ownership during anonymous-to-permanent account linking When a guest user creates a room then signs into a permanent account, the merge path in linkAnonymousToPermanent deletes the old anonymous user but left room.ownerId pointing to the deleted record. This caused a false "owner has left" lockdown banner. Now transfers room.ownerId to the permanent user before deleting the anonymous record. Also short-circuits isRoomOwnerAbsent call in getRoomWithRelatedData when no ownerId is set to reduce read amplification on every subscription update. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 54996e8 commit b758a82

24 files changed

+1540
-194
lines changed

convex/_generated/api.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,15 @@ import type * as model_canvas from "../model/canvas.js";
2626
import type * as model_cleanup from "../model/cleanup.js";
2727
import type * as model_demo from "../model/demo.js";
2828
import type * as model_issues from "../model/issues.js";
29+
import type * as model_permissions from "../model/permissions.js";
30+
import type * as model_roles from "../model/roles.js";
2931
import type * as model_rooms from "../model/rooms.js";
3032
import type * as model_timer from "../model/timer.js";
3133
import type * as model_users from "../model/users.js";
3234
import type * as model_votes from "../model/votes.js";
35+
import type * as permissions from "../permissions.js";
3336
import type * as presence from "../presence.js";
37+
import type * as roles from "../roles.js";
3438
import type * as rooms from "../rooms.js";
3539
import type * as scales from "../scales.js";
3640
import type * as timer from "../timer.js";
@@ -62,11 +66,15 @@ declare const fullApi: ApiFromModules<{
6266
"model/cleanup": typeof model_cleanup;
6367
"model/demo": typeof model_demo;
6468
"model/issues": typeof model_issues;
69+
"model/permissions": typeof model_permissions;
70+
"model/roles": typeof model_roles;
6571
"model/rooms": typeof model_rooms;
6672
"model/timer": typeof model_timer;
6773
"model/users": typeof model_users;
6874
"model/votes": typeof model_votes;
75+
permissions: typeof permissions;
6976
presence: typeof presence;
77+
roles: typeof roles;
7078
rooms: typeof rooms;
7179
scales: typeof scales;
7280
timer: typeof timer;

convex/issues.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { v } from "convex/values";
22
import { query, mutation } from "./_generated/server";
33
import * as Issues from "./model/issues";
4-
import { requireRoomMember } from "./model/auth";
4+
import { requireRoomPermission } from "./model/auth";
55

66
/**
77
* List all issues for a room, ordered by their order field
@@ -42,7 +42,7 @@ export const create = mutation({
4242
title: v.string(),
4343
},
4444
handler: async (ctx, args) => {
45-
await requireRoomMember(ctx, args.roomId);
45+
await requireRoomPermission(ctx, args.roomId, "issueManagement");
4646
return await Issues.createIssue(ctx, args);
4747
},
4848
});
@@ -58,7 +58,7 @@ export const updateTitle = mutation({
5858
handler: async (ctx, args) => {
5959
const issue = await ctx.db.get(args.issueId);
6060
if (!issue) throw new Error("Issue not found");
61-
await requireRoomMember(ctx, issue.roomId);
61+
await requireRoomPermission(ctx, issue.roomId, "issueManagement");
6262
await Issues.updateIssueTitle(ctx, args);
6363
},
6464
});
@@ -74,7 +74,7 @@ export const updateEstimate = mutation({
7474
handler: async (ctx, args) => {
7575
const issue = await ctx.db.get(args.issueId);
7676
if (!issue) throw new Error("Issue not found");
77-
await requireRoomMember(ctx, issue.roomId);
77+
await requireRoomPermission(ctx, issue.roomId, "issueManagement");
7878
await Issues.updateIssueEstimate(ctx, args);
7979
},
8080
});
@@ -87,7 +87,7 @@ export const remove = mutation({
8787
handler: async (ctx, args) => {
8888
const issue = await ctx.db.get(args.issueId);
8989
if (!issue) throw new Error("Issue not found");
90-
await requireRoomMember(ctx, issue.roomId);
90+
await requireRoomPermission(ctx, issue.roomId, "issueManagement");
9191
await Issues.removeIssue(ctx, args.issueId);
9292
},
9393
});
@@ -101,7 +101,7 @@ export const startVoting = mutation({
101101
issueId: v.id("issues"),
102102
},
103103
handler: async (ctx, args) => {
104-
await requireRoomMember(ctx, args.roomId);
104+
await requireRoomPermission(ctx, args.roomId, "gameFlow");
105105
await Issues.startVotingOnIssue(ctx, args);
106106
},
107107
});
@@ -115,7 +115,7 @@ export const reorder = mutation({
115115
issueIds: v.array(v.id("issues")),
116116
},
117117
handler: async (ctx, args) => {
118-
await requireRoomMember(ctx, args.roomId);
118+
await requireRoomPermission(ctx, args.roomId, "issueManagement");
119119
await Issues.reorderIssues(ctx, args);
120120
},
121121
});
@@ -126,7 +126,7 @@ export const reorder = mutation({
126126
export const clearCurrentIssue = mutation({
127127
args: { roomId: v.id("rooms") },
128128
handler: async (ctx, args) => {
129-
await requireRoomMember(ctx, args.roomId);
129+
await requireRoomPermission(ctx, args.roomId, "gameFlow");
130130
await Issues.clearCurrentIssue(ctx, args.roomId);
131131
},
132132
});

convex/model/auth.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { QueryCtx, MutationCtx } from "../_generated/server";
22
import { Id, Doc } from "../_generated/dataModel";
3+
import { PermissionCategory } from "../permissions";
4+
import { requirePermission } from "./permissions";
35

46
/**
57
* Auth identity returned by ctx.auth.getUserIdentity().
@@ -81,3 +83,26 @@ export async function requireRoomMember(
8183
}
8284
return { identity, user, membership };
8385
}
86+
87+
/**
88+
* Requires authentication, room membership, AND permission for a specific category.
89+
* Combines requireRoomMember + requirePermission in one call.
90+
*/
91+
export async function requireRoomPermission(
92+
ctx: QueryCtx | MutationCtx,
93+
roomId: Id<"rooms">,
94+
category: PermissionCategory
95+
): Promise<{
96+
identity: AuthIdentity;
97+
user: Doc<"users">;
98+
membership: Doc<"roomMemberships">;
99+
room: Doc<"rooms">;
100+
}> {
101+
const { identity, user, membership } = await requireRoomMember(ctx, roomId);
102+
const room = await ctx.db.get(roomId);
103+
if (!room) {
104+
throw new Error("Room not found");
105+
}
106+
await requirePermission(ctx, room, membership, category);
107+
return { identity, user, membership, room };
108+
}

convex/model/permissions.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { QueryCtx } from "../_generated/server";
2+
import { Doc } from "../_generated/dataModel";
3+
import {
4+
MemberRole,
5+
PermissionLevel,
6+
PermissionCategory,
7+
getEffectivePermissions,
8+
getEffectiveRole,
9+
} from "../permissions";
10+
11+
/**
12+
* Checks whether a role satisfies a required permission level.
13+
* - "everyone" → any role passes
14+
* - "facilitators" → facilitator or owner passes
15+
* - "owner" → only owner passes
16+
*/
17+
export function roleSatisfiesLevel(
18+
role: MemberRole,
19+
level: PermissionLevel
20+
): boolean {
21+
if (level === "everyone") return true;
22+
if (level === "facilitators") return role === "facilitator" || role === "owner";
23+
if (level === "owner") return role === "owner";
24+
return false;
25+
}
26+
27+
/**
28+
* Checks if the room owner has left (no active membership).
29+
* Only explicit leave removes membership — network disconnect does NOT trigger this.
30+
* Returns false for legacy rooms without an owner.
31+
*/
32+
export async function isRoomOwnerAbsent(
33+
ctx: QueryCtx,
34+
room: Doc<"rooms">
35+
): Promise<boolean> {
36+
if (!room.ownerId) return false; // Legacy room, no owner set
37+
38+
const ownerMembership = await ctx.db
39+
.query("roomMemberships")
40+
.withIndex("by_room_user", (q) =>
41+
q.eq("roomId", room._id).eq("userId", room.ownerId!)
42+
)
43+
.first();
44+
45+
return !ownerMembership;
46+
}
47+
48+
/**
49+
* Permission guard for mutations. Throws if the actor's role doesn't satisfy
50+
* the required permission level for the given category.
51+
*
52+
* Lockdown logic: if the room owner is absent AND the category's permission
53+
* level is "owner", throws an error. Facilitator-level and everyone-level
54+
* actions are unaffected by lockdown.
55+
*/
56+
export async function requirePermission(
57+
ctx: QueryCtx,
58+
room: Doc<"rooms">,
59+
membership: Doc<"roomMemberships">,
60+
category: PermissionCategory
61+
): Promise<void> {
62+
const permissions = getEffectivePermissions(room);
63+
const level = permissions[category];
64+
const role = getEffectiveRole(membership);
65+
66+
// Check lockdown: if owner is absent and this action requires owner level
67+
if (level === "owner") {
68+
const ownerAbsent = await isRoomOwnerAbsent(ctx, room);
69+
if (ownerAbsent) {
70+
throw new Error(
71+
"Room owner has left. Owner-level actions are disabled until the owner returns."
72+
);
73+
}
74+
}
75+
76+
if (!roleSatisfiesLevel(role, level)) {
77+
throw new Error(
78+
`Permission denied: ${category} requires "${level}" level`
79+
);
80+
}
81+
}
82+
83+
/**
84+
* Check if an actor can remove a target member.
85+
* - Owner can remove anyone
86+
* - Facilitator can remove participants only (not other facilitators or owner)
87+
* - Participant cannot remove anyone
88+
*/
89+
export function canRemoveMember(
90+
actorRole: MemberRole,
91+
targetRole: MemberRole
92+
): boolean {
93+
if (actorRole === "owner") return true;
94+
if (actorRole === "facilitator") return targetRole === "participant";
95+
return false;
96+
}
97+
98+
/**
99+
* Check if an actor can promote a target to facilitator.
100+
* Owner and facilitator can promote participants.
101+
*/
102+
export function canPromoteToFacilitator(actorRole: MemberRole): boolean {
103+
return actorRole === "owner" || actorRole === "facilitator";
104+
}
105+
106+
/**
107+
* Check if an actor can demote a facilitator to participant.
108+
* Only owner can demote.
109+
*/
110+
export function canDemoteFacilitator(actorRole: MemberRole): boolean {
111+
return actorRole === "owner";
112+
}
113+
114+
/**
115+
* Check if an actor can transfer ownership.
116+
* Only owner can transfer.
117+
*/
118+
export function canTransferOwnership(actorRole: MemberRole): boolean {
119+
return actorRole === "owner";
120+
}
121+
122+
/**
123+
* Check if an actor can change room permissions.
124+
* Only owner can change permissions.
125+
*/
126+
export function canChangePermissions(actorRole: MemberRole): boolean {
127+
return actorRole === "owner";
128+
}

0 commit comments

Comments
 (0)