Organization support

This commit is contained in:
Arthur Belleville 2026-03-04 21:47:44 +01:00
parent f2a35a85dc
commit 7c15ff3275
No known key found for this signature in database
15 changed files with 1374 additions and 250 deletions

View file

@ -4,13 +4,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { MAX_TABLO_LIMIT, verifyTabloLimitForUser } from "../../helpers/helpers.js";
const createSupabaseMock = ({
profileData = { plan: "free" },
profileData = { organization_id: 1 },
organizationPlans = [{ plan: "free" }],
profileError = null,
organizationPlanError = null,
tabloCount = 0,
tabloError = null,
} = {}) => {
const profileSingle = vi.fn(async () => ({ data: profileData, error: profileError }));
const profileEq = vi.fn(() => ({ single: profileSingle }));
const profileEq = vi.fn((column: string) => {
if (column === "id") {
return { single: profileSingle };
}
return Promise.resolve({ data: organizationPlans, error: organizationPlanError });
});
const profileSelect = vi.fn(() => ({ eq: profileEq }));
const tabloEq = vi.fn(async () => ({ count: tabloCount, error: tabloError }));
@ -74,7 +81,8 @@ describe("verifyTabloLimitForUser", () => {
it("denies free users that reached the tablo limit", async () => {
const supabase = createSupabaseMock({
profileData: { plan: "free" },
profileData: { organization_id: 1 },
organizationPlans: [{ plan: "free" }],
tabloCount: MAX_TABLO_LIMIT,
});
const ctx = createContext(supabase, user);
@ -88,7 +96,8 @@ describe("verifyTabloLimitForUser", () => {
it("allows free users below the limit to proceed", async () => {
const belowLimitCount = Math.max(0, MAX_TABLO_LIMIT - 1);
const supabase = createSupabaseMock({
profileData: { plan: "free" },
profileData: { organization_id: 1 },
organizationPlans: [{ plan: "free" }],
tabloCount: belowLimitCount,
});
const ctx = createContext(supabase, user);
@ -101,7 +110,8 @@ describe("verifyTabloLimitForUser", () => {
it("skips tablo count check for non-free plans", async () => {
const supabase = createSupabaseMock({
profileData: { plan: "pro" },
profileData: { organization_id: 1 },
organizationPlans: [{ plan: "standard" }],
});
const ctx = createContext(supabase, user);

View file

@ -252,14 +252,49 @@ describe("Tablo Endpoint", () => {
it("should block free plan users who reached the tablo limit", async () => {
const { data: profileData } = await supabaseAdmin
.from("profiles")
.select("plan")
.select("plan, organization_id")
.eq("id", ownerUser.userId)
.single();
const originalPlan = profileData?.plan ?? "standard";
const ownerOrganizationId = profileData?.organization_id;
if (!ownerOrganizationId) {
throw new Error("owner organization_id is required for this test");
}
await supabaseAdmin.from("profiles").update({ plan: "free" }).eq("id", ownerUser.userId);
const fillerTablos: Array<{
owner_id: string;
organization_id: number;
name: string;
status: string;
color: string;
}> = [];
// Ensure the organization has at least 10 tablos before creation attempt.
const { count: existingCount } = await supabaseAdmin
.from("tablos")
.select("id", { count: "exact", head: true })
.eq("organization_id", ownerOrganizationId)
.is("deleted_at", null);
const missingTablos = Math.max(0, 10 - (existingCount || 0));
for (let i = 0; i < missingTablos; i += 1) {
fillerTablos.push({
owner_id: ownerUser.userId,
organization_id: ownerOrganizationId,
name: `Limit filler ${i}`,
status: "todo",
color: "#DDDDEE",
});
}
if (fillerTablos.length > 0) {
await supabaseAdmin.from("tablos").insert(fillerTablos);
}
try {
const res = await createTabloRequest(ownerUser, client, {
name: "Free Limit Tablo",
@ -289,10 +324,6 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Tablo updated successfully");
// Note: The current update logic has a bug where it checks if tablo.name !== updatedTablo.name
// after the update, which will always be false. So channel.update is never called.
// This should be fixed in the production code to compare with the original name.
});
it("should deny temp user from updating their own tablo (regularUserCheck blocks temporary users)", async () => {
@ -310,7 +341,7 @@ describe("Tablo Endpoint", () => {
name: "Should Not Update",
});
expect(res.status).toBe(500);
expect(res.status).toBe(403);
});
it("should deny temp user from updating owner's tablo (regularUserCheck blocks temporary users)", async () => {
@ -338,7 +369,7 @@ describe("Tablo Endpoint", () => {
name: "Should Not Update",
});
expect(res.status).toBe(500);
expect(res.status).toBe(403);
});
});

View file

@ -1,12 +1,56 @@
import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { EventAndTablo, Tables } from "@xtablo/shared-types";
import type { EventAndTablo } from "@xtablo/shared-types";
import type { Context, Next } from "hono";
import type { Transporter } from "nodemailer";
import type { StreamChat } from "stream-chat";
import { generatePassword } from "./token.js";
export const MAX_TABLO_LIMIT = 1;
export const MAX_TABLO_LIMIT = 10;
export const MAX_TEAM_MEMBER_LIMIT = 3;
export type NormalizedPlan = "solo" | "team" | "annual";
const PLAN_WEIGHT: Record<NormalizedPlan, number> = {
solo: 1,
team: 2,
annual: 3,
};
export const normalizePlan = (plan: string | null | undefined): NormalizedPlan => {
if (!plan) return "solo";
if (plan === "annual" || plan === "beta") return "annual";
if (plan === "team" || plan === "standard") return "team";
return "solo";
};
export const getHighestPlan = (plans: Array<string | null | undefined>): NormalizedPlan => {
const normalizedPlans = plans.map((plan) => normalizePlan(plan));
if (normalizedPlans.length === 0) {
return "solo";
}
return normalizedPlans.sort((a, b) => PLAN_WEIGHT[b] - PLAN_WEIGHT[a])[0]!;
};
export const getOrganizationPlan = async (
supabase: SupabaseClient,
organizationId: number
): Promise<{ plan: NormalizedPlan; error?: string }> => {
const { data: plans, error } = await supabase
.from("profiles")
.select("plan")
.eq("organization_id", organizationId);
if (error) {
return { plan: "solo", error: error.message };
}
return {
plan: getHighestPlan((plans || []).map((profile) => profile.plan)),
};
};
export const generateICSFromEvents = (
events: EventAndTablo[],
@ -184,10 +228,10 @@ export const verifyTabloLimitForUser = async (c: Context, next: Next) => {
const supabase = c.get("supabase");
const user = c.get("user");
// Get user profile to check subscription status
// Get user profile to check organization and subscription status
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("*")
.select("organization_id")
.eq("id", user.id)
.single();
@ -195,13 +239,26 @@ export const verifyTabloLimitForUser = async (c: Context, next: Next) => {
return c.json({ error: "Failed to get user profile" }, 500);
}
const userProfile = profile as Tables<"profiles">;
const userProfile = profile as { organization_id: number | null };
if (userProfile.plan === "free") {
if (!userProfile.organization_id) {
return c.json({ error: "No organization found for current user" }, 400);
}
const { plan, error: planError } = await getOrganizationPlan(
supabase,
userProfile.organization_id
);
if (planError) {
return c.json({ error: "Failed to resolve organization plan" }, 500);
}
if (plan === "solo") {
const { count, error: countError } = await supabase
.from("tablos")
.select("id", { count: "exact" })
.eq("owner_id", user.id);
.eq("organization_id", userProfile.organization_id)
.is("deleted_at", null);
const tabloCount = count as number;

View file

@ -1,7 +1,7 @@
import type { Database, TablesInsert } from "@xtablo/shared-types";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import { createInvitedUser } from "../helpers/helpers.js";
import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js";
import type { EventTypeConfig } from "../helpers/slots.js";
import type { MaybeAuthEnv } from "../types/app.types.js";
@ -27,7 +27,7 @@ const bookSlot = factory.createHandlers(async (c) => {
// TODO: Verify that the owner_id is correct
const { data: ownerData, error: ownerError } = await supabase
.from("profiles")
.select("id, name, email")
.select("id, name, email, organization_id")
.eq("short_user_id", data.owner_short_id)
.single();
@ -83,8 +83,23 @@ const bookSlot = factory.createHandlers(async (c) => {
id: string;
name: string;
email: string;
organization_id: number | null;
};
const ownerId = ownerDataTyped.id;
const ownerOrganizationId = ownerDataTyped.organization_id;
if (!ownerOrganizationId) {
return c.json({ error: "Owner organization not found" }, 500);
}
const { plan: organizationPlan, error: organizationPlanError } = await getOrganizationPlan(
supabase,
ownerOrganizationId
);
if (organizationPlanError) {
return c.json({ error: "Failed to resolve organization plan" }, 500);
}
const bookerUserDataTyped = bookerUser as {
id: string;
@ -121,11 +136,11 @@ const bookSlot = factory.createHandlers(async (c) => {
`
id,
name,
owner_id,
organization_id,
tablo_access!inner(user_id)
`
)
.eq("owner_id", ownerId)
.eq("organization_id", ownerOrganizationId)
.eq("tablo_access.user_id", bookerUserDataTyped.id)
.is("deleted_at", null)
.limit(1);
@ -138,6 +153,28 @@ const bookSlot = factory.createHandlers(async (c) => {
let tabloData: { id: string; name: string } | null = null;
if (!existingTablo.length) {
if (organizationPlan === "solo") {
const { count: tabloCount, error: tabloCountError } = await supabase
.from("tablos")
.select("id", { count: "exact", head: true })
.eq("organization_id", ownerOrganizationId)
.is("deleted_at", null);
if (tabloCountError) {
return c.json({ error: "Failed to validate organization tablo limit" }, 500);
}
if ((tabloCount || 0) >= MAX_TABLO_LIMIT) {
return c.json(
{
error:
"Your organization has reached the Solo plan tablo limit. Upgrade your plan to create more tablos.",
},
403
);
}
}
// Create the tablo with the specified owner
const { data: insertedTablo, error } = await supabase
.from("tablos")
@ -146,6 +183,7 @@ const bookSlot = factory.createHandlers(async (c) => {
color: "bg-blue-500",
status: "todo",
owner_id: ownerId,
organization_id: ownerOrganizationId,
})
.select()
.single();
@ -182,11 +220,24 @@ const bookSlot = factory.createHandlers(async (c) => {
}
// Create Stream chat channel with the owner as creator
const { data: organizationMembers, error: organizationMembersError } = await supabase
.from("profiles")
.select("id")
.eq("organization_id", ownerOrganizationId);
if (organizationMembersError) {
return c.json({ error: "Failed to load organization members" }, 500);
}
const channelMembers = Array.from(
new Set((organizationMembers || []).map((member) => member.id).concat(bookerUserDataTyped.id))
);
const channel = streamServerClient.channel("messaging", tabloData.id, {
// @ts-ignore
name: tabloData.name,
created_by_id: ownerId,
members: [ownerId, bookerUserDataTyped.id],
members: channelMembers,
});
await channel.create();

View file

@ -12,7 +12,7 @@ import { generateToken } from "../helpers/token.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { AuthEnv } from "../types/app.types.js";
type PostTablo = Omit<TabloInsert, "owner_id"> & {
type PostTablo = Omit<TabloInsert, "owner_id" | "organization_id"> & {
events?: EventInsertInTablo[];
};
@ -26,11 +26,22 @@ const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
const typedPayload = data as PostTablo;
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", user.id)
.single();
if (profileError || !profile?.organization_id) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
const { data: insertedTablo, error } = await supabase
.from("tablos")
.insert({
...typedPayload,
owner_id: user.id,
organization_id: profile.organization_id,
events: undefined,
})
.select()
@ -42,12 +53,25 @@ const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
const tabloData = insertedTablo as Tables<"tablos">;
const { data: organizationMembers, error: membersError } = await supabase
.from("profiles")
.select("id")
.eq("organization_id", profile.organization_id);
if (membersError) {
return c.json({ error: "Failed to load organization members" }, 500);
}
const channelMembers = Array.from(
new Set((organizationMembers || []).map((member) => member.id).concat(user.id))
);
const streamServerClient = c.get("streamServerClient");
const channel = streamServerClient.channel("messaging", tabloData.id, {
// @ts-ignore
name: tabloData.name,
created_by_id: user.id,
members: [user.id],
members: channelMembers,
});
await channel.create();
@ -72,23 +96,32 @@ const updateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
const { id, ...tablo } = data;
const { data: access, error: accessError } = await supabase
.from("tablo_access")
.select("is_admin")
.eq("tablo_id", id)
.eq("user_id", user.id)
.eq("is_active", true)
.single();
if (accessError || !access || !access.is_admin) {
return c.json({ error: "You are not authorized to update this tablo" }, 403);
}
const { data: update, error } = await supabase
.from("tablos")
.update(tablo)
.eq("id", id)
// TODO: this condition will need to be modified in the future
.eq("owner_id", user.id)
.select()
.single();
const updatedTablo = update as Tables<"tablos">;
const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name;
if (error) {
return c.json({ error: error.message }, 500);
}
const updatedTablo = update as Tables<"tablos">;
const isUpdatingName = tablo.name !== undefined;
if (isUpdatingName) {
const channel = streamServerClient.channel("messaging", updatedTablo.id);
try {
@ -112,12 +145,6 @@ const deleteTablo = factory.createHandlers(async (c) => {
const { id } = data;
const { error } = await supabase
.from("tablos")
.update({ deleted_at: new Date().toISOString() })
.eq("id", id)
.eq("owner_id", user.id);
// Verify that the user has admin access to this tablo
const { data: tabloAccess, error: accessError } = await supabase
.from("tablo_access")
@ -131,6 +158,11 @@ const deleteTablo = factory.createHandlers(async (c) => {
return c.json({ error: "You are not authorized to delete this tablo" }, 403);
}
const { error } = await supabase
.from("tablos")
.update({ deleted_at: new Date().toISOString() })
.eq("id", id);
if (error) {
return c.json({ error: error.message }, 500);
}

View file

@ -2,6 +2,12 @@ import { DeleteObjectsCommand, ListObjectsV2Command, PutObjectCommand } from "@a
import type { Tables } from "@xtablo/shared-types";
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import {
createInvitedUser,
getOrganizationPlan,
MAX_TABLO_LIMIT,
MAX_TEAM_MEMBER_LIMIT,
} from "../helpers/helpers.js";
import type { AuthEnv } from "../types/app.types.js";
const factory = createFactory<AuthEnv>();
@ -260,6 +266,280 @@ const deleteAvatar = factory.createHandlers(async (c) => {
});
});
const getOrganization = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", user.id)
.single();
if (profileError || !profile?.organization_id) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
const organizationId = profile.organization_id;
const { data: organization, error: organizationError } = await supabase
.from("organizations")
.select("id, name")
.eq("id", organizationId)
.single();
if (organizationError || !organization) {
return c.json({ error: "Organization not found" }, 404);
}
const { data: members, error: membersError } = await supabase
.from("profiles")
.select("id, email, name, first_name, last_name, avatar_url, created_at, is_temporary, plan")
.eq("organization_id", organizationId)
.order("created_at", { ascending: true });
if (membersError) {
return c.json({ error: "Failed to load organization members" }, 500);
}
const { count: tabloCount, error: tabloCountError } = await supabase
.from("tablos")
.select("id", { count: "exact", head: true })
.eq("organization_id", organizationId)
.is("deleted_at", null);
if (tabloCountError) {
return c.json({ error: "Failed to load organization tablos" }, 500);
}
const { plan, error: planError } = await getOrganizationPlan(supabase, organizationId);
if (planError) {
return c.json({ error: "Failed to resolve organization plan" }, 500);
}
return c.json({
organization: {
id: organization.id,
name: organization.name,
plan,
member_count: members?.length || 0,
tablo_count: tabloCount || 0,
},
members: members || [],
});
});
const updateOrganization = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const body = await c.req.json();
const rawName = typeof body?.name === "string" ? body.name : "";
const name = rawName.trim();
if (name.length < 2 || name.length > 100) {
return c.json({ error: "Organization name must be between 2 and 100 characters" }, 400);
}
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("organization_id, is_temporary")
.eq("id", user.id)
.single();
if (profileError || !profile?.organization_id) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
if (profile.is_temporary) {
return c.json({ error: "Temporary users cannot update organization settings" }, 403);
}
const { error: updateError } = await supabase
.from("organizations")
.update({ name })
.eq("id", profile.organization_id);
if (updateError) {
return c.json({ error: updateError.message }, 500);
}
return c.json({ message: "Organization updated successfully" });
});
const inviteToOrganization = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const transporter = c.get("transporter");
const streamServerClient = c.get("streamServerClient");
const body = await c.req.json();
const rawEmail = typeof body?.email === "string" ? body.email : "";
const recipientEmail = rawEmail.trim().toLowerCase();
if (!recipientEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipientEmail)) {
return c.json({ error: "A valid email is required" }, 400);
}
const { data: senderProfile, error: senderError } = await supabase
.from("profiles")
.select("organization_id, email, is_temporary")
.eq("id", user.id)
.single();
if (senderError || !senderProfile?.organization_id || !senderProfile?.email) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
if (senderProfile.is_temporary) {
return c.json({ error: "Temporary users cannot invite organization members" }, 403);
}
if (senderProfile.email.toLowerCase() === recipientEmail) {
return c.json({ error: "You cannot invite yourself" }, 400);
}
const organizationId = senderProfile.organization_id;
const { plan, error: planError } = await getOrganizationPlan(supabase, organizationId);
if (planError) {
return c.json({ error: "Failed to resolve organization plan" }, 500);
}
const { data: existingUser, error: existingUserError } = await supabase
.from("profiles")
.select("id, organization_id")
.eq("email", recipientEmail)
.maybeSingle();
if (existingUserError) {
return c.json({ error: existingUserError.message }, 500);
}
if (existingUser) {
if (existingUser.organization_id === organizationId) {
return c.json({ message: "User is already in your organization" });
}
return c.json({ error: "This email already belongs to another organization" }, 409);
}
const { count: membersCount, error: membersCountError } = await supabase
.from("profiles")
.select("id", { count: "exact", head: true })
.eq("organization_id", organizationId);
if (membersCountError) {
return c.json({ error: "Failed to check organization size" }, 500);
}
if (plan === "solo" && (membersCount || 0) >= 1) {
return c.json(
{
error: `Solo plan allows a single user only. Upgrade to Team to invite collaborators.`,
},
403
);
}
if (plan === "team" && (membersCount || 0) >= MAX_TEAM_MEMBER_LIMIT) {
return c.json(
{
error: `Team plan allows up to ${MAX_TEAM_MEMBER_LIMIT} users. Upgrade to Annual to invite more.`,
},
403
);
}
const invitedUser = await createInvitedUser(
supabase,
streamServerClient,
transporter,
recipientEmail,
senderProfile.email
);
if (!invitedUser.success || !invitedUser.userId) {
return c.json({ error: invitedUser.error || "Failed to create invited user" }, 500);
}
const { data: createdProfile, error: createdProfileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", invitedUser.userId)
.single();
if (createdProfileError) {
return c.json({ error: "Invited user was created, but profile update failed" }, 500);
}
const oldOrganizationId = createdProfile.organization_id;
const { error: assignOrganizationError } = await supabase
.from("profiles")
.update({ organization_id: organizationId })
.eq("id", invitedUser.userId);
if (assignOrganizationError) {
return c.json({ error: "Failed to assign invited user to your organization" }, 500);
}
const { data: organizationTablos, error: organizationTablosError } = await supabase
.from("tablos")
.select("id")
.eq("organization_id", organizationId)
.is("deleted_at", null);
if (organizationTablosError) {
return c.json({ error: "Failed to sync organization access" }, 500);
}
if ((organizationTablos || []).length > 0) {
const accessRows = (organizationTablos || []).map((tablo) => ({
tablo_id: tablo.id,
user_id: invitedUser.userId!,
granted_by: user.id,
is_active: true,
is_admin: true,
}));
const { error: accessError } = await supabase.from("tablo_access").upsert(accessRows, {
onConflict: "tablo_id,user_id",
});
if (accessError) {
return c.json({ error: "Failed to sync invited user tablo permissions" }, 500);
}
}
for (const tablo of organizationTablos || []) {
const channel = streamServerClient.channel("messaging", tablo.id);
try {
await channel.addMembers([invitedUser.userId]);
} catch (error) {
console.error("Failed to add invited user to Stream channel:", error);
}
}
if (oldOrganizationId && oldOrganizationId !== organizationId) {
const { count: oldOrgMembersCount } = await supabase
.from("profiles")
.select("id", { count: "exact", head: true })
.eq("organization_id", oldOrganizationId);
if ((oldOrgMembersCount || 0) === 0) {
await supabase.from("organizations").delete().eq("id", oldOrganizationId);
}
}
return c.json({
message: "Invitation sent successfully",
limits: {
plan,
max_tablos_for_solo: MAX_TABLO_LIMIT,
max_members_for_team: MAX_TEAM_MEMBER_LIMIT,
},
});
});
export const getUserRouter = () => {
const userRouter = new Hono();
@ -268,6 +548,9 @@ export const getUserRouter = () => {
userRouter.post("/mark-temporary", ...markTemporary);
userRouter.post("/profile/avatar", ...uploadAvatar);
userRouter.delete("/profile/avatar", ...deleteAvatar);
userRouter.get("/organization", ...getOrganization);
userRouter.patch("/organization", ...updateOrganization);
userRouter.post("/organization/invite", ...inviteToOrganization);
return userRouter;
};

View file

@ -13,7 +13,6 @@ import {
import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography";
import { cva, type VariantProps } from "class-variance-authority";
import {
AlertCircle,
CalendarCheckIcon,
CalendarIcon,
Circle,
@ -43,14 +42,14 @@ import {
Waves,
Zap,
} from "lucide-react";
import { useTablosList } from "../hooks/tablos";
import { useState } from "react";
import { Separator } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { useLogout } from "../hooks/auth";
import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe";
import { normalizeBillingPlan, useCreateCheckoutSession } from "../hooks/stripe";
import { useTablosList } from "../hooks/tablos";
import { isProd, isStaging } from "../lib/env";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
import { getXtabloIcon } from "../utils/iconHelpers";
@ -79,7 +78,10 @@ function NavLink({ isActive, children }: NavLinkProps) {
"*:data-[ui=notification-badge]:font-semibold",
isActive
? "bg-purple-100 dark:bg-purple-900/30 font-semibold text-[#804EEC] dark:text-purple-300 *:data-[ui=notification-badge]:bg-transparent"
: ["font-medium", "text-gray-500 dark:text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"]
: [
"font-medium",
"text-gray-500 dark:text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker",
]
)}
>
{children}
@ -99,7 +101,8 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
const itemVariants = cva("", {
variants: {
variant: {
default: "text-gray-600 dark:text-gray-200/90 focus:bg-gray-200/80 dark:focus:bg-gray-500/80 focus:text-gray-900 dark:focus:text-white",
default:
"text-gray-600 dark:text-gray-200/90 focus:bg-gray-200/80 dark:focus:bg-gray-500/80 focus:text-gray-900 dark:focus:text-white",
destructive: "text-red-500/80 focus:bg-red-500/80 focus:text-white",
},
},
@ -301,17 +304,28 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
function getTabloIcon(color: string | null | undefined) {
switch (color) {
case "bg-blue-500": return Zap;
case "bg-green-500": return Leaf;
case "bg-purple-500": return Gem;
case "bg-red-500": return Flame;
case "bg-yellow-500": return Star;
case "bg-indigo-500": return Compass;
case "bg-pink-500": return Heart;
case "bg-teal-500": return Waves;
case "bg-orange-500": return Sun;
case "bg-cyan-500": return Sparkles;
default: return FolderIcon;
case "bg-blue-500":
return Zap;
case "bg-green-500":
return Leaf;
case "bg-purple-500":
return Gem;
case "bg-red-500":
return Flame;
case "bg-yellow-500":
return Star;
case "bg-indigo-500":
return Compass;
case "bg-pink-500":
return Heart;
case "bg-teal-500":
return Waves;
case "bg-orange-500":
return Sun;
case "bg-cyan-500":
return Sparkles;
default:
return FolderIcon;
}
}
@ -372,16 +386,17 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
const isReadOnly = useIsReadOnlyUser();
const user = useUser();
const { t } = useTranslation("navigation");
const { daysRemaining } = useTrialExpiration();
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
const TEAM_MONTHLY_PRICE_ID =
import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID ||
import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID ||
"";
// Show upsell when user is still in trial or using freemium tier
const shouldShowTrialUpsell =
daysRemaining !== null && user.plan === "none" && !user.is_temporary;
const shouldShowFreemiumUpsell = user.plan === "free" && !user.is_temporary;
const isUrgent = daysRemaining !== null && daysRemaining <= 3;
const currentPlan = normalizeBillingPlan(user.plan);
// Show upsell when organization is on Solo plan
const shouldShowSoloUpsell = currentPlan === "solo" && !user.is_temporary;
type List<T> = T[];
@ -484,12 +499,16 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
className="w-full"
aria-label={isCollapsed ? label : undefined}
>
<div className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}>
<div
className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}
>
<span className="[&>svg]:w-6 [&>svg]:h-6">{icon}</span>
<TypographyLarge
className={twMerge(
"text-base transition-all duration-300 font-normal",
isActive ? "text-[#804EEC] dark:text-purple-300" : "text-gray-500 dark:text-gray-300/90",
isActive
? "text-[#804EEC] dark:text-purple-300"
: "text-gray-500 dark:text-gray-300/90",
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
)}
>
@ -507,45 +526,23 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
{!isCollapsed && <RecentProjectsSection />}
<ul role="list" className={twMerge("mt-auto grid py-1", isCollapsed ? "pl-2.5 pr-3" : "")}>
{/* Trial upsell message */}
{shouldShowTrialUpsell && !isCollapsed && (
{/* Solo upsell message */}
{shouldShowSoloUpsell && !isCollapsed && (
<li className="mb-2">
<div
className={twMerge(
"mx-2 mb-2 p-3 rounded-lg border",
isUrgent
? "bg-linear-to-br from-red-50 to-orange-50 dark:from-red-950/20 dark:to-orange-950/20 border-red-200 dark:border-red-800"
: "bg-linear-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border-purple-200 dark:border-purple-800"
"bg-linear-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border-purple-200 dark:border-purple-800"
)}
>
<div className="flex items-start gap-2 mb-2">
{isUrgent ? (
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
) : (
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400 shrink-0 mt-0.5" />
)}
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p
className={twMerge(
"text-xs font-medium",
isUrgent
? "text-red-900 dark:text-red-100"
: "text-purple-900 dark:text-purple-100"
)}
>
{daysRemaining === 0
? "Dernier jour d'essai"
: `${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} restant${daysRemaining > 1 ? "s" : ""}`}
<p className="text-xs font-medium text-purple-900 dark:text-purple-100">
Plan Solo
</p>
<p
className={twMerge(
"text-xs mt-0.5",
isUrgent
? "text-red-700 dark:text-red-300"
: "text-purple-700 dark:text-purple-300"
)}
>
{isUrgent ? "Essayer Starter maintenant" : "Essayer Starter"}
<p className="text-xs mt-0.5 text-purple-700 dark:text-purple-300">
Passez au plan Team pour inviter jusqu'à 3 utilisateurs.
</p>
</div>
</div>
@ -553,64 +550,20 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
size="sm"
onClick={() =>
createCheckout({
priceId: STANDARD_MONTHLY_PRICE_ID,
priceId: TEAM_MONTHLY_PRICE_ID,
successUrl: `${window.location.origin}?upgraded=true`,
cancelUrl: `${window.location.origin}?canceled=true`,
})
}
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
className={twMerge(
"w-full h-7 text-xs gap-1",
isUrgent
? "bg-linear-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600"
: "bg-linear-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
)}
disabled={checkoutPending || !TEAM_MONTHLY_PRICE_ID}
className="w-full h-7 text-xs gap-1 bg-linear-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
>
{checkoutPending ? (
"..."
) : (
<>
<CreditCard className="w-3 h-3" />
Mettre à niveau
</>
)}
</Button>
</div>
</li>
)}
{/* Freemium upsell message */}
{shouldShowFreemiumUpsell && !isCollapsed && (
<li className="mb-2">
<div className="mx-2 mb-2 p-3 rounded-lg border bg-linear-to-br from-blue-50 to-cyan-50 dark:from-blue-950/20 dark:to-cyan-950/20 border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2 mb-2">
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-blue-900 dark:text-blue-100">
Plan Freemium
</p>
<p className="text-xs mt-0.5 text-blue-700 dark:text-blue-300">
Passer au plan Starter pour profiter de projets illimités.
</p>
</div>
</div>
<Button
size="sm"
onClick={() =>
createCheckout({
priceId: STANDARD_MONTHLY_PRICE_ID,
successUrl: `${window.location.origin}?upgraded=true`,
cancelUrl: `${window.location.origin}?canceled=true`,
})
}
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
className="w-full h-7 text-xs gap-1 bg-linear-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600"
>
{checkoutPending ? (
"..."
) : (
<>
<CreditCard className="w-3 h-3" />
Essayer Starter
Passer au plan Team
</>
)}
</Button>
@ -648,11 +601,15 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
aria-label={isCollapsed ? "Feedback" : undefined}
>
<div className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}>
<span className="[&>svg]:w-6 [&>svg]:h-6"><SendIcon aria-hidden="true" /></span>
<span className="[&>svg]:w-6 [&>svg]:h-6">
<SendIcon aria-hidden="true" />
</span>
<TypographyLarge
className={twMerge(
"text-base transition-all duration-300 font-normal",
location.pathname === "/feedback" ? "text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-300/90",
location.pathname === "/feedback"
? "text-gray-900 dark:text-white"
: "text-gray-500 dark:text-gray-300/90",
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
)}
>

View file

@ -1,4 +1,3 @@
import { pluralize } from "@xtablo/shared";
import { Badge } from "@xtablo/ui/components/badge";
import { Button } from "@xtablo/ui/components/button";
import {
@ -9,14 +8,13 @@ import {
CardTitle,
} from "@xtablo/ui/components/card";
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
import { useMemo } from "react";
import {
normalizeBillingPlan,
useCancelSubscription,
useCreateCheckoutSession,
useCreatePortalSession,
useReactivateSubscription,
useSubscription,
useTrialExpiration,
} from "../hooks/stripe";
import { useUser } from "../providers/UserStoreProvider";
@ -39,36 +37,33 @@ export function SubscriptionCard() {
const { mutate: reactivateSubscription, isPending: reactivatePending } =
useReactivateSubscription();
const { daysRemaining } = useTrialExpiration();
const daysRemainingValue = useMemo(() => {
if (!daysRemaining) return 7;
return daysRemaining;
}, [daysRemaining]);
const isPaying = user.plan === "trial" || user.plan === "standard";
const isBeta = user.plan === "beta";
const isFreemium = user.plan === "free";
const showTrialBanner = user.plan === "none";
const normalizedPlan = normalizeBillingPlan(user.plan);
const isPaying = normalizedPlan === "team" || normalizedPlan === "annual";
const isAnnual = normalizedPlan === "annual";
const isSolo = normalizedPlan === "solo";
// Replace with your actual price ID from Stripe Dashboard
const infinitePriceId = import.meta.env.VITE_STRIPE_INFINITE_PRICE_ID || "";
const standardPriceId = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
const teamPriceId =
import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID ||
import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID ||
"";
const annualPriceId = import.meta.env.VITE_STRIPE_ANNUAL_PRICE_ID || "";
const soloPriceId = import.meta.env.VITE_STRIPE_SOLO_PRICE_ID || "";
const priceId =
allowedInfiniteUsers.includes(user.email!) && infinitePriceId
? infinitePriceId
: standardPriceId;
: teamPriceId || annualPriceId || soloPriceId;
const getStatusBadge = () => {
// Check for beta plan first
if (isBeta) {
// Annual plan badge
if (isAnnual) {
return (
<Badge className="gap-1.5 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600">
<Sparkles className="w-3 h-3" />
Beta
Annual
</Badge>
);
}
@ -124,11 +119,11 @@ export function SubscriptionCard() {
{getStatusBadge()}
</div>
<CardDescription>
{isBeta
? "Vous avez accès à toutes les fonctionnalités gratuitement en tant que bêta-testeur"
{isAnnual
? "Vous disposez du plan Annual avec limites illimitées"
: isPaying
? "Gérez votre abonnement et votre facturation"
: "Passez à Starter pour débloquer toutes les fonctionnalités"}
: "Passez au plan Team ou Annual pour travailler en équipe"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -138,24 +133,23 @@ export function SubscriptionCard() {
</div>
) : (
<>
{/* Beta Plan */}
{isBeta && (
{/* Annual Plan */}
{isAnnual && (
<div className="space-y-4">
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
Plan Beta
Plan Annual
</p>
</div>
<p className="text-xs text-purple-700 dark:text-purple-300">
Accès gratuit et illimité à toutes les fonctionnalités
Utilisateurs et tablos illimités
</p>
<div className="pt-2 border-t border-purple-200 dark:border-purple-800">
<p className="text-xs text-purple-600 dark:text-purple-400">
Merci de faire partie de nos bêta-testeurs ! Votre retour nous aide à
améliorer XTablo.
Votre organisation bénéficie des capacités maximales.
</p>
</div>
</div>
@ -163,16 +157,15 @@ export function SubscriptionCard() {
</div>
)}
{showTrialBanner && (
{isSolo && (
<div className="space-y-4">
<div className="bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<div className="space-y-2">
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
Accès gratuit pendant 7 jours
Plan Solo
</p>
<p className="text-xs text-purple-700 dark:text-purple-300">
Il vous reste {daysRemainingValue} {pluralize("jour", daysRemainingValue)}{" "}
pour passer au plan Starter.
1 utilisateur et 10 tablos maximum pour votre organisation.
</p>
</div>
</div>
@ -195,46 +188,7 @@ export function SubscriptionCard() {
) : (
<>
<CheckCircle2 className="w-4 h-4" />
Passer au plan Starter
</>
)}
</Button>
</div>
)}
{isFreemium && (
<div className="space-y-4">
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/20 dark:to-cyan-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="space-y-2">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Plan Freemium
</p>
<p className="text-xs text-blue-700 dark:text-blue-300">
Un seul tablo disponible gratuitement, passez au plan Starter pour profiter de
toutes les fonctionnalités.
</p>
</div>
</div>
<Button
onClick={() =>
createCheckout({
priceId,
successUrl: `${window.location.origin}/settings?success=true`,
cancelUrl: `${window.location.origin}/settings?canceled=true`,
})
}
disabled={checkoutPending || !priceId}
className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
>
{checkoutPending ? (
<>
<Loader2Icon className="w-4 h-4 animate-spin" />
Chargement...
</>
) : (
<>
<CheckCircle2 className="w-4 h-4" />
Passer au plan Starter
Passer au plan Team
</>
)}
</Button>
@ -248,10 +202,10 @@ export function SubscriptionCard() {
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-green-900 dark:text-green-100">
Plan Starter
Plan {isAnnual ? "Annual" : "Team"}
</p>
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
Toutes les fonctionnalités débloquées
Collaboration d'organisation activée
</p>
</div>
{subscription.current_period_end && (
@ -312,7 +266,7 @@ export function SubscriptionCard() {
Abonnement en cours d'annulation
</p>
<p className="text-xs text-orange-700 dark:text-orange-300 mt-1">
Votre abonnement Starter sera annulé le{" "}
Votre abonnement {isAnnual ? "Annual" : "Team"} sera annulé le{" "}
{subscription.current_period_end &&
new Date(subscription.current_period_end * 1000).toLocaleDateString(
"fr-FR",
@ -324,7 +278,7 @@ export function SubscriptionCard() {
)}
</p>
<p className="text-xs text-orange-600 dark:text-orange-400 mt-2">
Vous aurez accès aux fonctionnalités Starter jusqu'à cette date.
Vous gardez vos droits actuels jusqu'à cette date.
</p>
</div>
</div>

View file

@ -0,0 +1,94 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "@xtablo/shared";
import { useAuthedApi } from "./auth";
export interface OrganizationSummary {
id: number;
name: string;
plan: string;
member_count: number;
tablo_count: number;
}
export interface OrganizationMember {
id: string;
email: string | null;
name: string | null;
first_name: string | null;
last_name: string | null;
avatar_url: string | null;
created_at: string | null;
is_temporary: boolean;
plan: string | null;
}
export interface OrganizationResponse {
organization: OrganizationSummary;
members: OrganizationMember[];
}
export const useOrganization = () => {
const api = useAuthedApi();
return useQuery({
queryKey: ["organization"],
queryFn: async () => {
const { data } = await api.get<OrganizationResponse>("/api/v1/users/organization");
return data;
},
});
};
export const useUpdateOrganization = () => {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (name: string) => {
const { data } = await api.patch("/api/v1/users/organization", { name });
return data;
},
onSuccess: () => {
toast.add({
title: "Organisation mise à jour",
description: "Le nom de l'organisation a bien été enregistré",
type: "success",
});
queryClient.invalidateQueries({ queryKey: ["organization"] });
},
onError: (error: Error) => {
toast.add({
title: "Erreur",
description: error.message || "Impossible de mettre à jour l'organisation",
type: "error",
});
},
});
};
export const useInviteOrganizationUser = () => {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (email: string) => {
const { data } = await api.post("/api/v1/users/organization/invite", { email });
return data;
},
onSuccess: () => {
toast.add({
title: "Invitation envoyée",
description: "L'utilisateur a été invité dans votre organisation",
type: "success",
});
queryClient.invalidateQueries({ queryKey: ["organization"] });
},
onError: (error: Error) => {
toast.add({
title: "Erreur",
description: error.message || "Impossible d'envoyer l'invitation",
type: "error",
});
},
});
};

View file

@ -15,7 +15,15 @@ import { useAuthedApi } from "./auth";
// Initialize Stripe
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || "");
const TRIAL_DURATION = 7 * 24 * 60 * 60 * 1000;
export type BillingPlan = "solo" | "team" | "annual";
export const normalizeBillingPlan = (plan: string | null | undefined): BillingPlan => {
if (!plan) return "solo";
if (plan === "annual" || plan === "beta") return "annual";
if (plan === "team" || plan === "standard") return "team";
return "solo";
};
/**
* Hook to get user's subscription status from Supabase
@ -54,7 +62,8 @@ export function useSubscription() {
export function useIsPayingUser() {
const user = useUser();
const isPaying = user.plan === "trial" || user.plan === "standard";
const normalizedPlan = normalizeBillingPlan(user.plan);
const isPaying = normalizedPlan === "team" || normalizedPlan === "annual";
// Direct access from user profile (fastest)
return {
@ -66,16 +75,11 @@ export function useIsPayingUser() {
export const useIsPastTrial = () => {
const user = useMaybeUser();
// Calculate if user is past trial inline
// User is past trial if: plan is 'none' AND created more than 7 days ago
// Trial model is deprecated in favor of explicit billing plans.
// Keep this hook returning false to avoid blocking existing users unexpectedly.
const isPastTrial = useMemo(() => {
if (!user) return false;
const isPastTrial =
user.plan === "none" &&
new Date(user.created_at || "") < new Date(Date.now() - TRIAL_DURATION);
return isPastTrial;
void user;
return false;
}, [user]);
return { isPastTrial, isLoading: !user };
@ -88,24 +92,8 @@ export const useIsPastTrial = () => {
export const useTrialExpiration = () => {
const user = useMaybeUser();
const trialExpiration = useMemo(() => {
if (!user?.created_at) return null;
const creationDate = new Date(user.created_at);
const expirationDate = new Date(creationDate.getTime() + TRIAL_DURATION);
return expirationDate;
}, [user?.created_at]);
const daysRemaining = useMemo(() => {
if (!trialExpiration) return null;
const now = new Date();
const diffInMs = trialExpiration.getTime() - now.getTime();
const diffInDays = Math.ceil(diffInMs / (1000 * 60 * 60 * 24));
return Math.max(0, diffInDays);
}, [trialExpiration]);
// Trial model is deprecated in favor of explicit billing plans.
const daysRemaining = useMemo(() => null as number | null, [user]);
return { daysRemaining, isLoading: !user };
};

View file

@ -32,6 +32,25 @@
"placeholder": "Write your introduction message here...",
"emailDescription": "This message will be sent by email to people you invite"
},
"organization": {
"title": "Organization",
"description": "Manage your team workspace",
"name": "Organization name",
"namePlaceholder": "Your organization name",
"currentPlan": "Current plan: {{plan}}",
"stats": "{{members}} member(s) · {{tablos}} tablo(s)",
"save": "Save organization",
"saving": "Saving..."
},
"teamInvite": {
"title": "Invite a teammate",
"description": "Invite a new user by email to join your organization",
"emailLabel": "Teammate email",
"emailPlaceholder": "name@company.com",
"hint": "The invited user will get credentials by email.",
"invite": "Send invite",
"inviting": "Sending..."
},
"cookies": {
"title": "Cookie Preferences",
"description": "Manage your cookie and tracking preferences. Changes will take effect after page reload.",

View file

@ -32,6 +32,25 @@
"placeholder": "Écrivez votre message d'introduction ici...",
"emailDescription": "Ce message sera envoyé par email aux personnes que vous invitez"
},
"organization": {
"title": "Organisation",
"description": "Gérez votre espace d'équipe",
"name": "Nom de l'organisation",
"namePlaceholder": "Nom de votre organisation",
"currentPlan": "Plan actuel : {{plan}}",
"stats": "{{members}} membre(s) · {{tablos}} tablo(s)",
"save": "Enregistrer l'organisation",
"saving": "Enregistrement..."
},
"teamInvite": {
"title": "Inviter un collaborateur",
"description": "Invitez un nouvel utilisateur par email dans votre organisation",
"emailLabel": "Email du collaborateur",
"emailPlaceholder": "nom@entreprise.com",
"hint": "L'utilisateur invité recevra ses identifiants par email.",
"invite": "Envoyer l'invitation",
"inviting": "Envoi..."
},
"cookies": {
"title": "Préférences des cookies",
"description": "Gérez vos préférences en matière de cookies et de suivi. Les modifications prendront effet après rechargement de la page.",

View file

@ -23,11 +23,16 @@ import { Switch } from "@xtablo/ui/components/switch";
import { Textarea } from "@xtablo/ui/components/textarea";
import { TypographyH3, TypographyMuted, TypographySmall } from "@xtablo/ui/components/typography";
import { CameraIcon, CookieIcon, Loader2Icon, Trash2Icon, UploadIcon } from "lucide-react";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LanguageSelector } from "../components/LanguageSelector";
import { SubscriptionCard } from "../components/SubscriptionCard";
import { useIntroduction } from "../hooks/intros";
import {
useInviteOrganizationUser,
useOrganization,
useUpdateOrganization,
} from "../hooks/organization";
import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "../hooks/profile";
import { useCookieConsent } from "../hooks/useCookieConsent";
import { useUser } from "../providers/UserStoreProvider";
@ -45,6 +50,11 @@ export default function SettingsPage() {
const { mutate: uploadAvatar } = useUploadAvatar();
const { mutateAsync: removeAvatar, isPending: removeAvatarPending } = useRemoveAvatar();
const { preferences, saveConsent } = useCookieConsent();
const { data: organizationData, isLoading: organizationLoading } = useOrganization();
const { mutate: updateOrganization, isPending: updateOrganizationPending } =
useUpdateOrganization();
const { mutate: inviteOrganizationUser, isPending: inviteOrganizationUserPending } =
useInviteOrganizationUser();
const [firstName, setFirstName] = useState(user?.first_name || "");
const [lastName, setLastName] = useState(user?.last_name || "");
@ -53,8 +63,16 @@ export default function SettingsPage() {
const [imageToCrop, setImageToCrop] = useState<string | null>(null);
const [isCropDialogOpen, setIsCropDialogOpen] = useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const [organizationName, setOrganizationName] = useState("");
const [inviteEmail, setInviteEmail] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (organizationData?.organization?.name) {
setOrganizationName(organizationData.organization.name);
}
}, [organizationData?.organization?.name]);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
@ -294,6 +312,83 @@ export default function SettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("settings:organization.title")}</CardTitle>
<CardDescription>{t("settings:organization.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="organizationName">{t("settings:organization.name")}</Label>
<Input
id="organizationName"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder={t("settings:organization.namePlaceholder")}
disabled={organizationLoading}
/>
<p className="text-xs text-muted-foreground">
{t("settings:organization.currentPlan", {
plan: organizationData?.organization?.plan || "solo",
})}
</p>
<p className="text-xs text-muted-foreground">
{t("settings:organization.stats", {
members: organizationData?.organization?.member_count || 0,
tablos: organizationData?.organization?.tablo_count || 0,
})}
</p>
</div>
<div className="flex justify-end">
<Button
disabled={updateOrganizationPending || organizationName.trim().length < 2}
onClick={() => updateOrganization(organizationName.trim())}
>
{updateOrganizationPending
? t("settings:organization.saving")
: t("settings:organization.save")}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("settings:teamInvite.title")}</CardTitle>
<CardDescription>{t("settings:teamInvite.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="inviteOrganizationEmail">
{t("settings:teamInvite.emailLabel")}
</Label>
<Input
id="inviteOrganizationEmail"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder={t("settings:teamInvite.emailPlaceholder")}
/>
<p className="text-xs text-muted-foreground">{t("settings:teamInvite.hint")}</p>
</div>
<div className="flex justify-end">
<Button
disabled={inviteOrganizationUserPending || !inviteEmail.trim()}
onClick={() => {
inviteOrganizationUser(inviteEmail.trim());
setInviteEmail("");
}}
>
{inviteOrganizationUserPending
? t("settings:teamInvite.inviting")
: t("settings:teamInvite.invite")}
</Button>
</div>
</CardContent>
</Card>
{/* Subscription Section */}
<SubscriptionCard />

View file

@ -0,0 +1,389 @@
-- Organizations: team collaboration model
-- - Every user belongs to exactly one organization
-- - Tablos are created by users but owned by organizations
-- - Organization members automatically get admin access to organization tablos
-- ============================================================================
-- Organizations
-- ============================================================================
CREATE TABLE IF NOT EXISTS public.organizations (
id SERIAL PRIMARY KEY,
internal_uuid uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
name text NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.organizations IS 'Organizations grouping users from the same company for shared tablo access.';
COMMENT ON COLUMN public.organizations.id IS 'Primary key (SERIAL), external references should use this value.';
COMMENT ON COLUMN public.organizations.internal_uuid IS 'Internal backup UUID for organization records.';
DROP TRIGGER IF EXISTS update_organizations_updated_at ON public.organizations;
CREATE TRIGGER update_organizations_updated_at
BEFORE UPDATE ON public.organizations
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- Random two-word English organization names
CREATE OR REPLACE FUNCTION public.generate_cool_organization_name() RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
adjectives text[] := ARRAY[
'Amber', 'Arctic', 'Atlas', 'Azure', 'Bold', 'Bright', 'Cobalt', 'Cosmic',
'Crisp', 'Dynamic', 'Electric', 'Emerald', 'Epic', 'Golden', 'Grand', 'Iron',
'Ivory', 'Lunar', 'Modern', 'Nova', 'Ocean', 'Orbit', 'Prime', 'Quantum',
'Rapid', 'Royal', 'Silver', 'Solar', 'Summit', 'Swift', 'Ultra', 'Urban', 'Velvet'
];
nouns text[] := ARRAY[
'Bridge', 'Cloud', 'Collective', 'Compass', 'Craft', 'Forge', 'Frontier', 'Harbor',
'Horizon', 'House', 'Labs', 'Line', 'Logic', 'Matrix', 'Minds', 'Network',
'Nexus', 'Peak', 'Pioneer', 'Pulse', 'Reach', 'Rocket', 'Scope', 'Signal',
'Spark', 'Sphere', 'Stack', 'Studio', 'Systems', 'Tower', 'Ventures', 'Works', 'Yard'
];
BEGIN
RETURN
adjectives[1 + floor(random() * array_length(adjectives, 1))::integer]
|| ' ' ||
nouns[1 + floor(random() * array_length(nouns, 1))::integer];
END;
$$;
COMMENT ON FUNCTION public.generate_cool_organization_name() IS 'Returns a random two-word English organization name.';
CREATE OR REPLACE FUNCTION public.create_personal_organization() RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
new_org_id integer;
BEGIN
INSERT INTO public.organizations (name)
VALUES (public.generate_cool_organization_name())
RETURNING id INTO new_org_id;
RETURN new_org_id;
END;
$$;
ALTER FUNCTION public.create_personal_organization() OWNER TO postgres;
-- ============================================================================
-- Profiles: one organization per user
-- ============================================================================
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS organization_id integer;
-- Existing users: create one personal organization each
UPDATE public.profiles
SET organization_id = public.create_personal_organization()
WHERE organization_id IS NULL;
-- Ensure future profile inserts always receive an organization
CREATE OR REPLACE FUNCTION public.ensure_profile_organization() RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF NEW.organization_id IS NULL THEN
NEW.organization_id := public.create_personal_organization();
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.ensure_profile_organization() OWNER TO postgres;
DROP TRIGGER IF EXISTS trigger_ensure_profile_organization ON public.profiles;
CREATE TRIGGER trigger_ensure_profile_organization
BEFORE INSERT ON public.profiles
FOR EACH ROW
EXECUTE FUNCTION public.ensure_profile_organization();
ALTER TABLE public.profiles
ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE public.profiles
DROP CONSTRAINT IF EXISTS profiles_organization_id_fkey;
ALTER TABLE public.profiles
ADD CONSTRAINT profiles_organization_id_fkey
FOREIGN KEY (organization_id)
REFERENCES public.organizations(id)
ON DELETE RESTRICT;
CREATE INDEX IF NOT EXISTS profiles_organization_id_idx ON public.profiles (organization_id);
COMMENT ON COLUMN public.profiles.organization_id IS 'Organization the user belongs to. A user can only belong to one organization.';
-- ============================================================================
-- Tablos: owned by organizations
-- ============================================================================
ALTER TABLE public.tablos
ADD COLUMN IF NOT EXISTS organization_id integer;
-- Backfill existing tablos using owner's organization
UPDATE public.tablos t
SET organization_id = p.organization_id
FROM public.profiles p
WHERE p.id = t.owner_id
AND t.organization_id IS NULL;
-- Ensure future tablos inherit owner's organization when omitted
CREATE OR REPLACE FUNCTION public.set_tablo_organization_from_owner() RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
owner_organization_id integer;
BEGIN
IF NEW.organization_id IS NULL THEN
SELECT p.organization_id
INTO owner_organization_id
FROM public.profiles p
WHERE p.id = NEW.owner_id
LIMIT 1;
IF owner_organization_id IS NULL THEN
RAISE EXCEPTION USING
ERRCODE = '23503',
MESSAGE = 'Tablo owner has no organization';
END IF;
NEW.organization_id := owner_organization_id;
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.set_tablo_organization_from_owner() OWNER TO postgres;
DROP TRIGGER IF EXISTS trigger_set_tablo_organization ON public.tablos;
CREATE TRIGGER trigger_set_tablo_organization
BEFORE INSERT ON public.tablos
FOR EACH ROW
EXECUTE FUNCTION public.set_tablo_organization_from_owner();
ALTER TABLE public.tablos
ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE public.tablos
DROP CONSTRAINT IF EXISTS tablos_organization_id_fkey;
ALTER TABLE public.tablos
ADD CONSTRAINT tablos_organization_id_fkey
FOREIGN KEY (organization_id)
REFERENCES public.organizations(id)
ON DELETE RESTRICT;
CREATE INDEX IF NOT EXISTS tablos_organization_id_idx ON public.tablos (organization_id);
COMMENT ON COLUMN public.tablos.organization_id IS 'Organization that owns the tablo.';
-- ============================================================================
-- Access synchronization for organization members
-- ============================================================================
-- Replace owner-only access creation with org-wide admin access creation
CREATE OR REPLACE FUNCTION public.create_tablo_access_for_owner() RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
INSERT INTO public.tablo_access (
tablo_id,
user_id,
granted_by,
is_active,
is_admin
)
SELECT
NEW.id,
p.id,
NEW.owner_id,
TRUE,
TRUE
FROM public.profiles p
WHERE p.organization_id = NEW.organization_id
ON CONFLICT (tablo_id, user_id)
DO UPDATE SET
is_active = TRUE,
is_admin = TRUE,
granted_by = EXCLUDED.granted_by;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.create_tablo_access_for_owner() OWNER TO postgres;
-- Existing data: ensure every org member has admin access to org tablos
INSERT INTO public.tablo_access (
tablo_id,
user_id,
granted_by,
is_active,
is_admin
)
SELECT
t.id,
p.id,
t.owner_id,
TRUE,
TRUE
FROM public.tablos t
JOIN public.profiles p
ON p.organization_id = t.organization_id
WHERE t.deleted_at IS NULL
ON CONFLICT (tablo_id, user_id)
DO UPDATE SET
is_active = TRUE,
is_admin = TRUE,
granted_by = EXCLUDED.granted_by;
-- When a user joins/moves organization, sync tablo_access automatically
CREATE OR REPLACE FUNCTION public.sync_org_member_tablo_access() RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF TG_OP = 'UPDATE' AND NEW.organization_id IS DISTINCT FROM OLD.organization_id THEN
UPDATE public.tablo_access ta
SET is_active = FALSE
WHERE ta.user_id = NEW.id
AND ta.is_admin = TRUE
AND EXISTS (
SELECT 1
FROM public.tablos t
WHERE t.id = ta.tablo_id
AND t.organization_id = OLD.organization_id
);
END IF;
INSERT INTO public.tablo_access (
tablo_id,
user_id,
granted_by,
is_active,
is_admin
)
SELECT
t.id,
NEW.id,
NEW.id,
TRUE,
TRUE
FROM public.tablos t
WHERE t.organization_id = NEW.organization_id
AND t.deleted_at IS NULL
ON CONFLICT (tablo_id, user_id)
DO UPDATE SET
is_active = TRUE,
is_admin = TRUE,
granted_by = EXCLUDED.granted_by;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.sync_org_member_tablo_access() OWNER TO postgres;
DROP TRIGGER IF EXISTS trigger_sync_org_member_tablo_access ON public.profiles;
CREATE TRIGGER trigger_sync_org_member_tablo_access
AFTER INSERT OR UPDATE OF organization_id ON public.profiles
FOR EACH ROW
EXECUTE FUNCTION public.sync_org_member_tablo_access();
-- ============================================================================
-- RLS updates
-- ============================================================================
CREATE OR REPLACE FUNCTION public.current_user_organization_id() RETURNS integer
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT p.organization_id
FROM public.profiles p
WHERE p.id = auth.uid();
$$;
ALTER FUNCTION public.current_user_organization_id() OWNER TO postgres;
-- Organizations RLS
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can view their own organization" ON public.organizations;
CREATE POLICY "Users can view their own organization"
ON public.organizations
FOR SELECT
TO authenticated
USING (id = public.current_user_organization_id());
DROP POLICY IF EXISTS "Users can update their own organization" ON public.organizations;
CREATE POLICY "Users can update their own organization"
ON public.organizations
FOR UPDATE
TO authenticated
USING (id = public.current_user_organization_id())
WITH CHECK (id = public.current_user_organization_id());
-- Tablos RLS: organization members have owner-level access
DROP POLICY IF EXISTS "Users can view tablos they have access to" ON public.tablos;
CREATE POLICY "Users can view tablos they have access to"
ON public.tablos
FOR SELECT
TO authenticated
USING (
organization_id = public.current_user_organization_id()
OR EXISTS (
SELECT 1
FROM public.tablo_access ta
WHERE ta.tablo_id = tablos.id
AND ta.user_id = auth.uid()
AND ta.is_active = TRUE
)
);
DROP POLICY IF EXISTS "Users can insert own tablos" ON public.tablos;
CREATE POLICY "Users can insert own tablos"
ON public.tablos
FOR INSERT
TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND (
organization_id = public.current_user_organization_id()
OR organization_id IS NULL
)
);
DROP POLICY IF EXISTS "Users can update own tablos" ON public.tablos;
CREATE POLICY "Users can update own tablos"
ON public.tablos
FOR UPDATE
TO authenticated
USING (organization_id = public.current_user_organization_id())
WITH CHECK (organization_id = public.current_user_organization_id());
-- Tasks RLS already relies on tablo_access membership. Org-level tablo_access sync above
-- gives all organization members full access (admin) to organization tasks.
-- ============================================================================
-- Plan aliases for new billing model
-- ============================================================================
ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'solo';
ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'team';
ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'annual';
COMMENT ON TYPE public.subscription_plan IS 'Billing plans: solo (1 user, 10 tablos), team (3 users, unlimited tablos), annual (unlimited). Legacy values are kept for compatibility.';
GRANT SELECT, UPDATE ON public.organizations TO authenticated;
GRANT EXECUTE ON FUNCTION public.current_user_organization_id() TO authenticated;

View file

@ -0,0 +1,145 @@
-- Update Stripe subscription -> profile.plan mapping for the new billing model
-- Plans are now: solo, team, annual.
-- Legacy plan values can still exist, but new webhook sync should assign the new values.
CREATE OR REPLACE FUNCTION public.update_profile_subscription_status() RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_user_id uuid;
v_customer_id text;
v_plan_hint text;
v_has_paid_subscription boolean := FALSE;
v_plan public.subscription_plan := 'solo';
BEGIN
-- Resolve customer id from the changed stripe row
IF TG_TABLE_NAME = 'subscriptions' THEN
v_customer_id := NEW.customer;
ELSIF TG_TABLE_NAME = 'subscription_items' THEN
SELECT s.customer
INTO v_customer_id
FROM stripe.subscriptions s
WHERE s.id = NEW.subscription;
ELSE
RETURN NEW;
END IF;
IF v_customer_id IS NULL THEN
RETURN NEW;
END IF;
-- Resolve application user id from Stripe customer metadata
SELECT (c.metadata->>'user_id')::uuid
INTO v_user_id
FROM stripe.customers c
WHERE c.id = v_customer_id;
IF v_user_id IS NULL THEN
RETURN NEW;
END IF;
-- Pick the best active/trialing subscription for this user and infer plan from
-- price/product metadata or lookup key. Fall back to Team when paid but unmapped.
WITH candidate_subscriptions AS (
SELECT
s.status::text AS status,
lower(
coalesce(
NULLIF(pr.metadata->>'plan', ''),
NULLIF(pr.lookup_key, ''),
NULLIF(pd.metadata->>'plan', ''),
NULLIF(pd.name, '')
)
) AS plan_hint,
CASE s.status::text
WHEN 'active' THEN 3
WHEN 'past_due' THEN 2
WHEN 'trialing' THEN 1
ELSE 0
END AS status_weight,
CASE
WHEN lower(
coalesce(
NULLIF(pr.metadata->>'plan', ''),
NULLIF(pr.lookup_key, ''),
NULLIF(pd.metadata->>'plan', ''),
NULLIF(pd.name, '')
)
) LIKE '%annual%' THEN 3
WHEN lower(
coalesce(
NULLIF(pr.metadata->>'plan', ''),
NULLIF(pr.lookup_key, ''),
NULLIF(pd.metadata->>'plan', ''),
NULLIF(pd.name, '')
)
) LIKE '%team%' OR lower(
coalesce(
NULLIF(pr.metadata->>'plan', ''),
NULLIF(pr.lookup_key, ''),
NULLIF(pd.metadata->>'plan', ''),
NULLIF(pd.name, '')
)
) LIKE '%standard%' THEN 2
WHEN lower(
coalesce(
NULLIF(pr.metadata->>'plan', ''),
NULLIF(pr.lookup_key, ''),
NULLIF(pd.metadata->>'plan', ''),
NULLIF(pd.name, '')
)
) LIKE '%solo%' THEN 1
ELSE 0
END AS plan_weight
FROM stripe.subscriptions s
JOIN stripe.customers c
ON c.id = s.customer
LEFT JOIN stripe.subscription_items si
ON si.subscription = s.id
LEFT JOIN stripe.prices pr
ON pr.id = si.price
LEFT JOIN stripe.products pd
ON pd.id = pr.product
WHERE (c.metadata->>'user_id')::uuid = v_user_id
AND s.status::text IN ('active', 'past_due', 'trialing')
AND (
si.current_period_end IS NULL
OR to_timestamp(si.current_period_end) > now()
)
)
SELECT
TRUE,
cs.plan_hint
INTO v_has_paid_subscription, v_plan_hint
FROM candidate_subscriptions cs
ORDER BY cs.plan_weight DESC, cs.status_weight DESC
LIMIT 1;
IF v_has_paid_subscription THEN
IF v_plan_hint LIKE '%annual%' OR v_plan_hint LIKE '%beta%' THEN
v_plan := 'annual';
ELSIF v_plan_hint LIKE '%team%' OR v_plan_hint LIKE '%standard%' THEN
v_plan := 'team';
ELSIF v_plan_hint LIKE '%solo%' OR v_plan_hint LIKE '%free%' THEN
v_plan := 'solo';
ELSE
-- paid but unmapped => Team by default (backward compatibility)
v_plan := 'team';
END IF;
ELSE
v_plan := 'solo';
END IF;
UPDATE public.profiles
SET plan = v_plan
WHERE id = v_user_id;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.update_profile_subscription_status() OWNER TO postgres;
COMMENT ON FUNCTION public.update_profile_subscription_status() IS
'Maps Stripe subscription state to profile.plan using new billing plans (solo, team, annual).';