Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/lotus/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@
organization_views.InviteView.as_view(),
name="invite-to-organization",
),
path(
"app/organization/invite_link/",
organization_views.InviteLinkView.as_view(),
name="invite-to-organization-link",
),
# Stripe
path(
"stripe/webhook/", webhook_views.stripe_webhook_endpoint, name="stripe-webhook"
Expand Down
69 changes: 61 additions & 8 deletions backend/metering_billing/views/organization_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from django.conf import settings
from django.core.mail import BadHeaderError, EmailMultiAlternatives
from drf_spectacular.utils import extend_schema, inline_serializer
from metering_billing.models import TeamInviteToken
from metering_billing.exceptions import DuplicateCustomer
from metering_billing.models import TeamInviteToken, User
from metering_billing.permissions import ValidOrganization
from metering_billing.utils import now_plus_day
from metering_billing.utils import now_plus_day, now_utc
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
Expand All @@ -16,16 +17,15 @@
logger = logging.getLogger("django.server")


class InviteRequestSerializer(serializers.Serializer):
email = serializers.EmailField()


class InviteView(APIView):
permission_classes = [IsAuthenticated & ValidOrganization]

@extend_schema(
request=inline_serializer(
name="InviteRequestSerializer",
fields={
"email": serializers.EmailField(),
},
),
request=InviteRequestSerializer,
responses={
200: inline_serializer(
name="InviteResponseSerializer",
Expand All @@ -34,14 +34,22 @@ class InviteView(APIView):
},
)
def post(self, request, *args, **kwargs):
InviteRequestSerializer(data=request.data).is_valid(raise_exception=True)
email = request.data.get("email", None)
if User.objects.filter(email=email).exists():
raise DuplicateCustomer("User with that email already exists")
user = request.user
organization = request.organization
team = organization.team

token_object, created = TeamInviteToken.objects.get_or_create(
team=team, email=email, defaults={"user": user}
)
if token_object.expire_at > now_utc():
token_object.delete()
token_object = TeamInviteToken.objects.create(
team=team, email=email, user=user
)
if not created:
token_object.user = user
token_object.expire_at = now_plus_day()
Expand All @@ -58,6 +66,49 @@ def post(self, request, *args, **kwargs):
return Response({"email": email}, status=status.HTTP_200_OK)


class InviteLinkView(APIView):
permission_classes = [IsAuthenticated & ValidOrganization]

@extend_schema(
request=InviteRequestSerializer,
responses={
200: inline_serializer(
name="InviteLinkResponseSerializer",
fields={
"email": serializers.EmailField(),
"link": serializers.URLField(),
},
)
},
)
def post(self, request, *args, **kwargs):
InviteRequestSerializer(data=request.data).is_valid(raise_exception=True)
email = request.data.get("email", None)
user = request.user
organization = request.organization
team = organization.team
if User.objects.filter(email=email).exists():
raise DuplicateCustomer("User with that email already exists")
token_object, created = TeamInviteToken.objects.get_or_create(
team=team, email=email, defaults={"user": user}
)
if token_object.expire_at > now_utc():
token_object.delete()
token_object = TeamInviteToken.objects.create(
team=team, email=email, user=user
)
token_object.expire_at = now_plus_day()
if not created:
token_object.user = user
token_object.save()
path = "register?token=%s" % (token_object.token)
password_reset_url = "%s/%s" % (settings.APP_URL, path)

return Response(
{"email": email, "link": password_reset_url}, status=status.HTTP_200_OK
)


def send_invite_email(reset_url, organization_name, to):
subject = f"Join {organization_name} in Lotus"
body = f"Use this link to join {organization_name} team: {reset_url}"
Expand All @@ -77,3 +128,5 @@ def send_invite_email(reset_url, organization_name, to):
return False

return True

return True
2 changes: 2 additions & 0 deletions frontend/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,8 @@ export const Authentication = {
export const Organization = {
invite: (email: string): Promise<{ email: string }> =>
requests.post("app/organization/invite/", { email }),
invite_link: (email: string): Promise<{ email: string }> =>
requests.post("app/organization/invite_link/", { email }),
get: (): Promise<OrganizationType[]> => requests.get("app/organizations/"),
createOrg: (
organization_name: string,
Expand Down
104 changes: 89 additions & 15 deletions frontend/src/components/Settings/settings/tabs/TeamTab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { FC, useState } from "react";
import { useQuery, useMutation } from "react-query";
import { useNavigate } from "react-router-dom";
import { Table, Typography, Input, Button, Form, Tag } from "antd";
import { Table, Typography, Input, Button, Form, Tag, Modal } from "antd";
import { toast } from "react-toastify";
import { Organization } from "../../../../api/api";
import LoadingSpinner from "../../../LoadingSpinner";
Expand All @@ -17,11 +17,14 @@ interface FormElements extends HTMLFormElement {
const TeamTab: FC = () => {
const navigate = useNavigate();
const [email, setEmail] = useState("");

const [action, setAction] = useState<string | null>(null);
const [visibleInviteLink, setVisibleInviteLink] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const {
data: organization, // organization is the data returned from the query
isLoading,
isError,
refetch,
} = useQuery(["organization"], () =>
Organization.get().then((res) => res[0])
);
Expand All @@ -30,11 +33,17 @@ const TeamTab: FC = () => {
setEmail(event.target.value);
};

const mutation = useMutation(
const closeInviteLinkModal = () => {
setVisibleInviteLink(false);
setInviteLink(null);
};

const inviteMutation = useMutation(
(data: { email: string }) => Organization.invite(email),
{
onSuccess: (response) => {
toast.success("Invite sent");
refetch();
},
onError: (error: any) => {
if (error.response.data) {
Expand All @@ -50,14 +59,41 @@ const TeamTab: FC = () => {
}
);

const handleSendInviteEmail = (event: React.FormEvent<FormElements>) => {
mutation.mutate({ email });
const inviteLinkMutation = useMutation(
(data: { email: string }) => Organization.invite_link(email),
{
onSuccess: (response: any) => {
const link = response.link;
setInviteLink(link);
console.log(link);
if (link) {
setVisibleInviteLink(true);
}
refetch();
},
onError: (error: any) => {
console.log(error.response);
if (error.response.data.detail) {
toast.error(error.response.data.detail);
} else {
toast.error("Cannot generate an invite link now, try again later.");
}
},
}
);

const handleInvite = () => {
if (action === "sendInvite") {
inviteMutation.mutate({ email });
} else if (action === "generateInviteLink") {
inviteLinkMutation.mutate({ email });
}
};

return (
<div>
<Typography.Title level={2}>Team Members</Typography.Title>
<div className="flex flex-row space-x-10 ">
<div className="flex flex-row space-x-10">
<div className="px-4 sm:px-6 lg:px-8 basis-7/12 border-2 border-solid rounded border-[#EAEAEB]">
<div className="mt-8 flex flex-col">
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
Expand Down Expand Up @@ -87,7 +123,8 @@ const TeamTab: FC = () => {
dataIndex: "status",
key: "status",
render: (status: string) => {
const color = status === "Active" ? "green" : "yellow";
const color =
status === "Active" ? "green" : "yellow";
return (
<Tag color={color} key={status}>
{status.toUpperCase()}
Expand Down Expand Up @@ -119,10 +156,10 @@ const TeamTab: FC = () => {
</div>
</div>
</div>
<div className="basis-5/12 justify-self-center ">
<div className="basis-5/12 justify-self-center">
<h2>Invite to Team</h2>
<div className="w-96">
<Form onFinish={handleSendInviteEmail} name="normal_login">
<div className="w-112">
<Form onFinish={handleInvite} name="normal_login">
<Form.Item>
<label htmlFor="email">Email</label>
<Input
Expand All @@ -133,15 +170,52 @@ const TeamTab: FC = () => {
onChange={handleEmailChange}
/>
</Form.Item>
<Form.Item>
<Button htmlType="submit">Send Invite</Button>
</Form.Item>
</Form>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<Button
style={{ marginRight: "6px" }}
onClick={() => {
setAction("sendInvite");
handleInvite();
}}
>
Send Invite
</Button>
<Button
style={{ marginLeft: "6px" }}
onClick={() => {
setAction("generateInviteLink");
handleInvite();
}}
>
Generate Invite Link
</Button>
</div>
</div>
</div>
</div>

{mutation.isLoading && <LoadingSpinner />}
<Modal
visible={visibleInviteLink}
title={email + " Invite Link"}
onCancel={closeInviteLinkModal}
footer={
<Button key="Okay" onClick={closeInviteLinkModal} type="primary">
Okay
</Button>
}
>
<div className="flex flex-col">
<p className="text-2xl font-main" />
<p className="text-lg font-main">
Your invite link is:{" "}
{inviteLink ? <Input value={inviteLink} readOnly /> : "Loading..."}
</p>
</div>
</Modal>
{(action === "sendInvite" && inviteMutation.isLoading) ||
(action === "generateInviteLink" && inviteLinkMutation.isLoading && (
<LoadingSpinner />
))}
</div>
);
};
Expand Down