Merge pull request #75 from artslidd/work-1

Rework invited user flow
This commit is contained in:
Arthur Belleville 2026-04-15 15:55:33 +02:00 committed by GitHub
commit 14b47db42b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 6743 additions and 725 deletions

View file

@ -28,12 +28,14 @@ describe("billing helpers", () => {
id: "owner-user",
created_at: "2026-01-01T10:00:00.000Z",
is_temporary: false,
is_client: false,
plan: "annual",
},
{
id: "late-user",
created_at: "2026-01-02T10:00:00.000Z",
is_temporary: false,
is_client: false,
plan: "solo",
},
]);
@ -47,18 +49,21 @@ describe("billing helpers", () => {
id: "user-1",
created_at: "2026-01-01T10:00:00.000Z",
is_temporary: false,
is_client: false,
plan: "solo",
},
{
id: "temp-1",
created_at: "2026-01-02T10:00:00.000Z",
is_temporary: true,
is_client: false,
plan: "solo",
},
{
id: "user-2",
created_at: "2026-01-03T10:00:00.000Z",
is_temporary: null,
is_client: false,
plan: "team",
},
]);

View file

@ -12,7 +12,7 @@ describe("Middleware Tests", () => {
const middlewareManager = MiddlewareManager.getInstance();
const createProfilesSupabaseMock = (result: {
data: { is_temporary: boolean } | null;
data: { is_temporary?: boolean; is_client?: boolean } | null;
error: { message: string } | null;
}) => ({
from: vi.fn().mockReturnValue({
@ -342,6 +342,33 @@ describe("Middleware Tests", () => {
expect(res.status).toBe(401);
expect(data.error).toBe("User is read only");
});
it("should return 401 for client users", async () => {
const app = new Hono();
app.use(async (c, next) => {
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
(c as any).set(
"supabase",
createProfilesSupabaseMock({
data: { is_temporary: false, is_client: true },
error: null,
}) as any
);
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
(c as any).set("user", { id: "client-user" } as any);
await next();
});
app.use(middlewareManager.regularUserCheck);
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const res = await client.test.$get();
const data = await res.json();
expect(res.status).toBe(401);
expect(data.error).toBe("User is read only");
});
});
describe("Billing Checkout Access Middleware", () => {

View file

@ -0,0 +1,386 @@
import { createClient } from "@supabase/supabase-js";
import { testClient } from "hono/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import type { TestUserData } from "../helpers/dbSetup.js";
import { getTestUser } from "../helpers/dbSetup.js";
// Mock nodemailer
const mockSendMail = vi.fn();
vi.mock("nodemailer", () => ({
default: {
createTransport: vi.fn(() => ({
sendMail: mockSendMail,
})),
},
createTransport: vi.fn(() => ({
sendMail: mockSendMail,
})),
}));
describe("Client Invites Endpoints", () => {
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const ownerUser = getTestUser("owner");
const tempUser = getTestUser("temp");
const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
// The owner has admin access to this tablo (created via TEST_TABLOS with owner_key: "owner")
const adminTabloId = "test_tablo_owner_private";
beforeEach(() => {
vi.clearAllMocks();
mockSendMail.mockResolvedValue({ messageId: "test-message-id" });
});
// ─── Helpers ────────────────────────────────────────────────────────────────
const postInvite = (user: TestUserData, tabloId: string, email: string) =>
client["client-invites"][":tabloId"].$post(
{ param: { tabloId }, json: { email } },
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
);
const getPending = (user: TestUserData, tabloId: string) =>
client["client-invites"][":tabloId"].pending.$get(
{ param: { tabloId } },
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
);
const deleteInvite = (user: TestUserData, tabloId: string, inviteId: number) =>
client["client-invites"][":tabloId"][":inviteId"].$delete(
{ param: { tabloId, inviteId: String(inviteId) } },
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
);
const acceptInvite = (user: TestUserData, token: string) =>
client["client-invites"].accept[":token"].$post(
{ param: { token } },
{ headers: { Authorization: `Bearer ${user.accessToken}` } }
);
// ─── Helper: insert a client_invite row directly via admin ──────────────────
const insertClientInvite = async (opts: {
tabloId: string;
invitedEmail: string;
invitedBy: string;
token: string;
isPending?: boolean;
expiresAt?: string;
}) => {
const expiresAt = opts.expiresAt ?? new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString();
const { data, error } = await supabaseAdmin
.from("client_invites")
.insert({
tablo_id: opts.tabloId,
invited_email: opts.invitedEmail,
invited_by: opts.invitedBy,
invite_token: opts.token,
is_pending: opts.isPending ?? true,
expires_at: expiresAt,
})
.select("id")
.single();
if (error) throw new Error(`Failed to insert client_invite: ${error.message}`);
return data.id as number;
};
// ─── Cleanup helper ──────────────────────────────────────────────────────────
const cleanupInvitesByEmail = async (email: string) => {
await supabaseAdmin.from("client_invites").delete().eq("invited_email", email);
// Also clean up any client user that may have been created
const { data: usersData } = await supabaseAdmin.auth.admin.listUsers();
// biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime
const users = usersData as any;
// biome-ignore lint/suspicious/noExplicitAny: admin user type
const clientUser = users?.users?.find((u: any) => u.email === email);
if (clientUser) {
await supabaseAdmin.from("tablo_access").delete().eq("user_id", clientUser.id);
await supabaseAdmin.auth.admin.deleteUser(clientUser.id);
}
};
// ════════════════════════════════════════════════════════════════════════════
// POST /:tabloId — Create client invite
// ════════════════════════════════════════════════════════════════════════════
describe("POST /client-invites/:tabloId", () => {
const testEmail = "test_client_invite_new@example.com";
beforeEach(async () => {
await cleanupInvitesByEmail(testEmail);
});
it("should create a client invite for a valid email (admin)", async () => {
const res = await postInvite(ownerUser, adminTabloId, testEmail);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
// Verify row was inserted
const { data: invite } = await supabaseAdmin
.from("client_invites")
.select("id, invited_email, is_pending")
.eq("tablo_id", adminTabloId)
.eq("invited_email", testEmail)
.single();
expect(invite).toBeDefined();
expect(invite?.is_pending).toBe(true);
});
it("should reject non-admin users with 403", async () => {
// tempUser is NOT admin of adminTabloId (owner user owns it)
const res = await postInvite(tempUser, adminTabloId, testEmail);
expect(res.status).toBe(403);
});
it("should return 400 for an invalid email", async () => {
const res = await postInvite(ownerUser, adminTabloId, "not-an-email");
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("valid email");
});
it("should return 400 for a missing email", async () => {
const res = client["client-invites"][":tabloId"].$post(
{ param: { tabloId: adminTabloId }, json: {} },
{ headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
);
expect((await res).status).toBe(400);
});
it("should return 401 for unauthenticated requests", async () => {
const res = await client["client-invites"][":tabloId"].$post({
param: { tabloId: adminTabloId },
json: { email: testEmail },
});
expect(res.status).toBe(401);
});
});
// ════════════════════════════════════════════════════════════════════════════
// POST /accept/:token — Accept a client invite
// ════════════════════════════════════════════════════════════════════════════
describe("POST /client-invites/accept/:token", () => {
it("should accept an invite and return tabloId", async () => {
const token = `test_accept_valid_${Date.now()}`;
// Insert invite for the owner user's email
await insertClientInvite({
tabloId: adminTabloId,
invitedEmail: ownerUser.email,
invitedBy: ownerUser.userId,
token,
});
try {
const res = await acceptInvite(ownerUser, token);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(data.tabloId).toBe(adminTabloId);
// Verify invite is now not pending
const { data: invite } = await supabaseAdmin
.from("client_invites")
.select("is_pending")
.eq("invite_token", token)
.single();
expect(invite?.is_pending).toBe(false);
} finally {
await supabaseAdmin.from("client_invites").delete().eq("invite_token", token);
}
});
it("should return 410 for an expired invite", async () => {
const token = `test_expired_${Date.now()}`;
const pastDate = new Date(Date.now() - 1000).toISOString(); // already expired
await insertClientInvite({
tabloId: adminTabloId,
invitedEmail: ownerUser.email,
invitedBy: ownerUser.userId,
token,
expiresAt: pastDate,
});
try {
const res = await acceptInvite(ownerUser, token);
expect(res.status).toBe(410);
const data = await res.json();
expect(data.error).toContain("expired");
} finally {
await supabaseAdmin.from("client_invites").delete().eq("invite_token", token);
}
});
it("should return 403 when email does not match the authenticated user", async () => {
const token = `test_email_mismatch_${Date.now()}`;
// Invite is for tempUser's email but we authenticate as ownerUser
await insertClientInvite({
tabloId: adminTabloId,
invitedEmail: tempUser.email,
invitedBy: ownerUser.userId,
token,
});
try {
const res = await acceptInvite(ownerUser, token); // wrong user
expect(res.status).toBe(403);
} finally {
await supabaseAdmin.from("client_invites").delete().eq("invite_token", token);
}
});
it("should return 404 for a non-existent token", async () => {
const res = await acceptInvite(ownerUser, "nonexistent_token_xyz");
expect(res.status).toBe(404);
});
it("should return 401 for unauthenticated requests", async () => {
const res = await client["client-invites"].accept[":token"].$post({
param: { token: "some_token" },
});
expect(res.status).toBe(401);
});
});
// ════════════════════════════════════════════════════════════════════════════
// GET /:tabloId/pending — List pending client invites
// ════════════════════════════════════════════════════════════════════════════
describe("GET /client-invites/:tabloId/pending", () => {
const pendingEmail = "test_client_pending_list@example.com";
let insertedId: number;
beforeEach(async () => {
await cleanupInvitesByEmail(pendingEmail);
insertedId = await insertClientInvite({
tabloId: adminTabloId,
invitedEmail: pendingEmail,
invitedBy: ownerUser.userId,
token: `test_pending_${Date.now()}`,
});
});
it("should return pending invites for an admin", async () => {
const res = await getPending(ownerUser, adminTabloId);
expect(res.status).toBe(200);
const data = await res.json();
expect(Array.isArray(data.invites)).toBe(true);
const found = data.invites.find((inv: { id: number }) => inv.id === insertedId);
expect(found).toBeDefined();
expect(found.invited_email).toBe(pendingEmail);
expect(found.is_pending).toBe(true);
});
it("should return 403 for a non-admin user", async () => {
const res = await getPending(tempUser, adminTabloId);
expect(res.status).toBe(403);
});
it("should return 401 for unauthenticated requests", async () => {
const res = await client["client-invites"][":tabloId"].pending.$get({
param: { tabloId: adminTabloId },
});
expect(res.status).toBe(401);
});
});
// ════════════════════════════════════════════════════════════════════════════
// DELETE /:tabloId/:inviteId — Cancel a client invite
// ════════════════════════════════════════════════════════════════════════════
describe("DELETE /client-invites/:tabloId/:inviteId", () => {
const cancelEmail = "test_client_cancel@example.com";
beforeEach(async () => {
await cleanupInvitesByEmail(cancelEmail);
});
it("should cancel a pending invite and revoke client access", async () => {
// First create a client user and tablo_access entry via the API
const token = `test_cancel_${Date.now()}`;
const inviteId = await insertClientInvite({
tabloId: adminTabloId,
invitedEmail: cancelEmail,
invitedBy: ownerUser.userId,
token,
});
// Create a mock profile to revoke (uses admin client to simulate client user existing)
// We'll skip verifying the user's actual auth account since we just need to test cancellation
const res = await deleteInvite(ownerUser, adminTabloId, inviteId);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
// Verify invite is now not pending
const { data: invite } = await supabaseAdmin
.from("client_invites")
.select("is_pending")
.eq("id", inviteId)
.single();
expect(invite?.is_pending).toBe(false);
});
it("should return 403 for a non-admin user", async () => {
const token = `test_cancel_nonadmin_${Date.now()}`;
const inviteId = await insertClientInvite({
tabloId: adminTabloId,
invitedEmail: cancelEmail,
invitedBy: ownerUser.userId,
token,
});
const res = await deleteInvite(tempUser, adminTabloId, inviteId);
expect(res.status).toBe(403);
});
it("should return 404 for a non-existent invite", async () => {
const res = await deleteInvite(ownerUser, adminTabloId, 999999);
expect(res.status).toBe(404);
});
it("should return 400 for an already-cancelled invite", async () => {
const token = `test_cancel_already_${Date.now()}`;
const inviteId = await insertClientInvite({
tabloId: adminTabloId,
invitedEmail: cancelEmail,
invitedBy: ownerUser.userId,
token,
isPending: false, // already cancelled
});
const res = await deleteInvite(ownerUser, adminTabloId, inviteId);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("pending");
});
it("should return 401 for unauthenticated requests", async () => {
const res = await client["client-invites"][":tabloId"][":inviteId"].$delete({
param: { tabloId: adminTabloId, inviteId: "1" },
});
expect(res.status).toBe(401);
});
});
});

View file

@ -7,6 +7,7 @@ type BillingProfileRow = {
id: string;
created_at: string | null;
is_temporary: boolean | null;
is_client: boolean | null;
plan: string | null;
};
@ -87,7 +88,7 @@ export const parseTrialRolloutDate = (
export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null;
export const getBillableMemberCount = (profiles: BillingProfileRow[]) =>
profiles.filter((profile) => profile.is_temporary !== true).length;
profiles.filter((profile) => profile.is_temporary !== true && profile.is_client !== true).length;
export const getTrialWindow = (input: {
ownerCreatedAt: Date;
@ -179,7 +180,7 @@ const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRo
const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => {
const { data, error } = await supabase
.from("profiles")
.select("id, created_at, is_temporary, plan")
.select("id, created_at, is_temporary, is_client, plan")
.eq("organization_id", organizationId)
.order("created_at", { ascending: true });

View file

@ -363,3 +363,68 @@ export const createInvitedUser = async (
return { success: true, userId: newUser.user.id };
};
/**
* Creates or finds a client user, marks them as is_client, and grants tablo access.
*/
export async function createClientUser(
supabase: SupabaseClient,
recipientEmail: string,
tabloId: string,
grantedBy: string
): Promise<{ success: boolean; error?: string; userId?: string }> {
// Check if user already exists
const { data: existingUsersData } = await supabase.auth.admin.listUsers();
// biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime
const existingUsers = existingUsersData as any;
const existingUser = existingUsers?.users?.find(
// biome-ignore lint/suspicious/noExplicitAny: admin user type
(u: any) => u.email?.toLowerCase() === recipientEmail.toLowerCase()
);
let userId: string;
if (existingUser) {
userId = existingUser.id;
// Mark as client if not already
await supabase
.from("profiles")
.update({ is_client: true })
.eq("id", userId)
.eq("is_client", false);
} else {
// Create new auth user (no password — magic link only)
const { data: authData, error: authError } = await supabase.auth.admin.createUser({
email: recipientEmail,
email_confirm: true,
user_metadata: { role: "client" },
});
if (authError || !authData?.user) {
return { success: false, error: authError?.message ?? "Failed to create user" };
}
userId = authData.user.id;
await supabase.from("profiles").update({ is_client: true }).eq("id", userId);
}
// Grant tablo access if not already granted
const { data: existingAccess } = await supabase
.from("tablo_access")
.select("id, is_active")
.eq("tablo_id", tabloId)
.eq("user_id", userId)
.single();
if (!existingAccess) {
await supabase.from("tablo_access").insert({
tablo_id: tabloId,
user_id: userId,
granted_by: grantedBy,
is_admin: false,
is_active: true,
});
} else if (!existingAccess.is_active) {
await supabase.from("tablo_access").update({ is_active: true }).eq("id", existingAccess.id);
}
return { success: true, userId };
}

View file

@ -84,7 +84,7 @@ export class MiddlewareManager {
const { data: profile, error } = await supabase
.from("profiles")
.select("is_temporary")
.select("is_temporary, is_client")
.eq("id", user.id)
.single();
@ -92,7 +92,7 @@ export class MiddlewareManager {
return c.json({ error: error?.message ?? "Profile not found" }, 500);
}
if (!allowTemporaryUsers && profile.is_temporary) {
if ((!allowTemporaryUsers && profile.is_temporary) || profile.is_client) {
return c.json({ error: "User is read only" }, 401);
}

View file

@ -1,6 +1,7 @@
import { Hono } from "hono";
import type { AppConfig } from "../config.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import { getClientInvitesRouter } from "./clientInvites.js";
import { getNotesRouter } from "./notes.js";
import { getStripeRouter } from "./stripe.js";
import { getTabloRouter } from "./tablo.js";
@ -19,6 +20,7 @@ export const getAuthenticatedRouter = (config: AppConfig) => {
authRouter.route("/tablos", getTabloRouter(config));
authRouter.route("/tablo-data", getTabloDataRouter());
authRouter.route("/notes", getNotesRouter());
authRouter.route("/client-invites", getClientInvitesRouter());
// stripe routes
authRouter.route("/stripe", getStripeRouter(config));

View file

@ -0,0 +1,223 @@
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import { checkTabloAdmin, createClientUser } from "../helpers/helpers.js";
import { generateToken } from "../helpers/token.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { AuthEnv } from "../types/app.types.js";
const factory = createFactory<AuthEnv>();
const CLIENT_INVITE_EXPIRY_HOURS = 72;
/** POST /:tabloId — Create a client invite (admin only) */
const createClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const tabloId = c.req.param("tabloId");
const body = await c.req.json();
const rawEmail = String(body.email || "")
.trim()
.toLowerCase();
if (!rawEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail)) {
return c.json({ error: "A valid email is required" }, 400);
}
// Create / find the client user and grant tablo access
const result = await createClientUser(supabase, rawEmail, tabloId, user.id);
if (!result.success || !result.userId) {
return c.json({ error: result.error ?? "Failed to create client user" }, 500);
}
const token = generateToken();
const expiresAt = new Date(
Date.now() + CLIENT_INVITE_EXPIRY_HOURS * 60 * 60 * 1000
).toISOString();
const { error: insertError } = await supabase.from("client_invites").insert({
tablo_id: tabloId,
invited_email: rawEmail,
invited_by: user.id,
invite_token: token,
is_pending: true,
expires_at: expiresAt,
});
if (insertError) {
if (insertError.code === "23505") {
return c.json({ error: "A pending invite already exists for this email and tablo" }, 409);
}
return c.json({ error: insertError.message }, 500);
}
// Generate a Supabase magic link that redirects to the client portal callback
const clientsUrl = process.env.CLIENTS_URL || "https://clients.xtablo.com";
const redirectTo = `${clientsUrl}/auth/callback?token=${encodeURIComponent(token)}`;
const { error: magicLinkError } = await supabase.auth.admin.generateLink({
type: "magiclink",
email: rawEmail,
options: { redirectTo },
});
if (magicLinkError) {
console.error("Failed to generate magic link:", magicLinkError);
// Non-fatal: invite record is already created
}
return c.json({ success: true });
});
/** POST /accept/:token — Accept a client invite */
const acceptClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const token = c.req.param("token");
const { data: invite, error: inviteError } = await supabase
.from("client_invites")
.select("id, tablo_id, invited_email, invited_by, is_pending, expires_at")
.eq("invite_token", token)
.maybeSingle();
if (inviteError) {
return c.json({ error: inviteError.message }, 500);
}
if (!invite || !invite.is_pending) {
return c.json({ error: "Invite not found or already used" }, 404);
}
// Check expiration
if (invite.expires_at && new Date(invite.expires_at) < new Date()) {
return c.json({ error: "This invite has expired" }, 410);
}
// Email must match the authenticated user
if (invite.invited_email?.toLowerCase() !== user.email?.toLowerCase()) {
return c.json({ error: "This invite was not issued to your account" }, 403);
}
// Mark invite as accepted
await supabase.from("client_invites").update({ is_pending: false }).eq("id", invite.id);
// Ensure tablo access is active
const { data: existingAccess } = await supabase
.from("tablo_access")
.select("id, is_active")
.eq("tablo_id", invite.tablo_id)
.eq("user_id", user.id)
.maybeSingle();
if (!existingAccess) {
await supabase.from("tablo_access").insert({
tablo_id: invite.tablo_id,
user_id: user.id,
granted_by: invite.invited_by,
is_admin: false,
is_active: true,
});
} else if (!existingAccess.is_active) {
await supabase.from("tablo_access").update({ is_active: true }).eq("id", existingAccess.id);
}
return c.json({ success: true, tabloId: invite.tablo_id });
});
/** GET /:tabloId/pending — List pending client invites (admin only) */
const getPendingClientInvites = (
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const supabase = c.get("supabase");
const tabloId = c.req.param("tabloId");
const { data: invites, error } = await supabase
.from("client_invites")
.select("id, invited_email, expires_at, is_pending, created_at")
.eq("tablo_id", tabloId)
.eq("is_pending", true)
.order("created_at", { ascending: false });
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({ invites: invites ?? [] });
});
/** DELETE /:tabloId/:inviteId — Cancel a client invite (admin only) */
const cancelClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const supabase = c.get("supabase");
const tabloId = c.req.param("tabloId");
const inviteId = Number(c.req.param("inviteId"));
if (!Number.isInteger(inviteId) || inviteId <= 0) {
return c.json({ error: "Invalid invite id" }, 400);
}
const { data: invite, error: inviteError } = await supabase
.from("client_invites")
.select("id, invited_email, is_pending")
.eq("id", inviteId)
.eq("tablo_id", tabloId)
.maybeSingle();
if (inviteError) {
return c.json({ error: inviteError.message }, 500);
}
if (!invite) {
return c.json({ error: "Invite not found" }, 404);
}
if (!invite.is_pending) {
return c.json({ error: "Invite is no longer pending" }, 400);
}
// Mark invite as cancelled
const { error: cancelError } = await supabase
.from("client_invites")
.update({ is_pending: false })
.eq("id", inviteId)
.eq("tablo_id", tabloId);
if (cancelError) {
return c.json({ error: cancelError.message }, 500);
}
// Revoke tablo access for the client user
if (invite.invited_email) {
const { data: clientProfile } = await supabase
.from("profiles")
.select("id")
.eq("email", invite.invited_email)
.maybeSingle();
if (clientProfile?.id) {
await supabase
.from("tablo_access")
.update({ is_active: false })
.eq("tablo_id", tabloId)
.eq("user_id", clientProfile.id);
}
}
return c.json({ success: true });
});
export const getClientInvitesRouter = () => {
const router = new Hono<AuthEnv>();
const middlewareManager = MiddlewareManager.getInstance();
router.post("/:tabloId", ...createClientInvite(middlewareManager));
router.post("/accept/:token", ...acceptClientInvite(middlewareManager));
router.get("/:tabloId/pending", ...getPendingClientInvites(middlewareManager));
router.delete("/:tabloId/:inviteId", ...cancelClientInvite(middlewareManager));
return router;
};

299
apps/clients/biome.json Normal file
View file

@ -0,0 +1,299 @@
{
"root": false,
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"files": {
"ignoreUnknown": true,
"includes": ["src/**/*", "*.{tsx,js,jsx,json}", "vite.config.ts"]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noBannedTypes": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error",
"noUselessTypeConstraint": "error"
},
"correctness": {
"noChildrenProp": "error",
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useIsNan": "error",
"useJsxKeyInIterable": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"nursery": {},
"security": { "noDangerouslySetInnerHtmlWithChildren": "error" },
"style": {
"noCommonJs": "error",
"noNamespace": "error",
"useArrayLiterals": "error",
"useAsConstAssertion": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCommentText": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateJsxProps": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noExplicitAny": "error",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noMisleadingInstantiator": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeDeclarationMerging": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error",
"useNamespaceKeyword": "error"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
},
"globals": [
"onanimationend",
"ongamepadconnected",
"onlostpointercapture",
"onanimationiteration",
"onkeyup",
"onmousedown",
"onanimationstart",
"onslotchange",
"onprogress",
"ontransitionstart",
"onpause",
"onended",
"onpointerover",
"onscrollend",
"onformdata",
"ontransitionrun",
"onanimationcancel",
"ondrag",
"onchange",
"onbeforeinstallprompt",
"onbeforexrselect",
"onmessage",
"ontransitioncancel",
"onpointerdown",
"onabort",
"onpointerout",
"oncuechange",
"ongotpointercapture",
"onscrollsnapchanging",
"onsearch",
"onsubmit",
"onstalled",
"onsuspend",
"onreset",
"onerror",
"onresize",
"onmouseenter",
"ongamepaddisconnected",
"ondragover",
"onbeforetoggle",
"onmouseover",
"onpagehide",
"onmousemove",
"onratechange",
"onmessageerror",
"onwheel",
"ondevicemotion",
"onauxclick",
"ontransitionend",
"onpaste",
"onpageswap",
"ononline",
"ondeviceorientationabsolute",
"onkeydown",
"onclose",
"onselect",
"onpageshow",
"onpointercancel",
"onbeforematch",
"onpointerrawupdate",
"ondragleave",
"onscrollsnapchange",
"onseeked",
"onwaiting",
"onbeforeunload",
"onplaying",
"onvolumechange",
"ondragend",
"onstorage",
"onloadeddata",
"onfocus",
"onoffline",
"onplay",
"onafterprint",
"onclick",
"oncut",
"onmouseout",
"ondblclick",
"oncanplay",
"onloadstart",
"onappinstalled",
"onpointermove",
"ontoggle",
"oncontextmenu",
"onblur",
"oncancel",
"onbeforeprint",
"oncontextrestored",
"onloadedmetadata",
"onpointerup",
"onlanguagechange",
"oncopy",
"onselectstart",
"onscroll",
"onload",
"ondragstart",
"onbeforeinput",
"oncanplaythrough",
"oninput",
"oninvalid",
"ontimeupdate",
"ondurationchange",
"onselectionchange",
"onmouseup",
"location",
"onkeypress",
"onpointerleave",
"oncontextlost",
"ondrop",
"onsecuritypolicyviolation",
"oncontentvisibilityautostatechange",
"ondeviceorientation",
"onseeking",
"onrejectionhandled",
"onunload",
"onmouseleave",
"onhashchange",
"onpointerenter",
"onmousewheel",
"onunhandledrejection",
"ondragenter",
"onpopstate",
"onpagereveal",
"onemptied"
]
},
"json": {
"parser": { "allowComments": true, "allowTrailingCommas": false },
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"trailingCommas": "none"
}
},
"overrides": [
{ "linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } } },
{ "linter": { "rules": { "style": { "useNodejsImportProtocol": "off" } } } },
{
"linter": {
"rules": {
"style": { "useNodejsImportProtocol": "off" },
"suspicious": { "noExplicitAny": "off" }
}
}
},
{
"includes": ["src/**/*.{ts,tsx}", "*.{ts,tsx}"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": { "useConst": "error" },
"suspicious": {
"noClassAssign": "off",
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"useGetterReturn": "off"
}
}
}
}
]
}

12
apps/clients/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xtablo — Client Portal</title>
</head>
<body>
<div id="client-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

50
apps/clients/package.json Normal file
View file

@ -0,0 +1,50 @@
{
"name": "@xtablo/clients",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite dev --port 5175",
"build": "tsc -b && vite build --mode production",
"build:staging": "tsc -b && vite build --mode staging",
"build:prod": "tsc -b && vite build --mode production",
"deploy": "wrangler deploy",
"typecheck": "tsc -b",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"preview": "vite preview",
"clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite"
},
"devDependencies": {
"@biomejs/biome": "2.2.5",
"@cloudflare/vite-plugin": "^1.9.4",
"@tailwindcss/vite": "^4.0.14",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.14",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0",
"vite": "^6.2.2",
"vite-tsconfig-paths": "^5.1.4",
"wrangler": "^4.24.3"
},
"dependencies": {
"@tanstack/react-query": "^5.69.0",
"@xtablo/shared": "workspace:*",
"@xtablo/shared-types": "workspace:*",
"@xtablo/tablo-views": "workspace:*",
"@xtablo/ui": "workspace:*",
"@xtablo/chat-ui": "workspace:*",
"i18next": "^25.6.0",
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.460.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^16.2.0",
"react-router-dom": "^7.9.4",
"tailwind-merge": "^3.0.2",
"zustand": "^5.0.5"
}
}

9
apps/clients/src/App.tsx Normal file
View file

@ -0,0 +1,9 @@
import AppRoutes from "./routes";
export default function App() {
return (
<div className="min-h-screen bg-background">
<AppRoutes />
</div>
);
}

View file

@ -0,0 +1,67 @@
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import { Outlet } from "react-router-dom";
import { supabase } from "../lib/supabase";
function getInitials(email: string): string {
const parts = email.split("@")[0].split(/[._-]/);
return parts
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
}
export function ClientLayout() {
const { session } = useSession();
if (!session) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-3">
<p className="text-lg font-medium text-foreground">Accès non autorisé</p>
<p className="text-sm text-muted-foreground">
Veuillez utiliser le lien reçu dans votre email pour accéder à cette page.
</p>
</div>
</div>
);
}
const email = session.user.email ?? "";
const initials = email ? getInitials(email) : "?";
const handleLogout = async () => {
await supabase.auth.signOut();
};
return (
<div className="min-h-screen bg-background">
{/* Top bar */}
<header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-14 items-center justify-between px-4 max-w-7xl mx-auto">
{/* Brand */}
<span className="text-lg font-bold text-foreground">Xtablo</span>
{/* User info + logout */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground hidden sm:block">{email}</span>
</div>
<Button variant="outline" size="sm" onClick={handleLogout}>
Déconnexion
</Button>
</div>
</div>
</header>
{/* Page content */}
<main className="max-w-7xl mx-auto px-4 py-6">
<Outlet />
</main>
</div>
);
}

31
apps/clients/src/i18n.ts Normal file
View file

@ -0,0 +1,31 @@
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import bookingEn from "./locales/en/booking.json";
// Import translation files
import bookingFr from "./locales/fr/booking.json";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
fr: {
booking: bookingFr,
},
en: {
booking: bookingEn,
},
},
fallbackLng: "fr",
defaultNS: "booking",
interpolation: {
escapeValue: false,
},
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
},
});
export default i18n;

