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_clientcolumn +client_invitestable + 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.jsonpackages/tablo-views/tsconfig.jsonpackages/tablo-views/src/index.ts— barrel exportpackages/tablo-views/src/TabloTasksSection.tsx— moved from apps/mainpackages/tablo-views/src/TabloFilesSection.tsx— moved from apps/mainpackages/tablo-views/src/TabloDiscussionSection.tsx— moved from apps/mainpackages/tablo-views/src/TabloEventsSection.tsx— moved from apps/mainpackages/tablo-views/src/EtapesSection.tsx— extracted from tablo-details.tsxpackages/tablo-views/src/RoadmapSection.tsx— extracted from tablo-details.tsxpackages/tablo-views/src/ChatMessages.tsx— moved from apps/mainpackages/tablo-views/src/TabloHeaderActions.tsx— moved from apps/mainpackages/tablo-views/src/hooks/useChat.ts— moved from apps/mainpackages/tablo-views/src/hooks/useChatUnread.ts— moved from apps/mainpackages/tablo-views/src/components/gantt/GanttChart.tsx— moved from apps/mainpackages/tablo-views/src/components/kanban/KanbanBoard.tsx— moved from apps/mainpackages/tablo-views/src/components/kanban/KanbanColumn.tsx— moved from apps/mainpackages/tablo-views/src/components/kanban/KanbanTaskCard.tsx— moved from apps/mainpackages/tablo-views/src/components/kanban/InlineTaskCreate.tsx— moved from apps/mainpackages/tablo-views/src/components/kanban/TaskModal.tsx— moved from apps/mainpackages/tablo-views/src/components/kanban/types.ts— moved from apps/main
App: apps/clients:
apps/clients/package.jsonapps/clients/vite.config.tsapps/clients/wrangler.tomlapps/clients/worker/index.tsapps/clients/index.htmlapps/clients/tsconfig.jsonapps/clients/src/main.tsxapps/clients/src/main.cssapps/clients/src/App.tsxapps/clients/src/routes.tsxapps/clients/src/i18n.tsapps/clients/src/pages/AuthCallback.tsxapps/clients/src/pages/ClientTabloPage.tsxapps/clients/src/pages/ClientTabloListPage.tsxapps/clients/src/components/ClientLayout.tsx
Modified files
apps/api/src/middlewares/middleware.ts— addis_clientcheck tocreateProfileAccessMiddlewareapps/api/src/routers/authRouter.ts— mountclientInvitesrouterapps/api/src/routers/tablo.ts— addcheckTabloAdminto new client invite endpointapps/api/src/helpers/helpers.ts— addcreateClientUser()functionapps/api/src/helpers/billing.ts— excludeis_clientfromgetBillableMemberCountapps/api/src/__tests__/middlewares/middlewares.test.ts— addis_clientmiddleware testsapps/main/src/pages/tablo-details.tsx— import sections from@xtablo/tablo-viewsinstead of localapps/main/src/components/TabloHeaderActions.tsx— add client invite UI to share dialogpackages/shared-types/src/database.types.ts— regenerated after migration (or manually add types)package.json(root) — adddev:clientsscriptpnpm-workspace.yaml— already coversapps/*andpackages/*, 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_clientuser blocked byregularUserCheckMiddleware
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
createClientUserhelper
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.tsxapps/main/src/components/TabloFilesSection.tsx->packages/tablo-views/src/TabloFilesSection.tsxapps/main/src/components/TabloDiscussionSection.tsx->packages/tablo-views/src/TabloDiscussionSection.tsxapps/main/src/components/TabloEventsSection.tsx->packages/tablo-views/src/TabloEventsSection.tsxapps/main/src/components/TabloHeaderActions.tsx->packages/tablo-views/src/TabloHeaderActions.tsxapps/main/src/components/ChatMessages.tsx->packages/tablo-views/src/ChatMessages.tsxapps/main/src/hooks/useChat.ts->packages/tablo-views/src/hooks/useChat.tsapps/main/src/hooks/useChatUnread.ts->packages/tablo-views/src/hooks/useChatUnread.tsapps/main/src/components/gantt/GanttChart.tsx->packages/tablo-views/src/components/gantt/GanttChart.tsxapps/main/src/components/kanban/KanbanBoard.tsx->packages/tablo-views/src/components/kanban/KanbanBoard.tsxapps/main/src/components/kanban/KanbanColumn.tsx->packages/tablo-views/src/components/kanban/KanbanColumn.tsxapps/main/src/components/kanban/KanbanTaskCard.tsx->packages/tablo-views/src/components/kanban/KanbanTaskCard.tsxapps/main/src/components/kanban/InlineTaskCreate.tsx->packages/tablo-views/src/components/kanban/InlineTaskCreate.tsxapps/main/src/components/kanban/TaskModal.tsx->packages/tablo-views/src/components/kanban/TaskModal.tsxapps/main/src/components/kanban/types.ts->packages/tablo-views/src/components/kanban/types.ts
Files to extract from tablo-details.tsx:
-
EtapesSectionfunction (lines 950-1288) ->packages/tablo-views/src/EtapesSection.tsx -
RoadmapSectionfunction (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:
- Move to
@xtablo/shared/hooks/(if they're pure React Query wrappers around API calls), OR - Stay in
apps/mainand 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):
- Copy the file to
packages/tablo-views/src/ - 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)
- Use
- Replace
@ui/components/LoadingSpinnerwith@xtablo/ui/components/loading-spinneror 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. TheuseIsReadOnlyUseranduseUserhooks depend on Zustand store fromapps/main. Solution: acceptisReadOnly: booleanandcurrentUseras props instead.
TabloDiscussionSection.tsx:
../hooks/useChat->./hooks/useChat../hooks/tablos-> check availability../providers/UserStoreProvider-> acceptcurrentUseras prop./ChatMessages->./ChatMessages
TabloEventsSection.tsx:
-
../hooks/events-> check availability -
../providers/UserStoreProvider-> acceptisReadOnlyas 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-viewsdependency toapps/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) — adddev:clientsscript -
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:clientsscript to rootpackage.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:
- A "Client Access" heading with a description
- An email input + "Send Magic Link" button
- A list of pending client invites with expiration dates and cancel buttons
- An expiration warning badge when
expires_atis 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/maindev 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/clientsdev 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"