diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index c40679e..0000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,330 +0,0 @@
-version: 2.1
-
-# Import the Node orb
-orbs:
- node: circleci/node@7.2.1
-
-# Jobs
-jobs:
- # ============================================
- # TEST PHASE
- # ============================================
-
- test-lint:
- executor:
- name: node/default
- resource_class: small
- tag: 'lts'
- steps:
- - checkout
- - node/install-packages:
- pkg-manager: pnpm
- - run:
- name: Run linting
- command: pnpm run lint
-
- test-typecheck:
- executor:
- name: node/default
- resource_class: small
- tag: 'lts'
- steps:
- - checkout
- - node/install-packages:
- pkg-manager: pnpm
- cache-path: ~/.pnpm-store
- - run:
- name: Type check all packages
- command: pnpm run typecheck
-
- test-unit:
- executor:
- name: node/default
- resource_class: medium
- tag: 'lts'
- steps:
- - checkout
- - node/install-packages:
- pkg-manager: pnpm
- cache-path: ~/.pnpm-store
- - run:
- name: Run unit tests
- command: pnpm --filter @xtablo/main run test
-
- test-api:
- executor:
- name: node/default
- tag: 'lts'
- resource_class: small
- steps:
- - checkout
- - node/install-packages:
- pkg-manager: pnpm
- cache-path: ~/.pnpm-store
- - run:
- name: Run API checks
- command: |
- if [ "${RUN_API_INTEGRATION_TESTS:-0}" = "1" ]; then
- pnpm --filter @xtablo/api run test
- else
- echo "Skipping API integration tests (set RUN_API_INTEGRATION_TESTS=1 to enable)."
- pnpm --filter @xtablo/api run build
- fi
-
- # ============================================
- # BUILD PHASE
- # ============================================
-
- build-apps:
- docker:
- - image: cimg/node:lts
- resource_class: small
- parameters:
- environment:
- type: string
- default: "staging"
- steps:
- - checkout
- - node/install-packages:
- pkg-manager: pnpm
- cache-path: ~/.pnpm-store
- - run:
- name: Build main app for << parameters.environment >>
- command: |
- cd apps/main
- pnpm run build:<< parameters.environment >>
- - run:
- name: Build external app
- command: |
- cd apps/external
- pnpm run build
- - persist_to_workspace:
- root: .
- paths:
- - apps/main/dist
- - apps/external/dist
- - store_artifacts:
- path: apps/main/dist
- destination: main-app-<< parameters.environment >>
- - store_artifacts:
- path: apps/external/dist
- destination: external-app
-
- build-api:
- docker:
- - image: cimg/node:lts
- resource_class: small
- steps:
- - checkout
- - node/install-packages:
- pkg-manager: pnpm
- cache-path: ~/.pnpm-store
- - run:
- name: Build API
- command: pnpm --filter @xtablo/api run build
- - persist_to_workspace:
- root: .
- paths:
- - apps/api/dist
- - store_artifacts:
- path: apps/api/dist
- destination: api
-
- # ============================================
- # DOCKER BUILD PHASE
- # ============================================
-
- build-docker-api:
- machine:
- image: ubuntu-2204:current
- resource_class: medium
- steps:
- - checkout
- - attach_workspace:
- at: .
- - run:
- name: Build API Docker image
- command: docker build -f apps/api/Dockerfile -t xtablo-api:${CIRCLE_SHA1} -t xtablo-api:latest .
- - run:
- name: Save Docker image
- command: |
- mkdir -p /tmp/docker-images
- docker save xtablo-api:${CIRCLE_SHA1} -o /tmp/docker-images/api.tar
- - persist_to_workspace:
- root: /tmp
- paths:
- - docker-images/api.tar
-
- # ============================================
- # DEPLOY PHASE
- # ============================================
-
- deploy-staging:
- docker:
- - image: cimg/node:lts
- resource_class: small
- steps:
- - checkout
- - attach_workspace:
- at: .
- - node/install-packages:
- pkg-manager: pnpm
- cache-path: ~/.pnpm-store
- - run:
- name: Deploy main app to staging
- command: |
- cd apps/main
- echo "Deploying main app to staging environment..."
- npx wrangler deploy --env staging
- - run:
- name: Deploy external app to staging
- command: |
- cd apps/external
- echo "Deploying external app to staging..."
- # Add external app staging deployment if needed
- # npx wrangler deploy --env staging
- - run:
- name: Deploy API to staging
- command: |
- echo "Deploying API to staging environment..."
- # Add your API deployment commands here
- # Example for Google Cloud Run:
- # gcloud run deploy xtablo-api-staging --image gcr.io/${GCP_PROJECT}/xtablo-api:${CIRCLE_SHA1} --region us-central1
-
- deploy-production:
- docker:
- - image: cimg/node:lts
- resource_class: small
- steps:
- - checkout
- - attach_workspace:
- at: .
- - node/install-packages:
- pkg-manager: pnpm
- cache-path: ~/.pnpm-store
- - run:
- name: Deploy main app to production
- command: |
- cd apps/main
- echo "Deploying main app to production environment..."
- npx wrangler deploy --env production
- - run:
- name: Deploy external app to production
- command: |
- cd apps/external
- echo "Deploying external app to production..."
- # Add external app production deployment if needed
- # npx wrangler deploy --env production
- - run:
- name: Deploy API to production
- command: |
- echo "Deploying API to production environment..."
- # Add your production API deployment commands here
- # Example for Google Cloud Run:
- # gcloud run deploy xtablo-api --image gcr.io/${GCP_PROJECT}/xtablo-api:${CIRCLE_SHA1} --region us-central1
-
-# Workflows
-workflows:
- version: 2
-
- # Run on all branches (except main and develop)
- test-and-build:
- when:
- and:
- - not:
- equal: [ main, << pipeline.git.branch >> ]
- - not:
- equal: [ develop, << pipeline.git.branch >> ]
- jobs:
- # Test phase - run in parallel
- - test-lint
- - test-typecheck
- - test-unit
- - test-api
-
- # Build phase - run after tests pass
- - build-apps:
- requires:
- - test-lint
- - test-typecheck
- - test-unit
-
- - build-api:
- requires:
- - test-api
-
- - build-docker-api:
- requires:
- - build-api
-
- # Staging deployment workflow (develop branch)
- deploy-to-staging:
- when:
- equal: [ develop, << pipeline.git.branch >> ]
- jobs:
- # Test phase
- - test-lint
- - test-typecheck
- - test-unit
- - test-api
-
- # Build phase for staging
- - build-apps:
- environment: "staging"
- requires:
- - test-lint
- - test-typecheck
- - test-unit
-
- - build-api:
- requires:
- - test-api
-
- - build-docker-api:
- requires:
- - build-api
-
- # Deploy to staging
- - deploy-staging:
- requires:
- - build-apps
- - build-docker-api
-
- # Production deployment workflow (main branch)
- deploy-to-production:
- when:
- equal: [ main, << pipeline.git.branch >> ]
- jobs:
- # Test phase
- - test-lint
- - test-typecheck
- - test-unit
- - test-api
-
- # Build phase for production
- - build-apps:
- environment: "prod"
- requires:
- - test-lint
- - test-typecheck
- - test-unit
-
- - build-api:
- requires:
- - test-api
-
- - build-docker-api:
- requires:
- - build-api
-
- # Manual approval gate before production
- - hold-for-approval:
- type: approval
- requires:
- - build-apps
- - build-docker-api
-
- # Deploy to production
- - deploy-production:
- requires:
- - hold-for-approval
diff --git a/.dockerignore b/.dockerignore
index 30331cb..aaa32d1 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -38,7 +38,6 @@ node_modules
# CI/CD
.github
**/cloudbuild.yaml
-**/.circleci
# Misc
**/.turbo
@@ -48,4 +47,3 @@ node_modules
**/temp
**/.next
**/.nuxt
-
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..d917eb5
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,55 @@
+name: xtablo-ci
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ checks:
+ name: Checks
+ runs-on:
+ - self-hosted
+ - linux
+ - x64
+
+ timeout-minutes: 45
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v5
+ with:
+ version: 10.19.0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 20
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile --child-concurrency=2
+
+ - name: Lint
+ run: pnpm turbo run lint --concurrency=2
+
+ - name: Typecheck
+ run: pnpm turbo run typecheck --concurrency=1
+
+ - name: Test main app
+ run: pnpm --filter @xtablo/main test -- --maxWorkers=1 --no-file-parallelism
+
+ - name: Test clients app
+ run: pnpm --filter @xtablo/clients test -- --maxWorkers=1 --no-file-parallelism
+
+ - name: Typecheck API
+ run: pnpm --filter @xtablo/api typecheck
diff --git a/.github/workflows/frontend-sourcemaps.yml b/.github/workflows/frontend-sourcemaps.yml
new file mode 100644
index 0000000..70ab116
--- /dev/null
+++ b/.github/workflows/frontend-sourcemaps.yml
@@ -0,0 +1,74 @@
+name: Frontend Sourcemaps
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ - develop
+
+jobs:
+ upload-sourcemaps:
+ runs-on:
+ - self-hosted
+ - linux
+ - x64
+ env:
+ DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
+ DATADOG_SITE: datadoghq.com
+ RELEASE_VERSION: ${{ github.sha }}
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+
+ - name: Set up pnpm
+ uses: pnpm/action-setup@v5
+ with:
+ version: 10.19.0
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - name: Validate Datadog secrets
+ run: |
+ test -n "$DATADOG_API_KEY"
+ test -n "$DATADOG_SITE"
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile --child-concurrency=2
+
+ - name: Build main
+ run: pnpm --filter @xtablo/main build:prod
+ env:
+ VITE_APP_VERSION: ${{ env.RELEASE_VERSION }}
+
+ - name: Upload main sourcemaps
+ run: pnpm exec datadog-ci sourcemaps upload apps/main/dist --service xtablo-ui --release-version "$RELEASE_VERSION" --minified-path-prefix https://app.xtablo.com/assets
+
+ - name: Remove main sourcemaps
+ run: find apps/main/dist -name '*.map' -delete
+
+ - name: Build clients
+ run: pnpm --filter @xtablo/clients build:prod
+ env:
+ VITE_APP_VERSION: ${{ env.RELEASE_VERSION }}
+
+ - name: Upload clients sourcemaps
+ run: pnpm exec datadog-ci sourcemaps upload apps/clients/dist --service xtablo-clients --release-version "$RELEASE_VERSION" --minified-path-prefix https://clients.xtablo.com/assets
+
+ - name: Remove clients sourcemaps
+ run: find apps/clients/dist -name '*.map' -delete
+
+ - name: Build external
+ run: pnpm --filter @xtablo/external build
+ env:
+ VITE_APP_VERSION: ${{ env.RELEASE_VERSION }}
+
+ - name: Upload external sourcemaps
+ run: pnpm exec datadog-ci sourcemaps upload apps/external/dist --service xtablo-external --release-version "$RELEASE_VERSION" --minified-path-prefix https://embed.xtablo.com/assets
+
+ - name: Remove external sourcemaps
+ run: find apps/external/dist -name '*.map' -delete
diff --git a/apps/admin/index.html b/apps/admin/index.html
new file mode 100644
index 0000000..49d4ec8
--- /dev/null
+++ b/apps/admin/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ XTablo Admin
+
+
+
+
+
+
diff --git a/apps/admin/package.json b/apps/admin/package.json
new file mode 100644
index 0000000..c431f5f
--- /dev/null
+++ b/apps/admin/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@xtablo/admin",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev --port 5176",
+ "build": "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",
+ "test": "vitest run --mode test --passWithNoTests",
+ "test:watch": "vitest watch --mode test --passWithNoTests",
+ "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",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.3.0",
+ "@types/react": "19.0.10",
+ "@types/react-dom": "19.0.4",
+ "@vitejs/plugin-react": "^4.3.4",
+ "happy-dom": "^20.0.0",
+ "tailwindcss": "^4.0.14",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.2.2",
+ "vite-tsconfig-paths": "^5.1.4",
+ "vitest": "^3.2.4",
+ "wrangler": "^4.24.3"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.69.0",
+ "@xtablo/shared": "workspace:*",
+ "@xtablo/shared-types": "workspace:*",
+ "@xtablo/ui": "workspace:*",
+ "react": "19.0.0",
+ "react-dom": "19.0.0",
+ "react-router-dom": "^7.9.4"
+ }
+}
diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx
new file mode 100644
index 0000000..d723e06
--- /dev/null
+++ b/apps/admin/src/App.tsx
@@ -0,0 +1,9 @@
+import AppRoutes from "./routes";
+
+export default function App() {
+ return (
+
+ );
+}
diff --git a/apps/admin/src/components/AdminLayout.test.tsx b/apps/admin/src/components/AdminLayout.test.tsx
new file mode 100644
index 0000000..469d6af
--- /dev/null
+++ b/apps/admin/src/components/AdminLayout.test.tsx
@@ -0,0 +1,66 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { storeAdminSession } from "../lib/adminSession";
+import { adminApi } from "../lib/api";
+import AppRoutes from "../routes";
+
+vi.mock("../lib/api", () => ({
+ adminApi: {
+ get: vi.fn(),
+ },
+}));
+
+describe("AdminLayout", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ storeAdminSession({
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
+ operatorEmail: "ops@xtablo.com",
+ operatorId: "operator-1",
+ role: "operator",
+ sessionToken: "admin-session-token",
+ });
+ vi.mocked(adminApi.get).mockResolvedValue({
+ data: {
+ alerts: [],
+ metrics: [],
+ shortcuts: [],
+ },
+ });
+ });
+
+ it("shows the production badge and admin sections", async () => {
+ render(
+
+
+
+ );
+
+ expect(await screen.findByText(/^production$/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", {
+ name: /production command deck for privileged supabase operations/i,
+ })
+ ).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /operations home/i })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /data explorer/i })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /analytics studio/i })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /action center/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /lock admin app/i })).toBeInTheDocument();
+ });
+
+ it("clears the stored admin session when locking the app", async () => {
+ render(
+
+
+
+ );
+
+ const button = await screen.findByRole("button", { name: /lock admin app/i });
+ fireEvent.click(button);
+
+ expect(localStorage.getItem("xtablo-admin-session")).toBeNull();
+ });
+});
diff --git a/apps/admin/src/components/AdminLayout.tsx b/apps/admin/src/components/AdminLayout.tsx
new file mode 100644
index 0000000..257aa04
--- /dev/null
+++ b/apps/admin/src/components/AdminLayout.tsx
@@ -0,0 +1,38 @@
+import { Outlet } from "react-router-dom";
+import { useAdminSession } from "../hooks/useAdminSession";
+import { AdminNavigation } from "./AdminNavigation";
+import { ProductionBadge } from "./ProductionBadge";
+
+export function AdminLayout() {
+ const { logout, operatorEmail } = useAdminSession();
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/AdminNavigation.tsx b/apps/admin/src/components/AdminNavigation.tsx
new file mode 100644
index 0000000..c004fe8
--- /dev/null
+++ b/apps/admin/src/components/AdminNavigation.tsx
@@ -0,0 +1,30 @@
+import { NavLink } from "react-router-dom";
+
+const navItems = [
+ { label: "Operations Home", to: "/" },
+ { label: "Data Explorer", to: "/explorer" },
+ { label: "Analytics Studio", to: "/analytics" },
+ { label: "Action Center", to: "/actions" },
+];
+
+export function AdminNavigation() {
+ return (
+
+ );
+}
diff --git a/apps/admin/src/components/PrivilegedGate.test.tsx b/apps/admin/src/components/PrivilegedGate.test.tsx
new file mode 100644
index 0000000..72e19dd
--- /dev/null
+++ b/apps/admin/src/components/PrivilegedGate.test.tsx
@@ -0,0 +1,61 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { adminApi } from "../lib/api";
+import AppRoutes from "../routes";
+
+vi.mock("../lib/api", () => ({
+ adminApi: {
+ get: vi.fn(),
+ post: vi.fn(),
+ },
+}));
+
+describe("PrivilegedGate", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+ vi.mocked(adminApi.get).mockResolvedValue({
+ data: {
+ alerts: [],
+ metrics: [],
+ shortcuts: [],
+ },
+ });
+ });
+
+ it("exchanges a privileged token and enters the admin shell", async () => {
+ vi.mocked(adminApi.post).mockResolvedValue({
+ data: {
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
+ operatorEmail: "ops@xtablo.com",
+ operatorId: "operator-1",
+ role: "operator",
+ sessionToken: "admin-session-token",
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ fireEvent.change(screen.getByLabelText(/access token/i), {
+ target: { value: "valid-access-token" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /unlock admin/i }));
+
+ await waitFor(() => {
+ expect(adminApi.post).toHaveBeenCalledWith("/admin/auth/exchange", {
+ accessToken: "valid-access-token",
+ });
+ });
+
+ expect(
+ await screen.findByRole("heading", {
+ name: /production command deck for privileged supabase operations/i,
+ })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/apps/admin/src/components/PrivilegedGate.tsx b/apps/admin/src/components/PrivilegedGate.tsx
new file mode 100644
index 0000000..3bdfa36
--- /dev/null
+++ b/apps/admin/src/components/PrivilegedGate.tsx
@@ -0,0 +1,51 @@
+import { FormEvent, useState } from "react";
+
+type PrivilegedGateProps = {
+ error?: string | null;
+ isPending?: boolean;
+ onUnlock: (accessToken: string) => Promise;
+};
+
+export function PrivilegedGate({ error = null, isPending = false, onUnlock }: PrivilegedGateProps) {
+ const [accessToken, setAccessToken] = useState("");
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ await onUnlock(accessToken);
+ };
+
+ return (
+
+
+
Internal Only
+
Admin access token required
+
+ Normal XTablo login is not sufficient. Enter a privileged access token to unlock the
+ internal admin dashboard.
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/ProductionBadge.tsx b/apps/admin/src/components/ProductionBadge.tsx
new file mode 100644
index 0000000..7583812
--- /dev/null
+++ b/apps/admin/src/components/ProductionBadge.tsx
@@ -0,0 +1,7 @@
+export function ProductionBadge() {
+ return (
+
+ Production
+
+ );
+}
diff --git a/apps/admin/src/components/actions/ActionRunner.tsx b/apps/admin/src/components/actions/ActionRunner.tsx
new file mode 100644
index 0000000..6d0bf05
--- /dev/null
+++ b/apps/admin/src/components/actions/ActionRunner.tsx
@@ -0,0 +1,133 @@
+import type { AdminActionSummary } from "@xtablo/shared-types";
+import { useEffect, useMemo, useState } from "react";
+import { actionSeverityCopy } from "../../registry/actions";
+
+type ActionRunnerProps = {
+ actions: AdminActionSummary[];
+ error: string | null;
+ isRunning: boolean;
+ onRun: (payload: Record) => Promise;
+ onSelectActionId: (actionId: string) => void;
+ resultMessage: string | null;
+ selectedActionId: string | null;
+};
+
+export function ActionRunner({
+ actions,
+ error,
+ isRunning,
+ onRun,
+ onSelectActionId,
+ resultMessage,
+ selectedActionId,
+}: ActionRunnerProps) {
+ const selectedAction = useMemo(
+ () => actions.find((action) => action.id === selectedActionId) ?? null,
+ [actions, selectedActionId]
+ );
+ const [values, setValues] = useState>({});
+
+ useEffect(() => {
+ if (!selectedAction) {
+ return;
+ }
+
+ setValues(Object.fromEntries(selectedAction.fields.map((field) => [field.id, ""])));
+ }, [selectedAction]);
+
+ const tone = selectedAction
+ ? actionSeverityCopy[selectedAction.id as keyof typeof actionSeverityCopy]
+ : null;
+
+ return (
+
+
+
+
+ {selectedAction ? (
+
+ ) : (
+ Select an action to begin.
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/components/analytics/ChartBuilder.tsx b/apps/admin/src/components/analytics/ChartBuilder.tsx
new file mode 100644
index 0000000..9bc80b4
--- /dev/null
+++ b/apps/admin/src/components/analytics/ChartBuilder.tsx
@@ -0,0 +1,183 @@
+import type {
+ AdminDatasetPoint,
+ AdminDatasetResult,
+ AdminDatasetSummary,
+} from "@xtablo/shared-types";
+
+type ChartBuilderProps = {
+ dataset: AdminDatasetResult | null;
+ datasets: AdminDatasetSummary[];
+ onSelectDatasetId: (datasetId: string) => void;
+ selectedDatasetId: string | null;
+};
+
+function BarChart({ points }: { points: AdminDatasetPoint[] }) {
+ const maxValue = Math.max(...points.map((point) => point.value), 1);
+
+ return (
+
+ {points.map((point) => (
+
+
+
+
{point.value}
+
+ {point.label}
+
+
+
+ ))}
+
+ );
+}
+
+function LineChart({ points }: { points: AdminDatasetPoint[] }) {
+ const width = 560;
+ const height = 220;
+ const maxValue = Math.max(...points.map((point) => point.value), 1);
+ const polyline = points
+ .map((point, index) => {
+ const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width;
+ const y = height - (point.value / maxValue) * (height - 24) - 12;
+ return `${x},${y}`;
+ })
+ .join(" ");
+
+ return (
+
+
+
+ {points.map((point) => (
+
+
{point.label}
+
{point.value}
+
+ ))}
+
+
+ );
+}
+
+function DonutChart({ points }: { points: AdminDatasetPoint[] }) {
+ const total = points.reduce((sum, point) => sum + point.value, 0) || 1;
+ const palette = ["#172554", "#0f766e", "#b45309", "#7c2d12", "#475569"];
+ let currentStop = 0;
+ const gradientStops = points
+ .map((point, index) => {
+ const start = currentStop;
+ currentStop += (point.value / total) * 100;
+ return `${palette[index % palette.length]} ${start}% ${currentStop}%`;
+ })
+ .join(", ");
+
+ return (
+
+
+
+ {points.map((point, index) => (
+
+ ))}
+
+
+ );
+}
+
+export function ChartBuilder({
+ dataset,
+ datasets,
+ onSelectDatasetId,
+ selectedDatasetId,
+}: ChartBuilderProps) {
+ return (
+
+
+ {datasets.map((entry) => (
+
+ ))}
+
+
+ {dataset ? (
+
+
+
+
Dataset
+
{dataset.label}
+
{dataset.description}
+
+
+
+ {dataset.dimensionLabel} x {dataset.metricLabel}
+
+
+ {dataset.points.reduce((sum, point) => sum + point.value, 0)}
+
+
+
+
+ {dataset.chartType === "line" ? : null}
+ {dataset.chartType === "bar" ? : null}
+ {dataset.chartType === "donut" ? : null}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/admin/src/components/analytics/SavedDashboardList.tsx b/apps/admin/src/components/analytics/SavedDashboardList.tsx
new file mode 100644
index 0000000..a76fa65
--- /dev/null
+++ b/apps/admin/src/components/analytics/SavedDashboardList.tsx
@@ -0,0 +1,35 @@
+type SavedDashboard = {
+ datasetId: string;
+ description: string;
+ id: string;
+ label: string;
+};
+
+type SavedDashboardListProps = {
+ dashboards: readonly SavedDashboard[];
+ onOpen: (datasetId: string) => void;
+};
+
+export function SavedDashboardList({ dashboards, onOpen }: SavedDashboardListProps) {
+ return (
+
+
+
Saved Views
+
Operator Dashboards
+
+
+ {dashboards.map((dashboard) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/admin/src/components/data-explorer/AdminGrid.tsx b/apps/admin/src/components/data-explorer/AdminGrid.tsx
new file mode 100644
index 0000000..db49c90
--- /dev/null
+++ b/apps/admin/src/components/data-explorer/AdminGrid.tsx
@@ -0,0 +1,47 @@
+import type { AdminTableMeta } from "@xtablo/shared-types";
+
+type AdminGridProps = {
+ meta: AdminTableMeta | null;
+ onSelectRow: (row: Record) => void;
+ rows: Record[];
+ selectedRowId: string | null;
+};
+
+export function AdminGrid({ meta, onSelectRow, rows, selectedRowId }: AdminGridProps) {
+ if (!meta) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {meta.columns.map((column) => (
+ |
+ {column.label}
+ |
+ ))}
+
+
+
+ {rows.map((row, index) => (
+ onSelectRow(row)}
+ >
+ {meta.columns.map((column) => (
+ |
+ {String(row[column.id] ?? "")}
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/data-explorer/RowEditForm.test.tsx b/apps/admin/src/components/data-explorer/RowEditForm.test.tsx
new file mode 100644
index 0000000..26892f7
--- /dev/null
+++ b/apps/admin/src/components/data-explorer/RowEditForm.test.tsx
@@ -0,0 +1,43 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { RowEditForm } from "./RowEditForm";
+
+describe("RowEditForm", () => {
+ it("shows a diff preview before saving a sensitive record", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+
+ render(
+
+ );
+
+ fireEvent.change(screen.getByLabelText(/first name/i), {
+ target: { value: "Ada" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /review changes/i }));
+
+ expect(await screen.findByText(/before/i)).toBeInTheDocument();
+ expect(screen.getByText(/after/i)).toBeInTheDocument();
+ expect(screen.getByText(/first name:\s*test/i)).toBeInTheDocument();
+ expect(screen.getByText(/first name:\s*ada/i)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: /confirm update/i }));
+
+ await waitFor(() =>
+ expect(onSave).toHaveBeenCalledWith({
+ first_name: "Ada",
+ })
+ );
+ });
+});
diff --git a/apps/admin/src/components/data-explorer/RowEditForm.tsx b/apps/admin/src/components/data-explorer/RowEditForm.tsx
new file mode 100644
index 0000000..bcb1572
--- /dev/null
+++ b/apps/admin/src/components/data-explorer/RowEditForm.tsx
@@ -0,0 +1,112 @@
+import type { AdminTableColumn } from "@xtablo/shared-types";
+import { FormEvent, useEffect, useMemo, useState } from "react";
+
+type RowEditFormProps = {
+ columns: AdminTableColumn[];
+ editableFields: string[];
+ isSaving?: boolean;
+ onSave: (changes: Record) => Promise;
+ record: Record;
+};
+
+export function RowEditForm({
+ columns,
+ editableFields,
+ isSaving = false,
+ onSave,
+ record,
+}: RowEditFormProps) {
+ const [draft, setDraft] = useState(record);
+ const [showDiff, setShowDiff] = useState(false);
+
+ useEffect(() => {
+ setDraft(record);
+ setShowDiff(false);
+ }, [record]);
+
+ const editableColumns = useMemo(
+ () => columns.filter((column) => editableFields.includes(column.id)),
+ [columns, editableFields]
+ );
+
+ const changedFields = editableColumns.filter((column) => draft[column.id] !== record[column.id]);
+ const hasChanges = changedFields.length > 0;
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ setShowDiff(true);
+ };
+
+ const handleSave = async () => {
+ if (!hasChanges) {
+ return;
+ }
+
+ await onSave(
+ Object.fromEntries(changedFields.map((column) => [column.id, draft[column.id] ?? null]))
+ );
+ setShowDiff(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/admin/src/hooks/useAdminActions.ts b/apps/admin/src/hooks/useAdminActions.ts
new file mode 100644
index 0000000..4256922
--- /dev/null
+++ b/apps/admin/src/hooks/useAdminActions.ts
@@ -0,0 +1,98 @@
+import type { AdminActionRunResponse, AdminActionSummary } from "@xtablo/shared-types";
+import { useEffect, useState } from "react";
+import { adminApi } from "../lib/api";
+
+function getErrorMessage(error: unknown, fallbackMessage: string) {
+ if (typeof error === "object" && error !== null && "response" in error) {
+ const response = error.response;
+ if (
+ typeof response === "object" &&
+ response !== null &&
+ "data" in response &&
+ typeof response.data === "object" &&
+ response.data !== null &&
+ "error" in response.data &&
+ typeof response.data.error === "string"
+ ) {
+ return response.data.error;
+ }
+ }
+
+ return fallbackMessage;
+}
+
+export function useAdminActions() {
+ const [actions, setActions] = useState([]);
+ const [selectedActionId, setSelectedActionId] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isRunning, setIsRunning] = useState(false);
+ const [error, setError] = useState(null);
+ const [resultMessage, setResultMessage] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const loadActions = async () => {
+ try {
+ const response = await adminApi.get<{ actions: AdminActionSummary[] }>("/admin/actions");
+ if (!isMounted) {
+ return;
+ }
+
+ setActions(response.data.actions);
+ setSelectedActionId((currentValue) => currentValue ?? response.data.actions[0]?.id ?? null);
+ } catch (error) {
+ if (isMounted) {
+ setError(getErrorMessage(error, "Failed to load admin actions"));
+ }
+ } finally {
+ if (isMounted) {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ void loadActions();
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ const runAction = async (payload: Record) => {
+ if (!selectedActionId) {
+ return;
+ }
+
+ setIsRunning(true);
+ setError(null);
+ setResultMessage(null);
+
+ try {
+ const response = await adminApi.post(
+ `/admin/actions/${selectedActionId}/run`,
+ payload
+ );
+ setResultMessage(response.data.message);
+ } catch (error) {
+ const message = getErrorMessage(error, "Failed to run admin action");
+ setError(message);
+ throw new Error(message);
+ } finally {
+ setIsRunning(false);
+ }
+ };
+
+ return {
+ actions,
+ error,
+ isLoading,
+ isRunning,
+ resultMessage,
+ runAction,
+ selectedActionId,
+ setError,
+ setResultMessage,
+ setSelectedActionId,
+ };
+}
diff --git a/apps/admin/src/hooks/useAdminDatasets.ts b/apps/admin/src/hooks/useAdminDatasets.ts
new file mode 100644
index 0000000..b74bd4d
--- /dev/null
+++ b/apps/admin/src/hooks/useAdminDatasets.ts
@@ -0,0 +1,109 @@
+import type { AdminDatasetResult, AdminDatasetSummary } from "@xtablo/shared-types";
+import { useEffect, useState } from "react";
+import { adminApi } from "../lib/api";
+
+function getErrorMessage(error: unknown, fallbackMessage: string) {
+ if (typeof error === "object" && error !== null && "response" in error) {
+ const response = error.response;
+ if (
+ typeof response === "object" &&
+ response !== null &&
+ "data" in response &&
+ typeof response.data === "object" &&
+ response.data !== null &&
+ "error" in response.data &&
+ typeof response.data.error === "string"
+ ) {
+ return response.data.error;
+ }
+ }
+
+ return fallbackMessage;
+}
+
+export function useAdminDatasets() {
+ const [datasets, setDatasets] = useState([]);
+ const [selectedDatasetId, setSelectedDatasetId] = useState(null);
+ const [dataset, setDataset] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const loadDatasets = async () => {
+ try {
+ const response = await adminApi.get<{ datasets: AdminDatasetSummary[] }>("/admin/datasets");
+ if (!isMounted) {
+ return;
+ }
+
+ setDatasets(response.data.datasets);
+ setSelectedDatasetId(
+ (currentValue) => currentValue ?? response.data.datasets[0]?.id ?? null
+ );
+ } catch (error) {
+ if (isMounted) {
+ setError(getErrorMessage(error, "Failed to load admin datasets"));
+ setIsLoading(false);
+ }
+ }
+ };
+
+ void loadDatasets();
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const loadDataset = async () => {
+ if (!selectedDatasetId) {
+ setIsLoading(false);
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await adminApi.get(
+ `/admin/datasets/${selectedDatasetId}`
+ );
+
+ if (!isMounted) {
+ return;
+ }
+
+ setDataset(response.data);
+ } catch (error) {
+ if (isMounted) {
+ setError(getErrorMessage(error, "Failed to load admin dataset"));
+ setDataset(null);
+ }
+ } finally {
+ if (isMounted) {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ void loadDataset();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [selectedDatasetId]);
+
+ return {
+ dataset,
+ datasets,
+ error,
+ isLoading,
+ selectedDatasetId,
+ setSelectedDatasetId,
+ };
+}
diff --git a/apps/admin/src/hooks/useAdminOverview.ts b/apps/admin/src/hooks/useAdminOverview.ts
new file mode 100644
index 0000000..f33df94
--- /dev/null
+++ b/apps/admin/src/hooks/useAdminOverview.ts
@@ -0,0 +1,44 @@
+import type { AdminOverviewResponse } from "@xtablo/shared-types";
+import { useEffect, useState } from "react";
+import { adminApi } from "../lib/api";
+
+export function useAdminOverview() {
+ const [overview, setOverview] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const loadOverview = async () => {
+ try {
+ const response = await adminApi.get("/admin/overview");
+ if (!isMounted) {
+ return;
+ }
+
+ setOverview(response.data);
+ } catch {
+ if (isMounted) {
+ setError("Failed to load admin overview");
+ }
+ } finally {
+ if (isMounted) {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ void loadOverview();
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ return {
+ error,
+ isLoading,
+ overview,
+ };
+}
diff --git a/apps/admin/src/hooks/useAdminSession.ts b/apps/admin/src/hooks/useAdminSession.ts
new file mode 100644
index 0000000..0f25690
--- /dev/null
+++ b/apps/admin/src/hooks/useAdminSession.ts
@@ -0,0 +1,57 @@
+import type { AdminSessionResponse } from "@xtablo/shared-types";
+import { useEffect, useState } from "react";
+import {
+ clearStoredAdminSession,
+ getStoredAdminSession,
+ type StoredAdminSession,
+ storeAdminSession,
+} from "../lib/adminSession";
+import { adminApi } from "../lib/api";
+
+export function useAdminSession() {
+ const [session, setSession] = useState(() => getStoredAdminSession());
+ const [isPending, setIsPending] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setSession(getStoredAdminSession());
+ }, []);
+
+ const unlock = async (accessToken: string) => {
+ setIsPending(true);
+ setError(null);
+
+ try {
+ const response = await adminApi.post("/admin/auth/exchange", {
+ accessToken,
+ });
+
+ storeAdminSession(response.data);
+ setSession(response.data);
+ return response.data;
+ } catch {
+ clearStoredAdminSession();
+ setSession(null);
+ setError("Invalid privileged access token");
+ return null;
+ } finally {
+ setIsPending(false);
+ }
+ };
+
+ const logout = () => {
+ clearStoredAdminSession();
+ setSession(null);
+ };
+
+ return {
+ error,
+ isAuthenticated: session !== null,
+ isPending,
+ logout,
+ operatorEmail: session?.operatorEmail ?? null,
+ role: session?.role ?? null,
+ session,
+ unlock,
+ };
+}
diff --git a/apps/admin/src/hooks/useAdminTables.ts b/apps/admin/src/hooks/useAdminTables.ts
new file mode 100644
index 0000000..3335d6e
--- /dev/null
+++ b/apps/admin/src/hooks/useAdminTables.ts
@@ -0,0 +1,145 @@
+import type { AdminTableMeta, AdminTableSummary } from "@xtablo/shared-types";
+import { useEffect, useState } from "react";
+import { adminApi } from "../lib/api";
+
+export type AdminRow = Record;
+
+function getErrorMessage(error: unknown, fallbackMessage: string) {
+ if (typeof error === "object" && error !== null && "response" in error) {
+ const response = error.response;
+ if (
+ typeof response === "object" &&
+ response !== null &&
+ "data" in response &&
+ typeof response.data === "object" &&
+ response.data !== null &&
+ "error" in response.data &&
+ typeof response.data.error === "string"
+ ) {
+ return response.data.error;
+ }
+ }
+
+ return fallbackMessage;
+}
+
+export function useAdminTables() {
+ const [tables, setTables] = useState([]);
+ const [selectedTableId, setSelectedTableId] = useState(null);
+ const [meta, setMeta] = useState(null);
+ const [rows, setRows] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const loadTables = async () => {
+ try {
+ const response = await adminApi.get<{ tables: AdminTableSummary[] }>("/admin/tables");
+ if (!isMounted) {
+ return;
+ }
+
+ setTables(response.data.tables);
+ setSelectedTableId((currentValue) => currentValue ?? response.data.tables[0]?.id ?? null);
+ } catch {
+ if (isMounted) {
+ setError("Failed to load admin tables");
+ }
+ }
+ };
+
+ void loadTables();
+
+ return () => {
+ isMounted = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const loadTableData = async () => {
+ if (!selectedTableId) {
+ setIsLoading(false);
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const [metaResponse, rowsResponse] = await Promise.all([
+ adminApi.get(`/admin/tables/${selectedTableId}/meta`),
+ adminApi.get<{ rows: AdminRow[] }>(`/admin/tables/${selectedTableId}/rows`),
+ ]);
+
+ if (!isMounted) {
+ return;
+ }
+
+ setMeta(metaResponse.data);
+ setRows(rowsResponse.data.rows);
+ } catch {
+ if (isMounted) {
+ setError("Failed to load admin table data");
+ setMeta(null);
+ setRows([]);
+ }
+ } finally {
+ if (isMounted) {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ void loadTableData();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [selectedTableId]);
+
+ const updateRow = async (rowId: string, changes: Partial) => {
+ if (!selectedTableId) {
+ throw new Error("No admin table selected");
+ }
+
+ try {
+ const response = await adminApi.patch<{ row: AdminRow }>(
+ `/admin/tables/${selectedTableId}/rows/${rowId}`,
+ changes
+ );
+ const updatedRow = response.data.row;
+
+ setRows((currentRows) =>
+ currentRows.map((row) => {
+ if (String(row[meta?.primaryKey ?? "id"] ?? "") !== rowId) {
+ return row;
+ }
+
+ return updatedRow;
+ })
+ );
+ setError(null);
+
+ return updatedRow;
+ } catch (error) {
+ const message = getErrorMessage(error, "Failed to update admin row");
+ setError(message);
+ throw new Error(message);
+ }
+ };
+
+ return {
+ error,
+ isLoading,
+ meta,
+ rows,
+ selectedTableId,
+ setSelectedTableId,
+ tables,
+ updateRow,
+ };
+}
diff --git a/apps/admin/src/lib/adminSession.ts b/apps/admin/src/lib/adminSession.ts
new file mode 100644
index 0000000..5eab8e1
--- /dev/null
+++ b/apps/admin/src/lib/adminSession.ts
@@ -0,0 +1,38 @@
+import type { AdminRole } from "@xtablo/shared-types";
+
+const ADMIN_SESSION_STORAGE_KEY = "xtablo-admin-session";
+
+export type StoredAdminSession = {
+ expiresAt: string;
+ operatorEmail: string;
+ operatorId: string;
+ role: AdminRole;
+ sessionToken: string;
+};
+
+export function getStoredAdminSession() {
+ const rawSession = localStorage.getItem(ADMIN_SESSION_STORAGE_KEY);
+ if (!rawSession) {
+ return null;
+ }
+
+ try {
+ const parsedSession = JSON.parse(rawSession) as StoredAdminSession;
+ if (new Date(parsedSession.expiresAt).getTime() <= Date.now()) {
+ localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
+ return null;
+ }
+ return parsedSession;
+ } catch {
+ localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
+ return null;
+ }
+}
+
+export function storeAdminSession(session: StoredAdminSession) {
+ localStorage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify(session));
+}
+
+export function clearStoredAdminSession() {
+ localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
+}
diff --git a/apps/admin/src/lib/api.test.ts b/apps/admin/src/lib/api.test.ts
new file mode 100644
index 0000000..7ed9724
--- /dev/null
+++ b/apps/admin/src/lib/api.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vitest";
+import { resolveAdminApiBaseUrl } from "./api";
+
+describe("resolveAdminApiBaseUrl", () => {
+ it("pins the deployed admin panel to the production api", () => {
+ expect(resolveAdminApiBaseUrl("production", "https://api-staging.xtablo.com/api/v1")).toBe(
+ "https://api.xtablo.com/api/v1"
+ );
+ expect(resolveAdminApiBaseUrl("production")).toBe("https://api.xtablo.com/api/v1");
+ });
+
+ it("keeps localhost for local development", () => {
+ expect(resolveAdminApiBaseUrl("development")).toBe("http://localhost:8080/api/v1");
+ });
+});
diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts
new file mode 100644
index 0000000..5ddfcdb
--- /dev/null
+++ b/apps/admin/src/lib/api.ts
@@ -0,0 +1,27 @@
+import { buildApi } from "@xtablo/shared";
+import { getStoredAdminSession } from "./adminSession";
+
+const LOCAL_ADMIN_API_BASE_URL = "http://localhost:8080/api/v1";
+const PRODUCTION_ADMIN_API_BASE_URL = "https://api.xtablo.com/api/v1";
+
+export function resolveAdminApiBaseUrl(mode = import.meta.env.MODE, _envApiUrl?: string) {
+ if (mode === "development") {
+ return LOCAL_ADMIN_API_BASE_URL;
+ }
+
+ return PRODUCTION_ADMIN_API_BASE_URL;
+}
+
+const apiBaseUrl = resolveAdminApiBaseUrl();
+
+export const adminApi = buildApi(apiBaseUrl);
+
+adminApi.interceptors.request.use((config) => {
+ const adminSession = getStoredAdminSession();
+
+ if (adminSession) {
+ config.headers.Authorization = `Bearer ${adminSession.sessionToken}`;
+ }
+
+ return config;
+});
diff --git a/apps/admin/src/main.css b/apps/admin/src/main.css
new file mode 100644
index 0000000..70631d1
--- /dev/null
+++ b/apps/admin/src/main.css
@@ -0,0 +1,30 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --background: oklch(0.97 0.01 95);
+ --foreground: oklch(0.2 0.02 255);
+ --card: oklch(0.995 0.002 95);
+ --card-foreground: oklch(0.2 0.02 255);
+ --border: oklch(0.88 0.01 95);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-border: var(--border);
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+
+ body {
+ @apply bg-background text-foreground antialiased;
+ }
+}
diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx
new file mode 100644
index 0000000..6781fde
--- /dev/null
+++ b/apps/admin/src/main.tsx
@@ -0,0 +1,15 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import App from "./App";
+
+import "@xtablo/ui/globals.css";
+import "./main.css";
+
+createRoot(document.getElementById("admin-root")!).render(
+
+
+
+
+
+);
diff --git a/apps/admin/src/pages/ActionCenterPage.test.tsx b/apps/admin/src/pages/ActionCenterPage.test.tsx
new file mode 100644
index 0000000..6ffa003
--- /dev/null
+++ b/apps/admin/src/pages/ActionCenterPage.test.tsx
@@ -0,0 +1,66 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { adminApi } from "../lib/api";
+import { ActionCenterPage } from "./ActionCenterPage";
+
+vi.mock("../lib/api", () => ({
+ adminApi: {
+ get: vi.fn(),
+ post: vi.fn(),
+ },
+}));
+
+describe("ActionCenterPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("loads actions and runs a guarded workflow", async () => {
+ vi.mocked(adminApi.get).mockResolvedValue({
+ data: {
+ actions: [
+ {
+ description: "Disable a user's access to a tablo.",
+ fields: [
+ { id: "tabloId", label: "Tablo ID", required: true },
+ { id: "userId", label: "User ID", required: true },
+ { id: "reason", label: "Reason", required: true },
+ ],
+ id: "deactivate_tablo_access",
+ label: "Deactivate Tablo Access",
+ },
+ ],
+ },
+ });
+ vi.mocked(adminApi.post).mockResolvedValue({
+ data: {
+ message: "Tablo access deactivated and logged.",
+ success: true,
+ },
+ });
+
+ render();
+
+ expect(await screen.findByText(/action center/i)).toBeInTheDocument();
+
+ fireEvent.change(screen.getByLabelText(/tablo id/i), {
+ target: { value: "tablo-1" },
+ });
+ fireEvent.change(screen.getByLabelText(/user id/i), {
+ target: { value: "user-1" },
+ });
+ fireEvent.change(screen.getByLabelText(/reason/i), {
+ target: { value: "manual cleanup" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /run action/i }));
+
+ await waitFor(() =>
+ expect(adminApi.post).toHaveBeenCalledWith("/admin/actions/deactivate_tablo_access/run", {
+ reason: "manual cleanup",
+ tabloId: "tablo-1",
+ userId: "user-1",
+ })
+ );
+ expect(await screen.findByText(/deactivated and logged/i)).toBeInTheDocument();
+ });
+});
diff --git a/apps/admin/src/pages/ActionCenterPage.tsx b/apps/admin/src/pages/ActionCenterPage.tsx
new file mode 100644
index 0000000..b4330bf
--- /dev/null
+++ b/apps/admin/src/pages/ActionCenterPage.tsx
@@ -0,0 +1,47 @@
+import { ActionRunner } from "../components/actions/ActionRunner";
+import { useAdminActions } from "../hooks/useAdminActions";
+
+export function ActionCenterPage() {
+ const {
+ actions,
+ error,
+ isLoading,
+ isRunning,
+ resultMessage,
+ runAction,
+ selectedActionId,
+ setError,
+ setResultMessage,
+ setSelectedActionId,
+ } = useAdminActions();
+
+ return (
+
+
+ Actions
+ Action Center
+
+ Run guarded production actions with explicit operator input and audit logging.
+
+
+
+ {isLoading ?
Loading actions...
: null}
+
+ {!isLoading ? (
+
{
+ setSelectedActionId(actionId);
+ setError(null);
+ setResultMessage(null);
+ }}
+ resultMessage={resultMessage}
+ selectedActionId={selectedActionId}
+ />
+ ) : null}
+
+ );
+}
diff --git a/apps/admin/src/pages/AnalyticsStudioPage.test.tsx b/apps/admin/src/pages/AnalyticsStudioPage.test.tsx
new file mode 100644
index 0000000..f55d574
--- /dev/null
+++ b/apps/admin/src/pages/AnalyticsStudioPage.test.tsx
@@ -0,0 +1,85 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { adminApi } from "../lib/api";
+import { AnalyticsStudioPage } from "./AnalyticsStudioPage";
+
+vi.mock("../lib/api", () => ({
+ adminApi: {
+ get: vi.fn(),
+ },
+}));
+
+describe("AnalyticsStudioPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("loads curated datasets and switches charts", async () => {
+ vi.mocked(adminApi.get).mockImplementation(async (path: string) => {
+ if (path === "/admin/datasets") {
+ return {
+ data: {
+ datasets: [
+ {
+ description: "New users over time.",
+ id: "profile_growth",
+ label: "User Growth",
+ },
+ {
+ description: "Users by plan.",
+ id: "plan_mix",
+ label: "Plan Mix",
+ },
+ ],
+ },
+ };
+ }
+
+ if (path === "/admin/datasets/profile_growth") {
+ return {
+ data: {
+ chartType: "line",
+ description: "New users over time.",
+ dimensionLabel: "Created Day",
+ id: "profile_growth",
+ label: "User Growth",
+ metricLabel: "Users Created",
+ points: [
+ { label: "2026-04-20", value: 2 },
+ { label: "2026-04-21", value: 4 },
+ ],
+ },
+ };
+ }
+
+ if (path === "/admin/datasets/plan_mix") {
+ return {
+ data: {
+ chartType: "donut",
+ description: "Users by plan.",
+ dimensionLabel: "Plan",
+ id: "plan_mix",
+ label: "Plan Mix",
+ metricLabel: "Users",
+ points: [
+ { label: "solo", value: 6 },
+ { label: "team", value: 3 },
+ ],
+ },
+ };
+ }
+
+ throw new Error(`Unexpected path: ${path}`);
+ });
+
+ render();
+
+ expect(await screen.findByText(/analytics studio/i)).toBeInTheDocument();
+ expect(await screen.findByRole("button", { name: /user growth/i })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: /plan mix/i }));
+
+ await waitFor(() => expect(adminApi.get).toHaveBeenCalledWith("/admin/datasets/plan_mix"));
+ expect(await screen.findByText(/total/i)).toBeInTheDocument();
+ });
+});
diff --git a/apps/admin/src/pages/AnalyticsStudioPage.tsx b/apps/admin/src/pages/AnalyticsStudioPage.tsx
new file mode 100644
index 0000000..1f696f6
--- /dev/null
+++ b/apps/admin/src/pages/AnalyticsStudioPage.tsx
@@ -0,0 +1,38 @@
+import { ChartBuilder } from "../components/analytics/ChartBuilder";
+import { SavedDashboardList } from "../components/analytics/SavedDashboardList";
+import { useAdminDatasets } from "../hooks/useAdminDatasets";
+import { savedDashboardPresets } from "../registry/datasets";
+
+export function AnalyticsStudioPage() {
+ const { dataset, datasets, error, isLoading, selectedDatasetId, setSelectedDatasetId } =
+ useAdminDatasets();
+
+ return (
+
+
+ Analytics
+ Analytics Studio
+
+ Curated production datasets for operators who need charted context before they take action
+ in the explorer or action center.
+
+
+
+ {isLoading ?
Loading analytics...
: null}
+ {error ?
{error}
: null}
+
+
+
+ setSelectedDatasetId(datasetId)}
+ />
+
+
+ );
+}
diff --git a/apps/admin/src/pages/DataExplorerPage.test.tsx b/apps/admin/src/pages/DataExplorerPage.test.tsx
new file mode 100644
index 0000000..944d5c3
--- /dev/null
+++ b/apps/admin/src/pages/DataExplorerPage.test.tsx
@@ -0,0 +1,98 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { adminApi } from "../lib/api";
+import { DataExplorerPage } from "./DataExplorerPage";
+
+vi.mock("../lib/api", () => ({
+ adminApi: {
+ get: vi.fn(),
+ patch: vi.fn(),
+ },
+}));
+
+describe("DataExplorerPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("loads rows for the selected table and saves approved edits", async () => {
+ vi.mocked(adminApi.get).mockImplementation(async (path: string) => {
+ if (path === "/admin/tables") {
+ return {
+ data: {
+ tables: [
+ { id: "profiles", label: "Users" },
+ { id: "tablo_access", label: "Tablo Access" },
+ ],
+ },
+ };
+ }
+
+ if (path === "/admin/tables/profiles/meta") {
+ return {
+ data: {
+ columns: [
+ { id: "id", label: "ID" },
+ { id: "email", label: "Email" },
+ { id: "first_name", label: "First name" },
+ ],
+ editableFields: ["first_name"],
+ id: "profiles",
+ label: "Users",
+ primaryKey: "id",
+ },
+ };
+ }
+
+ if (path === "/admin/tables/profiles/rows") {
+ return {
+ data: {
+ rows: [
+ {
+ email: "test_owner@example.com",
+ first_name: "Test",
+ id: "user-1",
+ },
+ ],
+ },
+ };
+ }
+
+ throw new Error(`Unexpected path: ${path}`);
+ });
+ vi.mocked(adminApi.patch).mockResolvedValue({
+ data: {
+ row: {
+ email: "test_owner@example.com",
+ first_name: "Ada",
+ id: "user-1",
+ },
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ expect(await screen.findByRole("button", { name: /users/i })).toBeInTheDocument();
+ expect(await screen.findByText(/email/i)).toBeInTheDocument();
+ expect(await screen.findByText(/test_owner@example.com/i)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText(/test_owner@example.com/i));
+ fireEvent.change(screen.getByLabelText(/first name/i), {
+ target: { value: "Ada" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /review changes/i }));
+ fireEvent.click(screen.getByRole("button", { name: /confirm update/i }));
+
+ await waitFor(() =>
+ expect(adminApi.patch).toHaveBeenCalledWith("/admin/tables/profiles/rows/user-1", {
+ first_name: "Ada",
+ })
+ );
+ expect(await screen.findByText(/row updated and logged/i)).toBeInTheDocument();
+ });
+});
diff --git a/apps/admin/src/pages/DataExplorerPage.tsx b/apps/admin/src/pages/DataExplorerPage.tsx
new file mode 100644
index 0000000..a7e35e8
--- /dev/null
+++ b/apps/admin/src/pages/DataExplorerPage.tsx
@@ -0,0 +1,143 @@
+import { useEffect, useMemo, useState } from "react";
+import { AdminGrid } from "../components/data-explorer/AdminGrid";
+import { RowEditForm } from "../components/data-explorer/RowEditForm";
+import { useAdminTables } from "../hooks/useAdminTables";
+
+export function DataExplorerPage() {
+ const { error, isLoading, meta, rows, selectedTableId, setSelectedTableId, tables, updateRow } =
+ useAdminTables();
+ const [selectedRowId, setSelectedRowId] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveMessage, setSaveMessage] = useState(null);
+
+ useEffect(() => {
+ setSelectedRowId(null);
+ setSaveMessage(null);
+ }, [selectedTableId]);
+
+ const selectedRow = useMemo(() => {
+ if (!meta || !selectedRowId) {
+ return null;
+ }
+
+ return rows.find((row) => String(row[meta.primaryKey] ?? "") === selectedRowId) ?? null;
+ }, [meta, rows, selectedRowId]);
+
+ const handleSave = async (changes: Record) => {
+ if (!selectedRowId) {
+ return;
+ }
+
+ setIsSaving(true);
+ setSaveMessage(null);
+
+ try {
+ await updateRow(selectedRowId, changes);
+ setSaveMessage("Row updated and logged.");
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ {isLoading ? Loading explorer...
: null}
+ {error ? {error}
: null}
+ {!isLoading && !error ? (
+ {
+ if (!meta) {
+ return;
+ }
+
+ setSelectedRowId(String(row[meta.primaryKey] ?? ""));
+ setSaveMessage(null);
+ }}
+ rows={rows}
+ selectedRowId={selectedRowId}
+ />
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/pages/OperationsHomePage.tsx b/apps/admin/src/pages/OperationsHomePage.tsx
new file mode 100644
index 0000000..6260c83
--- /dev/null
+++ b/apps/admin/src/pages/OperationsHomePage.tsx
@@ -0,0 +1,92 @@
+import { Link } from "react-router-dom";
+import { useAdminOverview } from "../hooks/useAdminOverview";
+
+export function OperationsHomePage() {
+ const { error, isLoading, overview } = useAdminOverview();
+
+ return (
+
+
+ Operations
+
+ Production command deck for privileged Supabase operations.
+
+
+ Monitor the current state of users, access grants, and tablos before drilling into
+ explorer edits, analytics, or controlled admin actions.
+
+
+
+ {isLoading ?
Loading operations overview...
: null}
+ {error ?
{error}
: null}
+
+ {overview ? (
+ <>
+
+ {overview.metrics.map((metric) => (
+
+
+ {metric.label}
+
+ {metric.value}
+ {metric.changeLabel}
+
+ ))}
+
+
+
+
+
+
Alerts
+
Operational Watchlist
+
+
+ {overview.alerts.map((alert) => (
+
+
+
+ {alert.severity}
+
+
{alert.title}
+
+ {alert.description}
+
+ ))}
+
+
+
+
+
Shortcuts
+
Common Paths
+
+ {overview.shortcuts.map((shortcut) => (
+
+ {shortcut.label}
+
+ ))}
+
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/apps/admin/src/registry/actions.ts b/apps/admin/src/registry/actions.ts
new file mode 100644
index 0000000..d159b57
--- /dev/null
+++ b/apps/admin/src/registry/actions.ts
@@ -0,0 +1,10 @@
+export const actionSeverityCopy = {
+ deactivate_tablo_access: {
+ badge: "Restriction",
+ tone: "warning",
+ },
+ grant_tablo_admin: {
+ badge: "Privilege",
+ tone: "critical",
+ },
+} as const;
diff --git a/apps/admin/src/registry/datasets.ts b/apps/admin/src/registry/datasets.ts
new file mode 100644
index 0000000..8fa262e
--- /dev/null
+++ b/apps/admin/src/registry/datasets.ts
@@ -0,0 +1,20 @@
+export const savedDashboardPresets = [
+ {
+ datasetId: "profile_growth",
+ description: "Track production user creation velocity.",
+ id: "growth",
+ label: "Growth Watch",
+ },
+ {
+ datasetId: "plan_mix",
+ description: "Review monetization mix across the current user base.",
+ id: "plans",
+ label: "Plan Pulse",
+ },
+ {
+ datasetId: "tablo_access_mix",
+ description: "Spot access drift and admin-heavy tablos.",
+ id: "access",
+ label: "Access Posture",
+ },
+] as const;
diff --git a/apps/admin/src/routes.test.tsx b/apps/admin/src/routes.test.tsx
new file mode 100644
index 0000000..418cc42
--- /dev/null
+++ b/apps/admin/src/routes.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import AppRoutes from "./routes";
+
+it("renders the privileged gate on the root route", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText(/admin access token/i)).toBeInTheDocument();
+});
diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx
new file mode 100644
index 0000000..d3ddd57
--- /dev/null
+++ b/apps/admin/src/routes.tsx
@@ -0,0 +1,33 @@
+import { Outlet, Route, Routes } from "react-router-dom";
+import { AdminLayout } from "./components/AdminLayout";
+import { PrivilegedGate } from "./components/PrivilegedGate";
+import { useAdminSession } from "./hooks/useAdminSession";
+import { ActionCenterPage } from "./pages/ActionCenterPage";
+import { AnalyticsStudioPage } from "./pages/AnalyticsStudioPage";
+import { DataExplorerPage } from "./pages/DataExplorerPage";
+import { OperationsHomePage } from "./pages/OperationsHomePage";
+
+function AdminEntry() {
+ const { error, isAuthenticated, isPending, unlock } = useAdminSession();
+
+ if (isAuthenticated) {
+ return ;
+ }
+
+ return ;
+}
+
+export default function AppRoutes() {
+ return (
+
+ }>
+ }>
+ } />
+ } />
+ } />
+ } />
+
+
+
+ );
+}
diff --git a/apps/admin/src/setupTests.ts b/apps/admin/src/setupTests.ts
new file mode 100644
index 0000000..b403818
--- /dev/null
+++ b/apps/admin/src/setupTests.ts
@@ -0,0 +1,6 @@
+import "@testing-library/jest-dom";
+import { cleanup } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json
new file mode 100644
index 0000000..4a98594
--- /dev/null
+++ b/apps/admin/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "types": ["vite/client", "vitest/globals"],
+ "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/shared-types": ["../../packages/shared-types/src"],
+ "@xtablo/shared-types/*": ["../../packages/shared-types/src/*"]
+ }
+ },
+ "include": ["src", "worker"],
+ "references": []
+}
diff --git a/apps/admin/tsconfig.tsbuildinfo b/apps/admin/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..2e651c2
--- /dev/null
+++ b/apps/admin/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/app.tsx","./src/main.tsx","./src/routes.test.tsx","./src/routes.tsx","./src/setuptests.ts","./src/components/adminlayout.test.tsx","./src/components/adminlayout.tsx","./src/components/adminnavigation.tsx","./src/components/privilegedgate.test.tsx","./src/components/privilegedgate.tsx","./src/components/productionbadge.tsx","./src/components/actions/actionrunner.tsx","./src/components/analytics/chartbuilder.tsx","./src/components/analytics/saveddashboardlist.tsx","./src/components/data-explorer/admingrid.tsx","./src/components/data-explorer/roweditform.test.tsx","./src/components/data-explorer/roweditform.tsx","./src/hooks/useadminactions.ts","./src/hooks/useadmindatasets.ts","./src/hooks/useadminoverview.ts","./src/hooks/useadminsession.ts","./src/hooks/useadmintables.ts","./src/lib/adminsession.ts","./src/lib/api.test.ts","./src/lib/api.ts","./src/pages/actioncenterpage.test.tsx","./src/pages/actioncenterpage.tsx","./src/pages/analyticsstudiopage.test.tsx","./src/pages/analyticsstudiopage.tsx","./src/pages/dataexplorerpage.test.tsx","./src/pages/dataexplorerpage.tsx","./src/pages/operationshomepage.tsx","./src/registry/actions.ts","./src/registry/datasets.ts","./worker/index.test.ts","./worker/index.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts
new file mode 100644
index 0000000..b80495d
--- /dev/null
+++ b/apps/admin/vite.config.ts
@@ -0,0 +1,29 @@
+///
+
+import { cloudflare } from "@cloudflare/vite-plugin";
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig, type PluginOption } from "vite";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+export default defineConfig(({ mode }) => {
+ const plugins: PluginOption[] = [
+ react(),
+ tailwindcss(),
+ tsconfigPaths({ ignoreConfigErrors: true }),
+ ];
+
+ if (mode !== "test" && process.env.VITEST !== "true") {
+ plugins.push(cloudflare({ inspectorPort: 9233 }));
+ }
+
+ return {
+ plugins,
+ server: { cors: false },
+ test: {
+ globals: true,
+ environment: "happy-dom",
+ setupFiles: "./src/setupTests.ts",
+ },
+ };
+});
diff --git a/apps/admin/worker/index.test.ts b/apps/admin/worker/index.test.ts
new file mode 100644
index 0000000..41bc176
--- /dev/null
+++ b/apps/admin/worker/index.test.ts
@@ -0,0 +1,88 @@
+// @vitest-environment node
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import worker, {
+ ADMIN_APP_SESSION_COOKIE,
+ buildAccessDeniedHtml,
+ createSignedAdminAppSession,
+} from "./index";
+
+const env = {
+ ADMIN_APP_ACCESS_TOKEN: "super-secret-admin-app-token",
+ ADMIN_APP_SESSION_SECRET: "worker-session-secret",
+ ASSETS: {
+ fetch: vi.fn(async () => new Response("app", { status: 200 })),
+ },
+};
+
+describe("admin worker firewall", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("serves the admin access gate when no session cookie is present", async () => {
+ const response = await worker.fetch(
+ new Request("https://admin-panel.xtablo.com/", {
+ headers: {
+ accept: "text/html",
+ },
+ }),
+ env
+ );
+
+ expect(response.status).toBe(401);
+ await expect(response.text()).resolves.toContain("Internal Admin Access");
+ });
+
+ it("creates a signed app session cookie from a valid access token", async () => {
+ const request = new Request("https://admin-panel.xtablo.com/__admin/access", {
+ body: new URLSearchParams({ accessToken: env.ADMIN_APP_ACCESS_TOKEN }),
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ method: "POST",
+ });
+
+ const response = await worker.fetch(request, env);
+
+ expect(response.status).toBe(302);
+ expect(response.headers.get("location")).toBe("https://admin-panel.xtablo.com/");
+ expect(response.headers.get("set-cookie")).toContain(`${ADMIN_APP_SESSION_COOKIE}=`);
+ });
+
+ it("allows authenticated requests through to static assets", async () => {
+ const session = await createSignedAdminAppSession(env.ADMIN_APP_SESSION_SECRET);
+ const request = new Request("https://admin-panel.xtablo.com/", {
+ headers: {
+ cookie: `${ADMIN_APP_SESSION_COOKIE}=${session}`,
+ },
+ });
+
+ const response = await worker.fetch(request, env);
+
+ expect(response.status).toBe(200);
+ expect(env.ASSETS.fetch).toHaveBeenCalledOnce();
+ });
+
+ it("rejects invalid access tokens", async () => {
+ const request = new Request("https://admin-panel.xtablo.com/__admin/access", {
+ body: new URLSearchParams({ accessToken: "wrong-token" }),
+ headers: {
+ accept: "text/html",
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ method: "POST",
+ });
+
+ const response = await worker.fetch(request, env);
+
+ expect(response.status).toBe(401);
+ await expect(response.text()).resolves.toContain("Invalid app access token");
+ });
+});
+
+describe("buildAccessDeniedHtml", () => {
+ it("renders the access error when provided", () => {
+ expect(buildAccessDeniedHtml("Bad token")).toContain("Bad token");
+ });
+});
diff --git a/apps/admin/worker/index.ts b/apps/admin/worker/index.ts
new file mode 100644
index 0000000..32af99f
--- /dev/null
+++ b/apps/admin/worker/index.ts
@@ -0,0 +1,280 @@
+export const ADMIN_APP_SESSION_COOKIE = "xtablo-admin-app-session";
+const ADMIN_ACCESS_PATH = "/__admin/access";
+const ADMIN_LOGOUT_PATH = "/__admin/logout";
+const SESSION_TTL_SECONDS = 60 * 60 * 12;
+
+type WorkerEnv = {
+ ADMIN_APP_ACCESS_TOKEN: string;
+ ADMIN_APP_SESSION_SECRET: string;
+ ASSETS: {
+ fetch: (request: Request) => Promise;
+ };
+};
+
+function base64UrlEncode(bytes: Uint8Array) {
+ return btoa(String.fromCharCode(...bytes))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=/g, "");
+}
+
+function base64UrlDecode(value: string) {
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
+ const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4));
+ const binary = atob(`${normalized}${padding}`);
+
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
+}
+
+async function importSigningKey(secret: string) {
+ return crypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign", "verify"]
+ );
+}
+
+async function signValue(value: string, secret: string) {
+ const key = await importSigningKey(secret);
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value));
+
+ return base64UrlEncode(new Uint8Array(signature));
+}
+
+async function verifyValueSignature(value: string, signature: string, secret: string) {
+ const key = await importSigningKey(secret);
+
+ return crypto.subtle.verify(
+ "HMAC",
+ key,
+ base64UrlDecode(signature),
+ new TextEncoder().encode(value)
+ );
+}
+
+function parseCookie(cookieHeader: string | null, cookieName: string) {
+ if (!cookieHeader) {
+ return null;
+ }
+
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`));
+ return match?.[1] ?? null;
+}
+
+export async function createSignedAdminAppSession(secret: string, now = Date.now()) {
+ const expiresAt = Math.floor(now / 1000) + SESSION_TTL_SECONDS;
+ const payload = `${expiresAt}`;
+ const signature = await signValue(payload, secret);
+
+ return `${payload}.${signature}`;
+}
+
+export async function hasValidAdminAppSession(request: Request, secret: string, now = Date.now()) {
+ const sessionCookie = parseCookie(request.headers.get("cookie"), ADMIN_APP_SESSION_COOKIE);
+ if (!sessionCookie) {
+ return false;
+ }
+
+ const [expiresAtValue, signature] = sessionCookie.split(".");
+ if (!expiresAtValue || !signature) {
+ return false;
+ }
+
+ const expiresAt = Number.parseInt(expiresAtValue, 10);
+ if (Number.isNaN(expiresAt) || expiresAt <= Math.floor(now / 1000)) {
+ return false;
+ }
+
+ return verifyValueSignature(expiresAtValue, signature, secret);
+}
+
+function isHtmlRequest(request: Request) {
+ const accept = request.headers.get("accept") ?? "";
+ return accept.includes("text/html") || accept.includes("*/*");
+}
+
+function buildSessionCookie(session: string) {
+ return `${ADMIN_APP_SESSION_COOKIE}=${session}; HttpOnly; Path=/; SameSite=Strict; Secure; Max-Age=${SESSION_TTL_SECONDS}`;
+}
+
+function buildExpiredSessionCookie() {
+ return `${ADMIN_APP_SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Strict; Secure; Max-Age=0`;
+}
+
+export function buildAccessDeniedHtml(error?: string) {
+ return `
+
+
+
+
+ XTablo Admin Access
+
+
+
+
+ Internal Only
+ Internal Admin Access
+
+ This app is firewalled behind a dedicated app-access token before any admin session
+ can be established.
+
+
+ ${error ? `${error}
` : ""}
+
+
+
+`;
+}
+
+function respondUnauthorized(request: Request, error?: string) {
+ if (isHtmlRequest(request)) {
+ return new Response(buildAccessDeniedHtml(error), {
+ headers: {
+ "Content-Type": "text/html; charset=utf-8",
+ "Cache-Control": "no-store",
+ },
+ status: 401,
+ });
+ }
+
+ return new Response("Unauthorized", { status: 401 });
+}
+
+async function handleAccessRequest(request: Request, env: WorkerEnv) {
+ const formData = await request.formData().catch(() => null);
+ const accessToken = formData?.get("accessToken");
+
+ if (accessToken !== env.ADMIN_APP_ACCESS_TOKEN) {
+ return respondUnauthorized(request, "Invalid app access token");
+ }
+
+ const session = await createSignedAdminAppSession(env.ADMIN_APP_SESSION_SECRET);
+ const location = new URL("/", request.url).toString();
+
+ return new Response(null, {
+ headers: {
+ Location: location,
+ "Set-Cookie": buildSessionCookie(session),
+ "Cache-Control": "no-store",
+ },
+ status: 302,
+ });
+}
+
+async function handleLogoutRequest(request: Request) {
+ return new Response(null, {
+ headers: {
+ Location: new URL("/", request.url).toString(),
+ "Set-Cookie": buildExpiredSessionCookie(),
+ "Cache-Control": "no-store",
+ },
+ status: 302,
+ });
+}
+
+export default {
+ async fetch(request: Request, env: WorkerEnv) {
+ const url = new URL(request.url);
+
+ if (request.method === "POST" && url.pathname === ADMIN_ACCESS_PATH) {
+ return handleAccessRequest(request, env);
+ }
+
+ if (request.method === "POST" && url.pathname === ADMIN_LOGOUT_PATH) {
+ return handleLogoutRequest(request);
+ }
+
+ const hasSession = await hasValidAdminAppSession(request, env.ADMIN_APP_SESSION_SECRET);
+ if (!hasSession) {
+ return respondUnauthorized(request);
+ }
+
+ return env.ASSETS.fetch(request);
+ },
+};
diff --git a/apps/admin/wrangler.toml b/apps/admin/wrangler.toml
new file mode 100644
index 0000000..8889e95
--- /dev/null
+++ b/apps/admin/wrangler.toml
@@ -0,0 +1,14 @@
+name = "xtablo-admin"
+main = "worker/index.ts"
+compatibility_date = "2025-07-09"
+
+[assets]
+directory = "./dist/"
+not_found_handling = "single-page-application"
+
+[observability]
+enabled = true
+
+[[routes]]
+pattern = "admin-panel.xtablo.com"
+custom_domain = true
diff --git a/apps/api/README.md b/apps/api/README.md
index cfb930a..124ccb9 100644
--- a/apps/api/README.md
+++ b/apps/api/README.md
@@ -88,3 +88,54 @@ See `.env.example` for required environment variables.
## Deployment
The API is deployed to Google Cloud Run. See `cloudbuild.yaml` for deployment configuration.
+
+## Dokploy Deployment
+
+Prefer a Dokploy `Application` instead of a Compose service.
+
+Dokploy supports Dockerfile-based applications directly, which fits this API better than maintaining a dedicated compose wrapper. Configure the application like this:
+
+- Build Type: `Dockerfile`
+- Dockerfile Path: `apps/api/Dockerfile`
+- Docker Context Path: `.`
+- Docker Build Stage: `final`
+- Port: `8080`
+- Run Command: leave empty and use the image `CMD`
+
+Set these Dokploy environment variables:
+
+- `NODE_ENV`
+- `DD_SERVICE=xtablo-api`
+- `DD_ENV`
+- `DD_VERSION`
+- `DD_LOGS_INJECTION=true`
+- `SUPABASE_URL`
+- `SUPABASE_SERVICE_ROLE_KEY`
+- `SUPABASE_CONNECTION_STRING`
+- `SUPABASE_CA_CERT`
+- `STRIPE_SECRET_KEY`
+- `STRIPE_WEBHOOK_SECRET`
+- `STRIPE_SOLO_PRICE_ID`
+- `STRIPE_TEAM_PRICE_ID`
+- `STRIPE_FOUNDER_PRICE_ID`
+- `EMAIL_USER`
+- `EMAIL_CLIENT_ID`
+- `EMAIL_CLIENT_SECRET`
+- `EMAIL_REFRESH_TOKEN`
+- `R2_ACCOUNT_ID`
+- `R2_ACCESS_KEY_ID`
+- `R2_SECRET_ACCESS_KEY`
+- `TASKS_SECRET`
+
+For Datadog logs, the Dokploy host should already run a Datadog Agent with Docker log collection enabled. Then add these Docker Swarm labels in Dokploy `Advanced Settings -> Swarm Settings -> Labels`:
+
+- `com.datadoghq.tags.service=xtablo-api`
+- `com.datadoghq.tags.env=${DD_ENV}`
+- `com.datadoghq.tags.version=${DD_VERSION}`
+- `com.datadoghq.ad.logs=[{"source":"nodejs","service":"xtablo-api"}]`
+
+This gives you:
+
+- container logs in Datadog with `source: nodejs`
+- service tagging as `xtablo-api`
+- APM/log correlation via `dd-trace` plus `DD_LOGS_INJECTION=true`
diff --git a/apps/api/src/__tests__/config/stripe-config.test.ts b/apps/api/src/__tests__/config/stripe-config.test.ts
index fdc130b..0a29917 100644
--- a/apps/api/src/__tests__/config/stripe-config.test.ts
+++ b/apps/api/src/__tests__/config/stripe-config.test.ts
@@ -3,6 +3,7 @@ import { createConfig } from "../../config.js";
import type { Secrets } from "../../secrets.js";
const baseSecrets: Secrets = {
+ adminTokenSigningSecret: "admin-token-signing-secret",
supabaseServiceRoleKey: "service-role-from-secret-manager",
supabaseConnectionString: "postgres://secret-manager",
supabaseCaCert: "ca-cert",
diff --git a/apps/api/src/__tests__/helpers/adminTokenTestUtils.ts b/apps/api/src/__tests__/helpers/adminTokenTestUtils.ts
new file mode 100644
index 0000000..859816e
--- /dev/null
+++ b/apps/api/src/__tests__/helpers/adminTokenTestUtils.ts
@@ -0,0 +1,22 @@
+import { createHmac } from "node:crypto";
+
+type TestAdminTokenClaims = {
+ aud: string;
+ email: string;
+ exp: number;
+ role: "viewer" | "operator" | "superadmin";
+ sub: string;
+ type: "admin_access" | "admin_session";
+};
+
+function encodeSegment(value: unknown) {
+ return Buffer.from(JSON.stringify(value)).toString("base64url");
+}
+
+export function createSignedAdminToken(claims: TestAdminTokenClaims, secret: string) {
+ const header = encodeSegment({ alg: "HS256", typ: "JWT" });
+ const payload = encodeSegment(claims);
+ const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
+
+ return `${header}.${payload}.${signature}`;
+}
diff --git a/apps/api/src/__tests__/helpers/billing.test.ts b/apps/api/src/__tests__/helpers/billing.test.ts
index c43a874..62c0d11 100644
--- a/apps/api/src/__tests__/helpers/billing.test.ts
+++ b/apps/api/src/__tests__/helpers/billing.test.ts
@@ -28,12 +28,14 @@ describe("billing helpers", () => {
id: "owner-user",
created_at: "2026-01-01T10:00:00.000Z",
is_temporary: false,
+ is_client: false,
plan: "annual",
},
{
id: "late-user",
created_at: "2026-01-02T10:00:00.000Z",
is_temporary: false,
+ is_client: false,
plan: "solo",
},
]);
@@ -47,18 +49,21 @@ describe("billing helpers", () => {
id: "user-1",
created_at: "2026-01-01T10:00:00.000Z",
is_temporary: false,
+ is_client: false,
plan: "solo",
},
{
id: "temp-1",
created_at: "2026-01-02T10:00:00.000Z",
is_temporary: true,
+ is_client: false,
plan: "solo",
},
{
id: "user-2",
created_at: "2026-01-03T10:00:00.000Z",
is_temporary: null,
+ is_client: false,
plan: "team",
},
]);
diff --git a/apps/api/src/__tests__/middlewares/adminAuth.test.ts b/apps/api/src/__tests__/middlewares/adminAuth.test.ts
new file mode 100644
index 0000000..a595103
--- /dev/null
+++ b/apps/api/src/__tests__/middlewares/adminAuth.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { createConfig } from "../../config.js";
+import { MiddlewareManager } from "../../middlewares/middleware.js";
+import { getMainRouter } from "../../routers/index.js";
+import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
+
+const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
+const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
+
+describe("Admin Auth Middleware", () => {
+ process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
+ process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
+ process.env.ADMIN_APP_URL = "http://localhost:5176";
+
+ const config = createConfig();
+ MiddlewareManager.initialize(config);
+ const app = getMainRouter(config);
+
+ it("rejects admin routes without an admin session", async () => {
+ const res = await app.request("/admin/tables/profiles");
+
+ expect(res.status).toBe(401);
+ await expect(res.json()).resolves.toMatchObject({
+ error: "Admin session required",
+ code: "ADMIN_SESSION_REQUIRED",
+ });
+ });
+
+ it("returns the current admin session for a valid admin session token", async () => {
+ const sessionToken = createSignedAdminToken(
+ {
+ aud: ADMIN_TOKEN_AUDIENCE,
+ email: "ops@xtablo.com",
+ exp: Math.floor(Date.now() / 1000) + 900,
+ role: "operator",
+ sub: "operator-1",
+ type: "admin_session",
+ },
+ ADMIN_TOKEN_SIGNING_SECRET
+ );
+
+ const res = await app.request("/admin/auth/session", {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ },
+ });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({
+ role: "operator",
+ operatorEmail: "ops@xtablo.com",
+ operatorId: "operator-1",
+ });
+ });
+});
diff --git a/apps/api/src/__tests__/middlewares/middlewares.test.ts b/apps/api/src/__tests__/middlewares/middlewares.test.ts
index 489506f..08390ab 100644
--- a/apps/api/src/__tests__/middlewares/middlewares.test.ts
+++ b/apps/api/src/__tests__/middlewares/middlewares.test.ts
@@ -12,7 +12,7 @@ describe("Middleware Tests", () => {
const middlewareManager = MiddlewareManager.getInstance();
const createProfilesSupabaseMock = (result: {
- data: { is_temporary: boolean } | null;
+ data: { is_temporary?: boolean; is_client?: boolean } | null;
error: { message: string } | null;
}) => ({
from: vi.fn().mockReturnValue({
@@ -24,9 +24,7 @@ describe("Middleware Tests", () => {
}),
});
- const createBillingStateSupabaseMock = (input: {
- ownerPlan?: string | null;
- }) => {
+ const createBillingStateSupabaseMock = (input: { ownerPlan?: string | null }) => {
const ownerPlan = input.ownerPlan ?? "none";
return {
@@ -325,10 +323,37 @@ describe("Middleware Tests", () => {
createProfilesSupabaseMock({
data: { is_temporary: true },
error: null,
- }) as any
+ })
);
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
- (c as any).set("user", { id: "temp-user" } as any);
+ (c as any).set("user", { id: "temp-user" });
+ await next();
+ });
+ app.use(middlewareManager.regularUserCheck);
+ app.get("/test", (c) => c.json({ success: true }));
+
+ // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
+ const client = testClient(app) as any;
+ const res = await client.test.$get();
+ const data = await res.json();
+
+ expect(res.status).toBe(401);
+ expect(data.error).toBe("User is read only");
+ });
+
+ it("should return 401 for client users", async () => {
+ const app = new Hono();
+ app.use(async (c, next) => {
+ // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
+ (c as any).set(
+ "supabase",
+ createProfilesSupabaseMock({
+ data: { is_temporary: false, is_client: true },
+ error: null,
+ })
+ );
+ // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
+ (c as any).set("user", { id: "client-user" });
await next();
});
app.use(middlewareManager.regularUserCheck);
@@ -354,10 +379,10 @@ describe("Middleware Tests", () => {
createProfilesSupabaseMock({
data: { is_temporary: true },
error: null,
- }) as any
+ })
);
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
- (c as any).set("user", { id: "temp-user" } as any);
+ (c as any).set("user", { id: "temp-user" });
await next();
});
app.use(middlewareManager.billingCheckoutAccess);
@@ -382,10 +407,10 @@ describe("Middleware Tests", () => {
"supabase",
createBillingStateSupabaseMock({
ownerPlan: "none",
- }) as any
+ })
);
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
- (c as any).set("user", { id: "owner-user" } as any);
+ (c as any).set("user", { id: "owner-user" });
await next();
});
app.use(middlewareManager.activePlanAccess);
@@ -408,10 +433,10 @@ describe("Middleware Tests", () => {
"supabase",
createBillingStateSupabaseMock({
ownerPlan: "solo",
- }) as any
+ })
);
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
- (c as any).set("user", { id: "owner-user" } as any);
+ (c as any).set("user", { id: "owner-user" });
await next();
});
app.use(middlewareManager.activePlanAccess);
diff --git a/apps/api/src/__tests__/routes/adminActions.test.ts b/apps/api/src/__tests__/routes/adminActions.test.ts
new file mode 100644
index 0000000..65a13de
--- /dev/null
+++ b/apps/api/src/__tests__/routes/adminActions.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import { createConfig } from "../../config.js";
+import { MiddlewareManager } from "../../middlewares/middleware.js";
+import { getMainRouter } from "../../routers/index.js";
+import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
+
+const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
+const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
+
+describe("Admin Actions Router", () => {
+ process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
+ process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
+ process.env.ADMIN_APP_URL = "http://localhost:5176";
+
+ const config = createConfig();
+ MiddlewareManager.initialize(config);
+ const app = getMainRouter(config);
+
+ const sessionToken = createSignedAdminToken(
+ {
+ aud: ADMIN_TOKEN_AUDIENCE,
+ email: "ops@xtablo.com",
+ exp: Math.floor(Date.now() / 1000) + 900,
+ role: "operator",
+ sub: "operator-1",
+ type: "admin_session",
+ },
+ ADMIN_TOKEN_SIGNING_SECRET
+ );
+
+ it("lists curated admin actions", async () => {
+ const res = await app.request("/admin/actions", {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ },
+ });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({
+ actions: expect.arrayContaining([
+ expect.objectContaining({
+ id: "deactivate_tablo_access",
+ label: "Deactivate Tablo Access",
+ }),
+ ]),
+ });
+ });
+
+ it("validates required input before running an action", async () => {
+ const res = await app.request("/admin/actions/deactivate_tablo_access/run", {
+ body: JSON.stringify({ tabloId: "tablo-1" }),
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ "Content-Type": "application/json",
+ },
+ method: "POST",
+ });
+
+ expect(res.status).toBe(400);
+ await expect(res.json()).resolves.toMatchObject({
+ error: "tabloId, userId, and reason are required",
+ });
+ });
+});
diff --git a/apps/api/src/__tests__/routes/adminAuth.test.ts b/apps/api/src/__tests__/routes/adminAuth.test.ts
new file mode 100644
index 0000000..18dd43a
--- /dev/null
+++ b/apps/api/src/__tests__/routes/adminAuth.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import { createConfig } from "../../config.js";
+import { MiddlewareManager } from "../../middlewares/middleware.js";
+import { getMainRouter } from "../../routers/index.js";
+import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
+
+const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
+const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
+
+describe("Admin Auth Router", () => {
+ process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
+ process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
+ process.env.ADMIN_APP_URL = "http://localhost:5176";
+
+ const config = createConfig();
+ MiddlewareManager.initialize(config);
+ const app = getMainRouter(config);
+
+ it("rejects requests without a valid privileged token", async () => {
+ const res = await app.request("/admin/auth/exchange", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ accessToken: "bad-token" }),
+ });
+
+ expect(res.status).toBe(401);
+ await expect(res.json()).resolves.toMatchObject({
+ error: "Invalid privileged access token",
+ code: "INVALID_ADMIN_ACCESS_TOKEN",
+ });
+ });
+
+ it("exchanges a valid privileged token for an admin session", async () => {
+ const accessToken = createSignedAdminToken(
+ {
+ aud: ADMIN_TOKEN_AUDIENCE,
+ email: "ops@xtablo.com",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ role: "operator",
+ sub: "operator-1",
+ type: "admin_access",
+ },
+ ADMIN_TOKEN_SIGNING_SECRET
+ );
+
+ const res = await app.request("/admin/auth/exchange", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ accessToken }),
+ });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({
+ role: "operator",
+ operatorEmail: "ops@xtablo.com",
+ sessionToken: expect.any(String),
+ expiresAt: expect.any(String),
+ });
+ });
+});
diff --git a/apps/api/src/__tests__/routes/adminDatasets.test.ts b/apps/api/src/__tests__/routes/adminDatasets.test.ts
new file mode 100644
index 0000000..59c8b6d
--- /dev/null
+++ b/apps/api/src/__tests__/routes/adminDatasets.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import { createConfig } from "../../config.js";
+import { MiddlewareManager } from "../../middlewares/middleware.js";
+import { getMainRouter } from "../../routers/index.js";
+import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
+
+const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
+const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
+
+describe("Admin Datasets Router", () => {
+ process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
+ process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
+ process.env.ADMIN_APP_URL = "http://localhost:5176";
+
+ const config = createConfig();
+ MiddlewareManager.initialize(config);
+ const app = getMainRouter(config);
+
+ const sessionToken = createSignedAdminToken(
+ {
+ aud: ADMIN_TOKEN_AUDIENCE,
+ email: "ops@xtablo.com",
+ exp: Math.floor(Date.now() / 1000) + 900,
+ role: "operator",
+ sub: "operator-1",
+ type: "admin_session",
+ },
+ ADMIN_TOKEN_SIGNING_SECRET
+ );
+
+ it("lists curated admin datasets", async () => {
+ const res = await app.request("/admin/datasets", {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ },
+ });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({
+ datasets: expect.arrayContaining([
+ expect.objectContaining({
+ id: "profile_growth",
+ label: "User Growth",
+ }),
+ ]),
+ });
+ });
+
+ it("returns chart-ready data for a registered dataset", async () => {
+ const res = await app.request("/admin/datasets/plan_mix", {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ },
+ });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({
+ chartType: "donut",
+ id: "plan_mix",
+ metricLabel: "Users",
+ points: expect.any(Array),
+ });
+ });
+});
diff --git a/apps/api/src/__tests__/routes/adminTableEdits.test.ts b/apps/api/src/__tests__/routes/adminTableEdits.test.ts
new file mode 100644
index 0000000..f997acf
--- /dev/null
+++ b/apps/api/src/__tests__/routes/adminTableEdits.test.ts
@@ -0,0 +1,72 @@
+import { createClient } from "@supabase/supabase-js";
+import { describe, expect, it } from "vitest";
+import { createConfig } from "../../config.js";
+import { MiddlewareManager } from "../../middlewares/middleware.js";
+import { getMainRouter } from "../../routers/index.js";
+import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
+import { getTestData } from "../helpers/dbSetup.js";
+
+const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
+const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
+
+describe("Admin Table Edit Router", () => {
+ process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
+ process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
+ process.env.ADMIN_APP_URL = "http://localhost:5176";
+
+ const config = createConfig();
+ MiddlewareManager.initialize(config);
+ const app = getMainRouter(config);
+
+ const sessionToken = createSignedAdminToken(
+ {
+ aud: ADMIN_TOKEN_AUDIENCE,
+ email: "ops@xtablo.com",
+ exp: Math.floor(Date.now() / 1000) + 900,
+ role: "operator",
+ sub: "operator-1",
+ type: "admin_session",
+ },
+ ADMIN_TOKEN_SIGNING_SECRET
+ );
+
+ it("writes an audit log entry for a successful update", async () => {
+ const ownerUserId = getTestData().users.owner.userId;
+
+ const res = await app.request(`/admin/tables/profiles/rows/${ownerUserId}`, {
+ method: "PATCH",
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ first_name: "Ada" }),
+ });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({
+ row: expect.objectContaining({
+ first_name: "Ada",
+ id: ownerUserId,
+ }),
+ });
+
+ const auditClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
+ auth: { autoRefreshToken: false, persistSession: false },
+ });
+
+ const { data: auditRows, error } = await auditClient
+ .from("admin_audit_log")
+ .select("*")
+ .eq("target_id", ownerUserId)
+ .eq("action", "update")
+ .order("created_at", { ascending: false })
+ .limit(1);
+
+ expect(error).toBeNull();
+ expect(auditRows).toHaveLength(1);
+ expect(auditRows?.[0]).toMatchObject({
+ operator_email: "ops@xtablo.com",
+ target_type: "profiles",
+ });
+ });
+});
diff --git a/apps/api/src/__tests__/routes/adminTables.test.ts b/apps/api/src/__tests__/routes/adminTables.test.ts
new file mode 100644
index 0000000..6e591a7
--- /dev/null
+++ b/apps/api/src/__tests__/routes/adminTables.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from "vitest";
+import { createConfig } from "../../config.js";
+import { MiddlewareManager } from "../../middlewares/middleware.js";
+import { getMainRouter } from "../../routers/index.js";
+import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
+
+const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
+const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
+
+describe("Admin Tables Router", () => {
+ process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
+ process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
+ process.env.ADMIN_APP_URL = "http://localhost:5176";
+
+ const config = createConfig();
+ MiddlewareManager.initialize(config);
+ const app = getMainRouter(config);
+
+ const sessionToken = createSignedAdminToken(
+ {
+ aud: ADMIN_TOKEN_AUDIENCE,
+ email: "ops@xtablo.com",
+ exp: Math.floor(Date.now() / 1000) + 900,
+ role: "operator",
+ sub: "operator-1",
+ type: "admin_session",
+ },
+ ADMIN_TOKEN_SIGNING_SECRET
+ );
+
+ it("lists only approved admin tables", async () => {
+ const res = await app.request("/admin/tables", {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ },
+ });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({
+ tables: expect.arrayContaining([
+ expect.objectContaining({
+ id: "profiles",
+ label: "Users",
+ }),
+ ]),
+ });
+ });
+
+ it("returns metadata for an approved table", async () => {
+ const res = await app.request("/admin/tables/profiles/meta", {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ },
+ });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({
+ id: "profiles",
+ label: "Users",
+ editableFields: ["first_name", "last_name"],
+ primaryKey: "id",
+ columns: expect.arrayContaining([
+ expect.objectContaining({
+ id: "email",
+ label: "Email",
+ }),
+ ]),
+ });
+ });
+
+ it("returns rows for an approved table", async () => {
+ const res = await app.request("/admin/tables/profiles/rows", {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ },
+ });
+
+ expect(res.status).toBe(200);
+ await expect(res.json()).resolves.toMatchObject({
+ rows: expect.arrayContaining([
+ expect.objectContaining({
+ email: "test_owner@example.com",
+ }),
+ ]),
+ });
+ });
+
+ it("rejects tables that are not in the registry", async () => {
+ const res = await app.request("/admin/tables/secrets/meta", {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ },
+ });
+
+ expect(res.status).toBe(404);
+ await expect(res.json()).resolves.toMatchObject({
+ error: "Admin table 'secrets' is not registered",
+ });
+ });
+});
diff --git a/apps/api/src/__tests__/routes/clientInvites.test.ts b/apps/api/src/__tests__/routes/clientInvites.test.ts
new file mode 100644
index 0000000..5ffcefb
--- /dev/null
+++ b/apps/api/src/__tests__/routes/clientInvites.test.ts
@@ -0,0 +1,464 @@
+import { createClient } from "@supabase/supabase-js";
+import { testClient } from "hono/testing";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createConfig } from "../../config.js";
+import { MiddlewareManager } from "../../middlewares/middleware.js";
+import { getMainRouter } from "../../routers/index.js";
+import type { TestUserData } from "../helpers/dbSetup.js";
+import { getTestUser } from "../helpers/dbSetup.js";
+
+// Mock nodemailer
+const mockSendMail = vi.fn();
+vi.mock("nodemailer", () => ({
+ default: {
+ createTransport: vi.fn(() => ({
+ sendMail: mockSendMail,
+ })),
+ },
+ createTransport: vi.fn(() => ({
+ sendMail: mockSendMail,
+ })),
+}));
+
+describe("Client Invites Endpoints", () => {
+ const config = createConfig();
+ MiddlewareManager.initialize(config);
+ const app = getMainRouter(config);
+ // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
+ const client = testClient(app) as any;
+
+ const ownerUser = getTestUser("owner");
+ const tempUser = getTestUser("temp");
+
+ const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
+ auth: { persistSession: false },
+ });
+
+ // The owner has admin access to this tablo (created via TEST_TABLOS with owner_key: "owner")
+ const adminTabloId = "test_tablo_owner_private";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSendMail.mockResolvedValue({ messageId: "test-message-id" });
+ });
+
+ // ─── Helpers ────────────────────────────────────────────────────────────────
+
+ const postInvite = (user: TestUserData, tabloId: string, email: string) =>
+ client["client-invites"][":tabloId"].$post(
+ { param: { tabloId }, json: { email } },
+ { headers: { Authorization: `Bearer ${user.accessToken}` } }
+ );
+
+ const getPending = (user: TestUserData, tabloId: string) =>
+ client["client-invites"][":tabloId"].pending.$get(
+ { param: { tabloId } },
+ { headers: { Authorization: `Bearer ${user.accessToken}` } }
+ );
+
+ const deleteInvite = (user: TestUserData, tabloId: string, inviteId: number) =>
+ client["client-invites"][":tabloId"][":inviteId"].$delete(
+ { param: { tabloId, inviteId: String(inviteId) } },
+ { headers: { Authorization: `Bearer ${user.accessToken}` } }
+ );
+
+ const getSetupInvite = (token: string) =>
+ client["client-invites"].setup[":token"].$get({
+ param: { token },
+ });
+
+ const completeSetupInvite = (token: string, password: string) =>
+ client["client-invites"].setup[":token"].$post({
+ param: { token },
+ json: { password },
+ });
+
+ const insertClientInvite = async (opts: {
+ tabloId: string;
+ invitedEmail: string;
+ invitedBy: string;
+ token: string;
+ inviteType?: string;
+ isPending?: boolean;
+ expiresAt?: string;
+ }) => {
+ const expiresAt = opts.expiresAt ?? new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString();
+
+ const { data, error } = await supabaseAdmin
+ .from("client_invites")
+ .insert({
+ tablo_id: opts.tabloId,
+ invited_email: opts.invitedEmail,
+ invited_by: opts.invitedBy,
+ invite_token: opts.token,
+ invite_type: opts.inviteType ?? "setup",
+ is_pending: opts.isPending ?? true,
+ expires_at: expiresAt,
+ })
+ .select("id")
+ .single();
+
+ if (error) throw new Error(`Failed to insert client_invite: ${error.message}`);
+ return data.id as number;
+ };
+
+ const cleanupInvitesByEmail = async (email: string) => {
+ await supabaseAdmin.from("client_invites").delete().eq("invited_email", email);
+
+ const { data: usersData } = await supabaseAdmin.auth.admin.listUsers();
+ // biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime
+ const users = usersData as any;
+ // biome-ignore lint/suspicious/noExplicitAny: admin user type
+ const clientUser = users?.users?.find((u: any) => u.email === email);
+ if (clientUser) {
+ await supabaseAdmin.from("tablo_access").delete().eq("user_id", clientUser.id);
+ await supabaseAdmin.auth.admin.deleteUser(clientUser.id);
+ }
+ };
+
+ const createClientAccount = async (
+ email: string,
+ input?: { onboarded?: boolean; password?: string }
+ ) => {
+ const password = input?.password ?? "client_password_123";
+ const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({
+ email,
+ password,
+ email_confirm: true,
+ user_metadata: { role: "client" },
+ });
+
+ if (authError || !authData?.user) {
+ throw new Error(`Failed to create client account: ${authError?.message}`);
+ }
+
+ const updates: Record = { is_client: true };
+ if (input?.onboarded) {
+ updates.client_onboarded_at = new Date().toISOString();
+ }
+
+ const { error: profileError } = await supabaseAdmin
+ .from("profiles")
+ .update(updates)
+ .eq("id", authData.user.id);
+
+ if (profileError) {
+ throw new Error(`Failed to update client profile: ${profileError.message}`);
+ }
+
+ return authData.user;
+ };
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // POST /:tabloId — Create client invite
+ // ════════════════════════════════════════════════════════════════════════════
+
+ describe("POST /client-invites/:tabloId", () => {
+ const testEmail = "test_client_invite_new@example.com";
+ const existingClientEmail = "test_existing_client_invite@example.com";
+
+ beforeEach(async () => {
+ await cleanupInvitesByEmail(testEmail);
+ await cleanupInvitesByEmail(existingClientEmail);
+ });
+
+ it("creates a setup token for a first-time client invite", async () => {
+ const res = await postInvite(ownerUser, adminTabloId, testEmail);
+
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.success).toBe(true);
+ expect(data.inviteMode).toBe("setup");
+
+ const { data: invite } = await supabaseAdmin
+ .from("client_invites")
+ .select("id, invited_email, is_pending, invite_token, invite_type")
+ .eq("tablo_id", adminTabloId)
+ .eq("invited_email", testEmail)
+ .single();
+
+ expect(invite).toBeDefined();
+ expect(invite?.is_pending).toBe(true);
+ expect(invite?.invite_token).toBeTruthy();
+ expect(invite?.invite_type).toBe("setup");
+ expect(mockSendMail).toHaveBeenCalledTimes(1);
+ expect(mockSendMail.mock.calls[0]?.[0]?.html).toContain("/set-password?token=");
+ });
+
+ it("sends an access notification for an already-onboarded client", async () => {
+ await createClientAccount(existingClientEmail, { onboarded: true });
+
+ const res = await postInvite(ownerUser, adminTabloId, existingClientEmail);
+
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.success).toBe(true);
+ expect(data.inviteMode).toBe("notification");
+
+ const { data: invite } = await supabaseAdmin
+ .from("client_invites")
+ .select("id")
+ .eq("tablo_id", adminTabloId)
+ .eq("invited_email", existingClientEmail)
+ .maybeSingle();
+
+ expect(invite).toBeNull();
+ expect(mockSendMail).toHaveBeenCalledTimes(1);
+ expect(mockSendMail.mock.calls[0]?.[0]?.html).toContain(`/tablo/${adminTabloId}`);
+ });
+
+ it("rejects emails already used by a main-app account", async () => {
+ const res = await postInvite(ownerUser, adminTabloId, ownerUser.email);
+
+ expect(res.status).toBe(409);
+ const data = await res.json();
+ expect(data.error).toContain("already belongs");
+ });
+
+ it("rejects temporary users before admin check", async () => {
+ const res = await postInvite(tempUser, adminTabloId, testEmail);
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 400 for an invalid email", async () => {
+ const res = await postInvite(ownerUser, adminTabloId, "not-an-email");
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toContain("valid email");
+ });
+
+ it("returns 400 for a missing email", async () => {
+ const res = client["client-invites"][":tabloId"].$post(
+ { param: { tabloId: adminTabloId }, json: {} },
+ { headers: { Authorization: `Bearer ${ownerUser.accessToken}` } }
+ );
+ expect((await res).status).toBe(400);
+ });
+
+ it("returns 401 for unauthenticated requests", async () => {
+ const res = await client["client-invites"][":tabloId"].$post({
+ param: { tabloId: adminTabloId },
+ json: { email: testEmail },
+ });
+ expect(res.status).toBe(401);
+ });
+ });
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // GET/POST /setup/:token — Validate and complete a client setup invite
+ // ════════════════════════════════════════════════════════════════════════════
+
+ describe("GET/POST /client-invites/setup/:token", () => {
+ const setupEmail = "test_client_setup@example.com";
+
+ beforeEach(async () => {
+ await cleanupInvitesByEmail(setupEmail);
+ });
+
+ it("returns invite metadata for a valid pending setup token", async () => {
+ const token = `test_setup_valid_${Date.now()}`;
+ await insertClientInvite({
+ tabloId: adminTabloId,
+ invitedEmail: setupEmail,
+ invitedBy: ownerUser.userId,
+ token,
+ });
+
+ try {
+ const res = await getSetupInvite(token);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.email).toBe(setupEmail);
+ expect(data.tabloId).toBe(adminTabloId);
+ } finally {
+ await supabaseAdmin.from("client_invites").delete().eq("invite_token", token);
+ }
+ });
+
+ it("returns 410 for an expired invite", async () => {
+ const token = `test_expired_${Date.now()}`;
+ const pastDate = new Date(Date.now() - 1000).toISOString();
+
+ await insertClientInvite({
+ tabloId: adminTabloId,
+ invitedEmail: setupEmail,
+ invitedBy: ownerUser.userId,
+ token,
+ expiresAt: pastDate,
+ });
+
+ try {
+ const res = await getSetupInvite(token);
+ expect(res.status).toBe(410);
+ const data = await res.json();
+ expect(data.error).toContain("expired");
+ } finally {
+ await supabaseAdmin.from("client_invites").delete().eq("invite_token", token);
+ }
+ });
+
+ it("completes password setup once and rejects reuse", async () => {
+ const token = `test_setup_complete_${Date.now()}`;
+ await createClientAccount(setupEmail);
+ await insertClientInvite({
+ tabloId: adminTabloId,
+ invitedEmail: setupEmail,
+ invitedBy: ownerUser.userId,
+ token,
+ });
+
+ try {
+ const res = await completeSetupInvite(token, "new_password_123");
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.success).toBe(true);
+ expect(data.email).toBe(setupEmail);
+ expect(data.tabloId).toBe(adminTabloId);
+
+ const reused = await completeSetupInvite(token, "new_password_456");
+ expect(reused.status).toBe(404);
+ } finally {
+ await supabaseAdmin.from("client_invites").delete().eq("invite_token", token);
+ }
+ });
+
+ it("returns 404 for a non-existent token", async () => {
+ const res = await getSetupInvite("nonexistent_token_xyz");
+ expect(res.status).toBe(404);
+ });
+
+ it("marks cancelled pending setup tokens unusable", async () => {
+ const token = `test_setup_cancelled_${Date.now()}`;
+ const inviteId = await insertClientInvite({
+ tabloId: adminTabloId,
+ invitedEmail: setupEmail,
+ invitedBy: ownerUser.userId,
+ token,
+ });
+
+ const cancelRes = await deleteInvite(ownerUser, adminTabloId, inviteId);
+ expect(cancelRes.status).toBe(200);
+
+ const res = await getSetupInvite(token);
+ expect(res.status).toBe(404);
+ });
+ });
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // GET /:tabloId/pending — List pending client invites
+ // ════════════════════════════════════════════════════════════════════════════
+
+ describe("GET /client-invites/:tabloId/pending", () => {
+ const pendingEmail = "test_client_pending_list@example.com";
+ let insertedId: number;
+
+ beforeEach(async () => {
+ await cleanupInvitesByEmail(pendingEmail);
+ insertedId = await insertClientInvite({
+ tabloId: adminTabloId,
+ invitedEmail: pendingEmail,
+ invitedBy: ownerUser.userId,
+ token: `test_pending_${Date.now()}`,
+ });
+ });
+
+ it("returns pending invites for an admin", async () => {
+ const res = await getPending(ownerUser, adminTabloId);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(Array.isArray(data.invites)).toBe(true);
+
+ const found = data.invites.find((inv: { id: number }) => inv.id === insertedId);
+ expect(found).toBeDefined();
+ expect(found.invited_email).toBe(pendingEmail);
+ expect(found.is_pending).toBe(true);
+ });
+
+ it("returns 401 for a temporary user before admin check", async () => {
+ const res = await getPending(tempUser, adminTabloId);
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 401 for unauthenticated requests", async () => {
+ const res = await client["client-invites"][":tabloId"].pending.$get({
+ param: { tabloId: adminTabloId },
+ });
+ expect(res.status).toBe(401);
+ });
+ });
+
+ // ════════════════════════════════════════════════════════════════════════════
+ // DELETE /:tabloId/:inviteId — Cancel a client invite
+ // ════════════════════════════════════════════════════════════════════════════
+
+ describe("DELETE /client-invites/:tabloId/:inviteId", () => {
+ const cancelEmail = "test_client_cancel@example.com";
+
+ beforeEach(async () => {
+ await cleanupInvitesByEmail(cancelEmail);
+ });
+
+ it("cancels a pending invite and revokes client access", async () => {
+ const token = `test_cancel_${Date.now()}`;
+ const inviteId = await insertClientInvite({
+ tabloId: adminTabloId,
+ invitedEmail: cancelEmail,
+ invitedBy: ownerUser.userId,
+ token,
+ });
+
+ const res = await deleteInvite(ownerUser, adminTabloId, inviteId);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.success).toBe(true);
+
+ const { data: invite } = await supabaseAdmin
+ .from("client_invites")
+ .select("is_pending")
+ .eq("id", inviteId)
+ .single();
+ expect(invite?.is_pending).toBe(false);
+ });
+
+ it("returns 401 for a temporary user before admin check", async () => {
+ const token = `test_cancel_nonadmin_${Date.now()}`;
+ const inviteId = await insertClientInvite({
+ tabloId: adminTabloId,
+ invitedEmail: cancelEmail,
+ invitedBy: ownerUser.userId,
+ token,
+ });
+
+ const res = await deleteInvite(tempUser, adminTabloId, inviteId);
+ expect(res.status).toBe(401);
+ });
+
+ it("returns 404 for a non-existent invite", async () => {
+ const res = await deleteInvite(ownerUser, adminTabloId, 999999);
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 400 for an already-cancelled invite", async () => {
+ const token = `test_cancel_already_${Date.now()}`;
+ const inviteId = await insertClientInvite({
+ tabloId: adminTabloId,
+ invitedEmail: cancelEmail,
+ invitedBy: ownerUser.userId,
+ token,
+ isPending: false,
+ });
+
+ const res = await deleteInvite(ownerUser, adminTabloId, inviteId);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toContain("pending");
+ });
+
+ it("returns 401 for unauthenticated requests", async () => {
+ const res = await client["client-invites"][":tabloId"][":inviteId"].$delete({
+ param: { tabloId: adminTabloId, inviteId: "1" },
+ });
+ expect(res.status).toBe(401);
+ });
+ });
+});
diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts
index b445c90..3af1417 100644
--- a/apps/api/src/__tests__/routes/tablo.test.ts
+++ b/apps/api/src/__tests__/routes/tablo.test.ts
@@ -1,10 +1,11 @@
-import { createClient } from "@supabase/supabase-js";
import { randomUUID } from "node:crypto";
+import { createClient } from "@supabase/supabase-js";
import { testClient } from "hono/testing";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
+import { TEST_USERS } from "../fixtures/testData.js";
import type { TestUserData } from "../helpers/dbSetup.js";
import { getTestUser } from "../helpers/dbSetup.js";
@@ -34,6 +35,7 @@ describe("Tablo Endpoint", () => {
const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
+ const createdTabloIds: string[] = [];
beforeEach(() => {
// Reset all mocks before each test
@@ -41,6 +43,16 @@ describe("Tablo Endpoint", () => {
mockSendMail.mockResolvedValue({ messageId: "test-message-id" });
});
+ afterEach(async () => {
+ if (createdTabloIds.length === 0) {
+ return;
+ }
+
+ const idsToDelete = createdTabloIds.splice(0, createdTabloIds.length);
+ await supabaseAdmin.from("tablo_access").delete().in("tablo_id", idsToDelete);
+ await supabaseAdmin.from("tablos").delete().in("id", idsToDelete);
+ });
+
// Helper function to create tablo
const createTabloRequest = async (
user: TestUserData,
@@ -98,6 +110,23 @@ describe("Tablo Endpoint", () => {
);
};
+ const signInAsTestUser = async (email: string, password: string) => {
+ const userClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
+ auth: {
+ autoRefreshToken: false,
+ persistSession: false,
+ },
+ });
+
+ const { data, error } = await userClient.auth.signInWithPassword({ email, password });
+
+ if (error || !data.session) {
+ throw new Error(`Failed to sign in ${email}: ${error?.message ?? "missing session"}`);
+ }
+
+ return userClient;
+ };
+
// Helper function to get tablo members
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const getTabloMembersRequest = async (user: TestUserData, client: any, tabloId: string) => {
@@ -171,6 +200,8 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("Tablo created successfully");
+ expect(data.tablo?.id).toBeDefined();
+ createdTabloIds.push(data.tablo.id);
});
it("should deny temp user from creating a tablo (regularUserCheck blocks temporary users)", async () => {
@@ -271,7 +302,16 @@ describe("Tablo Endpoint", () => {
}
if (fillerTablos.length > 0) {
- await supabaseAdmin.from("tablos").insert(fillerTablos);
+ const { data: insertedTablos, error: insertError } = await supabaseAdmin
+ .from("tablos")
+ .insert(fillerTablos)
+ .select("id");
+
+ if (insertError) {
+ throw new Error(`Failed to insert filler tablos: ${insertError.message}`);
+ }
+
+ createdTabloIds.push(...(insertedTablos ?? []).map((tablo) => tablo.id));
}
try {
@@ -393,6 +433,35 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(403);
});
+
+ it("should allow owner to delete a tablo that contains etapes", async () => {
+ const createRes = await createTabloRequest(ownerUser, client, {
+ name: "Delete With Etape",
+ status: "todo",
+ color: "#804EEC",
+ });
+
+ expect(createRes.status).toBe(200);
+ const createData = await createRes.json();
+ const createdTabloId = createData.tablo.id as string;
+ createdTabloIds.push(createdTabloId);
+
+ const ownerClient = await signInAsTestUser(TEST_USERS.owner.email, TEST_USERS.owner.password);
+ const { error: createEtapeError } = await ownerClient.from("tasks").insert({
+ tablo_id: createdTabloId,
+ title: "Delete me",
+ is_parent: true,
+ status: "todo",
+ });
+
+ expect(createEtapeError).toBeNull();
+
+ const res = await deleteTabloRequest(ownerUser, client, createdTabloId);
+
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.message).toBe("Tablo deleted successfully");
+ });
});
describe("GET /tablos/members/:tablo_id - Get Tablo Members", () => {
diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts
index 38f74f0..a6a4134 100644
--- a/apps/api/src/config.ts
+++ b/apps/api/src/config.ts
@@ -23,6 +23,9 @@ export interface AppConfig {
R2_SECRET_ACCESS_KEY: string;
LOG_LEVEL: "debug" | "info" | "warn" | "error";
TASKS_SECRET: string;
+ ADMIN_TOKEN_SIGNING_SECRET: string;
+ ADMIN_TOKEN_AUDIENCE: string;
+ ADMIN_APP_URL: string;
/**
* Test user
@@ -85,10 +88,7 @@ export function createConfig(secrets?: Secrets): AppConfig {
? validateEnvVar("STRIPE_WEBHOOK_SECRET", process.env.STRIPE_WEBHOOK_SECRET)
: getStripeWebhookSecretFromEnv() || getStripeWebhookSecret(isStagingMode),
STRIPE_SOLO_PRICE_ID: validateEnvVar("STRIPE_SOLO_PRICE_ID", process.env.STRIPE_SOLO_PRICE_ID),
- STRIPE_TEAM_PRICE_ID: validateEnvVar(
- "STRIPE_TEAM_PRICE_ID",
- process.env.STRIPE_TEAM_PRICE_ID
- ),
+ STRIPE_TEAM_PRICE_ID: validateEnvVar("STRIPE_TEAM_PRICE_ID", process.env.STRIPE_TEAM_PRICE_ID),
STRIPE_FOUNDER_PRICE_ID: validateEnvVar(
"STRIPE_FOUNDER_PRICE_ID",
process.env.STRIPE_FOUNDER_PRICE_ID
@@ -110,6 +110,11 @@ export function createConfig(secrets?: Secrets): AppConfig {
? validateEnvVar("R2_SECRET_ACCESS_KEY", process.env.R2_SECRET_ACCESS_KEY)
: secrets!.r2SecretAccessKey,
TASKS_SECRET: process.env.TASKS_SECRET || "",
+ ADMIN_TOKEN_SIGNING_SECRET: isTestMode
+ ? validateEnvVar("ADMIN_TOKEN_SIGNING_SECRET", process.env.ADMIN_TOKEN_SIGNING_SECRET)
+ : secrets!.adminTokenSigningSecret,
+ ADMIN_TOKEN_AUDIENCE: process.env.ADMIN_TOKEN_AUDIENCE || "xtablo-admin",
+ ADMIN_APP_URL: process.env.ADMIN_APP_URL || "http://localhost:5176",
LOG_LEVEL: "info",
TEST_USER_DATA: {
id: "test",
diff --git a/apps/api/src/helpers/adminAudit.ts b/apps/api/src/helpers/adminAudit.ts
new file mode 100644
index 0000000..2489688
--- /dev/null
+++ b/apps/api/src/helpers/adminAudit.ts
@@ -0,0 +1,40 @@
+import type { SupabaseClient } from "@supabase/supabase-js";
+
+type AdminAuditArgs = {
+ action: string;
+ after?: unknown;
+ before?: unknown;
+ operatorEmail: string;
+ operatorId: string;
+ role: string;
+ supabase: SupabaseClient;
+ targetId: string;
+ targetType: string;
+};
+
+export async function recordAdminAuditLog({
+ action,
+ after,
+ before,
+ operatorEmail,
+ operatorId,
+ role,
+ supabase,
+ targetId,
+ targetType,
+}: AdminAuditArgs) {
+ const { error } = await supabase.from("admin_audit_log").insert({
+ action,
+ after,
+ before,
+ operator_email: operatorEmail,
+ operator_id: operatorId,
+ role,
+ target_id: targetId,
+ target_type: targetType,
+ });
+
+ if (error) {
+ throw new Error(`Failed to write admin audit log: ${error.message}`);
+ }
+}
diff --git a/apps/api/src/helpers/adminRegistry.ts b/apps/api/src/helpers/adminRegistry.ts
new file mode 100644
index 0000000..8f17c22
--- /dev/null
+++ b/apps/api/src/helpers/adminRegistry.ts
@@ -0,0 +1,146 @@
+import type { Database } from "@xtablo/shared-types";
+
+type AdminTableColumn = {
+ id: string;
+ label: string;
+};
+
+type AdminTableDefinition = {
+ columns: AdminTableColumn[];
+ editableColumns?: string[];
+ id: string;
+ label: string;
+ primaryKey: string;
+ select: string;
+ source: keyof Database["public"]["Tables"];
+};
+
+export const adminTableRegistry: Record = {
+ profiles: {
+ columns: [
+ { id: "id", label: "ID" },
+ { id: "email", label: "Email" },
+ { id: "first_name", label: "First name" },
+ { id: "last_name", label: "Last name" },
+ ],
+ editableColumns: ["first_name", "last_name"],
+ id: "profiles",
+ label: "Users",
+ primaryKey: "id",
+ select: "id,email,first_name,last_name",
+ source: "profiles",
+ },
+ tablo_access: {
+ columns: [
+ { id: "tablo_id", label: "Tablo ID" },
+ { id: "user_id", label: "User ID" },
+ { id: "is_active", label: "Active" },
+ { id: "is_admin", label: "Admin" },
+ ],
+ editableColumns: [],
+ id: "tablo_access",
+ label: "Tablo Access",
+ primaryKey: "user_id",
+ select: "tablo_id,user_id,is_active,is_admin",
+ source: "tablo_access",
+ },
+};
+
+type AdminDatasetDefinition = {
+ description: string;
+ id: string;
+ label: string;
+};
+
+type AdminActionFieldDefinition = {
+ id: string;
+ label: string;
+ placeholder?: string;
+ required?: boolean;
+};
+
+type AdminActionDefinition = {
+ description: string;
+ fields: AdminActionFieldDefinition[];
+ id: string;
+ label: string;
+};
+
+export const adminDatasetRegistry: Record = {
+ profile_growth: {
+ description: "New user creation trend over time.",
+ id: "profile_growth",
+ label: "User Growth",
+ },
+ plan_mix: {
+ description: "Production users by current subscription plan.",
+ id: "plan_mix",
+ label: "Plan Mix",
+ },
+ tablo_access_mix: {
+ description: "Current active, inactive, and admin access posture.",
+ id: "tablo_access_mix",
+ label: "Tablo Access Mix",
+ },
+};
+
+export const adminActionRegistry: Record = {
+ deactivate_tablo_access: {
+ description: "Disable a user's access to a tablo and log the operator reason.",
+ fields: [
+ { id: "tabloId", label: "Tablo ID", placeholder: "tablo_123", required: true },
+ { id: "userId", label: "User ID", placeholder: "user_123", required: true },
+ {
+ id: "reason",
+ label: "Reason",
+ placeholder: "Explain why this access is being removed",
+ required: true,
+ },
+ ],
+ id: "deactivate_tablo_access",
+ label: "Deactivate Tablo Access",
+ },
+ grant_tablo_admin: {
+ description: "Promote an existing tablo member to admin and force active access.",
+ fields: [
+ { id: "tabloId", label: "Tablo ID", placeholder: "tablo_123", required: true },
+ { id: "userId", label: "User ID", placeholder: "user_123", required: true },
+ {
+ id: "reason",
+ label: "Reason",
+ placeholder: "Explain why admin access is being granted",
+ required: true,
+ },
+ ],
+ id: "grant_tablo_admin",
+ label: "Grant Tablo Admin",
+ },
+};
+
+export function getAdminTableDefinition(tableId: string) {
+ return adminTableRegistry[tableId] ?? null;
+}
+
+export function listAdminTables() {
+ return Object.values(adminTableRegistry).map(({ id, label }) => ({ id, label }));
+}
+
+export function getAdminDatasetDefinition(datasetId: string) {
+ return adminDatasetRegistry[datasetId] ?? null;
+}
+
+export function listAdminDatasets() {
+ return Object.values(adminDatasetRegistry);
+}
+
+export function getAdminActionDefinition(actionId: string) {
+ return adminActionRegistry[actionId] ?? null;
+}
+
+export function listAdminActions() {
+ return Object.values(adminActionRegistry);
+}
+
+export function normalizeAdminRows(rows: unknown[]) {
+ return rows as Record[];
+}
diff --git a/apps/api/src/helpers/adminTokens.ts b/apps/api/src/helpers/adminTokens.ts
new file mode 100644
index 0000000..b382e23
--- /dev/null
+++ b/apps/api/src/helpers/adminTokens.ts
@@ -0,0 +1,201 @@
+import { createHmac, timingSafeEqual } from "node:crypto";
+import type { AppConfig } from "../config.js";
+
+export type AdminRole = "viewer" | "operator" | "superadmin";
+
+type TokenKind = "admin_access" | "admin_session";
+
+type AdminTokenClaims = {
+ aud: string;
+ email: string;
+ exp: number;
+ role: AdminRole;
+ sub: string;
+ type: TokenKind;
+};
+
+export type AdminSessionClaims = {
+ aud: string;
+ exp: number;
+ operatorEmail: string;
+ operatorId: string;
+ role: AdminRole;
+};
+
+type AdminTokenErrorCode =
+ | "ADMIN_SESSION_REQUIRED"
+ | "INVALID_ADMIN_ACCESS_TOKEN"
+ | "INVALID_ADMIN_SESSION";
+
+type AdminTokenFailure = {
+ code: AdminTokenErrorCode;
+ error: string;
+ statusCode: 401;
+ success: false;
+};
+
+type AdminTokenSuccess = {
+ success: true;
+ value: T;
+};
+
+export type AdminTokenResult = AdminTokenFailure | AdminTokenSuccess;
+
+type ExchangeResult = {
+ expiresAt: string;
+ operatorEmail: string;
+ operatorId: string;
+ role: AdminRole;
+ sessionToken: string;
+};
+
+function encodeSegment(value: unknown) {
+ return Buffer.from(JSON.stringify(value)).toString("base64url");
+}
+
+function decodeSegment(segment: string): T | null {
+ try {
+ return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as T;
+ } catch {
+ return null;
+ }
+}
+
+function signToken(claims: AdminTokenClaims, secret: string) {
+ const header = encodeSegment({ alg: "HS256", typ: "JWT" });
+ const payload = encodeSegment(claims);
+ const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
+
+ return `${header}.${payload}.${signature}`;
+}
+
+function invalidToken(error: string, code: AdminTokenErrorCode): AdminTokenFailure {
+ return {
+ code,
+ error,
+ statusCode: 401,
+ success: false,
+ };
+}
+
+function isFailure(result: AdminTokenResult): result is AdminTokenFailure {
+ return !result.success;
+}
+
+function verifyToken(
+ token: string,
+ config: AppConfig,
+ expectedType: TokenKind
+): AdminTokenResult {
+ const segments = token.split(".");
+ if (segments.length !== 3) {
+ return invalidToken(
+ expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
+ expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
+ );
+ }
+
+ const [header, payload, signature] = segments;
+ const expectedSignature = createHmac("sha256", config.ADMIN_TOKEN_SIGNING_SECRET)
+ .update(`${header}.${payload}`)
+ .digest();
+ const receivedSignature = Buffer.from(signature, "base64url");
+
+ if (
+ expectedSignature.length !== receivedSignature.length ||
+ !timingSafeEqual(expectedSignature, receivedSignature)
+ ) {
+ return invalidToken(
+ expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
+ expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
+ );
+ }
+
+ const claims = decodeSegment(payload);
+ if (!claims) {
+ return invalidToken(
+ expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
+ expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
+ );
+ }
+
+ if (claims.type !== expectedType || claims.aud !== config.ADMIN_TOKEN_AUDIENCE) {
+ return invalidToken(
+ expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
+ expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
+ );
+ }
+
+ if (claims.exp <= Math.floor(Date.now() / 1000)) {
+ return invalidToken(
+ expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
+ expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
+ );
+ }
+
+ return { success: true, value: claims };
+}
+
+export function exchangePrivilegedToken(
+ token: string,
+ config: AppConfig
+): AdminTokenResult {
+ const verifiedAccessToken = verifyToken(token, config, "admin_access");
+ if (isFailure(verifiedAccessToken)) {
+ return verifiedAccessToken;
+ }
+
+ const accessClaims = verifiedAccessToken.value;
+ const sessionExpiry = Math.floor(Date.now() / 1000) + 15 * 60;
+ const sessionToken = signToken(
+ {
+ aud: config.ADMIN_TOKEN_AUDIENCE,
+ email: accessClaims.email,
+ exp: sessionExpiry,
+ role: accessClaims.role,
+ sub: accessClaims.sub,
+ type: "admin_session",
+ },
+ config.ADMIN_TOKEN_SIGNING_SECRET
+ );
+
+ return {
+ success: true,
+ value: {
+ expiresAt: new Date(sessionExpiry * 1000).toISOString(),
+ operatorEmail: accessClaims.email,
+ operatorId: accessClaims.sub,
+ role: accessClaims.role,
+ sessionToken,
+ },
+ };
+}
+
+export function verifyAdminSession(
+ token: string | undefined,
+ config: AppConfig
+): AdminTokenResult {
+ if (!token) {
+ return invalidToken("Admin session required", "ADMIN_SESSION_REQUIRED");
+ }
+
+ const verifiedSession = verifyToken(token, config, "admin_session");
+ if (isFailure(verifiedSession)) {
+ return {
+ ...verifiedSession,
+ code: "ADMIN_SESSION_REQUIRED",
+ error: "Admin session required",
+ };
+ }
+
+ return {
+ success: true,
+ value: {
+ aud: verifiedSession.value.aud,
+ exp: verifiedSession.value.exp,
+ operatorEmail: verifiedSession.value.email,
+ operatorId: verifiedSession.value.sub,
+ role: verifiedSession.value.role,
+ },
+ };
+}
diff --git a/apps/api/src/helpers/billing.ts b/apps/api/src/helpers/billing.ts
index 0e043d4..e0e10da 100644
--- a/apps/api/src/helpers/billing.ts
+++ b/apps/api/src/helpers/billing.ts
@@ -7,6 +7,7 @@ type BillingProfileRow = {
id: string;
created_at: string | null;
is_temporary: boolean | null;
+ is_client: boolean | null;
plan: string | null;
};
@@ -87,7 +88,7 @@ export const parseTrialRolloutDate = (
export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null;
export const getBillableMemberCount = (profiles: BillingProfileRow[]) =>
- profiles.filter((profile) => profile.is_temporary !== true).length;
+ profiles.filter((profile) => profile.is_temporary !== true && profile.is_client !== true).length;
export const getTrialWindow = (input: {
ownerCreatedAt: Date;
@@ -179,7 +180,7 @@ const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRo
const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => {
const { data, error } = await supabase
.from("profiles")
- .select("id, created_at, is_temporary, plan")
+ .select("id, created_at, is_temporary, is_client, plan")
.eq("organization_id", organizationId)
.order("created_at", { ascending: true });
diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts
index 25ec729..298d6d2 100644
--- a/apps/api/src/helpers/helpers.ts
+++ b/apps/api/src/helpers/helpers.ts
@@ -363,3 +363,197 @@ export const createInvitedUser = async (
return { success: true, userId: newUser.user.id };
};
+
+type ClientAccount = {
+ email: string;
+ is_client: boolean;
+ client_onboarded_at: string | null;
+ userId: string;
+};
+
+type ClientAccountResult =
+ | { success: true; account: ClientAccount; wasCreated: boolean }
+ | { success: false; error: string };
+
+const getAuthUserByEmail = async (supabase: SupabaseClient, email: string) => {
+ const { data: existingUsersData, error } = await supabase.auth.admin.listUsers();
+ if (error) {
+ return { user: null, error: error.message };
+ }
+
+ // biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime
+ const existingUsers = existingUsersData as any;
+ const existingUser = existingUsers?.users?.find(
+ // biome-ignore lint/suspicious/noExplicitAny: admin user type
+ (u: any) => u.email?.toLowerCase() === email.toLowerCase()
+ );
+
+ return { user: existingUser ?? null, error: null };
+};
+
+export async function hasCompletedClientOnboarding(
+ supabase: SupabaseClient,
+ userId: string
+): Promise<{ completed: boolean; error?: string }> {
+ const { data: profile, error } = await supabase
+ .from("profiles")
+ .select("client_onboarded_at")
+ .eq("id", userId)
+ .maybeSingle();
+
+ if (error) {
+ return { completed: false, error: error.message };
+ }
+
+ return { completed: !!profile?.client_onboarded_at };
+}
+
+export async function findOrCreateClientAccount(
+ supabase: SupabaseClient,
+ recipientEmail: string
+): Promise {
+ const normalizedEmail = recipientEmail.trim().toLowerCase();
+ const { user: existingUser, error: lookupError } = await getAuthUserByEmail(
+ supabase,
+ normalizedEmail
+ );
+
+ if (lookupError) {
+ return { success: false, error: lookupError };
+ }
+
+ if (existingUser) {
+ const { data: existingProfile, error: profileError } = await supabase
+ .from("profiles")
+ .select("email, is_client, client_onboarded_at")
+ .eq("id", existingUser.id)
+ .maybeSingle();
+
+ if (profileError) {
+ return { success: false, error: profileError.message };
+ }
+
+ if (!existingProfile) {
+ return { success: false, error: "Client profile not found" };
+ }
+
+ if (!existingProfile.is_client) {
+ return {
+ success: false,
+ error: "This email already belongs to a main app account",
+ };
+ }
+
+ return {
+ success: true,
+ wasCreated: false,
+ account: {
+ email: existingProfile.email ?? normalizedEmail,
+ is_client: existingProfile.is_client,
+ client_onboarded_at: existingProfile.client_onboarded_at,
+ userId: existingUser.id,
+ },
+ };
+ }
+
+ const { data: authData, error: authError } = await supabase.auth.admin.createUser({
+ email: normalizedEmail,
+ email_confirm: true,
+ user_metadata: { role: "client" },
+ });
+
+ if (authError || !authData?.user) {
+ return { success: false, error: authError?.message ?? "Failed to create client user" };
+ }
+
+ const { error: updateProfileError } = await supabase
+ .from("profiles")
+ .update({ is_client: true, client_onboarded_at: null })
+ .eq("id", authData.user.id);
+
+ if (updateProfileError) {
+ return { success: false, error: updateProfileError.message };
+ }
+
+ return {
+ success: true,
+ wasCreated: true,
+ account: {
+ email: normalizedEmail,
+ is_client: true,
+ client_onboarded_at: null,
+ userId: authData.user.id,
+ },
+ };
+}
+
+export async function ensureClientTabloAccess(
+ supabase: SupabaseClient,
+ tabloId: string,
+ userId: string,
+ grantedBy: string
+): Promise<{ success: boolean; error?: string }> {
+ const { data: existingAccess, error: accessError } = await supabase
+ .from("tablo_access")
+ .select("id, is_active")
+ .eq("tablo_id", tabloId)
+ .eq("user_id", userId)
+ .maybeSingle();
+
+ if (accessError) {
+ return { success: false, error: accessError.message };
+ }
+
+ if (!existingAccess) {
+ const { error: insertError } = await supabase.from("tablo_access").insert({
+ tablo_id: tabloId,
+ user_id: userId,
+ granted_by: grantedBy,
+ is_admin: false,
+ is_active: true,
+ });
+ if (insertError) {
+ return { success: false, error: insertError.message };
+ }
+ return { success: true };
+ }
+
+ if (!existingAccess.is_active) {
+ const { error: updateError } = await supabase
+ .from("tablo_access")
+ .update({ is_active: true })
+ .eq("id", existingAccess.id);
+ if (updateError) {
+ return { success: false, error: updateError.message };
+ }
+ }
+
+ return { success: true };
+}
+
+export async function createClientSetupInvite(
+ supabase: SupabaseClient,
+ input: {
+ tabloId: string;
+ invitedEmail: string;
+ invitedBy: string;
+ token: string;
+ expiresAt: string;
+ }
+): Promise<{ success: boolean; error?: string }> {
+ const { error } = await supabase.from("client_invites").insert({
+ tablo_id: input.tabloId,
+ invited_email: input.invitedEmail,
+ invited_by: input.invitedBy,
+ invite_token: input.token,
+ invite_type: "setup",
+ is_pending: true,
+ expires_at: input.expiresAt,
+ });
+
+ if (error) {
+ return { success: false, error: error.message };
+ }
+
+ return { success: true };
+}
diff --git a/apps/api/src/helpers/orgIcons.test.ts b/apps/api/src/helpers/orgIcons.test.ts
index 6493d46..ef943dd 100644
--- a/apps/api/src/helpers/orgIcons.test.ts
+++ b/apps/api/src/helpers/orgIcons.test.ts
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { resizeOrgIcon, buildOrgIconKey, ICON_SIZES, ORG_ICONS_BUCKET } from "./orgIcons.js";
+import { describe, expect, it } from "vitest";
+import { buildOrgIconKey, ICON_SIZES, resizeOrgIcon } from "./orgIcons.js";
describe("buildOrgIconKey", () => {
it("builds the correct R2 key for a given org and size", () => {
@@ -32,7 +32,12 @@ describe("resizeOrgIcon", () => {
it("returns buffers for all required sizes from a valid PNG input", async () => {
const sharp = (await import("sharp")).default;
const input = await sharp({
- create: { width: 512, height: 512, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } },
+ create: {
+ width: 512,
+ height: 512,
+ channels: 4,
+ background: { r: 255, g: 0, b: 0, alpha: 1 },
+ },
})
.png()
.toBuffer();
@@ -49,7 +54,12 @@ describe("resizeOrgIcon", () => {
it("rejects images smaller than 512x512", async () => {
const sharp = (await import("sharp")).default;
const tooSmall = await sharp({
- create: { width: 256, height: 256, channels: 4, background: { r: 0, g: 0, b: 255, alpha: 1 } },
+ create: {
+ width: 256,
+ height: 256,
+ channels: 4,
+ background: { r: 0, g: 0, b: 255, alpha: 1 },
+ },
})
.png()
.toBuffer();
diff --git a/apps/api/src/middlewares/middleware.ts b/apps/api/src/middlewares/middleware.ts
index 773a20b..b53c15b 100644
--- a/apps/api/src/middlewares/middleware.ts
+++ b/apps/api/src/middlewares/middleware.ts
@@ -6,6 +6,7 @@ import { createMiddleware } from "hono/factory";
import type { Transporter } from "nodemailer";
import { Stripe } from "stripe";
import { type AppConfig } from "../config.js";
+import { type AdminTokenResult, verifyAdminSession } from "../helpers/adminTokens.js";
import { authenticateFromHeader } from "../helpers/auth.js";
import { createStripeSync } from "./stripeSync.js";
import { createTransporter } from "./transporter.js";
@@ -24,6 +25,9 @@ export type Middlewares = {
Variables: { supabase: SupabaseClient; user: User };
Bindings: { user: User };
}>;
+ adminAuthMiddleware: MiddlewareHandler<{
+ Variables: { adminSession: import("../helpers/adminTokens.js").AdminSessionClaims };
+ }>;
r2Middleware: MiddlewareHandler<{
Variables: { s3_client: S3Client };
}>;
@@ -74,6 +78,10 @@ export class MiddlewareManager {
}
private initializeMiddlewares(config: AppConfig): Middlewares {
+ const isAdminTokenFailure = (
+ result: AdminTokenResult
+ ): result is Extract, { success: false }> => !result.success;
+
const createProfileAccessMiddleware = (allowTemporaryUsers: boolean) =>
createMiddleware<{
Variables: { supabase: SupabaseClient; user: User };
@@ -84,7 +92,7 @@ export class MiddlewareManager {
const { data: profile, error } = await supabase
.from("profiles")
- .select("is_temporary")
+ .select("is_temporary, is_client")
.eq("id", user.id)
.single();
@@ -92,7 +100,7 @@ export class MiddlewareManager {
return c.json({ error: error?.message ?? "Profile not found" }, 500);
}
- if (!allowTemporaryUsers && profile.is_temporary) {
+ if ((!allowTemporaryUsers && profile.is_temporary) || profile.is_client) {
return c.json({ error: "User is read only" }, 401);
}
@@ -141,6 +149,38 @@ export class MiddlewareManager {
await next();
});
+ const adminAuthMiddleware = createMiddleware<{
+ Variables: { adminSession: import("../helpers/adminTokens.js").AdminSessionClaims };
+ }>(async (c, next) => {
+ const authHeader = c.req.header("Authorization");
+
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
+ return c.json(
+ {
+ code: "ADMIN_SESSION_REQUIRED",
+ error: "Admin session required",
+ },
+ 401
+ );
+ }
+
+ const sessionToken = authHeader.substring(7);
+ const verifiedSession = verifyAdminSession(sessionToken, config);
+
+ if (isAdminTokenFailure(verifiedSession)) {
+ return c.json(
+ {
+ code: verifiedSession.code,
+ error: verifiedSession.error,
+ },
+ verifiedSession.statusCode
+ );
+ }
+
+ c.set("adminSession", verifiedSession.value);
+ await next();
+ });
+
const maybeAuthenticatedMiddleware = createMiddleware<{
Variables: { supabase: SupabaseClient; user: User | null };
}>(async (c, next) => {
@@ -241,6 +281,7 @@ export class MiddlewareManager {
supabaseMiddleware,
basicAuthMiddleware,
authMiddleware,
+ adminAuthMiddleware,
maybeAuthenticatedMiddleware,
r2Middleware,
regularUserCheckMiddleware,
@@ -264,6 +305,10 @@ export class MiddlewareManager {
return this.middlewares.authMiddleware;
}
+ get adminAuth() {
+ return this.middlewares.adminAuthMiddleware;
+ }
+
get maybeAuthenticated() {
return this.middlewares.maybeAuthenticatedMiddleware;
}
diff --git a/apps/api/src/routers/admin.ts b/apps/api/src/routers/admin.ts
new file mode 100644
index 0000000..00aa5b1
--- /dev/null
+++ b/apps/api/src/routers/admin.ts
@@ -0,0 +1,32 @@
+import { Hono } from "hono";
+import type { AppConfig } from "../config.js";
+import { MiddlewareManager } from "../middlewares/middleware.js";
+import type { BaseEnv } from "../types/app.types.js";
+import { getAdminActionsRouter } from "./adminActions.js";
+import { getAdminAuthRouter } from "./adminAuth.js";
+import { getAdminDatasetsRouter } from "./adminDatasets.js";
+import { getAdminOverviewRouter } from "./adminOverview.js";
+import { getAdminTablesRouter } from "./adminTables.js";
+
+export const getAdminRouter = (config: AppConfig) => {
+ const adminRouter = new Hono();
+ const middlewareManager = MiddlewareManager.getInstance();
+
+ adminRouter.route("/auth", getAdminAuthRouter(config));
+
+ adminRouter.use("/overview", middlewareManager.adminAuth);
+ adminRouter.use("/overview/*", middlewareManager.adminAuth);
+ adminRouter.use("/tables", middlewareManager.adminAuth);
+ adminRouter.use("/tables/*", middlewareManager.adminAuth);
+ adminRouter.use("/datasets", middlewareManager.adminAuth);
+ adminRouter.use("/datasets/*", middlewareManager.adminAuth);
+ adminRouter.use("/actions", middlewareManager.adminAuth);
+ adminRouter.use("/actions/*", middlewareManager.adminAuth);
+
+ adminRouter.route("/overview", getAdminOverviewRouter());
+ adminRouter.route("/tables", getAdminTablesRouter());
+ adminRouter.route("/datasets", getAdminDatasetsRouter());
+ adminRouter.route("/actions", getAdminActionsRouter());
+
+ return adminRouter;
+};
diff --git a/apps/api/src/routers/adminActions.ts b/apps/api/src/routers/adminActions.ts
new file mode 100644
index 0000000..3431786
--- /dev/null
+++ b/apps/api/src/routers/adminActions.ts
@@ -0,0 +1,104 @@
+import type { AdminActionRunResponse } from "@xtablo/shared-types";
+import { Hono } from "hono";
+import { recordAdminAuditLog } from "../helpers/adminAudit.js";
+import { getAdminActionDefinition, listAdminActions } from "../helpers/adminRegistry.js";
+import type { BaseEnv } from "../types/app.types.js";
+
+type ActionInput = {
+ reason?: string;
+ tabloId?: string;
+ userId?: string;
+};
+
+function getActionInput(body: unknown): ActionInput {
+ if (!body || typeof body !== "object") {
+ return {};
+ }
+
+ const input = body as Record;
+
+ return {
+ reason: typeof input.reason === "string" ? input.reason : undefined,
+ tabloId: typeof input.tabloId === "string" ? input.tabloId : undefined,
+ userId: typeof input.userId === "string" ? input.userId : undefined,
+ };
+}
+
+export const getAdminActionsRouter = () => {
+ const adminActionsRouter = new Hono();
+
+ adminActionsRouter.get("/", async (c) => {
+ return c.json({ actions: listAdminActions() }, 200);
+ });
+
+ adminActionsRouter.post("/:actionId/run", async (c) => {
+ const supabase = c.get("supabase");
+ const adminSession = c.get("adminSession");
+ const actionId = c.req.param("actionId");
+ const definition = getAdminActionDefinition(actionId);
+
+ if (!definition) {
+ return c.json({ error: `Admin action '${actionId}' is not registered` }, 404);
+ }
+
+ const { reason, tabloId, userId } = getActionInput(await c.req.json().catch(() => null));
+
+ if (!tabloId || !userId || !reason) {
+ return c.json({ error: "tabloId, userId, and reason are required" }, 400);
+ }
+
+ const { data: before, error: beforeError } = await supabase
+ .from("tablo_access")
+ .select("id,tablo_id,user_id,is_active,is_admin")
+ .eq("tablo_id", tabloId)
+ .eq("user_id", userId)
+ .maybeSingle();
+
+ if (beforeError) {
+ return c.json({ error: `Failed to load tablo access for action '${actionId}'` }, 500);
+ }
+
+ if (!before) {
+ return c.json({ error: "Target tablo access row was not found" }, 404);
+ }
+
+ const changes =
+ actionId === "grant_tablo_admin" ? { is_active: true, is_admin: true } : { is_active: false };
+
+ const { data: after, error: updateError } = await supabase
+ .from("tablo_access")
+ .update(changes)
+ .eq("id", before.id)
+ .select("id,tablo_id,user_id,is_active,is_admin")
+ .single();
+
+ if (updateError || !after) {
+ return c.json({ error: `Failed to run admin action '${actionId}'` }, 500);
+ }
+
+ await recordAdminAuditLog({
+ action: `${actionId}:${reason}`,
+ after,
+ before,
+ operatorEmail: adminSession.operatorEmail,
+ operatorId: adminSession.operatorId,
+ role: adminSession.role,
+ supabase,
+ targetId: `${tabloId}:${userId}`,
+ targetType: "tablo_access",
+ });
+
+ return c.json(
+ {
+ message:
+ actionId === "grant_tablo_admin"
+ ? "Tablo admin access granted and logged."
+ : "Tablo access deactivated and logged.",
+ success: true,
+ } satisfies AdminActionRunResponse,
+ 200
+ );
+ });
+
+ return adminActionsRouter;
+};
diff --git a/apps/api/src/routers/adminAuth.ts b/apps/api/src/routers/adminAuth.ts
new file mode 100644
index 0000000..065d284
--- /dev/null
+++ b/apps/api/src/routers/adminAuth.ts
@@ -0,0 +1,55 @@
+import { Hono } from "hono";
+import type { AppConfig } from "../config.js";
+import { type AdminTokenResult, exchangePrivilegedToken } from "../helpers/adminTokens.js";
+import { MiddlewareManager } from "../middlewares/middleware.js";
+import type { BaseEnv } from "../types/app.types.js";
+
+export const getAdminAuthRouter = (config: AppConfig) => {
+ const adminAuthRouter = new Hono();
+ const middlewareManager = MiddlewareManager.getInstance();
+ const isAdminTokenFailure = (
+ result: AdminTokenResult
+ ): result is Extract, { success: false }> => !result.success;
+
+ adminAuthRouter.post("/exchange", async (c) => {
+ const body = await c.req.json().catch(() => null);
+ const accessToken =
+ body && typeof body === "object" && "accessToken" in body ? body.accessToken : undefined;
+
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
+ return c.json(
+ {
+ code: "INVALID_ADMIN_ACCESS_TOKEN",
+ error: "Invalid privileged access token",
+ },
+ 401
+ );
+ }
+
+ const exchangeResult = exchangePrivilegedToken(accessToken, config);
+
+ if (isAdminTokenFailure(exchangeResult)) {
+ return c.json(
+ {
+ code: exchangeResult.code,
+ error: exchangeResult.error,
+ },
+ exchangeResult.statusCode
+ );
+ }
+
+ return c.json(exchangeResult.value, 200);
+ });
+
+ adminAuthRouter.use("/session", middlewareManager.adminAuth);
+
+ adminAuthRouter.get("/session", async (c) => {
+ const adminSession = c.get("adminSession");
+
+ return c.json(adminSession, 200);
+ });
+
+ adminAuthRouter.post("/logout", async (c) => c.json({ success: true }, 200));
+
+ return adminAuthRouter;
+};
diff --git a/apps/api/src/routers/adminDatasets.ts b/apps/api/src/routers/adminDatasets.ts
new file mode 100644
index 0000000..3c6444d
--- /dev/null
+++ b/apps/api/src/routers/adminDatasets.ts
@@ -0,0 +1,155 @@
+import type { AdminDatasetResult } from "@xtablo/shared-types";
+import { Hono } from "hono";
+import { getAdminDatasetDefinition, listAdminDatasets } from "../helpers/adminRegistry.js";
+import type { BaseEnv } from "../types/app.types.js";
+
+function bucketByDay(values: Array) {
+ const counts = new Map();
+
+ values.forEach((value) => {
+ if (!value) {
+ return;
+ }
+
+ const bucket = value.slice(0, 10);
+ counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
+ });
+
+ return Array.from(counts.entries())
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([label, value]) => ({ label, value }));
+}
+
+function bucketByValue(values: Array, emptyLabel: string) {
+ const counts = new Map();
+
+ values.forEach((value) => {
+ const bucket = value?.trim() || emptyLabel;
+ counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
+ });
+
+ return Array.from(counts.entries())
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([label, value]) => ({ label, value }));
+}
+
+function bucketTabloAccess(rows: Array<{ is_active: boolean | null; is_admin: boolean | null }>) {
+ const counts = new Map([
+ ["Active Member", 0],
+ ["Active Admin", 0],
+ ["Inactive", 0],
+ ]);
+
+ rows.forEach((row) => {
+ if (row.is_active) {
+ const label = row.is_admin ? "Active Admin" : "Active Member";
+ counts.set(label, (counts.get(label) ?? 0) + 1);
+ return;
+ }
+
+ counts.set("Inactive", (counts.get("Inactive") ?? 0) + 1);
+ });
+
+ return Array.from(counts.entries()).map(([label, value]) => ({ label, value }));
+}
+
+type AdminDatasetPayload = Pick<
+ AdminDatasetResult,
+ "chartType" | "dimensionLabel" | "metricLabel" | "points"
+>;
+
+async function getDatasetPoints(
+ datasetId: string,
+ supabase: BaseEnv["Variables"]["supabase"]
+): Promise {
+ switch (datasetId) {
+ case "profile_growth": {
+ const { data, error } = await supabase
+ .from("profiles")
+ .select("created_at")
+ .order("created_at", { ascending: true })
+ .limit(365);
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ return {
+ chartType: "line",
+ dimensionLabel: "Created Day",
+ metricLabel: "Users Created",
+ points: bucketByDay((data ?? []).map((row) => row.created_at)),
+ };
+ }
+ case "plan_mix": {
+ const { data, error } = await supabase.from("profiles").select("plan").limit(500);
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ return {
+ chartType: "donut",
+ dimensionLabel: "Plan",
+ metricLabel: "Users",
+ points: bucketByValue(
+ (data ?? []).map((row) => row.plan),
+ "No Plan"
+ ),
+ };
+ }
+ case "tablo_access_mix": {
+ const { data, error } = await supabase
+ .from("tablo_access")
+ .select("is_active,is_admin")
+ .limit(500);
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ return {
+ chartType: "bar",
+ dimensionLabel: "Access Type",
+ metricLabel: "Rows",
+ points: bucketTabloAccess(data ?? []),
+ };
+ }
+ default:
+ throw new Error(`Unknown admin dataset '${datasetId}'`);
+ }
+}
+
+export const getAdminDatasetsRouter = () => {
+ const adminDatasetsRouter = new Hono();
+
+ adminDatasetsRouter.get("/", async (c) => {
+ return c.json({ datasets: listAdminDatasets() }, 200);
+ });
+
+ adminDatasetsRouter.get("/:datasetId", async (c) => {
+ const supabase = c.get("supabase");
+ const datasetId = c.req.param("datasetId");
+ const definition = getAdminDatasetDefinition(datasetId);
+
+ if (!definition) {
+ return c.json({ error: `Admin dataset '${datasetId}' is not registered` }, 404);
+ }
+
+ try {
+ const dataset = await getDatasetPoints(datasetId, supabase);
+
+ return c.json(
+ {
+ ...definition,
+ ...dataset,
+ } satisfies AdminDatasetResult,
+ 200
+ );
+ } catch {
+ return c.json({ error: `Failed to load admin dataset '${datasetId}'` }, 500);
+ }
+ });
+
+ return adminDatasetsRouter;
+};
diff --git a/apps/api/src/routers/adminOverview.ts b/apps/api/src/routers/adminOverview.ts
new file mode 100644
index 0000000..22671d7
--- /dev/null
+++ b/apps/api/src/routers/adminOverview.ts
@@ -0,0 +1,141 @@
+import type { AdminOverviewResponse } from "@xtablo/shared-types";
+import { Hono } from "hono";
+import type { BaseEnv } from "../types/app.types.js";
+
+function startOfRecentWindow(days: number) {
+ const date = new Date();
+ date.setUTCDate(date.getUTCDate() - days);
+ return date.toISOString();
+}
+
+async function countRows(
+ query: PromiseLike<{ count: number | null; error: { message: string } | null }>
+) {
+ const { count, error } = await query;
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ return count ?? 0;
+}
+
+export const getAdminOverviewRouter = () => {
+ const adminOverviewRouter = new Hono();
+
+ adminOverviewRouter.get("/", async (c) => {
+ const supabase = c.get("supabase");
+ const sevenDaysAgo = startOfRecentWindow(7);
+
+ try {
+ const [
+ totalUsers,
+ recentUsers,
+ totalTablos,
+ recentTablos,
+ activeAccess,
+ adminAccess,
+ temporaryUsers,
+ inactiveAccess,
+ ] = await Promise.all([
+ countRows(supabase.from("profiles").select("*", { count: "exact", head: true })),
+ countRows(
+ supabase
+ .from("profiles")
+ .select("*", { count: "exact", head: true })
+ .gte("created_at", sevenDaysAgo)
+ ),
+ countRows(
+ supabase.from("tablos").select("*", { count: "exact", head: true }).is("deleted_at", null)
+ ),
+ countRows(
+ supabase
+ .from("tablos")
+ .select("*", { count: "exact", head: true })
+ .is("deleted_at", null)
+ .gte("created_at", sevenDaysAgo)
+ ),
+ countRows(
+ supabase
+ .from("tablo_access")
+ .select("*", { count: "exact", head: true })
+ .eq("is_active", true)
+ ),
+ countRows(
+ supabase
+ .from("tablo_access")
+ .select("*", { count: "exact", head: true })
+ .eq("is_active", true)
+ .eq("is_admin", true)
+ ),
+ countRows(
+ supabase
+ .from("profiles")
+ .select("*", { count: "exact", head: true })
+ .eq("is_temporary", true)
+ ),
+ countRows(
+ supabase
+ .from("tablo_access")
+ .select("*", { count: "exact", head: true })
+ .eq("is_active", false)
+ ),
+ ]);
+
+ const response: AdminOverviewResponse = {
+ alerts: [
+ {
+ description: `${temporaryUsers} temporary users still exist in production.`,
+ id: "temporary-users",
+ severity: temporaryUsers > 0 ? "warning" : "info",
+ title: "Temporary Accounts",
+ },
+ {
+ description: `${inactiveAccess} tablo access rows are inactive and may need review.`,
+ id: "inactive-access",
+ severity: inactiveAccess > 10 ? "critical" : "warning",
+ title: "Inactive Access Drift",
+ },
+ ],
+ metrics: [
+ {
+ changeLabel: `+${recentUsers} last 7d`,
+ id: "total-users",
+ label: "Total Users",
+ value: totalUsers.toLocaleString(),
+ },
+ {
+ changeLabel: `+${recentTablos} last 7d`,
+ id: "total-tablos",
+ label: "Active Tablos",
+ value: totalTablos.toLocaleString(),
+ },
+ {
+ changeLabel: `${adminAccess} admin grants`,
+ id: "active-access",
+ label: "Active Access",
+ value: activeAccess.toLocaleString(),
+ },
+ {
+ changeLabel: `${temporaryUsers} temporary`,
+ id: "admin-access",
+ label: "Admin Grants",
+ value: adminAccess.toLocaleString(),
+ },
+ ],
+ shortcuts: [
+ { href: "/explorer", id: "profiles", label: "Inspect Users" },
+ { href: "/explorer", id: "access", label: "Review Tablo Access" },
+ { href: "/analytics", id: "growth", label: "Open Growth Analytics" },
+ { href: "/actions", id: "actions", label: "Run Admin Actions" },
+ ],
+ };
+
+ return c.json(response, 200);
+ } catch {
+ return c.json({ error: "Failed to load admin overview" }, 500);
+ }
+ });
+
+ return adminOverviewRouter;
+};
diff --git a/apps/api/src/routers/adminTables.ts b/apps/api/src/routers/adminTables.ts
new file mode 100644
index 0000000..7f2b9da
--- /dev/null
+++ b/apps/api/src/routers/adminTables.ts
@@ -0,0 +1,139 @@
+import { Hono } from "hono";
+import { recordAdminAuditLog } from "../helpers/adminAudit.js";
+import {
+ getAdminTableDefinition,
+ listAdminTables,
+ normalizeAdminRows,
+} from "../helpers/adminRegistry.js";
+import type { BaseEnv } from "../types/app.types.js";
+
+export const getAdminTablesRouter = () => {
+ const adminTablesRouter = new Hono();
+
+ adminTablesRouter.get("/", async (c) => {
+ return c.json({ tables: listAdminTables() }, 200);
+ });
+
+ adminTablesRouter.get("/:tableId/meta", async (c) => {
+ const tableId = c.req.param("tableId");
+ const tableDefinition = getAdminTableDefinition(tableId);
+
+ if (!tableDefinition) {
+ return c.json(
+ {
+ error: `Admin table '${tableId}' is not registered`,
+ },
+ 404
+ );
+ }
+
+ return c.json(
+ {
+ columns: tableDefinition.columns,
+ editableFields: tableDefinition.editableColumns ?? [],
+ id: tableDefinition.id,
+ label: tableDefinition.label,
+ primaryKey: tableDefinition.primaryKey,
+ },
+ 200
+ );
+ });
+
+ adminTablesRouter.get("/:tableId/rows", async (c) => {
+ const supabase = c.get("supabase");
+ const tableId = c.req.param("tableId");
+ const tableDefinition = getAdminTableDefinition(tableId);
+
+ if (!tableDefinition) {
+ return c.json(
+ {
+ error: `Admin table '${tableId}' is not registered`,
+ },
+ 404
+ );
+ }
+
+ const { data, error } = await supabase
+ .from(tableDefinition.source)
+ .select(tableDefinition.select)
+ .limit(50);
+
+ if (error) {
+ return c.json(
+ {
+ error: `Failed to load admin table '${tableId}'`,
+ },
+ 500
+ );
+ }
+
+ return c.json({ rows: normalizeAdminRows(data ?? []) }, 200);
+ });
+
+ adminTablesRouter.patch("/:tableId/rows/:rowId", async (c) => {
+ const supabase = c.get("supabase");
+ const adminSession = c.get("adminSession");
+ const tableId = c.req.param("tableId");
+ const rowId = c.req.param("rowId");
+ const tableDefinition = getAdminTableDefinition(tableId);
+
+ if (!tableDefinition) {
+ return c.json(
+ {
+ error: `Admin table '${tableId}' is not registered`,
+ },
+ 404
+ );
+ }
+
+ const body = await c.req.json().catch(() => null);
+ if (!body || typeof body !== "object") {
+ return c.json({ error: "Invalid update payload" }, 400);
+ }
+
+ const requestedChanges = Object.fromEntries(
+ Object.entries(body).filter(([key]) => tableDefinition.editableColumns?.includes(key))
+ );
+
+ if (Object.keys(requestedChanges).length === 0) {
+ return c.json({ error: "No editable fields provided" }, 400);
+ }
+
+ const { data: existingRow, error: existingRowError } = await supabase
+ .from(tableDefinition.source)
+ .select(tableDefinition.select)
+ .eq(tableDefinition.primaryKey, rowId)
+ .single();
+
+ if (existingRowError || !existingRow) {
+ return c.json({ error: `Admin row '${rowId}' was not found` }, 404);
+ }
+
+ const { data: updatedRow, error: updateError } = await supabase
+ .from(tableDefinition.source)
+ .update(requestedChanges)
+ .eq(tableDefinition.primaryKey, rowId)
+ .select(tableDefinition.select)
+ .single();
+
+ if (updateError || !updatedRow) {
+ return c.json({ error: `Failed to update admin table '${tableId}'` }, 500);
+ }
+
+ await recordAdminAuditLog({
+ action: "update",
+ after: updatedRow,
+ before: existingRow,
+ operatorEmail: adminSession.operatorEmail,
+ operatorId: adminSession.operatorId,
+ role: adminSession.role,
+ supabase,
+ targetId: rowId,
+ targetType: tableId,
+ });
+
+ return c.json({ row: updatedRow }, 200);
+ });
+
+ return adminTablesRouter;
+};
diff --git a/apps/api/src/routers/authRouter.ts b/apps/api/src/routers/authRouter.ts
index 41f2c53..4c308b8 100644
--- a/apps/api/src/routers/authRouter.ts
+++ b/apps/api/src/routers/authRouter.ts
@@ -1,6 +1,7 @@
import { Hono } from "hono";
import type { AppConfig } from "../config.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
+import { getClientInvitesRouter } from "./clientInvites.js";
import { getNotesRouter } from "./notes.js";
import { getStripeRouter } from "./stripe.js";
import { getTabloRouter } from "./tablo.js";
@@ -19,6 +20,7 @@ export const getAuthenticatedRouter = (config: AppConfig) => {
authRouter.route("/tablos", getTabloRouter(config));
authRouter.route("/tablo-data", getTabloDataRouter());
authRouter.route("/notes", getNotesRouter());
+ authRouter.route("/client-invites", getClientInvitesRouter());
// stripe routes
authRouter.route("/stripe", getStripeRouter(config));
diff --git a/apps/api/src/routers/clientInvites.ts b/apps/api/src/routers/clientInvites.ts
new file mode 100644
index 0000000..d42da4c
--- /dev/null
+++ b/apps/api/src/routers/clientInvites.ts
@@ -0,0 +1,361 @@
+import { Hono } from "hono";
+import { createFactory } from "hono/factory";
+import {
+ checkTabloAdmin,
+ createClientSetupInvite,
+ ensureClientTabloAccess,
+ findOrCreateClientAccount,
+} from "../helpers/helpers.js";
+import { generateToken } from "../helpers/token.js";
+import { MiddlewareManager } from "../middlewares/middleware.js";
+import type { AuthEnv, BaseEnv } from "../types/app.types.js";
+
+const authFactory = createFactory();
+const publicFactory = createFactory();
+
+const CLIENT_INVITE_EXPIRY_HOURS = 72;
+
+const getClientsUrl = () => process.env.CLIENTS_URL || "https://clients.xtablo.com";
+
+const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
+
+const findInviteByToken = async (token: string, supabase: BaseEnv["Variables"]["supabase"]) =>
+ supabase
+ .from("client_invites")
+ .select(
+ "id, tablo_id, invited_email, invited_by, invite_type, is_pending, expires_at, used_at, cancelled_at, setup_completed_at"
+ )
+ .eq("invite_token", token)
+ .maybeSingle();
+
+const validateSetupInvite = async (token: string, supabase: BaseEnv["Variables"]["supabase"]) => {
+ const { data: invite, error } = await findInviteByToken(token, supabase);
+
+ if (error) {
+ return { status: 500 as const, body: { error: error.message } };
+ }
+
+ if (!invite || invite.invite_type !== "setup" || !invite.is_pending) {
+ return { status: 404 as const, body: { error: "Invite not found or already used" } };
+ }
+
+ if (invite.cancelled_at || invite.used_at || invite.setup_completed_at) {
+ return { status: 404 as const, body: { error: "Invite not found or already used" } };
+ }
+
+ if (invite.expires_at && new Date(invite.expires_at) < new Date()) {
+ return { status: 410 as const, body: { error: "This invite has expired" } };
+ }
+
+ return { status: 200 as const, invite };
+};
+
+const sendSetupEmail = async (
+ transporter: BaseEnv["Variables"]["transporter"],
+ input: { email: string; setupUrl: string }
+) => {
+ await transporter.sendMail({
+ from: "Xtablo ",
+ to: input.email,
+ subject: "Configurez votre accès client Xtablo",
+ html: `
+ Vous avez été invité sur Xtablo
+ Bonjour,
+ Créez votre mot de passe via le lien ci-dessous pour accéder à votre espace client :
+ Configurer mon mot de passe
+ Ce lien expire dans ${CLIENT_INVITE_EXPIRY_HOURS} heures et ne peut être utilisé qu'une seule fois.
+ `,
+ });
+};
+
+const sendAccessNotificationEmail = async (
+ transporter: BaseEnv["Variables"]["transporter"],
+ input: { email: string; tabloUrl: string }
+) => {
+ await transporter.sendMail({
+ from: "Xtablo ",
+ to: input.email,
+ subject: "Vous avez maintenant accès à un nouveau tablo",
+ html: `
+ Vous avez maintenant accès à un tablo
+ Bonjour,
+ Votre accès a été ajouté. Utilisez le lien ci-dessous pour ouvrir directement le tablo :
+ Ouvrir le tablo
+ Si vous n'êtes pas connecté, vous serez redirigé vers la page de connexion.
+ `,
+ });
+};
+
+/** POST /:tabloId — Create a client invite (admin only) */
+const createClientInvite = (middlewareManager: ReturnType) =>
+ authFactory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
+ const user = c.get("user");
+ const supabase = c.get("supabase");
+ const transporter = c.get("transporter");
+ const tabloId = c.req.param("tabloId");
+
+ const body = await c.req.json();
+ const rawEmail = String(body.email || "")
+ .trim()
+ .toLowerCase();
+
+ if (!rawEmail || !isValidEmail(rawEmail)) {
+ return c.json({ error: "A valid email is required" }, 400);
+ }
+
+ const accountResult = await findOrCreateClientAccount(supabase, rawEmail);
+ if ("error" in accountResult) {
+ const errorMessage = accountResult.error;
+ if (errorMessage.includes("already belongs")) {
+ return c.json({ error: errorMessage }, 409);
+ }
+ return c.json({ error: errorMessage }, 500);
+ }
+
+ const accessResult = await ensureClientTabloAccess(
+ supabase,
+ tabloId,
+ accountResult.account.userId,
+ user.id
+ );
+ if (!accessResult.success) {
+ return c.json({ error: accessResult.error ?? "Failed to grant client access" }, 500);
+ }
+
+ const clientsUrl = getClientsUrl();
+
+ if (accountResult.account.client_onboarded_at) {
+ try {
+ await sendAccessNotificationEmail(transporter, {
+ email: rawEmail,
+ tabloUrl: `${clientsUrl}/tablo/${tabloId}`,
+ });
+ } catch (emailError) {
+ console.error("Failed to send client access notification email:", emailError);
+ }
+
+ return c.json({ success: true, inviteMode: "notification" as const });
+ }
+
+ const token = generateToken();
+ const expiresAt = new Date(
+ Date.now() + CLIENT_INVITE_EXPIRY_HOURS * 60 * 60 * 1000
+ ).toISOString();
+
+ const inviteResult = await createClientSetupInvite(supabase, {
+ tabloId,
+ invitedEmail: rawEmail,
+ invitedBy: user.id,
+ token,
+ expiresAt,
+ });
+
+ if (!inviteResult.success) {
+ if (inviteResult.error?.includes("idx_client_invites_pending_setup_email_tablo")) {
+ return c.json({ error: "A pending invite already exists for this email and tablo" }, 409);
+ }
+ return c.json({ error: inviteResult.error ?? "Failed to create setup invite" }, 500);
+ }
+
+ try {
+ await sendSetupEmail(transporter, {
+ email: rawEmail,
+ setupUrl: `${clientsUrl}/set-password?token=${encodeURIComponent(token)}`,
+ });
+ } catch (emailError) {
+ console.error("Failed to send client setup email:", emailError);
+ }
+
+ return c.json({ success: true, inviteMode: "setup" as const });
+ });
+
+/** GET /setup/:token — Validate a setup invite token */
+const getSetupInvite = () =>
+ publicFactory.createHandlers(async (c) => {
+ const supabase = c.get("supabase");
+ const token = c.req.param("token");
+
+ const validation = await validateSetupInvite(token, supabase);
+ if (validation.status !== 200) {
+ return c.json(validation.body, validation.status);
+ }
+
+ return c.json({
+ email: validation.invite.invited_email,
+ tabloId: validation.invite.tablo_id,
+ });
+ });
+
+/** POST /setup/:token — Complete one-time password setup */
+const completeSetupInvite = () =>
+ publicFactory.createHandlers(async (c) => {
+ const supabase = c.get("supabase");
+ const token = c.req.param("token");
+ const body = await c.req.json();
+ const password = String(body.password || "");
+
+ if (password.length < 8) {
+ return c.json({ error: "Password must be at least 8 characters long" }, 400);
+ }
+
+ const validation = await validateSetupInvite(token, supabase);
+ if (validation.status !== 200) {
+ return c.json(validation.body, validation.status);
+ }
+
+ const invite = validation.invite;
+ const { data: usersData, error: listUsersError } = await supabase.auth.admin.listUsers();
+ if (listUsersError) {
+ return c.json({ error: listUsersError.message }, 500);
+ }
+
+ // biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime
+ const users = usersData as any;
+ // biome-ignore lint/suspicious/noExplicitAny: admin user type
+ const clientUser = users?.users?.find((candidate: any) => {
+ return candidate.email?.toLowerCase() === invite.invited_email?.toLowerCase();
+ });
+
+ if (!clientUser?.id) {
+ return c.json({ error: "Client account not found" }, 404);
+ }
+
+ const { error: updateUserError } = await supabase.auth.admin.updateUserById(clientUser.id, {
+ password,
+ });
+ if (updateUserError) {
+ return c.json({ error: updateUserError.message }, 500);
+ }
+
+ const completedAt = new Date().toISOString();
+
+ const { error: updateProfileError } = await supabase
+ .from("profiles")
+ .update({ client_onboarded_at: completedAt, is_client: true })
+ .eq("id", clientUser.id);
+ if (updateProfileError) {
+ return c.json({ error: updateProfileError.message }, 500);
+ }
+
+ const { error: consumeInviteError } = await supabase
+ .from("client_invites")
+ .update({
+ is_pending: false,
+ used_at: completedAt,
+ setup_completed_at: completedAt,
+ })
+ .eq("id", invite.id)
+ .eq("is_pending", true);
+ if (consumeInviteError) {
+ return c.json({ error: consumeInviteError.message }, 500);
+ }
+
+ return c.json({
+ success: true,
+ email: invite.invited_email,
+ tabloId: invite.tablo_id,
+ });
+ });
+
+/** GET /:tabloId/pending — List pending client invites (admin only) */
+const getPendingClientInvites = (
+ middlewareManager: ReturnType
+) =>
+ authFactory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
+ const supabase = c.get("supabase");
+ const tabloId = c.req.param("tabloId");
+
+ const { data: invites, error } = await supabase
+ .from("client_invites")
+ .select("id, invited_email, expires_at, is_pending, created_at, invite_type")
+ .eq("tablo_id", tabloId)
+ .eq("invite_type", "setup")
+ .eq("is_pending", true)
+ .order("created_at", { ascending: false });
+
+ if (error) {
+ return c.json({ error: error.message }, 500);
+ }
+
+ return c.json({ invites: invites ?? [] });
+ });
+
+/** DELETE /:tabloId/:inviteId — Cancel a client invite (admin only) */
+const cancelClientInvite = (middlewareManager: ReturnType) =>
+ authFactory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
+ const supabase = c.get("supabase");
+ const tabloId = c.req.param("tabloId");
+ const inviteId = Number(c.req.param("inviteId"));
+
+ if (!Number.isInteger(inviteId) || inviteId <= 0) {
+ return c.json({ error: "Invalid invite id" }, 400);
+ }
+
+ const { data: invite, error: inviteError } = await supabase
+ .from("client_invites")
+ .select("id, invited_email, is_pending, invite_type")
+ .eq("id", inviteId)
+ .eq("tablo_id", tabloId)
+ .maybeSingle();
+
+ if (inviteError) {
+ return c.json({ error: inviteError.message }, 500);
+ }
+
+ if (!invite) {
+ return c.json({ error: "Invite not found" }, 404);
+ }
+
+ if (!invite.is_pending) {
+ return c.json({ error: "Invite is no longer pending" }, 400);
+ }
+
+ const cancelledAt = new Date().toISOString();
+ const { error: cancelError } = await supabase
+ .from("client_invites")
+ .update({ is_pending: false, cancelled_at: cancelledAt })
+ .eq("id", inviteId)
+ .eq("tablo_id", tabloId);
+
+ if (cancelError) {
+ return c.json({ error: cancelError.message }, 500);
+ }
+
+ if (invite.invited_email) {
+ const { data: clientProfile } = await supabase
+ .from("profiles")
+ .select("id")
+ .eq("email", invite.invited_email)
+ .maybeSingle();
+
+ if (clientProfile?.id) {
+ await supabase
+ .from("tablo_access")
+ .update({ is_active: false })
+ .eq("tablo_id", tabloId)
+ .eq("user_id", clientProfile.id);
+ }
+ }
+
+ return c.json({ success: true });
+ });
+
+export const getClientInvitesRouter = () => {
+ const router = new Hono();
+ const middlewareManager = MiddlewareManager.getInstance();
+
+ router.post("/:tabloId", ...createClientInvite(middlewareManager));
+ router.get("/:tabloId/pending", ...getPendingClientInvites(middlewareManager));
+ router.delete("/:tabloId/:inviteId", ...cancelClientInvite(middlewareManager));
+
+ return router;
+};
+
+export const getPublicClientInvitesRouter = () => {
+ const router = new Hono();
+
+ router.get("/setup/:token", ...getSetupInvite());
+ router.post("/setup/:token", ...completeSetupInvite());
+
+ return router;
+};
diff --git a/apps/api/src/routers/index.ts b/apps/api/src/routers/index.ts
index 1ca996e..ea248f1 100644
--- a/apps/api/src/routers/index.ts
+++ b/apps/api/src/routers/index.ts
@@ -2,7 +2,9 @@ import { Hono } from "hono";
import type { AppConfig } from "../config.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { BaseEnv } from "../types/app.types.js";
+import { getAdminRouter } from "./admin.js";
import { getAuthenticatedRouter } from "./authRouter.js";
+import { getPublicClientInvitesRouter } from "./clientInvites.js";
import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js";
import { getPublicRouter } from "./public.js";
import { getStripeWebhookRouter } from "./stripe.js";
@@ -31,6 +33,12 @@ export const getMainRouter = (config: AppConfig) => {
// webhooks
mainRouter.route("/stripe-webhook", getStripeWebhookRouter());
+ // admin routes
+ mainRouter.route("/admin", getAdminRouter(config));
+
+ // public client onboarding routes
+ mainRouter.route("/client-invites", getPublicClientInvitesRouter());
+
// maybe authenticated routes (checked first to allow unauthenticated booking)
mainRouter.route("/", getMaybeAuthenticatedRouter());
diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts
index 5ce02b7..7b843ae 100644
--- a/apps/api/src/routers/tablo.ts
+++ b/apps/api/src/routers/tablo.ts
@@ -28,44 +28,44 @@ const createTablo = (middlewareManager: ReturnType;
+ const tabloData = insertedTablo as Tables<"tablos">;
- if (typedPayload.events) {
- const eventsToInsert = typedPayload.events.map((event) => ({
- ...event,
- tablo_id: tabloData.id,
- created_by: user.id,
- }));
+ if (typedPayload.events) {
+ const eventsToInsert = typedPayload.events.map((event) => ({
+ ...event,
+ tablo_id: tabloData.id,
+ created_by: user.id,
+ }));
- await supabase.from("events").insert(eventsToInsert);
- }
+ await supabase.from("events").insert(eventsToInsert);
+ }
return c.json({ message: "Tablo created successfully", tablo: tabloData });
}
);
@@ -90,12 +90,7 @@ const updateTablo = (middlewareManager: ReturnType {
const deletedAt = new Date().toISOString();
- const { error: tasksSoftDeleteError } = await supabase
- .from("tasks")
- .update({ deleted_at: deletedAt })
- .eq("tablo_id", id)
- .is("deleted_at", null);
+ const { error: tasksDeleteError } = await supabase.from("tasks").delete().eq("tablo_id", id);
- if (tasksSoftDeleteError) {
- // Backward compatibility for environments where tasks.deleted_at is not migrated yet.
- const isMissingDeletedAtColumn =
- tasksSoftDeleteError.code === "42703" ||
- tasksSoftDeleteError.message?.toLowerCase().includes("deleted_at");
-
- if (isMissingDeletedAtColumn) {
- const { error: tasksDeleteError } = await supabase.from("tasks").delete().eq("tablo_id", id);
- if (tasksDeleteError) {
- return c.json({ error: tasksDeleteError.message }, 500);
- }
- } else {
- return c.json({ error: tasksSoftDeleteError.message }, 500);
- }
+ if (tasksDeleteError) {
+ return c.json({ error: tasksDeleteError.message }, 500);
}
const { error } = await supabase.from("tablos").update({ deleted_at: deletedAt }).eq("id", id);
@@ -229,13 +208,9 @@ const inviteToTablo = (
if (!recipientUser) {
// Create a new invited user and add them to the tablo
- const result = await createInvitedUser(
- supabase,
- transporter,
- recipientEmail,
- sender.email,
- { isTemporary: true }
- );
+ const result = await createInvitedUser(supabase, transporter, recipientEmail, sender.email, {
+ isTemporary: true,
+ });
if (!result.success) {
return c.json({ error: result.error }, 500);
diff --git a/apps/api/src/routers/tablo_data.ts b/apps/api/src/routers/tablo_data.ts
index 3bb7a32..3a7e8e7 100644
--- a/apps/api/src/routers/tablo_data.ts
+++ b/apps/api/src/routers/tablo_data.ts
@@ -147,47 +147,47 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const postTabloFile = (middlewareManager: ReturnType) =>
factory.createHandlers(checkTabloMember, async (c) => {
- const tabloId = c.req.param("tabloId");
- const user = c.get("user");
- // Get the file path - supports both wildcard (*) and named parameter (:fileName)
- const filePath = c.req.param("path") || c.req.param("fileName");
+ const tabloId = c.req.param("tabloId");
+ const user = c.get("user");
+ // Get the file path - supports both wildcard (*) and named parameter (:fileName)
+ const filePath = c.req.param("path") || c.req.param("fileName");
- if (!filePath) {
- return c.json({ error: "File path is required" }, 400);
- }
-
- const s3_client = c.get("s3_client");
-
- try {
- const body = await c.req.json();
- const { content, contentType = "text/plain" } = body;
-
- if (!content) {
- return c.json({ error: "Content is required" }, 400);
+ if (!filePath) {
+ return c.json({ error: "File path is required" }, 400);
}
- await s3_client.send(
- new PutObjectCommand({
- Bucket: "tablo-data",
- Key: `${tabloId}/${filePath}`,
- Body: content,
- ContentType: contentType,
- Metadata: {
- "uploaded-by": user.id,
- },
- })
- );
- fileNamesCache.delete(tabloId);
+ const s3_client = c.get("s3_client");
- return c.json({
- message: "File uploaded successfully",
- fileName: filePath,
- tabloId,
- });
- } catch (error) {
- console.error("Error uploading file:", error);
- return c.json({ error: "Failed to upload file" }, 500);
- }
+ try {
+ const body = await c.req.json();
+ const { content, contentType = "text/plain" } = body;
+
+ if (!content) {
+ return c.json({ error: "Content is required" }, 400);
+ }
+
+ await s3_client.send(
+ new PutObjectCommand({
+ Bucket: "tablo-data",
+ Key: `${tabloId}/${filePath}`,
+ Body: content,
+ ContentType: contentType,
+ Metadata: {
+ "uploaded-by": user.id,
+ },
+ })
+ );
+ fileNamesCache.delete(tabloId);
+
+ return c.json({
+ message: "File uploaded successfully",
+ fileName: filePath,
+ tabloId,
+ });
+ } catch (error) {
+ console.error("Error uploading file:", error);
+ return c.json({ error: "Failed to upload file" }, 500);
+ }
});
const deleteTabloFile = (middlewareManager: ReturnType) =>
@@ -336,10 +336,7 @@ const getTabloFolders = factory.createHandlers(checkTabloMember, async (c) => {
// POST /tablo-data/:tabloId/folders - Create a new folder (admin only)
const createTabloFolder = (middlewareManager: ReturnType) =>
- factory.createHandlers(
- middlewareManager.regularUserCheck,
- checkTabloAdmin,
- async (c) => {
+ factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const s3_client = c.get("s3_client");
const user = c.get("user");
@@ -380,15 +377,11 @@ const createTabloFolder = (middlewareManager: ReturnType) =>
- factory.createHandlers(
- middlewareManager.regularUserCheck,
- checkTabloAdmin,
- async (c) => {
+ factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const folderId = c.req.param("folderId");
const s3_client = c.get("s3_client");
@@ -433,15 +426,11 @@ const updateTabloFolder = (middlewareManager: ReturnType) =>
- factory.createHandlers(
- middlewareManager.regularUserCheck,
- checkTabloAdmin,
- async (c) => {
+ factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const folderId = c.req.param("folderId");
const s3_client = c.get("s3_client");
@@ -469,8 +458,7 @@ const deleteTabloFolder = (middlewareManager: ReturnType {
if (removeAccessError) {
return c.json({ error: "Failed to revoke member tablo permissions" }, 500);
}
-
}
const { error: inviteCleanupError } = await supabase
diff --git a/apps/api/src/secrets.ts b/apps/api/src/secrets.ts
index 2126133..f3e917d 100644
--- a/apps/api/src/secrets.ts
+++ b/apps/api/src/secrets.ts
@@ -21,6 +21,7 @@ export type Secrets = {
supabaseServiceRoleKey: string;
supabaseConnectionString: string;
supabaseCaCert: string;
+ adminTokenSigningSecret: string;
emailClientSecret: string;
emailRefreshToken: string;
r2AccessKeyId: string;
@@ -42,6 +43,7 @@ export async function loadSecrets(): Promise {
supabaseServiceRoleKey: await fetchSecret("supabase-service-role-key"),
supabaseConnectionString: await fetchSecret("supabase-connection-string"),
supabaseCaCert: await fetchSecret("supabase-ca-cert"),
+ adminTokenSigningSecret: await fetchSecret("admin-token-signing-secret"),
emailClientSecret: await fetchSecret("email-client-secret"),
emailRefreshToken: await fetchSecret("email-refresh-token"),
r2AccessKeyId: await fetchSecret("r2-access-key-id"),
diff --git a/apps/api/src/types/app.types.ts b/apps/api/src/types/app.types.ts
index 1f14da7..5c1f28e 100644
--- a/apps/api/src/types/app.types.ts
+++ b/apps/api/src/types/app.types.ts
@@ -4,12 +4,14 @@ import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { Hono } from "hono";
import type { Transporter } from "nodemailer";
import type Stripe from "stripe";
+import type { AdminSessionClaims } from "../helpers/adminTokens.js";
/**
* Base environment variables available across all routes
*/
export type BaseEnv = {
Variables: {
+ adminSession: AdminSessionClaims;
supabase: SupabaseClient;
s3_client: S3Client;
transporter: Transporter;
diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts
index e2c7fcf..8586540 100644
--- a/apps/api/vitest.config.ts
+++ b/apps/api/vitest.config.ts
@@ -12,6 +12,7 @@ export default defineConfig({
exclude: ["node_modules", "dist"],
reporters: ["verbose"],
pool: "forks",
+ fileParallelism: false,
},
resolve: {
alias: {
diff --git a/apps/chat-worker/src/index.ts b/apps/chat-worker/src/index.ts
index 5eb2a65..6f3adee 100644
--- a/apps/chat-worker/src/index.ts
+++ b/apps/chat-worker/src/index.ts
@@ -10,12 +10,13 @@ export { ChatRoom };
const app = new Hono<{ Bindings: Env }>();
-// CORS — allow the main app origins
+// CORS — allow the web app origins that embed chat
app.use("*", cors({
origin: [
"http://localhost:5173",
"https://app.xtablo.com",
"https://app-staging.xtablo.com",
+ "https://clients.xtablo.com",
],
allowHeaders: ["Authorization", "Content-Type"],
allowMethods: ["GET", "POST", "OPTIONS"],
diff --git a/apps/clients/.env.production b/apps/clients/.env.production
new file mode 100644
index 0000000..7c3ba26
--- /dev/null
+++ b/apps/clients/.env.production
@@ -0,0 +1,7 @@
+VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
+VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM
+
+VITE_CHAT_WS_URL=wss://chat.xtablo.com
+VITE_CHAT_API_URL=https://chat.xtablo.com
+
+VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app
diff --git a/apps/clients/biome.json b/apps/clients/biome.json
new file mode 100644
index 0000000..fa8b1ab
--- /dev/null
+++ b/apps/clients/biome.json
@@ -0,0 +1,299 @@
+{
+ "root": false,
+ "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
+ "files": {
+ "ignoreUnknown": true,
+ "includes": ["src/**/*", "*.{tsx,js,jsx,json}", "vite.config.ts"]
+ },
+ "formatter": {
+ "enabled": true,
+ "formatWithErrors": false,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineEnding": "lf",
+ "lineWidth": 100,
+ "attributePosition": "auto"
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": false,
+ "complexity": {
+ "noAdjacentSpacesInRegex": "error",
+ "noBannedTypes": "error",
+ "noExtraBooleanCast": "error",
+ "noUselessCatch": "error",
+ "noUselessEscapeInRegex": "error",
+ "noUselessTypeConstraint": "error"
+ },
+ "correctness": {
+ "noChildrenProp": "error",
+ "noConstAssign": "error",
+ "noConstantCondition": "error",
+ "noEmptyCharacterClassInRegex": "error",
+ "noEmptyPattern": "error",
+ "noGlobalObjectCalls": "error",
+ "noInvalidBuiltinInstantiation": "error",
+ "noInvalidConstructorSuper": "error",
+ "noNonoctalDecimalEscape": "error",
+ "noPrecisionLoss": "error",
+ "noSelfAssign": "error",
+ "noSetterReturn": "error",
+ "noSwitchDeclarations": "error",
+ "noUndeclaredVariables": "error",
+ "noUnreachable": "error",
+ "noUnreachableSuper": "error",
+ "noUnsafeFinally": "error",
+ "noUnsafeOptionalChaining": "error",
+ "noUnusedLabels": "error",
+ "noUnusedPrivateClassMembers": "error",
+ "noUnusedVariables": "error",
+ "noUnusedImports": "error",
+ "useIsNan": "error",
+ "useJsxKeyInIterable": "error",
+ "useValidForDirection": "error",
+ "useValidTypeof": "error",
+ "useYield": "error"
+ },
+ "nursery": {},
+ "security": { "noDangerouslySetInnerHtmlWithChildren": "error" },
+ "style": {
+ "noCommonJs": "error",
+ "noNamespace": "error",
+ "useArrayLiterals": "error",
+ "useAsConstAssertion": "error",
+ "useConst": "error",
+ "useTemplate": "error"
+ },
+ "suspicious": {
+ "noAsyncPromiseExecutor": "error",
+ "noCatchAssign": "error",
+ "noClassAssign": "error",
+ "noCommentText": "error",
+ "noCompareNegZero": "error",
+ "noConstantBinaryExpressions": "error",
+ "noControlCharactersInRegex": "error",
+ "noDebugger": "error",
+ "noDuplicateCase": "error",
+ "noDuplicateClassMembers": "error",
+ "noDuplicateElseIf": "error",
+ "noDuplicateJsxProps": "error",
+ "noDuplicateObjectKeys": "error",
+ "noDuplicateParameters": "error",
+ "noEmptyBlockStatements": "error",
+ "noExplicitAny": "error",
+ "noExtraNonNullAssertion": "error",
+ "noFallthroughSwitchClause": "error",
+ "noFunctionAssign": "error",
+ "noGlobalAssign": "error",
+ "noImportAssign": "error",
+ "noIrregularWhitespace": "error",
+ "noMisleadingCharacterClass": "error",
+ "noMisleadingInstantiator": "error",
+ "noPrototypeBuiltins": "error",
+ "noRedeclare": "error",
+ "noShadowRestrictedNames": "error",
+ "noSparseArray": "error",
+ "noUnsafeDeclarationMerging": "error",
+ "noUnsafeNegation": "error",
+ "noUselessRegexBackrefs": "error",
+ "noWith": "error",
+ "useGetterReturn": "error",
+ "useNamespaceKeyword": "error"
+ }
+ }
+ },
+ "javascript": {
+ "formatter": {
+ "jsxQuoteStyle": "double",
+ "quoteProperties": "asNeeded",
+ "trailingCommas": "es5",
+ "semicolons": "always",
+ "arrowParentheses": "always",
+ "bracketSameLine": false,
+ "quoteStyle": "double",
+ "attributePosition": "auto",
+ "bracketSpacing": true
+ },
+ "globals": [
+ "onanimationend",
+ "ongamepadconnected",
+ "onlostpointercapture",
+ "onanimationiteration",
+ "onkeyup",
+ "onmousedown",
+ "onanimationstart",
+ "onslotchange",
+ "onprogress",
+ "ontransitionstart",
+ "onpause",
+ "onended",
+ "onpointerover",
+ "onscrollend",
+ "onformdata",
+ "ontransitionrun",
+ "onanimationcancel",
+ "ondrag",
+ "onchange",
+ "onbeforeinstallprompt",
+ "onbeforexrselect",
+ "onmessage",
+ "ontransitioncancel",
+ "onpointerdown",
+ "onabort",
+ "onpointerout",
+ "oncuechange",
+ "ongotpointercapture",
+ "onscrollsnapchanging",
+ "onsearch",
+ "onsubmit",
+ "onstalled",
+ "onsuspend",
+ "onreset",
+ "onerror",
+ "onresize",
+ "onmouseenter",
+ "ongamepaddisconnected",
+ "ondragover",
+ "onbeforetoggle",
+ "onmouseover",
+ "onpagehide",
+ "onmousemove",
+ "onratechange",
+ "onmessageerror",
+ "onwheel",
+ "ondevicemotion",
+ "onauxclick",
+ "ontransitionend",
+ "onpaste",
+ "onpageswap",
+ "ononline",
+ "ondeviceorientationabsolute",
+ "onkeydown",
+ "onclose",
+ "onselect",
+ "onpageshow",
+ "onpointercancel",
+ "onbeforematch",
+ "onpointerrawupdate",
+ "ondragleave",
+ "onscrollsnapchange",
+ "onseeked",
+ "onwaiting",
+ "onbeforeunload",
+ "onplaying",
+ "onvolumechange",
+ "ondragend",
+ "onstorage",
+ "onloadeddata",
+ "onfocus",
+ "onoffline",
+ "onplay",
+ "onafterprint",
+ "onclick",
+ "oncut",
+ "onmouseout",
+ "ondblclick",
+ "oncanplay",
+ "onloadstart",
+ "onappinstalled",
+ "onpointermove",
+ "ontoggle",
+ "oncontextmenu",
+ "onblur",
+ "oncancel",
+ "onbeforeprint",
+ "oncontextrestored",
+ "onloadedmetadata",
+ "onpointerup",
+ "onlanguagechange",
+ "oncopy",
+ "onselectstart",
+ "onscroll",
+ "onload",
+ "ondragstart",
+ "onbeforeinput",
+ "oncanplaythrough",
+ "oninput",
+ "oninvalid",
+ "ontimeupdate",
+ "ondurationchange",
+ "onselectionchange",
+ "onmouseup",
+ "location",
+ "onkeypress",
+ "onpointerleave",
+ "oncontextlost",
+ "ondrop",
+ "onsecuritypolicyviolation",
+ "oncontentvisibilityautostatechange",
+ "ondeviceorientation",
+ "onseeking",
+ "onrejectionhandled",
+ "onunload",
+ "onmouseleave",
+ "onhashchange",
+ "onpointerenter",
+ "onmousewheel",
+ "onunhandledrejection",
+ "ondragenter",
+ "onpopstate",
+ "onpagereveal",
+ "onemptied"
+ ]
+ },
+ "json": {
+ "parser": { "allowComments": true, "allowTrailingCommas": false },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineEnding": "lf",
+ "lineWidth": 100,
+ "trailingCommas": "none"
+ }
+ },
+ "overrides": [
+ { "linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } } },
+ { "linter": { "rules": { "style": { "useNodejsImportProtocol": "off" } } } },
+ {
+ "linter": {
+ "rules": {
+ "style": { "useNodejsImportProtocol": "off" },
+ "suspicious": { "noExplicitAny": "off" }
+ }
+ }
+ },
+ {
+ "includes": ["src/**/*.{ts,tsx}", "*.{ts,tsx}"],
+ "linter": {
+ "rules": {
+ "complexity": { "noArguments": "error" },
+ "correctness": {
+ "noConstAssign": "off",
+ "noGlobalObjectCalls": "off",
+ "noInvalidBuiltinInstantiation": "off",
+ "noInvalidConstructorSuper": "off",
+ "noSetterReturn": "off",
+ "noUndeclaredVariables": "off",
+ "noUnreachable": "off",
+ "noUnreachableSuper": "off"
+ },
+ "style": { "useConst": "error" },
+ "suspicious": {
+ "noClassAssign": "off",
+ "noDuplicateClassMembers": "off",
+ "noDuplicateObjectKeys": "off",
+ "noDuplicateParameters": "off",
+ "noFunctionAssign": "off",
+ "noImportAssign": "off",
+ "noRedeclare": "off",
+ "noUnsafeNegation": "off",
+ "noVar": "error",
+ "useGetterReturn": "off"
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/apps/clients/index.html b/apps/clients/index.html
new file mode 100644
index 0000000..ba3e01b
--- /dev/null
+++ b/apps/clients/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Xtablo — Portail client
+
+
+
+
+
+
diff --git a/apps/clients/package.json b/apps/clients/package.json
new file mode 100644
index 0000000..bae1419
--- /dev/null
+++ b/apps/clients/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "@xtablo/clients",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev --port 5175",
+ "build": "tsc -b && vite build --mode production",
+ "build:staging": "tsc -b && vite build --mode staging",
+ "build:prod": "tsc -b && vite build --mode production",
+ "deploy:prod": "wrangler deploy --env=\"\"",
+ "typecheck": "tsc -b",
+ "test": "vitest run --mode test --passWithNoTests",
+ "test:watch": "vitest watch --passWithNoTests",
+ "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",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.0",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/react": "19.0.10",
+ "@types/react-dom": "19.0.4",
+ "@vitejs/plugin-react": "^4.3.4",
+ "jsdom": "^20.0.3",
+ "tailwindcss": "^4.0.14",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.2.2",
+ "vite-tsconfig-paths": "^5.1.4",
+ "vitest": "^3.2.4",
+ "wrangler": "^4.24.3"
+ },
+ "dependencies": {
+ "@datadog/browser-rum": "^6.13.0",
+ "@datadog/browser-rum-react": "^6.13.0",
+ "@tanstack/react-query": "^5.69.0",
+ "@xtablo/auth-ui": "workspace:*",
+ "@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"
+ }
+}
diff --git a/apps/clients/public/icon.jpg b/apps/clients/public/icon.jpg
new file mode 100644
index 0000000..026425e
Binary files /dev/null and b/apps/clients/public/icon.jpg differ
diff --git a/apps/clients/src/App.tsx b/apps/clients/src/App.tsx
new file mode 100644
index 0000000..3683784
--- /dev/null
+++ b/apps/clients/src/App.tsx
@@ -0,0 +1,9 @@
+import AppRoutes from "./routes";
+
+export default function App() {
+ return (
+
+ );
+}
diff --git a/apps/clients/src/components/ClientAuthGate.tsx b/apps/clients/src/components/ClientAuthGate.tsx
new file mode 100644
index 0000000..4c75eda
--- /dev/null
+++ b/apps/clients/src/components/ClientAuthGate.tsx
@@ -0,0 +1,56 @@
+import { useSession } from "@xtablo/shared/contexts/SessionContext";
+import { useEffect, useState } from "react";
+import { Navigate, Outlet, useLocation } from "react-router-dom";
+import { supabase } from "../lib/supabase";
+
+export function ClientAuthGate() {
+ const { session } = useSession();
+ const location = useLocation();
+ const [isCheckingSession, setIsCheckingSession] = useState(true);
+ const [hasSession, setHasSession] = useState(false);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ if (session) {
+ setHasSession(true);
+ setIsCheckingSession(false);
+ return () => {
+ isMounted = false;
+ };
+ }
+
+ supabase.auth
+ .getSession()
+ .then(({ data }) => {
+ if (!isMounted) return;
+ setHasSession(Boolean(data.session));
+ })
+ .finally(() => {
+ if (isMounted) {
+ setIsCheckingSession(false);
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, [session]);
+
+ if (session || hasSession) {
+ return ;
+ }
+
+ if (isCheckingSession) {
+ return (
+
+ );
+ }
+
+ const redirectUrl = `${location.pathname}${location.search}${location.hash}`;
+ localStorage.setItem("clients.redirectUrl", redirectUrl);
+
+ return ;
+}
diff --git a/apps/clients/src/components/ClientLayout.test.tsx b/apps/clients/src/components/ClientLayout.test.tsx
new file mode 100644
index 0000000..4578431
--- /dev/null
+++ b/apps/clients/src/components/ClientLayout.test.tsx
@@ -0,0 +1,43 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import AppRoutes from "../routes";
+import { renderWithProviders } from "../test/testHelpers";
+import { ClientLayout } from "./ClientLayout";
+
+describe("ClientLayout", () => {
+ it("uses the main app style header shell and scrolling main viewport", () => {
+ const { container } = renderWithProviders();
+
+ const header = container.querySelector("header");
+ expect(header).toHaveClass("h-[75px]");
+ expect(header).toHaveClass("bg-navbar-background");
+
+ const headerInner = header?.firstElementChild;
+ expect(headerInner).toHaveClass("w-full");
+ expect(headerInner).toHaveClass("max-w-7xl");
+ expect(headerInner).toHaveClass("mx-auto");
+
+ const main = container.querySelector("main");
+ expect(main).toHaveClass("flex-1");
+ expect(main).toHaveClass("overflow-auto");
+ expect(main).not.toHaveClass("max-w-7xl");
+ expect(main).not.toHaveClass("mx-auto");
+
+ const contentWrapper = main?.firstElementChild;
+ expect(contentWrapper).toHaveClass("mx-auto");
+ expect(contentWrapper).toHaveClass("w-full");
+ expect(contentWrapper).toHaveClass("max-w-7xl");
+ expect(contentWrapper).toHaveClass("px-4");
+ expect(contentWrapper).toHaveClass("sm:px-6");
+ });
+
+ it("redirects unauthenticated client routes to the login page", async () => {
+ renderWithProviders(, {
+ route: "/tablo/tablo-1",
+ testUser: undefined,
+ });
+
+ expect(await screen.findByTestId("auth-card-shell")).toBeInTheDocument();
+ expect(await screen.findByRole("button", { name: "Connexion" })).toBeInTheDocument();
+ });
+});
diff --git a/apps/clients/src/components/ClientLayout.tsx b/apps/clients/src/components/ClientLayout.tsx
new file mode 100644
index 0000000..a2079db
--- /dev/null
+++ b/apps/clients/src/components/ClientLayout.tsx
@@ -0,0 +1,52 @@
+import { useSession } from "@xtablo/shared/contexts/SessionContext";
+import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar";
+import { Button } from "@xtablo/ui/components/button";
+import { Outlet } from "react-router-dom";
+import { supabase } from "../lib/supabase";
+
+function getInitials(email: string): string {
+ const parts = email.split("@")[0].split(/[._-]/);
+ return parts
+ .slice(0, 2)
+ .map((p) => p[0]?.toUpperCase() ?? "")
+ .join("");
+}
+
+export function ClientLayout() {
+ const { session } = useSession();
+ if (!session) return null;
+
+ const email = session.user.email ?? "";
+ const initials = email ? getInitials(email) : "?";
+
+ const handleLogout = async () => {
+ await supabase.auth.signOut();
+ };
+
+ return (
+
+
+
+
Xtablo
+
+
+
+ {initials}
+
+
{email}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/clients/src/envProduction.test.ts b/apps/clients/src/envProduction.test.ts
new file mode 100644
index 0000000..90f3834
--- /dev/null
+++ b/apps/clients/src/envProduction.test.ts
@@ -0,0 +1,15 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+
+const productionEnv = readFileSync(resolve(process.cwd(), ".env.production"), "utf8");
+
+describe("clients production env", () => {
+ it("points the API URL to staging while client portal testing is in progress and keeps chat endpoints configured", () => {
+ expect(productionEnv).toContain(
+ "VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app"
+ );
+ expect(productionEnv).toContain("VITE_CHAT_API_URL=https://chat.xtablo.com");
+ expect(productionEnv).toContain("VITE_CHAT_WS_URL=wss://chat.xtablo.com");
+ });
+});
diff --git a/apps/clients/src/i18n.test.ts b/apps/clients/src/i18n.test.ts
new file mode 100644
index 0000000..813951c
--- /dev/null
+++ b/apps/clients/src/i18n.test.ts
@@ -0,0 +1,47 @@
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import authEn from "../../main/src/locales/en/auth.json";
+import chatEn from "../../main/src/locales/en/chat.json";
+import commonEn from "../../main/src/locales/en/common.json";
+import componentsEn from "../../main/src/locales/en/components.json";
+import pagesEn from "../../main/src/locales/en/pages.json";
+import tabloEn from "../../main/src/locales/en/tablo.json";
+import authFr from "../../main/src/locales/fr/auth.json";
+import chatFr from "../../main/src/locales/fr/chat.json";
+import commonFr from "../../main/src/locales/fr/common.json";
+import componentsFr from "../../main/src/locales/fr/components.json";
+import pagesFr from "../../main/src/locales/fr/pages.json";
+import tabloFr from "../../main/src/locales/fr/tablo.json";
+import bookingEn from "./locales/en/booking.json";
+import bookingFr from "./locales/fr/booking.json";
+
+i18n.use(initReactI18next).init({
+ resources: {
+ fr: {
+ auth: authFr,
+ booking: bookingFr,
+ chat: chatFr,
+ common: commonFr,
+ components: componentsFr,
+ pages: pagesFr,
+ tablo: tabloFr,
+ },
+ en: {
+ auth: authEn,
+ booking: bookingEn,
+ chat: chatEn,
+ common: commonEn,
+ components: componentsEn,
+ pages: pagesEn,
+ tablo: tabloEn,
+ },
+ },
+ lng: "fr",
+ fallbackLng: "fr",
+ defaultNS: "booking",
+ interpolation: {
+ escapeValue: false,
+ },
+});
+
+export default i18n;
diff --git a/apps/clients/src/i18n.ts b/apps/clients/src/i18n.ts
new file mode 100644
index 0000000..44c6469
--- /dev/null
+++ b/apps/clients/src/i18n.ts
@@ -0,0 +1,55 @@
+import i18n from "i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import { initReactI18next } from "react-i18next";
+import authEn from "../../main/src/locales/en/auth.json";
+import chatEn from "../../main/src/locales/en/chat.json";
+import commonEn from "../../main/src/locales/en/common.json";
+import componentsEn from "../../main/src/locales/en/components.json";
+import pagesEn from "../../main/src/locales/en/pages.json";
+import tabloEn from "../../main/src/locales/en/tablo.json";
+import authFr from "../../main/src/locales/fr/auth.json";
+import chatFr from "../../main/src/locales/fr/chat.json";
+import commonFr from "../../main/src/locales/fr/common.json";
+import componentsFr from "../../main/src/locales/fr/components.json";
+import pagesFr from "../../main/src/locales/fr/pages.json";
+import tabloFr from "../../main/src/locales/fr/tablo.json";
+import bookingEn from "./locales/en/booking.json";
+// Import translation files
+import bookingFr from "./locales/fr/booking.json";
+
+i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources: {
+ fr: {
+ auth: authFr,
+ booking: bookingFr,
+ chat: chatFr,
+ common: commonFr,
+ components: componentsFr,
+ pages: pagesFr,
+ tablo: tabloFr,
+ },
+ en: {
+ auth: authEn,
+ booking: bookingEn,
+ chat: chatEn,
+ common: commonEn,
+ components: componentsEn,
+ pages: pagesEn,
+ tablo: tabloEn,
+ },
+ },
+ fallbackLng: "fr",
+ defaultNS: "booking",
+ interpolation: {
+ escapeValue: false,
+ },
+ detection: {
+ order: ["localStorage", "navigator"],
+ caches: ["localStorage"],
+ },
+ });
+
+export default i18n;
diff --git a/apps/clients/src/lib/rum.ts b/apps/clients/src/lib/rum.ts
new file mode 100644
index 0000000..4a512ab
--- /dev/null
+++ b/apps/clients/src/lib/rum.ts
@@ -0,0 +1,23 @@
+import { datadogRum } from "@datadog/browser-rum";
+import { reactPlugin } from "@datadog/browser-rum-react";
+
+datadogRum.init({
+ applicationId: "8e268e1a-1be0-44c6-b12a-978530d497c7",
+ clientToken: "pub1761af09ab04e215cc90d34da6ac576b",
+ site: "datadoghq.com",
+ service: "xtablo-clients-ui",
+ env: import.meta.env.MODE,
+ version: import.meta.env.VITE_APP_VERSION,
+
+ sessionSampleRate: 100,
+ sessionReplaySampleRate: 80,
+ defaultPrivacyLevel: "mask-user-input",
+ plugins: [reactPlugin({ router: true })],
+ trackViewsManually: true,
+ trackUserInteractions: true,
+ trackResources: true,
+ trackingConsent: "granted",
+ startSessionReplayRecordingManually: false,
+});
+
+export default datadogRum;
diff --git a/apps/clients/src/lib/supabase.ts b/apps/clients/src/lib/supabase.ts
new file mode 100644
index 0000000..99c2e17
--- /dev/null
+++ b/apps/clients/src/lib/supabase.ts
@@ -0,0 +1,10 @@
+import { createSupabaseClient } from "@xtablo/shared";
+
+const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
+const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
+
+if (!supabaseUrl || !supabaseAnonKey) {
+ throw new Error("Missing Supabase environment variables");
+}
+
+export const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey);
diff --git a/apps/clients/src/locales/en/booking.json b/apps/clients/src/locales/en/booking.json
new file mode 100644
index 0000000..5c560df
--- /dev/null
+++ b/apps/clients/src/locales/en/booking.json
@@ -0,0 +1,3 @@
+{
+ "welcome": "Welcome"
+}
diff --git a/apps/clients/src/locales/fr/booking.json b/apps/clients/src/locales/fr/booking.json
new file mode 100644
index 0000000..ead2829
--- /dev/null
+++ b/apps/clients/src/locales/fr/booking.json
@@ -0,0 +1,3 @@
+{
+ "welcome": "Bienvenue"
+}
diff --git a/apps/clients/src/main.css b/apps/clients/src/main.css
new file mode 100644
index 0000000..104b0b7
--- /dev/null
+++ b/apps/clients/src/main.css
@@ -0,0 +1,1286 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@source "../../../packages/chat-ui/src/**/*.{ts,tsx}";
+@source "../../../packages/tablo-views/src/**/*.{ts,tsx}";
+@source "../../../packages/auth-ui/src/**/*.{ts,tsx}";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+ --navbar-background: rgb(249, 250, 251);
+ --navbar-darker: #e5e7eb;
+}
+
+.dark {
+ --background: oklch(0.17 0.012 290);
+ --foreground: oklch(0.985 0.005 290);
+ --card: oklch(0.16 0.014 290);
+ --card-foreground: oklch(0.985 0.005 290);
+ --popover: oklch(0.16 0.014 290);
+ --popover-foreground: oklch(0.985 0.005 290);
+ --primary: oklch(0.985 0.005 290);
+ --primary-foreground: oklch(0.2 0.012 290);
+ --secondary: oklch(0.22 0.018 290);
+ --secondary-foreground: oklch(0.985 0.005 290);
+ --muted: oklch(0.22 0.018 290);
+ --muted-foreground: oklch(0.65 0.02 290);
+ --accent: oklch(0.22 0.018 290);
+ --accent-foreground: oklch(0.985 0.005 290);
+ --destructive: oklch(0.396 0.141 25.723);
+ --destructive-foreground: oklch(0.637 0.237 25.331);
+ --border: oklch(0.26 0.02 290);
+ --input: oklch(0.26 0.02 290);
+ --ring: oklch(0.45 0.03 290);
+ --chart-1: oklch(0.55 0.2 290);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.18 0.016 290);
+ --sidebar-foreground: oklch(0.985 0.005 290);
+ --sidebar-primary: oklch(0.55 0.2 290);
+ --sidebar-primary-foreground: oklch(0.985 0.005 290);
+ --sidebar-accent: oklch(0.24 0.02 290);
+ --sidebar-accent-foreground: oklch(0.985 0.005 290);
+ --sidebar-border: oklch(0.26 0.02 290);
+ --sidebar-ring: oklch(0.45 0.03 290);
+ --navbar-background: #1e1b2e;
+ --navbar-darker: #141121;
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-navbar-background: var(--navbar-background);
+ --color-navbar-darker: var(--navbar-darker);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ @media (display-mode: standalone) {
+ body {
+ padding-top: env(safe-area-inset-top);
+ padding-bottom: env(safe-area-inset-bottom);
+ padding-left: env(safe-area-inset-left);
+ padding-right: env(safe-area-inset-right);
+ }
+ }
+}
+
+.str-chat {
+ --str-chat__primary-color: #804eec;
+ --str-chat__active-primary-color: #6f3fd4;
+ --str-chat__surface-color: #f5f3f7;
+ --str-chat__secondary-surface-color: #e8e4ec;
+ --str-chat__primary-surface-color: #f4f3ff;
+ --str-chat__primary-surface-color-low-emphasis: #f8f7ff;
+ --str-chat__border-radius-circle: 6px;
+ --str-chat__own-message-bubble-color: #804eec;
+ --str-chat__own-message-text-color: #ffffff;
+}
+
+.dark .str-chat {
+ --str-chat__primary-color: #9b6ff0;
+ --str-chat__active-primary-color: #804eec;
+ --str-chat__surface-color: rgba(128, 78, 236, 0.12);
+ --str-chat__secondary-surface-color: rgba(128, 78, 236, 0.08);
+ --str-chat__primary-surface-color: rgba(128, 78, 236, 0.1);
+ --str-chat__primary-surface-color-low-emphasis: rgba(128, 78, 236, 0.05);
+ --str-chat__background-color: rgba(30, 27, 46, 0.6);
+ --str-chat__secondary-background-color: rgba(20, 17, 33, 0.5);
+ --str-chat__border-color: rgba(128, 78, 236, 0.15);
+ --str-chat__text-color: #eeeaf5;
+ --str-chat__text-low-emphasis-color: #a8a0b8;
+ --str-chat__disabled-color: rgba(128, 78, 236, 0.2);
+ --str-chat__own-message-bubble-color: #804eec;
+ --str-chat__own-message-text-color: #ffffff;
+}
+
+@keyframes gradient-x {
+ 0%,
+ 100% {
+ background-position: 0% 50%;
+ }
+ 50% {
+ background-position: 100% 50%;
+ }
+}
+
+.animate-gradient-x {
+ animation: gradient-x 15s ease infinite;
+}
+
+@keyframes wave-float {
+ 0%,
+ 100% {
+ transform: translateY(0px) rotate(0deg);
+ }
+ 25% {
+ transform: translateY(-20px) rotate(1deg);
+ }
+ 50% {
+ transform: translateY(-10px) rotate(-1deg);
+ }
+ 75% {
+ transform: translateY(-15px) rotate(0.5deg);
+ }
+}
+
+@keyframes wave-pulse {
+ 0%,
+ 100% {
+ transform: scale(1) rotateZ(0deg);
+ opacity: 0.3;
+ }
+ 25% {
+ transform: scale(1.05) rotateZ(1deg);
+ opacity: 0.4;
+ }
+ 50% {
+ transform: scale(0.95) rotateZ(-1deg);
+ opacity: 0.5;
+ }
+ 75% {
+ transform: scale(1.02) rotateZ(0.5deg);
+ opacity: 0.35;
+ }
+}
+
+.animate-wave-float {
+ animation: wave-float 8s ease-in-out infinite;
+}
+
+.animate-wave-float-delayed {
+ animation: wave-float 10s ease-in-out infinite 2s;
+}
+
+.animate-wave-float-slow {
+ animation: wave-float 12s ease-in-out infinite 4s;
+}
+
+.animate-wave-pulse {
+ animation: wave-pulse 6s ease-in-out infinite;
+}
+
+.animate-wave-pulse-delayed {
+ animation: wave-pulse 8s ease-in-out infinite 3s;
+}
+
+.animate-wave-pulse-slow {
+ animation: wave-pulse 10s ease-in-out infinite 1s;
+}
+
+/* Moving Animations */
+@keyframes move-right-slow {
+ 0% {
+ transform: translateX(-100px);
+ }
+ 100% {
+ transform: translateX(calc(100vw + 100px));
+ }
+}
+
+@keyframes move-right-medium {
+ 0% {
+ transform: translateX(-80px);
+ }
+ 100% {
+ transform: translateX(calc(100vw + 80px));
+ }
+}
+
+@keyframes move-right-fast {
+ 0% {
+ transform: translateX(-120px);
+ }
+ 100% {
+ transform: translateX(calc(100vw + 120px));
+ }
+}
+
+@keyframes move-down-slow {
+ 0% {
+ transform: translateY(-100px);
+ }
+ 100% {
+ transform: translateY(calc(100vh + 100px));
+ }
+}
+
+@keyframes move-down-medium {
+ 0% {
+ transform: translateY(-80px);
+ }
+ 100% {
+ transform: translateY(calc(100vh + 80px));
+ }
+}
+
+@keyframes move-diagonal-1 {
+ 0% {
+ transform: translate(-100px, -100px);
+ }
+ 100% {
+ transform: translate(calc(100vw + 100px), calc(100vh + 100px));
+ }
+}
+
+@keyframes move-diagonal-2 {
+ 0% {
+ transform: translate(-80px, -50px);
+ }
+ 100% {
+ transform: translate(calc(100vw + 80px), calc(100vh + 50px));
+ }
+}
+
+@keyframes move-diagonal-3 {
+ 0% {
+ transform: translate(-60px, -80px);
+ }
+ 100% {
+ transform: translate(calc(100vw + 60px), calc(100vh + 80px));
+ }
+}
+
+@keyframes orbit-1 {
+ 0% {
+ transform: translate(-50%, -50%) rotate(0deg) translateX(150px) rotate(0deg);
+ }
+ 100% {
+ transform: translate(-50%, -50%) rotate(360deg) translateX(150px) rotate(-360deg);
+ }
+}
+
+@keyframes orbit-2 {
+ 0% {
+ transform: translate(-50%, -50%) rotate(0deg) translateX(200px) rotate(0deg);
+ }
+ 100% {
+ transform: translate(-50%, -50%) rotate(-360deg) translateX(200px) rotate(360deg);
+ }
+}
+
+@keyframes orbit-3 {
+ 0% {
+ transform: translate(-50%, -50%) rotate(0deg) translateX(100px) rotate(0deg);
+ }
+ 100% {
+ transform: translate(-50%, -50%) rotate(360deg) translateX(100px) rotate(-360deg);
+ }
+}
+
+/* Gentle Animations */
+@keyframes spin-slow {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes spin-reverse {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(-360deg);
+ }
+}
+
+@keyframes bounce-gentle {
+ 0%,
+ 100% {
+ transform: translateY(0px);
+ }
+ 50% {
+ transform: translateY(-10px);
+ }
+}
+
+@keyframes bounce-soft {
+ 0%,
+ 100% {
+ transform: translateY(0px);
+ }
+ 50% {
+ transform: translateY(-8px);
+ }
+}
+
+@keyframes pulse-gentle {
+ 0%,
+ 100% {
+ transform: scale(1);
+ opacity: 0.4;
+ }
+ 50% {
+ transform: scale(1.1);
+ opacity: 0.6;
+ }
+}
+
+@keyframes wiggle {
+ 0%,
+ 100% {
+ transform: rotate(0deg);
+ }
+ 25% {
+ transform: rotate(3deg);
+ }
+ 75% {
+ transform: rotate(-3deg);
+ }
+}
+
+@keyframes float-gentle {
+ 0%,
+ 100% {
+ transform: translateY(0px);
+ }
+ 50% {
+ transform: translateY(-5px);
+ }
+}
+
+@keyframes scale-gentle {
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.05);
+ }
+}
+
+@keyframes rotate-gentle {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(180deg);
+ }
+}
+
+@keyframes sway {
+ 0%,
+ 100% {
+ transform: translateX(0px);
+ }
+ 50% {
+ transform: translateX(10px);
+ }
+}
+
+/* Animation Classes */
+.animate-move-right-slow {
+ animation: move-right-slow 25s linear infinite;
+}
+.animate-move-right-medium {
+ animation: move-right-medium 20s linear infinite;
+}
+.animate-move-right-fast {
+ animation: move-right-fast 15s linear infinite;
+}
+.animate-move-down-slow {
+ animation: move-down-slow 30s linear infinite;
+}
+.animate-move-down-medium {
+ animation: move-down-medium 25s linear infinite;
+}
+.animate-move-diagonal-1 {
+ animation: move-diagonal-1 35s linear infinite;
+}
+.animate-move-diagonal-2 {
+ animation: move-diagonal-2 28s linear infinite;
+}
+.animate-move-diagonal-3 {
+ animation: move-diagonal-3 32s linear infinite;
+}
+.animate-orbit-1 {
+ animation: orbit-1 20s linear infinite;
+}
+.animate-orbit-2 {
+ animation: orbit-2 25s linear infinite reverse;
+}
+.animate-orbit-3 {
+ animation: orbit-3 15s linear infinite;
+}
+.animate-spin-slow {
+ animation: spin-slow 8s linear infinite;
+}
+.animate-spin-reverse {
+ animation: spin-reverse 6s linear infinite;
+}
+.animate-bounce-gentle {
+ animation: bounce-gentle 3s ease-in-out infinite;
+}
+.animate-bounce-soft {
+ animation: bounce-soft 4s ease-in-out infinite;
+}
+.animate-pulse-gentle {
+ animation: pulse-gentle 4s ease-in-out infinite;
+}
+.animate-wiggle {
+ animation: wiggle 2s ease-in-out infinite;
+}
+.animate-float-gentle {
+ animation: float-gentle 5s ease-in-out infinite;
+}
+.animate-scale-gentle {
+ animation: scale-gentle 6s ease-in-out infinite;
+}
+.animate-rotate-gentle {
+ animation: rotate-gentle 8s ease-in-out infinite;
+}
+.animate-sway {
+ animation: sway 3s ease-in-out infinite;
+}
+
+/* Enhanced Animations */
+@keyframes orbit-4 {
+ 0% {
+ transform: translate(-50%, -50%) rotate(0deg) translateX(250px) rotate(0deg);
+ }
+ 100% {
+ transform: translate(-50%, -50%) rotate(360deg) translateX(250px) rotate(-360deg);
+ }
+}
+
+@keyframes orbit-5 {
+ 0% {
+ transform: translate(-50%, -50%) rotate(0deg) translateX(120px) rotate(0deg);
+ }
+ 100% {
+ transform: translate(-50%, -50%) rotate(-360deg) translateX(120px) rotate(360deg);
+ }
+}
+
+@keyframes zigzag-1 {
+ 0% {
+ transform: translateX(-100px) translateY(0px);
+ }
+ 25% {
+ transform: translateX(25vw) translateY(-50px);
+ }
+ 50% {
+ transform: translateX(50vw) translateY(50px);
+ }
+ 75% {
+ transform: translateX(75vw) translateY(-30px);
+ }
+ 100% {
+ transform: translateX(calc(100vw + 100px)) translateY(20px);
+ }
+}
+
+@keyframes zigzag-2 {
+ 0% {
+ transform: translateX(-80px) translateY(0px);
+ }
+ 20% {
+ transform: translateX(20vw) translateY(40px);
+ }
+ 40% {
+ transform: translateX(40vw) translateY(-60px);
+ }
+ 60% {
+ transform: translateX(60vw) translateY(30px);
+ }
+ 80% {
+ transform: translateX(80vw) translateY(-40px);
+ }
+ 100% {
+ transform: translateX(calc(100vw + 80px)) translateY(0px);
+ }
+}
+
+@keyframes zigzag-3 {
+ 0% {
+ transform: translateX(-120px) translateY(0px);
+ }
+ 16% {
+ transform: translateX(16vw) translateY(-70px);
+ }
+ 33% {
+ transform: translateX(33vw) translateY(80px);
+ }
+ 50% {
+ transform: translateX(50vw) translateY(-50px);
+ }
+ 66% {
+ transform: translateX(66vw) translateY(60px);
+ }
+ 83% {
+ transform: translateX(83vw) translateY(-40px);
+ }
+ 100% {
+ transform: translateX(calc(100vw + 120px)) translateY(0px);
+ }
+}
+
+@keyframes spiral-1 {
+ 0% {
+ transform: translate(0px, 0px) rotate(0deg) scale(0.5);
+ }
+ 25% {
+ transform: translate(25vw, 25vh) rotate(90deg) scale(1);
+ }
+ 50% {
+ transform: translate(50vw, 50vh) rotate(180deg) scale(1.5);
+ }
+ 75% {
+ transform: translate(75vw, 75vh) rotate(270deg) scale(1);
+ }
+ 100% {
+ transform: translate(100vw, 100vh) rotate(360deg) scale(0.5);
+ }
+}
+
+@keyframes spiral-2 {
+ 0% {
+ transform: translate(0px, 0px) rotate(0deg) scale(1.5);
+ }
+ 25% {
+ transform: translate(-25vw, 25vh) rotate(-90deg) scale(0.8);
+ }
+ 50% {
+ transform: translate(-50vw, 50vh) rotate(-180deg) scale(0.5);
+ }
+ 75% {
+ transform: translate(-75vw, 75vh) rotate(-270deg) scale(1.2);
+ }
+ 100% {
+ transform: translate(-100vw, 100vh) rotate(-360deg) scale(1.5);
+ }
+}
+
+@keyframes float-random-1 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) rotate(0deg);
+ }
+ 25% {
+ transform: translate(50px, -30px) rotate(45deg);
+ }
+ 50% {
+ transform: translate(-30px, 40px) rotate(-30deg);
+ }
+ 75% {
+ transform: translate(40px, 20px) rotate(60deg);
+ }
+}
+
+@keyframes float-random-2 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) rotate(0deg);
+ }
+ 20% {
+ transform: translate(-40px, -50px) rotate(-45deg);
+ }
+ 40% {
+ transform: translate(60px, -20px) rotate(90deg);
+ }
+ 60% {
+ transform: translate(-20px, 60px) rotate(-60deg);
+ }
+ 80% {
+ transform: translate(30px, -40px) rotate(120deg);
+ }
+}
+
+@keyframes float-random-3 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) rotate(0deg);
+ }
+ 33% {
+ transform: translate(70px, 30px) rotate(180deg);
+ }
+ 66% {
+ transform: translate(-50px, -40px) rotate(-90deg);
+ }
+}
+
+@keyframes float-random-4 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) rotate(0deg);
+ }
+ 25% {
+ transform: translate(-60px, 50px) rotate(270deg);
+ }
+ 50% {
+ transform: translate(80px, -30px) rotate(180deg);
+ }
+ 75% {
+ transform: translate(-40px, -60px) rotate(90deg);
+ }
+}
+
+@keyframes wave-1 {
+ 0% {
+ transform: translateX(-100px) translateY(0px);
+ }
+ 25% {
+ transform: translateX(25vw) translateY(-80px);
+ }
+ 50% {
+ transform: translateX(50vw) translateY(0px);
+ }
+ 75% {
+ transform: translateX(75vw) translateY(80px);
+ }
+ 100% {
+ transform: translateX(calc(100vw + 100px)) translateY(0px);
+ }
+}
+
+@keyframes wave-2 {
+ 0% {
+ transform: translateX(-100px) translateY(0px);
+ }
+ 20% {
+ transform: translateX(20vw) translateY(60px);
+ }
+ 40% {
+ transform: translateX(40vw) translateY(-60px);
+ }
+ 60% {
+ transform: translateX(60vw) translateY(60px);
+ }
+ 80% {
+ transform: translateX(80vw) translateY(-60px);
+ }
+ 100% {
+ transform: translateX(calc(100vw + 100px)) translateY(0px);
+ }
+}
+
+@keyframes wave-3 {
+ 0% {
+ transform: translateX(-100px) translateY(0px);
+ }
+ 33% {
+ transform: translateX(33vw) translateY(-100px);
+ }
+ 66% {
+ transform: translateX(66vw) translateY(100px);
+ }
+ 100% {
+ transform: translateX(calc(100vw + 100px)) translateY(0px);
+ }
+}
+
+@keyframes wave-4 {
+ 0% {
+ transform: translateX(-100px) translateY(0px);
+ }
+ 16% {
+ transform: translateX(16vw) translateY(40px);
+ }
+ 33% {
+ transform: translateX(33vw) translateY(-80px);
+ }
+ 50% {
+ transform: translateX(50vw) translateY(40px);
+ }
+ 66% {
+ transform: translateX(66vw) translateY(-80px);
+ }
+ 83% {
+ transform: translateX(83vw) translateY(40px);
+ }
+ 100% {
+ transform: translateX(calc(100vw + 100px)) translateY(0px);
+ }
+}
+
+@keyframes corner-shoot-1 {
+ 0% {
+ transform: translate(0px, 0px) rotate(0deg);
+ }
+ 100% {
+ transform: translate(100vw, 100vh) rotate(720deg);
+ }
+}
+
+@keyframes corner-shoot-2 {
+ 0% {
+ transform: translate(0px, 0px) rotate(0deg);
+ }
+ 100% {
+ transform: translate(-100vw, 100vh) rotate(-720deg);
+ }
+}
+
+@keyframes corner-shoot-3 {
+ 0% {
+ transform: translate(0px, 0px) rotate(0deg);
+ }
+ 100% {
+ transform: translate(100vw, -100vh) rotate(720deg);
+ }
+}
+
+@keyframes corner-shoot-4 {
+ 0% {
+ transform: translate(0px, 0px) rotate(0deg);
+ }
+ 100% {
+ transform: translate(-100vw, -100vh) rotate(-720deg);
+ }
+}
+
+@keyframes bounce-ball-1 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px);
+ }
+ 25% {
+ transform: translate(200px, -150px);
+ }
+ 50% {
+ transform: translate(400px, 0px);
+ }
+ 75% {
+ transform: translate(600px, -100px);
+ }
+}
+
+@keyframes bounce-ball-2 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px);
+ }
+ 33% {
+ transform: translate(-300px, -200px);
+ }
+ 66% {
+ transform: translate(-600px, 0px);
+ }
+}
+
+@keyframes bounce-ball-3 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px);
+ }
+ 20% {
+ transform: translate(150px, -100px);
+ }
+ 40% {
+ transform: translate(300px, 50px);
+ }
+ 60% {
+ transform: translate(150px, -80px);
+ }
+ 80% {
+ transform: translate(-150px, 30px);
+ }
+}
+
+/* Crazy Animations */
+@keyframes spin-fast {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(720deg);
+ }
+}
+@keyframes pulse-fast {
+ 0%,
+ 100% {
+ transform: scale(0.8);
+ opacity: 0.3;
+ }
+ 50% {
+ transform: scale(1.3);
+ opacity: 0.8;
+ }
+}
+@keyframes wobble {
+ 0%,
+ 100% {
+ transform: rotate(0deg) scale(1);
+ }
+ 25% {
+ transform: rotate(5deg) scale(1.1);
+ }
+ 50% {
+ transform: rotate(-5deg) scale(0.9);
+ }
+ 75% {
+ transform: rotate(3deg) scale(1.05);
+ }
+}
+@keyframes shake {
+ 0%,
+ 100% {
+ transform: translateX(0px);
+ }
+ 25% {
+ transform: translateX(-10px);
+ }
+ 75% {
+ transform: translateX(10px);
+ }
+}
+@keyframes bounce-crazy {
+ 0%,
+ 100% {
+ transform: translateY(0px) scale(1);
+ }
+ 50% {
+ transform: translateY(-50px) scale(1.2);
+ }
+}
+@keyframes spin-wobble {
+ 0% {
+ transform: rotate(0deg) scale(1);
+ }
+ 25% {
+ transform: rotate(90deg) scale(1.1);
+ }
+ 50% {
+ transform: rotate(180deg) scale(0.9);
+ }
+ 75% {
+ transform: rotate(270deg) scale(1.05);
+ }
+ 100% {
+ transform: rotate(360deg) scale(1);
+ }
+}
+@keyframes flip {
+ 0% {
+ transform: rotateY(0deg);
+ }
+ 50% {
+ transform: rotateY(180deg);
+ }
+ 100% {
+ transform: rotateY(360deg);
+ }
+}
+@keyframes twirl {
+ 0% {
+ transform: rotate(0deg) translateX(0px);
+ }
+ 25% {
+ transform: rotate(90deg) translateX(20px);
+ }
+ 50% {
+ transform: rotate(180deg) translateX(0px);
+ }
+ 75% {
+ transform: rotate(270deg) translateX(-20px);
+ }
+ 100% {
+ transform: rotate(360deg) translateX(0px);
+ }
+}
+@keyframes dance {
+ 0%,
+ 100% {
+ transform: translateY(0px) rotate(0deg);
+ }
+ 25% {
+ transform: translateY(-20px) rotate(10deg);
+ }
+ 50% {
+ transform: translateY(10px) rotate(-5deg);
+ }
+ 75% {
+ transform: translateY(-15px) rotate(8deg);
+ }
+}
+@keyframes jiggle {
+ 0%,
+ 100% {
+ transform: rotate(0deg);
+ }
+ 25% {
+ transform: rotate(2deg) translateX(2px);
+ }
+ 50% {
+ transform: rotate(-2deg) translateX(-2px);
+ }
+ 75% {
+ transform: rotate(1deg) translateX(1px);
+ }
+}
+@keyframes vibrate {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px);
+ }
+ 25% {
+ transform: translate(2px, -2px);
+ }
+ 50% {
+ transform: translate(-2px, 2px);
+ }
+ 75% {
+ transform: translate(2px, 2px);
+ }
+}
+@keyframes swing {
+ 0%,
+ 100% {
+ transform: rotate(0deg);
+ }
+ 25% {
+ transform: rotate(15deg);
+ }
+ 75% {
+ transform: rotate(-15deg);
+ }
+}
+@keyframes pendulum {
+ 0%,
+ 100% {
+ transform: rotate(0deg);
+ }
+ 50% {
+ transform: rotate(30deg);
+ }
+}
+@keyframes elastic {
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.3) rotate(180deg);
+ }
+}
+@keyframes rubber {
+ 0%,
+ 100% {
+ transform: scaleX(1) scaleY(1);
+ }
+ 25% {
+ transform: scaleX(1.2) scaleY(0.8);
+ }
+ 75% {
+ transform: scaleX(0.8) scaleY(1.2);
+ }
+}
+@keyframes rocket {
+ 0% {
+ transform: scale(0.5) rotate(0deg);
+ }
+ 100% {
+ transform: scale(2) rotate(360deg);
+ }
+}
+@keyframes comet {
+ 0% {
+ transform: scale(1) rotate(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(0.2) rotate(720deg);
+ opacity: 0.2;
+ }
+}
+@keyframes meteor {
+ 0% {
+ transform: scale(0.2) rotate(0deg);
+ opacity: 0.2;
+ }
+ 100% {
+ transform: scale(1.5) rotate(-720deg);
+ opacity: 1;
+ }
+}
+@keyframes blast {
+ 0%,
+ 100% {
+ transform: scale(1) rotate(0deg);
+ }
+ 50% {
+ transform: scale(2) rotate(180deg);
+ }
+}
+@keyframes spin-bounce {
+ 0%,
+ 100% {
+ transform: rotate(0deg) translateY(0px);
+ }
+ 50% {
+ transform: rotate(180deg) translateY(-30px);
+ }
+}
+@keyframes flip-bounce {
+ 0%,
+ 100% {
+ transform: rotateX(0deg) translateY(0px);
+ }
+ 50% {
+ transform: rotateX(180deg) translateY(-25px);
+ }
+}
+@keyframes scale-bounce {
+ 0%,
+ 100% {
+ transform: scale(1) translateY(0px);
+ }
+ 50% {
+ transform: scale(1.5) translateY(-40px);
+ }
+}
+
+/* New Animation Classes */
+.animate-orbit-4 {
+ animation: orbit-4 18s linear infinite;
+}
+.animate-orbit-5 {
+ animation: orbit-5 22s linear infinite reverse;
+}
+.animate-zigzag-1 {
+ animation: zigzag-1 18s linear infinite;
+}
+.animate-zigzag-2 {
+ animation: zigzag-2 22s linear infinite;
+}
+.animate-zigzag-3 {
+ animation: zigzag-3 16s linear infinite;
+}
+.animate-spiral-1 {
+ animation: spiral-1 30s linear infinite;
+}
+.animate-spiral-2 {
+ animation: spiral-2 25s linear infinite;
+}
+.animate-float-random-1 {
+ animation: float-random-1 8s ease-in-out infinite;
+}
+.animate-float-random-2 {
+ animation: float-random-2 10s ease-in-out infinite;
+}
+.animate-float-random-3 {
+ animation: float-random-3 12s ease-in-out infinite;
+}
+.animate-float-random-4 {
+ animation: float-random-4 9s ease-in-out infinite;
+}
+.animate-wave-1 {
+ animation: wave-1 20s linear infinite;
+}
+.animate-wave-2 {
+ animation: wave-2 24s linear infinite;
+}
+.animate-wave-3 {
+ animation: wave-3 18s linear infinite;
+}
+.animate-wave-4 {
+ animation: wave-4 26s linear infinite;
+}
+.animate-corner-shoot-1 {
+ animation: corner-shoot-1 15s linear infinite;
+}
+.animate-corner-shoot-2 {
+ animation: corner-shoot-2 18s linear infinite;
+}
+.animate-corner-shoot-3 {
+ animation: corner-shoot-3 20s linear infinite;
+}
+.animate-corner-shoot-4 {
+ animation: corner-shoot-4 16s linear infinite;
+}
+.animate-bounce-ball-1 {
+ animation: bounce-ball-1 12s ease-in-out infinite;
+}
+.animate-bounce-ball-2 {
+ animation: bounce-ball-2 14s ease-in-out infinite;
+}
+.animate-bounce-ball-3 {
+ animation: bounce-ball-3 10s ease-in-out infinite;
+}
+.animate-spin-fast {
+ animation: spin-fast 2s linear infinite;
+}
+.animate-pulse-fast {
+ animation: pulse-fast 1.5s ease-in-out infinite;
+}
+.animate-wobble {
+ animation: wobble 2s ease-in-out infinite;
+}
+.animate-shake {
+ animation: shake 0.5s ease-in-out infinite;
+}
+.animate-bounce-crazy {
+ animation: bounce-crazy 1s ease-in-out infinite;
+}
+.animate-spin-wobble {
+ animation: spin-wobble 4s ease-in-out infinite;
+}
+.animate-flip {
+ animation: flip 3s ease-in-out infinite;
+}
+.animate-twirl {
+ animation: twirl 5s ease-in-out infinite;
+}
+.animate-dance {
+ animation: dance 3s ease-in-out infinite;
+}
+.animate-jiggle {
+ animation: jiggle 1s ease-in-out infinite;
+}
+.animate-vibrate {
+ animation: vibrate 0.3s ease-in-out infinite;
+}
+.animate-swing {
+ animation: swing 4s ease-in-out infinite;
+}
+.animate-pendulum {
+ animation: pendulum 6s ease-in-out infinite;
+}
+.animate-elastic {
+ animation: elastic 4s ease-in-out infinite;
+}
+.animate-rubber {
+ animation: rubber 2s ease-in-out infinite;
+}
+.animate-rocket {
+ animation: rocket 8s ease-in-out infinite;
+}
+.animate-comet {
+ animation: comet 12s ease-in-out infinite;
+}
+.animate-meteor {
+ animation: meteor 10s ease-in-out infinite;
+}
+.animate-blast {
+ animation: blast 3s ease-in-out infinite;
+}
+.animate-spin-bounce {
+ animation: spin-bounce 4s ease-in-out infinite;
+}
+.animate-flip-bounce {
+ animation: flip-bounce 5s ease-in-out infinite;
+}
+.animate-scale-bounce {
+ animation: scale-bounce 3s ease-in-out infinite;
+}
+
+/* Animated Border Light */
+@keyframes border-light {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.animate-border-light {
+ position: relative;
+ border-radius: 1rem;
+}
+
+.animate-border-light::before {
+ content: "";
+ position: absolute;
+ inset: -2px;
+ background: conic-gradient(
+ from 0deg,
+ transparent 0deg,
+ transparent 270deg,
+ rgba(168, 85, 247, 0.8) 300deg,
+ rgba(147, 51, 234, 1) 330deg,
+ rgba(168, 85, 247, 0.8) 360deg,
+ transparent 30deg,
+ transparent 360deg
+ );
+ border-radius: inherit;
+ animation: border-light 3s linear infinite;
+ z-index: -1;
+}
+
+.animate-border-light::after {
+ content: "";
+ position: absolute;
+ inset: 2px;
+ background: inherit;
+ border-radius: inherit;
+ z-index: -1;
+}
diff --git a/apps/clients/src/main.tsx b/apps/clients/src/main.tsx
new file mode 100644
index 0000000..6d41477
--- /dev/null
+++ b/apps/clients/src/main.tsx
@@ -0,0 +1,31 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { queryClient } from "@xtablo/shared";
+import { SessionProvider } from "@xtablo/shared/contexts/SessionContext";
+import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
+import { Toaster } from "@xtablo/ui/components/sonner";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter as Router } from "react-router-dom";
+import App from "./App";
+import { supabase } from "./lib/supabase";
+
+import "@xtablo/ui/styles/globals.css";
+import "@xtablo/tablo-views/styles/tablo-details-shell.css";
+import "./main.css";
+import "./i18n";
+import "./lib/rum";
+
+createRoot(document.getElementById("client-root")!).render(
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/apps/clients/src/mainCss.test.ts b/apps/clients/src/mainCss.test.ts
new file mode 100644
index 0000000..a7f2a5c
--- /dev/null
+++ b/apps/clients/src/mainCss.test.ts
@@ -0,0 +1,17 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+
+const mainCss = readFileSync(resolve(process.cwd(), "src/main.css"), "utf8");
+
+describe("clients main.css", () => {
+ it("keeps shared package sources and theme tokens aligned with the main app", () => {
+ expect(mainCss).toContain('@source "../../../packages/chat-ui/src/**/*.{ts,tsx}";');
+ expect(mainCss).toContain('@source "../../../packages/tablo-views/src/**/*.{ts,tsx}";');
+ expect(mainCss).toContain('@source "../../../packages/auth-ui/src/**/*.{ts,tsx}";');
+ expect(mainCss).toContain("--navbar-background: rgb(249, 250, 251);");
+ expect(mainCss).toContain("--color-navbar-background: var(--navbar-background);");
+ expect(mainCss).toContain("--str-chat__own-message-bubble-color: #804eec;");
+ expect(mainCss).toContain("--str-chat__own-message-text-color: #ffffff;");
+ });
+});
diff --git a/apps/clients/src/pages/AuthCallback.tsx b/apps/clients/src/pages/AuthCallback.tsx
new file mode 100644
index 0000000..79bc692
--- /dev/null
+++ b/apps/clients/src/pages/AuthCallback.tsx
@@ -0,0 +1,76 @@
+import { useEffect, useRef, useState } from "react";
+import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
+import { useSession } from "@xtablo/shared/contexts/SessionContext";
+
+export function AuthCallback() {
+ const [searchParams] = useSearchParams();
+ const token = searchParams.get("token");
+ const { session } = useSession();
+ const navigate = useNavigate();
+ const [error, setError] = useState(null);
+ const hasAccepted = useRef(false);
+
+ useEffect(() => {
+ if (!token) {
+ return;
+ }
+
+ if (!session || hasAccepted.current) {
+ return;
+ }
+
+ hasAccepted.current = true;
+
+ const apiUrl = import.meta.env.VITE_API_URL as string;
+
+ fetch(`${apiUrl}/api/v1/client-invites/accept/${token}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${session.access_token}`,
+ "Content-Type": "application/json",
+ },
+ })
+ .then(async (res) => {
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(
+ (body as { message?: string }).message ?? "Erreur lors de l'acceptation de l'invitation"
+ );
+ }
+ return res.json() as Promise<{ tabloId: string }>;
+ })
+ .then((data) => {
+ navigate(`/tablo/${data.tabloId}`, { replace: true });
+ })
+ .catch((err: unknown) => {
+ console.error("Accept invite error:", err);
+ setError(
+ "Une erreur est survenue lors de l'acceptation de l'invitation. Veuillez contacter la personne qui vous a invite."
+ );
+ });
+ }, [session, token, navigate]);
+
+ if (!token) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
Authentification en cours...
+
+
+ );
+}
diff --git a/apps/clients/src/pages/ClientTabloListPage.tsx b/apps/clients/src/pages/ClientTabloListPage.tsx
new file mode 100644
index 0000000..bbfff3d
--- /dev/null
+++ b/apps/clients/src/pages/ClientTabloListPage.tsx
@@ -0,0 +1,61 @@
+import { useQuery } from "@tanstack/react-query";
+import type { UserTablo } from "@xtablo/shared-types";
+import { Link, Navigate } from "react-router-dom";
+import { supabase } from "../lib/supabase";
+
+function useClientTablosList() {
+ return useQuery({
+ queryKey: ["client-tablos-list"],
+ queryFn: async () => {
+ const { data, error } = await supabase.from("user_tablos").select("*");
+ if (error) throw error;
+ return (data ?? []) as UserTablo[];
+ },
+ });
+}
+
+export function ClientTabloListPage() {
+ const { data: tablos, isLoading } = useClientTablosList();
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!tablos || tablos.length === 0) {
+ return (
+
+
Aucun projet disponible.
+
+ );
+ }
+
+ if (tablos.length === 1) {
+ return ;
+ }
+
+ return (
+
+
+
Mes projets
+
Sélectionnez un projet pour y accéder.
+
+
+
+ {tablos.map((tablo) => (
+
+ {tablo.color &&
}
+
{tablo.name}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/clients/src/pages/ClientTabloPage.test.tsx b/apps/clients/src/pages/ClientTabloPage.test.tsx
new file mode 100644
index 0000000..b687b51
--- /dev/null
+++ b/apps/clients/src/pages/ClientTabloPage.test.tsx
@@ -0,0 +1,599 @@
+import { screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "../test/testHelpers";
+import { ClientTabloPage } from "./ClientTabloPage";
+
+const {
+ apiGetMock,
+ apiPostMock,
+ apiPutMock,
+ apiDeleteMock,
+ updateTaskMock,
+ insertTaskMock,
+ deleteTaskMock,
+ supabaseFromMock,
+} = vi.hoisted(() => {
+ const apiGetMock = vi.fn(async (url: string) => {
+ if (url.endsWith("/brief.pdf")) {
+ return {
+ status: 200,
+ data: { content: "test file content", contentType: "application/pdf" },
+ };
+ }
+
+ return { status: 200, data: { folders: [] } };
+ });
+ const apiPostMock = vi.fn(async () => ({
+ status: 200,
+ data: {
+ message: "ok",
+ fileName: "brief.pdf",
+ tabloId: "tablo-1",
+ folder: { id: "folder-1", name: "Livrable", description: "" },
+ },
+ }));
+ const apiPutMock = vi.fn(async () => ({
+ status: 200,
+ data: { folder: { id: "folder-1", name: "Livrable mis à jour", description: "Desc" } },
+ }));
+ const apiDeleteMock = vi.fn(async () => ({ status: 200, data: { message: "ok" } }));
+ const createUpdateBuilder = () => {
+ const builder = {
+ error: null as null,
+ eq: vi.fn(() => builder),
+ select: vi.fn(() => ({
+ single: async () => ({ data: { id: "task-1" }, error: null }),
+ })),
+ };
+ return builder;
+ };
+ const updateTaskMock = vi.fn(() => createUpdateBuilder());
+ const insertTaskMock = vi.fn(() => ({
+ select: () => ({
+ single: async () => ({ data: { id: "task-created" }, error: null }),
+ }),
+ }));
+ const deleteTaskMock = vi.fn(() => ({
+ eq: vi.fn(async () => ({ error: null })),
+ }));
+ const supabaseFromMock = vi.fn(() => ({
+ insert: insertTaskMock,
+ update: updateTaskMock,
+ delete: deleteTaskMock,
+ }));
+
+ return {
+ apiGetMock,
+ apiPostMock,
+ apiPutMock,
+ apiDeleteMock,
+ updateTaskMock,
+ insertTaskMock,
+ deleteTaskMock,
+ supabaseFromMock,
+ };
+});
+let latestTabloTasksSectionProps: Record | null = null;
+let latestEtapesSectionProps: Record | null = null;
+let latestRoadmapSectionProps: Record | null = null;
+let latestTabloFilesSectionProps: Record | null = null;
+
+vi.mock("@xtablo/shared", async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ buildApi: () => ({
+ create: () => ({
+ get: apiGetMock,
+ post: apiPostMock,
+ put: apiPutMock,
+ delete: apiDeleteMock,
+ }),
+ }),
+ };
+});
+
+vi.mock("../lib/supabase", () => ({
+ supabase: {
+ from: supabaseFromMock,
+ },
+}));
+
+vi.mock("@tanstack/react-query", async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ useQuery: ({ queryKey, queryFn }: { queryKey: string[]; queryFn?: () => Promise }) => {
+ if (queryKey[0] === "client-tablo-folders" && queryFn) {
+ void queryFn();
+ }
+ switch (queryKey[0]) {
+ case "client-tablo":
+ return {
+ data: {
+ id: "tablo-1",
+ name: "Client Project",
+ color: "bg-blue-500",
+ image: null,
+ created_at: "2026-01-01T00:00:00.000Z",
+ deleted_at: null,
+ position: 0,
+ status: "todo",
+ user_id: "user-1",
+ is_admin: false,
+ access_level: "guest",
+ },
+ isLoading: false,
+ };
+ case "client-tasks":
+ return {
+ data: [
+ {
+ id: "task-1",
+ title: "Prepare proposal",
+ status: "todo",
+ tablo_id: "tablo-1",
+ assignee_id: "client-user-1",
+ },
+ ],
+ isLoading: false,
+ error: null,
+ };
+ case "client-etapes":
+ return {
+ data: [
+ {
+ id: "etape-1",
+ title: "Kickoff",
+ status: "in_progress",
+ position: 0,
+ },
+ ],
+ isLoading: false,
+ error: null,
+ };
+ case "client-events":
+ case "client-members":
+ case "client-tablo-folders":
+ return {
+ data: [],
+ isLoading: false,
+ error: null,
+ };
+ case "client-tablo-files":
+ return {
+ data: { fileNames: [] },
+ isLoading: false,
+ error: null,
+ };
+ default:
+ return {
+ data: undefined,
+ isLoading: false,
+ error: null,
+ };
+ }
+ },
+ };
+});
+
+vi.mock("@xtablo/tablo-views", async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ EtapesSection: (props: Record) => {
+ latestEtapesSectionProps = props;
+ return (
+
+
Etapes section
+
+
+
+ );
+ },
+ RoadmapSection: (props: Record) => {
+ latestRoadmapSectionProps = props;
+ return (
+
+
Roadmap section
+
+
+ );
+ },
+ TabloDiscussionSection: () => Discussion section
,
+ TabloEventsSection: () => Events section
,
+ TabloFilesSection: (props: Record) => {
+ latestTabloFilesSectionProps = props;
+ return (
+
+
Files section
+
+
+
+
+
+
+ );
+ },
+ TabloTasksSection: (props: Record) => {
+ latestTabloTasksSectionProps = props;
+ return (
+
+
Tasks section
+
+
+
+
+
+ );
+ },
+ };
+});
+
+describe("ClientTabloPage parity shell", () => {
+ beforeEach(() => {
+ window.URL.createObjectURL = vi.fn(() => "blob:test");
+ window.URL.revokeObjectURL = vi.fn();
+ HTMLAnchorElement.prototype.click = vi.fn();
+ apiGetMock.mockClear();
+ apiPostMock.mockClear();
+ apiPutMock.mockClear();
+ apiDeleteMock.mockClear();
+ updateTaskMock.mockClear();
+ insertTaskMock.mockClear();
+ deleteTaskMock.mockClear();
+ supabaseFromMock.mockClear();
+ latestTabloTasksSectionProps = null;
+ latestEtapesSectionProps = null;
+ latestRoadmapSectionProps = null;
+ latestTabloFilesSectionProps = null;
+ });
+
+ it("requests folders from the tablo-data API route", () => {
+ renderWithProviders(, {
+ route: "/tablo/tablo-1",
+ path: "/tablo/:tabloId",
+ });
+
+ expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders");
+ });
+
+ it("wires real task mutation callbacks throughout the client task surfaces", async () => {
+ const { user } = renderWithProviders(, {
+ route: "/tablo/tablo-1",
+ path: "/tablo/:tabloId",
+ });
+
+ await user.click(screen.getByRole("button", { name: "Étapes" }));
+
+ expect(latestEtapesSectionProps?.onCreateTask).toBeTypeOf("function");
+ expect(latestEtapesSectionProps?.onTaskStatusChange).toBeTypeOf("function");
+
+ await user.click(screen.getByRole("button", { name: "Créer tâche d'étape test" }));
+ await user.click(screen.getByRole("button", { name: "Terminer tâche d'étape test" }));
+
+ await user.click(screen.getByRole("button", { name: "Tâches" }));
+
+ expect(latestTabloTasksSectionProps?.onCreateTask).toBeTypeOf("function");
+ expect(latestTabloTasksSectionProps?.onUpdateTask).toBeTypeOf("function");
+ expect(latestTabloTasksSectionProps?.onDeleteTask).toBeTypeOf("function");
+ expect(latestTabloTasksSectionProps?.onUpdateTaskPositions).toBeTypeOf("function");
+
+ await user.click(screen.getByRole("button", { name: "Créer tâche test" }));
+ await user.click(screen.getByRole("button", { name: "Modifier tâche test" }));
+ await user.click(screen.getByRole("button", { name: "Supprimer tâche test" }));
+ await user.click(screen.getByRole("button", { name: "Déplacer la tâche test" }));
+
+ await user.click(screen.getByRole("button", { name: "Roadmap" }));
+
+ expect(latestRoadmapSectionProps?.onTaskStatusChange).toBeTypeOf("function");
+
+ await user.click(screen.getByRole("button", { name: "Changer statut roadmap test" }));
+
+ await waitFor(() => {
+ expect(supabaseFromMock).toHaveBeenCalledWith("tasks");
+ expect(insertTaskMock).toHaveBeenCalledTimes(2);
+ expect(insertTaskMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tablo_id: "tablo-1",
+ title: "Task from etape",
+ status: "todo",
+ assignee_id: null,
+ position: 0,
+ parent_task_id: "etape-1",
+ is_parent: false,
+ description: null,
+ due_date: null,
+ })
+ );
+ expect(updateTaskMock).toHaveBeenCalledWith({ title: "Updated task title" });
+ expect(updateTaskMock).toHaveBeenCalledWith({ position: 7, status: "done" });
+ expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" });
+ expect(deleteTaskMock).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("renders the main-route style header metadata and discussion CTA", () => {
+ renderWithProviders(, {
+ route: "/tablo/tablo-1",
+ path: "/tablo/:tabloId",
+ });
+
+ expect(screen.getByText("Client Project")).toBeInTheDocument();
+ expect(screen.getAllByRole("button", { name: "Discussion" })).toHaveLength(2);
+ expect(screen.getAllByText("Rôle").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Créé le").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Progression").length).toBeGreaterThan(0);
+ });
+
+ it("keeps the shared main-app header labels even when the client locale is english", () => {
+ renderWithProviders(, {
+ route: "/tablo/tablo-1",
+ path: "/tablo/:tabloId",
+ language: "en",
+ });
+
+ expect(screen.getAllByText("Rôle").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Créé le").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Progression").length).toBeGreaterThan(0);
+ expect(screen.queryByText("Role")).not.toBeInTheDocument();
+ expect(screen.queryByText("Created on")).not.toBeInTheDocument();
+ expect(screen.queryByText("Progress")).not.toBeInTheDocument();
+ });
+
+ it("keeps client restrictions by hiding invite and layout-edit controls", () => {
+ renderWithProviders(, {
+ route: "/tablo/tablo-1",
+ path: "/tablo/:tabloId",
+ });
+
+ expect(screen.queryByRole("button", { name: "Inviter" })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Modifier la mise en page" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders a read-only overview matching the main route cards", () => {
+ renderWithProviders(, {
+ route: "/tablo/tablo-1",
+ path: "/tablo/:tabloId",
+ });
+
+ expect(screen.getByText("Description du projet")).toBeInTheDocument();
+ expect(screen.getByText("Mes tâches")).toBeInTheDocument();
+ expect(screen.getByText("Informations")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Ajouter" })).not.toBeInTheDocument();
+ });
+
+ it("lets the client quickly toggle a task from the overview card", async () => {
+ const { user } = renderWithProviders(, {
+ route: "/tablo/tablo-1",
+ path: "/tablo/:tabloId",
+ });
+
+ await user.click(screen.getByRole("button", { name: "Prepare proposal" }));
+
+ await waitFor(() => {
+ expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" });
+ });
+ });
+
+ it("wires file and folder actions in the client files tab while keeping file deletion disabled", async () => {
+ const { user } = renderWithProviders(, {
+ route: "/tablo/tablo-1",
+ path: "/tablo/:tabloId",
+ });
+
+ await user.click(screen.getByRole("button", { name: "Fichiers" }));
+
+ expect(latestTabloFilesSectionProps?.isReadOnly).toBe(false);
+ expect(latestTabloFilesSectionProps?.onCreateFile).toBeTypeOf("function");
+ expect(latestTabloFilesSectionProps?.onDownloadFile).toBeTypeOf("function");
+ expect(latestTabloFilesSectionProps?.onCreateFolder).toBeTypeOf("function");
+ expect(latestTabloFilesSectionProps?.onUpdateFolder).toBeTypeOf("function");
+ expect(latestTabloFilesSectionProps?.onDeleteFolder).toBeTypeOf("function");
+ expect(latestTabloFilesSectionProps?.onDeleteFile).toBeUndefined();
+
+ await user.click(screen.getByRole("button", { name: "Créer fichier test" }));
+ await user.click(screen.getByRole("button", { name: "Télécharger fichier test" }));
+ await user.click(screen.getByRole("button", { name: "Créer livrable test" }));
+ await user.click(screen.getByRole("button", { name: "Modifier livrable test" }));
+ await user.click(screen.getByRole("button", { name: "Supprimer livrable test" }));
+
+ await waitFor(() => {
+ expect(apiPostMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/file/brief.pdf", {
+ content: "data:application/pdf;base64,AAAA",
+ contentType: "application/pdf",
+ });
+ expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/brief.pdf");
+ expect(apiPostMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders", {
+ name: "Livrable",
+ description: "Desc",
+ });
+ expect(apiPutMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1", {
+ name: "Livrable mis à jour",
+ description: "Desc",
+ });
+ expect(apiDeleteMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1");
+ });
+ });
+});
diff --git a/apps/clients/src/pages/ClientTabloPage.tsx b/apps/clients/src/pages/ClientTabloPage.tsx
new file mode 100644
index 0000000..2ff728d
--- /dev/null
+++ b/apps/clients/src/pages/ClientTabloPage.tsx
@@ -0,0 +1,716 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { buildApi, cn } from "@xtablo/shared";
+import { useSession } from "@xtablo/shared/contexts/SessionContext";
+import type {
+ Etape,
+ KanbanTask,
+ KanbanTaskUpdate,
+ TabloFolder,
+ TaskStatus,
+ UserTablo,
+} from "@xtablo/shared-types";
+import {
+ EtapesSection,
+ RoadmapSection,
+ type SingleTabloTabId,
+ SingleTabloView,
+ TabloDiscussionSection,
+ TabloEventsSection,
+ TabloFilesSection,
+ TabloTasksSection,
+} from "@xtablo/tablo-views";
+import { FolderIcon } from "lucide-react";
+import { useState } from "react";
+import { useParams } from "react-router-dom";
+import { supabase } from "../lib/supabase";
+
+const API_URL = import.meta.env.VITE_API_URL as string;
+
+// ─── Local hooks ──────────────────────────────────────────────────────────────
+
+function useAuthedApi(accessToken: string | undefined) {
+ return buildApi(API_URL).create({
+ headers: {
+ Authorization: `Bearer ${accessToken ?? ""}`,
+ },
+ });
+}
+
+function useClientTablo(tabloId: string) {
+ return useQuery({
+ queryKey: ["client-tablo", tabloId],
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("user_tablos")
+ .select("*")
+ .eq("id", tabloId)
+ .single();
+ if (error) throw error;
+ return data as UserTablo;
+ },
+ enabled: !!tabloId,
+ });
+}
+
+function useClientTabloTasks(tabloId: string) {
+ return useQuery({
+ queryKey: ["client-tasks", tabloId],
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("tasks_with_assignee")
+ .select("*")
+ .eq("tablo_id", tabloId)
+ .eq("is_parent", false)
+ .order("updated_at", { ascending: false });
+ if (error) throw error;
+ return (data ?? []) as KanbanTask[];
+ },
+ enabled: !!tabloId,
+ });
+}
+
+function useClientTabloEtapes(tabloId: string) {
+ return useQuery({
+ queryKey: ["client-etapes", tabloId],
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("tasks")
+ .select("*")
+ .eq("tablo_id", tabloId)
+ .eq("is_parent", true)
+ .order("position", { ascending: true });
+ if (error) throw error;
+ return (data ?? []) as Etape[];
+ },
+ enabled: !!tabloId,
+ });
+}
+
+function useClientTabloEvents(tabloId: string) {
+ return useQuery({
+ queryKey: ["client-events", tabloId],
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("events_and_tablos")
+ .select("*")
+ .eq("tablo_id", tabloId)
+ .order("start_date", { ascending: false });
+ if (error) throw error;
+ return data ?? [];
+ },
+ enabled: !!tabloId,
+ });
+}
+
+function useClientTabloMembers(tabloId: string, accessToken: string | undefined) {
+ const api = useAuthedApi(accessToken);
+ return useQuery({
+ queryKey: ["client-members", tabloId],
+ queryFn: async () => {
+ const { data } = await api.get<{
+ members: {
+ id: string;
+ name: string;
+ is_admin: boolean;
+ email: string;
+ avatar_url: string | null;
+ }[];
+ }>(`/api/v1/tablos/members/${tabloId}`);
+ return data.members;
+ },
+ enabled: !!tabloId && !!accessToken,
+ });
+}
+
+function useClientTabloFiles(tabloId: string, accessToken: string | undefined) {
+ const api = useAuthedApi(accessToken);
+ return useQuery<{ fileNames: string[] }>({
+ queryKey: ["client-tablo-files", tabloId],
+ queryFn: async () => {
+ const { data } = await api.get(`/api/v1/tablo-data/${tabloId}/filenames`);
+ return data as { fileNames: string[] };
+ },
+ enabled: !!tabloId && !!accessToken,
+ });
+}
+
+function useClientTabloFolders(tabloId: string, accessToken: string | undefined) {
+ const api = useAuthedApi(accessToken);
+ return useQuery({
+ queryKey: ["client-tablo-folders", tabloId],
+ queryFn: async () => {
+ const { data } = await api.get<{ folders: TabloFolder[] }>(
+ `/api/v1/tablo-data/${tabloId}/folders`
+ );
+ return data.folders ?? [];
+ },
+ enabled: !!tabloId && !!accessToken,
+ });
+}
+
+const invalidateClientFileQueries = (
+ queryClient: ReturnType,
+ tabloId: string
+) => {
+ queryClient.invalidateQueries({ queryKey: ["client-tablo-files", tabloId] });
+ queryClient.invalidateQueries({ queryKey: ["client-tablo-folders", tabloId] });
+};
+
+function useClientCreateFile(tabloId: string, accessToken: string | undefined) {
+ const api = useAuthedApi(accessToken);
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: {
+ tabloId: string;
+ fileName: string;
+ data: { content: string; contentType: string };
+ }) => {
+ const response = await api.post(
+ `/api/v1/tablo-data/${params.tabloId}/file/${params.fileName}`,
+ params.data
+ );
+ if (response.status !== 200) {
+ throw new Error("Failed to create file");
+ }
+ return response.data;
+ },
+ onSuccess: () => invalidateClientFileQueries(queryClient, tabloId),
+ });
+}
+
+function useClientDownloadFile(accessToken: string | undefined) {
+ const api = useAuthedApi(accessToken);
+
+ return useMutation({
+ mutationFn: async ({ tabloId, fileName }: { tabloId: string; fileName: string }) => {
+ const response = await api.get(`/api/v1/tablo-data/${tabloId}/${fileName}`);
+ if (response.status !== 200) {
+ throw new Error("Failed to download file");
+ }
+
+ const fileData = response.data as { content: string; contentType?: string };
+ let blob: Blob;
+
+ if (fileData.content.startsWith("data:")) {
+ const fileResponse = await fetch(fileData.content);
+ blob = await fileResponse.blob();
+ } else {
+ blob = new Blob([fileData.content], {
+ type: fileData.contentType || "application/octet-stream",
+ });
+ }
+
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+ },
+ });
+}
+
+function useClientCreateFolder(tabloId: string, accessToken: string | undefined) {
+ const api = useAuthedApi(accessToken);
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: {
+ tabloId: string;
+ name: string;
+ description: string;
+ createdBy: string;
+ }) => {
+ const response = await api.post(`/api/v1/tablo-data/${params.tabloId}/folders`, {
+ name: params.name,
+ description: params.description,
+ });
+ if (response.status !== 200) {
+ throw new Error("Failed to create folder");
+ }
+ return response.data;
+ },
+ onSuccess: () => invalidateClientFileQueries(queryClient, tabloId),
+ });
+}
+
+function useClientUpdateFolder(tabloId: string, accessToken: string | undefined) {
+ const api = useAuthedApi(accessToken);
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: {
+ tabloId: string;
+ folderId: string;
+ name: string;
+ description: string;
+ }) => {
+ const response = await api.put(
+ `/api/v1/tablo-data/${params.tabloId}/folders/${params.folderId}`,
+ {
+ name: params.name,
+ description: params.description,
+ }
+ );
+ if (response.status !== 200) {
+ throw new Error("Failed to update folder");
+ }
+ return response.data;
+ },
+ onSuccess: () => invalidateClientFileQueries(queryClient, tabloId),
+ });
+}
+
+function useClientDeleteFolder(tabloId: string, accessToken: string | undefined) {
+ const api = useAuthedApi(accessToken);
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (params: { tabloId: string; folderId: string; folderName: string }) => {
+ const response = await api.delete(
+ `/api/v1/tablo-data/${params.tabloId}/folders/${params.folderId}`
+ );
+ if (response.status !== 200) {
+ throw new Error("Failed to delete folder");
+ }
+ return response.data;
+ },
+ onSuccess: () => invalidateClientFileQueries(queryClient, tabloId),
+ });
+}
+
+type ClientTaskCreateInput = {
+ tablo_id: string;
+ title: string;
+ status?: TaskStatus | string;
+ parent_task_id?: string | null;
+ is_parent?: boolean;
+ position?: number;
+ description?: string | null;
+ assignee_id?: string | null;
+ due_date?: string | null;
+};
+
+const invalidateClientTaskQueries = (
+ queryClient: ReturnType,
+ tabloId: string
+) => {
+ queryClient.invalidateQueries({ queryKey: ["client-tasks", tabloId] });
+};
+
+function useClientCreateTask(tabloId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (task: ClientTaskCreateInput) => {
+ const { data, error } = await supabase
+ .from("tasks")
+ .insert({
+ tablo_id: task.tablo_id,
+ title: task.title,
+ status: (task.status as TaskStatus | undefined) ?? "todo",
+ assignee_id: task.assignee_id ?? null,
+ position: task.position ?? 0,
+ parent_task_id: task.parent_task_id ?? null,
+ is_parent: task.is_parent ?? false,
+ description: task.description ?? null,
+ due_date: task.due_date ?? null,
+ })
+ .select()
+ .single();
+
+ if (error) throw error;
+ return data;
+ },
+ onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId),
+ });
+}
+
+function useClientUpdateTask(tabloId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ id,
+ tablo_id: _tabloId,
+ ...updates
+ }: KanbanTaskUpdate & { id: string; tablo_id?: string }) => {
+ const { data, error } = await supabase
+ .from("tasks")
+ .update(updates)
+ .eq("id", id)
+ .select()
+ .single();
+
+ if (error) throw error;
+ return data;
+ },
+ onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId),
+ });
+}
+
+function useClientDeleteTask(tabloId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (taskId: string) => {
+ const { error } = await supabase.from("tasks").delete().eq("id", taskId);
+ if (error) throw error;
+ return taskId;
+ },
+ onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId),
+ });
+}
+
+function useClientUpdateTaskPositions(tabloId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (
+ updates: Array<{
+ id: string;
+ position: number;
+ status?: TaskStatus;
+ parent_task_id?: string | null;
+ }>
+ ) => {
+ const results = await Promise.all(
+ updates.map(({ id, position, status, parent_task_id }) =>
+ supabase
+ .from("tasks")
+ .update({
+ position,
+ ...(status && { status }),
+ ...(parent_task_id !== undefined ? { parent_task_id } : {}),
+ })
+ .eq("id", id)
+ )
+ );
+
+ const errors = results.filter((result) => result.error);
+ if (errors.length > 0) {
+ throw new Error("Failed to update some task positions");
+ }
+
+ return updates;
+ },
+ onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId),
+ });
+}
+
+function getStatusConfig(status: string) {
+ switch (status) {
+ case "in_progress":
+ return {
+ label: "En cours",
+ badgeClass:
+ "bg-yellow-50 text-yellow-700 border border-yellow-200 dark:bg-yellow-950/30 dark:text-yellow-400 dark:border-yellow-800",
+ };
+ case "done":
+ return {
+ label: "Terminé",
+ badgeClass:
+ "bg-green-50 text-green-600 border border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800",
+ };
+ default:
+ return {
+ label: "À faire",
+ badgeClass:
+ "bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800",
+ };
+ }
+}
+
+function getEtapeProgressStats(etapes: Etape[]) {
+ const total = etapes.length;
+ const done = etapes.filter((etape) => etape.status === "done").length;
+ const started = etapes.filter((etape) =>
+ new Set(["in_progress", "in_review", "done"]).has(etape.status ?? "todo")
+ ).length;
+
+ if (total === 0) {
+ return {
+ startedPercentage: 0,
+ donePercentage: 0,
+ };
+ }
+
+ return {
+ startedPercentage: Math.round((started / total) * 100),
+ donePercentage: Math.round((done / total) * 100),
+ };
+}
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
+
+export function ClientTabloPage() {
+ const { tabloId } = useParams<{ tabloId: string }>();
+ const { session } = useSession();
+ const [activeTab, setActiveTab] = useState("overview");
+
+ const accessToken = session?.access_token;
+ const currentUserId = session?.user.id ?? "";
+
+ const { data: tablo, isLoading: tabloLoading } = useClientTablo(tabloId ?? "");
+ const { data: tasks = [] } = useClientTabloTasks(tabloId ?? "");
+ const { data: etapes = [] } = useClientTabloEtapes(tabloId ?? "");
+ const {
+ data: events,
+ isLoading: eventsLoading,
+ error: eventsError,
+ } = useClientTabloEvents(tabloId ?? "");
+ const { data: members = [] } = useClientTabloMembers(tabloId ?? "", accessToken);
+ const {
+ data: filesData,
+ isLoading: filesLoading,
+ error: filesError,
+ } = useClientTabloFiles(tabloId ?? "", accessToken);
+ const {
+ data: folders = [],
+ isLoading: foldersLoading,
+ error: foldersError,
+ } = useClientTabloFolders(tabloId ?? "", accessToken);
+ const { mutate: createTask } = useClientCreateTask(tabloId ?? "");
+ const { mutate: updateTask } = useClientUpdateTask(tabloId ?? "");
+ const { mutate: deleteTask } = useClientDeleteTask(tabloId ?? "");
+ const { mutate: updateTaskPositions } = useClientUpdateTaskPositions(tabloId ?? "");
+ const { mutateAsync: createFile } = useClientCreateFile(tabloId ?? "", accessToken);
+ const { mutateAsync: downloadFile } = useClientDownloadFile(accessToken);
+ const { mutateAsync: createFolder } = useClientCreateFolder(tabloId ?? "", accessToken);
+ const { mutateAsync: updateFolder } = useClientUpdateFolder(tabloId ?? "", accessToken);
+ const { mutateAsync: deleteFolder } = useClientDeleteFolder(tabloId ?? "", accessToken);
+
+ const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith("."));
+
+ const currentUser = { id: currentUserId, avatar_url: null };
+
+ if (tabloLoading) {
+ return (
+
+ );
+ }
+
+ if (!tablo) {
+ return (
+
+ );
+ }
+
+ const { label: statusLabel, badgeClass } = getStatusConfig(tablo.status);
+ const progress = getEtapeProgressStats(etapes);
+
+ return (
+ setActiveTab("discussion") }}
+ >
+ {activeTab === "overview" && (
+
+
+
+
+ Description du projet
+
+
+ Ce projet regroupe les tâches, fichiers et événements associés. Utilisez les onglets
+ ci-dessus pour naviguer entre les différentes sections.
+
+
+
+
+
+
+ Mes tâches
+
+
+
+ {tasks.length === 0 ? (
+
Aucune tâche
+ ) : (
+ tasks.slice(0, 5).map((task) => (
+
+ ))
+ )}
+
+
+
+
+
+
+
+
Fichiers
+
+
+ {fileNames.length === 0 ? (
+
Aucun fichier
+ ) : (
+ fileNames.slice(0, 5).map((fileName) => (
+
+ ))
+ )}
+
+
+
+
+
Informations
+
+
+
- Tâches
+ - {tasks.length}
+
+
+
- Fichiers
+ - {fileNames.length}
+
+
+
- Statut
+ -
+ {statusLabel}
+
+
+
+
- Rôle
+ - Invité
+
+
+
+
+
+ )}
+
+ {activeTab === "etapes" && (
+ createTask(task)}
+ onCreateEtape={async () => undefined}
+ onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
+ />
+ )}
+
+ {activeTab === "tasks" && (
+ createTask(task)}
+ onUpdateTask={(task) => updateTask(task)}
+ onDeleteTask={(taskId) => deleteTask(taskId)}
+ onUpdateTaskPositions={(updates) => updateTaskPositions(updates)}
+ />
+ )}
+
+ {activeTab === "files" && (
+ createFile(params)}
+ onDownloadFile={(params) => downloadFile(params)}
+ onCreateFolder={(params) => createFolder(params)}
+ onUpdateFolder={(params) => updateFolder(params)}
+ onDeleteFolder={(params) => deleteFolder(params)}
+ />
+ )}
+
+ {activeTab === "discussion" && (
+
+ )}
+
+ {activeTab === "events" && (
+ [0]["events"]}
+ isLoading={eventsLoading}
+ error={eventsError instanceof Error ? eventsError : null}
+ currentUser={currentUser}
+ members={members}
+ />
+ )}
+
+ {activeTab === "roadmap" && (
+ undefined}
+ onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
+ />
+ )}
+
+ );
+}
diff --git a/apps/clients/src/pages/LoginPage.test.tsx b/apps/clients/src/pages/LoginPage.test.tsx
new file mode 100644
index 0000000..47f77d7
--- /dev/null
+++ b/apps/clients/src/pages/LoginPage.test.tsx
@@ -0,0 +1,66 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "../test/testHelpers";
+import { LoginPage } from "./LoginPage";
+
+const { mockSignInWithPassword, mockNavigate } = vi.hoisted(() => ({
+ mockSignInWithPassword: vi.fn(),
+ mockNavigate: vi.fn(),
+}));
+
+vi.mock("../lib/supabase", () => ({
+ supabase: {
+ auth: {
+ signInWithPassword: mockSignInWithPassword,
+ },
+ },
+}));
+
+vi.mock("react-router-dom", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+describe("LoginPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ mockSignInWithPassword.mockResolvedValue({
+ data: { user: { email_confirmed_at: new Date().toISOString() } },
+ error: null,
+ });
+ });
+
+ it("renders the shared auth shell and form", () => {
+ renderWithProviders(, { testUser: undefined });
+
+ expect(screen.getByTestId("auth-card-shell")).toBeInTheDocument();
+ expect(screen.getByLabelText("Email")).toBeInTheDocument();
+ expect(screen.getByLabelText("Mot de passe")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Connexion" })).toBeInTheDocument();
+ expect(screen.getAllByAltText("Xtablo")[0]).toHaveAttribute(
+ "src",
+ "https://assets.xtablo.com/logo_dark.png"
+ );
+ });
+
+ it("submits email/password login and resumes the stored redirect", async () => {
+ localStorage.setItem("clients.redirectUrl", "/tablo/tablo-42");
+ renderWithProviders(, { testUser: undefined });
+
+ fireEvent.change(screen.getByLabelText("Email"), { target: { value: "client@example.com" } });
+ fireEvent.change(screen.getByLabelText("Mot de passe"), { target: { value: "password123" } });
+ fireEvent.click(screen.getByRole("button", { name: "Connexion" }));
+
+ await waitFor(() => {
+ expect(mockSignInWithPassword).toHaveBeenCalledWith({
+ email: "client@example.com",
+ password: "password123",
+ });
+ expect(mockNavigate).toHaveBeenCalledWith("/tablo/tablo-42");
+ });
+ });
+});
diff --git a/apps/clients/src/pages/LoginPage.tsx b/apps/clients/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..f38f582
--- /dev/null
+++ b/apps/clients/src/pages/LoginPage.tsx
@@ -0,0 +1,102 @@
+import { AuthCardShell, AuthEmailPasswordForm, AuthInfoBanner } from "@xtablo/auth-ui";
+import { useSession } from "@xtablo/shared/contexts/SessionContext";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link, useNavigate } from "react-router-dom";
+import { supabase } from "../lib/supabase";
+
+export function LoginPage() {
+ const { t } = useTranslation(["auth", "common"]);
+ const { session } = useSession();
+ const navigate = useNavigate();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [isPending, setIsPending] = useState(false);
+ const [error, setError] = useState(null);
+
+ if (session) {
+ const redirectUrl = localStorage.getItem("clients.redirectUrl");
+ if (redirectUrl) {
+ localStorage.removeItem("clients.redirectUrl");
+ navigate(redirectUrl);
+ } else {
+ navigate("/");
+ }
+ }
+
+ const onSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setIsPending(true);
+ setError(null);
+
+ const { error: signInError } = await supabase.auth.signInWithPassword({
+ email,
+ password,
+ });
+
+ if (signInError) {
+ setError(signInError.message);
+ setIsPending(false);
+ return;
+ }
+
+ const redirectUrl = localStorage.getItem("clients.redirectUrl");
+ if (redirectUrl) {
+ localStorage.removeItem("clients.redirectUrl");
+ navigate(redirectUrl);
+ } else {
+ navigate("/");
+ }
+ };
+
+ return (
+
+
+ {t("auth:common.backHome")}
+
+ }
+ showThemeToggle
+ >
+
+ {error ?
: null}
+
+
+
+ {t("auth:login.forgotPassword")}
+
+
+ }
+ />
+
+
+ );
+}
diff --git a/apps/clients/src/pages/ResetPasswordPage.test.tsx b/apps/clients/src/pages/ResetPasswordPage.test.tsx
new file mode 100644
index 0000000..18a4657
--- /dev/null
+++ b/apps/clients/src/pages/ResetPasswordPage.test.tsx
@@ -0,0 +1,42 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "../test/testHelpers";
+import { ResetPasswordPage } from "./ResetPasswordPage";
+
+const { mockResetPasswordForEmail } = vi.hoisted(() => ({
+ mockResetPasswordForEmail: vi.fn(),
+}));
+
+vi.mock("../lib/supabase", () => ({
+ supabase: {
+ auth: {
+ resetPasswordForEmail: mockResetPasswordForEmail,
+ },
+ },
+}));
+
+describe("ResetPasswordPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockResetPasswordForEmail.mockResolvedValue({ error: null });
+ });
+
+ it("renders the shared auth shell", () => {
+ renderWithProviders(, { testUser: undefined });
+
+ expect(screen.getByTestId("auth-card-shell")).toBeInTheDocument();
+ expect(screen.getByLabelText("Email")).toBeInTheDocument();
+ });
+
+ it("submits a password reset email", async () => {
+ renderWithProviders(, { testUser: undefined });
+
+ fireEvent.change(screen.getByLabelText("Email"), { target: { value: "client@example.com" } });
+ fireEvent.click(screen.getByRole("button", { name: "Envoyer le lien de réinitialisation" }));
+
+ await waitFor(() => {
+ expect(mockResetPasswordForEmail).toHaveBeenCalled();
+ expect(screen.getByText(/vérifiez votre boîte mail/i)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/clients/src/pages/ResetPasswordPage.tsx b/apps/clients/src/pages/ResetPasswordPage.tsx
new file mode 100644
index 0000000..15031cd
--- /dev/null
+++ b/apps/clients/src/pages/ResetPasswordPage.tsx
@@ -0,0 +1,74 @@
+import { AuthCardShell, AuthInfoBanner } from "@xtablo/auth-ui";
+import { Button } from "@xtablo/ui/components/button";
+import { Input } from "@xtablo/ui/components/input";
+import { Label } from "@xtablo/ui/components/label";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
+import { supabase } from "../lib/supabase";
+
+export function ResetPasswordPage() {
+ const { t } = useTranslation(["auth", "common"]);
+ const [email, setEmail] = useState("");
+ const [error, setError] = useState(null);
+ const [isPending, setIsPending] = useState(false);
+ const [isSubmitted, setIsSubmitted] = useState(false);
+
+ const onSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setIsPending(true);
+ setError(null);
+
+ const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
+ redirectTo: `${window.location.origin}/set-password`,
+ });
+
+ if (resetError) {
+ setError(resetError.message);
+ setIsPending(false);
+ return;
+ }
+
+ setIsSubmitted(true);
+ setIsPending(false);
+ };
+
+ return (
+
+
+ {error ?
: null}
+ {isSubmitted ? (
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/apps/clients/src/pages/SetPasswordPage.test.tsx b/apps/clients/src/pages/SetPasswordPage.test.tsx
new file mode 100644
index 0000000..250d2c6
--- /dev/null
+++ b/apps/clients/src/pages/SetPasswordPage.test.tsx
@@ -0,0 +1,94 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "../test/testHelpers";
+import { SetPasswordPage } from "./SetPasswordPage";
+
+const { mockSignInWithPassword, mockUpdateUser, mockNavigate } = vi.hoisted(() => ({
+ mockSignInWithPassword: vi.fn(),
+ mockUpdateUser: vi.fn(),
+ mockNavigate: vi.fn(),
+}));
+
+vi.mock("../lib/supabase", () => ({
+ supabase: {
+ auth: {
+ signInWithPassword: mockSignInWithPassword,
+ updateUser: mockUpdateUser,
+ },
+ },
+}));
+
+vi.mock("react-router-dom", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+describe("SetPasswordPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal("fetch", vi.fn());
+ mockSignInWithPassword.mockResolvedValue({ data: {}, error: null });
+ mockUpdateUser.mockResolvedValue({ data: {}, error: null });
+ });
+
+ it("renders an invalid-token state when invite validation fails", async () => {
+ vi.mocked(fetch).mockResolvedValueOnce(
+ new Response(JSON.stringify({ error: "Invite not found or already used" }), { status: 404 })
+ );
+
+ renderWithProviders(, {
+ route: "/set-password?token=bad-token",
+ path: "/set-password",
+ testUser: undefined,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/lien invalide/i)).toBeInTheDocument();
+ });
+ });
+
+ it("submits invite-based password setup once", async () => {
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ email: "client@example.com", tabloId: "tablo-1" }), {
+ status: 200,
+ })
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({ success: true, email: "client@example.com", tabloId: "tablo-1" }),
+ {
+ status: 200,
+ }
+ )
+ );
+
+ renderWithProviders(, {
+ route: "/set-password?token=good-token",
+ path: "/set-password",
+ testUser: undefined,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Mot de passe")).toBeInTheDocument();
+ });
+
+ fireEvent.change(screen.getByLabelText("Mot de passe"), { target: { value: "password123" } });
+ fireEvent.change(screen.getByLabelText("Confirmer le mot de passe"), {
+ target: { value: "password123" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Définir mon mot de passe" }));
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledTimes(2);
+ expect(mockSignInWithPassword).toHaveBeenCalledWith({
+ email: "client@example.com",
+ password: "password123",
+ });
+ expect(mockNavigate).toHaveBeenCalledWith("/tablo/tablo-1");
+ });
+ });
+});
diff --git a/apps/clients/src/pages/SetPasswordPage.tsx b/apps/clients/src/pages/SetPasswordPage.tsx
new file mode 100644
index 0000000..f775c4b
--- /dev/null
+++ b/apps/clients/src/pages/SetPasswordPage.tsx
@@ -0,0 +1,205 @@
+import { AuthCardShell, AuthInfoBanner } from "@xtablo/auth-ui";
+import { useSession } from "@xtablo/shared/contexts/SessionContext";
+import { Button } from "@xtablo/ui/components/button";
+import { Input } from "@xtablo/ui/components/input";
+import { Label } from "@xtablo/ui/components/label";
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import { supabase } from "../lib/supabase";
+
+type InviteDetails = {
+ email: string;
+ tabloId: string;
+};
+
+type InviteState = "loading" | "ready" | "invalid";
+
+function getApiUrl() {
+ return import.meta.env.VITE_API_URL ?? "";
+}
+
+async function parseError(response: Response) {
+ const body = (await response.json().catch(() => ({}))) as { error?: string; message?: string };
+ return body.error ?? body.message ?? "Une erreur est survenue.";
+}
+
+export function SetPasswordPage() {
+ const { t } = useTranslation("auth");
+ const { session } = useSession();
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const token = searchParams.get("token");
+ const [inviteState, setInviteState] = useState(token ? "loading" : "ready");
+ const [inviteDetails, setInviteDetails] = useState(null);
+ const [password, setPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [isPending, setIsPending] = useState(false);
+
+ const isInviteFlow = Boolean(token);
+ const description = useMemo(
+ () =>
+ isInviteFlow
+ ? "Définissez votre mot de passe pour accéder à votre espace client."
+ : t("updatePassword.description"),
+ [isInviteFlow, t]
+ );
+
+ useEffect(() => {
+ if (!token) {
+ return;
+ }
+
+ let isMounted = true;
+
+ fetch(`${getApiUrl()}/api/v1/client-invites/setup/${token}`)
+ .then(async (response) => {
+ if (!response.ok) {
+ throw new Error(await parseError(response));
+ }
+
+ return (await response.json()) as InviteDetails;
+ })
+ .then((details) => {
+ if (!isMounted) return;
+ setInviteDetails(details);
+ setInviteState("ready");
+ })
+ .catch((err: unknown) => {
+ if (!isMounted) return;
+ setError(err instanceof Error ? err.message : "Lien invalide");
+ setInviteState("invalid");
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, [token]);
+
+ const onSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setError(null);
+
+ if (password.length < 8) {
+ setError(t("signup.errors.passwordLength"));
+ return;
+ }
+
+ if (password !== confirmPassword) {
+ setError(t("signup.errors.passwordMatch"));
+ return;
+ }
+
+ setIsPending(true);
+
+ try {
+ if (token && inviteDetails) {
+ const response = await fetch(`${getApiUrl()}/api/v1/client-invites/setup/${token}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ password }),
+ });
+
+ if (!response.ok) {
+ throw new Error(await parseError(response));
+ }
+
+ const result = (await response.json()) as InviteDetails & { success: boolean };
+ const { error: signInError } = await supabase.auth.signInWithPassword({
+ email: result.email,
+ password,
+ });
+
+ if (signInError) {
+ throw signInError;
+ }
+
+ navigate(`/tablo/${result.tabloId}`);
+ return;
+ }
+
+ const { error: updateError } = await supabase.auth.updateUser({ password });
+ if (updateError) {
+ throw updateError;
+ }
+
+ navigate("/login");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Une erreur est survenue.");
+ } finally {
+ setIsPending(false);
+ }
+ };
+
+ if (inviteState === "loading") {
+ return (
+
+
+
+ );
+ }
+
+ if (inviteState === "invalid") {
+ return (
+
+
+
+ );
+ }
+
+ const shouldShowRecoveryHint = !isInviteFlow && !session;
+
+ return (
+
+
+ {error ?
: null}
+ {shouldShowRecoveryHint ? (
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/apps/clients/src/routes.tsx b/apps/clients/src/routes.tsx
new file mode 100644
index 0000000..e10531f
--- /dev/null
+++ b/apps/clients/src/routes.tsx
@@ -0,0 +1,26 @@
+import { Route, Routes } from "react-router-dom";
+import { ClientAuthGate } from "./components/ClientAuthGate";
+import { ClientLayout } from "./components/ClientLayout";
+import { AuthCallback } from "./pages/AuthCallback";
+import { ClientTabloListPage } from "./pages/ClientTabloListPage";
+import { ClientTabloPage } from "./pages/ClientTabloPage";
+import { LoginPage } from "./pages/LoginPage";
+import { ResetPasswordPage } from "./pages/ResetPasswordPage";
+import { SetPasswordPage } from "./pages/SetPasswordPage";
+
+export default function AppRoutes() {
+ return (
+
+ } />
+ } />
+ } />
+ } />
+ }>
+ }>
+ } />
+ } />
+
+
+
+ );
+}
diff --git a/apps/clients/src/setupTests.ts b/apps/clients/src/setupTests.ts
new file mode 100644
index 0000000..d54e5d0
--- /dev/null
+++ b/apps/clients/src/setupTests.ts
@@ -0,0 +1,41 @@
+import "@testing-library/jest-dom";
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+import "./i18n.test";
+
+afterEach(() => {
+ cleanup();
+});
+
+global.ResizeObserver = class ResizeObserver {
+ observe() {
+ return undefined;
+ }
+ unobserve() {
+ return undefined;
+ }
+ disconnect() {
+ return undefined;
+ }
+};
+
+if (typeof Element !== "undefined") {
+ Element.prototype.scrollIntoView = vi.fn();
+ Element.prototype.scrollTo = vi.fn();
+}
+
+if (typeof window !== "undefined") {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+}
diff --git a/apps/clients/src/test/testHelpers.test.tsx b/apps/clients/src/test/testHelpers.test.tsx
new file mode 100644
index 0000000..b221b34
--- /dev/null
+++ b/apps/clients/src/test/testHelpers.test.tsx
@@ -0,0 +1,11 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { renderWithProviders } from "./testHelpers";
+
+describe("client test harness", () => {
+ it("renders a smoke test placeholder", () => {
+ renderWithProviders(client test harness
);
+
+ expect(screen.getByText("client test harness")).toBeInTheDocument();
+ });
+});
diff --git a/apps/clients/src/test/testHelpers.tsx b/apps/clients/src/test/testHelpers.tsx
new file mode 100644
index 0000000..51ae585
--- /dev/null
+++ b/apps/clients/src/test/testHelpers.tsx
@@ -0,0 +1,69 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { type RenderResult, render } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { SessionTestProvider } from "@xtablo/shared/contexts/SessionContext";
+import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
+import React from "react";
+import { I18nextProvider } from "react-i18next";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import testI18n from "../i18n.test";
+
+const defaultUser = {
+ id: "client-user-1",
+ app_metadata: {},
+ aud: "test",
+ created_at: "2021-01-01",
+ email: "client@example.com",
+ user_metadata: {
+ full_name: "Client User",
+ },
+};
+
+interface RenderWithProvidersOptions {
+ route?: string;
+ path?: string;
+ language?: string;
+ testUser?: typeof defaultUser | undefined;
+}
+
+export const renderWithProviders = (
+ ui: React.ReactNode,
+ options: RenderWithProvidersOptions = {}
+): RenderResult & { user: ReturnType } => {
+ const { route = "/", path, language = "fr" } = options;
+ const testUser = Object.hasOwn(options, "testUser") ? options.testUser : defaultUser;
+
+ testI18n.changeLanguage(language);
+
+ const testQueryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ staleTime: Infinity,
+ },
+ },
+ });
+
+ const content = path ? (
+
+
+
+ ) : (
+ ui
+ );
+
+ return {
+ user: userEvent.setup(),
+ ...render(
+
+
+
+
+ {content}
+
+
+
+
+ ),
+ };
+};
diff --git a/apps/clients/src/vite-env.d.ts b/apps/clients/src/vite-env.d.ts
new file mode 100644
index 0000000..f9164af
--- /dev/null
+++ b/apps/clients/src/vite-env.d.ts
@@ -0,0 +1,9 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_APP_VERSION: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/apps/clients/src/viteConfig.test.ts b/apps/clients/src/viteConfig.test.ts
new file mode 100644
index 0000000..dad6799
--- /dev/null
+++ b/apps/clients/src/viteConfig.test.ts
@@ -0,0 +1,18 @@
+// @vitest-environment node
+
+import { describe, expect, it } from "vitest";
+
+import configFactory from "../vite.config";
+
+describe("clients vite config", () => {
+ it("uses hidden sourcemaps for production builds", () => {
+ const config = configFactory({
+ mode: "production",
+ command: "build",
+ isSsrBuild: false,
+ isPreview: false,
+ });
+
+ expect(config.build?.sourcemap).toBe("hidden");
+ });
+});
diff --git a/apps/clients/tsconfig.json b/apps/clients/tsconfig.json
new file mode 100644
index 0000000..f763816
--- /dev/null
+++ b/apps/clients/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@xtablo/auth-ui": ["../../packages/auth-ui/src"],
+ "@xtablo/auth-ui/*": ["../../packages/auth-ui/src/*"],
+ "@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": []
+}
diff --git a/apps/clients/tsconfig.tsbuildinfo b/apps/clients/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..0266a11
--- /dev/null
+++ b/apps/clients/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/app.tsx","./src/envproduction.test.ts","./src/i18n.test.ts","./src/i18n.ts","./src/main.tsx","./src/maincss.test.ts","./src/routes.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/viteconfig.test.ts","./src/components/clientauthgate.tsx","./src/components/clientlayout.test.tsx","./src/components/clientlayout.tsx","./src/lib/rum.ts","./src/lib/supabase.ts","./src/pages/authcallback.tsx","./src/pages/clienttablolistpage.tsx","./src/pages/clienttablopage.test.tsx","./src/pages/clienttablopage.tsx","./src/pages/loginpage.test.tsx","./src/pages/loginpage.tsx","./src/pages/resetpasswordpage.test.tsx","./src/pages/resetpasswordpage.tsx","./src/pages/setpasswordpage.test.tsx","./src/pages/setpasswordpage.tsx","./src/test/testhelpers.test.tsx","./src/test/testhelpers.tsx"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/apps/clients/vite.config.ts b/apps/clients/vite.config.ts
new file mode 100644
index 0000000..db6ef09
--- /dev/null
+++ b/apps/clients/vite.config.ts
@@ -0,0 +1,36 @@
+import { cloudflare } from "@cloudflare/vite-plugin";
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig, type PluginOption } from "vite";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+export default defineConfig(({ mode }) => {
+ const plugins: PluginOption[] = [
+ react(),
+ tailwindcss(),
+ tsconfigPaths({ ignoreConfigErrors: true }),
+ ];
+
+ if (mode !== "test" && process.env.VITEST !== "true") {
+ plugins.push(cloudflare({ inspectorPort: 9232 }));
+ }
+
+ return {
+ plugins,
+ build: {
+ sourcemap: mode === "test" ? false : "hidden",
+ },
+ server: { cors: false },
+ define: process.env.VITEST
+ ? {
+ "import.meta.env.VITE_SUPABASE_URL": JSON.stringify("https://test.supabase.co"),
+ "import.meta.env.VITE_SUPABASE_ANON_KEY": JSON.stringify("test-anon-key"),
+ }
+ : undefined,
+ test: {
+ globals: true,
+ environment: "jsdom",
+ setupFiles: "./src/setupTests.ts",
+ },
+ };
+});
diff --git a/apps/clients/worker-configuration.d.ts b/apps/clients/worker-configuration.d.ts
new file mode 100644
index 0000000..8d33658
--- /dev/null
+++ b/apps/clients/worker-configuration.d.ts
@@ -0,0 +1,8376 @@
+/* eslint-disable */
+// Generated by Wrangler by running `wrangler types` (hash: cde5d6bbbb8f5d59a6f6313ce6d7b38b)
+// Runtime types generated with workerd@1.20251011.0 2025-07-09
+declare namespace Cloudflare {
+ interface GlobalProps {
+ mainModule: typeof import("./worker/index");
+ }
+ interface Env {
+ }
+}
+interface Env extends Cloudflare.Env {}
+
+// Begin runtime types
+/*! *****************************************************************************
+Copyright (c) Cloudflare. All rights reserved.
+Copyright (c) Microsoft Corporation. All rights reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at http://www.apache.org/licenses/LICENSE-2.0
+THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
+WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
+MERCHANTABLITY OR NON-INFRINGEMENT.
+See the Apache Version 2.0 License for specific language governing permissions
+and limitations under the License.
+***************************************************************************** */
+/* eslint-disable */
+// noinspection JSUnusedGlobalSymbols
+declare var onmessage: never;
+/**
+ * An abnormal event (called an exception) which occurs as a result of calling a method or accessing a property of a web API.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException)
+ */
+declare class DOMException extends Error {
+ constructor(message?: string, name?: string);
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message) */
+ readonly message: string;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name) */
+ readonly name: string;
+ /**
+ * @deprecated
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)
+ */
+ readonly code: number;
+ static readonly INDEX_SIZE_ERR: number;
+ static readonly DOMSTRING_SIZE_ERR: number;
+ static readonly HIERARCHY_REQUEST_ERR: number;
+ static readonly WRONG_DOCUMENT_ERR: number;
+ static readonly INVALID_CHARACTER_ERR: number;
+ static readonly NO_DATA_ALLOWED_ERR: number;
+ static readonly NO_MODIFICATION_ALLOWED_ERR: number;
+ static readonly NOT_FOUND_ERR: number;
+ static readonly NOT_SUPPORTED_ERR: number;
+ static readonly INUSE_ATTRIBUTE_ERR: number;
+ static readonly INVALID_STATE_ERR: number;
+ static readonly SYNTAX_ERR: number;
+ static readonly INVALID_MODIFICATION_ERR: number;
+ static readonly NAMESPACE_ERR: number;
+ static readonly INVALID_ACCESS_ERR: number;
+ static readonly VALIDATION_ERR: number;
+ static readonly TYPE_MISMATCH_ERR: number;
+ static readonly SECURITY_ERR: number;
+ static readonly NETWORK_ERR: number;
+ static readonly ABORT_ERR: number;
+ static readonly URL_MISMATCH_ERR: number;
+ static readonly QUOTA_EXCEEDED_ERR: number;
+ static readonly TIMEOUT_ERR: number;
+ static readonly INVALID_NODE_TYPE_ERR: number;
+ static readonly DATA_CLONE_ERR: number;
+ get stack(): any;
+ set stack(value: any);
+}
+type WorkerGlobalScopeEventMap = {
+ fetch: FetchEvent;
+ scheduled: ScheduledEvent;
+ queue: QueueEvent;
+ unhandledrejection: PromiseRejectionEvent;
+ rejectionhandled: PromiseRejectionEvent;
+};
+declare abstract class WorkerGlobalScope extends EventTarget {
+ EventTarget: typeof EventTarget;
+}
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console) */
+interface Console {
+ "assert"(condition?: boolean, ...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static) */
+ clear(): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) */
+ count(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) */
+ countReset(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) */
+ debug(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) */
+ dir(item?: any, options?: any): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) */
+ dirxml(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) */
+ error(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) */
+ group(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) */
+ groupCollapsed(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) */
+ groupEnd(): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) */
+ info(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) */
+ log(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static) */
+ table(tabularData?: any, properties?: string[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) */
+ time(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) */
+ timeEnd(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) */
+ timeLog(label?: string, ...data: any[]): void;
+ timeStamp(label?: string): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static) */
+ trace(...data: any[]): void;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) */
+ warn(...data: any[]): void;
+}
+declare const console: Console;
+type BufferSource = ArrayBufferView | ArrayBuffer;
+type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array;
+declare namespace WebAssembly {
+ class CompileError extends Error {
+ constructor(message?: string);
+ }
+ class RuntimeError extends Error {
+ constructor(message?: string);
+ }
+ type ValueType = "anyfunc" | "externref" | "f32" | "f64" | "i32" | "i64" | "v128";
+ interface GlobalDescriptor {
+ value: ValueType;
+ mutable?: boolean;
+ }
+ class Global {
+ constructor(descriptor: GlobalDescriptor, value?: any);
+ value: any;
+ valueOf(): any;
+ }
+ type ImportValue = ExportValue | number;
+ type ModuleImports = Record;
+ type Imports = Record;
+ type ExportValue = Function | Global | Memory | Table;
+ type Exports = Record;
+ class Instance {
+ constructor(module: Module, imports?: Imports);
+ readonly exports: Exports;
+ }
+ interface MemoryDescriptor {
+ initial: number;
+ maximum?: number;
+ shared?: boolean;
+ }
+ class Memory {
+ constructor(descriptor: MemoryDescriptor);
+ readonly buffer: ArrayBuffer;
+ grow(delta: number): number;
+ }
+ type ImportExportKind = "function" | "global" | "memory" | "table";
+ interface ModuleExportDescriptor {
+ kind: ImportExportKind;
+ name: string;
+ }
+ interface ModuleImportDescriptor {
+ kind: ImportExportKind;
+ module: string;
+ name: string;
+ }
+ abstract class Module {
+ static customSections(module: Module, sectionName: string): ArrayBuffer[];
+ static exports(module: Module): ModuleExportDescriptor[];
+ static imports(module: Module): ModuleImportDescriptor[];
+ }
+ type TableKind = "anyfunc" | "externref";
+ interface TableDescriptor {
+ element: TableKind;
+ initial: number;
+ maximum?: number;
+ }
+ class Table {
+ constructor(descriptor: TableDescriptor, value?: any);
+ readonly length: number;
+ get(index: number): any;
+ grow(delta: number, value?: any): number;
+ set(index: number, value?: any): void;
+ }
+ function instantiate(module: Module, imports?: Imports): Promise;
+ function validate(bytes: BufferSource): boolean;
+}
+/**
+ * This ServiceWorker API interface represents the global execution context of a service worker.
+ * Available only in secure contexts.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope)
+ */
+interface ServiceWorkerGlobalScope extends WorkerGlobalScope {
+ DOMException: typeof DOMException;
+ WorkerGlobalScope: typeof WorkerGlobalScope;
+ btoa(data: string): string;
+ atob(data: string): string;
+ setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
+ setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+ clearTimeout(timeoutId: number | null): void;
+ setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
+ setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+ clearInterval(timeoutId: number | null): void;
+ queueMicrotask(task: Function): void;
+ structuredClone(value: T, options?: StructuredSerializeOptions): T;
+ reportError(error: any): void;
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
+ self: ServiceWorkerGlobalScope;
+ crypto: Crypto;
+ caches: CacheStorage;
+ scheduler: Scheduler;
+ performance: Performance;
+ Cloudflare: Cloudflare;
+ readonly origin: string;
+ Event: typeof Event;
+ ExtendableEvent: typeof ExtendableEvent;
+ CustomEvent: typeof CustomEvent;
+ PromiseRejectionEvent: typeof PromiseRejectionEvent;
+ FetchEvent: typeof FetchEvent;
+ TailEvent: typeof TailEvent;
+ TraceEvent: typeof TailEvent;
+ ScheduledEvent: typeof ScheduledEvent;
+ MessageEvent: typeof MessageEvent;
+ CloseEvent: typeof CloseEvent;
+ ReadableStreamDefaultReader: typeof ReadableStreamDefaultReader;
+ ReadableStreamBYOBReader: typeof ReadableStreamBYOBReader;
+ ReadableStream: typeof ReadableStream;
+ WritableStream: typeof WritableStream;
+ WritableStreamDefaultWriter: typeof WritableStreamDefaultWriter;
+ TransformStream: typeof TransformStream;
+ ByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy;
+ CountQueuingStrategy: typeof CountQueuingStrategy;
+ ErrorEvent: typeof ErrorEvent;
+ EventSource: typeof EventSource;
+ ReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest;
+ ReadableStreamDefaultController: typeof ReadableStreamDefaultController;
+ ReadableByteStreamController: typeof ReadableByteStreamController;
+ WritableStreamDefaultController: typeof WritableStreamDefaultController;
+ TransformStreamDefaultController: typeof TransformStreamDefaultController;
+ CompressionStream: typeof CompressionStream;
+ DecompressionStream: typeof DecompressionStream;
+ TextEncoderStream: typeof TextEncoderStream;
+ TextDecoderStream: typeof TextDecoderStream;
+ Headers: typeof Headers;
+ Body: typeof Body;
+ Request: typeof Request;
+ Response: typeof Response;
+ WebSocket: typeof WebSocket;
+ WebSocketPair: typeof WebSocketPair;
+ WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair;
+ AbortController: typeof AbortController;
+ AbortSignal: typeof AbortSignal;
+ TextDecoder: typeof TextDecoder;
+ TextEncoder: typeof TextEncoder;
+ navigator: Navigator;
+ Navigator: typeof Navigator;
+ URL: typeof URL;
+ URLSearchParams: typeof URLSearchParams;
+ URLPattern: typeof URLPattern;
+ Blob: typeof Blob;
+ File: typeof File;
+ FormData: typeof FormData;
+ Crypto: typeof Crypto;
+ SubtleCrypto: typeof SubtleCrypto;
+ CryptoKey: typeof CryptoKey;
+ CacheStorage: typeof CacheStorage;
+ Cache: typeof Cache;
+ FixedLengthStream: typeof FixedLengthStream;
+ IdentityTransformStream: typeof IdentityTransformStream;
+ HTMLRewriter: typeof HTMLRewriter;
+}
+declare function addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void;
+declare function removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void;
+/**
+ * Dispatches a synthetic event event to target and returns true if either event's cancelable attribute value is false or its preventDefault() method was not invoked, and false otherwise.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
+ */
+declare function dispatchEvent(event: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap]): boolean;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */
+declare function btoa(data: string): string;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */
+declare function atob(data: string): string;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
+declare function setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
+declare function setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */
+declare function clearTimeout(timeoutId: number | null): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
+declare function setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
+declare function setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */
+declare function clearInterval(timeoutId: number | null): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */
+declare function queueMicrotask(task: Function): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */
+declare function structuredClone(value: T, options?: StructuredSerializeOptions): T;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */
+declare function reportError(error: any): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */
+declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
+declare const self: ServiceWorkerGlobalScope;
+/**
+* The Web Crypto API provides a set of low-level functions for common cryptographic tasks.
+* The Workers runtime implements the full surface of this API, but with some differences in
+* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)
+* compared to those implemented in most browsers.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)
+*/
+declare const crypto: Crypto;
+/**
+* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
+*/
+declare const caches: CacheStorage;
+declare const scheduler: Scheduler;
+/**
+* The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
+* as well as timing of subrequests and other operations.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
+*/
+declare const performance: Performance;
+declare const Cloudflare: Cloudflare;
+declare const origin: string;
+declare const navigator: Navigator;
+interface TestController {
+}
+interface ExecutionContext {
+ waitUntil(promise: Promise): void;
+ passThroughOnException(): void;
+ readonly props: Props;
+}
+type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise;
+type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise;
+type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise;
+interface ExportedHandler {
+ fetch?: ExportedHandlerFetchHandler;
+ tail?: ExportedHandlerTailHandler;
+ trace?: ExportedHandlerTraceHandler;
+ tailStream?: ExportedHandlerTailStreamHandler;
+ scheduled?: ExportedHandlerScheduledHandler;
+ test?: ExportedHandlerTestHandler;
+ email?: EmailExportedHandler;
+ queue?: ExportedHandlerQueueHandler;
+}
+interface StructuredSerializeOptions {
+ transfer?: any[];
+}
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent) */
+declare abstract class PromiseRejectionEvent extends Event {
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise) */
+ readonly promise: Promise;
+ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason) */
+ readonly reason: any;
+}
+declare abstract class Navigator {
+ sendBeacon(url: string, body?: (ReadableStream | string | (ArrayBuffer | ArrayBufferView) | Blob | FormData | URLSearchParams | URLSearchParams)): boolean;
+ readonly userAgent: string;
+ readonly hardwareConcurrency: number;
+ readonly language: string;
+ readonly languages: string[];
+}
+interface AlarmInvocationInfo {
+ readonly isRetry: boolean;
+ readonly retryCount: number;
+}
+interface Cloudflare {
+ readonly compatibilityFlags: Record;
+}
+interface DurableObject {
+ fetch(request: Request): Response | Promise;
+ alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise;
+ webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise;
+ webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise;
+ webSocketError?(ws: WebSocket, error: unknown): void | Promise;
+}
+type DurableObjectStub = Fetcher & {
+ readonly id: DurableObjectId;
+ readonly name?: string;
+};
+interface DurableObjectId {
+ toString(): string;
+ equals(other: DurableObjectId): boolean;
+ readonly name?: string;
+}
+declare abstract class DurableObjectNamespace {
+ newUniqueId(options?: DurableObjectNamespaceNewUniqueIdOptions): DurableObjectId;
+ idFromName(name: string): DurableObjectId;
+ idFromString(id: string): DurableObjectId;
+ get(id: DurableObjectId, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub;
+ getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub;
+ jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace;
+}
+type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high";
+interface DurableObjectNamespaceNewUniqueIdOptions {
+ jurisdiction?: DurableObjectJurisdiction;
+}
+type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me";
+interface DurableObjectNamespaceGetDurableObjectOptions {
+ locationHint?: DurableObjectLocationHint;
+}
+interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> {
+}
+interface DurableObjectState {
+ waitUntil(promise: Promise): void;
+ readonly props: Props;
+ readonly id: DurableObjectId;
+ readonly storage: DurableObjectStorage;
+ container?: Container;
+ blockConcurrencyWhile(callback: () => Promise): Promise;
+ acceptWebSocket(ws: WebSocket, tags?: string[]): void;
+ getWebSockets(tag?: string): WebSocket[];
+ setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void;
+ getWebSocketAutoResponse(): WebSocketRequestResponsePair | null;
+ getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null;
+ setHibernatableWebSocketEventTimeout(timeoutMs?: number): void;
+ getHibernatableWebSocketEventTimeout(): number | null;
+ getTags(ws: WebSocket): string[];
+ abort(reason?: string): void;
+}
+interface DurableObjectTransaction {
+ get(key: string, options?: DurableObjectGetOptions): Promise;
+ get(keys: string[], options?: DurableObjectGetOptions): Promise