mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
add + remove users from organisation
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Organisation, OrganisationMember } from "@issue/shared";
|
import { Organisation, OrganisationMember, User } from "@issue/shared";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { db } from "../client";
|
import { db } from "../client";
|
||||||
|
|
||||||
@@ -103,10 +103,21 @@ export async function getOrganisationMembers(organisationId: number) {
|
|||||||
.select()
|
.select()
|
||||||
.from(OrganisationMember)
|
.from(OrganisationMember)
|
||||||
.where(eq(OrganisationMember.organisationId, organisationId))
|
.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;
|
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) {
|
export async function removeOrganisationMember(organisationId: number, userId: number) {
|
||||||
await db
|
await db
|
||||||
.delete(OrganisationMember)
|
.delete(OrganisationMember)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const main = async () => {
|
|||||||
"/auth/login": withCors(routes.authLogin),
|
"/auth/login": withCors(routes.authLogin),
|
||||||
"/auth/me": withCors(withAuth(routes.authMe)),
|
"/auth/me": withCors(withAuth(routes.authMe)),
|
||||||
|
|
||||||
|
"/user/by-username": withCors(withAuth(routes.userByUsername)),
|
||||||
"/user/update": withCors(withAuth(routes.userUpdate)),
|
"/user/update": withCors(withAuth(routes.userUpdate)),
|
||||||
"/user/upload-avatar": withCors(routes.userUploadAvatar),
|
"/user/upload-avatar": withCors(routes.userUploadAvatar),
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import projectDelete from "./project/delete";
|
|||||||
import projectUpdate from "./project/update";
|
import projectUpdate from "./project/update";
|
||||||
import projectWithCreator from "./project/with-creator";
|
import projectWithCreator from "./project/with-creator";
|
||||||
import projectsWithCreators from "./project/with-creators";
|
import projectsWithCreators from "./project/with-creators";
|
||||||
|
import userByUsername from "./user/by-username";
|
||||||
import userUpdate from "./user/update";
|
import userUpdate from "./user/update";
|
||||||
import userUploadAvatar from "./user/upload-avatar";
|
import userUploadAvatar from "./user/upload-avatar";
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export const routes = {
|
|||||||
authLogin,
|
authLogin,
|
||||||
authMe,
|
authMe,
|
||||||
|
|
||||||
|
userByUsername,
|
||||||
userUpdate,
|
userUpdate,
|
||||||
userUploadAvatar,
|
userUploadAvatar,
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { BunRequest } from "bun";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import { createOrganisationMember, getOrganisationById, getUserById } from "../../db/queries";
|
import {
|
||||||
|
createOrganisationMember,
|
||||||
|
getOrganisationById,
|
||||||
|
getOrganisationMemberRole,
|
||||||
|
getUserById,
|
||||||
|
} from "../../db/queries";
|
||||||
|
|
||||||
// /organisation/add-member?organisationId=1&userId=2&role=member
|
// /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 url = new URL(req.url);
|
||||||
const organisationId = url.searchParams.get("organisationId");
|
const organisationId = url.searchParams.get("organisationId");
|
||||||
const userId = url.searchParams.get("userId");
|
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 });
|
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);
|
const member = await createOrganisationMember(orgIdNumber, userIdNumber, role);
|
||||||
|
|
||||||
return Response.json(member);
|
return Response.json(member);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { BunRequest } from "bun";
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
import { getOrganisationById, getUserById, removeOrganisationMember } from "../../db/queries";
|
import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries";
|
||||||
|
|
||||||
// /organisation/remove-member?organisationId=1&userId=2
|
// /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 url = new URL(req.url);
|
||||||
const organisationId = url.searchParams.get("organisationId");
|
const organisationId = url.searchParams.get("organisationId");
|
||||||
const userId = url.searchParams.get("userId");
|
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 });
|
return new Response(`organisation with id ${organisationId} not found`, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUserById(userIdNumber);
|
const memberToRemove = await getOrganisationMemberRole(orgIdNumber, userIdNumber);
|
||||||
if (!user) {
|
if (!memberToRemove) {
|
||||||
return new Response(`user with id ${userId} not found`, { status: 404 });
|
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);
|
await removeOrganisationMember(orgIdNumber, userIdNumber);
|
||||||
|
|||||||
19
packages/backend/src/routes/user/by-username.ts
Normal file
19
packages/backend/src/routes/user/by-username.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -14,6 +14,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["**", "!src-tauri/target", "!src-tauri/gen"]
|
"includes": ["**", "!dist", "!src-tauri/target", "!src-tauri/gen"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useCallback, useEffect, useState } from "react";
|
||||||
|
import { AddMemberDialog } from "@/components/add-member-dialog";
|
||||||
import { OrganisationSelect } from "@/components/organisation-select";
|
import { OrganisationSelect } from "@/components/organisation-select";
|
||||||
import { SettingsPageLayout } from "@/components/settings-page-layout";
|
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";
|
import { organisation } from "@/lib/server";
|
||||||
|
|
||||||
function Organisations() {
|
function Organisations() {
|
||||||
@@ -9,6 +14,12 @@ function Organisations() {
|
|||||||
|
|
||||||
const [organisations, setOrganisations] = useState<OrganisationResponse[]>([]);
|
const [organisations, setOrganisations] = useState<OrganisationResponse[]>([]);
|
||||||
const [selectedOrganisation, setSelectedOrganisation] = useState<OrganisationResponse | null>(null);
|
const [selectedOrganisation, setSelectedOrganisation] = useState<OrganisationResponse | null>(null);
|
||||||
|
const [members, setMembers] = useState<OrganisationMemberResponse[]>([]);
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
memberUserId: number;
|
||||||
|
memberName: string;
|
||||||
|
}>({ open: false, memberUserId: 0, memberName: "" });
|
||||||
|
|
||||||
const refetchOrganisations = useCallback(
|
const refetchOrganisations = useCallback(
|
||||||
async (options?: { selectOrganisationId?: number }) => {
|
async (options?: { selectOrganisationId?: number }) => {
|
||||||
@@ -52,6 +63,56 @@ function Organisations() {
|
|||||||
[user.id],
|
[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(() => {
|
useEffect(() => {
|
||||||
void refetchOrganisations();
|
void refetchOrganisations();
|
||||||
}, [refetchOrganisations]);
|
}, [refetchOrganisations]);
|
||||||
@@ -60,9 +121,13 @@ function Organisations() {
|
|||||||
setSelectedOrganisation((prev) => prev || organisations[0] || null);
|
setSelectedOrganisation((prev) => prev || organisations[0] || null);
|
||||||
}, [organisations]);
|
}, [organisations]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refetchMembers();
|
||||||
|
}, [refetchMembers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsPageLayout title="Organisations">
|
<SettingsPageLayout title="Organisations">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-2 -m-2">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<OrganisationSelect
|
<OrganisationSelect
|
||||||
organisations={organisations}
|
organisations={organisations}
|
||||||
@@ -75,23 +140,92 @@ function Organisations() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedOrganisation ? (
|
{selectedOrganisation ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<h2 className="text-xl font-600">{selectedOrganisation.Organisation.name}</h2>
|
<div className="w-xs border p-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<h2 className="text-xl font-600 mb-2">
|
||||||
Slug: {selectedOrganisation.Organisation.slug}
|
{selectedOrganisation.Organisation.name}
|
||||||
</p>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex flex-col gap-1">
|
||||||
Role: {selectedOrganisation.OrganisationMember.role}
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
Slug: {selectedOrganisation.Organisation.slug}
|
||||||
{selectedOrganisation.Organisation.description ? (
|
</p>
|
||||||
<p className="text-sm">{selectedOrganisation.Organisation.description}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
) : (
|
Role: {selectedOrganisation.OrganisationMember.role}
|
||||||
<p className="text-sm text-muted-foreground">No description</p>
|
</p>
|
||||||
)}
|
{selectedOrganisation.Organisation.description ? (
|
||||||
|
<p className="text-sm">{selectedOrganisation.Organisation.description}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No description</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border p-2">
|
||||||
|
<h2 className="text-xl font-600 mb-2">
|
||||||
|
{members.length} Member{members.length !== 1 ? "s" : ""}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-2 w-sm">
|
||||||
|
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
|
||||||
|
{members.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.OrganisationMember.id}
|
||||||
|
className="flex items-center justify-between p-2 border"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SmallUserDisplay user={member.User} />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(selectedOrganisation.OrganisationMember.role === "owner" ||
|
||||||
|
selectedOrganisation.OrganisationMember.role === "admin") && (
|
||||||
|
<AddMemberDialog
|
||||||
|
organisationId={selectedOrganisation.Organisation.id}
|
||||||
|
existingMembers={members.map((m) => m.User.username)}
|
||||||
|
onSuccess={refetchMembers}
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline">
|
||||||
|
Add user <Plus className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No organisations yet.</p>
|
<p className="text-sm text-muted-foreground">No organisations yet.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsPageLayout>
|
</SettingsPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
141
packages/frontend/src/components/add-member-dialog.tsx
Normal file
141
packages/frontend/src/components/add-member-dialog.tsx
Normal file
@@ -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<void>;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogTrigger asChild>{trigger || <Button variant="outline">Add Member</Button>}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className={"w-md"}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Member</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid mt-2">
|
||||||
|
<Field
|
||||||
|
label="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||||
|
submitAttempted={submitAttempted}
|
||||||
|
placeholder="Enter username"
|
||||||
|
error={error || undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2 w-full justify-end mt-2">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" type="button">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || (username.trim() === "" && submitAttempted)}
|
||||||
|
>
|
||||||
|
{submitting ? "Adding..." : "Add"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
packages/frontend/src/components/ui/confirm-dialog.tsx
Normal file
57
packages/frontend/src/components/ui/confirm-dialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">{message}</p>
|
||||||
|
<div className="flex gap-2 justify-end mt-4">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" type="button">
|
||||||
|
{cancelText}
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button variant={variant} onClick={handleConfirm} disabled={submitting}>
|
||||||
|
{submitting ? processingText : confirmText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export function Field({
|
|||||||
hidden = false,
|
hidden = false,
|
||||||
submitAttempted,
|
submitAttempted,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
@@ -18,6 +19,7 @@ export function Field({
|
|||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
submitAttempted?: boolean;
|
submitAttempted?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
const [internalTouched, setInternalTouched] = useState(false);
|
const [internalTouched, setInternalTouched] = useState(false);
|
||||||
const isTouched = submitAttempted || internalTouched;
|
const isTouched = submitAttempted || internalTouched;
|
||||||
@@ -43,12 +45,12 @@ export function Field({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onBlur={() => setInternalTouched(true)}
|
onBlur={() => setInternalTouched(true)}
|
||||||
name={label}
|
name={label}
|
||||||
aria-invalid={invalidMessage !== ""}
|
aria-invalid={error !== undefined || invalidMessage !== ""}
|
||||||
type={hidden ? "password" : "text"}
|
type={hidden ? "password" : "text"}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-end justify-end w-full text-xs -mb-0 -mt-1">
|
<div className="flex items-end justify-end w-full text-xs mb-0 -mt-1">
|
||||||
{invalidMessage !== "" ? (
|
{error || invalidMessage !== "" ? (
|
||||||
<Label className="text-destructive text-sm">{invalidMessage}</Label>
|
<Label className="text-destructive text-sm">{error ?? invalidMessage}</Label>
|
||||||
) : (
|
) : (
|
||||||
<Label className="opacity-0 text-sm">a</Label>
|
<Label className="opacity-0 text-sm">a</Label>
|
||||||
)}
|
)}
|
||||||
|
|||||||
32
packages/frontend/src/lib/server/organisation/addMember.ts
Normal file
32
packages/frontend/src/lib/server/organisation/addMember.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
|
export { addMember } from "./addMember";
|
||||||
export { byUser } from "./byUser";
|
export { byUser } from "./byUser";
|
||||||
export { create } from "./create";
|
export { create } from "./create";
|
||||||
|
export { members } from "./members";
|
||||||
|
export { removeMember } from "./removeMember";
|
||||||
|
|||||||
26
packages/frontend/src/lib/server/organisation/members.ts
Normal file
26
packages/frontend/src/lib/server/organisation/members.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/frontend/src/lib/server/user/byUsername.ts
Normal file
26
packages/frontend/src/lib/server/user/byUsername.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export { byUsername } from "./byUsername";
|
||||||
export { update } from "./update";
|
export { update } from "./update";
|
||||||
export { uploadAvatar } from "./uploadAvatar";
|
export { uploadAvatar } from "./uploadAvatar";
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type {
|
|||||||
OrganisationInsert,
|
OrganisationInsert,
|
||||||
OrganisationMemberInsert,
|
OrganisationMemberInsert,
|
||||||
OrganisationMemberRecord,
|
OrganisationMemberRecord,
|
||||||
|
OrganisationMemberResponse,
|
||||||
OrganisationRecord,
|
OrganisationRecord,
|
||||||
OrganisationResponse,
|
OrganisationResponse,
|
||||||
ProjectInsert,
|
ProjectInsert,
|
||||||
|
|||||||
@@ -116,3 +116,9 @@ export type OrganisationResponse = {
|
|||||||
Organisation: OrganisationRecord;
|
Organisation: OrganisationRecord;
|
||||||
OrganisationMember: OrganisationMemberRecord;
|
OrganisationMember: OrganisationMemberRecord;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OrganisationMemberResponse = {
|
||||||
|
OrganisationMember: OrganisationMemberRecord;
|
||||||
|
Organisation: OrganisationRecord;
|
||||||
|
User: UserRecord;
|
||||||
|
};
|
||||||
|
|||||||
7
todo.md
7
todo.md
@@ -1,7 +1,12 @@
|
|||||||
- org settings
|
- org settings
|
||||||
- add/invite user(s) to org
|
- sprints
|
||||||
- issues
|
- issues
|
||||||
- issue creator
|
- issue creator
|
||||||
- issue assignee
|
- issue assignee
|
||||||
- deadline
|
- deadline
|
||||||
|
- comments
|
||||||
|
- status
|
||||||
|
- sprints
|
||||||
- time tracking (linked to issues or standalone)
|
- 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
|
||||||
|
|||||||
Reference in New Issue
Block a user