diff --git a/api/src/database.types.ts b/api/src/database.types.ts index c22bd47..5c4ad28 100644 --- a/api/src/database.types.ts +++ b/api/src/database.types.ts @@ -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: [ diff --git a/api/src/helpers.ts b/api/src/helpers.ts index f852917..8127b08 100644 --- a/api/src/helpers.ts +++ b/api/src/helpers.ts @@ -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); diff --git a/api/src/tablo.ts b/api/src/tablo.ts index b3cac61..1a4352a 100644 --- a/api/src/tablo.ts +++ b/api/src/tablo.ts @@ -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 `, + to: recipientmail, + subject: "Vous avez été invité sur XTablo", + html: ` +

Bonjour !

+ +

${sender.email} vous a invité à rejoindre XTablo.

+ +

Un nouveau compte a été créé pour vous avec les identifiants suivants :

+
+

Email : ${recipientmail}

+

Mot de passe : ${password}

+
+ +

Veuillez cliquer sur le lien ci-dessous pour accepter l'invitation et configurer votre mot de passe.

+ +

Accepter et se connecter

+ `, + }); + + 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 `, 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); diff --git a/api/src/token.ts b/api/src/token.ts index e01bcd1..c59a7cf 100644 --- a/api/src/token.ts +++ b/api/src/token.ts @@ -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(""); +};