Compare commits

...

1 Commits

Author SHA1 Message Date
codegen-sh[bot] a8c2612734 Add admin role to GroupUser
This change adds an admin role to GroupUser that allows group admins to:
1. Administer other users in the group
2. Change the group name

Changes include:
- Database migration to add isAdmin field to group_users table
- Updated GroupUser model to include isAdmin field
- Added isGroupAdmin policy utility function
- Updated group policy to allow group admins to update groups
- Added API endpoints for managing admin status
- Updated GroupUsersStore to handle admin functionality
- Added tests for the new functionality
2025-08-28 09:40:16 +00:00
10 changed files with 338 additions and 5 deletions
+3
View File
@@ -22,6 +22,9 @@ class GroupUser extends Model {
/** The group that the user belongs to. */
@Relation(() => Group, { onDelete: "cascade" })
group: Group;
/** Whether the user is an admin of the group. */
isAdmin: boolean;
}
export default GroupUser;
+33 -1
View File
@@ -43,10 +43,19 @@ export default class GroupUsersStore extends Store<GroupUser> {
};
@action
async create({ groupId, userId }: { groupId: string; userId: string }) {
async create({
groupId,
userId,
isAdmin = false,
}: {
groupId: string;
userId: string;
isAdmin?: boolean;
}) {
const res = await client.post("/groups.add_user", {
id: groupId,
userId,
isAdmin,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
@@ -70,6 +79,29 @@ export default class GroupUsersStore extends Store<GroupUser> {
});
}
@action
async updateUser({
groupId,
userId,
isAdmin,
}: {
groupId: string;
userId: string;
isAdmin: boolean;
}) {
const res = await client.post("/groups.update_user", {
id: groupId,
userId,
isAdmin,
});
invariant(res?.data, "Group Membership data should be available");
res.data.users.forEach(this.rootStore.users.add);
res.data.groups.forEach(this.rootStore.groups.add);
const groupMemberships = res.data.groupMemberships.map(this.add);
return groupMemberships[0];
}
@action
removeGroupMemberships = (groupId: string) => {
this.data.forEach((_, key) => {
@@ -0,0 +1,15 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("group_users", "isAdmin", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
},
async down(queryInterface) {
await queryInterface.removeColumn("group_users", "isAdmin");
},
};
+3
View File
@@ -65,6 +65,9 @@ class GroupUser extends Model<
@Column(DataType.UUID)
createdById: string;
@Column(DataType.BOOLEAN)
isAdmin: boolean;
get modelId() {
return this.groupId;
}
+11 -3
View File
@@ -1,6 +1,6 @@
import { Group, User, Team } from "@server/models";
import { allow } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable } from "./utils";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, isGroupAdmin } from "./utils";
allow(User, "createGroup", Team, (actor, team) =>
and(
@@ -26,10 +26,18 @@ allow(User, "read", Group, (actor, team) =>
)
);
allow(User, ["update", "delete"], Group, (actor, team) =>
allow(User, "update", Group, async (actor, group) => {
return and(
//
await isGroupAdmin(actor, group),
isTeamMutable(actor)
);
});
allow(User, "delete", Group, (actor, group) =>
and(
//
isTeamAdmin(actor, team),
isTeamAdmin(actor, group),
isTeamMutable(actor)
)
);
+33
View File
@@ -100,3 +100,36 @@ export function isCloudHosted() {
}
return true;
}
/**
* Check if the actor is an admin of the group.
*
* @param actor The actor to check
* @param model The group model to check
* @returns True if the actor is an admin of the group
*/
export async function isGroupAdmin(
actor: User,
model: Model | null | undefined
): Promise<boolean> {
if (!model || !("id" in model)) {
return false;
}
// Team admins are always group admins
if (isTeamAdmin(actor, model)) {
return true;
}
// Check if the user is a group admin
const { GroupUser } = await import("@server/models");
const membership = await GroupUser.findOne({
where: {
userId: actor.id,
groupId: model.id,
isAdmin: true,
},
});
return !!membership;
}
+1
View File
@@ -9,6 +9,7 @@ export default function presentGroupUser(
id: `${membership.userId}-${membership.groupId}`,
userId: membership.userId,
groupId: membership.groupId,
isAdmin: membership.isAdmin,
user: options?.includeUser ? presentUser(membership.user) : undefined,
};
}
+177
View File
@@ -61,6 +61,7 @@ describe("#groups.update", () => {
});
expect(res.status).toEqual(403);
});
describe("when user is admin", () => {
let user: User, group: Group;
beforeEach(async () => {
@@ -91,7 +92,53 @@ describe("#groups.update", () => {
expect(body.data.name).toBe("Test");
expect(body.data.externalId).toBe("123");
});
});
describe("when user is group admin", () => {
let user: User, group: Group;
beforeEach(async () => {
user = await buildUser();
group = await buildGroup({
teamId: user.teamId,
});
// Make the user a group admin
const admin = await buildAdmin({
teamId: user.teamId,
});
await server.post("/api/groups.add_user", {
body: {
token: admin.getJwtToken(),
id: group.id,
userId: user.id,
isAdmin: true,
},
});
});
it("allows group admin to edit a group", async () => {
const res = await server.post("/api/groups.update", {
body: {
token: user.getJwtToken(),
id: group.id,
name: "Test by Group Admin",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBe("Test by Group Admin");
});
});
describe("when checking for noop updates", () => {
let user: User, group: Group;
beforeEach(async () => {
user = await buildAdmin();
group = await buildGroup({
teamId: user.teamId,
});
});
it("does not create an event if the update is a noop", async () => {
const res = await server.post("/api/groups.update", {
body: {
@@ -554,6 +601,27 @@ describe("#groups.add_user", () => {
expect(users.length).toEqual(1);
});
it("should add user to group as admin", async () => {
const user = await buildAdmin();
const anotherUser = await buildUser({
teamId: user.teamId,
});
const group = await buildGroup({
teamId: user.teamId,
});
const res = await server.post("/api/groups.add_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
isAdmin: true,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.groupMemberships[0].isAdmin).toEqual(true);
});
it("should require authentication", async () => {
const res = await server.post("/api/groups.add_user");
expect(res.status).toEqual(401);
@@ -668,3 +736,112 @@ describe("#groups.remove_user", () => {
expect(body).toMatchSnapshot();
});
});
describe("#groups.update_user", () => {
it("should update user admin status in group", async () => {
const user = await buildAdmin();
const anotherUser = await buildUser({
teamId: user.teamId,
});
const group = await buildGroup({
teamId: user.teamId,
});
// First add the user to the group
await server.post("/api/groups.add_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
},
});
// Then update the user to be an admin
const res = await server.post("/api/groups.update_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
isAdmin: true,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.groupMemberships[0].isAdmin).toEqual(true);
// Update the user to not be an admin
const res2 = await server.post("/api/groups.update_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
isAdmin: false,
},
});
const body2 = await res2.json();
expect(res2.status).toEqual(200);
expect(body2.data.groupMemberships[0].isAdmin).toEqual(false);
});
it("should require authentication", async () => {
const res = await server.post("/api/groups.update_user");
expect(res.status).toEqual(401);
});
it("should require admin", async () => {
const user = await buildUser();
const anotherUser = await buildUser({
teamId: user.teamId,
});
const group = await buildGroup({
teamId: user.teamId,
});
// Add the user to the group
const admin = await buildAdmin({
teamId: user.teamId,
});
await server.post("/api/groups.add_user", {
body: {
token: admin.getJwtToken(),
id: group.id,
userId: anotherUser.id,
},
});
// Try to update as non-admin
const res = await server.post("/api/groups.update_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
isAdmin: true,
},
});
expect(res.status).toEqual(403);
});
it("should 404 if user is not in group", async () => {
const user = await buildAdmin();
const anotherUser = await buildUser({
teamId: user.teamId,
});
const group = await buildGroup({
teamId: user.teamId,
});
const res = await server.post("/api/groups.update_user", {
body: {
token: user.getJwtToken(),
id: group.id,
userId: anotherUser.id,
isAdmin: true,
},
});
expect(res.status).toEqual(404);
});
});
+49 -1
View File
@@ -251,7 +251,7 @@ router.post(
validate(T.GroupsAddUserSchema),
transaction(),
async (ctx: APIContext<T.GroupsAddUserReq>) => {
const { id, userId } = ctx.input.body;
const { id, userId, isAdmin } = ctx.input.body;
const actor = ctx.state.auth.user;
const { transaction } = ctx.state;
@@ -270,11 +270,17 @@ router.post(
},
defaults: {
createdById: actor.id,
isAdmin: isAdmin || false,
},
},
{ name: "add_user" }
);
// If the user already exists in the group, update the admin status if provided
if (isAdmin !== undefined && groupUser.isAdmin !== isAdmin) {
await groupUser.update({ isAdmin });
}
groupUser.user = user;
ctx.body = {
@@ -322,4 +328,46 @@ router.post(
}
);
router.post(
"groups.update_user",
auth(),
validate(T.GroupsUpdateUserSchema),
transaction(),
async (ctx: APIContext<T.GroupsUpdateUserReq>) => {
const { id, userId, isAdmin } = ctx.input.body;
const actor = ctx.state.auth.user;
const { transaction } = ctx.state;
const group = await Group.findByPk(id, { transaction });
authorize(actor, "update", group);
const user = await User.findByPk(userId, { transaction });
authorize(actor, "read", user);
const groupUser = await GroupUser.unscoped().findOne({
where: {
groupId: group.id,
userId: user.id,
},
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!groupUser) {
ctx.throw(404, "User is not a member of this group");
}
await groupUser.update({ isAdmin });
groupUser.user = user;
ctx.body = {
data: {
users: [presentUser(user)],
groupMemberships: [presentGroupUser(groupUser, { includeUser: true })],
groups: [await presentGroup(group)],
},
};
}
);
export default router;
+13
View File
@@ -85,6 +85,8 @@ export const GroupsAddUserSchema = z.object({
body: BaseIdSchema.extend({
/** User Id */
userId: z.string().uuid(),
/** Whether the user is an admin of the group */
isAdmin: z.boolean().optional().default(false),
}),
});
@@ -98,3 +100,14 @@ export const GroupsRemoveUserSchema = z.object({
});
export type GroupsRemoveUserReq = z.infer<typeof GroupsRemoveUserSchema>;
export const GroupsUpdateUserSchema = z.object({
body: BaseIdSchema.extend({
/** User Id */
userId: z.string().uuid(),
/** Whether the user is an admin of the group */
isAdmin: z.boolean(),
}),
});
export type GroupsUpdateUserReq = z.infer<typeof GroupsUpdateUserSchema>;