xtablo-source/docs/superpowers/plans/2026-04-15-client-magic-links.md
Arthur Belleville 05c552ce73
docs: add client magic links implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:53:22 +02:00

57 KiB

Client Magic Links Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace temporary user invitations with magic links, served via a new apps/clients portal at clients.xtablo.com, powered by shared tablo view components extracted into packages/tablo-views.

Architecture: Three parallel workstreams — (1) database + API changes for is_client users and client_invites, (2) extract tablo view components into packages/tablo-views, (3) scaffold apps/clients portal. The API serves both apps/main and apps/clients with permission scoping via middleware.

Tech Stack: React 19, Vite, Cloudflare Workers, Hono, Supabase Auth (magic links), TanStack Query, Tailwind CSS v4, pnpm workspaces, Turborepo.

Spec: docs/superpowers/specs/2026-04-15-client-magic-links-design.md


File Structure

New files

Database:

  • supabase/migrations/20260415120000_add_client_invites.sql — migration: is_client column + client_invites table + RLS

API:

  • apps/api/src/routers/clientInvites.ts — client invite endpoints (create, accept, list, cancel)
  • apps/api/src/__tests__/routes/clientInvites.test.ts — tests for client invite routes

Package: packages/tablo-views:

  • packages/tablo-views/package.json
  • packages/tablo-views/tsconfig.json
  • packages/tablo-views/src/index.ts — barrel export
  • packages/tablo-views/src/TabloTasksSection.tsx — moved from apps/main
  • packages/tablo-views/src/TabloFilesSection.tsx — moved from apps/main
  • packages/tablo-views/src/TabloDiscussionSection.tsx — moved from apps/main
  • packages/tablo-views/src/TabloEventsSection.tsx — moved from apps/main
  • packages/tablo-views/src/EtapesSection.tsx — extracted from tablo-details.tsx
  • packages/tablo-views/src/RoadmapSection.tsx — extracted from tablo-details.tsx
  • packages/tablo-views/src/ChatMessages.tsx — moved from apps/main
  • packages/tablo-views/src/TabloHeaderActions.tsx — moved from apps/main
  • packages/tablo-views/src/hooks/useChat.ts — moved from apps/main
  • packages/tablo-views/src/hooks/useChatUnread.ts — moved from apps/main
  • packages/tablo-views/src/components/gantt/GanttChart.tsx — moved from apps/main
  • packages/tablo-views/src/components/kanban/KanbanBoard.tsx — moved from apps/main
  • packages/tablo-views/src/components/kanban/KanbanColumn.tsx — moved from apps/main
  • packages/tablo-views/src/components/kanban/KanbanTaskCard.tsx — moved from apps/main
  • packages/tablo-views/src/components/kanban/InlineTaskCreate.tsx — moved from apps/main
  • packages/tablo-views/src/components/kanban/TaskModal.tsx — moved from apps/main
  • packages/tablo-views/src/components/kanban/types.ts — moved from apps/main

App: apps/clients:

  • apps/clients/package.json
  • apps/clients/vite.config.ts
  • apps/clients/wrangler.toml
  • apps/clients/worker/index.ts
  • apps/clients/index.html
  • apps/clients/tsconfig.json
  • apps/clients/src/main.tsx
  • apps/clients/src/main.css
  • apps/clients/src/App.tsx
  • apps/clients/src/routes.tsx
  • apps/clients/src/i18n.ts
  • apps/clients/src/pages/AuthCallback.tsx
  • apps/clients/src/pages/ClientTabloPage.tsx
  • apps/clients/src/pages/ClientTabloListPage.tsx
  • apps/clients/src/components/ClientLayout.tsx

