mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8c2612734 |
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -65,6 +65,9 @@ class GroupUser extends Model<
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
isAdmin: boolean;
|
||||
|
||||
get modelId() {
|
||||
return this.groupId;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user