Files
warmbly/admin/src/main.tsx
T
Matthew Meszaros 734cb5fe08 feat: make self-hosted onboarding survivable by fixing invite_only, which could not onboard anyone (the accept route is JWT-only, so redeeming the invitation that would create your account required already having one, making the self-host default silently identical to fully closed), threading the invitation token through registration so an invited person lands in the inviting organization instead of a stray workspace, gating SSO just-in-time provisioning behind DISABLE_REGISTRATION (it bypassed the gate entirely, so an instance set to true was still open to anyone the IdP would assert) with SSO_AUTO_PROVISION as the opt-out, correcting the OIDC redirect URL that pointed at /api/v1 against a route at /v1 and 404'd every SSO login, scoping the first-launch exemption so it no longer overrides an explicit lockdown, preserving the remaining TTL when restoring a losing setup token so a public endpoint cannot hold the claim window open forever, replacing a generic 403 with typed registration_invite_only, registration_closed, invitation_invalid, setup_token_invalid and setup_already_complete codes that name the next step, logging why no claim link was issued on an already-claimed instance instead of staying silent, adding a warmblyctl operator CLI (status with health checks and a non-zero exit, reissuable setup-link, user create/list/reset-password/grant-admin/revoke-admin/disable-2fa, hash-password) so a locked-out operator no longer needs hand-written psql, adding read-only instance configuration over 104 environment variables with structural secret redaction and fingerprints, 35 health checks, a database-backed settings tier for the three keys no environment variable owns, hiding the signup form when the config already says invite_only rather than failing the whole form with a toast, and documenting first run, accounts and access, configuration, instance health and troubleshooting alongside the root .env.example the README told operators to write but never shipped (#114)
2026-08-16 05:58:11 +02:00