Modified files

  • apps/api/src/middlewares/middleware.ts — add is_client check to createProfileAccessMiddleware
  • apps/api/src/routers/authRouter.ts — mount clientInvites router
  • apps/api/src/routers/tablo.ts — add checkTabloAdmin to new client invite endpoint
  • apps/api/src/helpers/helpers.ts — add createClientUser() function
  • apps/api/src/helpers/billing.ts — exclude is_client from getBillableMemberCount
  • apps/api/src/__tests__/middlewares/middlewares.test.ts — add is_client middleware tests
  • apps/main/src/pages/tablo-details.tsx — import sections from @xtablo/tablo-views instead of local
  • apps/main/src/components/TabloHeaderActions.tsx — add client invite UI to share dialog
  • packages/shared-types/src/database.types.ts — regenerated after migration (or manually add types)
  • package.json (root) — add dev:clients script
  • pnpm-workspace.yaml — already covers apps/* and packages/*, no change needed

Task 1: Database Migration — is_client Column and client_invites Table

Files:

  • Create: supabase/migrations/20260415120000_add_client_invites.sql

  • Modify: packages/shared-types/src/database.types.ts

  • Step 1: Write the migration SQL

-- Add is_client column to profiles
ALTER TABLE public.profiles
  ADD COLUMN is_client boolean NOT NULL DEFAULT false;

-- Create client_invites table
CREATE TABLE public.client_invites (
  id serial PRIMARY KEY,
  tablo_id text NOT NULL REFERENCES public.tablos(id) ON DELETE CASCADE,
  invited_email varchar(255) NOT NULL,
  invited_by uuid NOT NULL REFERENCES public.profiles(id),
  invite_token text NOT NULL,
  expires_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'),
  is_pending boolean NOT NULL DEFAULT true,
  created_at timestamptz NOT NULL DEFAULT now()
);

-- Index for token lookups
CREATE UNIQUE INDEX idx_client_invites_token ON public.client_invites(invite_token);

-- Index for listing invites by tablo
CREATE INDEX idx_client_invites_tablo ON public.client_invites(tablo_id, is_pending);

-- RLS
ALTER TABLE public.client_invites ENABLE ROW LEVEL SECURITY;

-- Admins can manage invites they created
CREATE POLICY "Admins can manage their client invites"
  ON public.client_invites
  FOR ALL
  USING (invited_by = auth.uid());

-- Client users can read invites sent to their email
CREATE POLICY "Clients can read their own invites"
  ON public.client_invites
  FOR SELECT
  USING (
    invited_email = (
      SELECT email FROM auth.users WHERE id = auth.uid()
    )
  );

Save to supabase/migrations/20260415120000_add_client_invites.sql.

  • Step 2: Add TypeScript types for client_invites

Add the following types to packages/shared-types/src/database.types.ts in the Tables interface, following the existing pattern used by tablo_invites:

client_invites: {
  Row: {
    id: number;
    tablo_id: string;
    invited_email: string;
    invited_by: string;
    invite_token: string;
    expires_at: string;
    is_pending: boolean;
    created_at: string;
  };
  Insert: {
    id?: number;
    tablo_id: string;
    invited_email: string;
    invited_by: string;
    invite_token: string;
    expires_at?: string;
    is_pending?: boolean;
    created_at?: string;
  };
  Update: {
    id?: number;
    tablo_id?: string;
    invited_email?: string;
    invited_by?: string;
    invite_token?: string;
    expires_at?: string;
    is_pending?: boolean;
    created_at?: string;
  };
  Relationships: [
    {
      foreignKeyName: "client_invites_tablo_id_fkey";
      columns: ["tablo_id"];
      isOneToOne: false;
      referencedRelation: "tablos";
      referencedColumns: ["id"];
    },
    {
      foreignKeyName: "client_invites_invited_by_fkey";
      columns: ["invited_by"];
      isOneToOne: false;
      referencedRelation: "profiles";
      referencedColumns: ["id"];
    }
  ];
};

Also add is_client: boolean to the profiles Row, Insert, and Update types.

  • Step 3: Commit
git add supabase/migrations/20260415120000_add_client_invites.sql packages/shared-types/src/database.types.ts
git commit -m "feat(db): add is_client column and client_invites table"

Task 2: API Middleware — Add is_client Permission Check

Files:

  • Modify: apps/api/src/middlewares/middleware.ts:77-100

  • Modify: apps/api/src/helpers/billing.ts:89-90

  • Test: apps/api/src/__tests__/middlewares/middlewares.test.ts

  • Step 1: Write failing test for is_client user blocked by regularUserCheckMiddleware

In apps/api/src/__tests__/middlewares/middlewares.test.ts, add a new test in the "Regular user check middleware" describe block:

it("should return 401 for client users", async () => {
  const app = new Hono();
  const middlewareManager = MiddlewareManager.getInstance();

  app.use(middlewareManager.supabase);
  app.use(middlewareManager.auth);
  app.use(middlewareManager.regularUserCheck);
  app.get("/test", (c) => c.json({ success: true }));

  mockSupabaseFrom.mockImplementation((table: string) => {
    if (table === "profiles") {
      return {
        select: () => ({
          eq: () => ({
            single: () =>
              Promise.resolve({
                data: { is_temporary: false, is_client: true },
                error: null,
              }),
          }),
        }),
      };
    }
    return {};
  });

  const res = await app.request("/test", {
    headers: { Authorization: "Bearer valid-token" },
  });

  expect(res.status).toBe(401);
  const body = await res.json();
  expect(body.error).toBe("User is read only");
});
  • Step 2: Run test to verify it fails

Run: cd apps/api && pnpm test -- --run src/__tests__/middlewares/middlewares.test.ts Expected: FAIL — the current middleware only checks is_temporary, not is_client

  • Step 3: Update middleware to check is_client

In apps/api/src/middlewares/middleware.ts, modify createProfileAccessMiddleware (line 77-100):

Change the select from:

.select("is_temporary")

to:

.select("is_temporary, is_client")

Change the check from:

if (!allowTemporaryUsers && profile.is_temporary) {

to:

if ((!allowTemporaryUsers && profile.is_temporary) || profile.is_client) {

This blocks is_client users from all routes that use regularUserCheckMiddleware. Client-accessible routes don't use this middleware — they only require auth.

  • Step 4: Update billing exclusion

In apps/api/src/helpers/billing.ts, change getBillableMemberCount (line 89-90):

From:

export const getBillableMemberCount = (profiles: BillingProfileRow[]) =>
  profiles.filter((profile) => profile.is_temporary !== true).length;

To:

export const getBillableMemberCount = (profiles: BillingProfileRow[]) =>
  profiles.filter((profile) => profile.is_temporary !== true && profile.is_client !== true).length;

Note: The BillingProfileRow type (defined earlier in billing.ts) needs is_client added. Find the type definition and add is_client: boolean alongside the existing is_temporary: boolean.

  • Step 5: Run tests to verify they pass

Run: cd apps/api && pnpm test -- --run src/__tests__/middlewares/middlewares.test.ts Expected: PASS

  • Step 6: Commit
git add apps/api/src/middlewares/middleware.ts apps/api/src/helpers/billing.ts apps/api/src/__tests__/middlewares/middlewares.test.ts
git commit -m "feat(api): add is_client check to middleware and billing"

Task 3: API — Client Invite Endpoints

Files:

  • Create: apps/api/src/routers/clientInvites.ts

  • Modify: apps/api/src/routers/authRouter.ts

  • Modify: apps/api/src/helpers/helpers.ts

  • Create: apps/api/src/__tests__/routes/clientInvites.test.ts

  • Step 1: Add createClientUser helper

In apps/api/src/helpers/helpers.ts, add a new function after createInvitedUser:

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: existingUsers } = await supabase.auth.admin.listUsers();
  const existingUser = existingUsers?.users?.find(
    (u) => 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;

    // Set is_client on profile
    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 };
}
  • Step 2: Create client invites router

Create apps/api/src/routers/clientInvites.ts:

import { Hono } from "hono";
import { checkTabloAdmin } from "../helpers/helpers.js";
import { generateToken } from "../helpers/token.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import { createClientUser } from "../helpers/helpers.js";
import type { SupabaseClient, User } from "@supabase/supabase-js";

type Env = {
  Variables: {
    supabase: SupabaseClient;
    user: User;
  };
};

export const getClientInvitesRouter = () => {
  const router = new Hono<Env>();
  const middlewareManager = MiddlewareManager.getInstance();

  // Create client invite (admin only)
  router.post(
    "/:tabloId",
    middlewareManager.regularUserCheck,
    checkTabloAdmin,
    async (c) => {
      const supabase = c.get("supabase");
      const user = c.get("user");
      const tabloId = c.req.param("tabloId");
      const { email } = await c.req.json<{ email: string }>();

      if (!email || !email.includes("@")) {
        return c.json({ error: "Invalid email" }, 400);
      }

      const token = generateToken();

      // Create client user + tablo access
      const result = await createClientUser(supabase, email, tabloId, user.id);
      if (!result.success) {
        return c.json({ error: result.error }, 500);
      }

      // Create client_invites record
      const { error: insertError } = await supabase.from("client_invites").insert({
        tablo_id: tabloId,
        invited_email: email.toLowerCase(),
        invited_by: user.id,
        invite_token: token,
      });

      if (insertError) {
        return c.json({ error: insertError.message }, 500);
      }

      // Generate Supabase magic link
      const redirectTo = `${c.req.header("origin")?.replace("app.", "clients.") ?? "https://clients.xtablo.com"}/auth/callback?token=${token}`;

      const { error: linkError } = await supabase.auth.admin.generateLink({
        type: "magiclink",
        email,
        options: { redirectTo },
      });

      if (linkError) {
        return c.json({ error: "Failed to send magic link" }, 500);
      }

      return c.json({ success: true });
    }
  );

  // Accept client invite via token
  router.post("/accept/:token", async (c) => {
    const supabase = c.get("supabase");
    const user = c.get("user");
    const token = c.req.param("token");

    const { data: invite, error } = await supabase
      .from("client_invites")
      .select("*")
      .eq("invite_token", token)
      .eq("is_pending", true)
      .single();

    if (error || !invite) {
      return c.json({ error: "Invalid or expired invite" }, 404);
    }

    // Check expiration
    if (new Date(invite.expires_at) < new Date()) {
      return c.json({ error: "Invite has expired" }, 410);
    }

    // Verify email matches
    const { data: userProfile } = await supabase
      .from("profiles")
      .select("email")
      .eq("id", user.id)
      .single();

    if (userProfile?.email?.toLowerCase() !== invite.invited_email.toLowerCase()) {
      return c.json({ error: "Email mismatch" }, 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: access } = await supabase
      .from("tablo_access")
      .select("id, is_active")
      .eq("tablo_id", invite.tablo_id)
      .eq("user_id", user.id)
      .single();

    if (access && !access.is_active) {
      await supabase
        .from("tablo_access")
        .update({ is_active: true })
        .eq("id", access.id);
    }

    return c.json({ success: true, tabloId: invite.tablo_id });
  });

  // List pending client invites for a tablo (admin only)
  router.get(
    "/:tabloId/pending",
    middlewareManager.regularUserCheck,
    checkTabloAdmin,
    async (c) => {
      const supabase = c.get("supabase");
      const tabloId = c.req.param("tabloId");

      const { data, 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: data });
    }
  );

  // Cancel client invite (admin only)
  router.delete(
    "/:tabloId/:inviteId",
    middlewareManager.regularUserCheck,
    checkTabloAdmin,
    async (c) => {
      const supabase = c.get("supabase");
      const inviteId = c.req.param("inviteId");
      const tabloId = c.req.param("tabloId");

      const { data: invite } = await supabase
        .from("client_invites")
        .select("invited_email")
        .eq("id", Number(inviteId))
        .eq("tablo_id", tabloId)
        .single();

      if (!invite) {
        return c.json({ error: "Invite not found" }, 404);
      }

      // Mark as not pending
      await supabase
        .from("client_invites")
        .update({ is_pending: false })
        .eq("id", Number(inviteId));

      // Revoke tablo access for client user
      const { data: profile } = await supabase
        .from("profiles")
        .select("id, is_client")
        .eq("email", invite.invited_email)
        .single();

      if (profile?.is_client) {
        await supabase
          .from("tablo_access")
          .update({ is_active: false })
          .eq("tablo_id", tabloId)
          .eq("user_id", profile.id);
      }

      return c.json({ success: true });
    }
  );

  return router;
};
  • Step 3: Mount the router in authRouter.ts

In apps/api/src/routers/authRouter.ts, add the import and route:

import { getClientInvitesRouter } from "./clientInvites.js";

Add after the existing routes (before return authRouter):

authRouter.route("/client-invites", getClientInvitesRouter());
  • Step 4: Write tests for client invite endpoints

Create apps/api/src/__tests__/routes/clientInvites.test.ts. Follow the existing test patterns from apps/api/src/__tests__/routes/tablo.test.ts for mocking supabase. Key test cases:

import { describe, it, expect, vi, beforeEach } from "vitest";
// ... test setup matching existing patterns

describe("Client Invites Router", () => {
  describe("POST /:tabloId (create invite)", () => {
    it("should create client invite and return success", async () => {
      // Mock admin access check, user creation, invite insert, magic link generation
      // Assert: 200, { success: true }
    });

    it("should reject non-admin users", async () => {
      // Mock non-admin tablo_access
      // Assert: 403
    });

    it("should reject invalid email", async () => {
      // Send email without @
      // Assert: 400
    });
  });

  describe("POST /accept/:token", () => {
    it("should accept valid invite and return tabloId", async () => {
      // Mock valid pending invite, matching email, active tablo_access
      // Assert: 200, { success: true, tabloId: "..." }
    });

    it("should reject expired invite", async () => {
      // Mock invite with expires_at in the past
      // Assert: 410
    });

    it("should reject email mismatch", async () => {
      // Mock invite with different email than authenticated user
      // Assert: 403
    });
  });

  describe("GET /:tabloId/pending", () => {
    it("should return pending invites for admin", async () => {
      // Mock admin + pending invites
      // Assert: 200, { invites: [...] }
    });
  });

  describe("DELETE /:tabloId/:inviteId", () => {
    it("should cancel invite and revoke access for client user", async () => {
      // Mock invite + client profile
      // Assert: 200, tablo_access set to inactive
    });
  });
});

Fill in complete mock setup following the patterns in apps/api/src/__tests__/routes/tablo.test.ts.

  • Step 5: Run tests

Run: cd apps/api && pnpm test -- --run Expected: All tests pass

  • Step 6: Commit
git add apps/api/src/routers/clientInvites.ts apps/api/src/routers/authRouter.ts apps/api/src/helpers/helpers.ts apps/api/src/__tests__/routes/clientInvites.test.ts
git commit -m "feat(api): add client invite endpoints with magic link flow"

Task 4: Scaffold packages/tablo-views

Files:

  • Create: packages/tablo-views/package.json

  • Create: packages/tablo-views/tsconfig.json

  • Create: packages/tablo-views/src/index.ts

  • Step 1: Create packages/tablo-views/package.json

{
  "name": "@xtablo/tablo-views",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts",
    "./components/*": "./src/components/*.tsx",
    "./hooks/*": "./src/hooks/*.ts",
    "./*": "./src/*.tsx"
  },
  "scripts": {
    "lint": "biome check .",
    "lint:fix": "biome check --write .",
    "format": "biome format --write .",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@tanstack/react-query": "^5.69.0",
    "@xtablo/chat-ui": "workspace:*",
    "@xtablo/shared": "workspace:*",
    "@xtablo/shared-types": "workspace:*",
    "@xtablo/ui": "workspace:*",
    "date-fns": "^4.1.0",
    "lucide-react": "^0.460.0",
    "react": "19.0.0",
    "react-dom": "19.0.0",
    "react-i18next": "^16.2.0",
    "react-router-dom": "^7.9.4",
    "tailwind-merge": "^3.0.2"
  },
  "devDependencies": {
    "@biomejs/biome": "2.2.5",
    "@types/react": "19.0.10",
    "@types/react-dom": "19.0.4",
    "typescript": "^5.7.0"
  }
}
  • Step 2: Create packages/tablo-views/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "baseUrl": ".",
    "paths": {
      "@xtablo/ui": ["../ui/src"],
      "@xtablo/ui/*": ["../ui/src/*"],
      "@xtablo/shared": ["../shared/src"],
      "@xtablo/shared/*": ["../shared/src/*"]
    }
  },
  "include": ["src"]
}
  • Step 3: Create packages/tablo-views/src/index.ts
// Section components
export { TabloTasksSection } from "./TabloTasksSection";
export { TabloFilesSection } from "./TabloFilesSection";
export { TabloDiscussionSection } from "./TabloDiscussionSection";
export { TabloEventsSection } from "./TabloEventsSection";
export { EtapesSection } from "./EtapesSection";
export { RoadmapSection } from "./RoadmapSection";
export { TabloHeaderActions } from "./TabloHeaderActions";
export { ChatMessages } from "./ChatMessages";

// Sub-components
export { GanttChart } from "./components/gantt/GanttChart";
export { TaskModal } from "./components/kanban/TaskModal";
export { KanbanBoard } from "./components/kanban/KanbanBoard";

// Hooks
export { useChat } from "./hooks/useChat";
export { useChatUnread } from "./hooks/useChatUnread";

// Types
export type { TabloMember } from "./components/kanban/types";
  • Step 4: Install dependencies

Run: pnpm install

  • Step 5: Commit
git add packages/tablo-views/
git commit -m "feat: scaffold packages/tablo-views package"

Task 5: Move Tablo View Components to packages/tablo-views

This is the largest task. Move each component from apps/main/src/ to packages/tablo-views/src/, updating internal imports.

Files to move (source -> destination):

  • apps/main/src/components/TabloTasksSection.tsx -> packages/tablo-views/src/TabloTasksSection.tsx
  • apps/main/src/components/TabloFilesSection.tsx -> packages/tablo-views/src/TabloFilesSection.tsx
  • apps/main/src/components/TabloDiscussionSection.tsx -> packages/tablo-views/src/TabloDiscussionSection.tsx
  • apps/main/src/components/TabloEventsSection.tsx -> packages/tablo-views/src/TabloEventsSection.tsx
  • apps/main/src/components/TabloHeaderActions.tsx -> packages/tablo-views/src/TabloHeaderActions.tsx
  • apps/main/src/components/ChatMessages.tsx -> packages/tablo-views/src/ChatMessages.tsx
  • apps/main/src/hooks/useChat.ts -> packages/tablo-views/src/hooks/useChat.ts
  • apps/main/src/hooks/useChatUnread.ts -> packages/tablo-views/src/hooks/useChatUnread.ts
  • apps/main/src/components/gantt/GanttChart.tsx -> packages/tablo-views/src/components/gantt/GanttChart.tsx
  • apps/main/src/components/kanban/KanbanBoard.tsx -> packages/tablo-views/src/components/kanban/KanbanBoard.tsx
  • apps/main/src/components/kanban/KanbanColumn.tsx -> packages/tablo-views/src/components/kanban/KanbanColumn.tsx
  • apps/main/src/components/kanban/KanbanTaskCard.tsx -> packages/tablo-views/src/components/kanban/KanbanTaskCard.tsx
  • apps/main/src/components/kanban/InlineTaskCreate.tsx -> packages/tablo-views/src/components/kanban/InlineTaskCreate.tsx
  • apps/main/src/components/kanban/TaskModal.tsx -> packages/tablo-views/src/components/kanban/TaskModal.tsx
  • apps/main/src/components/kanban/types.ts -> packages/tablo-views/src/components/kanban/types.ts

Files to extract from tablo-details.tsx:

  • EtapesSection function (lines 950-1288) -> packages/tablo-views/src/EtapesSection.tsx

  • RoadmapSection function (lines 1292-1309) -> packages/tablo-views/src/RoadmapSection.tsx

  • Step 1: Move kanban sub-components first (no import changes needed between them)

Copy each file from apps/main/src/components/kanban/ to packages/tablo-views/src/components/kanban/. The internal relative imports between kanban files (./KanbanColumn, ./KanbanTaskCard, ./InlineTaskCreate, ./types) stay the same.

For TaskModal.tsx, update the hook imports from:

import { useTabloMembers } from "../../hooks/tablos";
import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "../../hooks/tasks";

to:

import { useTabloMembers } from "@xtablo/shared/hooks/tablos";
import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "@xtablo/shared/hooks/tasks";

Important: Check if these hooks exist in @xtablo/shared. If they are in apps/main/src/hooks/, they need to stay as peer dependencies. In that case, TaskModal should accept the needed callbacks as props instead of importing hooks directly. Examine the actual hooks to decide. If the hooks are in apps/main, accept them as props:

interface TaskModalProps {
  isOpen: boolean;
  tabloId?: string;
  taskId?: string;
  onClose: () => void;
  members?: TabloMember[];
  initialStatus?: TaskStatus;
  etapes?: Etape[];
  tablos?: UserTablo[];
  allowTabloSelection?: boolean;
  initialDueDate?: Date;
}

The hooks are already used within TaskModal, so the simplest approach is to keep @xtablo/tablo-views depending on the same hooks the main app uses. Since hooks like useCreateTask, useTabloMembers etc. are in apps/main/src/hooks/, they need to either:

  1. Move to @xtablo/shared/hooks/ (if they're pure React Query wrappers around API calls), OR
  2. Stay in apps/main and be passed as props/callbacks

The decision depends on whether these hooks have dependencies on app-specific context (like UserStoreProvider). Check each hook — if it only uses useSession and API calls, move it to @xtablo/shared. If it uses useUser() from UserStoreProvider, keep it in the app and pass data as props.

  • Step 2: Move GanttChart

Copy apps/main/src/components/gantt/GanttChart.tsx to packages/tablo-views/src/components/gantt/GanttChart.tsx.

Update the LoadingSpinner import. If it comes from @ui/components/LoadingSpinner (a local alias in apps/main), change to the full path or use a simple inline spinner.

  • Step 3: Move section components

For each section component (TabloTasksSection, TabloFilesSection, TabloDiscussionSection, TabloEventsSection, TabloHeaderActions, ChatMessages):

  1. Copy the file to packages/tablo-views/src/
  2. Update local imports to either:
    • Use @xtablo/shared/hooks/* if the hook exists there
    • Use relative imports within packages/tablo-views/ for co-located files (e.g., ./ChatMessages, ./components/kanban/KanbanBoard)
  3. Replace @ui/components/LoadingSpinner with @xtablo/ui/components/loading-spinner or equivalent

Key import changes per file:

TabloTasksSection.tsx:

  • ../hooks/tablos -> check if available in @xtablo/shared/hooks/tablos
  • ../hooks/tasks -> check if available in @xtablo/shared/hooks/tasks
  • ./kanban/KanbanBoard -> ./components/kanban/KanbanBoard
  • ./kanban/TaskModal -> ./components/kanban/TaskModal
  • ./TabloHeaderActions -> ./TabloHeaderActions

TabloFilesSection.tsx:

  • ../hooks/tablo_data -> check if in @xtablo/shared
  • ../hooks/tablo_folders -> check if in @xtablo/shared
  • ../providers/UserStoreProvider -> This is app-specific. The useIsReadOnlyUser and useUser hooks depend on Zustand store from apps/main. Solution: accept isReadOnly: boolean and currentUser as props instead.

TabloDiscussionSection.tsx:

  • ../hooks/useChat -> ./hooks/useChat
  • ../hooks/tablos -> check availability
  • ../providers/UserStoreProvider -> accept currentUser as prop
  • ./ChatMessages -> ./ChatMessages

TabloEventsSection.tsx:

  • ../hooks/events -> check availability

  • ../providers/UserStoreProvider -> accept isReadOnly as prop

  • ./TabloHeaderActions -> ./TabloHeaderActions

  • Step 4: Extract EtapesSection from tablo-details.tsx

Create packages/tablo-views/src/EtapesSection.tsx with the content from lines 950-1288 of tablo-details.tsx. Add the necessary imports at the top:

import { cn } from "@xtablo/shared";
import type { Etape, KanbanTask } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import { Input } from "@xtablo/ui/components/input";
import {
  CalendarIcon,
  ChevronDownIcon,
  ChevronRightIcon,
  CircleCheckIcon,
  PlusIcon,
} from "lucide-react";
import { useState } from "react";

The hooks useCreateTask and useCreateEtape need to be available. If they're in apps/main/src/hooks/tasks.ts, accept callbacks as props:

interface EtapesSectionProps {
  etapes: Etape[];
  tabloTasks: KanbanTask[];
  tabloId: string;
  isAdmin: boolean;
  onCreateTask: (task: { tablo_id: string; title: string; status: string; parent_task_id: string; is_parent: boolean; position: number }) => void;
  onCreateEtape: (params: { tabloId: string; title: string; position: number }) => Promise<void>;
  isCreatingEtape?: boolean;
}
  • Step 5: Extract RoadmapSection from tablo-details.tsx

Create packages/tablo-views/src/RoadmapSection.tsx:

import type { Etape, KanbanTask } from "@xtablo/shared-types";
import { GanttChart } from "./components/gantt/GanttChart";

interface RoadmapSectionProps {
  etapes: Etape[];
  tabloTasks: KanbanTask[];
  onDateClick: (date: Date) => void;
  onTaskStatusChange: (taskId: string, status: string) => void;
}

export function RoadmapSection({
  tabloTasks,
  onDateClick,
  onTaskStatusChange,
}: RoadmapSectionProps) {
  return (
    <GanttChart
      tasks={tabloTasks}
      isLoading={false}
      onDateClick={onDateClick}
      onTaskStatusChange={onTaskStatusChange}
    />
  );
}
  • Step 6: Delete moved files from apps/main

Remove the original files from apps/main that were moved. Do NOT delete files that are still needed by other parts of apps/main (e.g., ClickOutside, ImageColorPicker used by TabloHeaderActions — move those too or keep them and import from the new location).

  • Step 7: Run typecheck

Run: pnpm typecheck Expected: No errors. Fix any broken imports.

  • Step 8: Commit
git add packages/tablo-views/src/ apps/main/src/
git commit -m "refactor: move tablo view components to packages/tablo-views"

Task 6: Update apps/main to Import from packages/tablo-views

Files:

  • Modify: apps/main/package.json

  • Modify: apps/main/src/pages/tablo-details.tsx

  • Modify: apps/main/src/pages/chat.tsx (if it imports useChat or ChatMessages)

  • Step 1: Add @xtablo/tablo-views dependency to apps/main

In apps/main/package.json, add to dependencies:

"@xtablo/tablo-views": "workspace:*"
  • Step 2: Update imports in tablo-details.tsx

Replace the local imports with package imports:

From:

import { GanttChart } from "../components/gantt/GanttChart";
import { TaskModal } from "../components/kanban/TaskModal";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloEventsSection } from "../components/TabloEventsSection";
import { TabloFilesSection } from "../components/TabloFilesSection";
import { TabloTasksSection } from "../components/TabloTasksSection";
import { useChatUnread } from "../hooks/useChatUnread";

To:

import {
  TabloDiscussionSection,
  TabloEventsSection,
  TabloFilesSection,
  TabloTasksSection,
  EtapesSection,
  RoadmapSection,
  TaskModal,
  useChatUnread,
} from "@xtablo/tablo-views";

Remove the inline EtapesSection and RoadmapSection function definitions from tablo-details.tsx (they now live in the package).

Update the JSX where EtapesSection and RoadmapSection are rendered to pass the new callback props (if hooks were replaced with props in Task 5).

  • Step 3: Update chat.tsx if needed

If apps/main/src/pages/chat.tsx imports useChat or ChatMessages from local paths, update to import from @xtablo/tablo-views.

  • Step 4: Run pnpm install and typecheck

Run: pnpm install && pnpm typecheck Expected: No errors

  • Step 5: Run dev server and verify tablo details page works

Run: pnpm dev:main Navigate to a tablo details page. Verify all tabs render correctly: overview, etapes, tasks, files, discussion, events, roadmap.

  • Step 6: Commit
git add apps/main/package.json apps/main/src/
git commit -m "refactor: update apps/main to import tablo views from shared package"

Task 7: Scaffold apps/clients

Files:

  • Create: all files under apps/clients/

  • Modify: package.json (root) — add dev:clients script

  • Step 1: Create apps/clients/package.json

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

  if (mode !== "test" && process.env.VITEST !== "true") {
    plugins.push(cloudflare());
  }

  return {
    plugins,
    server: {
      cors: false,
    },
  };
});
  • Step 3: Create apps/clients/wrangler.toml
name = "xtablo-clients"
main = "worker/index.ts"
compatibility_date = "2025-07-09"

[assets]
directory = "./dist/"
not_found_handling = "single-page-application"

[observability]
enabled = true

[env.staging]
route = { pattern = "clients-staging.xtablo.com", custom_domain = true }

[env.production]
route = { pattern = "clients.xtablo.com", custom_domain = true }
  • Step 4: Create apps/clients/worker/index.ts
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 });
  },
};
  • Step 5: Create apps/clients/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "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": []
}
  • Step 6: Create apps/clients/index.html
<!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>
  • Step 7: Create apps/clients/src/main.css

Copy from apps/external/src/main.css (or apps/main/src/main.css if it has Tailwind imports). At minimum:

@import "tailwindcss";
@import "tw-animate-css";
@import "@xtablo/ui/styles/globals.css";
  • Step 8: Create apps/clients/src/i18n.ts

Copy from apps/external/src/i18n.ts — same i18next setup with browser language detection.

  • Step 9: Create apps/clients/src/main.tsx
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 "@xtablo/ui/styles/globals.css";
import "./main.css";
import "./i18n";

createRoot(document.getElementById("client-root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <SessionProvider>
        <ThemeProvider>
          <Toaster />
          <Router>
            <App />
          </Router>
        </ThemeProvider>
      </SessionProvider>
    </QueryClientProvider>
  </StrictMode>
);
  • Step 10: Create apps/clients/src/App.tsx
import AppRoutes from "./routes";

export default function App() {
  return (
    <div className="min-h-screen bg-background">
      <AppRoutes />
    </div>
  );
}
  • Step 11: Create apps/clients/src/routes.tsx
import { Route, Routes } from "react-router-dom";
import { ClientLayout } from "./components/ClientLayout";
import { AuthCallback } from "./pages/AuthCallback";
import { ClientTabloPage } from "./pages/ClientTabloPage";
import { ClientTabloListPage } from "./pages/ClientTabloListPage";

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>
  );
}
  • Step 12: Add dev:clients script to root package.json

Add to the scripts section of the root package.json:

"dev:clients": "turbo dev --filter=@xtablo/clients"
  • Step 13: Run pnpm install

Run: pnpm install

  • Step 14: Commit
git add apps/clients/ package.json
git commit -m "feat: scaffold apps/clients Cloudflare Worker app"

Task 8: Build apps/clients Pages and Layout

Files:

  • Create: apps/clients/src/components/ClientLayout.tsx

  • Create: apps/clients/src/pages/AuthCallback.tsx

  • Create: apps/clients/src/pages/ClientTabloPage.tsx

  • Create: apps/clients/src/pages/ClientTabloListPage.tsx

  • Step 1: Create ClientLayout.tsx

import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import { LogOut } from "lucide-react";
import { Outlet, useNavigate } from "react-router-dom";
import { supabase } from "@xtablo/shared/lib/supabase";

export function ClientLayout() {
  const { session } = useSession();
  const navigate = useNavigate();

  const handleLogout = async () => {
    await supabase.auth.signOut();
    navigate("/auth/callback");
  };

  if (!session) {
    return (
      <div className="flex items-center justify-center h-screen">
        <p className="text-muted-foreground">
          Your session has expired. Please use the link sent to your email to access this portal.
        </p>
      </div>
    );
  }

  const userEmail = session.user.email ?? "";
  const initials = userEmail.substring(0, 2).toUpperCase();

  return (
    <div className="min-h-screen flex flex-col">
      <header className="h-14 border-b border-border flex items-center justify-between px-4">
        <div className="flex items-center gap-2">
          <span className="font-semibold text-sm">Xtablo</span>
        </div>
        <div className="flex items-center gap-3">
          <Avatar className="h-8 w-8">
            <AvatarFallback className="text-xs">{initials}</AvatarFallback>
          </Avatar>
          <span className="text-sm text-muted-foreground hidden sm:block">{userEmail}</span>
          <Button variant="ghost" size="sm" onClick={handleLogout}>
            <LogOut className="h-4 w-4" />
          </Button>
        </div>
      </header>
      <main className="flex-1">
        <Outlet />
      </main>
    </div>
  );
}
  • Step 2: Create AuthCallback.tsx
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { supabase } from "@xtablo/shared/lib/supabase";

export function AuthCallback() {
  const [searchParams] = useSearchParams();
  const { session } = useSession();
  const navigate = useNavigate();
  const [error, setError] = useState<string | null>(null);

  const inviteToken = searchParams.get("token");

  useEffect(() => {
    if (!session || !inviteToken) return;

    const acceptInvite = async () => {
      const apiBase = import.meta.env.VITE_API_URL as string;
      const res = await fetch(`${apiBase}/api/v1/client-invites/accept/${inviteToken}`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${session.access_token}`,
          "Content-Type": "application/json",
        },
      });

      if (!res.ok) {
        const body = await res.json();
        setError(body.error ?? "Failed to accept invite");
        return;
      }

      const { tabloId } = await res.json();
      navigate(`/tablo/${tabloId}`, { replace: true });
    };

    acceptInvite();
  }, [session, inviteToken, navigate]);

  if (error) {
    return (
      <div className="flex items-center justify-center h-screen">
        <div className="text-center space-y-2">
          <p className="text-destructive font-medium">{error}</p>
          <p className="text-sm text-muted-foreground">
            Please contact the person who invited you for a new link.
          </p>
        </div>
      </div>
    );
  }

  return (
    <div className="flex items-center justify-center h-screen">
      <p className="text-muted-foreground">Authenticating...</p>
    </div>
  );
}
  • Step 3: Create ClientTabloPage.tsx
import { useQuery } from "@tanstack/react-query";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { api } from "@xtablo/shared/lib/api";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import {
  TabloDiscussionSection,
  TabloEventsSection,
  TabloFilesSection,
  TabloTasksSection,
  EtapesSection,
  RoadmapSection,
} from "@xtablo/tablo-views";
import {
  CalendarIcon,
  FolderIcon,
  KanbanIcon,
  LayoutDashboardIcon,
  ListChecksIcon,
  MapIcon,
  MessageCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";

type TabSection = "overview" | "etapes" | "tasks" | "files" | "discussion" | "events" | "roadmap";

const TABS: { id: TabSection; label: string; icon: React.ElementType }[] = [
  { id: "overview", label: "Overview", icon: LayoutDashboardIcon },
  { id: "etapes", label: "Stages", icon: ListChecksIcon },
  { id: "tasks", label: "Tasks", icon: KanbanIcon },
  { id: "files", label: "Files", icon: FolderIcon },
  { id: "discussion", label: "Discussion", icon: MessageCircleIcon },
  { id: "events", label: "Events", icon: CalendarIcon },
  { id: "roadmap", label: "Roadmap", icon: MapIcon },
];

export function ClientTabloPage() {
  const { tabloId } = useParams<{ tabloId: string }>();
  const [searchParams, setSearchParams] = useSearchParams();
  const { session } = useSession();

  const sectionParam = searchParams.get("section") as TabSection | null;
  const activeSection: TabSection =
    sectionParam && TABS.some((t) => t.id === sectionParam) ? sectionParam : "overview";

  // Fetch tablo details via API
  const { data: tablo, isLoading } = useQuery<UserTablo>({
    queryKey: ["tablos", tabloId],
    queryFn: async () => {
      const res = await api.get(`/api/v1/tablos/${tabloId}`, {
        headers: { Authorization: `Bearer ${session?.access_token}` },
      });
      return res.data;
    },
    enabled: !!tabloId && !!session,
  });

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-64">
        <p className="text-muted-foreground">Loading...</p>
      </div>
    );
  }

  if (!tablo) return null;

  return (
    <div className="p-4 md:p-6 max-w-7xl mx-auto">
      {/* Tablo header */}
      <div className="mb-6">
        <h1 className="text-2xl font-bold">{tablo.name}</h1>
        {tablo.description && (
          <p className="text-muted-foreground mt-1">{tablo.description}</p>
        )}
      </div>

      {/* Tab navigation */}
      <div className="flex gap-1 overflow-x-auto border-b border-border mb-6">
        {TABS.map((tab) => {
          const Icon = tab.icon;
          const isActive = activeSection === tab.id;
          return (
            <button
              key={tab.id}
              type="button"
              onClick={() => setSearchParams({ section: tab.id })}
              className={`flex items-center gap-2 px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
                isActive
                  ? "border-primary text-primary"
                  : "border-transparent text-muted-foreground hover:text-foreground"
              }`}
            >
              <Icon className="h-4 w-4" />
              {tab.label}
            </button>
          );
        })}
      </div>

      {/* Tab content */}
      <div>
        {activeSection === "tasks" && (
          <TabloTasksSection tablo={tablo} isAdmin={false} />
        )}
        {activeSection === "files" && (
          <TabloFilesSection tablo={tablo} isAdmin={false} />
        )}
        {activeSection === "discussion" && (
          <TabloDiscussionSection tablo={tablo} isAdmin={false} />
        )}
        {activeSection === "events" && (
          <TabloEventsSection tablo={tablo} isAdmin={false} />
        )}
        {/* etapes, roadmap, overview sections rendered similarly */}
      </div>
    </div>
  );
}

