Add invite by email feature

This commit is contained in:
Arthur Belleville 2025-07-03 22:24:41 +02:00
parent 6833089e0a
commit 2a1f0b7106
No known key found for this signature in database
3 changed files with 116 additions and 33 deletions

View file

@ -5,6 +5,7 @@ import { StreamChat } from "stream-chat";
import type { Transporter } from "nodemailer";
import { generateToken } from "./token.js";
import { config } from "./config.js";
import type { Tables } from "./database.types.js";
export const userRouter = new Hono<{
Variables: {
@ -44,12 +45,14 @@ userRouter.post("/invite", async (c) => {
const token = generateToken();
const { data: tablo, error: tabloError } = await supabase
const { data, error: tabloError } = await supabase
.from("tablos")
.select("*")
.eq("id", tablo_id)
.single();
const tablo = data as Tables<"tablos">;
if (tabloError) {
return c.json({ error: tabloError.message }, 500);
}
@ -80,10 +83,53 @@ userRouter.post("/invite", async (c) => {
from: `${sender.email} via XTablo <noreply@xtablo.com>`,
to: recipientmail,
subject: "Vous avez été invité à un tablo",
html: `<p>Vous avez été invité à un tablo avec <a href="${config.XTABLO_URL}/tablo/${tablo_id}?token=${token}">ce lien</a></p>`,
html: `<p>Vous avez été invité à un tablo avec <a href="${config.XTABLO_URL}/join/${tablo.name}?token=${token}">ce lien</a></p>`,
});
return c.json({
message: "Invite sent successfully",
});
});
userRouter.post("/join-tablo", async (c) => {
const { token } = await c.req.json();
const joiner = c.get("user");
const supabase = c.get("supabase");
const { data, error } = await supabase
.from("tablo_invites")
.select("id, tablo_id, invited_by")
.eq("invite_token", token)
.eq("invited_email", joiner.email)
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
if (!data) {
return c.json({ error: "Invalid token or email" }, 400);
}
const { id: invite_id, tablo_id, invited_by } = data;
const { error: tabloAccessError } = await supabase
.from("tablo_access")
.insert({
tablo_id,
user_id: joiner.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: invited_by,
});
if (tabloAccessError) {
return c.json({ error: tabloAccessError.message }, 500);
}
await supabase.from("tablo_invites").delete().eq("id", invite_id);
return c.json({ message: "Tablo joined successfully" });
});

View file

@ -30,3 +30,22 @@ export const useInviteUser = () => {
});
return mutate;
};
export const useJoinTablo = () => {
const { session } = useSession();
const { mutate } = useMutation({
mutationFn: async ({ token }: { token: string }) => {
const { data } = await api.post(
"/api/v1/users/join-tablo",
{ token },
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
return data;
},
});
return mutate;
};

View file

@ -1,13 +1,16 @@
import { useParams, useNavigate } from "react-router-dom";
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import { useUser } from "@ui/providers/UserStoreProvider";
import { useJoinTablo } from "@ui/hooks/invite";
export const JoinPage = () => {
const { tablo_name } = useParams<{ tablo_name: string }>();
// const [searchParams] = useSearchParams();
const navigate = useNavigate();
const user = useUser();
const joinTablo = useJoinTablo();
// const token = searchParams.get("token");
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
// const handleJoinTablo = async () => {
// if (!user || !tablo_name || !token) return;
@ -59,38 +62,40 @@ export const JoinPage = () => {
// );
// }
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-md">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Rejoindre Tablo
</h1>
<div className="bg-white p-6 rounded-lg shadow-lg mb-6">
<h2 className="text-xl font-semibold mb-2">{tablo_name}</h2>
<p className="text-gray-600 mb-4">
Vous avez é invité(e) à rejoindre ce tablo
</p>
</div>
<p className="text-gray-600 mb-6">
Veuillez vous connecter pour accepter cette invitation
</p>
<button
onClick={() => navigate("/login")}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
Se connecter
</button>
</div>
</div>
);
}
// if (!user) {
// return (
// <div className="min-h-screen flex items-center justify-center">
// <div className="text-center max-w-md">
// <h1 className="text-2xl font-bold text-gray-900 mb-4">
// Rejoindre Tablo &quot;{tablo_name}&quot;
// </h1>
// <div className="bg-white p-6 rounded-lg shadow-lg mb-6">
// <h2 className="text-xl font-semibold mb-2">{tablo_name}</h2>
// <p className="text-gray-600 mb-4">
// Vous avez été invité(e) à rejoindre ce tablo
// </p>
// </div>
// <p className="text-gray-600 mb-6">
// Veuillez vous connecter pour accepter cette invitation
// </p>
// <button
// onClick={() => navigate("/login")}
// className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
// >
// Se connecter
// </button>
// </div>
// </div>
// );
// }
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Rejoindre Tablo</h1>
<h1 className="text-3xl font-bold text-gray-900">
Rejoindre Tablo &quot;{tablo_name}&quot;
</h1>
</div>
<div className="bg-white p-8 rounded-lg shadow-lg">
@ -99,13 +104,26 @@ export const JoinPage = () => {
{tablo_name}
</h2>
<p className="text-gray-600">
Vous avez é invité(e) à rejoindre ce tablo
Vous avez é invité(e) par un enculé à rejoindre ce tablo
</p>
</div>
<div className="space-y-4">
<button
onClick={() => alert("Non implémenté")}
onClick={() => {
if (!user || !token) return;
joinTablo(
{ token },
{
onSuccess: () => {
navigate("/");
},
onError: (error) => {
alert(error.message);
},
}
);
}}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Accepter l&apos;invitation