Merge branch 'main' into work-2

This commit is contained in:
Arthur Belleville 2026-04-29 15:46:46 +02:00
commit 77aaca171d
No known key found for this signature in database
249 changed files with 30373 additions and 3450 deletions

View file

@ -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

View file

@ -38,7 +38,6 @@ node_modules
# CI/CD
.github
**/cloudbuild.yaml
**/.circleci
# Misc
**/.turbo
@ -48,4 +47,3 @@ node_modules
**/temp
**/.next
**/.nuxt

55
.github/workflows/ci.yml vendored Normal file
View file

@ -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

View file

@ -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

12
apps/admin/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XTablo Admin</title>
</head>
<body>
<div id="admin-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

46
apps/admin/package.json Normal file
View file

@ -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"
}
}

9
apps/admin/src/App.tsx Normal file
View file

@ -0,0 +1,9 @@
import AppRoutes from "./routes";
export default function App() {
return (
<div className="min-h-screen bg-background text-foreground">
<AppRoutes />
</div>
);
}

View file

@ -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(
<MemoryRouter initialEntries={["/"]}>
<AppRoutes />
</MemoryRouter>
);
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(
<MemoryRouter initialEntries={["/"]}>
<AppRoutes />
</MemoryRouter>
);
const button = await screen.findByRole("button", { name: /lock admin app/i });
fireEvent.click(button);
expect(localStorage.getItem("xtablo-admin-session")).toBeNull();
});
});

View file

@ -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 (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen gap-6 lg:grid-cols-[260px_minmax(0,1fr)] p-6">
<aside className="rounded-[2rem] border border-border bg-card p-5 shadow-sm">
<div className="flex flex-col gap-4">
<ProductionBadge />
<div>
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Operator</p>
<p className="mt-2 text-sm font-medium">{operatorEmail ?? "Unknown operator"}</p>
</div>
<AdminNavigation />
<form action="/__admin/logout" method="post">
<button
className="w-full rounded-2xl border border-border px-3 py-2 text-left text-sm font-medium text-foreground/75 hover:bg-black/5"
onClick={() => logout()}
type="submit"
>
Lock Admin App
</button>
</form>
</div>
</aside>
<section className="rounded-[2rem] border border-border bg-card/80 p-6 shadow-sm">
<Outlet />
</section>
</div>
</div>
);
}

View file

@ -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 (
<nav className="flex flex-col gap-2">
{navItems.map((item) => (
<NavLink
className={({ isActive }) =>
[
"rounded-2xl px-3 py-2 text-sm font-medium",
isActive ? "bg-foreground text-background" : "text-foreground/75 hover:bg-black/5",
].join(" ")
}
end={item.to === "/"}
key={item.to}
to={item.to}
>
{item.label}
</NavLink>
))}
</nav>
);
}

View file

@ -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(
<MemoryRouter initialEntries={["/"]}>
<AppRoutes />
</MemoryRouter>
);
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();
});
});

View file

@ -0,0 +1,51 @@
import { FormEvent, useState } from "react";
type PrivilegedGateProps = {
error?: string | null;
isPending?: boolean;
onUnlock: (accessToken: string) => Promise<unknown>;
};
export function PrivilegedGate({ error = null, isPending = false, onUnlock }: PrivilegedGateProps) {
const [accessToken, setAccessToken] = useState("");
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
await onUnlock(accessToken);
};
return (
<main className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-md rounded-3xl border border-border bg-card p-10 shadow-sm">
<p className="text-sm uppercase tracking-[0.25em] text-foreground/60">Internal Only</p>
<h1 className="mt-4 text-3xl font-semibold">Admin access token required</h1>
<p className="mt-3 text-sm text-foreground/70">
Normal XTablo login is not sufficient. Enter a privileged access token to unlock the
internal admin dashboard.
</p>
<form className="mt-8 space-y-4" onSubmit={handleSubmit}>
<label className="block text-sm font-medium" htmlFor="admin-access-token">
Access token
</label>
<input
id="admin-access-token"
className="w-full rounded-2xl border border-border bg-background px-4 py-3"
onChange={(event) => setAccessToken(event.target.value)}
value={accessToken}
/>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<button
className="w-full rounded-2xl bg-foreground px-4 py-3 text-background disabled:opacity-60"
disabled={isPending || accessToken.trim().length === 0}
type="submit"
>
{isPending ? "Unlocking..." : "Unlock Admin"}
</button>
</form>
</div>
</main>
);
}

View file

@ -0,0 +1,7 @@
export function ProductionBadge() {
return (
<div className="inline-flex items-center gap-2 rounded-full border border-red-300/60 bg-red-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-red-700">
Production
</div>
);
}

View file

