Skip to content

Commit fa5c433

Browse files
committed
frontend implementation for: l3montree-dev/devguard#246, adds ProjectMemberDialog, adds members table, tested and verified against backend
1 parent 566a774 commit fa5c433

11 files changed

Lines changed: 389 additions & 20 deletions

File tree

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Button } from "./ui/button";
1717
import { Form, FormItem, FormLabel } from "./ui/form";
1818
import { Input } from "./ui/input";
1919
import { useActiveOrg } from "@/hooks/useActiveOrg";
20+
import InlineCopy from "./common/InlineCopy";
2021

2122
interface Props {
2223
isOpen: boolean;
@@ -55,7 +56,13 @@ const MemberDialog: FunctionComponent<Props> = ({ isOpen, onOpenChange }) => {
5556
<DialogTitle>Invite member</DialogTitle>
5657
<DialogDescription>
5758
Invite a new member to your organization by entering their email
58-
address. An invitation will be sent.
59+
address.
60+
<div className="mt-2">
61+
<Callout intent="warning">
62+
Currently DevGuard does not send any E-Mails. Please copy the
63+
link and forward it manually.
64+
</Callout>
65+
</div>
5966
</DialogDescription>
6067
</DialogHeader>
6168
<Form {...form}>
@@ -71,7 +78,7 @@ const MemberDialog: FunctionComponent<Props> = ({ isOpen, onOpenChange }) => {
7178
<Callout intent="info">
7279
<p>
7380
Accept the invitation by visiting this link:{" "}
74-
{invitationCode}
81+
<InlineCopy content={invitationCode!} />
7582
</p>
7683
</Callout>
7784
)}

src/components/MembersTable.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,18 @@
1313
// limitations under the License.
1414

1515
import { classNames } from "@/utils/common";
16-
import React, { FunctionComponent } from "react";
16+
import { EllipsisVerticalIcon } from "@heroicons/react/24/outline";
17+
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
18+
import { FunctionComponent } from "react";
1719
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
1820
import { Badge } from "./ui/badge";
21+
import { buttonVariants } from "./ui/button";
1922
import {
2023
DropdownMenu,
2124
DropdownMenuContent,
2225
DropdownMenuItem,
26+
DropdownMenuSeparator,
2327
} from "./ui/dropdown-menu";
24-
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
25-
import { Button, buttonVariants } from "./ui/button";
26-
import { EllipsisVerticalIcon } from "@heroicons/react/24/outline";
2728

2829
interface Props {
2930
members: Array<{
@@ -32,8 +33,14 @@ interface Props {
3233
avatarUrl?: string;
3334
role?: string;
3435
}>;
36+
onRemoveMember: (id: string) => void;
37+
onChangeMemberRole: (id: string, role: "admin" | "member") => void;
3538
}
36-
const MembersTable: FunctionComponent<Props> = ({ members }) => {
39+
const MembersTable: FunctionComponent<Props> = ({
40+
members,
41+
onRemoveMember,
42+
onChangeMemberRole,
43+
}) => {
3744
return (
3845
<div className="overflow-hidden rounded-lg border">
3946
<table className="w-full text-sm">
@@ -84,8 +91,20 @@ const MembersTable: FunctionComponent<Props> = ({ members }) => {
8491
<EllipsisVerticalIcon className="h-5 w-5" />
8592
</DropdownMenuTrigger>
8693
<DropdownMenuContent>
87-
<DropdownMenuItem>Edit</DropdownMenuItem>
88-
<DropdownMenuItem>Delete</DropdownMenuItem>
94+
<DropdownMenuItem
95+
onClick={() => onChangeMemberRole(m.id, "admin")}
96+
>
97+
Make admin
98+
</DropdownMenuItem>
99+
<DropdownMenuItem
100+
onClick={() => onChangeMemberRole(m.id, "member")}
101+
>
102+
Make member
103+
</DropdownMenuItem>
104+
<DropdownMenuSeparator />
105+
<DropdownMenuItem onClick={() => onRemoveMember(m.id)}>
106+
Remove
107+
</DropdownMenuItem>
89108
</DropdownMenuContent>
90109
</DropdownMenu>
91110
</td>
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { FunctionComponent, useMemo, useState } from "react";
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogDescription,
6+
DialogFooter,
7+
DialogHeader,
8+
DialogTitle,
9+
} from "./ui/dialog";
10+
11+
import { useActiveOrg } from "@/hooks/useActiveOrg";
12+
import { browserApiClient } from "@/services/devGuardApi";
13+
import { toast } from "sonner";
14+
import { useActiveProject } from "../hooks/useActiveProject";
15+
import Callout from "./common/Callout";
16+
import {
17+
DropdownMenu,
18+
DropdownMenuCheckboxItem,
19+
DropdownMenuContent,
20+
DropdownMenuTrigger,
21+
} from "./ui/dropdown-menu";
22+
import { cn } from "../lib/utils";
23+
import { Button } from "./ui/button";
24+
import { useStore } from "../zustand/globalStoreProvider";
25+
26+
interface Props {
27+
isOpen: boolean;
28+
onOpenChange: (open: boolean) => void;
29+
}
30+
31+
const ProjectMemberDialog: FunctionComponent<Props> = ({
32+
isOpen,
33+
onOpenChange,
34+
}) => {
35+
const activeOrg = useActiveOrg();
36+
const activeProject = useActiveProject();
37+
const updateProject = useStore((s) => s.updateProject);
38+
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
39+
40+
const handleInviteSelectedMembers = async () => {
41+
const resp = await browserApiClient(
42+
"/organizations/" +
43+
activeOrg.slug +
44+
"/projects/" +
45+
activeProject.slug +
46+
"/members",
47+
{
48+
method: "POST",
49+
body: JSON.stringify({
50+
ids: selectedMembers,
51+
}),
52+
},
53+
);
54+
55+
if (!resp.ok) {
56+
toast.error("Failed to invite member");
57+
return;
58+
} else {
59+
updateProject({
60+
...activeProject,
61+
members: activeProject.members.concat(
62+
activeOrg.members
63+
.filter((e) => selectedMembers.includes(e.id))
64+
.map((e) => ({
65+
...e,
66+
role: "member",
67+
})),
68+
),
69+
});
70+
toast.success("Members successfully added to the project");
71+
onOpenChange(false);
72+
}
73+
};
74+
75+
const membersToInvite = useMemo(() => {
76+
const projectMemberIds = activeProject.members.reduce(
77+
(acc, m) => {
78+
acc[m.id] = true;
79+
return acc;
80+
},
81+
{} as { [key: string]: boolean },
82+
);
83+
return activeOrg.members.filter((m) => !projectMemberIds[m.id]);
84+
}, [activeProject.members, activeOrg.members]);
85+
86+
return (
87+
<Dialog open={isOpen} onOpenChange={onOpenChange}>
88+
<DialogContent>
89+
<DialogHeader>
90+
<DialogTitle>Invite member</DialogTitle>
91+
<DialogDescription>
92+
Invite a new member to your organization by entering their email
93+
address.
94+
</DialogDescription>
95+
</DialogHeader>
96+
<DropdownMenu>
97+
<DropdownMenuTrigger
98+
className={cn(
99+
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-card px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
100+
)}
101+
>
102+
Select the people you would like to invite to the project. (
103+
{selectedMembers.length} currently selected)
104+
</DropdownMenuTrigger>
105+
<DropdownMenuContent className="w-full">
106+
{membersToInvite.map((m) => (
107+
<DropdownMenuCheckboxItem
108+
onClick={() =>
109+
setSelectedMembers((prev) => {
110+
if (prev.includes(m.id)) {
111+
return prev.filter((el) => el !== m.id);
112+
}
113+
return prev.concat(m.id);
114+
})
115+
}
116+
checked={selectedMembers.includes(m.id)}
117+
key={m.id}
118+
>
119+
{m.name}
120+
</DropdownMenuCheckboxItem>
121+
))}
122+
</DropdownMenuContent>
123+
</DropdownMenu>
124+
<DialogFooter className="mt-2">
125+
<div className="flex flex-col items-end justify-end gap-2">
126+
<Button onClick={handleInviteSelectedMembers} type="submit">
127+
Invite
128+
</Button>
129+
</div>
130+
</DialogFooter>
131+
</DialogContent>
132+
</Dialog>
133+
);
134+
};
135+
136+
export default ProjectMemberDialog;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { CopyIcon } from "lucide-react";
2+
import { FunctionComponent } from "react";
3+
import { toast } from "sonner";
4+
5+
const InlineCopy: FunctionComponent<{ content: string }> = ({ content }) => {
6+
const handleCopy = () => {
7+
navigator.clipboard.writeText(content);
8+
toast.success("Copied to clipboard");
9+
};
10+
return (
11+
<span className="inline-flex items-center gap-1">
12+
{content}
13+
<button
14+
onClick={handleCopy}
15+
type="button"
16+
className="rounded-lg p-1 transition-all hover:bg-white/20"
17+
>
18+
<CopyIcon className="h-4 w-4" />
19+
</button>
20+
</span>
21+
);
22+
};
23+
24+
export default InlineCopy;

src/decorators/withSession.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export async function withSession(ctx: GetServerSidePropsContext) {
3838
}
3939

4040
// call the initial endpoint with the latest information available
41-
return session.data as User;
41+
return session.data as { identity: User };
4242
} catch (e: unknown) {
4343
if (isAxiosError(e)) {
4444
if (e.response?.status === 401) {

src/hooks/useActiveProject.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@ import { useStore } from "@/zustand/globalStoreProvider";
1717
// along with this program. If not, see <https://www.gnu.org/licenses/>.
1818
export function useActiveProject() {
1919
return useStore((s) => {
20-
return s.project;
20+
return s.project!;
2121
});
2222
}

src/pages/[organizationSlug]/projects/[projectSlug]/settings.tsx

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { GetServerSidePropsContext } from "next";
2-
import { FunctionComponent } from "react";
2+
import { FunctionComponent, useState } from "react";
33
import Page from "../../../../components/Page";
44

55
import { middleware } from "@/decorators/middleware";
@@ -32,6 +32,9 @@ import Section from "../../../../components/common/Section";
3232
import { Label } from "../../../../components/ui/label";
3333
import { convertRepos } from "../../../../hooks/useRepositorySearch";
3434
import ConnectToRepoSection from "../../../../components/ConnectToRepoSection";
35+
import MemberDialog from "../../../../components/MemberDialog";
36+
import MembersTable from "../../../../components/MembersTable";
37+
import ProjectMemberDialog from "../../../../components/ProjectMemberDialog";
3538

3639
interface Props {
3740
project: ProjectDTO;
@@ -42,6 +45,61 @@ const Index: FunctionComponent<Props> = ({ repositories }) => {
4245
const activeOrg = useActiveOrg();
4346
const project = useActiveProject();
4447
const updateProject = useStore((s) => s.updateProject);
48+
const [memberDialogOpen, setMemberDialogOpen] = useState(false);
49+
50+
const handleChangeMemberRole = async (
51+
id: string,
52+
role: "admin" | "member",
53+
) => {
54+
const resp = await browserApiClient(
55+
"/organizations/" +
56+
activeOrg.slug +
57+
"/projects/" +
58+
project?.slug +
59+
"/members/" +
60+
id,
61+
{
62+
method: "PUT",
63+
body: JSON.stringify({ role }),
64+
},
65+
);
66+
67+
if (resp.ok) {
68+
updateProject({
69+
...project!,
70+
members: project!.members.map((member) =>
71+
member.id === id ? { ...member, role } : member,
72+
),
73+
});
74+
toast.success("Role successfully changed");
75+
} else {
76+
toast.error("Failed to update member role");
77+
}
78+
};
79+
80+
const handleRemoveMember = async (id: string) => {
81+
const resp = await browserApiClient(
82+
"/organizations/" +
83+
activeOrg.slug +
84+
"/projects/" +
85+
project?.slug +
86+
"/members/" +
87+
id,
88+
{
89+
method: "DELETE",
90+
},
91+
);
92+
93+
if (resp.ok) {
94+
updateProject({
95+
...project!,
96+
members: project!.members.filter((member) => member.id !== id),
97+
});
98+
toast.success("Member deleted");
99+
} else {
100+
toast.error("Failed to remove member");
101+
}
102+
};
45103

46104
const projectMenu = useProjectMenu();
47105

@@ -97,6 +155,28 @@ const Index: FunctionComponent<Props> = ({ repositories }) => {
97155
repositoryId={project?.repositoryId}
98156
repositoryName={project?.repositoryName}
99157
/>
158+
<hr />
159+
<Section
160+
title="Member"
161+
description="Manage the members of your organization"
162+
>
163+
<MembersTable
164+
onChangeMemberRole={handleChangeMemberRole}
165+
onRemoveMember={handleRemoveMember}
166+
members={project.members}
167+
/>
168+
<ProjectMemberDialog
169+
isOpen={memberDialogOpen}
170+
onOpenChange={setMemberDialogOpen}
171+
/>
172+
173+
<div className="flex flex-row justify-end">
174+
<Button onClick={() => setMemberDialogOpen(true)}>
175+
Add Member
176+
</Button>
177+
</div>
178+
</Section>
179+
<hr />
100180
<Form {...form}>
101181
<form onSubmit={form.handleSubmit(handleUpdate)}>
102182
<ProjectForm forceVerticalSections={false} form={form} />

0 commit comments

Comments
 (0)