# 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**

```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`:

```typescript
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**

```bash
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:

```typescript
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:
```typescript
.select("is_temporary")
```
to:
```typescript
.select("is_temporary, is_client")
```

Change the check from:
```typescript
if (!allowTemporaryUsers && profile.is_temporary) {
```
to:
```typescript
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:
```typescript
export const getBillableMemberCount = (profiles: BillingProfileRow[]) =>
  profiles.filter((profile) => profile.is_temporary !== true).length;
```
To:
```typescript
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**

```bash
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`:

```typescript
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`:

```typescript
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:

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

Add after the existing routes (before `return authRouter`):
```typescript
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:

```typescript
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**

```bash
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`**

```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`**

```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`**

```typescript
// 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**

```bash
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:
```typescript
import { useTabloMembers } from "../../hooks/tablos";
import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "../../hooks/tasks";
```
to:
```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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`:

```typescript
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**

```bash
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:
```json
"@xtablo/tablo-views": "workspace:*"
```

- [ ] **Step 2: Update imports in `tablo-details.tsx`**

Replace the local imports with package imports:

From:
```typescript
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:
```typescript
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**

```bash
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`**

```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`**

```typescript
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`**

```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`**

```typescript
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`**

```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`**

```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:

```css
@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`**

```typescript
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`**

```typescript
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`**

```typescript
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`:
```json
"dev:clients": "turbo dev --filter=@xtablo/clients"
```

- [ ] **Step 13: Run pnpm install**

Run: `pnpm install`

- [ ] **Step 14: Commit**

```bash
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`**

```typescript
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`**

```typescript
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`**

```typescript
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`**

```typescript
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**

```bash
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`:

```typescript
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**

```bash
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**

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