@ -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<string, string>) => Promise<void>;
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<Record<string, string>>({});
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 (
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="rounded-[2rem] border border-border bg-card p-5">
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Approved Actions</p>
<div className="mt-4 flex flex-col gap-3">
{actions.map((action) => (
<button
className={`rounded-[1.25rem] border px-4 py-3 text-left ${
action.id === selectedActionId
? "border-foreground bg-foreground text-background"
: "border-border bg-background/70"
}`}
key={action.id}
onClick={() => onSelectActionId(action.id)}
type="button"
>
<p className="text-sm font-semibold">{action.label}</p>
<p className="mt-1 text-sm opacity-80">{action.description}</p>
</button>
))}
</div>
</aside>
<section className="rounded-[2rem] border border-border bg-card p-6">
{selectedAction ? (
<form
className="space-y-5"
onSubmit={(event) => {
event.preventDefault();
void onRun(values);
}}
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Action</p>
<h2 className="mt-2 text-3xl font-semibold">{selectedAction.label}</h2>
<p className="mt-2 max-w-2xl text-sm text-foreground/70">
{selectedAction.description}
</p>
</div>
{tone ? (
<span
className={`rounded-full px-3 py-1 text-xs uppercase tracking-[0.2em] ${
tone.tone === "critical"
? "bg-red-100 text-red-700"
: "bg-amber-100 text-amber-700"
}`}
>
{tone.badge}
</span>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
{selectedAction.fields.map((field) => (
<label className="block" htmlFor={field.id} key={field.id}>
<span className="mb-2 block text-sm font-medium">{field.label}</span>
<input
className="w-full rounded-2xl border border-border bg-background px-3 py-2"
id={field.id}
onChange={(event) =>
setValues((currentValue) => ({
...currentValue,
[field.id]: event.target.value,
}))
}
placeholder={field.placeholder}
required={field.required}
value={values[field.id] ?? ""}
/>
</label>
))}
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{resultMessage ? <p className="text-sm text-emerald-700">{resultMessage}</p> : null}
<button
className="rounded-2xl bg-foreground px-4 py-2 text-background disabled:cursor-not-allowed disabled:opacity-60"
disabled={isRunning}
type="submit"
>
{isRunning ? "Running..." : "Run Action"}
</button>
</form>
) : (
<p className="text-sm text-foreground/70">Select an action to begin.</p>
)}
</section>
</div>
);
}

View file

@ -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 (
<div className="grid min-h-64 grid-cols-[repeat(auto-fit,minmax(56px,1fr))] items-end gap-3">
{points.map((point) => (
<div className="flex h-full flex-col justify-end gap-2" key={point.label}>
<div
className="rounded-t-2xl bg-[linear-gradient(180deg,rgba(20,36,84,0.92),rgba(88,140,126,0.9))]"
style={{ height: `${Math.max((point.value / maxValue) * 180, 12)}px` }}
/>
<div>
<p className="text-xs font-medium">{point.value}</p>
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-foreground/55">
{point.label}
</p>
</div>
</div>
))}
</div>
);
}
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 (
<div className="space-y-4">
<svg
className="w-full overflow-visible rounded-[2rem] border border-border bg-[linear-gradient(180deg,rgba(252,249,244,0.96),rgba(239,235,225,0.96))] p-4"
viewBox={`0 0 ${width} ${height}`}
>
<polyline
fill="none"
points={polyline}
stroke="rgb(23 37 84)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="4"
/>
{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 <circle cx={x} cy={y} fill="rgb(15 118 110)" key={point.label} r="5" />;
})}
</svg>
<div className="grid gap-3 md:grid-cols-3">
{points.map((point) => (
<div
className="rounded-2xl border border-border/80 bg-background/70 px-3 py-2"
key={point.label}
>
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">{point.label}</p>
<p className="mt-1 text-lg font-semibold">{point.value}</p>
</div>
))}
</div>
</div>
);
}
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 (
<div className="grid gap-6 md:grid-cols-[220px_minmax(0,1fr)] md:items-center">
<div
className="mx-auto h-52 w-52 rounded-full border border-border"
style={{
background: `conic-gradient(${gradientStops})`,
}}
>
<div className="m-auto mt-8 flex h-36 w-36 items-center justify-center rounded-full bg-card text-center">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">Total</p>
<p className="mt-1 text-3xl font-semibold">{total}</p>
</div>
</div>
</div>
<div className="space-y-3">
{points.map((point, index) => (
<div
className="flex items-center justify-between rounded-2xl border border-border/80 px-4 py-3"
key={point.label}
>
<div className="flex items-center gap-3">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: palette[index % palette.length] }}
/>
<p className="text-sm font-medium">{point.label}</p>
</div>
<p className="text-sm text-foreground/70">{point.value}</p>
</div>
))}
</div>
</div>
);
}
export function ChartBuilder({
dataset,
datasets,
onSelectDatasetId,
selectedDatasetId,
}: ChartBuilderProps) {
return (
<div className="space-y-6">
<div className="flex flex-wrap gap-3">
{datasets.map((entry) => (
<button
className={`rounded-full border px-4 py-2 text-sm ${
entry.id === selectedDatasetId
? "border-foreground bg-foreground text-background"
: "border-border bg-card"
}`}
key={entry.id}
onClick={() => onSelectDatasetId(entry.id)}
type="button"
>
{entry.label}
</button>
))}
</div>
{dataset ? (
<section className="rounded-[2rem] border border-border bg-card p-6 shadow-[0_24px_80px_rgba(15,23,42,0.08)]">
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Dataset</p>
<h2 className="mt-2 text-3xl font-semibold">{dataset.label}</h2>
<p className="mt-2 max-w-2xl text-sm text-foreground/70">{dataset.description}</p>
</div>
<div className="rounded-2xl border border-border/80 bg-background/70 px-4 py-3 text-right">
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">
{dataset.dimensionLabel} x {dataset.metricLabel}
</p>
<p className="mt-2 text-lg font-semibold">
{dataset.points.reduce((sum, point) => sum + point.value, 0)}
</p>
</div>
</div>
{dataset.chartType === "line" ? <LineChart points={dataset.points} /> : null}
{dataset.chartType === "bar" ? <BarChart points={dataset.points} /> : null}
{dataset.chartType === "donut" ? <DonutChart points={dataset.points} /> : null}
</section>
) : null}
</div>
);
}

