Merge pull request #74 from artslidd/develop
Chat improvements, UI fixes, and auth cleanup
This commit is contained in:
commit
bc07836538
156 changed files with 15667 additions and 2815 deletions
|
|
@ -1,6 +1,5 @@
|
|||
SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
|
||||
|
||||
STREAM_CHAT_API_KEY=h7bwnn8ynjpx
|
||||
|
||||
XTABLO_URL=https://app.xtablo.com
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
386
apps/api/src/__tests__/routes/clientInvites.test.ts
Normal file
386
apps/api/src/__tests__/routes/clientInvites.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
241
apps/api/src/routers/clientInvites.ts
Normal file
241
apps/api/src/routers/clientInvites.ts
Normal 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 été 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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` });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
20
apps/chat-worker/package.json
Normal file
20
apps/chat-worker/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
166
apps/chat-worker/src/durable-objects/ChatRoom.ts
Normal file
166
apps/chat-worker/src/durable-objects/ChatRoom.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
apps/chat-worker/src/index.ts
Normal file
165
apps/chat-worker/src/index.ts
Normal 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;
|
||||
34
apps/chat-worker/src/lib/auth.ts
Normal file
34
apps/chat-worker/src/lib/auth.ts
Normal 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);
|
||||
}
|
||||
82
apps/chat-worker/src/lib/supabase.ts
Normal file
82
apps/chat-worker/src/lib/supabase.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
37
apps/chat-worker/src/lib/types.ts
Normal file
37
apps/chat-worker/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
15
apps/chat-worker/tsconfig.json
Normal file
15
apps/chat-worker/tsconfig.json
Normal 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"]
|
||||
}
|
||||
14
apps/chat-worker/turbo.json
Normal file
14
apps/chat-worker/turbo.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
apps/chat-worker/wrangler.toml
Normal file
26
apps/chat-worker/wrangler.toml
Normal 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
299
apps/clients/biome.json
Normal 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
12
apps/clients/index.html
Normal 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
50
apps/clients/package.json
Normal 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
9
apps/clients/src/App.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import AppRoutes from "./routes";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
apps/clients/src/components/ClientLayout.tsx
Normal file
67
apps/clients/src/components/ClientLayout.tsx
Normal 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
31
apps/clients/src/i18n.ts
Normal 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;
|
||||
10
apps/clients/src/lib/supabase.ts
Normal file
10
apps/clients/src/lib/supabase.ts
Normal 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);
|
||||
3
apps/clients/src/locales/en/booking.json
Normal file
3
apps/clients/src/locales/en/booking.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"welcome": "Welcome"
|
||||
}
|
||||
3
apps/clients/src/locales/fr/booking.json
Normal file
3
apps/clients/src/locales/fr/booking.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"welcome": "Bienvenue"
|
||||
}
|
||||
1266
apps/clients/src/main.css
Normal file
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
29
apps/clients/src/main.tsx
Normal 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>
|
||||
);
|
||||
66
apps/clients/src/pages/AuthCallback.tsx
Normal file
66
apps/clients/src/pages/AuthCallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
apps/clients/src/pages/ClientTabloListPage.tsx
Normal file
63
apps/clients/src/pages/ClientTabloListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
apps/clients/src/pages/ClientTabloPage.tsx
Normal file
310
apps/clients/src/pages/ClientTabloPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/clients/src/routes.tsx
Normal file
17
apps/clients/src/routes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/clients/tsconfig.json
Normal file
31
apps/clients/tsconfig.json
Normal 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": []
|
||||
}
|
||||
1
apps/clients/tsconfig.tsbuildinfo
Normal file
1
apps/clients/tsconfig.tsbuildinfo
Normal 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"}
|
||||
18
apps/clients/vite.config.ts
Normal file
18
apps/clients/vite.config.ts
Normal 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 },
|
||||
};
|
||||
});
|
||||
9
apps/clients/worker/index.ts
Normal file
9
apps/clients/worker/index.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
11
apps/clients/wrangler.toml
Normal file
11
apps/clients/wrangler.toml
Normal 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
|
||||
1
apps/external/.env.production
vendored
1
apps/external/.env.production
vendored
|
|
@ -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
|
||||
4
apps/external/src/UserStoreProvider.tsx
vendored
4
apps/external/src/UserStoreProvider.tsx
vendored
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
4
apps/external/vite.config.ts
vendored
4
apps/external/vite.config.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
1
apps/external/worker-configuration.d.ts
vendored
1
apps/external/worker-configuration.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
90
apps/main/src/components/ChatChannelPreview.tsx
Normal file
90
apps/main/src/components/ChatChannelPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
apps/main/src/components/ChatHeader.tsx
Normal file
52
apps/main/src/components/ChatHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
202
apps/main/src/components/SubscriptionCard.test.tsx
Normal file
202
apps/main/src/components/SubscriptionCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
147
apps/main/src/components/UpgradePanel.test.tsx
Normal file
147
apps/main/src/components/UpgradePanel.test.tsx
Normal 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("");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
185
apps/main/src/contexts/UpgradeBlockContext.test.tsx
Normal file
185
apps/main/src/contexts/UpgradeBlockContext.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
88
apps/main/src/hooks/client_invites.ts
Normal file
88
apps/main/src/hooks/client_invites.ts
Normal 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 }
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
apps/main/src/locales/en/chat.json
Normal file
13
apps/main/src/locales/en/chat.json
Normal 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"
|
||||
}
|
||||
13
apps/main/src/locales/fr/chat.json
Normal file
13
apps/main/src/locales/fr/chat.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <></>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue