Merge pull request #74 from artslidd/develop

Chat improvements, UI fixes, and auth cleanup
This commit is contained in:
Arthur Belleville 2026-04-15 21:38:10 +02:00 committed by GitHub
commit bc07836538
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
156 changed files with 15667 additions and 2815 deletions

View file

@ -1,6 +1,5 @@
SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
STREAM_CHAT_API_KEY=h7bwnn8ynjpx
XTABLO_URL=https://app.xtablo.com

View file

@ -14,7 +14,7 @@ steps:
- '--region'
- 'europe-west1'
- '--set-env-vars'
- 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,STREAM_CHAT_API_KEY=$_STREAM_CHAT_API_KEY,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID'
- 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID'
images:
- 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA'

View file

@ -33,7 +33,6 @@
"multer": "^2.0.2",
"nodemailer": "^7.0.4",
"sharp": "^0.34.5",
"stream-chat": "^9.8.0",
"stripe": "^20.0.0",
"ts-node": "^10.9.2"
},

View file

@ -10,10 +10,8 @@ const baseSecrets: Secrets = {
emailRefreshToken: "email-refresh-token",
r2AccessKeyId: "r2-access-key-id",
r2SecretAccessKey: "r2-secret-access-key",
streamChatApiSecret: "stream-chat-api-secret",
stripeSecretKey: "sk_live_secret_manager",
stripeWebhookSecret: "whsec_live_secret_manager",
streamChatApiSecretStaging: "stream-chat-api-secret-staging",
stripeSecretKeyStaging: "sk_live_staging_secret_manager",
stripeWebhookSecretStaging: "whsec_live_staging_secret_manager",
};

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", () => {
@ -427,26 +454,6 @@ describe("Middleware Tests", () => {
});
});
describe("StreamChat Middleware", () => {
it("should inject StreamChat client into context", async () => {
const app = new Hono();
app.use(middlewareManager.streamChat);
app.get("/test", (c) => {
const streamClient = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("streamServerClient");
return c.json({ hasStreamClient: !!streamClient });
});
// 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(200);
expect(data.hasStreamClient).toBe(true);
});
});
describe("R2 Middleware", () => {
it("should inject S3 client into context", async () => {
const app = new Hono();
@ -531,18 +538,14 @@ describe("Middleware Tests", () => {
it("should chain multiple middlewares correctly", async () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.streamChat);
app.use(middlewareManager.stripe);
app.get("/test", (c) => {
const supabase = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("supabase");
const streamClient = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("streamServerClient");
const stripe = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests
(c as any).get("stripe");
return c.json({
hasSupabase: !!supabase,
hasStreamClient: !!streamClient,
hasStripe: !!stripe,
});
});
@ -554,7 +557,6 @@ describe("Middleware Tests", () => {
expect(res.status).toBe(200);
expect(data.hasSupabase).toBe(true);
expect(data.hasStreamClient).toBe(true);
expect(data.hasStripe).toBe(true);
});
@ -562,7 +564,6 @@ describe("Middleware Tests", () => {
const app = new Hono();
app.use(middlewareManager.supabase);
app.use(middlewareManager.auth); // This will fail
app.use(middlewareManager.streamChat); // This should not execute
app.get("/test", (c) => c.json({ success: true }));
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access

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

@ -1,31 +1,11 @@
import { createClient } from "@supabase/supabase-js";
import { testClient } from "hono/testing";
import type { Channel, StreamChat } from "stream-chat";
import { afterAll, beforeAll, 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 { getTestUser } from "../helpers/dbSetup.js";
// Mock the stream-chat module
vi.mock("stream-chat", () => {
const mockChannel = {
create: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
};
const mockStreamChatInstance = {
channel: vi.fn(() => mockChannel),
upsertUser: vi.fn().mockResolvedValue({ users: {} }),
};
return {
StreamChat: {
getInstance: vi.fn(() => mockStreamChatInstance),
},
};
});
// Mock nodemailer
const mockSendMail = vi.fn();
vi.mock("nodemailer", () => ({
@ -54,16 +34,7 @@ describe("Booking Endpoint", () => {
const createdTablos: string[] = [];
const createdUsers: string[] = [];
// Get references to the mocked functions for assertions
let mockStreamChat: StreamChat;
let mockChannel: Channel;
beforeAll(async () => {
// Get references to the mocked instances
const { StreamChat } = await import("stream-chat");
mockStreamChat = StreamChat.getInstance("test_api_key", "test_api_secret");
mockChannel = mockStreamChat.channel("messaging", "test_channel_id");
// Get owner's short_user_id
const { data: ownerProfile } = await supabase
.from("profiles")
@ -324,10 +295,6 @@ describe("Booking Endpoint", () => {
createdUsers.push(userProfile.id);
}
// Verify Stream Chat channel was created
expect(mockChannel.create).toHaveBeenCalledTimes(1);
expect(mockChannel.sendMessage).toHaveBeenCalledTimes(1);
// Verify emails were sent (3 emails: welcome to new user, one to owner, one to booker)
expect(mockSendMail).toHaveBeenCalledTimes(3);
@ -407,10 +374,6 @@ describe("Booking Endpoint", () => {
createdTablos.push(data.tablo_id);
createdBookings.push(data.tablo_id);
// Verify Stream Chat channel was created
expect(mockChannel.create).toHaveBeenCalledTimes(1);
expect(mockChannel.sendMessage).toHaveBeenCalledTimes(1);
// Verify emails were sent (2 emails: one to owner, one to booker)
expect(mockSendMail).toHaveBeenCalledTimes(2);
@ -511,9 +474,6 @@ describe("Booking Endpoint", () => {
expect(data2.tablo_id).toBe(firstTabloId);
expect(data2.hasCreatedAccount).toBe(false);
// Stream Chat channel should still be created for the second booking
expect(mockChannel.create).toHaveBeenCalledTimes(1);
// Verify emails were sent for second booking (2 emails)
expect(mockSendMail).toHaveBeenCalledTimes(2);

View file

@ -8,35 +8,6 @@ import { getMainRouter } from "../../routers/index.js";
import type { TestUserData } from "../helpers/dbSetup.js";
import { getTestUser } from "../helpers/dbSetup.js";
// Mock Stream Chat operations
const mockChannelCreate = vi.fn();
const mockChannelUpdate = vi.fn();
const mockChannelDelete = vi.fn();
const mockChannelRemoveMembers = vi.fn();
const mockChannelAddMembers = vi.fn();
// Mock the channel method to return our mocked channel
const mockChannel = {
create: mockChannelCreate,
update: mockChannelUpdate,
delete: mockChannelDelete,
removeMembers: mockChannelRemoveMembers,
addMembers: mockChannelAddMembers,
};
// Mock the stream-chat module
vi.mock("stream-chat", () => {
const mockStreamChatInstance = {
channel: vi.fn(() => mockChannel),
};
return {
StreamChat: {
getInstance: vi.fn(() => mockStreamChatInstance),
},
};
});
// Mock nodemailer for email sending
const mockSendMail = vi.fn();
vi.mock("nodemailer", () => ({
@ -67,11 +38,6 @@ describe("Tablo Endpoint", () => {
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
mockChannelCreate.mockResolvedValue(undefined);
mockChannelUpdate.mockResolvedValue(undefined);
mockChannelDelete.mockResolvedValue(undefined);
mockChannelRemoveMembers.mockResolvedValue(undefined);
mockChannelAddMembers.mockResolvedValue(undefined);
mockSendMail.mockResolvedValue({ messageId: "test-message-id" });
});
@ -195,7 +161,7 @@ describe("Tablo Endpoint", () => {
await supabaseAdmin.from("profiles").update({ plan: "standard" }).eq("id", ownerUser.userId);
});
it("should allow owner to create a tablo and create a Stream Chat channel", async () => {
it("should allow owner to create a tablo", async () => {
const res = await createTabloRequest(ownerUser, client, {
name: "New Owner Tablo",
status: "todo",
@ -205,11 +171,6 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Tablo created successfully");
// Verify Stream Chat channel was created
expect(mockChannelCreate).toHaveBeenCalledTimes(1);
// Verify it was called (the channel is created with tablo data)
expect(mockChannelCreate).toHaveBeenCalled();
});
it("should deny temp user from creating a tablo (regularUserCheck blocks temporary users)", async () => {
@ -323,7 +284,6 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(403);
const data = await res.json();
expect(data.error).toBe("You have reached your tablo limit");
expect(mockChannelCreate).not.toHaveBeenCalled();
} finally {
await supabaseAdmin
.from("profiles")
@ -392,17 +352,13 @@ describe("Tablo Endpoint", () => {
});
describe("DELETE /tablos/delete - Delete Tablo", () => {
it("should allow owner with admin access to delete tablo and delete Stream Chat channel", async () => {
it("should allow owner with admin access to delete tablo", async () => {
// Owner has admin access to their tablos
const res = await deleteTabloRequest(ownerUser, client, "test_tablo_owner_private");
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Tablo deleted successfully");
// Verify Stream Chat channel was deleted
expect(mockChannelDelete).toHaveBeenCalledTimes(1);
expect(mockChannelDelete).toHaveBeenCalled();
});
it("should deny temp user without admin access from deleting tablo", async () => {
@ -558,7 +514,7 @@ describe("Tablo Endpoint", () => {
return tabloId;
};
it("should allow temp user to leave a shared tablo and remove from Stream Chat channel", async () => {
it("should allow temp user to leave a shared tablo", async () => {
const tabloId = await createSharedTabloForLeaveTest({
ownerId: ownerUser.userId,
memberId: temporaryUser.userId,
@ -569,13 +525,9 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Tablo left successfully");
// Verify Stream Chat channel removeMembers was called
expect(mockChannelRemoveMembers).toHaveBeenCalledTimes(1);
expect(mockChannelRemoveMembers).toHaveBeenCalledWith([temporaryUser.userId]);
});
it("should allow owner to leave a tablo and remove from Stream Chat channel", async () => {
it("should allow owner to leave a tablo", async () => {
const tabloId = await createSharedTabloForLeaveTest({
ownerId: temporaryUser.userId,
memberId: ownerUser.userId,
@ -587,10 +539,6 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Tablo left successfully");
// Verify Stream Chat channel removeMembers was called
expect(mockChannelRemoveMembers).toHaveBeenCalledTimes(1);
expect(mockChannelRemoveMembers).toHaveBeenCalledWith([ownerUser.userId]);
});
it("should deny unauthenticated leave request", async () => {

View file

@ -6,27 +6,6 @@ import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
// Mock Stream Chat operations
const mockChannelUpdate = vi.fn();
// Mock the channel method to return our mocked channel
const mockChannel = {
update: mockChannelUpdate,
};
// Mock the stream-chat module
vi.mock("stream-chat", () => {
const mockStreamChatInstance = {
channel: vi.fn(() => mockChannel),
};
return {
StreamChat: {
getInstance: vi.fn(() => mockStreamChatInstance),
},
};
});
// Create S3 mock for calendar file operations
const s3Mock = mockClient(S3Client);
@ -45,9 +24,6 @@ describe("Tasks Endpoint", () => {
// Mock PutObjectCommand for calendar file writes
s3Mock.on(PutObjectCommand).resolves({});
// Mock Stream Chat channel update
mockChannelUpdate.mockResolvedValue(undefined);
});
describe("POST /tasks/sync-calendars - Sync Calendar Files", () => {
@ -107,8 +83,8 @@ describe("Tasks Endpoint", () => {
});
});
describe("POST /tasks/sync-tablo-names - Sync Tablo Names to Stream", () => {
it("should call sync tablo names endpoint with basic auth and update Stream Chat channels (returns 200 if TASKS_SECRET properly configured)", async () => {
describe("POST /tasks/sync-tablo-names - Sync Tablo Names", () => {
it("should call sync tablo names endpoint with basic auth (returns 200 if TASKS_SECRET properly configured)", async () => {
const res = await client.tasks["sync-tablo-names"].$post(
{},
{

View file

@ -12,25 +12,6 @@ import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import { getTestUser } from "../helpers/dbSetup.js";
// Mock Stream Chat operations
const mockUpsertUser = vi.fn();
const mockCreateToken = vi.fn();
// Create an instance object that holds the mocks (like the working pattern in tablo.test.ts)
const mockStreamChatInstanceMethods = {
upsertUser: mockUpsertUser,
createToken: mockCreateToken,
};
// Mock the stream-chat module
vi.mock("stream-chat", () => {
return {
StreamChat: {
getInstance: vi.fn(() => mockStreamChatInstanceMethods),
},
};
});
// Create S3 mock for avatar operations
const s3Mock = mockClient(S3Client);
@ -50,10 +31,6 @@ describe("User Endpoint", () => {
vi.clearAllMocks();
s3Mock.reset();
// Mock Stream Chat operations
mockUpsertUser.mockResolvedValue({ users: { [ownerUser.userId]: {} } });
mockCreateToken.mockReturnValue("mock-stream-token-123");
// Mock S3 operations
s3Mock.on(PutObjectCommand).resolves({});
s3Mock.on(ListObjectsV2Command).resolves({
@ -63,7 +40,7 @@ describe("User Endpoint", () => {
});
describe("GET /me - Get User Profile", () => {
it("should return owner user profile with stream token", async () => {
it("should return owner user profile", async () => {
const res = await client.users.me.$get(
{},
{
@ -78,14 +55,9 @@ describe("User Endpoint", () => {
const data = await res.json();
expect(data.id).toBe(ownerUser.userId);
expect(data.email).toBe(ownerUser.email);
expect(data.streamToken).toBe("mock-stream-token-123");
// Verify Stream Chat createToken was called
expect(mockCreateToken).toHaveBeenCalledTimes(1);
expect(mockCreateToken).toHaveBeenCalledWith(ownerUser.userId);
});
it("should return temp user profile with stream token", async () => {
it("should return temp user profile", async () => {
const res = await client.users.me.$get(
{},
{
@ -100,11 +72,6 @@ describe("User Endpoint", () => {
const data = await res.json();
expect(data.id).toBe(temporaryUser.userId);
expect(data.email).toBe(temporaryUser.email);
expect(data.streamToken).toBe("mock-stream-token-123");
// Verify Stream Chat createToken was called
expect(mockCreateToken).toHaveBeenCalledTimes(1);
expect(mockCreateToken).toHaveBeenCalledWith(temporaryUser.userId);
});
it("should deny unauthenticated access", async () => {
@ -114,63 +81,6 @@ describe("User Endpoint", () => {
});
});
describe("POST /sign-up-to-stream - Sign Up User to Stream Chat", () => {
it("should sign up owner user to stream chat", async () => {
const res = await client.users["sign-up-to-stream"].$post(
{},
{
headers: {
Authorization: `Bearer ${ownerUser.accessToken}`,
"Content-Type": "application/json",
},
}
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("User signed up to stream");
// Verify Stream Chat upsertUser was called
expect(mockUpsertUser).toHaveBeenCalledTimes(1);
expect(mockUpsertUser).toHaveBeenCalledWith({
id: ownerUser.userId,
name: expect.any(String),
language: "fr",
});
});
it("should sign up temp user to stream chat", async () => {
const res = await client.users["sign-up-to-stream"].$post(
{},
{
headers: {
Authorization: `Bearer ${temporaryUser.accessToken}`,
"Content-Type": "application/json",
},
}
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("User signed up to stream");
// Verify Stream Chat upsertUser was called
expect(mockUpsertUser).toHaveBeenCalledTimes(1);
expect(mockUpsertUser).toHaveBeenCalledWith({
id: temporaryUser.userId,
name: expect.any(String),
language: "fr",
});
});
it("should deny unauthenticated stream signup", async () => {
const res = await client.users["sign-up-to-stream"].$post({});
expect(res.status).toBe(401);
expect(mockUpsertUser).not.toHaveBeenCalled();
});
});
describe("POST /profile/avatar - Upload Avatar", () => {
it("should upload avatar for owner user", async () => {
const res = await client.users.profile.avatar.$post(

View file

@ -8,8 +8,6 @@ export interface AppConfig {
SUPABASE_SERVICE_ROLE_KEY: string;
SUPABASE_CONNECTION_STRING: string;
SUPABASE_CA_CERT: string;
STREAM_CHAT_API_KEY: string;
STREAM_CHAT_API_SECRET: string;
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
STRIPE_SOLO_PRICE_ID: string;
@ -59,8 +57,6 @@ export function createConfig(secrets?: Secrets): AppConfig {
const isTestMode = NODE_ENV === "test";
const isStagingMode = NODE_ENV === "staging";
const getStreamChatApiSecret = (isStagingMode: boolean) =>
isStagingMode ? secrets!.streamChatApiSecretStaging : secrets!.streamChatApiSecret;
const getStripeSecretKey = (isStagingMode: boolean) =>
isStagingMode ? secrets!.stripeSecretKeyStaging : secrets!.stripeSecretKey;
const getStripeWebhookSecret = (isStagingMode: boolean) =>
@ -82,11 +78,6 @@ export function createConfig(secrets?: Secrets): AppConfig {
SUPABASE_CA_CERT: isTestMode
? validateEnvVar("SUPABASE_CA_CERT", process.env.SUPABASE_CA_CERT)
: secrets!.supabaseCaCert,
STREAM_CHAT_API_KEY: validateEnvVar("STREAM_CHAT_API_KEY", process.env.STREAM_CHAT_API_KEY),
// Env dependent
STREAM_CHAT_API_SECRET: isTestMode
? validateEnvVar("STREAM_CHAT_API_SECRET", process.env.STREAM_CHAT_API_SECRET)
: getStreamChatApiSecret(isStagingMode),
STRIPE_SECRET_KEY: isTestMode
? validateEnvVar("STRIPE_SECRET_KEY", process.env.STRIPE_SECRET_KEY)
: getStripeSecretKeyFromEnv() || getStripeSecretKey(isStagingMode),

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

@ -3,7 +3,6 @@ import type { SupabaseClient } from "@supabase/supabase-js";
import type { EventAndTablo } from "@xtablo/shared-types";
import type { Context, Next } from "hono";
import type { Transporter } from "nodemailer";
import type { StreamChat } from "stream-chat";
import { generatePassword } from "./token.js";
export const MAX_TABLO_LIMIT = 10;
@ -290,7 +289,6 @@ export const verifyTabloLimitForUser = async (c: Context, next: Next) => {
*/
export const createInvitedUser = async (
supabase: SupabaseClient,
streamServerClient: StreamChat,
transporter: Transporter,
recipientEmail: string,
senderEmail: string,
@ -334,12 +332,6 @@ export const createInvitedUser = async (
return { success: false, error: updateProfileError.message };
}
await streamServerClient.upsertUser({
id: newUser.user.id,
name: recipientEmail.split("@")[0],
language: "fr",
});
// Send welcome email to the new user
await transporter.sendMail({
from: `${senderEmail} via XTablo <noreply@xtablo.com>`,
@ -371,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

@ -4,7 +4,6 @@ import { createClient, type SupabaseClient, type User } from "@supabase/supabase
import type { Context, MiddlewareHandler, Next } from "hono";
import { createMiddleware } from "hono/factory";
import type { Transporter } from "nodemailer";
import { StreamChat } from "stream-chat";
import { Stripe } from "stripe";
import { type AppConfig } from "../config.js";
import { authenticateFromHeader } from "../helpers/auth.js";
@ -25,9 +24,6 @@ export type Middlewares = {
Variables: { supabase: SupabaseClient; user: User };
Bindings: { user: User };
}>;
streamChatMiddleware: MiddlewareHandler<{
Variables: { streamServerClient: StreamChat };
}>;
r2Middleware: MiddlewareHandler<{
Variables: { s3_client: S3Client };
}>;
@ -88,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();
@ -96,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);
}
@ -168,15 +164,6 @@ export class MiddlewareManager {
await next();
});
const streamChatMiddleware = createMiddleware(async (c: Context, next: Next) => {
const serverClient = StreamChat.getInstance(
config.STREAM_CHAT_API_KEY,
config.STREAM_CHAT_API_SECRET
);
c.set("streamServerClient", serverClient);
await next();
});
const r2Middleware = createMiddleware(async (c: Context, next: Next) => {
const s3 = new S3Client({
region: "auto",
@ -255,7 +242,6 @@ export class MiddlewareManager {
basicAuthMiddleware,
authMiddleware,
maybeAuthenticatedMiddleware,
streamChatMiddleware,
r2Middleware,
regularUserCheckMiddleware,
billingCheckoutAccessMiddleware,
@ -282,10 +268,6 @@ export class MiddlewareManager {
return this.middlewares.maybeAuthenticatedMiddleware;
}
get streamChat() {
return this.middlewares.streamChatMiddleware;
}
get r2() {
return this.middlewares.r2Middleware;
}

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,241 @@
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 { data: linkData, 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
} else if (linkData?.properties?.action_link) {
const transporter = c.get("transporter");
try {
await transporter.sendMail({
from: "Xtablo <noreply@xtablo.com>",
to: rawEmail,
subject: "Vous avez été invité sur Xtablo",
html: `
<h2>Vous avez é invité à collaborer sur un tablo</h2>
<p>Bonjour,</p>
<p>Cliquez sur le lien ci-dessous pour accéder à votre espace client :</p>
<p><a href="${linkData.properties.action_link}">Accéder à mon espace</a></p>
<p>Ce lien expire dans ${CLIENT_INVITE_EXPIRY_HOURS} heures.</p>
`,
});
} catch (emailError) {
console.error("Failed to send client invite email:", emailError);
}
}
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;
};

View file

@ -17,7 +17,6 @@ export const getMainRouter = (config: AppConfig) => {
mainRouter.use(middlewareManager.supabase);
// Apply remaining middlewares after public routes
mainRouter.use(middlewareManager.streamChat);
mainRouter.use(middlewareManager.r2);
mainRouter.use(middlewareManager.transporter);
mainRouter.use(middlewareManager.stripe);

View file

@ -9,7 +9,6 @@ const factory = createFactory<MaybeAuthEnv>();
const bookSlot = factory.createHandlers(async (c) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const transporter = c.get("transporter");
const maybeUser = c.get("user");
@ -55,7 +54,6 @@ const bookSlot = factory.createHandlers(async (c) => {
// Create a temporary user for the booking
const result = await createInvitedUser(
supabase,
streamServerClient,
transporter,
data.user_details.email,
ownerData.email,
@ -220,28 +218,6 @@ const bookSlot = factory.createHandlers(async (c) => {
return c.json({ error: tabloAccessError.message }, 500);
}
// Create Stream chat channel with the owner as creator
const { data: organizationMembers, error: organizationMembersError } = await supabase
.from("profiles")
.select("id")
.eq("organization_id", ownerOrganizationId);
if (organizationMembersError) {
return c.json({ error: "Failed to load organization members" }, 500);
}
const channelMembers = Array.from(
new Set((organizationMembers || []).map((member) => member.id).concat(bookerUserDataTyped.id))
);
const channel = streamServerClient.channel("messaging", tabloData.id, {
// @ts-ignore
name: tabloData.name,
created_by_id: ownerId,
members: channelMembers,
});
await channel.create();
const newEvent: TablesInsert<"events"> = {
description: eventTypeConfig.description || "",
end_time: data.event_details.end_time || "",
@ -258,12 +234,6 @@ const bookSlot = factory.createHandlers(async (c) => {
return c.json({ error: "Failed to create event" }, 500);
}
// Send a welcome message to the channel
await channel.sendMessage({
text: `🎉 Bienvenue dans votre nouveau tablo "${tabloData.name}" ! Votre rendez-vous "${newEvent.title}" est confirmé pour le ${newEvent.start_date} de ${newEvent.start_time} à ${newEvent.end_time}.`,
user_id: ownerId,
});
// Send email notifications to both owner and invited user
// Send email to the owner
await transporter.sendMail({

View file

@ -18,83 +18,6 @@ type PostTablo = Omit<TabloInsert, "owner_id" | "organization_id"> & {
const factory = createFactory<AuthEnv>();
const isAlreadyMemberError = (error: unknown): boolean => {
if (!error) return false;
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
return (
message.includes("already a member") ||
message.includes("already member") ||
message.includes("member already exists")
);
};
const upsertStreamUserFromProfile = async (
supabase: AuthEnv["Variables"]["supabase"],
streamServerClient: AuthEnv["Variables"]["streamServerClient"],
userId: string
) => {
const { data: profile } = await supabase
.from("profiles")
.select("name")
.eq("id", userId)
.maybeSingle();
await streamServerClient.upsertUser({
id: userId,
name: profile?.name ?? "",
language: "fr",
});
};
const ensureTabloChannelMember = async (
supabase: AuthEnv["Variables"]["supabase"],
streamServerClient: AuthEnv["Variables"]["streamServerClient"],
tabloId: string,
userId: string
) => {
const channel = streamServerClient.channel("messaging", tabloId);
try {
await channel.addMembers([userId]);
return;
} catch (error) {
if (isAlreadyMemberError(error)) {
return;
}
}
const { data: tablo } = await supabase
.from("tablos")
.select("name, owner_id")
.eq("id", tabloId)
.maybeSingle();
const { data: accessRows } = await supabase
.from("tablo_access")
.select("user_id")
.eq("tablo_id", tabloId)
.eq("is_active", true);
const members = Array.from(new Set((accessRows || []).map((row) => row.user_id).concat(userId)));
const channelToCreate = streamServerClient.channel("messaging", tabloId, {
// @ts-ignore
name: tablo?.name ?? "Tablo",
created_by_id: tablo?.owner_id ?? userId,
members,
});
try {
await channelToCreate.create();
} catch (error) {
if (isAlreadyMemberError(error)) {
return;
}
await channel.addMembers([userId]);
}
};
const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(
middlewareManager.regularUserCheck,
@ -134,28 +57,6 @@ const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
const tabloData = insertedTablo as Tables<"tablos">;
const { data: organizationMembers, error: membersError } = await supabase
.from("profiles")
.select("id")
.eq("organization_id", profile.organization_id);
if (membersError) {
return c.json({ error: "Failed to load organization members" }, 500);
}
const channelMembers = Array.from(
new Set((organizationMembers || []).map((member) => member.id).concat(user.id))
);
const streamServerClient = c.get("streamServerClient");
const channel = streamServerClient.channel("messaging", tabloData.id, {
// @ts-ignore
name: tabloData.name,
created_by_id: user.id,
members: channelMembers,
});
await channel.create();
if (typedPayload.events) {
const eventsToInsert = typedPayload.events.map((event) => ({
...event,
@ -173,7 +74,6 @@ const updateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const data = await c.req.json();
const { id, ...tablo } = data;
@ -190,7 +90,7 @@ const updateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
return c.json({ error: "You are not authorized to update this tablo" }, 403);
}
const { data: update, error } = await supabase
const { error } = await supabase
.from("tablos")
.update(tablo)
.eq("id", id)
@ -201,28 +101,12 @@ const updateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
return c.json({ error: error.message }, 500);
}
const updatedTablo = update as Tables<"tablos">;
const isUpdatingName = tablo.name !== undefined;
if (isUpdatingName) {
const channel = streamServerClient.channel("messaging", updatedTablo.id);
try {
await channel.update({
// @ts-ignore
name: updatedTablo.name,
});
} catch (error) {
console.error("error updating channel", error);
}
}
return c.json({ message: "Tablo updated successfully" });
});
const deleteTablo = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const data = await c.req.json();
const { id } = data;
@ -270,13 +154,6 @@ const deleteTablo = factory.createHandlers(async (c) => {
return c.json({ error: error.message }, 500);
}
const channel = streamServerClient.channel("messaging", id);
try {
await channel.delete();
} catch (error) {
console.error("error deleting channel", error);
}
return c.json({ message: "Tablo deleted successfully" });
});
@ -288,7 +165,6 @@ const inviteToTablo = (
const transporter = c.get("transporter");
const sender = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const tabloId = c.req.param("tabloId");
const { email: recipientmail } = await c.req.json();
@ -355,7 +231,6 @@ const inviteToTablo = (
// Create a new invited user and add them to the tablo
const result = await createInvitedUser(
supabase,
streamServerClient,
transporter,
recipientEmail,
sender.email,
@ -381,13 +256,6 @@ const inviteToTablo = (
return c.json({ error: tabloAccessError.message }, 500);
}
try {
await ensureTabloChannelMember(supabase, streamServerClient, tabloId, result.userId);
} catch (streamError) {
console.error("error adding temporary invited user to channel", streamError);
return c.json({ error: "Failed to sync chat access for invited user" }, 500);
}
return c.json({
message: "User created and invite sent successfully",
});
@ -438,7 +306,6 @@ const cancelPendingInvite = (middlewareManager: ReturnType<typeof MiddlewareMana
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const tabloId = c.req.param("tabloId");
const inviteId = Number(c.req.param("inviteId"));
@ -513,13 +380,6 @@ const cancelPendingInvite = (middlewareManager: ReturnType<typeof MiddlewareMana
if (revokeAccessError) {
return c.json({ error: revokeAccessError.message }, 500);
}
try {
const channel = streamServerClient.channel("messaging", tabloId);
await channel.removeMembers([invitedProfile.id]);
} catch (error) {
console.error("error removing cancelled invitee from channel", error);
}
}
return c.json({ message: "Invite cancelled successfully" });
@ -573,7 +433,6 @@ const acceptInviteById = (middlewareManager: ReturnType<typeof MiddlewareManager
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const inviteId = Number(c.req.param("inviteId"));
if (!Number.isInteger(inviteId) || inviteId <= 0) {
@ -598,13 +457,6 @@ const acceptInviteById = (middlewareManager: ReturnType<typeof MiddlewareManager
return c.json({ error: "You are not authorized to accept this invite" }, 403);
}
try {
await upsertStreamUserFromProfile(supabase, streamServerClient, user.id);
} catch (error) {
console.error("error upserting joining user to stream", error);
return c.json({ error: "Failed to provision chat user" }, 500);
}
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
tablo_id: inviteData.tablo_id,
user_id: user.id,
@ -621,13 +473,6 @@ const acceptInviteById = (middlewareManager: ReturnType<typeof MiddlewareManager
await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", inviteData.id);
try {
await ensureTabloChannelMember(supabase, streamServerClient, inviteData.tablo_id, user.id);
} catch (error) {
console.error("error adding member to channel", error);
return c.json({ error: "Failed to sync chat access for this tablo" }, 500);
}
return c.json({ tablo_id: inviteData.tablo_id });
});
@ -636,7 +481,6 @@ const joinTablo = factory.createHandlers(async (c) => {
const joiner = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data: inviteData, error } = await supabase
.from("tablo_invites")
@ -657,13 +501,6 @@ const joinTablo = factory.createHandlers(async (c) => {
const { id: invite_id, tablo_id, invited_by } = inviteData;
try {
await upsertStreamUserFromProfile(supabase, streamServerClient, joiner.id);
} catch (error) {
console.error("error upserting joining user to stream", error);
return c.json({ error: "Failed to provision chat user" }, 500);
}
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
tablo_id,
user_id: joiner.id,
@ -686,13 +523,6 @@ const joinTablo = factory.createHandlers(async (c) => {
// Mark invite as accepted instead of deleting (maintains audit trail)
await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", invite_id);
try {
await ensureTabloChannelMember(supabase, streamServerClient, tablo_id, joiner.id);
} catch (error) {
console.error("error adding member to channel", error);
return c.json({ error: "Failed to sync chat access for this tablo" }, 500);
}
return c.json({ tablo_id });
});
@ -748,12 +578,8 @@ const getTabloMembers = factory.createHandlers(async (c) => {
const leaveTablo = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { tablo_id } = await c.req.json();
const channel = streamServerClient.channel("messaging", tablo_id);
await channel.removeMembers([user.id]);
const { error } = await supabase
.from("tablo_access")
.update({ is_active: false })
@ -872,7 +698,6 @@ export const getTabloRouter = (config: AppConfig) => {
tabloRouter.use(middlewareManager.supabase);
tabloRouter.use(middlewareManager.auth);
tabloRouter.use(middlewareManager.streamChat);
tabloRouter.post("/create", ...createTablo(middlewareManager));
tabloRouter.patch("/update", ...updateTablo(middlewareManager));

View file

@ -39,7 +39,6 @@ const syncCalendars = factory.createHandlers(async (c) => {
const syncTabloNames = factory.createHandlers(async (c) => {
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const fifteenMinutesInMilliseconds = 1000 * 60 * 15;
@ -54,18 +53,6 @@ const syncTabloNames = factory.createHandlers(async (c) => {
const tablosData = data as { id: string; name: string }[];
tablosData.forEach(async (tablo) => {
const channel = streamServerClient.channel("messaging", tablo.id);
try {
await channel.update({
// @ts-ignore
name: tablo.name,
});
} catch (error) {
console.error(`error updating channel, tablo id: ${tablo.id}, error: ${error}`);
}
});
return c.json({ message: `Synced ${tablosData.length} tablo names` });
});

View file

@ -11,30 +11,9 @@ const factory = createFactory<AuthEnv>();
const isMissingRelationError = (code: string | undefined) =>
code === "42P01" || code === "PGRST205";
const signUpToStream = factory.createHandlers(async (c) => {
const { id } = c.get("user");
const supabase = c.get("supabase");
const { data } = await supabase.from("profiles").select("*").eq("id", id).single();
const user = data as Tables<"profiles">;
const streamServerClient = c.get("streamServerClient");
await streamServerClient.upsertUser({
id,
name: user.name ?? "",
language: "fr",
});
return c.json({
message: "User signed up to stream",
});
});
const getMe = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single();
@ -60,14 +39,7 @@ const getMe = factory.createHandlers(async (c) => {
effectivePlan = organizationPlan;
}
const user_id = data.id;
const token = streamServerClient.createToken(user_id);
return c.json({
...userData,
plan: effectivePlan,
streamToken: token,
});
return c.json({ ...userData, plan: effectivePlan });
});
const markTemporary = factory.createHandlers(async (c) => {
@ -515,7 +487,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const transporter = c.get("transporter");
const streamServerClient = c.get("streamServerClient");
const body = await c.req.json();
const rawEmail = typeof body?.email === "string" ? body.email : "";
const recipientEmail = rawEmail.trim().toLowerCase();
@ -613,7 +584,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
const invitedUser = await createInvitedUser(
supabase,
streamServerClient,
transporter,
recipientEmail,
senderProfile.email,
@ -673,15 +643,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
}
}
for (const tablo of organizationTablos || []) {
const channel = streamServerClient.channel("messaging", tablo.id);
try {
await channel.addMembers([invitedUser.userId]);
} catch (error) {
console.error("Failed to add invited user to Stream channel:", error);
}
}
if (oldOrganizationId && oldOrganizationId !== organizationId) {
const { count: oldOrgMembersCount } = await supabase
.from("profiles")
@ -717,7 +678,6 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
const removeOrganizationMember = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const memberId = c.req.param("memberId");
if (!memberId) {
@ -826,14 +786,6 @@ const removeOrganizationMember = factory.createHandlers(async (c) => {
return c.json({ error: "Failed to revoke member tablo permissions" }, 500);
}
for (const tabloId of tabloIds) {
try {
const channel = streamServerClient.channel("messaging", tabloId);
await channel.removeMembers([memberId]);
} catch (error) {
console.error("Failed to remove organization member from Stream channel:", error);
}
}
}
const { error: inviteCleanupError } = await supabase
@ -852,7 +804,6 @@ const removeOrganizationMember = factory.createHandlers(async (c) => {
export const getUserRouter = () => {
const userRouter = new Hono();
userRouter.post("/sign-up-to-stream", ...signUpToStream);
userRouter.get("/me", ...getMe);
userRouter.post("/mark-temporary", ...markTemporary);
userRouter.post("/profile/avatar", ...uploadAvatar);

View file

@ -26,11 +26,9 @@ export type Secrets = {
r2AccessKeyId: string;
r2SecretAccessKey: string;
// Env dependent
streamChatApiSecret: string;
stripeSecretKey: string;
stripeWebhookSecret: string;
// Staging
streamChatApiSecretStaging: string;
stripeSecretKeyStaging: string;
stripeWebhookSecretStaging: string;
};
@ -50,11 +48,9 @@ export async function loadSecrets(): Promise<Secrets> {
r2SecretAccessKey: await fetchSecret("r2-secret-access-key"),
// Env dependent
// Staging
streamChatApiSecretStaging: await fetchSecret("stream-chat-api-secret-staging"),
stripeSecretKeyStaging: await fetchSecret("stripe-secret-key-staging"),
stripeWebhookSecretStaging: await fetchSecret("stripe-webhook-secret-staging"),
// Production
streamChatApiSecret: await fetchSecret("stream-chat-api-secret"),
stripeSecretKey: await fetchSecret("stripe-secret-key"),
stripeWebhookSecret: await fetchSecret("stripe-webhook-secret"),
};

View file

@ -3,7 +3,6 @@ import type { StripeSync } from "@supabase/stripe-sync-engine";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { Hono } from "hono";
import type { Transporter } from "nodemailer";
import type { StreamChat } from "stream-chat";
import type Stripe from "stripe";
/**
@ -12,7 +11,6 @@ import type Stripe from "stripe";
export type BaseEnv = {
Variables: {
supabase: SupabaseClient;
streamServerClient: StreamChat;
s3_client: S3Client;
transporter: Transporter;
stripe: Stripe;

View file

@ -0,0 +1,20 @@
{
"name": "@xtablo/chat-worker",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"hono": "^4.7.7",
"jose": "^6.0.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250410.0",
"typescript": "^5.8.3",
"wrangler": "^4.14.0"
}
}

View file

@ -0,0 +1,166 @@
import { DurableObject } from "cloudflare:workers";
import type { Env, ClientMessage, ServerMessage } from "../lib/types";
import { PostgREST } from "../lib/supabase";
export class ChatRoom extends DurableObject<Env> {
private postgrest: PostgREST | null = null;
private getPostgREST(): PostgREST {
if (!this.postgrest) {
this.postgrest = new PostgREST(this.env.SUPABASE_URL, this.env.SUPABASE_SERVICE_ROLE_KEY);
}
return this.postgrest;
}
/**
* Handle incoming fetch requests WebSocket upgrades are forwarded here by the Worker.
* userId and channelId are passed via custom headers set by the Worker.
*/
async fetch(request: Request): Promise<Response> {
const userId = request.headers.get("X-User-Id");
const channelId = request.headers.get("X-Channel-Id");
if (!userId || !channelId) {
return new Response("Missing user or channel identity", { status: 400 });
}
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
const stored = await this.ctx.storage.get<string>("channelId");
if (!stored) {
await this.ctx.storage.put("channelId", channelId);
}
this.ctx.acceptWebSocket(server, [userId]);
this.broadcast({
type: "presence.update",
userId,
status: "online",
}, server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void> {
const tags = this.ctx.getTags(ws);
const userId = tags[0];
if (!userId) {
ws.close(4001, "Missing user identity");
return;
}
let msg: ClientMessage;
try {
msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
} catch {
this.sendTo(ws, { type: "error", code: "PARSE_ERROR", message: "Invalid JSON" });
return;
}
switch (msg.type) {
case "message.send":
await this.handleSendMessage(ws, userId, msg.text, msg.clientId);
break;
case "typing.start":
this.broadcast({ type: "typing", userId, isTyping: true }, ws);
break;
case "typing.stop":
this.broadcast({ type: "typing", userId, isTyping: false }, ws);
break;
case "presence.ping":
break;
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
const tags = this.ctx.getTags(ws);
const userId = tags[0];
if (userId) {
const remaining = this.ctx.getWebSockets(userId);
if (remaining.length === 0) {
this.broadcast({ type: "presence.update", userId, status: "offline" });
}
}
}
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
console.error("WebSocket error:", error);
ws.close(1011, "Internal error");
}
private async handleSendMessage(ws: WebSocket, userId: string, text: string, clientId: string): Promise<void> {
if (!text || text.trim().length === 0) {
this.sendTo(ws, { type: "error", code: "EMPTY_MESSAGE", message: "Message text is required" });
return;
}
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
const channelId = await this.getChannelId();
const serverMsg: ServerMessage = {
type: "message.new",
id,
userId,
text: text.trim(),
createdAt,
clientId,
};
this.broadcast(serverMsg);
this.ctx.waitUntil(this.persistMessage(channelId, id, userId, text.trim(), createdAt));
}
private async persistMessage(channelId: string, id: string, userId: string, text: string, createdAt: string): Promise<void> {
const db = this.getPostgREST();
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await db.insert("messages", {
id,
channel_id: channelId,
user_id: userId,
text,
created_at: createdAt,
});
return;
} catch (error) {
console.error(`Message persist attempt ${attempt + 1} failed:`, error);
if (attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
}
}
}
console.error(`Failed to persist message ${id} after ${maxRetries} attempts`);
}
private async getChannelId(): Promise<string> {
const channelId = await this.ctx.storage.get<string>("channelId");
if (!channelId) throw new Error("channelId not stored in DO");
return channelId;
}
private sendTo(ws: WebSocket, msg: ServerMessage): void {
try {
ws.send(JSON.stringify(msg));
} catch {
// Connection already closed
}
}
private broadcast(msg: ServerMessage, exclude?: WebSocket): void {
const payload = JSON.stringify(msg);
for (const ws of this.ctx.getWebSockets()) {
if (ws !== exclude) {
try {
ws.send(payload);
} catch {
// Connection already closed
}
}
}
}
}

View file

@ -0,0 +1,165 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { ChatRoom } from "./durable-objects/ChatRoom";
import { extractToken, verifyJwt } from "./lib/auth";
import { PostgREST } from "./lib/supabase";
import type { Env, ChatMessage, UnreadCount } from "./lib/types";
// Re-export DO class for wrangler
export { ChatRoom };
const app = new Hono<{ Bindings: Env }>();
// CORS — allow the main app origins
app.use("*", cors({
origin: [
"http://localhost:5173",
"https://app.xtablo.com",
"https://app-staging.xtablo.com",
],
allowHeaders: ["Authorization", "Content-Type"],
allowMethods: ["GET", "POST", "OPTIONS"],
}));
// Auth middleware — extract and verify JWT for all routes
// For WebSocket upgrades, the token comes via query param (?token=...) since browsers
// cannot send custom headers on WebSocket connections.
// For REST requests, the token comes via the Authorization header.
app.use("*", async (c, next) => {
const isWebSocket = c.req.header("Upgrade") === "websocket";
const token = isWebSocket
? new URL(c.req.url).searchParams.get("token")
: extractToken(c.req.header("Authorization"));
if (!token) {
return c.json({ error: "Missing authorization" }, 401);
}
try {
const auth = await verifyJwt(token, c.env.JWT_SECRET);
c.set("userId" as never, auth.userId);
} catch (error) {
return c.json({ error: "Invalid token" }, 401);
}
await next();
});
// Helper: check tablo membership via PostgREST
async function checkMembership(db: PostgREST, channelId: string, userId: string): Promise<boolean> {
const rows = await db.select<{ user_id: string }>(
"tablo_access",
`tablo_id=eq.${channelId}&user_id=eq.${userId}&is_active=eq.true&select=user_id&limit=1`
);
return rows.length > 0;
}
// WebSocket upgrade — route to Durable Object
app.get("/chat/ws/:channelId", async (c) => {
const upgradeHeader = c.req.header("Upgrade");
if (upgradeHeader !== "websocket") {
return c.json({ error: "Expected WebSocket upgrade" }, 426);
}
const channelId = c.req.param("channelId");
const userId = c.get("userId" as never) as string;
const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY);
const isMember = await checkMembership(db, channelId, userId);
if (!isMember) {
return c.json({ error: "Not a member of this channel" }, 403);
}
const id = c.env.CHAT_ROOM.idFromName(channelId);
const stub = c.env.CHAT_ROOM.get(id);
// Forward the WebSocket upgrade via fetch — DO RPC doesn't support WebSocket upgrades.
// Pass userId and channelId via headers so the DO can read them.
const url = new URL(c.req.url);
const doRequest = new Request(url.toString(), c.req.raw);
doRequest.headers.set("X-User-Id", userId);
doRequest.headers.set("X-Channel-Id", channelId);
return stub.fetch(doRequest);
});
// GET message history — paginated
app.get("/chat/channels/:channelId/messages", async (c) => {
const channelId = c.req.param("channelId");
const userId = c.get("userId" as never) as string;
const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY);
const isMember = await checkMembership(db, channelId, userId);
if (!isMember) {
return c.json({ error: "Not a member of this channel" }, 403);
}
const before = c.req.query("before");
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 100);
let query = `channel_id=eq.${channelId}&deleted_at=is.null&select=id,channel_id,user_id,text,created_at&order=created_at.desc&limit=${limit}`;
if (before) {
query += `&created_at=lt.${before}`;
}
const messages = await db.select<ChatMessage>(
"messages",
query
);
return c.json({ messages: messages.reverse(), hasMore: messages.length === limit });
});
// POST mark channel as read
app.post("/chat/channels/:channelId/read", async (c) => {
const channelId = c.req.param("channelId");
const userId = c.get("userId" as never) as string;
const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY);
await db.upsert("channel_read_state", {
user_id: userId,
channel_id: channelId,
last_read_at: new Date().toISOString(),
}, "user_id,channel_id");
return c.json({ ok: true });
});
// GET unread counts for current user across all channels
app.get("/chat/unread", async (c) => {
const userId = c.get("userId" as never) as string;
const db = new PostgREST(c.env.SUPABASE_URL, c.env.SUPABASE_SERVICE_ROLE_KEY);
// Get all tablos the user has access to
const accessRows = await db.select<{ tablo_id: string }>(
"tablo_access",
`user_id=eq.${userId}&is_active=eq.true&select=tablo_id`
);
if (accessRows.length === 0) {
return c.json({ unread: [] });
}
// For each channel, get unread count
const unread: UnreadCount[] = [];
for (const { tablo_id } of accessRows) {
// Get last read time
const readState = await db.select<{ last_read_at: string }>(
"channel_read_state",
`user_id=eq.${userId}&channel_id=eq.${tablo_id}&select=last_read_at&limit=1`
);
const lastReadAt = readState[0]?.last_read_at ?? "1970-01-01T00:00:00Z";
const count = await db.count(
"messages",
`channel_id=eq.${tablo_id}&deleted_at=is.null&created_at=gt.${lastReadAt}`
);
if (count > 0) {
unread.push({ channel_id: tablo_id, unread_count: count });
}
}
return c.json({ unread });
});
export default app;

View file

@ -0,0 +1,34 @@
import { jwtVerify } from "jose";
interface AuthResult {
userId: string;
email: string | null;
}
/**
* Verify a Supabase JWT and extract the user ID.
* Supabase JWTs are signed with the JWT secret and contain the user ID in the `sub` claim.
*/
export async function verifyJwt(token: string, jwtSecret: string): Promise<AuthResult> {
const secret = new TextEncoder().encode(jwtSecret);
const { payload } = await jwtVerify(token, secret, {
issuer: "https://mhcafqvzbrrwvahpvvzd.supabase.co/auth/v1",
});
if (!payload.sub) {
throw new Error("Missing sub claim in JWT");
}
return {
userId: payload.sub,
email: (payload.email as string) ?? null,
};
}
/**
* Extract Bearer token from Authorization header.
*/
export function extractToken(authHeader: string | undefined): string | null {
if (!authHeader?.startsWith("Bearer ")) return null;
return authHeader.slice(7);
}

View file

@ -0,0 +1,82 @@
/**
* Thin PostgREST client using fetch no Supabase SDK dependency.
* Used by both the Worker (history queries) and the Durable Object (message persistence).
*/
export class PostgREST {
private baseUrl: string;
private serviceRoleKey: string;
constructor(supabaseUrl: string, serviceRoleKey: string) {
this.baseUrl = `${supabaseUrl}/rest/v1`;
this.serviceRoleKey = serviceRoleKey;
}
private headers(): Record<string, string> {
return {
"apikey": this.serviceRoleKey,
"Authorization": `Bearer ${this.serviceRoleKey}`,
"Content-Type": "application/json",
"Prefer": "return=representation",
};
}
/** Insert a row and return the inserted data. */
async insert<T>(table: string, data: Record<string, unknown>): Promise<T[]> {
const res = await fetch(`${this.baseUrl}/${table}`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`PostgREST insert failed (${res.status}): ${body}`);
}
return res.json() as Promise<T[]>;
}
/** Upsert a row (requires Prefer: resolution=merge-duplicates). */
async upsert<T>(table: string, data: Record<string, unknown>, onConflict: string): Promise<T[]> {
const headers = this.headers();
headers["Prefer"] = "return=representation,resolution=merge-duplicates";
const res = await fetch(`${this.baseUrl}/${table}?on_conflict=${onConflict}`, {
method: "POST",
headers,
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`PostgREST upsert failed (${res.status}): ${body}`);
}
return res.json() as Promise<T[]>;
}
/** Select rows with PostgREST query string. */
async select<T>(table: string, query: string): Promise<T[]> {
const res = await fetch(`${this.baseUrl}/${table}?${query}`, {
method: "GET",
headers: this.headers(),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`PostgREST select failed (${res.status}): ${body}`);
}
return res.json() as Promise<T[]>;
}
/** Select with exact count header for unread queries. */
async count(table: string, query: string): Promise<number> {
const headers = this.headers();
headers["Prefer"] = "count=exact";
headers["Range-Unit"] = "items";
headers["Range"] = "0-0";
const res = await fetch(`${this.baseUrl}/${table}?${query}`, {
method: "HEAD",
headers,
});
const contentRange = res.headers.get("Content-Range");
if (!contentRange) return 0;
// Content-Range format: "0-0/42" or "*/0"
const total = contentRange.split("/")[1];
return total === "*" ? 0 : parseInt(total, 10);
}
}

View file

@ -0,0 +1,37 @@
// WebSocket message types — client to server
export type ClientMessage =
| { type: "message.send"; text: string; clientId: string }
| { type: "typing.start" }
| { type: "typing.stop" }
| { type: "presence.ping" };
// WebSocket message types — server to client
export type ServerMessage =
| { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string }
| { type: "typing"; userId: string; isTyping: boolean }
| { type: "presence.update"; userId: string; status: "online" | "offline" }
| { type: "error"; code: string; message: string };
// REST API types
export interface ChatMessage {
id: string;
channel_id: string;
user_id: string;
text: string;
created_at: string;
updated_at: string | null;
deleted_at: string | null;
}
export interface UnreadCount {
channel_id: string;
unread_count: number;
}
// Worker environment bindings
export interface Env {
CHAT_ROOM: DurableObjectNamespace;
SUPABASE_URL: string;
SUPABASE_SERVICE_ROLE_KEY: string;
JWT_SECRET: string;
}

View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"deploy": {
"passThroughEnv": [
"CLOUDFLARE_ACCOUNT_ID",
"CLOUDFLARE_API_TOKEN"
],
"cache": false,
"outputLogs": "new-only"
}
}
}

View file

@ -0,0 +1,26 @@
name = "xtablo-chat"
main = "src/index.ts"
compatibility_date = "2025-07-09"
[durable_objects]
bindings = [
{ name = "CHAT_ROOM", class_name = "ChatRoom" }
]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]
[observability]
enabled = true
[vars]
SUPABASE_URL = "https://mhcafqvzbrrwvahpvvzd.supabase.co"
# Secrets (set via `wrangler secret put`):
# SUPABASE_SERVICE_ROLE_KEY
# JWT_SECRET
[[routes]]
pattern = "chat.xtablo.com"
custom_domain = true

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:prod": "wrangler deploy --env=\"\"",
"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 @@
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/components/clientlayout.tsx","./src/lib/supabase.ts","./src/pages/authcallback.tsx","./src/pages/clienttablolistpage.tsx","./src/pages/clienttablopage.tsx"],"version":"5.9.3"}

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,11 @@
name = "xtablo-clients"
main = "worker/index.ts"
compatibility_date = "2025-07-09"
route = { pattern = "clients.xtablo.com", custom_domain = true }
[assets]
directory = "./dist/"
not_found_handling = "single-page-application"
[observability]
enabled = true

View file

@ -2,6 +2,5 @@ VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM
VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd
VITE_STREAM_CHAT_API_KEY="t5vvvddteapa"
VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app

View file

@ -5,9 +5,7 @@ import React from "react";
import { createStore, StoreApi, useStore } from "zustand";
import { api } from "./lib/api";
export type User = Tables<"profiles"> & {
streamToken: string | null;
};
export type User = Tables<"profiles">;
const UserStoreContext = React.createContext<StoreApi<User> | null>(null);

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

@ -10,7 +10,6 @@ declare namespace Cloudflare {
VITE_SUPABASE_URL: string;
VITE_SUPABASE_ANON_KEY: string;
VITE_SUPABASE_ID: string;
VITE_STREAM_CHAT_API_KEY: string;
VITE_API_URL: string;
}
}

View file

@ -4,7 +4,8 @@ VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM
VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd
VITE_STREAM_CHAT_API_KEY="h7bwnn8ynjpx"
VITE_CHAT_WS_URL=wss://chat.xtablo.com
VITE_CHAT_API_URL=https://chat.xtablo.com
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Qc159AmcXPHW4mTHUTW6it2mdZ3KQTxZGXZ188DKpXuXgpirUWOj24dnb7DzbcEAu45nU1S5k66Nm4liY3IlGOW00pndRsgUM
VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SO0HAAmcXPHW4mTkFIh3CvF

View file

@ -4,7 +4,8 @@ VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM
VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd
VITE_STREAM_CHAT_API_KEY="t5vvvddteapa"
VITE_CHAT_WS_URL=wss://chat.xtablo.com
VITE_CHAT_API_URL=https://chat.xtablo.com
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SPKLPAto3YQ7YhIrM5ViAUXWuSwKJeHyOyOINVg9cnwxxOcbMlyhxQcDYWDSLNQJukafxbc7kqpkGI82lFezaiM00rgcALKB0
VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SPr3qAto3YQ7YhIALNeFBva

View file

@ -99,8 +99,10 @@
"@tanstack/react-query": "^5.69.0",
"@types/react-router-dom": "^5.3.3",
"@typescript/native-preview": "7.0.0-dev.20251010.1",
"@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",
@ -119,8 +121,6 @@
"react-router-dom": "^7.9.4",
"react-stately": "^3.36.1",
"sonner": "^2.0.7",
"stream-chat": "^9.6.1",
"stream-chat-react": "^13.1.0",
"ts-pattern": "^5.6.2",
"uuid": "^11.1.0",
"zod": "^4.1.12",

View file

@ -1,105 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { Channel } from "stream-chat";
import { describe, expect, it, vi } from "vitest";
import { ChannelPreview } from "./ChannelPreview";
// Mock ChannelBadge
vi.mock("./ChannelBadge", () => ({
ChannelBadge: ({ displayTitle, isOnline }: { displayTitle?: string; isOnline: boolean }) => (
<div data-testid="channel-badge">
{displayTitle}-{isOnline ? "online" : "offline"}
</div>
),
}));
describe("ChannelPreview", () => {
const mockChannel = {
id: "channel-1",
data: {
created_at: new Date("2024-01-01").toISOString(),
config: {
name: "Test Channel",
},
},
state: {
members: {},
},
} as unknown as Channel;
const mockTablo = {
id: "tablo-1",
name: "Test Tablo",
color: "bg-blue-500",
user_id: "user-id",
access_level: "admin",
is_admin: true,
created_at: "2024-01-01T00:00:00Z",
deleted_at: "2024-01-01T00:00:00Z",
position: 0,
status: "active",
image: null,
};
const defaultProps = {
channel: mockChannel,
tablo: mockTablo,
displayTitle: "Test Channel",
};
it("renders without crashing", () => {
render(<ChannelPreview {...defaultProps} />);
expect(screen.getByText("Test Channel")).toBeInTheDocument();
});
it("displays channel title", () => {
render(<ChannelPreview {...defaultProps} />);
expect(screen.getByText("Test Channel")).toBeInTheDocument();
});
it("renders ChannelBadge component", () => {
render(<ChannelPreview {...defaultProps} />);
expect(screen.getByTestId("channel-badge")).toBeInTheDocument();
});
it("shows unread count badge when unreadCount > 0", () => {
render(<ChannelPreview {...defaultProps} unreadCount={5} />);
expect(screen.getByText("5")).toBeInTheDocument();
});
it("shows 99+ for unread counts over 99", () => {
render(<ChannelPreview {...defaultProps} unreadCount={150} />);
expect(screen.getByText("99+")).toBeInTheDocument();
});
it("does not show unread badge when count is 0", () => {
const { container } = render(<ChannelPreview {...defaultProps} unreadCount={0} />);
expect(container.querySelector(".min-w-\\[20px\\]")).not.toBeInTheDocument();
});
it("calls setActiveChannel when clicked", () => {
const setActiveChannel = vi.fn();
render(<ChannelPreview {...defaultProps} setActiveChannel={setActiveChannel} />);
fireEvent.click(screen.getByText("Test Channel"));
expect(setActiveChannel).toHaveBeenCalledWith(mockChannel);
});
it("highlights active channel", () => {
const { container } = render(<ChannelPreview {...defaultProps} activeChannel={mockChannel} />);
expect(container.querySelector(".bg-purple-50")).toBeInTheDocument();
});
it("displays latest message preview", () => {
render(<ChannelPreview {...defaultProps} latestMessagePreview="Hello world" />);
expect(screen.getByText("Hello world")).toBeInTheDocument();
});
it("applies custom className", () => {
const { container } = render(<ChannelPreview {...defaultProps} className="custom-class" />);
expect(container.querySelector(".custom-class")).toBeInTheDocument();
});
it("shows active indicator for active channel", () => {
const { container } = render(<ChannelPreview {...defaultProps} activeChannel={mockChannel} />);
expect(container.querySelector(".absolute.left-0.top-0.bottom-0.w-1")).toBeInTheDocument();
});
});

View file

@ -1,138 +0,0 @@
import { ChannelBadge } from "@ui/components/ChannelBadge";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Badge } from "@xtablo/ui/components/badge";
import { ReactNode } from "react";
import { Channel } from "stream-chat";
import { twMerge } from "tailwind-merge";
interface ChannelPreviewProps {
channel: Channel;
tablo: UserTablo | null;
displayTitle: string | undefined;
activeChannel?: Channel;
setActiveChannel?: (channel: Channel) => void;
unreadCount?: number;
latestMessagePreview?: ReactNode;
className?: string;
}
function formatTimestamp(timestamp: string | Date): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "now";
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
if (days < 7) return `${days}d`;
return date.toLocaleDateString();
}
// function getLastMessagePreview(lastMessage?: StreamMessage): string {
// if (!lastMessage) return "No messages yet";
// if (lastMessage.deleted_at) return "Message deleted";
// if (lastMessage.text) {
// return lastMessage.text.length > 50
// ? lastMessage.text.substring(0, 50) + "..."
// : lastMessage.text;
// }
// if (lastMessage.attachments?.length && lastMessage.attachments.length > 0) {
// const attachment = lastMessage.attachments[0];
// if (attachment.type === "image") return "📷 Image";
// if (attachment.type === "video") return "🎥 Video";
// if (attachment.type === "file") return "📎 File";
// }
// return "Message";
// }
function isUserOnline(channel: Channel): boolean {
const members = Object.values(channel.state?.members || {});
const otherMembers = members.filter(
// @ts-expect-error TODO: fix this
(member) => member.user?.id !== channel.data?.config?.created_by?.id
);
return otherMembers.some((member) => member.user?.online);
}
export function ChannelPreview({
displayTitle,
channel,
tablo,
activeChannel,
setActiveChannel,
unreadCount = 0,
latestMessagePreview,
className,
}: ChannelPreviewProps) {
const isActive = activeChannel?.id === channel.id;
const isOnline = isUserOnline(channel);
const timestamp = channel.data?.created_at;
const handleClick = () => {
setActiveChannel?.(channel);
};
return (
<div
className={twMerge(
"group relative flex items-center gap-3 p-3 cursor-pointer transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800",
isActive && "bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800",
className
)}
onClick={handleClick}
>
<ChannelBadge tablo={tablo} displayTitle={displayTitle} isOnline={isOnline} />
{/* Channel info */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h3
className={twMerge(
"font-medium text-gray-900 dark:text-gray-100 truncate",
isActive && "text-[#804EEC] dark:text-purple-400"
)}
>
{displayTitle}
</h3>
{timestamp && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2 shrink-0">
{formatTimestamp(timestamp)}
</span>
)}
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 max-h-10 overflow-hidden">
{latestMessagePreview}
</p>
{/* Unread count badge */}
{unreadCount > 0 && (
<div className="ml-2 shrink-0">
<Badge
color="indigo"
className="text-xs min-w-[20px] h-5 px-2 py-0 flex items-center justify-center"
>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
</div>
)}
</div>
</div>
{/* Active indicator */}
{isActive && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#804EEC] dark:bg-purple-400 rounded-r-full" />
)}
</div>
);
}

View file

@ -0,0 +1,90 @@
import { ChannelBadge } from "@ui/components/ChannelBadge";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Badge } from "@xtablo/ui/components/badge";
import { twMerge } from "tailwind-merge";
interface ChatChannelPreviewProps {
tablo: UserTablo;
isActive: boolean;
onClick: () => void;
unreadCount: number;
lastMessage?: string;
lastMessageTime?: string;
isOnline: boolean;
}
function formatTimestamp(timestamp: string | Date): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "now";
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
if (days < 7) return `${days}d`;
return date.toLocaleDateString();
}
export function ChatChannelPreview({
tablo,
isActive,
onClick,
unreadCount,
lastMessage,
lastMessageTime,
isOnline,
}: ChatChannelPreviewProps) {
return (
<div
className={twMerge(
"group relative flex items-center gap-3 p-3 cursor-pointer transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800",
isActive && "bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800"
)}
onClick={onClick}
>
<ChannelBadge tablo={tablo} displayTitle={tablo.name} isOnline={isOnline} />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h3
className={twMerge(
"font-medium text-gray-900 dark:text-gray-100 truncate",
isActive && "text-[#804EEC] dark:text-purple-400"
)}
>
{tablo.name}
</h3>
{lastMessageTime && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2 shrink-0">
{formatTimestamp(lastMessageTime)}
</span>
)}
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 max-h-10 overflow-hidden">
{lastMessage ?? "No messages yet"}
</p>
{unreadCount > 0 && (
<div className="ml-2 shrink-0">
<Badge
color="indigo"
className="text-xs min-w-[20px] h-5 px-2 py-0 flex items-center justify-center"
>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
</div>
)}
</div>
</div>
{isActive && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#804EEC] dark:bg-purple-400 rounded-r-full" />
)}
</div>
);
}

View file

@ -0,0 +1,52 @@
import { ChannelBadge } from "@ui/components/ChannelBadge";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
interface ChatHeaderProps {
tablo: UserTablo | null;
onToggleChannelList?: () => void;
isChannelListExpanded?: boolean;
onlineUsers: string[];
}
export function ChatHeader({
tablo,
onToggleChannelList,
isChannelListExpanded = false,
onlineUsers,
}: ChatHeaderProps) {
const memberCount = onlineUsers.length;
return (
<div className="flex items-center px-4 py-3 border-b border-gray-200 dark:border-gray-600/50 bg-white dark:bg-gray-800/40">
{onToggleChannelList && (
<button
onClick={onToggleChannelList}
className="mr-2 p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Toggle channel list"
>
<svg
className={`w-5 h-5 transition-transform duration-200 ${
isChannelListExpanded ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{tablo && (
<>
<ChannelBadge tablo={tablo} displayTitle={tablo.name} isOnline={memberCount > 0} />
<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>
)}
</div>
</>
)}
</div>
);
}

View file

@ -1,117 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CustomChannelHeader } from "./CustomChannelHeader";
// Mock stream-chat-react
vi.mock("stream-chat-react", () => ({
ChannelHeader: ({ Avatar }: { Avatar?: () => React.ReactElement }) => (
<div data-testid="channel-header">{Avatar && <Avatar />}</div>
),
useChannelStateContext: () => ({
channel: {
id: "test-channel",
data: {
config: {
name: "Test Channel",
},
},
},
}),
}));
// Mock ChannelBadge
vi.mock("./ChannelBadge", () => ({
ChannelBadge: ({ displayTitle }: { displayTitle?: string }) => (
<div data-testid="channel-badge">{displayTitle}</div>
),
}));
describe("CustomChannelHeader", () => {
const mockTablos = [
{
id: "test-channel",
name: "Test Tablo",
color: "bg-blue-500",
user_id: "user-id",
access_level: "admin",
is_admin: true,
created_at: "2024-01-01T00:00:00Z",
deleted_at: "2024-01-01T00:00:00Z",
position: 0,
status: "active",
image: null,
},
];
it("renders without crashing", () => {
render(<CustomChannelHeader tablos={mockTablos} />);
expect(screen.getByTestId("channel-header")).toBeInTheDocument();
});
it("renders ChannelHeader component", () => {
render(<CustomChannelHeader tablos={mockTablos} />);
expect(screen.getByTestId("channel-header")).toBeInTheDocument();
});
it("shows toggle button when showToggleButton is true", () => {
render(
<CustomChannelHeader
tablos={mockTablos}
showToggleButton={true}
onToggleChannelList={vi.fn()}
/>
);
const toggleButton = screen.getByLabelText("Toggle channel list");
expect(toggleButton).toBeInTheDocument();
});
it("hides toggle button when showToggleButton is false", () => {
render(
<CustomChannelHeader
tablos={mockTablos}
showToggleButton={false}
onToggleChannelList={vi.fn()}
/>
);
const toggleButton = screen.queryByLabelText("Toggle channel list");
expect(toggleButton).not.toBeInTheDocument();
});
it("calls onToggleChannelList when toggle button is clicked", () => {
const onToggleChannelList = vi.fn();
render(
<CustomChannelHeader
tablos={mockTablos}
onToggleChannelList={onToggleChannelList}
showToggleButton={true}
/>
);
const toggleButton = screen.getByLabelText("Toggle channel list");
fireEvent.click(toggleButton);
expect(onToggleChannelList).toHaveBeenCalled();
});
it("applies rotation class when isChannelListExpanded is true", () => {
const { container } = render(
<CustomChannelHeader
tablos={mockTablos}
onToggleChannelList={vi.fn()}
isChannelListExpanded={true}
showToggleButton={true}
/>
);
const svg = container.querySelector(".rotate-180");
expect(svg).toBeInTheDocument();
});
it("renders without toggle button when onToggleChannelList is not provided", () => {
render(<CustomChannelHeader tablos={mockTablos} />);
const toggleButton = screen.queryByLabelText("Toggle channel list");
expect(toggleButton).not.toBeInTheDocument();
});
it("renders ChannelBadge with correct props", () => {
render(<CustomChannelHeader tablos={mockTablos} />);
expect(screen.getByTestId("channel-badge")).toBeInTheDocument();
});
});

View file

@ -1,53 +0,0 @@
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { ChannelHeader, useChannelStateContext } from "stream-chat-react";
import { ChannelBadge } from "./ChannelBadge";
interface CustomChannelHeaderProps {
tablos: UserTablo[];
onToggleChannelList?: () => void;
isChannelListExpanded?: boolean;
showToggleButton?: boolean;
}
export const CustomChannelHeader = ({
tablos,
onToggleChannelList,
isChannelListExpanded = false,
showToggleButton = true,
}: CustomChannelHeaderProps) => {
const { channel } = useChannelStateContext();
return (
<div className="flex items-center">
{showToggleButton && onToggleChannelList && (
<button
onClick={onToggleChannelList}
className="mr-2 p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Toggle channel list"
>
<svg
className={`w-5 h-5 transition-transform duration-200 ${
isChannelListExpanded ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
<ChannelHeader
Avatar={() => {
return (
<ChannelBadge
tablo={tablos?.find((t) => t.id === channel?.id) ?? null}
displayTitle={channel?.data?.config?.name}
isOnline={false}
/>
);
}}
/>
</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

@ -163,6 +163,12 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
)}
>
<Avatar className="size-7">
{organizationData?.organization?.logo_url && (
<AvatarImage
src={`https://assets.xtablo.com/org-icons/${organizationData.organization.id}/icon-192.png`}
alt={organizationData.organization.name ?? "Organization"}
/>
)}
<AvatarFallback>
{organizationData?.organization?.name?.charAt(0).toUpperCase() ?? "O"}
</AvatarFallback>
@ -295,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)]"
@ -346,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

@ -32,11 +32,11 @@ export function PlanAnnouncement() {
const { active_subscription_plan } = organizationData;
if (!active_subscription_plan) return;
const lastAnnouncedPlan = sessionStorage.getItem(PLAN_ANNOUNCED_KEY);
const lastAnnouncedPlan = localStorage.getItem(PLAN_ANNOUNCED_KEY);
if (lastAnnouncedPlan === active_subscription_plan) return;
hasAnnounced.current = true;
sessionStorage.setItem(PLAN_ANNOUNCED_KEY, active_subscription_plan);
localStorage.setItem(PLAN_ANNOUNCED_KEY, active_subscription_plan);
const label = PLAN_LABELS[active_subscription_plan];
if (!label) return;

View file

@ -80,11 +80,11 @@ describe("ProtectedRoute", () => {
name: "Test User",
email: "test@example.com",
avatar_url: "https://example.com/avatar.jpg",
streamToken: null,
short_user_id: "123",
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

@ -0,0 +1,202 @@
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 { SubscriptionCard } from "./SubscriptionCard";
vi.mock("../hooks/organization", () => ({
useOrganization: vi.fn(),
}));
vi.mock("../hooks/stripe", async (importOriginal) => {
const actual = await importOriginal<typeof import("../hooks/stripe")>();
return {
...actual,
useSubscription: vi.fn(),
useCreateCheckoutSession: () => ({ mutate: vi.fn(), isPending: false }),
useCreatePortalSession: () => ({ mutate: vi.fn(), isPending: false }),
useCancelSubscription: () => ({ mutate: vi.fn(), isPending: false }),
useReactivateSubscription: () => ({ mutate: vi.fn(), isPending: false }),
};
});
vi.mock("../hooks/auth", () => ({
useAuthedApi: () => ({}),
}));
import { useOrganization } from "../hooks/organization";
import { useSubscription } from "../hooks/stripe";
const mockUseOrganization = vi.mocked(useOrganization);
const mockUseSubscription = vi.mocked(useSubscription);
const baseUser: User = {
id: "user-1",
short_user_id: "u1",
name: "Test User",
first_name: "Test",
last_name: "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(),
};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function renderCard(
user: User,
orgData: ReturnType<typeof useOrganization>["data"],
subscription: ReturnType<typeof useSubscription>["data"] = undefined
) {
mockUseOrganization.mockReturnValue({
data: orgData,
isLoading: false,
error: null,
} as ReturnType<typeof useOrganization>);
mockUseSubscription.mockReturnValue({
data: subscription,
isLoading: false,
error: null,
} as ReturnType<typeof useSubscription>);
return render(
<QueryClientProvider client={queryClient}>
<TestUserStoreProvider user={user}>
<SubscriptionCard />
</TestUserStoreProvider>
</QueryClientProvider>
);
}
const baseOrg = {
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",
trial_ends_at: "2026-02-01",
is_trial_expired: false,
required_plan: "solo" as const,
required_team_quantity: 1,
active_subscription_plan: null,
active_subscription_quantity: 0,
is_billing_owner: true,
};
describe("SubscriptionCard", () => {
it("shows 'Sans abonnement' badge when there is no subscription", () => {
renderCard(baseUser, baseOrg);
expect(screen.getByText("Sans abonnement")).toBeInTheDocument();
});
it("shows Founder badge and unlimited info for annual plan", () => {
const founderOrg = { ...baseOrg, active_subscription_plan: "annual" as const };
const founderUser = { ...baseUser, plan: "standard" as const };
renderCard(founderUser, founderOrg);
expect(screen.getByText("Founder")).toBeInTheDocument();
expect(screen.getByText(/Plan Founder \(annuel\)/)).toBeInTheDocument();
});
it("shows recommended plan for non-paying billing owner", () => {
renderCard(baseUser, baseOrg);
expect(screen.getByText(/Plan recommandé: Solo/)).toBeInTheDocument();
expect(screen.getByText(/Passer au plan Solo/)).toBeInTheDocument();
expect(screen.getByText("Devenir Founder (99€/an)")).toBeInTheDocument();
});
it("shows team as recommended plan when required", () => {
const teamOrg = {
...baseOrg,
required_plan: "team" as const,
required_team_quantity: 3,
};
renderCard(baseUser, teamOrg);
expect(screen.getByText(/Plan recommandé: Teams/)).toBeInTheDocument();
expect(screen.getByText(/3 sièges Teams/)).toBeInTheDocument();
});
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.queryByText(/Passer au plan/)).not.toBeInTheDocument();
});
it("shows active subscription details with manage and cancel buttons", () => {
const teamOrg = {
...baseOrg,
active_subscription_plan: "team" as const,
active_subscription_quantity: 3,
};
const activeSubscription = {
id: "sub_1",
status: "active",
current_period_end: Math.floor(Date.now() / 1000) + 86400 * 30,
cancel_at_period_end: false,
};
renderCard(baseUser, teamOrg, activeSubscription as any);
expect(screen.getByText("Actif")).toBeInTheDocument();
expect(screen.getByText("Gérer l'abonnement")).toBeInTheDocument();
expect(screen.getByText("Annuler")).toBeInTheDocument();
});
it("shows trialing badge for trialing subscription", () => {
const teamOrg = {
...baseOrg,
active_subscription_plan: "team" as const,
};
const trialingSubscription = {
id: "sub_1",
status: "trialing",
current_period_end: Math.floor(Date.now() / 1000) + 86400 * 14,
cancel_at_period_end: false,
};
renderCard(baseUser, teamOrg, trialingSubscription as any);
expect(screen.getByText("Période d'essai")).toBeInTheDocument();
});
it("shows past_due badge for overdue subscription", () => {
const teamOrg = {
...baseOrg,
active_subscription_plan: "team" as const,
};
const pastDueSubscription = {
id: "sub_1",
status: "past_due",
current_period_end: Math.floor(Date.now() / 1000) - 86400,
cancel_at_period_end: false,
};
renderCard(baseUser, teamOrg, pastDueSubscription as any);
expect(screen.getByText("Paiement en retard")).toBeInTheDocument();
});
it("shows reactivation UI for canceled subscription", () => {
const teamOrg = {
...baseOrg,
active_subscription_plan: "team" as const,
active_subscription_quantity: 2,
};
const canceledSubscription = {
id: "sub_1",
status: "active",
current_period_end: Math.floor(Date.now() / 1000) + 86400 * 15,
cancel_at_period_end: true,
};
renderCard(baseUser, teamOrg, canceledSubscription as any);
expect(screen.getByText(/Abonnement en cours d'annulation/)).toBeInTheDocument();
expect(screen.getByText("Réactiver l'abonnement")).toBeInTheDocument();
});
});

View file

@ -1,34 +1,21 @@
import { TabloDiscussionSection } from "@xtablo/tablo-views";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { TabloDiscussionSection } from "./TabloDiscussionSection";
// Mock Stream Chat
vi.mock("stream-chat-react", () => ({
Chat: ({ children }: { children: React.ReactNode }) => <div data-testid="chat">{children}</div>,
Channel: ({ children }: { children: React.ReactNode }) => (
<div data-testid="channel">{children}</div>
),
Window: ({ children }: { children: React.ReactNode }) => (
<div data-testid="window">{children}</div>
),
MessageList: () => <div data-testid="message-list">Messages</div>,
MessageInput: () => <div data-testid="message-input">Input</div>,
useChannelStateContext: () => ({ channel: null }),
useCreateChatClient: () => null,
useChatContext: () => ({
client: null,
setActiveChannel: vi.fn(),
vi.mock("@xtablo/tablo-views/hooks/useChat", () => ({
useChat: () => ({
messages: [],
sendMessage: vi.fn(),
sendTyping: vi.fn(),
isConnected: false,
typingUsers: [],
onlineUsers: [],
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
markAsRead: vi.fn(),
}),
}));
vi.mock("../providers/ChatProvider", () => ({
useChatContext: () => ({
client: null,
setActiveChannel: vi.fn(),
}),
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
describe("TabloDiscussionSection", () => {
const mockTablo = {
id: "test-tablo-id",
@ -46,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,69 +0,0 @@
import { CustomChannelHeader } from "@ui/components/CustomChannelHeader";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { useEffect, useState } from "react";
import { Channel as StreamChannel } from "stream-chat";
import { Channel, MessageInput, MessageList, useChatContext, Window } from "stream-chat-react";
import ChatProvider from "../providers/ChatProvider";
import { LoadingSpinner } from "./LoadingSpinner";
import { TabloHeaderActions } from "./TabloHeaderActions";
interface TabloDiscussionSectionProps {
tablo: UserTablo;
isAdmin: boolean;
}
const TabloChat = ({ tablo }: { tablo: UserTablo }) => {
const { client, setActiveChannel } = useChatContext();
const [channel, setChannel] = useState<StreamChannel | null>(null);
useEffect(() => {
const initChannel = async () => {
if (client && tablo.id) {
const newChannel = client.channel("messaging", tablo.id);
await newChannel.watch();
setChannel(newChannel);
setActiveChannel(newChannel);
}
};
initChannel();
}, [client, tablo.id, setActiveChannel]);
if (!channel) {
return (
<div className="flex items-center justify-center h-96">
<LoadingSpinner />
</div>
);
}
return (
<Channel channel={channel}>
<Window>
<CustomChannelHeader tablos={[tablo]} showToggleButton={false} />
<MessageList />
<MessageInput />
</Window>
</Channel>
);
};
export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectionProps) => {
return (
<div className="flex flex-col h-full">
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Discussion</h1>
<p className="text-muted-foreground mt-1">Conversations liées à ce tablo</p>
</div>
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
</div>
<div className="flex-1 bg-card rounded-lg border border-border overflow-hidden min-h-0">
<ChatProvider>
<TabloChat tablo={tablo} />
</ChatProvider>
</div>
</div>
);
};

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

@ -0,0 +1,147 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UpgradeBlockProvider } from "../contexts/UpgradeBlockContext";
import { TestUserStoreProvider, type User } from "../providers/UserStoreProvider";
import { UpgradePanel } from "./UpgradePanel";
vi.mock("../hooks/organization", () => ({
useOrganization: vi.fn(),
}));
vi.mock("../hooks/stripe", async (importOriginal) => {
const actual = await importOriginal<typeof import("../hooks/stripe")>();
return {
...actual,
useCreateCheckoutSession: () => ({ mutate: vi.fn(), isPending: false }),
};
});
vi.mock("../hooks/auth", () => ({
useLogout: () => ({ mutate: vi.fn() }),
useAuthedApi: () => ({}),
}));
import { useOrganization } from "../hooks/organization";
const mockUseOrganization = vi.mocked(useOrganization);
const baseUser: User = {
id: "user-1",
short_user_id: "u1",
name: "Test User",
first_name: "Test",
last_name: "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(),
};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function renderPanel(user: User, orgData: ReturnType<typeof useOrganization>["data"]) {
mockUseOrganization.mockReturnValue({
data: orgData,
isLoading: false,
error: null,
} as ReturnType<typeof useOrganization>);
return render(
<QueryClientProvider client={queryClient}>
<TestUserStoreProvider user={user}>
<UpgradeBlockProvider>
<UpgradePanel />
</UpgradeBlockProvider>
</TestUserStoreProvider>
</QueryClientProvider>
);
}
const noPlanOrg = {
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",
trial_ends_at: "2026-02-01",
is_trial_expired: false,
required_plan: "solo" as const,
required_team_quantity: 1,
active_subscription_plan: null,
active_subscription_quantity: 0,
is_billing_owner: true,
};
const trialExpiredOrg = {
...noPlanOrg,
is_trial_expired: true,
required_plan: "team" as const,
required_team_quantity: 3,
active_subscription_plan: "team" as const,
active_subscription_quantity: 1,
is_billing_owner: true,
};
const compliantOrg = {
...noPlanOrg,
active_subscription_plan: "team" as const,
active_subscription_quantity: 2,
is_billing_owner: true,
};
describe("UpgradePanel", () => {
it("renders nothing when user has a compliant subscription", () => {
const { container } = renderPanel(baseUser, compliantOrg);
expect(container.innerHTML).toBe("");
});
it("renders nothing for temporary users even with no plan", () => {
const tempUser = { ...baseUser, is_temporary: true };
const { container } = renderPanel(tempUser, noPlanOrg);
expect(container.innerHTML).toBe("");
});
it("renders the paywall for regular users with no plan", () => {
renderPanel(baseUser, noPlanOrg);
expect(screen.getByText("Choisissez un abonnement pour continuer")).toBeInTheDocument();
});
it("renders trial expired message when trial is expired", () => {
renderPanel(baseUser, trialExpiredOrg);
expect(screen.getByText("Votre période d'essai est terminée")).toBeInTheDocument();
});
it("shows checkout buttons for billing owner", () => {
renderPanel(baseUser, noPlanOrg);
expect(screen.getByText("Passer au plan Solo")).toBeInTheDocument();
expect(screen.getByText(/Passer au plan Teams/)).toBeInTheDocument();
expect(screen.getByText("Devenir Founder (99€/an)")).toBeInTheDocument();
});
it("disables buttons for non-billing-owner and shows warning", () => {
const nonOwnerOrg = { ...noPlanOrg, is_billing_owner: false };
renderPanel(baseUser, nonOwnerOrg);
const soloButton = screen.getByText("Passer au plan Solo").closest("button");
expect(soloButton).toBeDisabled();
expect(screen.getByText(/Seul le propriétaire de facturation/)).toBeInTheDocument();
});
it("renders nothing when org data is not yet loaded", () => {
const { container } = renderPanel(baseUser, undefined);
expect(container.innerHTML).toBe("");
});
});

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

@ -0,0 +1,185 @@
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";
// Mock the organization hook
vi.mock("../hooks/organization", () => ({
useOrganization: vi.fn(),
}));
import { useOrganization } from "../hooks/organization";
const mockUseOrganization = vi.mocked(useOrganization);
const baseUser: User = {
id: "user-1",
short_user_id: "u1",
name: "Test User",
first_name: "Test",
last_name: "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(),
};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TestConsumer() {
const context = useUpgradeBlock();
return (
<div>
<span data-testid="blocked">{String(context.isBlocked)}</span>
<span data-testid="reason">{context.reason ?? "none"}</span>
</div>
);
}
function MaybeTestConsumer() {
const context = useMaybeUpgradeBlock();
return (
<div>
<span data-testid="has-context">{String(context !== null)}</span>
</div>
);
}
function renderWithUser(user: User | null, orgData: ReturnType<typeof useOrganization>["data"]) {
mockUseOrganization.mockReturnValue({
data: orgData,
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useOrganization>);
return render(
<QueryClientProvider client={queryClient}>
<TestUserStoreProvider user={user}>
<UpgradeBlockProvider>
<TestConsumer />
</UpgradeBlockProvider>
</TestUserStoreProvider>
</QueryClientProvider>
);
}
const compliantOrgData = {
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",
trial_ends_at: "2026-02-01",
is_trial_expired: false,
required_plan: "solo" as const,
required_team_quantity: 1,
active_subscription_plan: "team" as const,
active_subscription_quantity: 2,
is_billing_owner: true,
};
const noPlanOrgData = {
...compliantOrgData,
active_subscription_plan: null,
active_subscription_quantity: 0,
};
const trialExpiredOrgData = {
...compliantOrgData,
is_trial_expired: true,
required_plan: "team" as const,
required_team_quantity: 5,
active_subscription_plan: "team" as const,
active_subscription_quantity: 2,
};
describe("UpgradeBlockProvider", () => {
it("is not blocked when user and org are loading (null)", () => {
renderWithUser(null, undefined);
expect(screen.getByTestId("blocked").textContent).toBe("false");
expect(screen.getByTestId("reason").textContent).toBe("none");
});
it("is not blocked when user is loaded but org data is still loading", () => {
renderWithUser(baseUser, undefined);
expect(screen.getByTestId("blocked").textContent).toBe("false");
});
it("is not blocked for temporary users regardless of org billing state", () => {
const temporaryUser = { ...baseUser, is_temporary: true };
renderWithUser(temporaryUser, noPlanOrgData);
expect(screen.getByTestId("blocked").textContent).toBe("false");
expect(screen.getByTestId("reason").textContent).toBe("none");
});
it("is not blocked for temporary users even with expired trial", () => {
const temporaryUser = { ...baseUser, is_temporary: true };
renderWithUser(temporaryUser, trialExpiredOrgData);
expect(screen.getByTestId("blocked").textContent).toBe("false");
});
it("blocks regular users when org has no active plan", () => {
renderWithUser(baseUser, noPlanOrgData);
expect(screen.getByTestId("blocked").textContent).toBe("true");
expect(screen.getByTestId("reason").textContent).toBe("no_plan");
});
it("blocks regular users when trial expired and plan is insufficient", () => {
renderWithUser(baseUser, trialExpiredOrgData);
expect(screen.getByTestId("blocked").textContent).toBe("true");
expect(screen.getByTestId("reason").textContent).toBe("trial_expired");
});
it("is not blocked for regular users with a compliant subscription", () => {
renderWithUser(baseUser, compliantOrgData);
expect(screen.getByTestId("blocked").textContent).toBe("false");
expect(screen.getByTestId("reason").textContent).toBe("none");
});
it("is not blocked with an annual plan even if team quantity is insufficient", () => {
const annualOrgData = {
...trialExpiredOrgData,
active_subscription_plan: "annual" as const,
};
renderWithUser(baseUser, annualOrgData);
expect(screen.getByTestId("blocked").textContent).toBe("false");
});
});
describe("useMaybeUpgradeBlock", () => {
it("returns null when outside provider", () => {
render(<MaybeTestConsumer />);
expect(screen.getByTestId("has-context").textContent).toBe("false");
});
it("returns context when inside provider", () => {
mockUseOrganization.mockReturnValue({
data: compliantOrgData,
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useOrganization>);
render(
<QueryClientProvider client={queryClient}>
<TestUserStoreProvider user={baseUser}>
<UpgradeBlockProvider>
<MaybeTestConsumer />
</UpgradeBlockProvider>
</TestUserStoreProvider>
</QueryClientProvider>
);
expect(screen.getByTestId("has-context").textContent).toBe("true");
});
});

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

@ -1,13 +1,11 @@
import { Session, User as SupabaseUser } from "@supabase/supabase-js";
import { useMutation } from "@tanstack/react-query";
import { queryClient, toast, useSession } from "@xtablo/shared";
import { useSignUpToStream } from "@xtablo/shared/hooks/auth";
import { AxiosInstance } from "axios";
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,
@ -15,6 +13,7 @@ import {
SignupBillingIntent,
} from "../lib/billing";
import { supabase } from "../lib/supabase";
import { clearOrgIdCookie } from "./organization";
export type User = SupabaseUser & {
user_metadata: {
@ -53,7 +52,6 @@ export const resolveSignupBillingIntent = (
export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
const navigate = useNavigate();
const [errors, setErrors] = useState<Record<string, string>>({});
const { signUpToStream } = useSignUpToStream(api);
const { mutate, isPending } = useMutation<
AuthResponse,
{ message: string; code: string },
@ -74,11 +72,6 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
},
});
if (error) throw error;
// Only sign up to stream if user is immediately confirmed (auto-confirm enabled in Supabase)
if (response.session?.access_token) {
await signUpToStream(response.session.access_token);
}
return response;
},
onSuccess: async (data, variables) => {
@ -168,7 +161,6 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
export function useLoginEmail({ redirectUrl }: { redirectUrl: string | null }) {
const navigate = useNavigate();
const [errors, setErrors] = useState<Record<string, string>>({});
const { signUpToStream } = useSignUpToStream(api);
const { mutate, isPending } = useMutation<
AuthResponse,
{ message: string; code: string },
@ -180,9 +172,6 @@ export function useLoginEmail({ redirectUrl }: { redirectUrl: string | null }) {
password: data.password.trim(),
});
if (error) throw error;
if (response.session?.access_token) {
await signUpToStream(response.session.access_token);
}
return response;
},
onSuccess: (data) => {
@ -270,11 +259,7 @@ export function useLogout() {
queryClient.removeQueries();
},
onSuccess: () => {
toast.add({
title: "Déconnexion réussie",
description: "Vous avez été déconnecté avec succès",
type: "success",
});
window.location.href = "/login";
},
onError: (error) => {
toast.add({

View file

@ -1,109 +0,0 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Channel, StreamChat } from "stream-chat";
import { useUser } from "../providers/UserStoreProvider";
export const useChannelFromUrl = (client: StreamChat) => {
const [channel, setChannel] = useState<Channel | null>(null);
const { channelId } = useParams();
useEffect(() => {
if (channelId) {
const channel = client.channel("messaging", channelId);
channel.watch();
setChannel(channel);
} else {
setChannel(null);
}
}, [channelId, client]);
return { channel, isChannelInUrl: !!channelId };
};
export const useTabloDiscussionUnread = (tabloId?: string) => {
const user = useUser();
const [hasUnread, setHasUnread] = useState(false);
useEffect(() => {
if (!tabloId || !user.id || !user.streamToken) {
setHasUnread(false);
return;
}
const apiKey = import.meta.env.VITE_STREAM_CHAT_API_KEY as string;
const client = StreamChat.getInstance(apiKey);
let isMounted = true;
let unsubscribe: (() => void) | undefined;
const syncUnread = (channel: Channel) => {
if (!isMounted) return;
setHasUnread(channel.countUnread() > 0);
};
const init = async () => {
try {
if (!client.userID) {
await client.connectUser(
{
id: user.id,
name: user.name ?? "",
},
user.streamToken
);
} else if (client.userID !== user.id) {
await client.disconnectUser();
await client.connectUser(
{
id: user.id,
name: user.name ?? "",
},
user.streamToken
);
}
const channels = await client.queryChannels(
{
type: "messaging",
id: { $eq: tabloId },
members: { $in: [user.id] },
},
{ last_message_at: -1 },
{ watch: true, state: true, presence: false, limit: 1 }
);
const channel = channels[0];
if (!channel) {
setHasUnread(false);
return;
}
syncUnread(channel);
const subscriptions = [
channel.on("message.new", () => syncUnread(channel)),
channel.on("message.read", () => syncUnread(channel)),
channel.on("notification.mark_read", () => syncUnread(channel)),
channel.on("notification.message_new", () => syncUnread(channel)),
];
unsubscribe = () => {
subscriptions.forEach((subscription) => {
subscription.unsubscribe();
});
};
} catch (error) {
console.error("Error loading tablo unread discussion state:", error);
if (isMounted) {
setHasUnread(false);
}
}
};
void init();
return () => {
isMounted = false;
unsubscribe?.();
};
}, [tabloId, user.id, user.name, user.streamToken]);
return { hasUnread };
};

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

@ -3,6 +3,7 @@ import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import authEn from "./locales/en/auth.json";
import availabilitiesEn from "./locales/en/availabilities.json";
import chatEn from "./locales/en/chat.json";
import commonEn from "./locales/en/common.json";
import componentsEn from "./locales/en/components.json";
import modalsEn from "./locales/en/modals.json";
@ -15,6 +16,7 @@ import settingsEn from "./locales/en/settings.json";
import tabloEn from "./locales/en/tablo.json";
import authFr from "./locales/fr/auth.json";
import availabilitiesFr from "./locales/fr/availabilities.json";
import chatFr from "./locales/fr/chat.json";
// Import translation files
import commonFr from "./locales/fr/common.json";
import componentsFr from "./locales/fr/components.json";
@ -45,6 +47,7 @@ i18n
notes: notesFr,
tablo: tabloFr,
onboarding: onboardingFr,
chat: chatFr,
},
en: {
common: commonEn,
@ -59,6 +62,7 @@ i18n
notes: notesEn,
tablo: tabloEn,
onboarding: onboardingEn,
chat: chatEn,
},
},
lng: "fr",

View file

@ -28,7 +28,6 @@ import { TabloDetailsPage } from "../pages/tablo-details";
import { TablosPage } from "../pages/tablos";
import { TasksPage } from "../pages/tasks";
import { UpdatePasswordPage } from "../pages/update-password";
import ChatProvider from "../providers/ChatProvider";
export const routes: RouteObject[] = [
// Protected routes
@ -75,11 +74,7 @@ export const routes: RouteObject[] = [
},
{
path: "chat",
element: (
<ChatProvider>
<ChatPage />
</ChatProvider>
),
element: <ChatPage />,
children: [{ index: true }, { path: ":channelId" }],
},
// Notes feature temporarily hidden

View file

@ -0,0 +1,13 @@
{
"pageTitle": "Discussions",
"placeholder": "Type a message...",
"selectConversation": "Select a conversation to start chatting",
"defaultUserName": "User",
"currentUserName": "Me",
"discussionTitle": "Discussion",
"discussionSubtitle": "Conversations related to this tablo",
"typingOne": "{{name}} is typing",
"typingTwo": "{{name1}} and {{name2}} are typing",
"typingMany": "Several people are typing",
"scrollToBottom": "Scroll to bottom"
}

View file

@ -0,0 +1,13 @@
{
"pageTitle": "Discussions",
"placeholder": "Envoyer un message...",
"selectConversation": "Sélectionnez une conversation pour commencer",
"defaultUserName": "Utilisateur",
"currentUserName": "Moi",
"discussionTitle": "Discussion",
"discussionSubtitle": "Conversations liées à ce tablo",
"typingOne": "{{name}} est en train d'écrire",
"typingTwo": "{{name1}} et {{name2}} sont en train d'écrire",
"typingMany": "Plusieurs personnes sont en train d'écrire",
"scrollToBottom": "Aller en bas"
}

View file

@ -1,5 +1,8 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "@xtablo/chat-ui/chat-ui.css";
@source "../../../packages/chat-ui/src/**/*.{ts,tsx}";
@custom-variant dark (&:is(.dark *));
@ -1239,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

@ -7,7 +7,6 @@ import { App } from "./App";
import "./lib/rum";
import "./i18n";
import "stream-chat-react/dist/css/v2/index.css";
import "@xtablo/ui/styles/globals.css";
import "./main.css";

View file

@ -3,12 +3,25 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { ChatPage } from "./chat";
const mockSetActiveChannel = vi.fn();
vi.mock("../hooks/useChat", () => ({
useChat: () => ({
messages: [],
sendMessage: vi.fn(),
sendTyping: vi.fn(),
isConnected: false,
typingUsers: [],
onlineUsers: [],
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
markAsRead: vi.fn(),
}),
}));
vi.mock("../hooks/channel", () => ({
useChannelFromUrl: () => ({
channel: null,
isChannelInUrl: false,
vi.mock("../hooks/useChatUnread", () => ({
useChatUnread: () => ({
unreadCounts: [],
getUnreadCount: () => 0,
hasUnread: () => false,
}),
}));
@ -19,38 +32,7 @@ vi.mock("../hooks/tablos", () => ({
{ id: "tablo-2", name: "Test Tablo 2" },
],
}),
}));
vi.mock("../providers/ChatProvider", () => ({
useChatClient: () => null,
useChatContext: () => ({
client: { id: "test-client" },
channel: null,
setActiveChannel: mockSetActiveChannel,
}),
}));
vi.mock("stream-chat-react", () => ({
Chat: ({ children }: { children: React.ReactNode }) => <div data-testid="chat">{children}</div>,
ChannelList: ({ children }: { children: React.ReactNode }) => (
<div data-testid="channel-list">{children}</div>
),
Channel: ({ children }: { children: React.ReactNode }) => (
<div data-testid="channel">{children}</div>
),
ChannelHeader: () => <div data-testid="channel-header">Header</div>,
MessageList: () => <div data-testid="message-list">Messages</div>,
MessageInput: () => <div data-testid="message-input">Input</div>,
Window: ({ children }: { children: React.ReactNode }) => (
<div data-testid="window">{children}</div>
),
useChannelStateContext: () => ({ channel: null }),
useCreateChatClient: () => null,
useChatContext: () => ({
client: { id: "test-client" },
channel: null,
setActiveChannel: mockSetActiveChannel,
}),
useTabloMembers: () => ({ data: [] }),
}));
describe("ChatPage", () => {
@ -63,49 +45,28 @@ describe("ChatPage", () => {
expect(container).toBeInTheDocument();
});
it("renders channel list", () => {
it("renders the discussions heading", () => {
renderWithProviders(<ChatPage />);
expect(screen.getByTestId("channel-list")).toBeInTheDocument();
expect(screen.getByText("Discussions")).toBeInTheDocument();
});
it("renders channel window", () => {
it("renders channel previews for each tablo", () => {
renderWithProviders(<ChatPage />);
expect(screen.getByTestId("channel")).toBeInTheDocument();
expect(screen.getByTestId("window")).toBeInTheDocument();
expect(screen.getByText("Test Tablo 1")).toBeInTheDocument();
expect(screen.getByText("Test Tablo 2")).toBeInTheDocument();
});
it("renders message list and input", () => {
it("shows empty state when no channel is selected", () => {
renderWithProviders(<ChatPage />);
expect(screen.getByTestId("message-list")).toBeInTheDocument();
expect(screen.getByTestId("message-input")).toBeInTheDocument();
});
it("applies correct filters for channel list", () => {
renderWithProviders(<ChatPage />);
// ChannelList should be rendered with proper filters
expect(screen.getByTestId("channel-list")).toBeInTheDocument();
expect(screen.getByText("Select a conversation to start chatting")).toBeInTheDocument();
});
});
// Note: Testing channel from URL would require re-mocking the hook with different values
// This is better tested with integration tests or by testing the hook separately.
describe("ChatPage - Channel List Toggle", () => {
it("starts with channel list expanded when no channel in URL", () => {
renderWithProviders(<ChatPage />);
const channelListContainer = screen.getByTestId("channel-list").parentElement;
expect(channelListContainer?.className).toContain("w-80");
});
it("has collapsible channel list", () => {
renderWithProviders(<ChatPage />);
const channelListContainer = screen.getByTestId("channel-list").parentElement;
expect(channelListContainer).toBeInTheDocument();
// The sidebar should be visible (w-80)
const heading = screen.getByText("Discussions");
expect(heading).toBeInTheDocument();
});
});

View file

@ -1,86 +1,101 @@
import { ChannelPreview } from "@ui/components/ChannelPreview";
import { CustomChannelHeader } from "@ui/components/CustomChannelHeader";
import { ChatMessages, useChat, useChatUnread } from "@xtablo/tablo-views";
import { useEffect, useState } from "react";
import {
Channel,
ChannelList,
MessageInput,
MessageList,
useChatContext,
Window,
} from "stream-chat-react";
import { useChannelFromUrl } from "../hooks/channel";
import { useTablosList } from "../hooks/tablos";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { ChatChannelPreview } from "../components/ChatChannelPreview";
import { ChatHeader } from "../components/ChatHeader";
import { useTabloMembers, useTablosList } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
export function ChatPage() {
const { t } = useTranslation("chat");
const user = useUser();
const filters = { members: { $in: [user.id] }, type: "messaging" };
const { client, channel, setActiveChannel } = useChatContext();
const { channel: channelFromUrl, isChannelInUrl } = useChannelFromUrl(client);
const { channelId } = useParams();
const navigate = useNavigate();
const { data: tablos } = useTablosList();
const [isChannelListExpanded, setIsChannelListExpanded] = useState(false);
const { getUnreadCount } = useChatUnread();
const [isChannelListExpanded, setIsChannelListExpanded] = useState(!channelId);
const toggleChannelList = () => {
setIsChannelListExpanded(!isChannelListExpanded);
};
const {
messages,
sendMessage,
sendTyping,
typingUsers,
onlineUsers,
loadMoreMessages,
hasMoreMessages,
markAsRead,
} = useChat(channelId);
const { data: members = [] } = useTabloMembers(channelId ?? "");
const activeTablo = tablos?.find((t) => t.id === channelId) ?? null;
// Mark as read when channel is focused
useEffect(() => {
if (channelFromUrl) {
setActiveChannel(channelFromUrl);
} else {
setIsChannelListExpanded(true);
if (channelId && messages.length > 0) {
markAsRead();
}
}, [channelFromUrl]);
}, [channelId, messages.length, markAsRead]);
const handleChannelSelect = (tabloId: string) => {
navigate(`/chat/${tabloId}`);
};
return (
<div className="flex flex-col h-[calc(100vh-75px)] bg-gray-50 dark:bg-background">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/40 shrink-0">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Discussions</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t("pageTitle")}</h1>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Channel list sidebar */}
<div
className={`border-r border-gray-200 dark:border-gray-600/50 bg-white dark:bg-gray-700/40 transition-all duration-300 ease-in-out overflow-hidden ${
isChannelListExpanded ? "w-80" : "w-0"
}`}
>
<ChannelList
filters={filters}
setActiveChannelOnMount={isChannelInUrl ? false : true}
Preview={({
displayTitle,
channel,
activeChannel,
setActiveChannel,
unread,
latestMessagePreview,
}) => (
<ChannelPreview
displayTitle={displayTitle}
channel={channel}
tablo={tablos?.find((t) => t.id === channel.id) ?? null}
activeChannel={activeChannel}
setActiveChannel={setActiveChannel}
unreadCount={unread}
latestMessagePreview={latestMessagePreview}
<div className="overflow-y-auto h-full">
{tablos?.map((tablo) => (
<ChatChannelPreview
key={tablo.id}
tablo={tablo}
isActive={channelId === tablo.id}
onClick={() => handleChannelSelect(tablo.id)}
unreadCount={getUnreadCount(tablo.id)}
isOnline={onlineUsers.some((uid) => uid !== user.id)}
/>
)}
/>
))}
</div>
</div>
<div className="flex-1 bg-white dark:bg-gray-700/40">
<Channel channel={channel}>
<Window>
<CustomChannelHeader
tablos={tablos ?? []}
onToggleChannelList={toggleChannelList}
{/* Chat area */}
<div className="flex-1 flex flex-col bg-white dark:bg-gray-700/40">
{channelId && activeTablo ? (
<>
<ChatHeader
tablo={activeTablo}
onToggleChannelList={() => setIsChannelListExpanded(!isChannelListExpanded)}
isChannelListExpanded={isChannelListExpanded}
onlineUsers={onlineUsers}
/>
<MessageList />
<MessageInput />
</Window>
</Channel>
<div className="flex-1 overflow-hidden">
<ChatMessages
messages={messages}
currentUserId={user.id}
members={members}
typingUsers={typingUsers}
hasMoreMessages={hasMoreMessages}
onLoadMore={loadMoreMessages}
onSend={sendMessage}
onTyping={sendTyping}
placeholder={t("placeholder")}
/>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
{t("selectConversation")}
</div>
)}
</div>
</div>
</div>

View file

@ -2,14 +2,6 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { OAuthSigninPage } from "./oauth-signin";
const mockSignUpToStream = vi.fn();
vi.mock("@xtablo/shared/hooks/auth", () => ({
useSignUpToStream: () => ({
signUpToStream: mockSignUpToStream,
}),
}));
vi.mock("../lib/api", () => ({
api: {},
}));
@ -30,44 +22,6 @@ describe("OAuthSigninPage", () => {
expect(container).toBeInTheDocument();
});
it.skip("renders empty component", () => {
const { container } = renderWithProviders(<OAuthSigninPage />);
expect(container.firstChild).toBeEmptyDOMElement();
});
it.skip("navigates to home when session exists without redirectUrl", () => {
renderWithProviders(<OAuthSigninPage />);
vi.advanceTimersByTime(150);
expect(mockSignUpToStream).toHaveBeenCalled();
});
it.skip("navigates to redirectUrl when session exists with redirectUrl", () => {
localStorage.setItem("redirectUrl", "/dashboard");
renderWithProviders(<OAuthSigninPage />);
vi.advanceTimersByTime(150);
expect(mockSignUpToStream).toHaveBeenCalled();
expect(localStorage.getItem("redirectUrl")).toBeNull();
});
it.skip("decodes redirectUrl before navigation", () => {
localStorage.setItem("redirectUrl", "%2Fdashboard%2Ftest");
renderWithProviders(<OAuthSigninPage />);
vi.advanceTimersByTime(150);
});
it("signs up to stream with access token", () => {
renderWithProviders(<OAuthSigninPage />);
vi.advanceTimersByTime(150);
expect(mockSignUpToStream).toHaveBeenCalled();
});
it("clears interval on unmount", () => {
const { unmount } = renderWithProviders(<OAuthSigninPage />);

View file

@ -1,18 +1,14 @@
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { useSignUpToStream } from "@xtablo/shared/hooks/auth";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../lib/api";
export const OAuthSigninPage = () => {
const navigate = useNavigate();
const { session } = useSession();
const { signUpToStream } = useSignUpToStream(api);
const redirectUrl = localStorage.getItem("redirectUrl");
useEffect(() => {
const interval = setInterval(() => {
if (session) {
signUpToStream(session.access_token);
if (redirectUrl) {
localStorage.removeItem("redirectUrl");
navigate(decodeURIComponent(redirectUrl));
@ -22,6 +18,6 @@ export const OAuthSigninPage = () => {
}
}, 100);
return () => clearInterval(interval);
}, [navigate, session, signUpToStream]);
}, [navigate, session]);
return <></>;
};

View file

@ -25,7 +25,6 @@ import { TypographyH3, TypographyMuted, TypographySmall } from "@xtablo/ui/compo
import { CameraIcon, CookieIcon, Loader2Icon, Trash2Icon, UploadIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@xtablo/ui/components/badge";
import { LanguageSelector } from "../components/LanguageSelector";
import { SubscriptionCard } from "../components/SubscriptionCard";
import { useIntroduction } from "../hooks/intros";
@ -33,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";
@ -243,17 +242,6 @@ export default function SettingsPage() {
<TypographyMuted>{t("settings:subtitle")}</TypographyMuted>
</div>
<div className="flex flex-wrap items-center gap-2 mt-2">
{organizationData?.active_subscription_plan === "annual" && (
<Badge className="bg-linear-to-r from-purple-500 to-blue-500 text-white border-transparent text-xs">
Founder
</Badge>
)}
{organizationData?.active_subscription_plan === "team" && (
<Badge color="indigo">Teams</Badge>
)}
{organizationData?.active_subscription_plan === "solo" && (
<Badge color="zinc">Solo</Badge>
)}
<LanguageSelector />
</div>
</div>
@ -541,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

@ -28,9 +28,11 @@ const layoutData: unknown = {
rightZone: ["files", "info"],
};
vi.mock("../hooks/channel", () => ({
useTabloDiscussionUnread: () => ({
hasUnread: false,
vi.mock("../hooks/useChatUnread", () => ({
useChatUnread: () => ({
unreadCounts: [],
getUnreadCount: () => 0,
hasUnread: () => false,
}),
}));

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 { useTabloDiscussionUnread } from "../hooks/channel";
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";
@ -173,7 +193,8 @@ export const TabloDetailsPage = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { data: tablos, isLoading } = useTablosList();
const { hasUnread: hasUnreadDiscussion } = useTabloDiscussionUnread(tabloId);
const { hasUnread } = useChatUnread();
const hasUnreadDiscussion = tabloId ? hasUnread(tabloId) : false;
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<Date | undefined>(
@ -181,7 +202,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";
@ -195,21 +216,41 @@ 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@]+$/;
return emailRegex.test(email);
};
const handleSendInvite = () => {
if (inviteEmail.trim() && tabloId) {
inviteUser({ email: inviteEmail, tablo_id: tabloId });
setInviteEmail("");
}
};
const filteredMembers = members?.filter(
(member) => !pendingInvites?.some((invite) => invite.invited_email === member.email)
);
@ -379,7 +420,7 @@ export const TabloDetailsPage = () => {
};
return (
<div>
<div className={cn(activeSection === "discussion" && "flex flex-col h-[calc(100vh-75px)]")}>
{/* ── Header ──────────────────────────────────────────────────────── */}
<div className="px-4 pt-4">
<div className="flex flex-col md:flex-row items-start justify-between mb-6 border-b border-[#F2F4F7] dark:border-gray-700 pb-5 gap-5 md:gap-0">
@ -499,7 +540,12 @@ export const TabloDetailsPage = () => {
</div>
{/* ── Tab content ─────────────────────────────────────────────────── */}
<div className="px-4 sm:px-6 pt-6 pb-8">
<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> = {
@ -782,12 +828,94 @@ export const TabloDetailsPage = () => {
);
})()}
{activeSection === "tasks" && <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "discussion" && (
<TabloDiscussionSection 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}
currentUserId={currentUser.id}
members={members}
/>
</div>
)}
{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 === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "etapes" && (
<EtapesSection
@ -795,11 +923,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>
@ -823,82 +963,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>
@ -935,373 +999,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();

Some files were not shown because too many files have changed in this diff Show more