View file

@ -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 (
<section className="rounded-[2rem] border border-border bg-card p-6">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Saved Views</p>
<h2 className="mt-2 text-2xl font-semibold">Operator Dashboards</h2>
</div>
<div className="mt-6 grid gap-4">
{dashboards.map((dashboard) => (
<button
className="rounded-[1.5rem] border border-border/80 bg-[linear-gradient(135deg,rgba(255,255,255,0.74),rgba(240,236,227,0.98))] p-4 text-left"
key={dashboard.id}
onClick={() => onOpen(dashboard.datasetId)}
type="button"
>
<p className="text-sm font-semibold">{dashboard.label}</p>
<p className="mt-2 text-sm text-foreground/70">{dashboard.description}</p>
</button>
))}
</div>
</section>
);
}

View file

@ -0,0 +1,47 @@
import type { AdminTableMeta } from "@xtablo/shared-types";
type AdminGridProps = {
meta: AdminTableMeta | null;
onSelectRow: (row: Record<string, string | boolean | null>) => void;
rows: Record<string, string | boolean | null>[];
selectedRowId: string | null;
};
export function AdminGrid({ meta, onSelectRow, rows, selectedRowId }: AdminGridProps) {
if (!meta) {
return null;
}
return (
<div className="overflow-hidden rounded-3xl border border-border bg-card">
<table className="min-w-full border-collapse">
<thead>
<tr className="border-b border-border bg-black/5 text-left">
{meta.columns.map((column) => (
<th className="px-4 py-3 text-sm font-medium" key={column.id}>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr
className={`cursor-pointer border-b border-border/60 last:border-b-0 ${
String(row[meta.primaryKey] ?? "") === selectedRowId ? "bg-black/5" : ""
}`}
key={`${row[meta.primaryKey] ?? row.id ?? index}`}
onClick={() => onSelectRow(row)}
>
{meta.columns.map((column) => (
<td className="px-4 py-3 text-sm" key={column.id}>
{String(row[column.id] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -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(
<RowEditForm
columns={[
{ id: "first_name", label: "First name" },
{ id: "email", label: "Email" },
]}
editableFields={["first_name"]}
onSave={onSave}
record={{
email: "test_owner@example.com",
first_name: "Test",
id: "user-1",
}}
/>
);
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",
})
);
});
});

View file

@ -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<string, string | boolean | null>) => Promise<void>;
record: Record<string, string | boolean | null>;
};
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<HTMLFormElement>) => {
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 (
<form className="space-y-4" onSubmit={handleSubmit}>
{editableColumns.map((column) => (
<label className="block" htmlFor={column.id} key={column.id}>
<span className="mb-2 block text-sm font-medium">{column.label}</span>
<input
className="w-full rounded-2xl border border-border px-3 py-2"
id={column.id}
onChange={(event) =>
setDraft((currentValue) => ({
...currentValue,
[column.id]: event.target.value,
}))
}
value={String(draft[column.id] ?? "")}
/>
</label>
))}
<button className="rounded-2xl bg-foreground px-4 py-2 text-background" type="submit">
Review Changes
</button>
{showDiff ? (
<div className="rounded-3xl border border-border bg-card p-4">
<p className="text-sm font-semibold uppercase tracking-[0.2em]">Before</p>
{changedFields.map((column) => (
<p key={`${column.id}-before`}>
{column.label}: {String(record[column.id] ?? "")}
</p>
))}
<p className="mt-4 text-sm font-semibold uppercase tracking-[0.2em]">After</p>
{changedFields.map((column) => (
<p key={`${column.id}-after`}>
{column.label}: {String(draft[column.id] ?? "")}
</p>
))}
{!hasChanges ? (
<p className="mt-4 text-sm text-foreground/70">No changes to save yet.</p>
) : null}
<div className="mt-4 flex gap-3">
<button
className="rounded-2xl border border-border px-4 py-2"
onClick={() => setShowDiff(false)}
type="button"
>
Keep Editing
</button>
<button
className="rounded-2xl bg-foreground px-4 py-2 text-background disabled:cursor-not-allowed disabled:opacity-60"
disabled={!hasChanges || isSaving}
onClick={() => void handleSave()}
type="button"
>
{isSaving ? "Saving..." : "Confirm Update"}
</button>
</div>
</div>
) : null}
</form>
);
}

View file

