diff --git a/packages/backend/src/routes/organisation/update-member-role.ts b/packages/backend/src/routes/organisation/update-member-role.ts index 3c7a4b9..f629ad3 100644 --- a/packages/backend/src/routes/organisation/update-member-role.ts +++ b/packages/backend/src/routes/organisation/update-member-role.ts @@ -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); } diff --git a/packages/frontend/src/components/organisations-dialog.tsx b/packages/frontend/src/components/organisations-dialog.tsx index d8439c5..547318f 100644 --- a/packages/frontend/src/components/organisations-dialog.tsx +++ b/packages/frontend/src/components/organisations-dialog.tsx @@ -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([]); 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; + }>({ + 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} - {(selectedOrganisation.OrganisationMember.role === "owner" || - selectedOrganisation.OrganisationMember.role === - "admin") && - member.OrganisationMember.role !== "owner" && - member.User.id !== user.id && ( - - )} +
+ {(selectedOrganisation.OrganisationMember.role === + "owner" || + selectedOrganisation.OrganisationMember.role === + "admin") && + member.OrganisationMember.role !== "owner" && + member.User.id !== user.id && ( + <> + + + + )} +
))} @@ -211,13 +282,13 @@ function OrganisationsDialog({ 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} /> diff --git a/packages/frontend/src/lib/server/organisation/index.ts b/packages/frontend/src/lib/server/organisation/index.ts index ff1040e..3534e7f 100644 --- a/packages/frontend/src/lib/server/organisation/index.ts +++ b/packages/frontend/src/lib/server/organisation/index.ts @@ -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"; diff --git a/packages/frontend/src/lib/server/organisation/updateMemberRole.ts b/packages/frontend/src/lib/server/organisation/updateMemberRole.ts new file mode 100644 index 0000000..096ace2 --- /dev/null +++ b/packages/frontend/src/lib/server/organisation/updateMemberRole.ts @@ -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); + } +} diff --git a/todo.md b/todo.md index e2ef859..70a110b 100644 --- a/todo.md +++ b/todo.md @@ -15,4 +15,3 @@ - time tracking (linked to issues or standalone) - user preferences - "assign to me by default" option for new issues -- org member role promotion/demotion