Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/geolibre-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@anthropic-ai/sdk": "^0.115.0",
"@carbonplan/zarr-layer": "^0.7.0",
"@cereusdb/standard": "^0.2.0",
"@clerk/react": "^6.14.1",
"@deck.gl/aggregation-layers": "9.3.7",
"@deck.gl/core": "^9.3.7",
"@deck.gl/geo-layers": "^9.3.7",
Expand Down
136 changes: 136 additions & 0 deletions apps/geolibre-desktop/src/components/auth/ClerkGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
ClerkFailed,
ClerkLoaded,
ClerkLoading,
ClerkProvider,
Show,
SignIn,
UserButton,
Waitlist,
} from "@clerk/react";
import { Button } from "@geolibre/ui";
import { AlertTriangle } from "lucide-react";
import { useSyncExternalStore, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useBeforeUnloadGuard } from "../../hooks/useBeforeUnloadGuard";

interface ClerkGateProps {
publishableKey: string;
/**
* Whether to serve Clerk's waitlist form at {@link WAITLIST_HASH}. Off unless
* the deployment opts in, because it only makes sense for a Clerk instance in
* waitlist sign-up mode.
*/
waitlist?: boolean;
children: ReactNode;
}

// The gate lives on a single page with no router, so the two signed-out screens
Comment on lines +19 to +28

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confidence: low. ClerkFailed, Show (with when="signed-in"/"signed-out" string props), and Waitlist/waitlistUrl are a less commonly documented part of Clerk's React surface — most existing Clerk integrations use <SignedIn>/<SignedOut> wrapper components instead of a generic <Show when=...> primitive. Since @clerk/react is a brand-new dependency for this repo (this PR adds it), it's worth double-checking these exports/props against the actual installed @clerk/react@6.14.1 type declarations to make sure tsc -b is actually type-checking real APIs here rather than something that happens to compile loosely. If this was confirmed via the "production build" step in the test plan, feel free to disregard.

// are told apart by the URL hash. `<SignIn routing="hash" />` owns the root hash
// and writes its own sub-steps (`#/factor-one`, `#/sso-callback`) there, so the
// waitlist takes a distinct prefix that those can never collide with.
const WAITLIST_HASH = "#/waitlist";
const SIGN_IN_HASH = "#/";

function subscribeToHash(onStoreChange: () => void): () => void {
window.addEventListener("hashchange", onStoreChange);
return () => window.removeEventListener("hashchange", onStoreChange);
}

function readHash(): string {
return window.location.hash;
}

/**
* Track the hash so Clerk's own cross-links between the two screens work.
*
* Both links are plain same-document navigations (`#/waitlist` ⇄ `#/`), which
* fire `hashchange` rather than reloading — reloading would re-download the
* whole bundle just to swap one card.
*/
function useOnWaitlistRoute(): boolean {
const hash = useSyncExternalStore(subscribeToHash, readHash, () => "");
return hash.startsWith(WAITLIST_HASH);
}