View file

@ -0,0 +1,10 @@
import { createSupabaseClient } from "@xtablo/shared";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing Supabase environment variables");
}
export const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey);

View file

@ -0,0 +1,3 @@
{
"welcome": "Welcome"
}

View file

@ -0,0 +1,3 @@
{
"welcome": "Bienvenue"
}

1266
apps/clients/src/main.css Normal file

File diff suppressed because it is too large Load diff

29
apps/clients/src/main.tsx Normal file
View file

@ -0,0 +1,29 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@xtablo/shared";
import { SessionProvider } from "@xtablo/shared/contexts/SessionContext";
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { Toaster } from "@xtablo/ui/components/sonner";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter as Router } from "react-router-dom";
import App from "./App";
import { supabase } from "./lib/supabase";
import "@xtablo/ui/styles/globals.css";
import "./main.css";
import "./i18n";
createRoot(document.getElementById("client-root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<SessionProvider supabase={supabase}>
<ThemeProvider>
<Toaster />
<Router>
<App />
</Router>
</ThemeProvider>
</SessionProvider>
</QueryClientProvider>
</StrictMode>
);

View file

@ -0,0 +1,66 @@
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
export function AuthCallback() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const { session } = useSession();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const hasAccepted = useRef(false);
useEffect(() => {
if (!session || !token || hasAccepted.current) {
return;
}
hasAccepted.current = true;
const apiUrl = import.meta.env.VITE_API_URL as string;
fetch(`${apiUrl}/api/v1/client-invites/accept/${token}`, {
method: "POST",
headers: {
Authorization: `Bearer ${session.access_token}`,
"Content-Type": "application/json",
},
})
.then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { message?: string }).message ?? "Erreur lors de l'acceptation de l'invitation");
}
return res.json() as Promise<{ tabloId: string }>;
})
.then((data) => {
navigate(`/tablo/${data.tabloId}`, { replace: true });
})
.catch((err: unknown) => {
console.error("Accept invite error:", err);
setError(
"Une erreur est survenue lors de l'acceptation de l'invitation. Veuillez contacter la personne qui vous a invité."
);
});
}, [session, token, navigate]);
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-3 max-w-md px-4">
<p className="text-lg font-medium text-destructive">Erreur</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto" />
<p className="text-sm text-muted-foreground">Authentification en cours...</p>
</div>
</div>
);
}

View file

