implement new invitation system

* fix comments on the frontend
* move jwt token service to its own module
* other fixes and updates
This commit is contained in:
Philipinho
2024-05-14 22:55:11 +01:00
parent 525990d6e5
commit eefe63d1cd
75 changed files with 10965 additions and 7846 deletions

View File

@ -0,0 +1,70 @@
import { Menu, ActionIcon, Text } from "@mantine/core";
import React from "react";
import { IconDots, IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import {
useResendInvitationMutation,
useRevokeInvitationMutation,
} from "@/features/workspace/queries/workspace-query.ts";
interface Props {
invitationId: string;
}
export default function InviteActionMenu({ invitationId }: Props) {
const resendInvitationMutation = useResendInvitationMutation();
const revokeInvitationMutation = useRevokeInvitationMutation();
const onResend = async () => {
await resendInvitationMutation.mutateAsync({ invitationId });
};
const onRevoke = async () => {
await revokeInvitationMutation.mutateAsync({ invitationId });
};
const openRevokeModal = () =>
modals.openConfirmModal({
title: "Revoke invitation",
children: (
<Text size="sm">
Are you sure you want to revoke this invitation? The user will not be
able to join the workspace.
</Text>
),
centered: true,
labels: { confirm: "Revoke", cancel: "Don't" },
confirmProps: { color: "red" },
onConfirm: onRevoke,
});
return (
<>
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onResend}>Resend invitation</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
onClick={openRevokeModal}
leftSection={<IconTrash size={16} stroke={2} />}
>
Revoke invitation
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}

View File

@ -1,50 +1,87 @@
import { Group, Box, Button, TagsInput, Space, Select } from "@mantine/core";
import { Group, Box, Button, TagsInput, Select } from "@mantine/core";
import WorkspaceInviteSection from "@/features/workspace/components/members/components/workspace-invite-section.tsx";
import React from "react";
import React, { useState } from "react";
import { MultiGroupSelect } from "@/features/group/components/multi-group-select.tsx";
import { UserRole } from "@/lib/types.ts";
import { userRoleData } from "@/features/workspace/types/user-role-data.ts";
import { useCreateInvitationMutation } from "@/features/workspace/queries/workspace-query.ts";
import { useNavigate } from "react-router-dom";
enum UserRole {
OWNER = "Owner",
ADMIN = "Admin",
MEMBER = "Member",
interface Props {
onClose: () => void;
}
export function WorkspaceInviteForm({ onClose }: Props) {
const [emails, setEmails] = useState<string[]>([]);
const [role, setRole] = useState<string | null>(UserRole.MEMBER);
const [groupIds, setGroupIds] = useState<string[]>([]);
const createInvitationMutation = useCreateInvitationMutation();
const navigate = useNavigate();
export function WorkspaceInviteForm() {
function handleSubmit(data) {
console.log(data);
async function handleSubmit() {
const validEmails = emails.filter((email) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
});
await createInvitationMutation.mutateAsync({
role: role.toLowerCase(),
emails: validEmails,
groupIds: groupIds,
});
onClose();
navigate("?tab=invites");
}
const handleGroupSelect = (value: string[]) => {
setGroupIds(value);
};
return (
<>
<Box maw="500" mx="auto">
<WorkspaceInviteSection />
<Space h="md" />
{/*<WorkspaceInviteSection /> */}
<TagsInput
description="Enter valid email addresses separated by comma or space"
label="Invite from email"
mt="sm"
description="Enter valid email addresses separated by comma or space [max: 50]"
label="Invite by email"
placeholder="enter valid emails addresses"
variant="filled"
splitChars={[",", " "]}
maxDropdownHeight={200}
maxTags={50}
onChange={setEmails}
/>
<Space h="md" />
<Select
mt="sm"
description="Select role to assign to all invited members"
label="Select role"
placeholder="Pick a role"
placeholder="Choose a role"
variant="filled"
data={Object.values(UserRole)}
data={userRoleData.filter((role) => role.value !== UserRole.OWNER)}
defaultValue={UserRole.MEMBER}
allowDeselect={false}
checkIconPosition="right"
onChange={setRole}
/>
<Group justify="center" mt="md">
<Button>Send invitation</Button>
<MultiGroupSelect
mt="sm"
description="Invited members will be granted access to spaces the groups can access"
label={"Add to groups"}
onChange={handleGroupSelect}
/>
<Group justify="flex-end" mt="md">
<Button
onClick={handleSubmit}
loading={createInvitationMutation.isPending}
>
Send invitation
</Button>
</Group>
</Box>
</>

View File

@ -10,7 +10,7 @@ export default function WorkspaceInviteModal() {
<Button onClick={open}>Invite members</Button>
<Modal
size="600"
size="550"
opened={opened}
onClose={close}
title="Invite new members"
@ -19,7 +19,7 @@ export default function WorkspaceInviteModal() {
<Divider size="xs" mb="xs" />
<ScrollArea h="80%">
<WorkspaceInviteForm />
<WorkspaceInviteForm onClose={close} />
</ScrollArea>
</Modal>
</>

View File

@ -0,0 +1,62 @@
import { Group, Table, Avatar, Text, Badge, Alert } from "@mantine/core";
import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
import React from "react";
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
import { IconInfoCircle } from "@tabler/icons-react";
import { format } from "date-fns";
export default function WorkspaceInvitesTable() {
const { data, isLoading } = useWorkspaceInvitationsQuery({
limit: 100,
});
return (
<>
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
Invited members who are yet to accept their invitation will appear here.
</Alert>
{data && (
<>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Date</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((invitation, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<Avatar src={invitation.email} />
<div>
<Text fz="sm" fw={500}>
{invitation.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>{getUserRoleLabel(invitation.role)}</Table.Td>
<Table.Td>
{format(invitation.createdAt, "MM/dd/yyyy")}
</Table.Td>
<Table.Td>
<InviteActionMenu invitationId={invitation.id} />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</>
)}
</>
);
}

View File

@ -12,7 +12,7 @@ import {
} from "@/features/workspace/types/user-role-data.ts";
export default function WorkspaceMembersTable() {
const { data, isLoading } = useWorkspaceMembersQuery();
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
const changeMemberRoleMutation = useChangeMemberRoleMutation();
const handleRoleChange = async (

View File

@ -6,12 +6,21 @@ import {
} from "@tanstack/react-query";
import {
changeMemberRole,
getInvitationById,
getPendingInvitations,
getWorkspace,
getWorkspaceMembers,
createInvitation,
resendInvitation,
revokeInvitation,
} from "@/features/workspace/services/workspace-service";
import { QueryParams } from "@/lib/types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import {
ICreateInvite,
IInvitation,
IWorkspace,
} from "@/features/workspace/types/workspace.types.ts";
export function useWorkspace(): UseQueryResult<IWorkspace, Error> {
return useQuery({
@ -44,3 +53,85 @@ export function useChangeMemberRoleMutation() {
},
});
}
export function useWorkspaceInvitationsQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IInvitation>, Error> {
return useQuery({
queryKey: ["invitations", params],
queryFn: () => getPendingInvitations(params),
});
}
export function useCreateInvitationMutation() {
const queryClient = useQueryClient();
return useMutation<void, Error, ICreateInvite>({
mutationFn: (data) => createInvitation(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Invitation successfully" });
// TODO: mutate cache
queryClient.invalidateQueries({
queryKey: ["invitations"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useResendInvitationMutation() {
return useMutation<
void,
Error,
{
invitationId: string;
}
>({
mutationFn: (data) => resendInvitation(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Invitation mail sent" });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRevokeInvitationMutation() {
const queryClient = useQueryClient();
return useMutation<
void,
Error,
{
invitationId: string;
}
>({
mutationFn: (data) => revokeInvitation(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Invitation revoked" });
queryClient.invalidateQueries({
queryKey: ["invitations"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useGetInvitationQuery(
invitationId: string,
): UseQueryResult<any, Error> {
return useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: ["invitations", invitationId],
queryFn: () => getInvitationById({ invitationId }),
enabled: !!invitationId,
});
}

View File

@ -1,11 +1,17 @@
import api from "@/lib/api-client";
import { IUser } from "@/features/user/types/user.types";
import { IWorkspace } from "../types/workspace.types";
import {
ICreateInvite,
IInvitation,
IWorkspace,
IAcceptInvite,
} from "../types/workspace.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { ITokenResponse } from "@/features/auth/types/auth.types.ts";
export async function getWorkspace(): Promise<IWorkspace> {
const req = await api.post<IWorkspace>("/workspace/info");
return req.data as IWorkspace;
return req.data;
}
// Todo: fix all paginated types
@ -18,8 +24,7 @@ export async function getWorkspaceMembers(
export async function updateWorkspace(data: Partial<IWorkspace>) {
const req = await api.post<IWorkspace>("/workspace/update", data);
return req.data as IWorkspace;
return req.data;
}
export async function changeMemberRole(data: {
@ -28,3 +33,42 @@ export async function changeMemberRole(data: {
}): Promise<void> {
await api.post("/workspace/members/role", data);
}
export async function getPendingInvitations(
params?: QueryParams,
): Promise<IPagination<IInvitation>> {
const req = await api.post("/workspace/invites", params);
return req.data;
}
export async function createInvitation(data: ICreateInvite) {
const req = await api.post("/workspace/invites/create", data);
return req.data;
}
export async function acceptInvitation(
data: IAcceptInvite,
): Promise<ITokenResponse> {
const req = await api.post("/workspace/invites/accept", data);
return req.data;
}
export async function resendInvitation(data: {
invitationId: string;
}): Promise<void> {
console.log(data);
await api.post("/workspace/invites/resend", data);
}
export async function revokeInvitation(data: {
invitationId: string;
}): Promise<void> {
await api.post("/workspace/invites/revoke", data);
}
export async function getInvitationById(data: {
invitationId: string;
}): Promise<IInvitation> {
const req = await api.post("/workspace/invites/info", data);
return req.data;
}

View File

@ -12,3 +12,25 @@ export interface IWorkspace {
createdAt: Date;
updatedAt: Date;
}
export interface ICreateInvite {
role: string;
emails: string[];
groupIds: string[];
}
export interface IInvitation {
id: string;
role: string;
email: string;
workspaceId: string;
invitedById: string;
createdAt: Date;
}
export interface IAcceptInvite {
invitationId: string;
name: string;
password: string;
token: string;
}