Improve invites in api

This commit is contained in:
Arthur Belleville 2025-10-28 12:01:03 +01:00
parent d9a54be4c8
commit 2c2a41112c
No known key found for this signature in database
4 changed files with 110 additions and 22 deletions

View file

@ -353,6 +353,7 @@ export type Database = {
id: string
is_temporary: boolean
last_name: string | null
last_signed_in: string | null
name: string | null
short_user_id: string
}
@ -363,6 +364,7 @@ export type Database = {
id: string
is_temporary?: boolean
last_name?: string | null
last_signed_in?: string | null
name?: string | null
short_user_id: string
}
@ -373,6 +375,7 @@ export type Database = {
id?: string
is_temporary?: boolean
last_name?: string | null
last_signed_in?: string | null
name?: string | null
short_user_id?: string
}
@ -471,24 +474,30 @@ export type Database = {
}
tablo_invites: {
Row: {
created_at: string
id: number
invite_token: string
invited_by: string
invited_email: string
is_pending: boolean
tablo_id: string
}
Insert: {
created_at?: string
id?: number
invite_token: string
invited_by: string
invited_email: string
is_pending?: boolean
tablo_id: string
}
Update: {
created_at?: string
id?: number
invite_token?: string
invited_by?: string
invited_email?: string
is_pending?: boolean
tablo_id?: string
}
Relationships: [

View file

@ -165,7 +165,7 @@ export const checkTabloMember = async (c: Context, next: Next) => {
export const checkTabloAdmin = async (c: Context, next: Next) => {
const supabase = c.get("supabase");
const user = c.get("user");
const tabloId = c.req.param("tabloId");
const tabloId = c.req.param("tabloId") || c.req.query("tablo_id");
const isAdmin = await isTabloAdmin(supabase, tabloId, user.id);
if (!isAdmin) {
return c.json({ error: "You are not an admin of this tablo" }, 403);

View file

@ -5,14 +5,14 @@ import type { Transporter } from "nodemailer";
import type { StreamChat } from "stream-chat";
import { config } from "./config.js";
import type { Tables } from "./database.types.ts";
import { writeCalendarFileToR2 } from "./helpers.js";
import { checkTabloAdmin, writeCalendarFileToR2 } from "./helpers.js";
import {
authMiddleware,
r2Middleware,
regularUserCheckMiddleware,
streamChatMiddleware,
} from "./middleware.js";
import { generateToken } from "./token.js";
import { generatePassword, generateToken } from "./token.js";
import { transporter } from "./transporter.js";
import type { EventInsertInTablo, TabloInsert } from "./types.ts";
@ -361,32 +361,24 @@ tabloRouter.delete("/delete", async (c) => {
return c.json({ message: "Tablo deleted successfully" });
});
tabloRouter.post("/invite", regularUserCheckMiddleware, async (c) => {
tabloRouter.post("/invite/:tabloId", regularUserCheckMiddleware, checkTabloAdmin, async (c) => {
const sender = c.get("user");
const supabase = c.get("supabase");
const { email: recipientmail, tablo_id } = await c.req.json();
const { tabloId } = c.req.param();
const { email: recipientmail } = await c.req.json();
const token = generateToken();
const { data, error: tabloError } = await supabase
// Get tablo name
const { data: tablo, error: tabloError } = await supabase
.from("tablos")
.select("*")
.eq("id", tablo_id)
.select("name")
.eq("id", tabloId)
.single();
const tablo = data as Tables<"tablos">;
if (tabloError) {
return c.json({ error: tabloError.message }, 500);
}
if (!tablo) {
if (tabloError || !tablo) {
return c.json({ error: "Tablo not found" }, 404);
}
if (tablo.owner_id !== sender.id) {
return c.json({ error: "You are not allowed to invite users to this tablo" }, 400);
}
const token = generateToken();
const { data: introConfigData, error: introError } = await supabase
.from("user_introductions")
@ -401,15 +393,94 @@ tabloRouter.post("/invite", regularUserCheckMiddleware, async (c) => {
const { error } = await supabase.from("tablo_invites").insert({
invited_email: recipientmail,
tablo_id: tablo_id,
tablo_id: tabloId,
invited_by: sender.id,
invite_token: token,
is_pending: true,
});
if (error) {
return c.json({ error: error.message }, 500);
}
// Get user from recipient email
const { data: recipientUser, error: recipientError } = await supabase
.from("profiles")
.select("id")
.eq("email", recipientmail)
.maybeSingle();
if (recipientError) {
return c.json({ error: recipientError.message }, 500);
}
if (!recipientUser) {
// Create a new invited user and add them to the tablo
// Create a new user account for the invited email
const password = generatePassword();
const { data: newUser, error: createUserError } = await supabase.auth.admin.createUser({
email: recipientmail,
password: password,
email_confirm: true,
user_metadata: {
name: recipientmail.split("@")[0],
first_name: recipientmail,
last_name: "",
role: "invited_user",
},
app_metadata: {
// Can't do that: https://github.com/supabase/auth/issues/1280
// role: "invited_user",
},
});
if (createUserError) {
return c.json({ error: createUserError.message }, 500);
}
// Add the new user to the tablo
const { error: accessError } = await supabase.from("tablo_access").insert({
tablo_id: tabloId,
user_id: newUser.user.id,
granted_by: sender.id,
is_active: true,
// ** IMPORTANT **
is_admin: false,
// -------------
});
if (accessError) {
return c.json({ error: accessError.message }, 500);
}
// Send welcome email to the new user
await transporter.sendMail({
from: `${sender.email} via XTablo <noreply@xtablo.com>`,
to: recipientmail,
subject: "Vous avez été invité sur XTablo",
html: `
<p>Bonjour !</p>
<p>${sender.email} vous a invité à rejoindre XTablo.</p>
<p>Un nouveau compte a é créé pour vous avec les identifiants suivants :</p>
<div style="background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 20px; margin: 16px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<p style="margin: 0; font-family: monospace;"><strong>Email :</strong> ${recipientmail}</p>
<p style="margin: 8px 0 0 0; font-family: monospace;"><strong>Mot de passe :</strong> ${password}</p>
</div>
<p>Veuillez cliquer sur le lien ci-dessous pour accepter l'invitation et configurer votre mot de passe.</p>
<p><a href="${config.XTABLO_URL}/login" style="display: inline-block; padding: 12px 24px; background-color: #007bff; color: white; text-decoration: none; border-radius: 4px; font-weight: bold;">Accepter et se connecter</a></p>
`,
});
return c.json({
message: "User created and invite sent successfully",
});
}
// Let the user know that they have been invited to the tablo
await transporter.sendMail({
from: `${sender.email} via XTablo <noreply@xtablo.com>`,
to: recipientmail,
@ -443,6 +514,7 @@ tabloRouter.post("/join", async (c) => {
.select("id, tablo_id, invited_by")
.eq("invite_token", token)
.eq("invited_email", joiner.email)
.eq("is_pending", true)
.maybeSingle();
if (error) {
@ -471,7 +543,8 @@ tabloRouter.post("/join", async (c) => {
return c.json({ error: tabloAccessError.message }, 500);
}
await supabase.from("tablo_invites").delete().eq("id", invite_id);
// Mark invite as accepted instead of deleting (maintains audit trail)
await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", invite_id);
try {
const channel = streamServerClient.channel("messaging", tablo_id);

View file

@ -5,3 +5,9 @@ export const generateToken = (): string => {
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(36).padStart(2, "0")).join("");
};
export const generatePassword = (): string => {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(36).padStart(2, "0")).join("");
};