-
-
Notifications
You must be signed in to change notification settings - Fork 678
feat: add optional Clerk access gate #1841
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ea1d8e0
4681a1c
527d4d1
3fd834a
66eb0b7
3ef2131
ed24b5a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| // 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. | ||
| */ | ||
|
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} /> | ||
| )} | ||
|
giswqs marked this conversation as resolved.
|
||
| </main> | ||
| </Show> | ||
| <Show when="signed-in"> | ||
|
giswqs marked this conversation as resolved.
|
||
| {children} | ||
|
giswqs marked this conversation as resolved.
|
||
| <div className="fixed end-2 top-2 z-[100]"> | ||
| <UserButton /> | ||
| </div> | ||
| </Show> | ||
|
giswqs marked this conversation as resolved.
|
||
| </ClerkLoaded> | ||
| </ClerkProvider> | ||
| ); | ||
| } | ||
| 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() ?? ""); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confidence: medium. This single glob assumes the whole That's not guaranteed — this very file documents a case where it wasn't (a few lines up): the Worth confirming against the actual |
||
| ]; | ||
| // 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 | ||
|
|
@@ -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), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confidence: low.
ClerkFailed,Show(withwhen="signed-in"/"signed-out"string props), andWaitlist/waitlistUrlare 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/reactis 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.1type declarations to make suretsc -bis 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.