Merge branch 'main' into work-2
This commit is contained in:
commit
77aaca171d
249 changed files with 30373 additions and 3450 deletions
|
|
@ -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
|
||||
|
|
@ -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
55
.github/workflows/ci.yml
vendored
Normal 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
|
||||
74
.github/workflows/frontend-sourcemaps.yml
vendored
Normal file
74
.github/workflows/frontend-sourcemaps.yml
vendored
Normal 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
12
apps/admin/index.html
Normal 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
46
apps/admin/package.json
Normal 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
9
apps/admin/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
apps/admin/src/components/AdminLayout.test.tsx
Normal file
66
apps/admin/src/components/AdminLayout.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
38
apps/admin/src/components/AdminLayout.tsx
Normal file
38
apps/admin/src/components/AdminLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/admin/src/components/AdminNavigation.tsx
Normal file
30
apps/admin/src/components/AdminNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/admin/src/components/PrivilegedGate.test.tsx
Normal file
61
apps/admin/src/components/PrivilegedGate.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
51
apps/admin/src/components/PrivilegedGate.tsx
Normal file
51
apps/admin/src/components/PrivilegedGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/admin/src/components/ProductionBadge.tsx
Normal file
7
apps/admin/src/components/ProductionBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
apps/admin/src/components/actions/ActionRunner.tsx
Normal file
133
apps/admin/src/components/actions/ActionRunner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
apps/admin/src/components/analytics/ChartBuilder.tsx
Normal file
183
apps/admin/src/components/analytics/ChartBuilder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/admin/src/components/analytics/SavedDashboardList.tsx
Normal file
35
apps/admin/src/components/analytics/SavedDashboardList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/admin/src/components/data-explorer/AdminGrid.tsx
Normal file
47
apps/admin/src/components/data-explorer/AdminGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/admin/src/components/data-explorer/RowEditForm.test.tsx
Normal file
43
apps/admin/src/components/data-explorer/RowEditForm.test.tsx
Normal 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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
112
apps/admin/src/components/data-explorer/RowEditForm.tsx
Normal file
112
apps/admin/src/components/data-explorer/RowEditForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
apps/admin/src/hooks/useAdminActions.ts
Normal file
98
apps/admin/src/hooks/useAdminActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
109
apps/admin/src/hooks/useAdminDatasets.ts
Normal file
109
apps/admin/src/hooks/useAdminDatasets.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
44
apps/admin/src/hooks/useAdminOverview.ts
Normal file
44
apps/admin/src/hooks/useAdminOverview.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
57
apps/admin/src/hooks/useAdminSession.ts
Normal file
57
apps/admin/src/hooks/useAdminSession.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
145
apps/admin/src/hooks/useAdminTables.ts
Normal file
145
apps/admin/src/hooks/useAdminTables.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
38
apps/admin/src/lib/adminSession.ts
Normal file
38
apps/admin/src/lib/adminSession.ts
Normal 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);
|
||||
}
|
||||
15
apps/admin/src/lib/api.test.ts
Normal file
15
apps/admin/src/lib/api.test.ts
Normal 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
27
apps/admin/src/lib/api.ts
Normal 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
30
apps/admin/src/main.css
Normal 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
15
apps/admin/src/main.tsx
Normal 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>
|
||||
);
|
||||
66
apps/admin/src/pages/ActionCenterPage.test.tsx
Normal file
66
apps/admin/src/pages/ActionCenterPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
47
apps/admin/src/pages/ActionCenterPage.tsx
Normal file
47
apps/admin/src/pages/ActionCenterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/admin/src/pages/AnalyticsStudioPage.test.tsx
Normal file
85
apps/admin/src/pages/AnalyticsStudioPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
38
apps/admin/src/pages/AnalyticsStudioPage.tsx
Normal file
38
apps/admin/src/pages/AnalyticsStudioPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
apps/admin/src/pages/DataExplorerPage.test.tsx
Normal file
98
apps/admin/src/pages/DataExplorerPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
143
apps/admin/src/pages/DataExplorerPage.tsx
Normal file
143
apps/admin/src/pages/DataExplorerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
apps/admin/src/pages/OperationsHomePage.tsx
Normal file
92
apps/admin/src/pages/OperationsHomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/admin/src/registry/actions.ts
Normal file
10
apps/admin/src/registry/actions.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export const actionSeverityCopy = {
|
||||
deactivate_tablo_access: {
|
||||
badge: "Restriction",
|
||||
tone: "warning",
|
||||
},
|
||||
grant_tablo_admin: {
|
||||
badge: "Privilege",
|
||||
tone: "critical",
|
||||
},
|
||||
} as const;
|
||||
20
apps/admin/src/registry/datasets.ts
Normal file
20
apps/admin/src/registry/datasets.ts
Normal 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;
|
||||
13
apps/admin/src/routes.test.tsx
Normal file
13
apps/admin/src/routes.test.tsx
Normal 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
33
apps/admin/src/routes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
apps/admin/src/setupTests.ts
Normal file
6
apps/admin/src/setupTests.ts
Normal 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
31
apps/admin/tsconfig.json
Normal 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": []
|
||||
}
|
||||
1
apps/admin/tsconfig.tsbuildinfo
Normal file
1
apps/admin/tsconfig.tsbuildinfo
Normal 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
29
apps/admin/vite.config.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
});
|
||||
88
apps/admin/worker/index.test.ts
Normal file
88
apps/admin/worker/index.test.ts
Normal 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
280
apps/admin/worker/index.ts
Normal 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
14
apps/admin/wrangler.toml
Normal 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
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
22
apps/api/src/__tests__/helpers/adminTokenTestUtils.ts
Normal file
22
apps/api/src/__tests__/helpers/adminTokenTestUtils.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
55
apps/api/src/__tests__/middlewares/adminAuth.test.ts
Normal file
55
apps/api/src/__tests__/middlewares/adminAuth.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
64
apps/api/src/__tests__/routes/adminActions.test.ts
Normal file
64
apps/api/src/__tests__/routes/adminActions.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
64
apps/api/src/__tests__/routes/adminAuth.test.ts
Normal file
64
apps/api/src/__tests__/routes/adminAuth.test.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
});
|
||||
64
apps/api/src/__tests__/routes/adminDatasets.test.ts
Normal file
64
apps/api/src/__tests__/routes/adminDatasets.test.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
});
|
||||
72
apps/api/src/__tests__/routes/adminTableEdits.test.ts
Normal file
72
apps/api/src/__tests__/routes/adminTableEdits.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
100
apps/api/src/__tests__/routes/adminTables.test.ts
Normal file
100
apps/api/src/__tests__/routes/adminTables.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
464
apps/api/src/__tests__/routes/clientInvites.test.ts
Normal file
464
apps/api/src/__tests__/routes/clientInvites.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
40
apps/api/src/helpers/adminAudit.ts
Normal file
40
apps/api/src/helpers/adminAudit.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
146
apps/api/src/helpers/adminRegistry.ts
Normal file
146
apps/api/src/helpers/adminRegistry.ts
Normal 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>[];
|
||||
}
|
||||
201
apps/api/src/helpers/adminTokens.ts
Normal file
201
apps/api/src/helpers/adminTokens.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
32
apps/api/src/routers/admin.ts
Normal file
32
apps/api/src/routers/admin.ts
Normal 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;
|
||||
};
|
||||
104
apps/api/src/routers/adminActions.ts
Normal file
104
apps/api/src/routers/adminActions.ts
Normal 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;
|
||||
};
|
||||
55
apps/api/src/routers/adminAuth.ts
Normal file
55
apps/api/src/routers/adminAuth.ts
Normal 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;
|
||||
};
|
||||
155
apps/api/src/routers/adminDatasets.ts
Normal file
155
apps/api/src/routers/adminDatasets.ts
Normal 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;
|
||||
};
|
||||
141
apps/api/src/routers/adminOverview.ts
Normal file
141
apps/api/src/routers/adminOverview.ts
Normal 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;
|
||||
};
|
||||
139
apps/api/src/routers/adminTables.ts
Normal file
139
apps/api/src/routers/adminTables.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
361
apps/api/src/routers/clientInvites.ts
Normal file
361
apps/api/src/routers/clientInvites.ts
Normal 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 été 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 été 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;
|
||||
};
|
||||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export default defineConfig({
|
|||
exclude: ["node_modules", "dist"],
|
||||
reporters: ["verbose"],
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
7
apps/clients/.env.production
Normal file
7
apps/clients/.env.production
Normal 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
299
apps/clients/biome.json
Normal 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
13
apps/clients/index.html
Normal 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
60
apps/clients/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
apps/clients/public/icon.jpg
Normal file
BIN
apps/clients/public/icon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
9
apps/clients/src/App.tsx
Normal file
9
apps/clients/src/App.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import AppRoutes from "./routes";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
apps/clients/src/components/ClientAuthGate.tsx
Normal file
56
apps/clients/src/components/ClientAuthGate.tsx
Normal 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 />;
|
||||
}
|
||||
43
apps/clients/src/components/ClientLayout.test.tsx
Normal file
43
apps/clients/src/components/ClientLayout.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
52
apps/clients/src/components/ClientLayout.tsx
Normal file
52
apps/clients/src/components/ClientLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/clients/src/envProduction.test.ts
Normal file
15
apps/clients/src/envProduction.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
47
apps/clients/src/i18n.test.ts
Normal file
47
apps/clients/src/i18n.test.ts
Normal 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
55
apps/clients/src/i18n.ts
Normal 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;
|
||||
23
apps/clients/src/lib/rum.ts
Normal file
23
apps/clients/src/lib/rum.ts
Normal 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;
|
||||
10
apps/clients/src/lib/supabase.ts
Normal file
10
apps/clients/src/lib/supabase.ts
Normal 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);
|
||||
3
apps/clients/src/locales/en/booking.json
Normal file
3
apps/clients/src/locales/en/booking.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"welcome": "Welcome"
|
||||
}
|
||||
3
apps/clients/src/locales/fr/booking.json
Normal file
3
apps/clients/src/locales/fr/booking.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"welcome": "Bienvenue"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue