diff --git a/packages/backend/src/db/queries/organisations.ts b/packages/backend/src/db/queries/organisations.ts index cd0d991..98aefec 100644 --- a/packages/backend/src/db/queries/organisations.ts +++ b/packages/backend/src/db/queries/organisations.ts @@ -1,4 +1,4 @@ -import { Organisation, OrganisationMember } from "@issue/shared"; +import { Organisation, OrganisationMember, User } from "@issue/shared"; import { and, eq } from "drizzle-orm"; import { db } from "../client"; @@ -103,10 +103,21 @@ export async function getOrganisationMembers(organisationId: number) { .select() .from(OrganisationMember) .where(eq(OrganisationMember.organisationId, organisationId)) - .innerJoin(Organisation, eq(OrganisationMember.organisationId, Organisation.id)); + .innerJoin(Organisation, eq(OrganisationMember.organisationId, Organisation.id)) + .innerJoin(User, eq(OrganisationMember.userId, User.id)); return members; } +export async function getOrganisationMemberRole(organisationId: number, userId: number) { + const [member] = await db + .select() + .from(OrganisationMember) + .where( + and(eq(OrganisationMember.organisationId, organisationId), eq(OrganisationMember.userId, userId)), + ); + return member; +} + export async function removeOrganisationMember(organisationId: number, userId: number) { await db .delete(OrganisationMember) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 960ae55..82c329b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -18,6 +18,7 @@ const main = async () => { "/auth/login": withCors(routes.authLogin), "/auth/me": withCors(withAuth(routes.authMe)), + "/user/by-username": withCors(withAuth(routes.userByUsername)), "/user/update": withCors(withAuth(routes.userUpdate)), "/user/upload-avatar": withCors(routes.userUploadAvatar), diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index d385809..8336166 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -23,6 +23,7 @@ import projectDelete from "./project/delete"; import projectUpdate from "./project/update"; import projectWithCreator from "./project/with-creator"; import projectsWithCreators from "./project/with-creators"; +import userByUsername from "./user/by-username"; import userUpdate from "./user/update"; import userUploadAvatar from "./user/upload-avatar"; @@ -31,6 +32,7 @@ export const routes = { authLogin, authMe, + userByUsername, userUpdate, userUploadAvatar, diff --git a/packages/backend/src/routes/organisation/add-member.ts b/packages/backend/src/routes/organisation/add-member.ts index 79c4ea5..516e944 100644 --- a/packages/backend/src/routes/organisation/add-member.ts +++ b/packages/backend/src/routes/organisation/add-member.ts @@ -1,8 +1,13 @@ -import type { BunRequest } from "bun"; -import { createOrganisationMember, getOrganisationById, getUserById } from "../../db/queries"; +import type { AuthedRequest } from "../../auth/middleware"; +import { + createOrganisationMember, + getOrganisationById, + getOrganisationMemberRole, + getUserById, +} from "../../db/queries"; // /organisation/add-member?organisationId=1&userId=2&role=member -export default async function organisationAddMember(req: BunRequest) { +export default async function organisationAddMember(req: AuthedRequest) { const url = new URL(req.url); const organisationId = url.searchParams.get("organisationId"); const userId = url.searchParams.get("userId"); @@ -32,6 +37,20 @@ export default async function organisationAddMember(req: BunRequest) { return new Response(`user with id ${userId} not found`, { status: 404 }); } + const existingMember = await getOrganisationMemberRole(orgIdNumber, userIdNumber); + if (existingMember) { + return new Response("User is already a member of this organisation", { status: 409 }); + } + + const requesterMember = await getOrganisationMemberRole(orgIdNumber, req.userId); + if (!requesterMember) { + return new Response("You are not a member of this organisation", { status: 403 }); + } + + if (requesterMember.role !== "owner" && requesterMember.role !== "admin") { + return new Response("Only owners and admins can add members", { status: 403 }); + } + const member = await createOrganisationMember(orgIdNumber, userIdNumber, role); return Response.json(member); diff --git a/packages/backend/src/routes/organisation/remove-member.ts b/packages/backend/src/routes/organisation/remove-member.ts index dfde1a4..9122161 100644 --- a/packages/backend/src/routes/organisation/remove-member.ts +++ b/packages/backend/src/routes/organisation/remove-member.ts @@ -1,8 +1,8 @@ -import type { BunRequest } from "bun"; -import { getOrganisationById, getUserById, removeOrganisationMember } from "../../db/queries"; +import type { AuthedRequest } from "../../auth/middleware"; +import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries"; // /organisation/remove-member?organisationId=1&userId=2 -export default async function organisationRemoveMember(req: BunRequest) { +export default async function organisationRemoveMember(req: AuthedRequest) { const url = new URL(req.url); const organisationId = url.searchParams.get("organisationId"); const userId = url.searchParams.get("userId"); @@ -26,9 +26,22 @@ export default async function organisationRemoveMember(req: BunRequest) { return new Response(`organisation with id ${organisationId} not found`, { status: 404 }); } - const user = await getUserById(userIdNumber); - if (!user) { - return new Response(`user with id ${userId} not found`, { status: 404 }); + const memberToRemove = await getOrganisationMemberRole(orgIdNumber, userIdNumber); + if (!memberToRemove) { + return new Response("User is not a member of this organisation", { status: 404 }); + } + + if (memberToRemove.role === "owner") { + return new Response("Cannot remove the organisation owner", { status: 403 }); + } + + const requesterMember = await getOrganisationMemberRole(orgIdNumber, req.userId); + if (!requesterMember) { + return new Response("You are not a member of this organisation", { status: 403 }); + } + + if (requesterMember.role !== "owner" && requesterMember.role !== "admin") { + return new Response("Only owners and admins can remove members", { status: 403 }); } await removeOrganisationMember(orgIdNumber, userIdNumber); diff --git a/packages/backend/src/routes/user/by-username.ts b/packages/backend/src/routes/user/by-username.ts new file mode 100644 index 0000000..fc703ce --- /dev/null +++ b/packages/backend/src/routes/user/by-username.ts @@ -0,0 +1,19 @@ +import type { BunRequest } from "bun"; +import { getUserByUsername } from "../../db/queries"; + +// /user/by-username?username=someusername +export default async function userByUsername(req: BunRequest) { + const url = new URL(req.url); + const username = url.searchParams.get("username"); + + if (!username) { + return new Response("username is required", { status: 400 }); + } + + const user = await getUserByUsername(username); + if (!user) { + return new Response(`User with username '${username}' not found`, { status: 404 }); + } + + return Response.json(user); +} diff --git a/packages/frontend/biome.json b/packages/frontend/biome.json index 484aa77..d9c4ec1 100644 --- a/packages/frontend/biome.json +++ b/packages/frontend/biome.json @@ -14,6 +14,6 @@ } }, "files": { - "includes": ["**", "!src-tauri/target", "!src-tauri/gen"] + "includes": ["**", "!dist", "!src-tauri/target", "!src-tauri/gen"] } } diff --git a/packages/frontend/src/Organisations.tsx b/packages/frontend/src/Organisations.tsx index 8bdde04..c53cf3a 100644 --- a/packages/frontend/src/Organisations.tsx +++ b/packages/frontend/src/Organisations.tsx @@ -1,7 +1,12 @@ -import type { OrganisationResponse, UserRecord } from "@issue/shared"; +import type { OrganisationMemberResponse, OrganisationResponse, UserRecord } from "@issue/shared"; +import { Plus, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; +import { AddMemberDialog } from "@/components/add-member-dialog"; import { OrganisationSelect } from "@/components/organisation-select"; import { SettingsPageLayout } from "@/components/settings-page-layout"; +import SmallUserDisplay from "@/components/small-user-display"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { organisation } from "@/lib/server"; function Organisations() { @@ -9,6 +14,12 @@ function Organisations() { const [organisations, setOrganisations] = useState([]); const [selectedOrganisation, setSelectedOrganisation] = useState(null); + const [members, setMembers] = useState([]); + const [confirmDialog, setConfirmDialog] = useState<{ + open: boolean; + memberUserId: number; + memberName: string; + }>({ open: false, memberUserId: 0, memberName: "" }); const refetchOrganisations = useCallback( async (options?: { selectOrganisationId?: number }) => { @@ -52,6 +63,56 @@ function Organisations() { [user.id], ); + const refetchMembers = useCallback(async () => { + if (!selectedOrganisation) return; + try { + await organisation.members({ + organisationId: selectedOrganisation.Organisation.id, + onSuccess: (data) => { + const members = data as OrganisationMemberResponse[]; + members.sort((a, b) => { + const nameCompare = a.User.name.localeCompare(b.User.name); + return nameCompare !== 0 + ? nameCompare + : b.OrganisationMember.role.localeCompare(a.OrganisationMember.role); + }); + setMembers(members); + }, + onError: (error) => { + console.error(error); + setMembers([]); + }, + }); + } catch (err) { + console.error("error fetching members:", err); + setMembers([]); + } + }, [selectedOrganisation]); + + const handleRemoveMember = async (memberUserId: number, memberName: string) => { + setConfirmDialog({ open: true, memberUserId, memberName }); + }; + + const confirmRemoveMember = async () => { + 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); + } + }; + useEffect(() => { void refetchOrganisations(); }, [refetchOrganisations]); @@ -60,9 +121,13 @@ function Organisations() { setSelectedOrganisation((prev) => prev || organisations[0] || null); }, [organisations]); + useEffect(() => { + void refetchMembers(); + }, [refetchMembers]); + return ( -
+
{selectedOrganisation ? ( -
-

{selectedOrganisation.Organisation.name}

-

- Slug: {selectedOrganisation.Organisation.slug} -

-

- Role: {selectedOrganisation.OrganisationMember.role} -

- {selectedOrganisation.Organisation.description ? ( -

{selectedOrganisation.Organisation.description}

- ) : ( -

No description

- )} +
+
+

+ {selectedOrganisation.Organisation.name} +

+
+

+ Slug: {selectedOrganisation.Organisation.slug} +

+

+ Role: {selectedOrganisation.OrganisationMember.role} +

+ {selectedOrganisation.Organisation.description ? ( +

{selectedOrganisation.Organisation.description}

+ ) : ( +

No description

+ )} +
+
+
+

+ {members.length} Member{members.length !== 1 ? "s" : ""} +

+
+
+ {members.map((member) => ( +
+
+ + + {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") && ( + m.User.username)} + onSuccess={refetchMembers} + trigger={ + + } + /> + )} +
+
) : (

No organisations yet.

)} + + 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" + />
); diff --git a/packages/frontend/src/components/add-member-dialog.tsx b/packages/frontend/src/components/add-member-dialog.tsx new file mode 100644 index 0000000..e2c5f74 --- /dev/null +++ b/packages/frontend/src/components/add-member-dialog.tsx @@ -0,0 +1,141 @@ +import { type FormEvent, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Field } from "@/components/ui/field"; +import { organisation, user } from "@/lib/server"; + +export function AddMemberDialog({ + organisationId, + existingMembers, + trigger, + onSuccess, +}: { + organisationId: number; + existingMembers: string[]; + trigger?: React.ReactNode; + onSuccess?: () => void | Promise; +}) { + const [open, setOpen] = useState(false); + const [username, setUsername] = useState(""); + const [submitAttempted, setSubmitAttempted] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const reset = () => { + setUsername(""); + setSubmitAttempted(false); + setSubmitting(false); + setError(null); + }; + + const onOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + reset(); + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setSubmitAttempted(true); + + if (username.trim() === "") { + return; + } + + if (existingMembers.includes(username)) { + setError("user is already a member of this organisation"); + return; + } + + setSubmitting(true); + try { + let userId: number | null = null; + await user.byUsername({ + username, + onSuccess: (userData) => { + userId = userData.id; + }, + onError: (err) => { + setError(err || "user not found"); + setSubmitting(false); + }, + }); + + if (!userId) { + return; + } + + await organisation.addMember({ + organisationId, + userId, + role: "member", + onSuccess: async () => { + setOpen(false); + reset(); + try { + await onSuccess?.(); + } catch (actionErr) { + console.error(actionErr); + } + }, + onError: (err) => { + setError(err || "failed to add member"); + setSubmitting(false); + }, + }); + } catch (err) { + console.error(err); + setError("failed to add member"); + setSubmitting(false); + } + }; + + return ( + + {trigger || } + + + + Add Member + + +
+
+ setUsername(e.target.value)} + validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} + submitAttempted={submitAttempted} + placeholder="Enter username" + error={error || undefined} + /> + +
+ + + + +
+
+
+
+
+ ); +} diff --git a/packages/frontend/src/components/ui/confirm-dialog.tsx b/packages/frontend/src/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000..c3d4bb4 --- /dev/null +++ b/packages/frontend/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { Button } from "./button"; +import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "./dialog"; + +export function ConfirmDialog({ + open, + onOpenChange, + onConfirm, + title, + message, + processingText = "Processing...", + confirmText = "Confirm", + cancelText = "Cancel", + variant = "default", +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + title: string; + message: string; + processingText?: string; + confirmText?: string; + cancelText?: string; + variant?: "default" | "destructive"; +}) { + const [submitting, setSubmitting] = useState(false); + + const handleConfirm = async () => { + setSubmitting(true); + try { + await onConfirm(); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {title} + +

{message}

+
+ + + + +
+
+
+ ); +} diff --git a/packages/frontend/src/components/ui/field.tsx b/packages/frontend/src/components/ui/field.tsx index 2fe9140..f3c1e35 100644 --- a/packages/frontend/src/components/ui/field.tsx +++ b/packages/frontend/src/components/ui/field.tsx @@ -10,6 +10,7 @@ export function Field({ hidden = false, submitAttempted, placeholder, + error, }: { label: string; value?: string; @@ -18,6 +19,7 @@ export function Field({ hidden?: boolean; submitAttempted?: boolean; placeholder?: string; + error?: string; }) { const [internalTouched, setInternalTouched] = useState(false); const isTouched = submitAttempted || internalTouched; @@ -43,12 +45,12 @@ export function Field({ onChange={onChange} onBlur={() => setInternalTouched(true)} name={label} - aria-invalid={invalidMessage !== ""} + aria-invalid={error !== undefined || invalidMessage !== ""} type={hidden ? "password" : "text"} /> -
- {invalidMessage !== "" ? ( - +
+ {error || invalidMessage !== "" ? ( + ) : ( )} diff --git a/packages/frontend/src/lib/server/organisation/addMember.ts b/packages/frontend/src/lib/server/organisation/addMember.ts new file mode 100644 index 0000000..a686ce9 --- /dev/null +++ b/packages/frontend/src/lib/server/organisation/addMember.ts @@ -0,0 +1,32 @@ +import { getAuthHeaders, getServerURL } from "@/lib/utils"; +import type { ServerQueryInput } from ".."; + +export async function addMember({ + organisationId, + userId, + role = "member", + onSuccess, + onError, +}: { + organisationId: number; + userId: number; + role?: string; +} & ServerQueryInput) { + const url = new URL(`${getServerURL()}/organisation/add-member`); + url.searchParams.set("organisationId", `${organisationId}`); + url.searchParams.set("userId", `${userId}`); + url.searchParams.set("role", role); + + const res = await fetch(url.toString(), { + method: "POST", + headers: getAuthHeaders(), + }); + + if (!res.ok) { + const error = await res.text(); + onError?.(error || `failed to add member (${res.status})`); + } else { + const data = await res.json(); + onSuccess?.(data, res); + } +} diff --git a/packages/frontend/src/lib/server/organisation/index.ts b/packages/frontend/src/lib/server/organisation/index.ts index 1505ba3..158656d 100644 --- a/packages/frontend/src/lib/server/organisation/index.ts +++ b/packages/frontend/src/lib/server/organisation/index.ts @@ -1,2 +1,5 @@ +export { addMember } from "./addMember"; export { byUser } from "./byUser"; export { create } from "./create"; +export { members } from "./members"; +export { removeMember } from "./removeMember"; diff --git a/packages/frontend/src/lib/server/organisation/members.ts b/packages/frontend/src/lib/server/organisation/members.ts new file mode 100644 index 0000000..76ed788 --- /dev/null +++ b/packages/frontend/src/lib/server/organisation/members.ts @@ -0,0 +1,26 @@ +import type { OrganisationMemberResponse } from "@issue/shared"; +import { getAuthHeaders, getServerURL } from "@/lib/utils"; +import type { ServerQueryInput } from ".."; + +export async function members({ + organisationId, + onSuccess, + onError, +}: { + organisationId: number; +} & ServerQueryInput) { + const url = new URL(`${getServerURL()}/organisation/members`); + url.searchParams.set("organisationId", `${organisationId}`); + + const res = await fetch(url.toString(), { + headers: getAuthHeaders(), + }); + + if (!res.ok) { + const error = await res.text(); + onError?.(error || `failed to get members (${res.status})`); + } else { + const data = (await res.json()) as OrganisationMemberResponse[]; + onSuccess?.(data, res); + } +} diff --git a/packages/frontend/src/lib/server/organisation/removeMember.ts b/packages/frontend/src/lib/server/organisation/removeMember.ts new file mode 100644 index 0000000..925815f --- /dev/null +++ b/packages/frontend/src/lib/server/organisation/removeMember.ts @@ -0,0 +1,29 @@ +import { getAuthHeaders, getServerURL } from "@/lib/utils"; +import type { ServerQueryInput } from ".."; + +export async function removeMember({ + organisationId, + userId, + onSuccess, + onError, +}: { + organisationId: number; + userId: number; +} & ServerQueryInput) { + const url = new URL(`${getServerURL()}/organisation/remove-member`); + url.searchParams.set("organisationId", `${organisationId}`); + url.searchParams.set("userId", `${userId}`); + + const res = await fetch(url.toString(), { + method: "POST", + headers: getAuthHeaders(), + }); + + if (!res.ok) { + const error = await res.text(); + onError?.(error || `failed to remove member (${res.status})`); + } else { + const data = await res.json(); + onSuccess?.(data, res); + } +} diff --git a/packages/frontend/src/lib/server/user/byUsername.ts b/packages/frontend/src/lib/server/user/byUsername.ts new file mode 100644 index 0000000..3dc2cfe --- /dev/null +++ b/packages/frontend/src/lib/server/user/byUsername.ts @@ -0,0 +1,26 @@ +import type { UserRecord } from "@issue/shared"; +import { getAuthHeaders, getServerURL } from "@/lib/utils"; +import type { ServerQueryInput } from ".."; + +export async function byUsername({ + username, + onSuccess, + onError, +}: { + username: string; +} & ServerQueryInput) { + const url = new URL(`${getServerURL()}/user/by-username`); + url.searchParams.set("username", username); + + const res = await fetch(url.toString(), { + headers: getAuthHeaders(), + }); + + if (!res.ok) { + const error = await res.text(); + onError?.(error || `failed to get user (${res.status})`); + } else { + const data = (await res.json()) as UserRecord; + onSuccess?.(data, res); + } +} diff --git a/packages/frontend/src/lib/server/user/index.ts b/packages/frontend/src/lib/server/user/index.ts index 0ae39e6..1242974 100644 --- a/packages/frontend/src/lib/server/user/index.ts +++ b/packages/frontend/src/lib/server/user/index.ts @@ -1,2 +1,3 @@ +export { byUsername } from "./byUsername"; export { update } from "./update"; export { uploadAvatar } from "./uploadAvatar"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8e20670..46d7a34 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,6 +5,7 @@ export type { OrganisationInsert, OrganisationMemberInsert, OrganisationMemberRecord, + OrganisationMemberResponse, OrganisationRecord, OrganisationResponse, ProjectInsert, diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index 53a465d..0103295 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -116,3 +116,9 @@ export type OrganisationResponse = { Organisation: OrganisationRecord; OrganisationMember: OrganisationMemberRecord; }; + +export type OrganisationMemberResponse = { + OrganisationMember: OrganisationMemberRecord; + Organisation: OrganisationRecord; + User: UserRecord; +}; diff --git a/todo.md b/todo.md index 520237b..cef05d5 100644 --- a/todo.md +++ b/todo.md @@ -1,7 +1,12 @@ - org settings -- add/invite user(s) to org +- sprints - issues - issue creator - issue assignee - deadline + - comments + - status + - sprints - time tracking (linked to issues or standalone) +- for users without avatars, a random colour + initials is shown + - use username as a seed in a random selection of colour list