Adapt the props to match whatever interface the extracted components expose after Task 5. The key difference from apps/main is: isAdmin is always false, and no share/invite/delete UI is rendered.

  • Step 4: Create ClientTabloListPage.tsx
import { useQuery } from "@tanstack/react-query";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { api } from "@xtablo/shared/lib/api";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { FolderIcon } from "lucide-react";
import { Link, Navigate } from "react-router-dom";

export function ClientTabloListPage() {
  const { session } = useSession();

  const { data: tablos, isLoading } = useQuery<UserTablo[]>({
    queryKey: ["tablos"],
    queryFn: async () => {
      const res = await api.get("/api/v1/tablos", {
        headers: { Authorization: `Bearer ${session?.access_token}` },
      });
      return res.data;
    },
    enabled: !!session,
  });

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-64">
        <p className="text-muted-foreground">Loading...</p>
      </div>
    );
  }

  if (!tablos || tablos.length === 0) {
    return (
      <div className="flex items-center justify-center h-64">
        <p className="text-muted-foreground">No projects available.</p>
      </div>
    );
  }

  // If only one tablo, redirect directly
  if (tablos.length === 1) {
    return <Navigate to={`/tablo/${tablos[0].id}`} replace />;
  }

  return (
    <div className="p-4 md:p-6 max-w-3xl mx-auto">
      <h1 className="text-2xl font-bold mb-6">Your Projects</h1>
      <div className="space-y-2">
        {tablos.map((tablo) => (
          <Link
            key={tablo.id}
            to={`/tablo/${tablo.id}`}
            className="flex items-center gap-3 p-4 rounded-lg border border-border hover:bg-accent transition-colors"
          >
            <FolderIcon className="h-5 w-5 text-muted-foreground" />
            <div>
              <p className="font-medium">{tablo.name}</p>
              {tablo.description && (
                <p className="text-sm text-muted-foreground">{tablo.description}</p>
              )}
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}
  • Step 5: Run dev server and verify

Run: pnpm dev:clients Expected: App starts on port 5175. The auth callback page shows "Authenticating..." without a session. The list page shows "No projects available" when not authenticated.

  • Step 6: Commit
git add apps/clients/src/
git commit -m "feat(clients): add layout, auth callback, tablo page, and list page"

Task 9: Client Invite UI in apps/main Share Dialog

Files:

  • Modify: apps/main/src/components/TabloHeaderActions.tsx (or wherever the share dialog lives)

  • Create: apps/main/src/hooks/client_invites.ts

  • Step 1: Create client invite hooks

Create apps/main/src/hooks/client_invites.ts:

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { api } from "@xtablo/shared/lib/api";
import { toast } from "@xtablo/shared";

export function usePendingClientInvites(tabloId: string) {
  const { session } = useSession();

  return useQuery({
    queryKey: ["client-invites", tabloId],
    queryFn: async () => {
      const res = await api.get(`/api/v1/client-invites/${tabloId}/pending`, {
        headers: { Authorization: `Bearer ${session?.access_token}` },
      });
      return res.data.invites as {
        id: number;
        invited_email: string;
        expires_at: string;
        is_pending: boolean;
        created_at: string;
      }[];
    },
    enabled: !!tabloId && !!session,
  });
}

export function useCreateClientInvite() {
  const { session } = useSession();
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ tabloId, email }: { tabloId: string; email: string }) => {
      const res = await api.post(
        `/api/v1/client-invites/${tabloId}`,
        { email },
        { headers: { Authorization: `Bearer ${session?.access_token}` } }
      );
      return res.data;
    },
    onSuccess: (_, { tabloId }) => {
      queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] });
      toast.add({ title: "Client invite sent", type: "success" }, { timeout: 3000 });
    },
    onError: () => {
      toast.add({ title: "Failed to send invite", type: "error" }, { timeout: 5000 });
    },
  });
}