209 lines
8.9 KiB
TypeScript

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./global.css";
import {
createBrowserRouter,
Navigate,
Outlet,
RouterProvider,
} from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import "@fontsource/inter/400.css";
import "@fontsource/inter/600.css";
import "@fontsource/poppins/600.css";
import "@fontsource/poppins/700.css";
import { Toaster } from "@/components/ui/sonner";
import { AppShell } from "@/components/layout/AppShell";
import { RequireAdmin } from "@/components/layout/RequireAdmin";
import LoginPage from "@/app/auth/LoginPage";
import OverviewPage from "@/app/dashboard/OverviewPage";
import WorkersPage from "@/app/dashboard/WorkersPage";
import WorkerDetailPage from "@/app/dashboard/WorkerDetailPage";
import WorkerNewPage from "@/app/dashboard/WorkerNewPage";
import AuditPage from "@/app/dashboard/AuditPage";
import OrganizationsPage from "@/app/dashboard/OrganizationsPage";
import OrganizationDetailPage from "@/app/dashboard/OrganizationDetailPage";
import UsersPage from "@/app/dashboard/UsersPage";
import UserDetailPage from "@/app/dashboard/UserDetailPage";
import WarmupPage from "@/app/dashboard/WarmupPage";
import WarmupAppealsPage from "@/app/dashboard/WarmupAppealsPage";
import WarmupContentLayout from "@/app/dashboard/warmup-content/WarmupContentLayout";
import WarmupContentOverviewPage from "@/app/dashboard/warmup-content/OverviewPage";
import WarmupContentLibraryPage from "@/app/dashboard/warmup-content/LibraryPage";
import WarmupContentJobsPage from "@/app/dashboard/warmup-content/JobsPage";
import CampaignsPage from "@/app/dashboard/CampaignsPage";
import LimitRequestsPage from "@/app/dashboard/LimitRequestsPage";
import OutreachPage from "@/app/dashboard/OutreachPage";
import AnalyticsPage from "@/app/dashboard/AnalyticsPage";
import MailboxesPage from "@/app/dashboard/MailboxesPage";
import EventsPage from "@/app/dashboard/EventsPage";
import SystemStatusPage from "@/app/dashboard/SystemStatusPage";
import HealthPage from "@/app/dashboard/HealthPage";
import ConfigurationPage from "@/app/dashboard/ConfigurationPage";
import InstanceSettingsPage from "@/app/dashboard/InstanceSettingsPage";
import LimitsPage from "@/app/dashboard/LimitsPage";
import RealtimeManager from "@/lib/realtime/RealtimeManager";
import { RequirePermission } from "@/components/layout/RequirePermission";
import { AdminPerm } from "@/lib/auth/permissions";
import { NotFoundPage } from "@/app/dashboard/StubPages";
// Mirror of web/src/main.tsx's tuned defaults. The admin app sees less
// traffic than the dashboard, so the staleness window is a touch wider:
// - staleTime: 60s — counters and lists are fine for a minute
// - gcTime: 5min — keep navigation snappy on tab-back
// - refetchOnWindowFocus: false — admin tabs sit in background all
// day; we don't want a thundering herd of refetches on focus
// - retry: 1 — same reasoning as the dashboard
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
refetchOnReconnect: "always",
retry: 1,
},
mutations: { retry: 0 },
},
});
const router = createBrowserRouter([
{
path: "/auth/login",
element: <LoginPage />,
},
{
path: "/",
element: <RequireAdmin />,
children: [
{
element: <AppShellWithKey />,
children: [
{ index: true, element: <OverviewPage /> },
{ path: "workers", element: <WorkersPage /> },
// Before :id so "new" isn't parsed as a worker id.
{ path: "workers/new", element: <WorkerNewPage /> },
{ path: "workers/:id", element: <WorkerDetailPage /> },
{ path: "mailboxes", element: <MailboxesPage /> },
{ path: "users", element: <UsersPage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "organizations", element: <OrganizationsPage /> },
{ path: "organizations/:id", element: <OrganizationDetailPage /> },
{ path: "warmup", element: <WarmupPage /> },
{ path: "warmup/appeals", element: <WarmupAppealsPage /> },
{
path: "warmup-content",
element: <WarmupContentLayout />,
children: [
{
index: true,
element: (
<Navigate to="/warmup-content/overview" replace />
),
},
{ path: "overview", element: <WarmupContentOverviewPage /> },
{ path: "library", element: <WarmupContentLibraryPage /> },
{ path: "jobs", element: <WarmupContentJobsPage /> },
],
},
{ path: "campaigns", element: <CampaignsPage /> },
{ path: "limit-requests", element: <LimitRequestsPage /> },
{ path: "outreach", element: <OutreachPage /> },
{ path: "analytics", element: <AnalyticsPage /> },
{ path: "events", element: <EventsPage /> },
// Instance: the operator's view of this deployment. Each
// route carries the same permission bit the backend gates
// its endpoint on.
{
path: "health",
element: (
<RequirePermission
perm={AdminPerm.ViewAnalytics}
permissionLabel="View analytics"
>
<HealthPage />
</RequirePermission>
),
},
{
path: "configuration",
element: (
<RequirePermission
perm={AdminPerm.ManageSettings}
permissionLabel="Manage settings"
>
<ConfigurationPage />
</RequirePermission>
),
},
{
path: "configuration/settings",
element: (
<RequirePermission
perm={AdminPerm.ManageSettings}
permissionLabel="Manage settings"
>
<InstanceSettingsPage />
</RequirePermission>
),
},
{
path: "limits",
element: (
<RequirePermission
perm={AdminPerm.ViewAnalytics}
permissionLabel="View analytics"
>
<LimitsPage />
</RequirePermission>
),
},
{
path: "system",
element: (
<RequirePermission
perm={AdminPerm.ViewAnalytics}
permissionLabel="View analytics"
>
<SystemStatusPage />
</RequirePermission>
),
},
{ path: "audit", element: <AuditPage /> },
{ path: "*", element: <NotFoundPage /> },
],
},
],
},
]);
// AppShell renders an <Outlet/> for the page. RealtimeManager is mounted
// here (inside QueryClientProvider, only for authenticated admins) so the
// admin:platform socket connects once and survives route changes.
function AppShellWithKey() {
return (
<>
<RealtimeManager />
<AppShell />
</>
);
}
// Tiny outlet helper exported so React-Router's typing is happy when
// we need a passthrough.
export { Outlet };
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster position="top-center" />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</StrictMode>,
);