org member promotion/demotion

This commit is contained in:
Oliver Bryan
2026-01-09 08:17:01 +00:00
parent c361b5cc64
commit 903fd5f347
5 changed files with 189 additions and 59 deletions

View File

@@ -1,12 +1,20 @@
import type { BunRequest } from "bun";
import { getOrganisationById, getUserById, updateOrganisationMemberRole } from "../../db/queries";
import type { AuthedRequest } from "../../auth/middleware";
import {
getOrganisationById,
getOrganisationMemberRole,
getUserById,
updateOrganisationMemberRole,
} from "../../db/queries";
// /organisation/update-member-role?organisationId=1&userId=2&role=admin
export default async function organisationUpdateMemberRole(req: BunRequest) {
export default async function organisationUpdateMemberRole(req: AuthedRequest) {
const url = new URL(req.url);
const organisationId = url.searchParams.get("organisationId");
const userId = url.searchParams.get("userId");
const role = url.searchParams.get("role");
if (!role || !["admin", "member"].includes(role)) {
return new Response("Invalid role: must be either 'admin' or 'member'", { status: 400 });
}
if (!organisationId || !userId || !role) {
return new Response(
@@ -32,7 +40,21 @@ export default async function organisationUpdateMemberRole(req: BunRequest) {
return new Response(`user with id ${userId} not found`, { status: 404 });
}
const member = await updateOrganisationMemberRole(orgIdNumber, userIdNumber, role);
const requesterMember = await getOrganisationMemberRole(orgIdNumber, req.userId);
if (!requesterMember) {
return new Response("You are not a member of this organisation", { status: 403 });
}
let member = await getOrganisationMemberRole(orgIdNumber, userIdNumber);
if (!member) {
return new Response(`User with id ${userId} is not a member of this organisation`, { status: 404 });
}
if (requesterMember.role !== "owner" && requesterMember.role !== "admin") {
return new Response("Only owners and admins can update member roles", { status: 403 });
}
member = await updateOrganisationMemberRole(orgIdNumber, userIdNumber, role);
return Response.json(member);
}

View File

@@ -1,5 +1,5 @@
import type { OrganisationMemberResponse, OrganisationResponse } from "@issue/shared";
import { Plus, X } from "lucide-react";
import { ChevronDown, ChevronUp, Plus, X } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { AddMemberDialog } from "@/components/add-member-dialog";
@@ -30,9 +30,21 @@ function OrganisationsDialog({
const [members, setMembers] = useState<OrganisationMemberResponse[]>([]);
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
memberUserId: number;
memberName: string;
}>({ open: false, memberUserId: 0, memberName: "" });
title: string;
message: string;
confirmText: string;
processingText: string;
variant: "default" | "destructive";
onConfirm: () => Promise<void>;
}>({
open: false,
title: "",
message: "",
confirmText: "",
processingText: "",
variant: "default",
onConfirm: async () => {},
});
const refetchMembers = useCallback(async () => {
if (!selectedOrganisation) return;
@@ -60,34 +72,70 @@ function OrganisationsDialog({
}
}, [selectedOrganisation]);
const handleRemoveMember = async (memberUserId: number, memberName: string) => {
setConfirmDialog({ open: true, memberUserId, memberName });
};
const confirmRemoveMember = async () => {
const handleRoleChange = (memberUserId: number, memberName: string, currentRole: string) => {
if (!selectedOrganisation) return;
try {
await organisation.removeMember({
organisationId: selectedOrganisation.Organisation.id,
userId: confirmDialog.memberUserId,
onSuccess: () => {
setConfirmDialog({ open: false, memberUserId: 0, memberName: "" });
void refetchMembers();
},
onError: (error) => {
console.error(error);
},
});
} catch (err) {
console.error(err);
}
const action = currentRole === "admin" ? "demote" : "promote";
const newRole = currentRole === "admin" ? "member" : "admin";
setConfirmDialog({
open: true,
title: action === "promote" ? "Promote Member" : "Demote Member",
message: `Are you sure you want to ${action} ${memberName} to ${newRole}?`,
confirmText: action === "promote" ? "Promote" : "Demote",
processingText: action === "promote" ? "Promoting..." : "Demoting...",
variant: action === "demote" ? "destructive" : "default",
onConfirm: async () => {
try {
await organisation.updateMemberRole({
organisationId: selectedOrganisation.Organisation.id,
userId: memberUserId,
role: newRole,
onSuccess: () => {
closeConfirmDialog();
void refetchMembers();
},
onError: (error) => {
console.error(error);
},
});
} catch (err) {
console.error(err);
}
},
});
};
// useEffect(() => {
// if (!open) return;
// void refetchOrganisations();
// }, [open, refetchOrganisations]);
const closeConfirmDialog = () => {
setConfirmDialog((prev) => ({ ...prev, open: false }));
};
const handleRemoveMember = (memberUserId: number, memberName: string) => {
if (!selectedOrganisation) return;
setConfirmDialog({
open: true,
title: "Remove Member",
message: `Are you sure you want to remove ${memberName} from this organisation?`,
confirmText: "Remove",
processingText: "Removing...",
variant: "destructive",
onConfirm: async () => {
try {
await organisation.removeMember({
organisationId: selectedOrganisation.Organisation.id,
userId: memberUserId,
onSuccess: () => {
closeConfirmDialog();
void refetchMembers();
},
onError: (error) => {
console.error(error);
},
});
} catch (err) {
console.error(err);
}
},
});
};
useEffect(() => {
if (!open) return;
@@ -168,24 +216,47 @@ function OrganisationsDialog({
{member.OrganisationMember.role}
</span>
</div>
{(selectedOrganisation.OrganisationMember.role === "owner" ||
selectedOrganisation.OrganisationMember.role ===
"admin") &&
member.OrganisationMember.role !== "owner" &&
member.User.id !== user.id && (
<Button
variant="dummy"
size="none"
onClick={() =>
handleRemoveMember(
member.User.id,
member.User.name,
)
}
>
<X className="size-5 text-destructive" />
</Button>
)}
<div className="flex items-center gap-2">
{(selectedOrganisation.OrganisationMember.role ===
"owner" ||
selectedOrganisation.OrganisationMember.role ===
"admin") &&
member.OrganisationMember.role !== "owner" &&
member.User.id !== user.id && (
<>
<Button
variant="dummy"
size="none"
onClick={() =>
handleRoleChange(
member.User.id,
member.User.name,
member.OrganisationMember.role,
)
}
>
{member.OrganisationMember.role ===
"admin" ? (
<ChevronDown className="size-5 text-yellow-500" />
) : (
<ChevronUp className="size-5 text-green-500" />
)}
</Button>
<Button
variant="dummy"
size="none"
onClick={() =>
handleRemoveMember(
member.User.id,
member.User.name,
)
}
>
<X className="size-5 text-destructive" />
</Button>
</>
)}
</div>
</div>
))}
</div>
@@ -211,13 +282,13 @@ function OrganisationsDialog({
<ConfirmDialog
open={confirmDialog.open}
onOpenChange={(open) => setConfirmDialog({ ...confirmDialog, open })}
onConfirm={confirmRemoveMember}
title="Remove Member"
processingText="Removing..."
message={`Are you sure you want to remove ${confirmDialog.memberName} from this organisation?`}
confirmText="Remove"
variant="destructive"
onOpenChange={(open) => setConfirmDialog((prev) => ({ ...prev, open }))}
onConfirm={confirmDialog.onConfirm}
title={confirmDialog.title}
processingText={confirmDialog.processingText}
message={confirmDialog.message}
confirmText={confirmDialog.confirmText}
variant={confirmDialog.variant}
/>
</div>
</DialogContent>

View File

@@ -3,3 +3,4 @@ export { byUser } from "@/lib/server/organisation/byUser";
export { create } from "@/lib/server/organisation/create";
export { members } from "@/lib/server/organisation/members";
export { removeMember } from "@/lib/server/organisation/removeMember";
export { updateMemberRole } from "@/lib/server/organisation/updateMemberRole";

View File

@@ -0,0 +1,37 @@
import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from "..";
export async function updateMemberRole({
organisationId,
userId,
role,
onSuccess,
onError,
}: {
organisationId: number;
userId: number;
role: string;
} & ServerQueryInput) {
const url = new URL(`${getServerURL()}/organisation/update-member-role`);
url.searchParams.set("organisationId", `${organisationId}`);
url.searchParams.set("userId", `${userId}`);
url.searchParams.set("role", role);
const csrfToken = getCsrfToken();
const headers: HeadersInit = {};
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
const res = await fetch(url.toString(), {
method: "POST",
headers,
credentials: "include",
});
if (!res.ok) {
const error = await res.text();
onError?.(error || `failed to update member role (${res.status})`);
} else {
const data = await res.json();
onSuccess?.(data, res);
}
}