export function useCancelClientInvite() {
  const { session } = useSession();
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ tabloId, inviteId }: { tabloId: string; inviteId: number }) => {
      const res = await api.delete(`/api/v1/client-invites/${tabloId}/${inviteId}`, {
        headers: { Authorization: `Bearer ${session?.access_token}` },
      });
      return res.data;
    },
    onSuccess: (_, { tabloId }) => {
      queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] });
    },
  });
}
  • Step 2: Add client invite section to the share dialog

In the share dialog component (either in TabloHeaderActions.tsx or in the share dialog in tablo-details.tsx), add a section below the existing invite section for client invites. This should include:

  1. A "Client Access" heading with a description
  2. An email input + "Send Magic Link" button
  3. A list of pending client invites with expiration dates and cancel buttons
  4. An expiration warning badge when expires_at is less than 5 days away

Use the hooks from step 1 (usePendingClientInvites, useCreateClientInvite, useCancelClientInvite).

The exact JSX depends on the existing share dialog structure. Follow the same patterns used for the existing pendingInvites list.

  • Step 3: Run typecheck and verify

Run: pnpm typecheck Run: pnpm dev:main Navigate to a tablo, open the share dialog. Verify the client invite section appears.

  • Step 4: Commit
git add apps/main/src/hooks/client_invites.ts apps/main/src/components/ apps/main/src/pages/
git commit -m "feat(main): add client invite UI to share dialog"

Task 10: End-to-End Verification

  • Step 1: Run full typecheck

Run: pnpm typecheck Expected: No errors across all packages

  • Step 2: Run all tests

Run: pnpm test Expected: All tests pass

  • Step 3: Run linter

Run: pnpm lint Expected: No errors (run pnpm lint:fix if needed)

  • Step 4: Verify apps/main dev server

Run: pnpm dev:main

  • Navigate to a tablo details page

  • Verify all tabs work (overview, etapes, tasks, files, discussion, events, roadmap)

  • Open share dialog, verify client invite section

  • Step 5: Verify apps/clients dev server

Run: pnpm dev:clients

  • Verify app loads on port 5175

  • Verify auth callback page renders

  • Verify tablo list page renders

  • Step 6: Final commit if any fixes were needed

git add -A
git commit -m "fix: resolve typecheck and lint issues from client magic links implementation"