@ -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<AdminActionSummary[]>([]);
const [selectedActionId, setSelectedActionId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRunning, setIsRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [resultMessage, setResultMessage] = useState<string | null>(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<string, string>) => {
if (!selectedActionId) {
return;
}
setIsRunning(true);
setError(null);
setResultMessage(null);
try {
const response = await adminApi.post<AdminActionRunResponse>(
`/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,
};
}

View file

@ -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<AdminDatasetSummary[]>([]);
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null);
const [dataset, setDataset] = useState<AdminDatasetResult | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<AdminDatasetResult>(
`/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,
};
}

View file

@ -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<AdminOverviewResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const loadOverview = async () => {
try {
const response = await adminApi.get<AdminOverviewResponse>("/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,
};
}

View file

@ -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<StoredAdminSession | null>(() => getStoredAdminSession());
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setSession(getStoredAdminSession());
}, []);
const unlock = async (accessToken: string) => {
setIsPending(true);
setError(null);
try {
const response = await adminApi.post<AdminSessionResponse>("/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,
};
}

View file

@ -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<string, string | boolean | null>;
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<AdminTableSummary[]>([]);
const [selectedTableId, setSelectedTableId] = useState<string | null>(null);
const [meta, setMeta] = useState<AdminTableMeta | null>(null);
const [rows, setRows] = useState<AdminRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<AdminTableMeta>(`/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<AdminRow>) => {
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,
};
}

View file

@ -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);
}

View file

@ -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");
});
});

27
apps/admin/src/lib/api.ts Normal file
View file

@ -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;
});

30
apps/admin/src/main.css Normal file
View file

@ -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;
}
}

15
apps/admin/src/main.tsx Normal file
View file

@ -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(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

View file

@ -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(<ActionCenterPage />);
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();
});
});

View file

@ -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 (
<div className="space-y-6">
<section className="rounded-[2rem] border border-border bg-[linear-gradient(135deg,rgba(255,245,245,0.95),rgba(245,236,228,0.98))] p-8">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Actions</p>
<h1 className="mt-3 text-4xl font-semibold">Action Center</h1>
<p className="mt-4 max-w-2xl text-sm text-foreground/70">
Run guarded production actions with explicit operator input and audit logging.
</p>
</section>
{isLoading ? <p>Loading actions...</p> : null}
{!isLoading ? (
<ActionRunner
actions={actions}
error={error}
isRunning={isRunning}
onRun={runAction}
onSelectActionId={(actionId) => {
setSelectedActionId(actionId);
setError(null);
setResultMessage(null);
}}
resultMessage={resultMessage}
selectedActionId={selectedActionId}
/>
) : null}
</div>
);
}

View file

@ -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(<AnalyticsStudioPage />);
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();
});
});

View file

@ -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 (
<div className="space-y-6">
<section className="rounded-[2rem] border border-border bg-[radial-gradient(circle_at_top_left,rgba(20,83,45,0.18),transparent_40%),linear-gradient(135deg,rgba(255,251,235,0.95),rgba(244,240,231,0.98))] p-8">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Analytics</p>
<h1 className="mt-3 text-4xl font-semibold">Analytics Studio</h1>
<p className="mt-4 max-w-2xl text-sm text-foreground/70">
Curated production datasets for operators who need charted context before they take action
in the explorer or action center.
</p>
</section>
{isLoading ? <p>Loading analytics...</p> : null}
{error ? <p className="text-red-600">{error}</p> : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<ChartBuilder
dataset={dataset}
datasets={datasets}
onSelectDatasetId={setSelectedDatasetId}
selectedDatasetId={selectedDatasetId}
/>
<SavedDashboardList
dashboards={savedDashboardPresets}
onOpen={(datasetId) => setSelectedDatasetId(datasetId)}
/>
</div>
</div>
);
}

View file

@ -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(
<MemoryRouter>
<DataExplorerPage />
</MemoryRouter>
);
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();
});
});

View file

@ -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<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<string | null>(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<string, string | boolean | null>) => {
if (!selectedRowId) {
return;
}
setIsSaving(true);
setSaveMessage(null);
try {
await updateRow(selectedRowId, changes);
setSaveMessage("Row updated and logged.");
} finally {
setIsSaving(false);
}
};
return (
<main className="min-h-screen p-6">
<div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)_360px]">
<aside className="rounded-3xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/60">Data Explorer</p>
<div className="mt-4 flex flex-col gap-2">
{tables.map((table) => (
<button
className={`rounded-2xl border px-3 py-2 text-left text-sm ${
selectedTableId === table.id
? "border-foreground bg-foreground text-background"
: "border-border"
}`}
key={table.id}
onClick={() => setSelectedTableId(table.id)}
type="button"
>
{table.label}
</button>
))}
</div>
</aside>
<section className="space-y-4">
<header>
<h1 className="text-3xl font-semibold">{meta?.label ?? "Explorer"}</h1>
<p className="mt-2 text-sm text-foreground/70">
Approved production tables exposed through the internal admin registry.
</p>
</header>
{isLoading ? <p>Loading explorer...</p> : null}
{error ? <p className="text-red-600">{error}</p> : null}
{!isLoading && !error ? (
<AdminGrid
meta={meta}
onSelectRow={(row) => {
if (!meta) {
return;
}
setSelectedRowId(String(row[meta.primaryKey] ?? ""));
setSaveMessage(null);
}}
rows={rows}
selectedRowId={selectedRowId}
/>
) : null}
</section>
<aside className="rounded-3xl border border-border bg-card p-5">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/60">Row Detail</p>
{!selectedRow || !meta ? (
<div className="mt-6 space-y-2 text-sm text-foreground/70">
<p>Select a row to inspect record details.</p>
<p>Approved edits are reviewed before they hit production.</p>
</div>
) : (
<div className="mt-6 space-y-6">
<div className="space-y-3">
{meta.columns.map((column) => (
<div className="rounded-2xl border border-border/80 px-3 py-2" key={column.id}>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">
{column.label}
</p>
<p className="mt-1 text-sm">{String(selectedRow[column.id] ?? "")}</p>
</div>
))}
</div>
{meta.editableFields.length > 0 ? (
<div className="space-y-3">
<div>
<h2 className="text-lg font-semibold">Guarded Edit</h2>
<p className="text-sm text-foreground/70">
Editable fields require a reviewed diff and create an audit log entry.
</p>
</div>
<RowEditForm
columns={meta.columns}
editableFields={meta.editableFields}
isSaving={isSaving}
onSave={handleSave}
record={selectedRow}
/>
{saveMessage ? <p className="text-sm text-emerald-700">{saveMessage}</p> : null}
</div>
) : (
<p className="text-sm text-foreground/70">
This table is currently read-only in the admin panel.
</p>
)}
</div>
)}
</aside>
</div>
</main>
);
}

View file

@ -0,0 +1,92 @@
import { Link } from "react-router-dom";
import { useAdminOverview } from "../hooks/useAdminOverview";
export function OperationsHomePage() {
const { error, isLoading, overview } = useAdminOverview();
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-border bg-[linear-gradient(135deg,rgba(17,24,39,0.96),rgba(24,57,76,0.88),rgba(148,88,32,0.74))] p-8 text-white shadow-[0_28px_90px_rgba(15,23,42,0.25)]">
<p className="text-xs uppercase tracking-[0.25em] text-white/65">Operations</p>
<h1 className="mt-3 max-w-3xl text-4xl font-semibold">
Production command deck for privileged Supabase operations.
</h1>
<p className="mt-4 max-w-2xl text-sm text-white/80">
Monitor the current state of users, access grants, and tablos before drilling into
explorer edits, analytics, or controlled admin actions.
</p>
</section>
{isLoading ? <p>Loading operations overview...</p> : null}
{error ? <p className="text-red-600">{error}</p> : null}
{overview ? (
<>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{overview.metrics.map((metric) => (
<article
className="rounded-[1.75rem] border border-border bg-card p-5"
key={metric.id}
>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">
{metric.label}
</p>
<p className="mt-3 text-3xl font-semibold">{metric.value}</p>
<p className="mt-2 text-sm text-foreground/65">{metric.changeLabel}</p>
</article>
))}
</section>
<section className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="rounded-[2rem] border border-border bg-card p-6">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Alerts</p>
<h2 className="mt-2 text-2xl font-semibold">Operational Watchlist</h2>
</div>
<div className="mt-6 grid gap-4">
{overview.alerts.map((alert) => (
<article
className="rounded-[1.5rem] border border-border/80 bg-background/70 p-4"
key={alert.id}
>
<div className="flex items-center gap-3">
<span
className={`rounded-full px-2 py-1 text-[11px] uppercase tracking-[0.18em] ${
alert.severity === "critical"
? "bg-red-100 text-red-700"
: alert.severity === "warning"
? "bg-amber-100 text-amber-700"
: "bg-slate-200 text-slate-700"
}`}
>
{alert.severity}
</span>
<h3 className="text-sm font-semibold">{alert.title}</h3>
</div>
<p className="mt-3 text-sm text-foreground/70">{alert.description}</p>
</article>
))}
</div>
</div>
<div className="rounded-[2rem] border border-border bg-card p-6">
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Shortcuts</p>
<h2 className="mt-2 text-2xl font-semibold">Common Paths</h2>
<div className="mt-6 flex flex-col gap-3">
{overview.shortcuts.map((shortcut) => (
<Link
className="rounded-[1.25rem] border border-border/80 bg-background/70 px-4 py-3 text-sm font-medium"
key={shortcut.id}
to={shortcut.href}
>
{shortcut.label}
</Link>
))}
</div>
</div>
</section>
</>
) : null}
</div>
);
}

View file

@ -0,0 +1,10 @@
export const actionSeverityCopy = {
deactivate_tablo_access: {
badge: "Restriction",
tone: "warning",
},
grant_tablo_admin: {
badge: "Privilege",
tone: "critical",
},
} as const;

View file

@ -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;

View file

@ -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(
<MemoryRouter initialEntries={["/"]}>
<AppRoutes />
</MemoryRouter>
);
expect(screen.getByText(/admin access token/i)).toBeInTheDocument();
});

33
apps/admin/src/routes.tsx Normal file
View file

@ -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 <Outlet />;
}
return <PrivilegedGate error={error} isPending={isPending} onUnlock={unlock} />;
}
export default function AppRoutes() {
return (
<Routes>
<Route element={<AdminEntry />}>
<Route element={<AdminLayout />}>
<Route index element={<OperationsHomePage />} />
<Route path="/explorer" element={<DataExplorerPage />} />
<Route path="/analytics" element={<AnalyticsStudioPage />} />
<Route path="/actions" element={<ActionCenterPage />} />
</Route>
</Route>
</Routes>
);
}

View file

@ -0,0 +1,6 @@
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

31
apps/admin/tsconfig.json Normal file
View file

@ -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": []
}

View file

@ -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"}

29
apps/admin/vite.config.ts Normal file
View file

@ -0,0 +1,29 @@
/// <reference types="vitest" />
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",
},
};
});

View file

@ -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("<html>app</html>", { 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");
});
});

280
apps/admin/worker/index.ts Normal file
View file

@ -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<Response>;
};
};
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 `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XTablo Admin Access</title>
<style>
:root {
color-scheme: light;
font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
background: #f7f3ea;
color: #162033;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.15), transparent 32%),
linear-gradient(135deg, #f9f4ea 0%, #f1ede3 48%, #efe4d1 100%);
}
main {
width: min(92vw, 440px);
background: rgba(255, 252, 247, 0.92);
border: 1px solid rgba(22, 32, 51, 0.12);
border-radius: 28px;
padding: 32px;
box-shadow: 0 24px 80px rgba(22, 32, 51, 0.12);
}
.eyebrow {
font: 600 11px/1.2 ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #6a7280;
}
h1 {
margin: 14px 0 0;
font-size: 2rem;
line-height: 1.05;
}
p {
margin: 14px 0 0;
font: 400 0.98rem/1.55 ui-sans-serif, system-ui, sans-serif;
color: #465062;
}
form {
margin-top: 24px;
}
label {
display: block;
font: 600 0.88rem/1.4 ui-sans-serif, system-ui, sans-serif;
}
input {
width: 100%;
margin-top: 10px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(22, 32, 51, 0.16);
background: white;
font: 400 0.95rem/1.4 ui-monospace, SFMono-Regular, monospace;
box-sizing: border-box;
}
button {
width: 100%;
margin-top: 16px;
border: 0;
border-radius: 18px;
padding: 14px 16px;
background: #172554;
color: white;
font: 600 0.95rem/1.2 ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
}
.error {
margin-top: 16px;
padding: 12px 14px;
border-radius: 16px;
background: #fef2f2;
color: #b91c1c;
font: 600 0.85rem/1.4 ui-sans-serif, system-ui, sans-serif;
}
.footnote {
margin-top: 18px;
font-size: 0.82rem;
}
</style>
</head>
<body>
<main>
<div class="eyebrow">Internal Only</div>
<h1>Internal Admin Access</h1>
<p>
This app is firewalled behind a dedicated app-access token before any admin session
can be established.
</p>
<form action="${ADMIN_ACCESS_PATH}" method="post">
<label for="accessToken">App Access Token</label>
<input id="accessToken" name="accessToken" type="password" autocomplete="off" required />
<button type="submit">Enter Admin App</button>
</form>
${error ? `<div class="error">${error}</div>` : ""}
<p class="footnote">A second privileged token is still required inside the admin shell.</p>
</main>
</body>
</html>`;
}
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);
},
};

14
apps/admin/wrangler.toml Normal file
View file

@ -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

View file

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

View file

@ -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",

View file

@ -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}`;
}

View file

@ -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",
},
]);