@ -0,0 +1,63 @@
import { useQuery } from "@tanstack/react-query";
import type { UserTablo } from "@xtablo/shared-types";
import { Navigate, Link } from "react-router-dom";
import { supabase } from "../lib/supabase";
function useClientTablosList() {
return useQuery<UserTablo[]>({
queryKey: ["client-tablos-list"],
queryFn: async () => {
const { data, error } = await supabase.from("user_tablos").select("*");
if (error) throw error;
return (data ?? []) as UserTablo[];
},
});
}
export function ClientTabloListPage() {
const { data: tablos, isLoading } = useClientTablosList();
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!tablos || tablos.length === 0) {
return (
<div className="text-center py-16">
<p className="text-muted-foreground">Aucun projet disponible.</p>
</div>
);
}
if (tablos.length === 1) {
return <Navigate to={`/tablo/${tablos[0].id}`} replace />;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Mes projets</h1>
<p className="text-muted-foreground mt-1">Sélectionnez un projet pour y accéder.</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tablos.map((tablo) => (
<Link
key={tablo.id}
to={`/tablo/${tablo.id}`}
className="block p-5 rounded-lg border border-border bg-card hover:bg-muted/50 transition-colors space-y-2"
>
{tablo.color && (
<div className={`w-8 h-8 rounded-lg ${tablo.color}`} />
)}
<h2 className="font-semibold text-foreground">{tablo.name}</h2>
</Link>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,310 @@
import { useQuery } from "@tanstack/react-query";
import { buildApi } from "@xtablo/shared";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import type { Etape, KanbanTask, TabloFolder, UserTablo } from "@xtablo/shared-types";
import { CalendarIcon, FolderIcon, KanbanIcon, ListChecksIcon, MapIcon, MessageCircleIcon } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import {
EtapesSection,
RoadmapSection,
TabloDiscussionSection,
TabloEventsSection,
TabloFilesSection,
TabloTasksSection,
} from "@xtablo/tablo-views";
import { supabase } from "../lib/supabase";
const API_URL = import.meta.env.VITE_API_URL as string;
// ─── Local hooks ──────────────────────────────────────────────────────────────
function useAuthedApi(accessToken: string | undefined) {
return buildApi(API_URL).create({
headers: {
Authorization: `Bearer ${accessToken ?? ""}`,
},
});
}
function useClientTablo(tabloId: string) {
return useQuery<UserTablo>({
queryKey: ["client-tablo", tabloId],
queryFn: async () => {
const { data, error } = await supabase
.from("user_tablos")
.select("*")
.eq("id", tabloId)
.single();
if (error) throw error;
return data as UserTablo;
},
enabled: !!tabloId,
});
}
function useClientTabloTasks(tabloId: string) {
return useQuery<KanbanTask[]>({
queryKey: ["client-tasks", tabloId],
queryFn: async () => {
const { data, error } = await supabase
.from("tasks_with_assignee")
.select("*")
.eq("tablo_id", tabloId)
.eq("is_parent", false)
.order("updated_at", { ascending: false });
if (error) throw error;
return (data ?? []) as KanbanTask[];
},
enabled: !!tabloId,
});
}
function useClientTabloEtapes(tabloId: string) {
return useQuery<Etape[]>({
queryKey: ["client-etapes", tabloId],
queryFn: async () => {
const { data, error } = await supabase
.from("tasks")
.select("*")
.eq("tablo_id", tabloId)
.eq("is_parent", true)
.order("position", { ascending: true });
if (error) throw error;
return (data ?? []) as Etape[];
},
enabled: !!tabloId,
});
}
function useClientTabloEvents(tabloId: string) {
return useQuery({
queryKey: ["client-events", tabloId],
queryFn: async () => {
const { data, error } = await supabase
.from("events_and_tablos")
.select("*")
.eq("tablo_id", tabloId)
.order("start_date", { ascending: false });
if (error) throw error;
return data ?? [];
},
enabled: !!tabloId,
});
}
function useClientTabloMembers(tabloId: string, accessToken: string | undefined) {
const api = useAuthedApi(accessToken);
return useQuery({
queryKey: ["client-members", tabloId],
queryFn: async () => {
const { data } = await api.get<{
members: {
id: string;
name: string;
is_admin: boolean;
email: string;
avatar_url: string | null;
}[];
}>(`/api/v1/tablos/members/${tabloId}`);
return data.members;
},
enabled: !!tabloId && !!accessToken,
});
}
function useClientTabloFiles(tabloId: string, accessToken: string | undefined) {
const api = useAuthedApi(accessToken);
return useQuery<{ fileNames: string[] }>({
queryKey: ["client-tablo-files", tabloId],
queryFn: async () => {
const { data } = await api.get(`/api/v1/tablo-data/${tabloId}/filenames`);
return data as { fileNames: string[] };
},
enabled: !!tabloId && !!accessToken,
});
}
function useClientTabloFolders(tabloId: string, accessToken: string | undefined) {
const api = useAuthedApi(accessToken);
return useQuery<TabloFolder[]>({
queryKey: ["client-tablo-folders", tabloId],
queryFn: async () => {
const { data } = await api.get<{ folders: TabloFolder[] }>(`/api/v1/tablo-folders/${tabloId}`);
return data.folders ?? [];
},
enabled: !!tabloId && !!accessToken,
});
}
// ─── Tabs ─────────────────────────────────────────────────────────────────────
type TabId = "overview" | "etapes" | "tasks" | "files" | "discussion" | "events" | "roadmap";
const TABS: { id: TabId; label: string; icon: React.ElementType }[] = [
{ id: "overview", label: "Aperçu", icon: ListChecksIcon },
{ id: "etapes", label: "Étapes", icon: ListChecksIcon },
{ id: "tasks", label: "Tâches", icon: KanbanIcon },
{ id: "files", label: "Fichiers", icon: FolderIcon },
{ id: "discussion", label: "Discussion", icon: MessageCircleIcon },
{ id: "events", label: "Événements", icon: CalendarIcon },
{ id: "roadmap", label: "Roadmap", icon: MapIcon },
];
// ─── Page ─────────────────────────────────────────────────────────────────────
export function ClientTabloPage() {
const { tabloId } = useParams<{ tabloId: string }>();
const { session } = useSession();
const [activeTab, setActiveTab] = useState<TabId>("overview");
const accessToken = session?.access_token;
const currentUserId = session?.user.id ?? "";
const { data: tablo, isLoading: tabloLoading } = useClientTablo(tabloId ?? "");
const { data: tasks = [] } = useClientTabloTasks(tabloId ?? "");
const { data: etapes = [] } = useClientTabloEtapes(tabloId ?? "");
const { data: events, isLoading: eventsLoading, error: eventsError } = useClientTabloEvents(tabloId ?? "");
const { data: members = [] } = useClientTabloMembers(tabloId ?? "", accessToken);
const { data: filesData, isLoading: filesLoading, error: filesError } = useClientTabloFiles(tabloId ?? "", accessToken);
const { data: folders = [], isLoading: foldersLoading, error: foldersError } = useClientTabloFolders(tabloId ?? "", accessToken);
const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith("."));
const currentUser = { id: currentUserId, avatar_url: null };
if (tabloLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!tablo) {
return (
<div className="text-center py-16">
<p className="text-muted-foreground">Projet introuvable.</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Tablo header */}
<div>
<h1 className="text-2xl font-bold text-foreground">{tablo.name}</h1>
</div>
{/* Tab bar */}
<div className="border-b border-border">
<nav className="flex gap-1 overflow-x-auto">
{TABS.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap transition-colors ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground"
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</nav>
</div>
{/* Tab content */}
<div>
{activeTab === "overview" && (
<div className="space-y-6">
{/* Simple overview: list etapes with progress */}
<EtapesSection
etapes={etapes}
tabloTasks={tasks}
tabloId={tablo.id}
isAdmin={false}
onCreateTask={() => {}}
onCreateEtape={async () => {}}
/>
</div>
)}
{activeTab === "etapes" && (
<EtapesSection
etapes={etapes}
tabloTasks={tasks}
tabloId={tablo.id}
isAdmin={false}
onCreateTask={() => {}}
onCreateEtape={async () => {}}
/>
)}
{activeTab === "tasks" && (
<TabloTasksSection
tablo={tablo}
isAdmin={false}
tasks={tasks}
members={members}
etapes={etapes}
currentUser={currentUser}
/>
)}
{activeTab === "files" && (
<TabloFilesSection
tablo={tablo}
isAdmin={false}
isReadOnly={true}
currentUserId={currentUserId}
fileNames={fileNames}
filesLoading={filesLoading}
filesError={filesError instanceof Error ? filesError : null}
folders={folders}
foldersLoading={foldersLoading}
foldersError={foldersError instanceof Error ? foldersError : null}
currentUser={currentUser}
members={members}
/>
)}
{activeTab === "discussion" && (
<TabloDiscussionSection
tablo={tablo}
isAdmin={false}
currentUserId={currentUserId}
members={members}
/>
)}
{activeTab === "events" && (
<TabloEventsSection
tablo={tablo}
isAdmin={false}
isReadOnly={true}
events={events as Parameters<typeof TabloEventsSection>[0]["events"]}
isLoading={eventsLoading}
error={eventsError instanceof Error ? eventsError : null}
currentUser={currentUser}
members={members}
/>
)}
{activeTab === "roadmap" && (
<RoadmapSection
tabloTasks={tasks}
onDateClick={() => {}}
onTaskStatusChange={() => {}}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
import { Route, Routes } from "react-router-dom";
import { ClientLayout } from "./components/ClientLayout";
import { AuthCallback } from "./pages/AuthCallback";
import { ClientTabloListPage } from "./pages/ClientTabloListPage";
import { ClientTabloPage } from "./pages/ClientTabloPage";
export default function AppRoutes() {
return (
<Routes>
<Route path="/auth/callback" element={<AuthCallback />} />
<Route element={<ClientLayout />}>
<Route path="/tablo/:tabloId" element={<ClientTabloPage />} />
<Route path="/" element={<ClientTabloListPage />} />
</Route>
</Routes>
);
}

View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@xtablo/ui": ["../../packages/ui/src"],
"@xtablo/ui/*": ["../../packages/ui/src/*"],
"@xtablo/shared": ["../../packages/shared/src"],
"@xtablo/shared/*": ["../../packages/shared/src/*"],
"@xtablo/tablo-views": ["../../packages/tablo-views/src"],
"@xtablo/tablo-views/*": ["../../packages/tablo-views/src/*"]
}
},
"include": ["src"],
"references": []
}

View file

@ -0,0 +1,18 @@
import { cloudflare } from "@cloudflare/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig, type PluginOption } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig(({ mode }) => {
const plugins: PluginOption[] = [react(), tailwindcss(), tsconfigPaths({ ignoreConfigErrors: true })];
if (mode !== "test" && process.env.VITEST !== "true") {
plugins.push(cloudflare({ inspectorPort: 9232 }));
}
return {
plugins,
server: { cors: false },
};
});

View file

@ -0,0 +1,9 @@
export default {
fetch(request: Request) {
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) {
return Response.json({ name: "Cloudflare" });
}
return new Response(null, { status: 404 });
},
};

View file

@ -0,0 +1,16 @@
name = "xtablo-clients"
main = "worker/index.ts"
compatibility_date = "2025-07-09"
[assets]
directory = "./dist/"
not_found_handling = "single-page-application"
[observability]
enabled = true
[env.staging]
route = { pattern = "clients-staging.xtablo.com", custom_domain = true }
[env.production]
route = { pattern = "clients.xtablo.com", custom_domain = true }

View file

@ -16,12 +16,12 @@ export default defineConfig(({ mode }) => {
react(),
// visualizer() as PluginOption,
tailwindcss(),
tsconfigPaths(),
tsconfigPaths({ ignoreConfigErrors: true }),
];
// Only include cloudflare plugin when not in test mode
if (mode !== "test" && process.env.VITEST !== "true") {
plugins.push(cloudflare());
plugins.push(cloudflare({ inspectorPort: 9231 }));
}
return {

View file

@ -102,6 +102,7 @@
"@xtablo/chat-ui": "workspace:*",
"@xtablo/shared": "workspace:*",
"@xtablo/shared-types": "workspace:*",
"@xtablo/tablo-views": "workspace:*",
"@xtablo/ui": "workspace:*",
"ag-grid-community": "^33.2.1",
"ag-grid-react": "^33.2.1",

View file

@ -42,9 +42,7 @@ export function ChatHeader({
<div className="ml-3">
<h2 className="font-semibold text-gray-900 dark:text-gray-100">{tablo.name}</h2>
{memberCount > 0 && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{memberCount} online
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{memberCount} online</p>
)}
</div>
</>

View file

@ -1,5 +1,6 @@
import { cn } from "@xtablo/shared";
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
import { TaskModal } from "@xtablo/tablo-views";
import { CheckCircle2, Plus } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -7,7 +8,6 @@ import { useNavigate } from "react-router-dom";
import { useTablosList } from "../hooks/tablos";
import { useAllTasks, useUpdateTask } from "../hooks/tasks";
import { useUser } from "../providers/UserStoreProvider";
import { TaskModal } from "./kanban/TaskModal";
type TaskWithTablo = KanbanTask & {
tablos: { id: string; name: string; color: string | null } | null;

View file

@ -54,11 +54,7 @@ export function Layout() {
aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"}
aria-expanded={isMobileMenuOpen}
>
{isMobileMenuOpen ? (
<XIcon className="h-6 w-6" />
) : (
<MenuIcon className="h-6 w-6" />
)}
{isMobileMenuOpen ? <XIcon className="h-6 w-6" /> : <MenuIcon className="h-6 w-6" />}
</Button>
{/* Mobile backdrop overlay */}
@ -66,9 +62,7 @@ export function Layout() {
className={twMerge(
"fixed inset-0 z-40 bg-black/50 md:hidden",
"transition-opacity duration-300 ease-in-out",
isMobileMenuOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
)}
onClick={closeMobileMenu}
aria-hidden="true"

View file

@ -301,11 +301,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
className={twMerge(
"group isolate flex flex-col overflow-y-auto overflow-x-hidden bg-navbar-background transition-all duration-300",
"h-full md:h-screen",
isMobileMenuOpen
? "w-40"
: effectivelyCollapsed
? "w-16"
: "w-48",
isMobileMenuOpen ? "w-40" : effectivelyCollapsed ? "w-16" : "w-48",
"md:flex",
// On mobile in standalone mode, respect safe area insets
"pl-[env(safe-area-inset-left,0px)] pt-[env(safe-area-inset-top,0px)] pb-[env(safe-area-inset-bottom,0px)]"
@ -352,7 +348,11 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
"hover:scale-110"
)}
>
{effectivelyCollapsed ? <PlusIcon aria-hidden="true" /> : <MinusIcon aria-hidden="true" />}
{effectivelyCollapsed ? (
<PlusIcon aria-hidden="true" />
) : (
<MinusIcon aria-hidden="true" />
)}
</Button>
)}
</div>

View file

@ -84,6 +84,7 @@ describe("ProtectedRoute", () => {
first_name: "Test",
last_name: "User",
is_temporary: false,
is_client: false,
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),

View file

@ -26,6 +26,7 @@ vi.mock("../hooks/auth", () => ({
import { useOrganization } from "../hooks/organization";
import { useSubscription } from "../hooks/stripe";
const mockUseOrganization = vi.mocked(useOrganization);
const mockUseSubscription = vi.mocked(useSubscription);
@ -38,6 +39,7 @@ const baseUser: User = {
email: "test@example.com",
avatar_url: null,
is_temporary: false,
is_client: false,
last_signed_in: null,
plan: "none",
created_at: new Date().toISOString(),
@ -50,7 +52,7 @@ const queryClient = new QueryClient({
function renderCard(
user: User,
orgData: ReturnType<typeof useOrganization>["data"],
subscription: ReturnType<typeof useSubscription>["data"] = undefined,
subscription: ReturnType<typeof useSubscription>["data"] = undefined
) {
mockUseOrganization.mockReturnValue({
data: orgData,
@ -74,7 +76,14 @@ function renderCard(
}
const baseOrg = {
organization: { id: 1, name: "Org", plan: "none", member_count: 1, tablo_count: 0, logo_url: null },
organization: {
id: 1,
name: "Org",
plan: "none",
member_count: 1,
tablo_count: 0,
logo_url: null,
},
members: [],
invites_sent: [],
trial_starts_at: "2026-01-01",
@ -122,9 +131,7 @@ describe("SubscriptionCard", () => {
it("shows billing owner restriction when user is not billing owner", () => {
const nonOwnerOrg = { ...baseOrg, is_billing_owner: false };
renderCard(baseUser, nonOwnerOrg);
expect(
screen.getByText(/Seul le propriétaire de facturation/)
).toBeInTheDocument();
expect(screen.getByText(/Seul le propriétaire de facturation/)).toBeInTheDocument();
expect(screen.queryByText(/Passer au plan/)).not.toBeInTheDocument();
});

View file

@ -1,8 +1,8 @@
import { TabloDiscussionSection } from "@xtablo/tablo-views";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { TabloDiscussionSection } from "./TabloDiscussionSection";
vi.mock("../hooks/useChat", () => ({
vi.mock("@xtablo/tablo-views/hooks/useChat", () => ({
useChat: () => ({
messages: [],
sendMessage: vi.fn(),
@ -33,7 +33,7 @@ describe("TabloDiscussionSection", () => {
it("renders without crashing", () => {
const { container } = renderWithProviders(
<TabloDiscussionSection tablo={mockTablo} isAdmin={true} />
<TabloDiscussionSection tablo={mockTablo} isAdmin={true} currentUserId="test-user-id" />
);
expect(container).toBeInTheDocument();
});

View file

@ -1,8 +1,8 @@
import { TabloEventsSection } from "@xtablo/tablo-views";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { TabloEventsSection } from "./TabloEventsSection";
vi.mock("../hooks/events", () => ({
vi.mock("@xtablo/tablo-views/hooks/events", () => ({
useEventsByTablo: () => ({
data: [
{
@ -46,14 +46,14 @@ describe("TabloEventsSection", () => {
it("renders without crashing", () => {
const { container } = renderWithProviders(
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
);
expect(container).toBeInTheDocument();
});
it("displays section title", () => {
const { container } = renderWithProviders(
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
);
// Just check that the component renders
expect(container).toBeInTheDocument();
@ -61,7 +61,7 @@ describe("TabloEventsSection", () => {
it("displays events from the tablo", () => {
const { container } = renderWithProviders(
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
);
// Component should render the events section
expect(container).toBeInTheDocument();
@ -69,7 +69,7 @@ describe("TabloEventsSection", () => {
it("shows add event button for admin users", () => {
const { container } = renderWithProviders(
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
);
// Component should render for admin users
expect(container).toBeInTheDocument();
@ -77,7 +77,7 @@ describe("TabloEventsSection", () => {
it("navigates to events page when add button is clicked", () => {
const { container } = renderWithProviders(
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
);
// Component renders successfully
expect(container).toBeInTheDocument();
@ -85,7 +85,7 @@ describe("TabloEventsSection", () => {
it("shows view all events link", () => {
const { container } = renderWithProviders(
<TabloEventsSection tablo={mockTablo} isAdmin={true} />
<TabloEventsSection tablo={mockTablo} isAdmin={true} currentUser={{ id: "test-user-id" }} />
);
// Component renders successfully
expect(container).toBeInTheDocument();
@ -93,7 +93,7 @@ describe("TabloEventsSection", () => {
it("hides add button for non-admin users", () => {
const { container } = renderWithProviders(
<TabloEventsSection tablo={mockTablo} isAdmin={false} />
<TabloEventsSection tablo={mockTablo} isAdmin={false} currentUser={{ id: "test-user-id" }} />
);
// Component renders for non-admin users
expect(container).toBeInTheDocument();

View file

@ -1,6 +1,6 @@
import { TabloFilesSection } from "@xtablo/tablo-views";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { TabloFilesSection } from "./TabloFilesSection";
vi.mock("../hooks/files", () => ({
useTabloFileNames: () => ({
@ -29,7 +29,12 @@ describe("TabloFilesSection", () => {
it("renders without crashing", () => {
const { container } = renderWithProviders(
<TabloFilesSection tablo={mockTablo} isAdmin={true} />
<TabloFilesSection
tablo={mockTablo}
isAdmin={true}
currentUserId="test-user-id"
currentUser={{ id: "test-user-id" }}
/>
);
expect(container).toBeInTheDocument();
});

View file

@ -1,5 +1,6 @@
import { toast } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { TabloHeaderActions } from "@xtablo/tablo-views";
import { Button } from "@xtablo/ui/components/button";
import { Input } from "@xtablo/ui/components/input";
import { TypographyH3, TypographyMuted, TypographyP } from "@xtablo/ui/components/typography";
@ -16,7 +17,6 @@ import {
} from "../hooks/tasks";
import { useUser } from "../providers/UserStoreProvider";
import { getEtapeProgressStats } from "../utils/etapeProgress";
import { TabloHeaderActions } from "./TabloHeaderActions";
interface TabloOverviewSectionProps {
tablo: UserTablo;
@ -289,7 +289,7 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro
{t("tablo:overview.description")}
</TypographyMuted>
</div>
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} currentUser={currentUser} />
</div>
{!canManageEtapes && (

View file

@ -23,6 +23,7 @@ vi.mock("../hooks/auth", () => ({
}));
import { useOrganization } from "../hooks/organization";
const mockUseOrganization = vi.mocked(useOrganization);
const baseUser: User = {
@ -34,6 +35,7 @@ const baseUser: User = {
email: "test@example.com",
avatar_url: null,
is_temporary: false,
is_client: false,
last_signed_in: null,
plan: "none",
created_at: new Date().toISOString(),
@ -62,7 +64,14 @@ function renderPanel(user: User, orgData: ReturnType<typeof useOrganization>["da
}
const noPlanOrg = {
organization: { id: 1, name: "Org", plan: "none", member_count: 1, tablo_count: 0, logo_url: null },
organization: {
id: 1,
name: "Org",
plan: "none",
member_count: 1,
tablo_count: 0,
logo_url: null,
},
members: [],
invites_sent: [],
trial_starts_at: "2026-01-01",
@ -128,9 +137,7 @@ describe("UpgradePanel", () => {
const soloButton = screen.getByText("Passer au plan Solo").closest("button");
expect(soloButton).toBeDisabled();
expect(
screen.getByText(/Seul le propriétaire de facturation/)
).toBeInTheDocument();
expect(screen.getByText(/Seul le propriétaire de facturation/)).toBeInTheDocument();
});
it("renders nothing when org data is not yet loaded", () => {

View file

@ -129,7 +129,8 @@ export function UpgradePanel() {
disabled={checkoutPending || !isBillingOwner}
className="w-full"
>
Passer au plan Teams ({requiredTeamQuantity} siège{requiredTeamQuantity > 1 ? "s" : ""})
Passer au plan Teams ({requiredTeamQuantity} siège
{requiredTeamQuantity > 1 ? "s" : ""})
</Button>
<Button

View file

@ -1,6 +1,2 @@
export { InlineTaskCreate } from "./InlineTaskCreate";
export { KanbanBoard } from "./KanbanBoard";
export { KanbanColumn } from "./KanbanColumn";
export { KanbanTaskCard } from "./KanbanTaskCard";
export { TaskModal } from "./TaskModal";
export type { TabloMember } from "./types";
export type { TabloMember } from "@xtablo/tablo-views";
export { KanbanBoard, TaskModal } from "@xtablo/tablo-views";

View file

@ -2,11 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TestUserStoreProvider, type User } from "../providers/UserStoreProvider";
import {
UpgradeBlockProvider,
useMaybeUpgradeBlock,
useUpgradeBlock,
} from "./UpgradeBlockContext";
import { UpgradeBlockProvider, useMaybeUpgradeBlock, useUpgradeBlock } from "./UpgradeBlockContext";
// Mock the organization hook
vi.mock("../hooks/organization", () => ({
@ -14,6 +10,7 @@ vi.mock("../hooks/organization", () => ({
}));
import { useOrganization } from "../hooks/organization";
const mockUseOrganization = vi.mocked(useOrganization);
const baseUser: User = {
@ -25,6 +22,7 @@ const baseUser: User = {
email: "test@example.com",
avatar_url: null,
is_temporary: false,
is_client: false,
last_signed_in: null,
plan: "none",
created_at: new Date().toISOString(),
@ -72,7 +70,14 @@ function renderWithUser(user: User | null, orgData: ReturnType<typeof useOrganiz
}
const compliantOrgData = {
organization: { id: 1, name: "Test Org", plan: "team", member_count: 2, tablo_count: 5, logo_url: null },
organization: {
id: 1,
name: "Test Org",
plan: "team",
member_count: 2,
tablo_count: 5,
logo_url: null,
},
members: [],
invites_sent: [],
trial_starts_at: "2026-01-01",

View file

@ -1,6 +1,6 @@
import React, { createContext, useContext } from "react";
import { getOrganizationUpgradeBlockReason, type UpgradeBlockReason } from "../hooks/stripe";
import { useOrganization } from "../hooks/organization";
import { getOrganizationUpgradeBlockReason, type UpgradeBlockReason } from "../hooks/stripe";
import { useMaybeUser } from "../providers/UserStoreProvider";
interface UpgradeBlockContextValue {

View file

@ -6,7 +6,6 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { match } from "ts-pattern";
import { api } from "../lib/api";
import { clearOrgIdCookie } from "./organization";
import {
DEFAULT_SIGNUP_BILLING_INTENT,
PENDING_BILLING_CHECKOUT_PLAN_KEY,
@ -14,6 +13,7 @@ import {
SignupBillingIntent,
} from "../lib/billing";
import { supabase } from "../lib/supabase";
import { clearOrgIdCookie } from "./organization";
export type User = SupabaseUser & {
user_metadata: {

View file

@ -0,0 +1,88 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast, useSession } from "@xtablo/shared";
import { useAuthedApi } from "./auth";
type PendingClientInvite = {
id: number;
invited_email: string;
expires_at: string;
is_pending: boolean;
created_at: string;
};
export const usePendingClientInvites = (tabloId: string) => {
const api = useAuthedApi();
const { session } = useSession();
return useQuery({
queryKey: ["client-invites", tabloId],
queryFn: async () => {
const { data } = await api.get<PendingClientInvite[]>(
`/api/v1/client-invites/${tabloId}/pending`
);
return data;
},
enabled: !!tabloId && !!session,
});
};
export const useCreateClientInvite = () => {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tabloId, email }: { tabloId: string; email: string }) => {
const { data } = await api.post<PendingClientInvite>(`/api/v1/client-invites/${tabloId}`, {
email,
});
return data;
},
onSuccess: (_data, { tabloId }) => {
queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] });
toast.add(
{
title: "Lien magique envoyé",
description: "L'invitation client a été envoyée avec succès",
type: "success",
},
{ timeout: 3000 }
);
},
onError: (error) => {
console.error("Error creating client invite:", error);
toast.add(
{
title: "Erreur",
description: "Impossible d'envoyer l'invitation client",
type: "error",
},
{ timeout: 5000 }
);
},
});
};
export const useCancelClientInvite = () => {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tabloId, inviteId }: { tabloId: string; inviteId: number }) => {
await api.delete(`/api/v1/client-invites/${tabloId}/${inviteId}`);
},
onSuccess: (_data, { tabloId }) => {
queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] });
},
onError: (error) => {
console.error("Error cancelling client invite:", error);
toast.add(
{
title: "Erreur",
description: "Impossible d'annuler l'invitation client",
type: "error",
},
{ timeout: 5000 }
);
},
});
};

View file

@ -8,8 +8,8 @@ import type {
KanbanTaskUpdate,
TaskStatus,
} from "@xtablo/shared-types";
import { supabase } from "../lib/supabase";
import { useMaybeUpgradeBlock } from "../contexts/UpgradeBlockContext";
import { supabase } from "../lib/supabase";
type CreateEtapeInput = {
tabloId: string;

View file

@ -1242,8 +1242,12 @@
}
@keyframes slide {
0% { transform: translateX(-100vw); }
100% { transform: translateX(100vw); }
0% {
transform: translateX(-100vw);
}
100% {
transform: translateX(100vw);
}
}
.animate-slide {

View file

@ -1,12 +1,10 @@
import { ChatMessages, useChat, useChatUnread } from "@xtablo/tablo-views";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { ChatChannelPreview } from "../components/ChatChannelPreview";
import { ChatHeader } from "../components/ChatHeader";
import { ChatMessages } from "../components/ChatMessages";
import { useChat } from "../hooks/useChat";
import { useChatUnread } from "../hooks/useChatUnread";
import { useTablosList, useTabloMembers } from "../hooks/tablos";
import { useTabloMembers, useTablosList } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
export function ChatPage() {

View file

@ -32,9 +32,9 @@ import {
useInviteOrganizationUser,
useOrganization,
useRemoveOrganizationMember,
useRemoveOrgLogo,
useUpdateOrganization,
useUploadOrgLogo,
useRemoveOrgLogo,
} from "../hooks/organization";
import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "../hooks/profile";
import { useCookieConsent } from "../hooks/useCookieConsent";
@ -529,7 +529,9 @@ export default function SettingsPage() {
<div className="flex justify-end">
<Button
disabled={inviteOrganizationUserPending || !inviteEmail.trim() || isAtTeamMemberLimit}
disabled={
inviteOrganizationUserPending || !inviteEmail.trim() || isAtTeamMemberLimit
}
onClick={() => {
inviteOrganizationUser(inviteEmail.trim());
setInviteEmail("");

View file

@ -1,7 +1,17 @@
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { cn, toast } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import type { Etape, KanbanTask } from "@xtablo/shared-types";
import type { KanbanTask } from "@xtablo/shared-types";
import {
EtapesSection,
RoadmapSection,
TabloDiscussionSection,
TabloEventsSection,
TabloFilesSection,
TabloTasksSection,
TaskModal,
useChatUnread,
} from "@xtablo/tablo-views";
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import {
@ -14,8 +24,6 @@ import {
import { Input } from "@xtablo/ui/components/input";
import {
CalendarIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleCheckIcon,
Compass,
EllipsisVerticalIcon,
@ -36,19 +44,30 @@ import {
Sun,
UserPlusIcon,
Waves,
XIcon,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { GanttChart } from "../components/gantt/GanttChart";
import { TaskModal } from "../components/kanban/TaskModal";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloEventsSection } from "../components/TabloEventsSection";
import { TabloFilesSection } from "../components/TabloFilesSection";
import { TabloTasksSection } from "../components/TabloTasksSection";
import { useChatUnread } from "../hooks/useChatUnread";
import {
useCancelClientInvite,
useCreateClientInvite,
usePendingClientInvites,
} from "../hooks/client_invites";
import { useEventsByTablo } from "../hooks/events";
import { useInviteUser } from "../hooks/invite";
import { useTabloFileNames } from "../hooks/tablo_data";
import {
useDeleteTabloFile,
useDownloadTabloFile,
useTabloFileNames,
useUploadTabloFile,
} from "../hooks/tablo_data";
import {
useCreateTabloFolder,
useDeleteTabloFolder,
useTabloFolders,
useUpdateTabloFolder,
} from "../hooks/tablo_folders";
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
import {
useTabloMembers,
@ -62,6 +81,7 @@ import {
useCreateTask,
useTabloEtapes,
useUpdateTask,
useUpdateTaskPositions,
} from "../hooks/tasks";
import { useUser } from "../providers/UserStoreProvider";
import { getEtapeProgressStats } from "../utils/etapeProgress";
@ -183,6 +203,7 @@ export const TabloDetailsPage = () => {
const [showAllOverviewTasks, setShowAllOverviewTasks] = useState(false);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [clientInviteEmail, setClientInviteEmail] = useState("");
const [isLayoutEditMode, setIsLayoutEditMode] = useState(false);
const [draggedOverviewBlock, setDraggedOverviewBlock] = useState<{
zone: "left" | "right";
@ -196,8 +217,35 @@ export const TabloDetailsPage = () => {
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? "");
const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite();
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
const { data: pendingClientInvites } = usePendingClientInvites(tabloId ?? "");
const { mutate: createClientInvite, isPending: isCreatingClientInvite } = useCreateClientInvite();
const { mutate: cancelClientInvite, isPending: isCancellingClientInvite } =
useCancelClientInvite();
const { mutate: updateTask } = useUpdateTask();
const { mutate: updateTablo } = useUpdateTablo();
const { mutate: updateTablo, mutateAsync: updateTabloAsync } = useUpdateTablo();
const { mutate: createTask } = useCreateTask();
const { mutateAsync: createEtape, isPending: isCreatingEtape } = useCreateEtape();
const { mutate: updateTaskPositions } = useUpdateTaskPositions();
// Files & folders hooks
const {
data: foldersData,
isLoading: foldersLoading,
error: foldersError,
} = useTabloFolders(tabloId ?? "");
const { mutateAsync: downloadFile } = useDownloadTabloFile();
const { mutateAsync: uploadFile } = useUploadTabloFile();
const { mutateAsync: deleteFile } = useDeleteTabloFile();
const { mutateAsync: createFolder, isPending: isCreatingFolder } = useCreateTabloFolder();
const { mutateAsync: updateFolder, isPending: isUpdatingFolder } = useUpdateTabloFolder();
const { mutateAsync: deleteFolder } = useDeleteTabloFolder();
// Events hooks
const {
data: events,
isLoading: eventsLoading,
error: eventsError,
} = useEventsByTablo(tabloId ?? null);
const isEmailValid = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@ -500,7 +548,12 @@ export const TabloDetailsPage = () => {
</div>
{/* ── Tab content ─────────────────────────────────────────────────── */}
<div className={cn("px-4 sm:px-6 pt-6 pb-8", activeSection === "discussion" && "flex flex-col flex-1 min-h-0 !px-0 !pt-0 !pb-0")}>
<div
className={cn(
"px-4 sm:px-6 pt-6 pb-8",
activeSection === "discussion" && "flex flex-col flex-1 min-h-0 !px-0 !pt-0 !pb-0"
)}
>
{activeSection === "overview" &&
(() => {
const overviewBlocks: Record<OverviewBlockId, React.ReactNode> = {
@ -783,14 +836,94 @@ export const TabloDetailsPage = () => {
);
})()}
{activeSection === "tasks" && <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "tasks" && (
<TabloTasksSection
tablo={tablo}
isAdmin={isAdmin}
tasks={tabloTasks}
members={members}
etapes={etapes}
currentUser={currentUser}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
onCreateTask={(task) => createTask(task)}
onUpdateTask={(task) => updateTask(task)}
onUpdateTaskPositions={(updates) => updateTaskPositions(updates)}
onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
}
onInviteUser={inviteUser}
onCancelInvite={(params) =>
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
}
/>
)}
{activeSection === "files" && (
<TabloFilesSection
tablo={tablo}
isAdmin={isAdmin}
currentUserId={currentUser.id}
fileNames={(filesData?.fileNames ?? []).filter((f) => !f.startsWith("."))}
filesLoading={false}
filesError={null}
folders={foldersData?.folders ?? []}
foldersLoading={foldersLoading}
foldersError={foldersError as Error | null}
currentUser={currentUser}
members={members}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
isCreatingFolder={isCreatingFolder}
isUpdatingFolder={isUpdatingFolder}
onCreateFile={(params) => uploadFile(params).then(() => undefined)}
onDeleteFile={(params) => deleteFile(params).then(() => undefined)}
onDownloadFile={(params) => downloadFile(params).then(() => undefined)}
onCreateFolder={(params) => createFolder(params).then(() => undefined)}
onUpdateFolder={(params) => updateFolder(params).then(() => undefined)}
onDeleteFolder={(params) => deleteFolder(params).then(() => undefined)}
onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
}
onInviteUser={inviteUser}
onCancelInvite={(params) =>
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
}
/>
)}
{activeSection === "discussion" && (
<div className="flex-1 min-h-0">
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
<TabloDiscussionSection
tablo={tablo}
isAdmin={isAdmin}
currentUserId={currentUser.id}
members={members}
/>
</div>
)}
{activeSection === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "events" && (
<TabloEventsSection
tablo={tablo}
isAdmin={isAdmin}
events={events ?? []}
isLoading={eventsLoading}
error={eventsError as Error | null}
currentUser={currentUser}
members={members}
pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
onCreateEvent={() => undefined}
onUpdateTablo={(data) =>
updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined)
}
onInviteUser={inviteUser}
onCancelInvite={(params) =>
cancelInvite({ ...params, inviteId: Number(params.inviteId) })
}
/>
)}
{activeSection === "etapes" && (
<EtapesSection
@ -798,11 +931,23 @@ export const TabloDetailsPage = () => {
tabloTasks={tabloTasks}
tabloId={tabloId ?? ""}
isAdmin={isAdmin}
onCreateTask={(task) =>
createTask({
...task,
status: task.status as "todo" | "in_progress" | "in_review" | "done",
})
}
onCreateEtape={(params) => createEtape(params).then(() => undefined)}
isCreatingEtape={isCreatingEtape}
/>
)}
{activeSection === "roadmap" && (
<RoadmapSection etapes={etapes} tabloTasks={tabloTasks} onDateClick={openTaskModal} />
<RoadmapSection
tabloTasks={tabloTasks}
onDateClick={openTaskModal}
onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
/>
)}
</div>
@ -826,82 +971,6 @@ export const TabloDetailsPage = () => {
</DialogHeader>
<div className="space-y-4">
{/* Invite Input */}
<div className="flex flex-col sm:flex-row gap-2">
<Input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Email de l'utilisateur"
className="flex-1 min-h-[44px]"
/>
{isInvitingUser ? (
<div className="flex justify-center items-center px-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
</div>
) : (
<Button
type="button"
onClick={handleSendInvite}
disabled={!isEmailValid(inviteEmail)}
>
Inviter
</Button>
)}
</div>
{/* Pending Invites */}
{pendingInvites && pendingInvites.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">
Invitations en attente ({pendingInvites.length})
</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{pendingInvites.map((invite) => (
<div
key={invite.id}
className="flex items-center space-x-2 p-2 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-dashed border-orange-200 dark:border-orange-900/50"
>
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center text-orange-600 dark:text-orange-400 text-xs">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<span className="text-xs font-medium text-foreground truncate block">
{invite.invited_email}
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() =>
cancelInvite({
tabloId: tabloId ?? "",
inviteId: invite.id,
})
}
disabled={isCancellingInvite || !tabloId}
title="Retirer l'invitation"
>
{isCancellingInvite ? "..." : "Retirer"}
</Button>
</div>
))}
</div>
</div>
)}
{/* Members List */}
{filteredMembers && filteredMembers.length > 0 && (
<div>
@ -938,373 +1007,125 @@ export const TabloDetailsPage = () => {
</div>
</div>
)}
{/* Separator */}
<div className="border-t border-border pt-4">
{/* Client Access Section */}
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">Accès client</h4>
<p className="text-xs text-muted-foreground">
Invitez des clients externes via un lien magique
</p>
</div>
{/* Client Invite Input */}
<div className="flex flex-col sm:flex-row gap-2">
<Input
type="email"
value={clientInviteEmail}
onChange={(e) => setClientInviteEmail(e.target.value)}
placeholder="Email du client"
className="flex-1 min-h-[44px]"
/>
{isCreatingClientInvite ? (
<div className="flex justify-center items-center px-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
</div>
) : (
<Button
type="button"
onClick={() => {
if (tabloId && clientInviteEmail) {
createClientInvite(
{ tabloId, email: clientInviteEmail },
{ onSuccess: () => setClientInviteEmail("") }
);
}
}}
disabled={!isEmailValid(clientInviteEmail)}
>
Envoyer le lien
</Button>
)}
</div>
{/* Pending Client Invites */}
{pendingClientInvites && pendingClientInvites.length > 0 && (
<div className="mt-3">
<h4 className="text-sm font-semibold text-foreground mb-2">
Invitations client en attente ({pendingClientInvites.length})
</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{pendingClientInvites.map((invite) => {
const daysUntilExpiry = Math.ceil(
(new Date(invite.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
const isExpiringSoon = daysUntilExpiry < 5;
return (
<div
key={invite.id}
className="flex items-center space-x-2 p-2 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-dashed border-blue-200 dark:border-blue-900/50"
>
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center text-blue-600 dark:text-blue-400 text-xs flex-shrink-0">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<span className="text-xs font-medium text-foreground truncate block">
{invite.invited_email}
</span>
<span
className={`text-xs ${
isExpiringSoon
? "text-orange-600 dark:text-orange-400 font-medium"
: "text-muted-foreground"
}`}
>
{isExpiringSoon && "⚠ "}
Expire dans {daysUntilExpiry} jour{daysUntilExpiry !== 1 ? "s" : ""}
</span>
</div>
{isExpiringSoon && (
<span className="text-xs px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded font-medium flex-shrink-0">
Bientôt expiré
</span>
)}
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 flex-shrink-0"
onClick={() =>
cancelClientInvite({
tabloId: tabloId ?? "",
inviteId: invite.id,
})
}
disabled={isCancellingClientInvite || !tabloId}
title="Annuler l'invitation"
>
<XIcon className="w-3.5 h-3.5" />
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
};
// ─── Etapes (Steps) section ─────────────────────────────────────────────────
function EtapesSection({
etapes,
tabloTasks,
tabloId,
isAdmin,
}: {
etapes: Etape[];
tabloTasks: KanbanTask[];
tabloId: string;
isAdmin: boolean;
}) {
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
new Set(etapes.map((e) => e.id))
);
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(null);
const [newEtapeTitle, setNewEtapeTitle] = useState("");
const [newTaskTitle, setNewTaskTitle] = useState("");
const { mutate: createTask } = useCreateTask();
const { mutateAsync: createEtape, isPending: isCreatingEtape } = useCreateEtape();
const toggleEtape = (id: string) => {
setExpandedEtapes((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleAddTask = (etapeId: string) => {
const title = newTaskTitle.trim();
if (!title || !tabloId) return;
createTask({
tablo_id: tabloId,
title,
status: "todo",
parent_task_id: etapeId,
is_parent: false,
position: tabloTasks.filter((t) => t.parent_task_id === etapeId).length,
});
setNewTaskTitle("");
setAddingTaskToEtape(null);
};
const handleAddEtape = async () => {
const title = newEtapeTitle.trim();
if (!title || !tabloId) {
return;
}
const nextPosition = etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1;
await createEtape({
tabloId,
title,
position: nextPosition,
});
setNewEtapeTitle("");
};
const statusConfig: Record<string, { label: string; color: string }> = {
todo: {
label: "À faire",
color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400",
},
in_progress: {
label: "En cours",
color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400",
},
in_review: {
label: "Vérification",
color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400",
},
done: {
label: "Terminé",
color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400",
},
};
return (
<div className="space-y-4">
{isAdmin && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Input
value={newEtapeTitle}
onChange={(event) => setNewEtapeTitle(event.target.value)}
placeholder="Nom de la nouvelle étape..."
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleAddEtape();
}
}}
className="h-11 sm:h-9 sm:w-80"
/>
<Button
onClick={() => void handleAddEtape()}
disabled={isCreatingEtape || !newEtapeTitle.trim()}
className="min-h-[44px] sm:min-h-0"
>
<PlusIcon className="w-4 h-4" />
Ajouter une étape
</Button>
</div>
)}
{etapes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<ListChecksIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Aucune étape</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
Les étapes permettent de structurer votre projet en grandes phases
</p>
</div>
) : (
etapes.map((etape, index) => {
const childTasks = tabloTasks.filter((t) => t.parent_task_id === etape.id);
const doneCount = childTasks.filter((t) => t.status === "done").length;
const totalCount = childTasks.length;
const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0;
const isExpanded = expandedEtapes.has(etape.id);
// Derive status from child tasks instead of etape.status
const derivedStatus =
totalCount === 0
? "todo"
: doneCount === totalCount
? "done"
: doneCount > 0
? "in_progress"
: "todo";
const status = statusConfig[derivedStatus] ?? statusConfig.todo;
return (
<div
key={etape.id}
className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden"
>
{/* Etape header */}
<button
type="button"
onClick={() => toggleEtape(etape.id)}
className="w-full flex items-center gap-3 sm:gap-4 px-3 sm:px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left min-h-[56px]"
>
{isExpanded ? (
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
) : (
<ChevronRightIcon className="w-5 h-5 text-gray-400 shrink-0" />
)}
<div className="w-8 h-8 rounded-lg bg-[#F4F3FF] dark:bg-purple-900/20 flex items-center justify-center shrink-0">
<span className="text-sm font-bold text-[#7F56D9] dark:text-purple-400">
{index + 1}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm sm:text-base">
{etape.title}
</h3>
{etape.description && (
<p className="text-xs sm:text-sm text-muted-foreground truncate mt-0.5">
{etape.description}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{etape.due_date && (
<div
className={cn(
"items-center gap-1 text-xs hidden sm:flex",
derivedStatus !== "done" &&
new Date(etape.due_date) < new Date(new Date().toDateString())
? "text-red-500"
: "text-muted-foreground"
)}
>
<CalendarIcon className="w-3.5 h-3.5" />
<span>
{new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
}).format(new Date(etape.due_date))}
</span>
</div>
)}
<span
className={cn(
"px-2 sm:px-2.5 py-1 rounded-full text-[10px] sm:text-xs font-medium",
status.color
)}
>
{status.label}
</span>
{totalCount > 0 && (
<div className="hidden sm:flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${progressPct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{doneCount}/{totalCount}
</span>
</div>
)}
</div>
</button>
{/* Child tasks + add task */}
{isExpanded && (
<div className="border-t border-gray-100 dark:border-gray-700">
{childTasks.length > 0 && (
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{childTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 px-3 sm:px-5 py-3 pl-8 sm:pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{task.status === "done" ? (
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />
) : (
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
)}
<span
className={cn(
"text-sm flex-1 truncate",
task.status === "done"
? "line-through text-gray-400"
: "text-gray-900 dark:text-gray-100"
)}
>
{task.title}
</span>
{task.due_date && (
<div
className={cn(
"flex items-center gap-1 text-xs shrink-0",
task.status !== "done" &&
new Date(task.due_date) < new Date(new Date().toDateString())
? "text-red-500"
: "text-muted-foreground"
)}
>
<CalendarIcon className="w-3 h-3" />
<span>
{new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
}).format(new Date(task.due_date))}
</span>
</div>
)}
{task.status && (
<span
className={cn(
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
(statusConfig[task.status] ?? statusConfig.todo).color
)}
>
{(statusConfig[task.status] ?? statusConfig.todo).label}
</span>
)}
</div>
))}
</div>
)}
{childTasks.length === 0 && addingTaskToEtape !== etape.id && (
<div className="px-3 sm:px-5 py-4 pl-8 sm:pl-16 text-sm text-muted-foreground">
Aucune tâche dans cette étape
</div>
)}
{/* Inline add task */}
{addingTaskToEtape === etape.id ? (
<div className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 border-t border-gray-100 dark:border-gray-700">
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
<input
autoFocus
type="text"
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddTask(etape.id);
if (e.key === "Escape") {
setAddingTaskToEtape(null);
setNewTaskTitle("");
}
}}
placeholder="Nom de la tâche..."
className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 min-w-0"
/>
<button
type="button"
onClick={() => handleAddTask(etape.id)}
disabled={!newTaskTitle.trim()}
className="text-xs font-medium px-3 py-2 rounded-md bg-[#804EEC] text-white hover:bg-[#6f3fd4] disabled:opacity-40 transition-colors min-h-[36px] shrink-0"
>
Ajouter
</button>
<button
type="button"
onClick={() => {
setAddingTaskToEtape(null);
setNewTaskTitle("");
}}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-2 min-h-[36px] shrink-0"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAddingTaskToEtape(etape.id);
setNewTaskTitle("");
}}
className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 text-sm text-muted-foreground hover:text-[#804EEC] hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors w-full text-left border-t border-gray-100 dark:border-gray-700 min-h-[44px]"
>
<PlusIcon className="w-4 h-4" />
Ajouter une tâche
</button>
)}
</div>
)}
</div>
);
})
)}
</div>
);
}
// ─── Roadmap Section ─────────────────────────────────────────────────────────
function RoadmapSection({
tabloTasks,
onDateClick,
}: {
etapes: Etape[];
tabloTasks: KanbanTask[];
onDateClick: (date: Date) => void;
}) {
const { mutate: updateTask } = useUpdateTask();
return (
<GanttChart
tasks={tabloTasks}
isLoading={false}
onDateClick={onDateClick}
onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
/>
);
}

View file

@ -3,6 +3,8 @@ import { DeleteTabloModal } from "@ui/components/DeleteTabloModal";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { toast } from "@xtablo/shared";
import { TabloInsert, UserTablo } from "@xtablo/shared/types/tablos.types";
import { TaskModal } from "@xtablo/tablo-views";
import { Badge } from "@xtablo/ui/components/badge";
import { Button } from "@xtablo/ui/components/button";
import {
Empty,
@ -40,11 +42,9 @@ import { useNavigate, useSearchParams } from "react-router-dom";
import { DashboardActionCards } from "src/components/DashboardActionCards";
import { DashboardTaskList } from "src/components/DashboardTaskList";
import { InviteOrganizationModal } from "src/components/InviteOrganizationModal";
import { TaskModal } from "src/components/kanban/TaskModal";
import { ProjectCardList } from "src/components/ProjectCardList";
import { Badge } from "@xtablo/ui/components/badge";
import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
import { useOrganization } from "../hooks/organization";
import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
function getTabloIcon(color: string | null | undefined) {
@ -107,7 +107,7 @@ export const TabloPage = () => {
const user = useUser();
const { data: organizationData } = useOrganization();
const isReadOnly = isReadOnlyUser || !canCreateTablo;
const isReadOnly = isReadOnlyUser || canCreateTablo === false;
const getGreeting = () => {
const hour = new Date().getHours();

View file

@ -1,5 +1,6 @@
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import type { KanbanColumn, KanbanTask } from "@xtablo/shared-types";
import { GanttChart, TaskModal } from "@xtablo/tablo-views";
import { Button } from "@xtablo/ui/components/button";
import {
DropdownMenu,
@ -40,8 +41,6 @@ import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { GanttChart } from "../components/gantt/GanttChart";
import { TaskModal } from "../components/kanban/TaskModal";
import { useTablosList } from "../hooks/tablos";
import { useAllTasks, useUpdateTask } from "../hooks/tasks";
import { useUser } from "../providers/UserStoreProvider";

View file

@ -62,6 +62,7 @@ describe("TestUserStoreProvider", () => {
email: null,
first_name: null,
is_temporary: false,
is_client: false,
last_name: null,
short_user_id: "short-id",
last_signed_in: null,

View file

@ -18,6 +18,7 @@ const defaultUser = {
email: "john@example.com",
avatar_url: "https://example.com/avatar.jpg",
is_temporary: false,
is_client: false,
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),

View file

@ -14,7 +14,7 @@ export default defineConfig(({ mode }) => {
react(),
visualizer() as PluginOption,
tailwindcss(),
tsconfigPaths(),
tsconfigPaths({ ignoreConfigErrors: true }),
];
plugins.push(

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,223 @@
# Client Magic Links — Design Spec
## Overview
Replace the temporary user invitation model with a magic link system for external client access. Clients access tablos via a dedicated portal at `clients.xtablo.com` (`apps/clients`), authenticated through Supabase passwordless magic links. Tablo view components are extracted into a shared `packages/tablo-views` package consumed by both `apps/main` and `apps/clients`.
Temporary users remain untouched during the transition period.
## Data Model
### New column: `profiles.is_client`
- `is_client: boolean NOT NULL DEFAULT false`
- Marks users created via client magic link invites
- Distinct from `is_temporary` — clean separation for the transition
- Excluded from billing (`getBillableMemberCount` filters out `is_client` users)
### New table: `client_invites`
| Column | Type | Notes |
|--------|------|-------|
| `id` | serial PK | |
| `tablo_id` | text FK -> tablos | |
| `invited_email` | varchar(255) | |
| `invited_by` | uuid FK -> profiles | |
| `invite_token` | text | URL-safe token for the magic link |
| `expires_at` | timestamptz | Default: `now() + interval '30 days'` |
| `is_pending` | boolean DEFAULT true | Flipped to false on acceptance |
| `created_at` | timestamptz DEFAULT now() | |
RLS policies:
- Admins (invite senders) can read/manage their invites
- Client users can read their own invites by email match
### Existing table: `tablo_access`
No schema changes. Client users get a standard row with `is_admin: false`, `is_active: true`. Access revocation uses the existing `is_active = false` pattern.
## Magic Link Invitation Flow
### Sending an invite (admin in `apps/main`)
1. Admin opens tablo share dialog, enters client email
2. `POST /api/v1/tablos/:tabloId/client-invites` — validates admin access, creates `client_invites` row with generated token and `expires_at = now() + 30 days`
3. If no Supabase account exists for that email, the API creates one via `supabase.auth.admin.createUser({ email })` and sets `is_client: true` on the resulting profile row. A `tablo_access` row is pre-granted (`is_admin: false`, `is_active: true`).
4. API calls `supabase.auth.admin.generateLink({ type: 'magiclink', email, options: { redirectTo: 'https://clients.xtablo.com/auth/callback?token=<invite_token>' } })` to generate the magic link
5. Supabase sends the magic link email to the client
### Client clicks the link
1. Supabase verifies the auth token, redirects to `clients.xtablo.com/auth/callback?token=<invite_token>`
2. Callback page exchanges the Supabase auth token for a session
3. The `invite_token` is used to call `POST /api/v1/client-invites/:token/accept` — marks invite as accepted (`is_pending: false`), confirms `tablo_access` is active
4. Client is redirected to `clients.xtablo.com/tablo/:tabloId`
### Expiration and renewal
- Expired invites (past `expires_at`) are rejected at acceptance time with a clear error message
- Admins can re-invite the same email, creating a new `client_invites` row with a fresh 30-day window
- Admins are warned in the UI when the expiration is soon (less than 5 days)
- Admin can revoke access by setting `tablo_access.is_active = false`
### Returning clients
- Active session + valid `tablo_access` = direct access, no re-invitation needed
- Expired session requires a new magic link from the admin
## API Permission Scoping
### Middleware
New middleware variant: `clientUserCheckMiddleware` — returns `403` for `is_client` users on non-client-accessible routes.
### Client-accessible endpoints
- `GET /api/v1/tablos/:tabloId` — view tablo details
- `GET /api/v1/tablo-data/:tabloId/*` — tasks, etapes, events, files metadata
- `GET /api/v1/tablo-files/:tabloId/*` — file downloads
- `POST /api/v1/tablo-files/:tabloId/upload` — file uploads
- Chat endpoints (messages, typing, presence via WebSocket)
- `GET /api/v1/user/me` — own profile
### Blocked for client users
- Tablo CRUD (create, update, delete)
- Invite management (sending/cancelling invites)
- Organization endpoints
- Billing/Stripe endpoints
- Settings, user management
### Billing
`getBillableMemberCount` updated to exclude `is_client` users (same pattern as `is_temporary`).
### RLS policies
New row-level policies on `client_invites`:
- Admins can manage invites they created
- Clients can read their own invites (by email match)
## `packages/tablo-views` — Shared Package
Source-only package (TypeScript directly, no build step). Same pattern as `@xtablo/shared` and `@xtablo/ui`.
### Structure
```
packages/tablo-views/
├── package.json (@xtablo/tablo-views)
├── tsconfig.json
└── src/
├── TabloOverviewSection.tsx
├── TabloEtapesSection.tsx
├── TabloTasksSection.tsx
├── TabloFilesSection.tsx
├── TabloDiscussionSection.tsx
├── TabloEventsSection.tsx
├── TabloRoadmapSection.tsx
├── components/ (shared sub-components these sections depend on)
└── hooks/ (data-fetching hooks for tablo views, including useChat)
```
### What moves from `apps/main`
- The 7 tab section components
- Sub-components they directly depend on (task cards, file list items, gantt chart, etc.)
- Data-fetching hooks used exclusively by these views (including `useChat` from `apps/main/src/hooks/useChat.ts`)
### What stays in `apps/main`
- `TabloDetailsPage` (page shell with tab navigation, share dialog, invite management)
- Layout, navigation, routing
- App-level providers
### Dependencies
`@xtablo/tablo-views` depends on:
- `@xtablo/ui`
- `@xtablo/shared`
- `@xtablo/shared-types`
- `@xtablo/chat-ui`
Consumed by both `apps/main` and `apps/clients`.
### Refactor in `apps/main`
`TabloDetailsPage` imports sections from `@xtablo/tablo-views` instead of local files. Behavior stays identical — this is a move, not a rewrite.
## `apps/clients` — Client Portal App
### Structure
```
apps/clients/
├── package.json (@xtablo/clients)
├── vite.config.ts
├── wrangler.toml (clients.xtablo.com)
├── worker/index.ts
├── index.html
├── tsconfig.json
├── tsconfig.app.json
└── src/
├── main.tsx
├── App.tsx
├── routes.tsx
├── pages/
│ ├── AuthCallback.tsx
│ └── ClientTabloPage.tsx
└── components/
└── ClientLayout.tsx
```
### Cloudflare Worker
`wrangler.toml` routes `clients.xtablo.com` with SPA not-found handling. Same asset-serving pattern as `apps/main` and `apps/external`.
### Layout
`ClientLayout.tsx` — no sidebar. Minimal top bar with:
- Tablo name and color
- Client user avatar and name
- Logout action
### Routes
| Path | Component | Purpose |
|------|-----------|---------|
| `/auth/callback` | `AuthCallback` | Supabase magic link redirect + invite token acceptance |
| `/tablo/:tabloId` | `ClientTabloPage` | Scoped tablo view with all tabs |
| `/` | Redirect | To `/tablo/:tabloId` if one tablo, or simple list if multiple |
### `ClientTabloPage`
Renders the same tab system as `TabloDetailsPage` using components from `@xtablo/tablo-views`. Differences from `apps/main`:
- No share/invite dialog
- No tablo settings or delete actions
- No admin-only actions in the UI
- File section: download and upload enabled, no delete
### Providers
`QueryClientProvider`, `SessionProvider`, `ThemeProvider`, i18n — same setup as other apps. No `UserStoreProvider` or organization context (clients don't belong to orgs).
### Dev server
Port 5175 via `pnpm dev:clients`.
## Chat Integration
Client users get real Supabase accounts, so chat works with minimal changes:
- **Authentication:** Same JWT-based auth for WebSocket connections
- **Identity:** Profile row (name, optional avatar) used for chat display. Profile seeded with invited email on creation. Client can update display name on first access.
- **Permissions:** Client users can send messages and see typing indicators in tablo discussions they have access to. Tablo ID maps to channel ID.
- **`@xtablo/chat-ui`:** No changes needed. Components are already app-agnostic.
- **`useChat` hook:** Moves to `packages/tablo-views/src/hooks/` so both apps can use it.
## Migration Strategy
- Temporary users (`is_temporary`) remain untouched
- Existing tablo invitations continue to work via `apps/main`
- New client invites use the magic link flow via `apps/clients`
- Once all clients have migrated to magic links, a future phase removes `is_temporary` and related code

View file

@ -15,6 +15,7 @@
"dev": "turbo dev",
"dev:main": "turbo dev --filter=@xtablo/main",
"dev:external": "turbo dev --filter=@xtablo/external",
"dev:clients": "turbo dev --filter=@xtablo/clients",
"dev:api": "turbo dev --filter=@xtablo/api",
"deploy:main:staging": "turbo deploy:staging --filter=@xtablo/main",
"deploy:main:prod": "turbo deploy:prod --filter=@xtablo/main",

View file

@ -78,6 +78,54 @@ export type Database = {
},
];
};
client_invites: {
Row: {
created_at: string;
expires_at: string;
id: number;
invited_by: string;
invited_email: string;
invite_token: string;
is_pending: boolean;
tablo_id: string;
};
Insert: {
created_at?: string;
expires_at?: string;
id?: number;
invited_by: string;
invited_email: string;
invite_token: string;
is_pending?: boolean;
tablo_id: string;
};
Update: {
created_at?: string;
expires_at?: string;
id?: number;
invited_by?: string;
invited_email?: string;
invite_token?: string;
is_pending?: boolean;
tablo_id?: string;
};
Relationships: [
{
foreignKeyName: "client_invites_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "tablos";
referencedColumns: ["id"];
},
{
foreignKeyName: "client_invites_invited_by_fkey";
columns: ["invited_by"];
isOneToOne: false;
referencedRelation: "profiles";
referencedColumns: ["id"];
},
];
};
devis: {
Row: {
client_email: string;
@ -385,6 +433,7 @@ export type Database = {
email: string | null;
first_name: string | null;
id: string;
is_client: boolean;
is_temporary: boolean;
last_name: string | null;
last_signed_in: string | null;
@ -398,6 +447,7 @@ export type Database = {
email?: string | null;
first_name?: string | null;
id: string;
is_client?: boolean;
is_temporary?: boolean;
last_name?: string | null;
last_signed_in?: string | null;
@ -411,6 +461,7 @@ export type Database = {
email?: string | null;
first_name?: string | null;
id?: string;
is_client?: boolean;
is_temporary?: boolean;
last_name?: string | null;
last_signed_in?: string | null;

View file

@ -0,0 +1,41 @@
{
"name": "@xtablo/tablo-views",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts",
"./*": "./src/*.tsx"
},
"scripts": {
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.69.0",
"@xtablo/chat-ui": "workspace:*",
"@xtablo/shared": "workspace:*",
"@xtablo/shared-types": "workspace:*",
"@xtablo/ui": "workspace:*",
"date-fns": "^4.1.0",
"lucide-react": "^0.460.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-i18next": "^16.2.0",
"react-router-dom": "^7.9.4",
"tailwind-merge": "^3.0.2"
},
"devDependencies": {
"@biomejs/biome": "2.2.5",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"typescript": "^5.7.0",
"vite": "^6.2.2"
}
}

View file

@ -0,0 +1,37 @@
import { useClickOutside } from "@xtablo/shared/hooks/useClickOutside";
import React from "react";
interface ClickOutsideProps {
children: React.ReactNode;
onClickOutside: () => void;
className?: string;
disabled?: boolean;
}
/**
* Component that wraps children and detects clicks outside
* @param children - The content to wrap
* @param onClickOutside - Function to call when clicking outside
* @param className - Optional className for the wrapper
* @param disabled - Disable click outside detection
*/
export const ClickOutside: React.FC<ClickOutsideProps> = ({
children,
onClickOutside,
className,
disabled = false,
}) => {
const ref = useClickOutside<HTMLDivElement>(
disabled
? () => {
// Do nothing
}
: onClickOutside
);
return (
<div ref={ref} className={className}>
{children}
</div>
);
};

View file

@ -0,0 +1,366 @@
import { cn } from "@xtablo/shared";
import type { Etape, KanbanTask } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import { Input } from "@xtablo/ui/components/input";
import {
CalendarIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleCheckIcon,
ListChecksIcon,
PlusIcon,
} from "lucide-react";
import { useState } from "react";
interface EtapesSectionProps {
etapes: Etape[];
tabloTasks: KanbanTask[];
tabloId: string;
isAdmin: boolean;
onCreateTask: (task: {
tablo_id: string;
title: string;
status: string;
parent_task_id: string;
is_parent: boolean;
position: number;
}) => void;
onCreateEtape: (params: { tabloId: string; title: string; position: number }) => Promise<void>;
isCreatingEtape?: boolean;
}
export function EtapesSection({
etapes,
tabloTasks,
tabloId,
isAdmin,
onCreateTask,
onCreateEtape,
isCreatingEtape = false,
}: EtapesSectionProps) {
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
new Set(etapes.map((e) => e.id))
);
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(null);
const [newEtapeTitle, setNewEtapeTitle] = useState("");
const [newTaskTitle, setNewTaskTitle] = useState("");
const toggleEtape = (id: string) => {
setExpandedEtapes((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleAddTask = (etapeId: string) => {
const title = newTaskTitle.trim();
if (!title || !tabloId) return;
onCreateTask({
tablo_id: tabloId,
title,
status: "todo",
parent_task_id: etapeId,
is_parent: false,
position: tabloTasks.filter((t) => t.parent_task_id === etapeId).length,
});
setNewTaskTitle("");
setAddingTaskToEtape(null);
};
const handleAddEtape = async () => {
const title = newEtapeTitle.trim();
if (!title || !tabloId) {
return;
}
const nextPosition = etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1;
await onCreateEtape({
tabloId,
title,
position: nextPosition,
});
setNewEtapeTitle("");
};
const statusConfig: Record<string, { label: string; color: string }> = {
todo: {
label: "À faire",
color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400",
},
in_progress: {
label: "En cours",
color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400",
},
in_review: {
label: "Vérification",
color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400",
},
done: {
label: "Terminé",
color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400",
},
};
return (
<div className="space-y-4">
{isAdmin && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Input
value={newEtapeTitle}
onChange={(event) => setNewEtapeTitle(event.target.value)}
placeholder="Nom de la nouvelle étape..."
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleAddEtape();
}
}}
className="h-11 sm:h-9 sm:w-80"
/>
<Button
onClick={() => void handleAddEtape()}
disabled={isCreatingEtape || !newEtapeTitle.trim()}
className="min-h-[44px] sm:min-h-0"
>
<PlusIcon className="w-4 h-4" />
Ajouter une étape
</Button>
</div>
)}
{etapes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<ListChecksIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Aucune étape</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
Les étapes permettent de structurer votre projet en grandes phases
</p>
</div>
) : (
etapes.map((etape, index) => {
const childTasks = tabloTasks.filter((t) => t.parent_task_id === etape.id);
const doneCount = childTasks.filter((t) => t.status === "done").length;
const totalCount = childTasks.length;
const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0;
const isExpanded = expandedEtapes.has(etape.id);
// Derive status from child tasks instead of etape.status
const derivedStatus =
totalCount === 0
? "todo"
: doneCount === totalCount
? "done"
: doneCount > 0
? "in_progress"
: "todo";
const status = statusConfig[derivedStatus] ?? statusConfig.todo;
return (
<div
key={etape.id}
className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden"
>
{/* Etape header */}
<button
type="button"
onClick={() => toggleEtape(etape.id)}
className="w-full flex items-center gap-3 sm:gap-4 px-3 sm:px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left min-h-[56px]"
>
{isExpanded ? (
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
) : (
<ChevronRightIcon className="w-5 h-5 text-gray-400 shrink-0" />
)}
<div className="w-8 h-8 rounded-lg bg-[#F4F3FF] dark:bg-purple-900/20 flex items-center justify-center shrink-0">
<span className="text-sm font-bold text-[#7F56D9] dark:text-purple-400">
{index + 1}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm sm:text-base">
{etape.title}
</h3>
{etape.description && (
<p className="text-xs sm:text-sm text-muted-foreground truncate mt-0.5">
{etape.description}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{etape.due_date && (
<div
className={cn(
"items-center gap-1 text-xs hidden sm:flex",
derivedStatus !== "done" &&
new Date(etape.due_date) < new Date(new Date().toDateString())
? "text-red-500"
: "text-muted-foreground"
)}
>
<CalendarIcon className="w-3.5 h-3.5" />
<span>
{new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
}).format(new Date(etape.due_date))}
</span>
</div>
)}
<span
className={cn(
"px-2 sm:px-2.5 py-1 rounded-full text-[10px] sm:text-xs font-medium",
status.color
)}
>
{status.label}
</span>
{totalCount > 0 && (
<div className="hidden sm:flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${progressPct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{doneCount}/{totalCount}
</span>
</div>
)}
</div>
</button>
{/* Child tasks + add task */}
{isExpanded && (
<div className="border-t border-gray-100 dark:border-gray-700">
{childTasks.length > 0 && (
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{childTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 px-3 sm:px-5 py-3 pl-8 sm:pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{task.status === "done" ? (
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />
) : (
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
)}
<span
className={cn(
"text-sm flex-1 truncate",
task.status === "done"
? "line-through text-gray-400"
: "text-gray-900 dark:text-gray-100"
)}
>
{task.title}
</span>
{task.due_date && (
<div
className={cn(
"flex items-center gap-1 text-xs shrink-0",
task.status !== "done" &&
new Date(task.due_date) < new Date(new Date().toDateString())
? "text-red-500"
: "text-muted-foreground"
)}
>
<CalendarIcon className="w-3 h-3" />
<span>
{new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
}).format(new Date(task.due_date))}
</span>
</div>
)}
{task.status && (
<span
className={cn(
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
(statusConfig[task.status] ?? statusConfig.todo).color
)}
>
{(statusConfig[task.status] ?? statusConfig.todo).label}
</span>
)}
</div>
))}
</div>
)}
{childTasks.length === 0 && addingTaskToEtape !== etape.id && (
<div className="px-3 sm:px-5 py-4 pl-8 sm:pl-16 text-sm text-muted-foreground">
Aucune tâche dans cette étape
</div>
)}
{/* Inline add task */}
{addingTaskToEtape === etape.id ? (
<div className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 border-t border-gray-100 dark:border-gray-700">
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
<input
autoFocus
type="text"
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddTask(etape.id);
if (e.key === "Escape") {
setAddingTaskToEtape(null);
setNewTaskTitle("");
}
}}
placeholder="Nom de la tâche..."
className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 min-w-0"
/>
<button
type="button"
onClick={() => handleAddTask(etape.id)}
disabled={!newTaskTitle.trim()}
className="text-xs font-medium px-3 py-2 rounded-md bg-[#804EEC] text-white hover:bg-[#6f3fd4] disabled:opacity-40 transition-colors min-h-[36px] shrink-0"
>
Ajouter
</button>
<button
type="button"
onClick={() => {
setAddingTaskToEtape(null);
setNewTaskTitle("");
}}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-2 min-h-[36px] shrink-0"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAddingTaskToEtape(etape.id);
setNewTaskTitle("");
}}
className="flex items-center gap-2 px-3 sm:px-5 py-3 pl-8 sm:pl-16 text-sm text-muted-foreground hover:text-[#804EEC] hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors w-full text-left border-t border-gray-100 dark:border-gray-700 min-h-[44px]"
>
<PlusIcon className="w-4 h-4" />
Ajouter une tâche
</button>
)}
</div>
)}
</div>
);
})
)}
</div>
);
}

View file

@ -0,0 +1,114 @@
interface ImageColorPickerProps {
creationMode: "image" | "color";
setCreationMode: (mode: "image" | "color") => void;
selectedColor: string;
setSelectedColor: (color: string) => void;
}
const AVAILABLE_COLORS = [
"bg-blue-500",
"bg-green-500",
"bg-purple-500",
"bg-red-500",
"bg-yellow-500",
"bg-indigo-500",
"bg-pink-500",
"bg-teal-500",
"bg-orange-500",
"bg-cyan-500",
];
export const ImageColorPicker = ({
creationMode,
setCreationMode,
selectedColor,
setSelectedColor,
}: ImageColorPickerProps) => {
return (
<div className="my-4 space-y-4">
{/* Mode Toggle */}
<div>
<label className="block text-base font-semibold text-gray-800 dark:text-gray-300 mb-2">
Style
</label>
<div className="flex rounded-md border border-gray-300 dark:border-gray-600 overflow-hidden">
<button
type="button"
className={`flex-1 px-4 py-2 text-sm font-medium ${
creationMode === "image"
? "bg-blue-600 text-white"
: "bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
} transition-colors`}
onClick={() => setCreationMode("image")}
>
Image (Bientôt disponible)
</button>
<button
type="button"
className={`flex-1 px-4 py-2 text-sm font-medium ${
creationMode === "color"
? "bg-blue-600 text-white"
: "bg-gray-50 dark:bg-gray-700 text-gray-800 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
} transition-colors`}
onClick={() => setCreationMode("color")}
>
Couleur
</button>
</div>
</div>
{/* Image Mode */}
{creationMode === "image" && (
<div className="space-y-4">
{/* File Upload - Coming Soon */}
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 border border-dashed border-gray-300 dark:border-gray-600">
<div className="text-center">
<svg
className="mx-auto h-8 w-8 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
<span className="font-medium">Import d&apos;images</span>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">Bientôt disponible</p>
</div>
</div>
</div>
)}
{/* Color Mode */}
{creationMode === "color" && (
<div>
<label className="block text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
Couleur
</label>
<div className="grid grid-cols-5 gap-2">
{AVAILABLE_COLORS.map((color) => (
<button
key={color}
type="button"
className={`w-12 h-12 ${color} rounded-lg border-2 ${
selectedColor === color
? "border-gray-800 dark:border-white scale-110"
: "border-gray-300 dark:border-gray-600"
} hover:scale-105 transition-all duration-200`}
onClick={() => setSelectedColor(color)}
>
{selectedColor === color && <span className="text-white text-lg"></span>}
</button>
))}
</div>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,23 @@
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
import { GanttChart } from "./components/gantt/GanttChart";
interface RoadmapSectionProps {
tabloTasks: KanbanTask[];
onDateClick: (date: Date) => void;
onTaskStatusChange: (taskId: string, status: TaskStatus) => void;
}
export function RoadmapSection({
tabloTasks,
onDateClick,
onTaskStatusChange,
}: RoadmapSectionProps) {
return (
<GanttChart
tasks={tabloTasks}
isLoading={false}
onDateClick={onDateClick}
onTaskStatusChange={onTaskStatusChange}
/>
);
}

View file

@ -1,17 +1,26 @@
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { useEffect } from "react";
import { useChat } from "../hooks/useChat";
import { useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
import { useChat } from "./hooks/useChat";
import { ChatMessages } from "./ChatMessages";
interface Member {
id: string;
name: string;
avatar_url: string | null;
}
interface TabloDiscussionSectionProps {
tablo: UserTablo;
isAdmin: boolean;
currentUserId: string;
members?: Member[];
}
export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) => {
const user = useUser();
export const TabloDiscussionSection = ({
tablo,
currentUserId,
members = [],
}: TabloDiscussionSectionProps) => {
const {
messages,
sendMessage,
@ -22,8 +31,6 @@ export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) =
markAsRead,
} = useChat(tablo.id);
const { data: members = [] } = useTabloMembers(tablo.id);
// Mark as read when opening the discussion
useEffect(() => {
if (messages.length > 0) {
@ -36,7 +43,7 @@ export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) =
<div className="flex-1 overflow-hidden min-h-0">
<ChatMessages
messages={messages}
currentUserId={user.id}
currentUserId={currentUserId}
members={members}
typingUsers={typingUsers}
hasMoreMessages={hasMoreMessages}

View file

@ -1,23 +1,74 @@
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import { Calendar, Clock, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useEventsByTablo } from "../hooks/events";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
import { TabloHeaderActions } from "./TabloHeaderActions";
interface TabloEvent {
event_id: string;
title: string;
start_date: string;
end_date?: string | null;
start_time?: string | null;
end_time?: string | null;
description?: string | null;
}
interface CurrentUser {
id: string;
avatar_url?: string | null;
}
interface PendingInvite {
id: string;
invited_email: string;
}
interface Member {
id: string;
name: string;
email?: string;
avatar_url?: string | null;
is_admin?: boolean;
}
interface TabloEventsSectionProps {
tablo: UserTablo;
isAdmin: boolean;
isReadOnly?: boolean;
events?: TabloEvent[];
isLoading?: boolean;
error?: Error | null;
currentUser: CurrentUser;
members?: Member[];
pendingInvites?: PendingInvite[];
isInvitingUser?: boolean;
isCancellingInvite?: boolean;
onCreateEvent?: () => void;
onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise<void>;
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
}
export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps) => {
const navigate = useNavigate();
export const TabloEventsSection = ({
tablo,
isAdmin,
isReadOnly = false,
events,
isLoading,
error,
currentUser,
members,
pendingInvites,
isInvitingUser,
isCancellingInvite,
onCreateEvent,
onUpdateTablo,
onInviteUser,
onCancelInvite,
}: TabloEventsSectionProps) => {
const { t } = useTranslation();
const { data: events, isLoading, error } = useEventsByTablo(tablo.id);
const isReadOnly = useIsReadOnlyUser();
// Filter upcoming events (events in the future or today)
const today = new Date();
@ -34,10 +85,6 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
return (a.start_time || "").localeCompare(b.start_time || "");
});
const handleCreateEvent = () => {
navigate(`/planning/create?tablo_id=${tablo.id}`);
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return new Intl.DateTimeFormat("fr-FR", {
@ -50,7 +97,6 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
const formatTime = (timeStr: string) => {
if (!timeStr) return "";
return timeStr.slice(0, 5); // HH:MM
};
@ -66,7 +112,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
</TypographyMuted>
{!isReadOnly && (
<Button
onClick={handleCreateEvent}
onClick={onCreateEvent}
className="flex items-center gap-2 mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
>
<Plus className="w-4 h-4" />
@ -74,7 +120,18 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
</Button>
)}
</div>
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
<TabloHeaderActions
tablo={tablo}
isAdmin={isAdmin}
currentUser={currentUser}
members={members}
pendingInvites={pendingInvites}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
onUpdateTablo={onUpdateTablo}
onInviteUser={onInviteUser}
onCancelInvite={onCancelInvite}
/>
</div>
{/* Events List */}
<div className="bg-card rounded-lg border border-border">
@ -176,7 +233,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
</p>
{!isReadOnly && (
<Button
onClick={handleCreateEvent}
onClick={onCreateEvent}
className="mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
>
<Plus className="w-4 h-4 mr-2" />

View file

@ -1,5 +1,6 @@
import { toast } from "@xtablo/shared";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import type { TabloFolder } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import {
Collapsible,
@ -26,28 +27,22 @@ import {
Trash2Icon,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import {
useCreateTabloFile,
useDeleteTabloFile,
useDownloadTabloFile,
useTabloFileNames,
} from "../hooks/tablo_data";
import {
extractFolderIdFromFileName,
getFileNameWithoutFolder,
getFolderFilePrefix,
TabloFolder,
useCreateTabloFolder,
useDeleteTabloFolder,
useTabloFolders,
useUpdateTabloFolder,
} from "../hooks/tablo_folders";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
import { TabloHeaderActions } from "./TabloHeaderActions";
interface TabloFilesSectionProps {
tablo: UserTablo;
isAdmin: boolean;
// Helper to extract folder ID from a file name
function extractFolderIdFromFileName(fileName: string): string | null {
const match = fileName.match(/^folder_([^_]+)_/);
return match ? match[1] : null;
}
// Helper to strip folder prefix from file name
function getFileNameWithoutFolder(fileName: string): string {
return fileName.replace(/^folder_[^_]+_/, "");
}
// Helper to build folder file prefix
function getFolderFilePrefix(folderId: string): string {
return `folder_${folderId}_`;
}
// Helper to get file icon color based on extension
@ -202,7 +197,6 @@ const FolderDialog = ({
}
};
// Reset form when dialog opens
const handleOpenChange = (open: boolean) => {
if (open) {
setName(folder?.name || "");
@ -453,19 +447,81 @@ const FolderSection = ({
);
};
export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => {
const currentUser = useUser();
const {
data: fileData,
isLoading: filesLoading,
error: filesError,
} = useTabloFileNames(tablo.id);
const {
data: foldersData,
isLoading: foldersLoading,
error: foldersError,
} = useTabloFolders(tablo.id);
interface CurrentUser {
id: string;
avatar_url?: string | null;
}
interface PendingInvite {
id: string;
invited_email: string;
}
interface Member {
id: string;
name: string;
email?: string;
avatar_url?: string | null;
is_admin?: boolean;
}
interface TabloFilesSectionProps {
tablo: UserTablo;
isAdmin: boolean;
isReadOnly?: boolean;
currentUserId: string;
fileNames?: string[];
filesLoading?: boolean;
filesError?: Error | null;
folders?: TabloFolder[];
foldersLoading?: boolean;
foldersError?: Error | null;
currentUser: CurrentUser;
members?: Member[];
pendingInvites?: PendingInvite[];
isInvitingUser?: boolean;
isCancellingInvite?: boolean;
isCreatingFolder?: boolean;
isUpdatingFolder?: boolean;
onCreateFile?: (params: { tabloId: string; fileName: string; data: { content: string; contentType: string } }) => Promise<void>;
onDeleteFile?: (params: { tabloId: string; fileName: string }) => Promise<void>;
onDownloadFile?: (params: { tabloId: string; fileName: string }) => Promise<void>;
onCreateFolder?: (params: { tabloId: string; name: string; description: string; createdBy: string }) => Promise<void>;
onUpdateFolder?: (params: { tabloId: string; folderId: string; name: string; description: string }) => Promise<void>;
onDeleteFolder?: (params: { tabloId: string; folderId: string; folderName: string }) => Promise<void>;
onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise<void>;
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
}
export const TabloFilesSection = ({
tablo,
isAdmin,
isReadOnly = false,
currentUserId,
fileNames,
filesLoading,
filesError,
folders = [],
foldersLoading,
foldersError,
currentUser,
members,
pendingInvites,
isInvitingUser,
isCancellingInvite,
isCreatingFolder = false,
isUpdatingFolder = false,
onCreateFile,
onDeleteFile,
onDownloadFile,
onCreateFolder,
onUpdateFolder,
onDeleteFolder,
onUpdateTablo,
onInviteUser,
onCancelInvite,
}: TabloFilesSectionProps) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadingToFolder, setUploadingToFolder] = useState<string | null>(null);
@ -477,27 +533,18 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
const [editingFolder, setEditingFolder] = useState<TabloFolder | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const createFile = useCreateTabloFile();
const deleteFile = useDeleteTabloFile();
const downloadFile = useDownloadTabloFile();
const createFolder = useCreateTabloFolder();
const updateFolder = useUpdateTabloFolder();
const deleteFolder = useDeleteTabloFolder();
const isReadOnly = useIsReadOnlyUser();
const folders = foldersData?.folders || [];
const folderIds = useMemo(() => new Set(folders.map((folder) => folder.id)), [folders]);
// Organize files by folder
const { filesInFolders, unorganizedFiles } = useMemo(() => {
if (!fileData?.fileNames) {
if (!fileNames) {
return { filesInFolders: new Map<string, string[]>(), unorganizedFiles: [] };
}
const filesInFolders = new Map<string, string[]>();
const unorganizedFiles: string[] = [];
for (const fileName of fileData.fileNames) {
// Skip metadata files
for (const fileName of fileNames) {
if (fileName.startsWith(".")) continue;
const folderId = extractFolderIdFromFileName(fileName);
@ -511,7 +558,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
}
return { filesInFolders, unorganizedFiles };
}, [fileData?.fileNames, folderIds]);
}, [fileNames, folderIds]);
const toggleFolder = (folderId: string) => {
setOpenFolders((prev) => {
@ -558,7 +605,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
? `${getFolderFilePrefix(targetFolderId)}${file.name}`
: file.name;
await createFile.mutateAsync({
await onCreateFile?.({
tabloId: tablo.id,
fileName,
data: {
@ -625,7 +672,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
setDeletingFile(fileName);
try {
await deleteFile.mutateAsync({ tabloId: tablo.id, fileName });
await onDeleteFile?.({ tabloId: tablo.id, fileName });
} catch (error) {
console.error("Delete error:", error);
} finally {
@ -638,7 +685,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
setDownloadingFile(fileName);
try {
await downloadFile.mutateAsync({ tabloId: tablo.id, fileName });
await onDownloadFile?.({ tabloId: tablo.id, fileName });
} catch (error) {
console.error("Download error:", error);
} finally {
@ -665,7 +712,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
if (!window.confirm(confirmMessage)) return;
await deleteFolder.mutateAsync({
await onDeleteFolder?.({
tabloId: tablo.id,
folderId: folder.id,
folderName: folder.name,
@ -674,18 +721,18 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
const handleSaveFolder = async (name: string, description: string) => {
if (editingFolder) {
await updateFolder.mutateAsync({
await onUpdateFolder?.({
tabloId: tablo.id,
folderId: editingFolder.id,
name,
description,
});
} else {
await createFolder.mutateAsync({
await onCreateFolder?.({
tabloId: tablo.id,
name,
description,
createdBy: currentUser?.id || "",
createdBy: currentUserId,
});
}
setIsFolderDialogOpen(false);
@ -703,7 +750,18 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
Gérez les fichiers et livrables de ce tablo
</TypographyMuted>
</div>
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
<TabloHeaderActions
tablo={tablo}
isAdmin={isAdmin}
currentUser={currentUser}
members={members}
pendingInvites={pendingInvites}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
onUpdateTablo={onUpdateTablo}
onInviteUser={onInviteUser}
onCancelInvite={onCancelInvite}
/>
</div>
{/* Error Banner */}
@ -987,7 +1045,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
}}
onSave={handleSaveFolder}
folder={editingFolder}
isLoading={createFolder.isPending || updateFolder.isPending}
isLoading={isCreatingFolder || isUpdatingFolder}
/>
</div>
);

View file

@ -1,5 +1,5 @@
import { toast } from "@xtablo/shared";
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import type { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import {
@ -13,21 +13,52 @@ import { Input } from "@xtablo/ui/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@xtablo/ui/components/popover";
import { Loader2, Settings, Share2, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useInviteUser } from "../hooks/invite";
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
import { useTabloMembers, useUpdateTablo } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
import { ClickOutside } from "./ClickOutside";
import { ImageColorPicker } from "./ImageColorPicker";
interface PendingInvite {
id: string;
invited_email: string;
}
interface Member {
id: string;
name: string;
email?: string;
avatar_url?: string | null;
is_admin?: boolean;
}
interface CurrentUser {
id: string;
avatar_url?: string | null;
}
interface TabloHeaderActionsProps {
tablo: UserTablo;
isAdmin: boolean;
currentUser: CurrentUser;
members?: Member[];
pendingInvites?: PendingInvite[];
isInvitingUser?: boolean;
isCancellingInvite?: boolean;
onUpdateTablo?: (data: TabloUpdate & { id: string }) => Promise<void>;
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
}
export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) => {
const { mutateAsync: updateTablo } = useUpdateTablo();
const currentUser = useUser();
export const TabloHeaderActions = ({
tablo,
isAdmin,
currentUser,
members = [],
pendingInvites = [],
isInvitingUser = false,
isCancellingInvite = false,
onUpdateTablo,
onInviteUser,
onCancelInvite,
}: TabloHeaderActionsProps) => {
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
@ -39,12 +70,6 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
const nameInputRef = useRef<HTMLInputElement>(null);
// Fetch members and invites for share dialog
const { data: members } = useTabloMembers(tablo?.id || "");
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo?.id || "");
const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite();
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
useEffect(() => {
setEditData(tablo);
setSelectedColor(tablo.color || "bg-blue-500");
@ -59,14 +84,14 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
}, [isEditingName]);
const handleSaveSettings = async () => {
if (editData && tablo) {
if (editData && tablo && onUpdateTablo) {
const updatedTablo: TabloUpdate & { id: string } = {
id: editData.id,
name: editData.name,
color: creationMode === "color" ? selectedColor : null,
};
try {
await updateTablo(updatedTablo);
await onUpdateTablo(updatedTablo);
toast.add(
{
title: "Tablo mis à jour",
@ -89,8 +114,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
};
const handleSendInvite = () => {
if (inviteEmail.trim() && tablo) {
inviteUser({ email: inviteEmail, tablo_id: tablo.id });
if (inviteEmail.trim() && tablo && onInviteUser) {
onInviteUser({ email: inviteEmail, tablo_id: tablo.id });
setInviteEmail("");
}
};
@ -278,7 +303,7 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => cancelInvite({ tabloId: tablo.id, inviteId: invite.id })}
onClick={() => onCancelInvite?.({ tabloId: tablo.id, inviteId: invite.id })}
disabled={isCancellingInvite}
title="Retirer l'invitation"
>

View file

@ -1,37 +1,71 @@
import { pluralize, toast } from "@xtablo/shared";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import type { KanbanColumn, KanbanTask, KanbanTaskInsert, TaskStatus } from "@xtablo/shared-types";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import type {
Etape,
KanbanColumn,
KanbanTask,
KanbanTaskInsert,
KanbanTaskUpdate,
TaskStatus,
} from "@xtablo/shared-types";
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import { AlertTriangle, ListChecks } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTabloMembers } from "../hooks/tablos";
import {
useCreateTask,
useTabloEtapes,
useTasksByTablo,
useUpdateTaskPositions,
} from "../hooks/tasks";
import { KanbanBoard } from "./kanban/KanbanBoard";
import { TaskModal } from "./kanban/TaskModal";
import { KanbanBoard } from "./components/kanban/KanbanBoard";
import type { TabloMember } from "./components/kanban/types";
import { TaskModal } from "./components/kanban/TaskModal";
import { TabloHeaderActions } from "./TabloHeaderActions";
interface CurrentUser {
id: string;
avatar_url?: string | null;
}
interface PendingInvite {
id: string;
invited_email: string;
}
interface TabloTasksSectionProps {
tablo: UserTablo;
isAdmin: boolean;
tasks?: KanbanTask[];
members?: TabloMember[];
etapes?: Etape[];
currentUser: CurrentUser;
pendingInvites?: PendingInvite[];
isInvitingUser?: boolean;
isCancellingInvite?: boolean;
onCreateTask?: (task: KanbanTaskInsert) => void;
onUpdateTask?: (task: KanbanTaskUpdate & { id: string; tablo_id: string }) => void;
onUpdateTaskPositions?: (updates: Array<{ id: string; position: number; status: TaskStatus }>) => void;
onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise<void>;
onInviteUser?: (params: { email: string; tablo_id: string }) => void;
onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void;
}
export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => {
const { data: members = [] } = useTabloMembers(tablo.id);
export const TabloTasksSection = ({
tablo,
isAdmin,
tasks,
members = [],
etapes = [],
currentUser,
pendingInvites,
isInvitingUser,
isCancellingInvite,
onCreateTask,
onUpdateTask,
onUpdateTaskPositions,
onUpdateTablo,
onInviteUser,
onCancelInvite,
}: TabloTasksSectionProps) => {
const [columns, setColumns] = useState<KanbanColumn[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<KanbanTask | null>(null);
const [modalStatus, setModalStatus] = useState<TaskStatus>("todo");
const { data: tasks } = useTasksByTablo(tablo.id);
const { data: etapes = [] } = useTabloEtapes(tablo.id);
const { mutate: updateTaskPositions } = useUpdateTaskPositions();
const { mutate: createTask } = useCreateTask();
const memberById = useMemo(
() => new Map(members.map((member) => [member.id, member])),
[members]
@ -72,7 +106,6 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
return tasksWithAssigneeFallback.filter((task) => !task.parent_task_id);
}, [tasksWithAssigneeFallback]);
// Helper functions defined before use
const initializeColumns = useCallback((tasks: KanbanTask[]): KanbanColumn[] => {
const defaultColumns: KanbanColumn[] = [
{
@ -137,19 +170,7 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
parent_task_id: taskData.parent_task_id ?? null,
};
createTask(newTask);
// setColumns((prevColumns) =>
// prevColumns.map((column: KanbanColumn) => {
// if (column.status === (taskData.status as TaskStatus)) {
// return {
// ...column,
// tasks: [newTask, ...column.tasks],
// };
// }
// return column;
// })
// );
onCreateTask?.(newTask);
toast.add(
{
@ -162,7 +183,7 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
};
const handleTaskMove = (taskId: string, newStatus: TaskStatus) => {
updateTaskPositions([
onUpdateTaskPositions?.([
{
id: taskId,
position: columns.find((column) => column.status === newStatus)?.position ?? 0,
@ -198,7 +219,18 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
Gérez vos tâches avec un tableau Kanban
</TypographyMuted>
</div>
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
<TabloHeaderActions
tablo={tablo}
isAdmin={isAdmin}
currentUser={currentUser}
members={members}
pendingInvites={pendingInvites}
isInvitingUser={isInvitingUser}
isCancellingInvite={isCancellingInvite}
onUpdateTablo={onUpdateTablo}
onInviteUser={onInviteUser}
onCancelInvite={onCancelInvite}
/>
</div>
{/* Warning for orphaned tasks */}
@ -238,11 +270,14 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) =>
<TaskModal
tabloId={tablo.id}
taskId={selectedTask?.id}
task={selectedTask}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
members={members}
initialStatus={modalStatus}
etapes={etapes}
onCreateTask={onCreateTask}
onUpdateTask={onUpdateTask}
/>
</div>
);

View file

@ -1,4 +1,3 @@
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import {
@ -253,7 +252,7 @@ export function GanttChart({ tasks, isLoading, onDateClick, onTaskStatusChange }
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner />
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}

View file

@ -130,24 +130,6 @@ export const InlineTaskCreate = ({ status, members, etapes, onSubmit }: InlineTa
{/* Type and Assignee */}
<div className="grid grid-cols-2 gap-2">
{/* <div className="space-y-1">
<Label htmlFor="type" className="text-xs text-muted-foreground">
Type
</Label>
<Select value={type} onValueChange={(value) => setType(value as TaskType)}>
<SelectTrigger id="type" size="sm" className="w-full text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="task">Task</SelectItem>
<SelectItem value="story">Story</SelectItem>
<SelectItem value="bug">Bug</SelectItem>
<SelectItem value="epic">Epic</SelectItem>
<SelectItem value="subtask">Subtask</SelectItem>
</SelectContent>
</Select>
</div> */}
<div className="space-y-1">
<Label htmlFor="assignee" className="text-xs text-muted-foreground">
Assigné à

View file

@ -1,5 +1,4 @@
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import type { Etape, TaskStatus } from "@xtablo/shared-types";
import type { Etape, KanbanTask, KanbanTaskInsert, KanbanTaskUpdate, TaskStatus } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import { DatePicker } from "@xtablo/ui/components/date-picker";
import { Input } from "@xtablo/ui/components/input";
@ -15,21 +14,32 @@ import { Textarea } from "@xtablo/ui/components/textarea";
import { TypographyH2 } from "@xtablo/ui/components/typography";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { useTabloMembers } from "../../hooks/tablos";
import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "../../hooks/tasks";
import type { TabloMember } from "./types";
/** Minimal UserTablo shape needed by this modal (tablo selector when creating). */
interface MinimalTablo {
id: string;
name: string;
color?: string | null;
}
interface TaskModalProps {
isOpen: boolean;
tabloId?: string; // Optional when creating a task - can select tablo
taskId?: string | undefined; // Optional - undefined when creating new task
tabloId?: string;
taskId?: string | undefined;
onClose: () => void;
members?: TabloMember[]; // Optional - will be fetched if tabloId is provided
/** Task data when editing an existing task. */
task?: KanbanTask | null;
members?: TabloMember[];
etapes?: Etape[];
initialStatus?: TaskStatus;
etapes?: Etape[]; // Optional - will be fetched if tabloId is provided
tablos?: UserTablo[]; // Optional - for tablo selection when creating
allowTabloSelection?: boolean; // Whether to show tablo selector
tablos?: MinimalTablo[];
allowTabloSelection?: boolean;
initialDueDate?: Date;
/** Called when creating a new task. */
onCreateTask?: (task: KanbanTaskInsert) => void;
/** Called when updating an existing task. */
onUpdateTask?: (task: KanbanTaskUpdate & { id: string; tablo_id: string }) => void;
}
export const TaskModal = ({
@ -37,14 +47,16 @@ export const TaskModal = ({
taskId,
isOpen,
onClose,
members: providedMembers,
task = null,
members: providedMembers = [],
initialStatus = "todo",
etapes: providedEtapes,
etapes: providedEtapes = [],
tablos,
allowTabloSelection = false,
initialDueDate,
onCreateTask,
onUpdateTask,
}: TaskModalProps) => {
const { data: task = null } = useTask(taskId);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [assigneeId, setAssigneeId] = useState<string>("unassigned");
@ -54,16 +66,6 @@ export const TaskModal = ({
initialTabloId || tablos?.[0]?.id || ""
);
// Determine which tablo to use for fetching data
const tabloIdForFetch = allowTabloSelection ? selectedTabloId : initialTabloId || "";
// Fetch members and etapes for selected tablo if not provided
const { data: fetchedMembers = [] } = useTabloMembers(tabloIdForFetch || "");
const { data: fetchedEtapes = [] } = useTabloEtapes(tabloIdForFetch || undefined);
// Use provided or fetched data
const members = providedMembers || fetchedMembers;
const etapes = providedEtapes || fetchedEtapes;
const currentTabloId = allowTabloSelection ? selectedTabloId : initialTabloId || "";
useEffect(() => {
@ -77,7 +79,6 @@ export const TaskModal = ({
setSelectedTabloId(task.tablo_id);
}
} else {
// Reset form when creating new task
setTitle("");
setDescription("");
setAssigneeId("unassigned");
@ -89,9 +90,6 @@ export const TaskModal = ({
}
}, [task, initialTabloId, allowTabloSelection, tablos, initialDueDate]);
const { mutate: createTask } = useCreateTask();
const { mutate: updateTask } = useUpdateTask();
// Format Date to YYYY-MM-DD string for database storage
const formatDateForDb = (date: Date | undefined): string | null => {
if (!date) return null;
@ -104,12 +102,12 @@ export const TaskModal = ({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
if (!currentTabloId) return; // Need a tablo to create task
if (!currentTabloId) return;
const dueDateValue = formatDateForDb(dueDate);
if (taskId && task) {
updateTask({
onUpdateTask?.({
tablo_id: task.tablo_id,
id: task.id,
title: title.trim(),
@ -120,7 +118,7 @@ export const TaskModal = ({
due_date: dueDateValue,
});
} else {
createTask({
onCreateTask?.({
tablo_id: currentTabloId,
title: title.trim(),
description: description.trim(),
@ -223,7 +221,7 @@ export const TaskModal = ({
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Non assigné</SelectItem>
{members.map((member) => (
{providedMembers.map((member) => (
<SelectItem key={member.id} value={member.id}>
{member.name}
</SelectItem>
@ -233,7 +231,7 @@ export const TaskModal = ({
</div>
{/* Étape */}
{etapes.length > 0 && (
{providedEtapes.length > 0 && (
<div className="space-y-2">
<Label htmlFor="etape">Étape</Label>
<Select value={etapeId} onValueChange={setEtapeId}>
@ -242,7 +240,7 @@ export const TaskModal = ({
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucune</SelectItem>
{etapes.map((etape) => (
{providedEtapes.map((etape) => (
<SelectItem key={etape.id} value={etape.id}>
{etape.title}
</SelectItem>

View file

@ -0,0 +1,20 @@
export { TabloTasksSection } from "./TabloTasksSection";
export { TabloFilesSection } from "./TabloFilesSection";
export { TabloDiscussionSection } from "./TabloDiscussionSection";
export { TabloEventsSection } from "./TabloEventsSection";
export { EtapesSection } from "./EtapesSection";
export { RoadmapSection } from "./RoadmapSection";
export { TabloHeaderActions } from "./TabloHeaderActions";
export { ChatMessages } from "./ChatMessages";
// Sub-components
export { GanttChart } from "./components/gantt/GanttChart";
export { TaskModal } from "./components/kanban/TaskModal";
export { KanbanBoard } from "./components/kanban/KanbanBoard";
// Hooks
export { useChat } from "./hooks/useChat";
export { useChatUnread } from "./hooks/useChatUnread";
// Types
export type { TabloMember } from "./components/kanban/types";

View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@xtablo/ui": ["../ui/src"],
"@xtablo/ui/*": ["../ui/src/*"],
"@xtablo/shared": ["../shared/src"],
"@xtablo/shared/*": ["../shared/src/*"]
}
},
"include": ["src", "src/vite-env.d.ts"]
}

View file

@ -140,6 +140,91 @@ importers:
specifier: ^4.14.0
version: 4.44.0(@cloudflare/workers-types@4.20260411.1)
apps/clients:
dependencies:
'@tanstack/react-query':
specifier: ^5.69.0
version: 5.90.5(react@19.0.0)
'@xtablo/chat-ui':
specifier: workspace:*
version: link:../../packages/chat-ui
'@xtablo/shared':
specifier: workspace:*
version: link:../../packages/shared
'@xtablo/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
'@xtablo/tablo-views':
specifier: workspace:*
version: link:../../packages/tablo-views
'@xtablo/ui':
specifier: workspace:*
version: link:../../packages/ui
i18next:
specifier: ^25.6.0
version: 25.6.0(typescript@5.9.3)
i18next-browser-languagedetector:
specifier: ^8.2.0
version: 8.2.0
lucide-react:
specifier: ^0.460.0
version: 0.460.0(react@19.0.0)
react:
specifier: 19.0.0
version: 19.0.0
react-dom:
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
react-i18next:
specifier: ^16.2.0
version: 16.2.0(i18next@25.6.0(typescript@5.9.3))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
react-router-dom:
specifier: ^7.9.4
version: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
tailwind-merge:
specifier: ^3.0.2
version: 3.3.1
zustand:
specifier: ^5.0.5
version: 5.0.8(@types/react@19.0.10)(react@19.0.0)(use-sync-external-store@1.6.0(react@19.0.0))
devDependencies:
'@biomejs/biome':
specifier: 2.2.5
version: 2.2.5
'@cloudflare/vite-plugin':
specifier: ^1.9.4
version: 1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.44.0(@cloudflare/workers-types@4.20260411.1))
'@tailwindcss/vite':
specifier: ^4.0.14
version: 4.1.15(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
'@types/react':
specifier: 19.0.10
version: 19.0.10
'@types/react-dom':
specifier: 19.0.4
version: 19.0.4(@types/react@19.0.10)
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.7.0(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
tailwindcss:
specifier: ^4.0.14
version: 4.1.15
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
typescript:
specifier: ^5.7.0
version: 5.9.3
vite:
specifier: ^6.2.2
version: 6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)
vite-tsconfig-paths:
specifier: ^5.1.4
version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
wrangler:
specifier: ^4.24.3
version: 4.44.0(@cloudflare/workers-types@4.20260411.1)
apps/external:
dependencies:
'@tanstack/react-query':
@ -314,6 +399,9 @@ importers:
'@xtablo/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
'@xtablo/tablo-views':
specifier: workspace:*
version: link:../../packages/tablo-views
'@xtablo/ui':
specifier: workspace:*
version: link:../../packages/ui
@ -642,6 +730,61 @@ importers:
specifier: ^5.7.0
version: 5.9.3
packages/tablo-views:
dependencies:
'@tanstack/react-query':
specifier: ^5.69.0
version: 5.90.5(react@19.0.0)
'@xtablo/chat-ui':
specifier: workspace:*
version: link:../chat-ui
'@xtablo/shared':
specifier: workspace:*
version: link:../shared
'@xtablo/shared-types':
specifier: workspace:*
version: link:../shared-types
'@xtablo/ui':
specifier: workspace:*
version: link:../ui
date-fns:
specifier: ^4.1.0
version: 4.1.0
lucide-react:
specifier: ^0.460.0
version: 0.460.0(react@19.0.0)
react:
specifier: 19.0.0
version: 19.0.0
react-dom:
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
react-i18next:
specifier: ^16.2.0
version: 16.2.0(i18next@25.6.0(typescript@5.9.3))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)
react-router-dom:
specifier: ^7.9.4
version: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
tailwind-merge:
specifier: ^3.0.2
version: 3.3.1
devDependencies:
'@biomejs/biome':
specifier: 2.2.5
version: 2.2.5
'@types/react':
specifier: 19.0.10
version: 19.0.10
'@types/react-dom':
specifier: 19.0.4
version: 19.0.4(@types/react@19.0.10)
typescript:
specifier: ^5.7.0
version: 5.9.3
vite:
specifier: ^6.2.2
version: 6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)
packages/ui:
dependencies:
'@floating-ui/react':

View file

@ -0,0 +1,40 @@
-- Add is_client column to profiles
ALTER TABLE public.profiles
ADD COLUMN is_client boolean NOT NULL DEFAULT false;
-- Create client_invites table
CREATE TABLE public.client_invites (
id serial PRIMARY KEY,
tablo_id text NOT NULL REFERENCES public.tablos(id) ON DELETE CASCADE,
invited_email varchar(255) NOT NULL,
invited_by uuid NOT NULL REFERENCES public.profiles(id),
invite_token text NOT NULL,
expires_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
is_pending boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Index for token lookups
CREATE UNIQUE INDEX idx_client_invites_token ON public.client_invites(invite_token);
-- Index for listing invites by tablo
CREATE INDEX idx_client_invites_tablo ON public.client_invites(tablo_id, is_pending);
-- RLS
ALTER TABLE public.client_invites ENABLE ROW LEVEL SECURITY;
-- Admins can manage invites they created
CREATE POLICY "Admins can manage their client invites"
ON public.client_invites
FOR ALL
USING (invited_by = auth.uid());
-- Client users can read invites sent to their email
CREATE POLICY "Clients can read their own invites"
ON public.client_invites
FOR SELECT
USING (
invited_email = (
SELECT email FROM auth.users WHERE id = auth.uid()
)
);