/**
* Optional whole-app sign-in gate for hosted web deployments.
*
* This module is dynamically imported only when a Clerk key is configured, so
* normal web, Tauri, mobile, and embedded builds do not initialize Clerk.
*
* It gates *rendering* only, and is not a server authorization boundary: the
* deployment must still validate Clerk sessions (or another credential) at the
* reverse proxy for `/sidecar`, `/ai`, and any other upstream service. See the
* Clerk section of docs/getting-started.md. That holds for the waitlist too —
* approving someone in the Clerk Dashboard decides who sees the interface, not
* who can reach the APIs behind it.
*/
Comment thread
giswqs marked this conversation as resolved.
export function ClerkGate({ publishableKey, waitlist = false, children }: ClerkGateProps) {
const { t } = useTranslation();
// Read unconditionally: hooks cannot be called behind a prop check, and the
// subscription is inert when the waitlist is off.
const onWaitlistRoute = useOnWaitlistRoute();
// Keep the unsaved-work prompt alive across the signed-out screens. <App />
// mounts the same guard, but it unmounts the moment the session ends — on an
// expiry or revocation as much as on a sign-out click. The project itself
// survives that (useAppStore is module-scope, so signing back in re-renders
// the same state), but without this the tab could then be closed or reloaded
// with unsaved changes and no "Leave site?" prompt, which is where the work
// would actually be lost. Duplicated while signed in, where both listeners
// read the same isDirty and the browser shows one prompt.
useBeforeUnloadGuard();
return (
<ClerkProvider publishableKey={publishableKey}>
<ClerkLoading>
<div className="flex min-h-screen items-center justify-center bg-background">
<div
aria-hidden="true"
className="h-8 w-8 animate-spin rounded-full border-2 border-muted border-t-primary"
/>
</div>
</ClerkLoading>
{/* Clerk reports a distinct "error" status (a key that no longer resolves,
an unreachable Frontend API, an outage). Both ClerkLoading and
ClerkLoaded render null in that state, so without this branch the gate
leaves a blank page with no way to tell a stuck deployment from a slow
one. */}
<ClerkFailed>
<main
role="alert"
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background p-8 text-center"
>
<AlertTriangle className="h-10 w-10 text-destructive" />
<div className="space-y-1">
<h1 className="text-lg font-semibold">{t("auth.unavailableTitle")}</h1>
<p className="max-w-md text-sm text-muted-foreground">
{t("auth.unavailableDescription")}
</p>
</div>
<Button onClick={() => window.location.reload()}>{t("auth.retry")}</Button>
</main>
</ClerkFailed>
<ClerkLoaded>
<Show when="signed-out">
<main className="flex min-h-screen items-center justify-center bg-background p-4">
{waitlist && onWaitlistRoute ? (
<Waitlist signInUrl={SIGN_IN_HASH} />
) : (
// `waitlistUrl` fills the "Join the waitlist" link Clerk renders
// inside the sign-in card when the instance is in waitlist mode.
// Left unset otherwise, so a restricted (invite-only) deployment
// shows no route to a form nobody can act on.
<SignIn routing="hash" waitlistUrl={waitlist ? WAITLIST_HASH : undefined} />
)}
Comment thread
giswqs marked this conversation as resolved.
</main>
</Show>
<Show when="signed-in">
Comment thread
giswqs marked this conversation as resolved.
{children}
Comment thread
giswqs marked this conversation as resolved.
<div className="fixed end-2 top-2 z-[100]">
<UserButton />
</div>
</Show>
Comment thread
giswqs marked this conversation as resolved.
</ClerkLoaded>
</ClerkProvider>
);
}
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1599,6 +1599,11 @@
"exportCsv": "Export CSV",
"exportGeoParquet": "Export GeoParquet"
},
"auth": {
"unavailableTitle": "Sign-in is unavailable",
"unavailableDescription": "GeoLibre could not reach the sign-in service, so it cannot tell whether you are signed in. This is usually temporary; if it persists, this deployment's authentication settings may need attention.",
"retry": "Try again"
},
"basemapExtract": {
"title": "Extract Offline Basemap",
"url": "Basemap URL",
Expand Down
48 changes: 48 additions & 0 deletions apps/geolibre-desktop/src/lib/clerk-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { readDeploymentEnvValue, type EnvRecord } from "./deployment-env";

export const CLERK_PUBLISHABLE_KEY_ENV = "VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY";

export const CLERK_WAITLIST_ENV = "VITE_GEOLIBRE_CLERK_WAITLIST";

// Values that turn the waitlist screen on, matching the "1"/"true" convention
// of the other opt-in deployment envs (see onboarding-suppression.ts).
const WAITLIST_ENABLED_VALUES = new Set(["1", "true"]);

/**
* Resolve the optional Clerk publishable key for a web deployment.
*
* A missing key keeps authentication completely disabled. Native and embedded
* callers should pass `false` for `webApp` so a build-time environment variable
* cannot accidentally gate an offline application. `webApp` must be derived from
* the build target alone — a runtime signal the visitor controls (a query
* parameter such as `?embed=1`) would let anyone switch the gate off.
*/
export function resolveClerkPublishableKey(
webApp: boolean,
deploymentEnv?: EnvRecord,
buildEnv?: EnvRecord,
): string | undefined {
if (!webApp) return undefined;
return readDeploymentEnvValue(CLERK_PUBLISHABLE_KEY_ENV, deploymentEnv, buildEnv)?.trim();
}

/**
* Whether the sign-in gate should also offer Clerk's waitlist form.
*
* Opt-in, and only meaningful alongside a publishable key: the gate renders the
* waitlist screen only when the deployment asks for it *and* the Clerk instance
* is in waitlist sign-up mode, so an operator running invite-only ("restricted")
* access never shows visitors a form that implies self-service access.
*
* `webApp` carries the same meaning as in {@link resolveClerkPublishableKey} —
* a build-time fact, never a runtime signal the visitor controls.
*/
export function resolveClerkWaitlistEnabled(
webApp: boolean,
deploymentEnv?: EnvRecord,
buildEnv?: EnvRecord,
): boolean {
if (!webApp) return false;
const value = readDeploymentEnvValue(CLERK_WAITLIST_ENV, deploymentEnv, buildEnv);
return WAITLIST_ENABLED_VALUES.has(value?.trim().toLowerCase() ?? "");
}
25 changes: 21 additions & 4 deletions apps/geolibre-desktop/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import i18n, { i18nReady } from "./i18n";
import { installDiagnosticsCapture } from "./lib/diagnostics";
import { isTauri } from "./lib/is-tauri";
import { installStaleChunkReload } from "./lib/stale-chunk-reload";
import { resolveClerkPublishableKey, resolveClerkWaitlistEnabled } from "./lib/clerk-auth";

installDiagnosticsCapture();
// In the desktop build, route geocoding (place search / reverse geocode)
Expand Down Expand Up @@ -95,6 +96,14 @@ if (isTauri()) {
// Recover from chunks orphaned by a web redeploy (stale lazy import → 404). A
// no-op in the desktop build, whose chunks are bundled locally.
installStaleChunkReload();
// "Web app" here means the *build*, never anything the visitor controls: the
// desktop shell and the Jupyter embed wheel are compiled without the gate, but a
// hosted deployment gates every request. In particular this must NOT consult
// `isEmbedded()` — that returns true for a plain `?embed=1` query parameter, so
// any visitor could disable a configured sign-in wall by typing a URL.
const isHostedWebApp = !isTauri() && !__GEOLIBRE_EMBED_BUILD__;
const clerkPublishableKey = resolveClerkPublishableKey(isHostedWebApp);
const clerkWaitlistEnabled = resolveClerkWaitlistEnabled(isHostedWebApp);
// Register the offline/PWA service worker (web build only). `registerSW` is a
// no-op stub in the Tauri desktop and embedded Jupyter builds, where the plugin
// is disabled (see vite.config.ts pwaPlugin).
Expand Down Expand Up @@ -138,18 +147,26 @@ registerSW({
void Promise.all([
import("./App"),
import("./components/common/error-boundaries"),
clerkPublishableKey ? import("./components/auth/ClerkGate") : Promise.resolve(null),
// Gate the first render on i18next being initialized with the active locale's
// (lazily loaded) catalog, so the UI never paints raw translation keys.
i18nReady,
])
.then(([{ default: App }, { AppErrorBoundary }]) => {
.then(([{ default: App }, { AppErrorBoundary }, clerkModule]) => {
const app = <App />;
const authenticatedApp =
clerkPublishableKey && clerkModule ? (
<clerkModule.ClerkGate publishableKey={clerkPublishableKey} waitlist={clerkWaitlistEnabled}>
{app}
</clerkModule.ClerkGate>
) : (
app
);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<I18nextProvider i18n={i18n}>
<AppErrorBoundary>
<TooltipProvider delayDuration={200}>
<App />
</TooltipProvider>
<TooltipProvider delayDuration={200}>{authenticatedApp}</TooltipProvider>
</AppErrorBoundary>
</I18nextProvider>
</React.StrictMode>,
Expand Down
8 changes: 8 additions & 0 deletions apps/geolibre-desktop/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ declare const __GEOLIBRE_STORE_BUILD__: boolean;
// UI compiles them out. false in every other build. See vite.config.ts.
declare const __GEOLIBRE_MAS_BUILD__: boolean;

// True only in the Jupyter embed wheel build (GEOLIBRE_EMBED=1), which is served
// from inside a notebook and must never render a hosted deployment's sign-in
// gate. false in every other build. Deliberately a *build* flag: the runtime
// `isEmbedded()` heuristic accepts a `?embed=1` query parameter, which a visitor
// controls and so cannot decide whether authentication applies. See
// vite.config.ts.
declare const __GEOLIBRE_EMBED_BUILD__: boolean;

// jsDelivr URLs for the PGlite engine and its PostGIS extension, injected by
// vite.config.ts. Only the embed (Jupyter wheel) build reads them, from
// pglite-loader.cdn.ts; web/desktop builds bundle PGlite and never reference
Expand Down
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,10 @@ function pwaPlugin(): Plugin[] {
// is auto-named `i18n-<hash>` and must stay precached, so this must NOT match
// it. English is bundled there, so it stays precached and works offline.
"**/i18n-locale-*.js",
// Optional hosted-web authentication. This chunk is requested only when a
// Clerk publishable key is configured, so public deployments should not
// download it during service-worker installation.
"**/ClerkGate-*.js",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confidence: medium. This single glob assumes the whole import("./components/auth/ClerkGate") subtree (ClerkGate.tsx + @clerk/react + @clerk/shared + @tanstack/query-core + js-cookie + glob-to-regexp, none of which match a manualChunks rule) collapses into one ClerkGate-*.js chunk.

That's not guaranteed — this very file documents a case where it wasn't (a few lines up): the cesium dynamic-import boundary needed two ignore patterns, **/cesium-* and **/Cesium-*, because Rollup emitted a differently-cased facade chunk that the first glob missed. If Rollup splits any of the Clerk-only deps into a separately-named chunk (or emits a facade), it would silently slip back into the PWA precache, defeating the point of this change for public (non-Clerk) deployments.

Worth confirming against the actual dist/assets output of a production build (or better, adding an automated check, since the PR's verification of this was a manual step) rather than relying on the single glob.

];
// Note: the 4 KB public/pyodide/pyodide-worker.js shim is intentionally left
// in the precache (revisioned, so no stale-after-deploy risk). The heavy
Expand Down Expand Up @@ -869,6 +873,7 @@ export default defineConfig({
__GEOLIBRE_VERSION__: JSON.stringify(APP_VERSION),
__GEOLIBRE_STORE_BUILD__: JSON.stringify(IS_STORE_BUILD),
__GEOLIBRE_MAS_BUILD__: JSON.stringify(IS_MAS_BUILD),
__GEOLIBRE_EMBED_BUILD__: JSON.stringify(IS_EMBED),
__PGLITE_CDN_URL__: JSON.stringify(PGLITE_CDN_URL),
__PGLITE_POSTGIS_CDN_URL__: JSON.stringify(PGLITE_POSTGIS_CDN_URL),
__CEREUS_WASM_CDN_URL__: JSON.stringify(CEREUS_WASM_CDN_URL),
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ services:
# them with the public TLS origins when deploying behind an ingress.
GEOLIBRE_SHARE_URL: "${GEOLIBRE_SHARE_URL:-http://localhost:8000}"
GEOLIBRE_COLLAB_URL: "${GEOLIBRE_COLLAB_URL:-ws://localhost:8787}"
GEOLIBRE_CLERK_PUBLISHABLE_KEY: "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}"
GEOLIBRE_CLERK_WAITLIST: "${GEOLIBRE_CLERK_WAITLIST:-}"
depends_on:
geolibre-server:
condition: service_healthy
Expand Down
Loading
Loading