View file

@ -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",
});
});
});

View file

@ -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);

View file

@ -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",
});
});
});

View file

@ -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),
});
});
});

View file

@ -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),
});
});
});

View file

@ -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",
});
});
});

View file

@ -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",
});
});
});

View file

@ -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<string, unknown> = { 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);
});
});
});

View file

@ -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", () => {

View file

@ -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",

View file

@ -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}`);
}
}

View file

@ -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<string, AdminTableDefinition> = {
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<string, AdminDatasetDefinition> = {
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<string, AdminActionDefinition> = {
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<string, unknown>[];
}

View file

@ -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<T> = {
success: true;
value: T;
};
export type AdminTokenResult<T> = AdminTokenFailure | AdminTokenSuccess<T>;
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<T>(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<T>(result: AdminTokenResult<T>): result is AdminTokenFailure {
return !result.success;
}
function verifyToken(
token: string,
config: AppConfig,
expectedType: TokenKind
): AdminTokenResult<AdminTokenClaims> {
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<AdminTokenClaims>(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<ExchangeResult> {
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<AdminSessionClaims> {
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,
},
};
}

View file

@ -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 });

View file

@ -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<ClientAccountResult> {
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 };
}

View file

@ -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();

View file

@ -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 = <T>(
result: AdminTokenResult<T>
): result is Extract<AdminTokenResult<T>, { 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;
}

View file

@ -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<BaseEnv>();
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;
};

View file

@ -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<string, unknown>;
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<BaseEnv>();
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;
};

View file

@ -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<BaseEnv>();
const middlewareManager = MiddlewareManager.getInstance();
const isAdminTokenFailure = <T>(
result: AdminTokenResult<T>
): result is Extract<AdminTokenResult<T>, { 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;
};

View file

@ -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<string | null>) {
const counts = new Map<string, number>();
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<string | null>, emptyLabel: string) {
const counts = new Map<string, number>();
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<string, number>([
["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<AdminDatasetPayload> {
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<BaseEnv>();
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;
};

View file

@ -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<BaseEnv>();
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;
};

View file

@ -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<BaseEnv>();
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;
};

View file

@ -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));

View file

@ -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<AuthEnv>();
const publicFactory = createFactory<BaseEnv>();
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 <noreply@xtablo.com>",
to: input.email,
subject: "Configurez votre accès client Xtablo",
html: `
<h2>Vous avez é invité sur Xtablo</h2>
<p>Bonjour,</p>
<p>Créez votre mot de passe via le lien ci-dessous pour accéder à votre espace client :</p>
<p><a href="${input.setupUrl}">Configurer mon mot de passe</a></p>
<p>Ce lien expire dans ${CLIENT_INVITE_EXPIRY_HOURS} heures et ne peut être utilisé qu'une seule fois.</p>
`,
});
};
const sendAccessNotificationEmail = async (
transporter: BaseEnv["Variables"]["transporter"],
input: { email: string; tabloUrl: string }
) => {
await transporter.sendMail({
from: "Xtablo <noreply@xtablo.com>",
to: input.email,
subject: "Vous avez maintenant accès à un nouveau tablo",
html: `
<h2>Vous avez maintenant accès à un tablo</h2>
<p>Bonjour,</p>
<p>Votre accès a é ajouté. Utilisez le lien ci-dessous pour ouvrir directement le tablo :</p>
<p><a href="${input.tabloUrl}">Ouvrir le tablo</a></p>
<p>Si vous n'êtes pas connecté, vous serez redirigé vers la page de connexion.</p>
`,
});
};
/** POST /:tabloId — Create a client invite (admin only) */
const createClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManager.getInstance>
) =>
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<typeof MiddlewareManager.getInstance>) =>
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<AuthEnv>();
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<BaseEnv>();
router.get("/setup/:token", ...getSetupInvite());
router.post("/setup/:token", ...completeSetupInvite());
return router;
};

View file

@ -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());

View file

@ -28,44 +28,44 @@ const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
const supabase = c.get("supabase");
const data = await c.req.json();
const typedPayload = data as PostTablo;
const typedPayload = data as PostTablo;
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", user.id)
.single();
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", user.id)
.single();
if (profileError || !profile?.organization_id) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
if (profileError || !profile?.organization_id) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
const { data: insertedTablo, error } = await supabase
.from("tablos")
.insert({
...typedPayload,
owner_id: user.id,
organization_id: profile.organization_id,
events: undefined,
})
.select()
.single();
const { data: insertedTablo, error } = await supabase
.from("tablos")
.insert({
...typedPayload,
owner_id: user.id,
organization_id: profile.organization_id,
events: undefined,
})
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
if (error) {
return c.json({ error: error.message }, 500);
}
const tabloData = insertedTablo as Tables<"tablos">;
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<typeof MiddlewareManager.getI
return c.json({ error: "You are not authorized to update this tablo" }, 403);
}
const { error } = await supabase
.from("tablos")
.update(tablo)
.eq("id", id)
.select()
.single();
const { error } = await supabase.from("tablos").update(tablo).eq("id", id).select().single();
if (error) {
return c.json({ error: error.message }, 500);
@ -126,26 +121,10 @@ const deleteTablo = factory.createHandlers(async (c) => {
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);

View file

@ -147,47 +147,47 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const postTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManager.getInstance>) =>
@ -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<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManage
console.error("Error creating folder:", error);
return c.json({ error: "Failed to create folder" }, 500);
}
}
);
});
// PUT /tablo-data/:tabloId/folders/:folderId - Update a folder (admin only)
const updateTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManage
console.error("Error updating folder:", error);
return c.json({ error: "Failed to update folder" }, 500);
}
}
);
});
// DELETE /tablo-data/:tabloId/folders/:folderId - Delete a folder (admin only)
const deleteTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManage
console.error("Error deleting folder:", error);
return c.json({ error: "Failed to delete folder" }, 500);
}
}
);
});
// ============================================
// ROUTER SETUP

View file

@ -785,7 +785,6 @@ const removeOrganizationMember = factory.createHandlers(async (c) => {
if (removeAccessError) {
return c.json({ error: "Failed to revoke member tablo permissions" }, 500);
}
}
const { error: inviteCleanupError } = await supabase

View file

@ -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<Secrets> {
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"),

View file

@ -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;

View file

@ -12,6 +12,7 @@ export default defineConfig({
exclude: ["node_modules", "dist"],
reporters: ["verbose"],
pool: "forks",
fileParallelism: false,
},
resolve: {
alias: {

View file

@ -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"],

View file

@ -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

299
apps/clients/biome.json Normal file
View file

@ -0,0 +1,299 @@
{
"root": false,
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"files": {
"ignoreUnknown": true,
"includes": ["src/**/*", "*.{tsx,js,jsx,json}", "vite.config.ts"]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto"
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noBannedTypes": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error",
"noUselessTypeConstraint": "error"
},
"correctness": {
"noChildrenProp": "error",
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useIsNan": "error",
"useJsxKeyInIterable": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"nursery": {},
"security": { "noDangerouslySetInnerHtmlWithChildren": "error" },
"style": {
"noCommonJs": "error",
"noNamespace": "error",
"useArrayLiterals": "error",
"useAsConstAssertion": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCommentText": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateJsxProps": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noExplicitAny": "error",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noMisleadingInstantiator": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeDeclarationMerging": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error",
"useNamespaceKeyword": "error"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
},
"globals": [
"onanimationend",
"ongamepadconnected",
"onlostpointercapture",
"onanimationiteration",
"onkeyup",
"onmousedown",
"onanimationstart",
"onslotchange",
"onprogress",
"ontransitionstart",
"onpause",
"onended",
"onpointerover",
"onscrollend",
"onformdata",
"ontransitionrun",
"onanimationcancel",
"ondrag",
"onchange",
"onbeforeinstallprompt",
"onbeforexrselect",
"onmessage",
"ontransitioncancel",
"onpointerdown",
"onabort",
"onpointerout",
"oncuechange",
"ongotpointercapture",
"onscrollsnapchanging",
"onsearch",
"onsubmit",
"onstalled",
"onsuspend",
"onreset",
"onerror",
"onresize",
"onmouseenter",
"ongamepaddisconnected",
"ondragover",
"onbeforetoggle",
"onmouseover",
"onpagehide",
"onmousemove",
"onratechange",
"onmessageerror",
"onwheel",
"ondevicemotion",
"onauxclick",
"ontransitionend",
"onpaste",
"onpageswap",
"ononline",
"ondeviceorientationabsolute",
"onkeydown",
"onclose",
"onselect",
"onpageshow",
"onpointercancel",
"onbeforematch",
"onpointerrawupdate",
"ondragleave",
"onscrollsnapchange",
"onseeked",
"onwaiting",
"onbeforeunload",
"onplaying",
"onvolumechange",
"ondragend",
"onstorage",
"onloadeddata",
"onfocus",
"onoffline",
"onplay",
"onafterprint",
"onclick",
"oncut",
"onmouseout",
"ondblclick",
"oncanplay",
"onloadstart",
"onappinstalled",
"onpointermove",
"ontoggle",
"oncontextmenu",
"onblur",
"oncancel",
"onbeforeprint",
"oncontextrestored",
"onloadedmetadata",
"onpointerup",
"onlanguagechange",
"oncopy",
"onselectstart",
"onscroll",
"onload",
"ondragstart",
"onbeforeinput",
"oncanplaythrough",
"oninput",
"oninvalid",
"ontimeupdate",
"ondurationchange",
"onselectionchange",
"onmouseup",
"location",
"onkeypress",
"onpointerleave",
"oncontextlost",
"ondrop",
"onsecuritypolicyviolation",
"oncontentvisibilityautostatechange",
"ondeviceorientation",
"onseeking",
"onrejectionhandled",
"onunload",
"onmouseleave",
"onhashchange",
"onpointerenter",
"onmousewheel",
"onunhandledrejection",
"ondragenter",
"onpopstate",
"onpagereveal",
"onemptied"
]
},
"json": {
"parser": { "allowComments": true, "allowTrailingCommas": false },
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"trailingCommas": "none"
}
},
"overrides": [
{ "linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } } },
{ "linter": { "rules": { "style": { "useNodejsImportProtocol": "off" } } } },
{
"linter": {
"rules": {
"style": { "useNodejsImportProtocol": "off" },
"suspicious": { "noExplicitAny": "off" }
}
}
},
{
"includes": ["src/**/*.{ts,tsx}", "*.{ts,tsx}"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": { "useConst": "error" },
"suspicious": {
"noClassAssign": "off",
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"useGetterReturn": "off"
}
}
}
}
]
}

13
apps/clients/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg" href="/icon.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Xtablo — Portail client</title>
</head>
<body>
<div id="client-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

60
apps/clients/package.json Normal file
View file

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

9
apps/clients/src/App.tsx Normal file
View file

@ -0,0 +1,9 @@
import AppRoutes from "./routes";
export default function App() {
return (
<div className="min-h-screen bg-background">
<AppRoutes />
</div>
);
}

View file

@ -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 <Outlet />;
}
if (isCheckingSession) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
</div>
);
}
const redirectUrl = `${location.pathname}${location.search}${location.hash}`;
localStorage.setItem("clients.redirectUrl", redirectUrl);
return <Navigate to="/login" replace />;
}

View file

@ -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(<ClientLayout />);
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(<AppRoutes />, {
route: "/tablo/tablo-1",
testUser: undefined,
});
expect(await screen.findByTestId("auth-card-shell")).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Connexion" })).toBeInTheDocument();
});
});

View file

@ -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 (
<div className="flex h-screen flex-col overflow-hidden bg-background">
<header className="h-[75px] shrink-0 border-b border-[#EAECF0] bg-navbar-background dark:border-gray-700">
<div className="mx-auto flex h-full w-full max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
<span className="text-lg font-semibold text-foreground">Xtablo</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground hidden sm:block">{email}</span>
</div>
<Button variant="outline" size="sm" onClick={handleLogout}>
Déconnexion
</Button>
</div>
</div>
</header>
<main className="flex-1 overflow-auto">
<div className="mx-auto w-full max-w-7xl px-4 sm:px-6">
<Outlet />
</div>
</main>
</div>
);
}

View file

@ -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");
});
});

View file

@ -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;

55
apps/clients/src/i18n.ts Normal file
View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,10 @@
import { createSupabaseClient } from "@xtablo/shared";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing Supabase environment variables");
}
export const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey);

View file

@ -0,0 +1,3 @@
{
"welcome": "Welcome"
}

View file

@ -0,0 +1,3 @@
{
"welcome": "Bienvenue"
}

Some files were not shown because too many files have changed in this diff Show more