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 @@ -17,6 +17,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.116.0",
"@auth0/auth0-react": "^2.23.0",
"@carbonplan/zarr-layer": "^0.8.0",
"@cereusdb/standard": "^0.2.0",
"@clerk/react": "^6.14.1",
Expand Down
234 changes: 234 additions & 0 deletions apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { Auth0Provider, useAuth0, type AppState } from "@auth0/auth0-react";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@geolibre/ui";
import { AlertTriangle, LogOut, User } from "lucide-react";
import { useCallback, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useBeforeUnloadGuard } from "../../hooks/useBeforeUnloadGuard";
import { CALLBACK_PARAMS, stashAuthReturnQuery } from "../../lib/auth-return-url";

interface Auth0GateProps {
/** Tenant (or custom) domain, already normalized to a bare hostname. */
domain: string;
/** The Auth0 application's client ID, which is public by design. */
clientId: string;
children: ReactNode;
}

/**
* The URL Auth0 returns to after a login or logout.
*
* Resolved from the build's base URL rather than the current location, so it is
* one stable value an operator can paste into the Auth0 application's **Allowed
* Callback URLs** and **Allowed Logout URLs** — Auth0 matches those exactly, and
* a per-entry-path value would be unmatchable. Subpath deployments
* (`GEOLIBRE_APP_BASE`) resolve to their own prefix; the relative-base demo
* build resolves against the directory currently being served.
*/
function redirectUri(): string {
return new URL(import.meta.env.BASE_URL || "/", window.location.href).href;
}

/**
* Strip the authorization-code parameters Auth0 appends to the return URL.
*
* They are single-use and meaningless once exchanged, and leaving them in the
* address bar means a reload (or a copied link) re-runs a callback that can only
* fail. `returnTo` carries the URL the visitor asked for before being sent to
* Auth0, so a link with GeoLibre's own query parameters survives the round trip.
*/
function onRedirectCallback(appState?: AppState): void {
const url = new URL(appState?.returnTo ?? window.location.href, window.location.href);
for (const param of CALLBACK_PARAMS) {
url.searchParams.delete(param);
}
window.history.replaceState({}, "", url.toString());
}
Comment thread
giswqs marked this conversation as resolved.

/** Full-screen centered layout shared by the loading, error, and signed-out screens. */
function AuthScreen({ children, alert = false }: { children: ReactNode; alert?: boolean }) {
return (
<main
{...(alert ? { role: "alert" } : {})}
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background p-8 text-center"
>
{children}
</main>
);
}

/** The signed-in user's avatar, with a menu offering sign-out. */
function UserMenu() {
const { t } = useTranslation();
const { user, logout } = useAuth0();
const label = user?.name || user?.email || user?.nickname;
return (
<div className="fixed end-2 top-2 z-[100]">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("auth.account")}
className="h-8 w-8 overflow-hidden rounded-full border border-border bg-background p-0 shadow-sm"
>
{user?.picture ? (
// Auth0 serves the avatar from the identity provider (Gravatar, a
// social login). referrerPolicy keeps the deployment URL out of
// that request; a broken image just falls back to the icon below.
<img
src={user.picture}
alt=""
referrerPolicy="no-referrer"
className="h-full w-full object-cover"
/>
) : (
<User className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{label ? (
<>
<DropdownMenuLabel className="truncate text-start font-normal">
{label}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem
onSelect={() => {
void logout({ logoutParams: { returnTo: redirectUri() } });
}}
>
<LogOut className="me-2 h-4 w-4" />
{t("auth.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

/** The three signed-out states: still resolving, failed, or waiting on the visitor. */
function Auth0Screens({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const { isLoading, isAuthenticated, error, loginWithRedirect } = useAuth0();

// Preserve the URL the visitor arrived on — Auth0 returns to the registered
// callback URL, which would otherwise drop a shared `?project=…` link.
// `returnTo` restores the address bar once the SDK has processed the callback;
// the stash additionally puts the query back *before* the next load reads it,
// for the settings resolved during boot (`?locale=`, `?theme=`). See
// lib/auth-return-url.ts.
const signIn = useCallback(() => {
stashAuthReturnQuery();
void loginWithRedirect({ appState: { returnTo: window.location.href } });

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.

Minor: void loginWithRedirect(...) here and void logout(...) at line 108 have no .catch(). If either promise rejects before the navigation fires (e.g. PKCE/crypto setup failure, a blocked redirect), it becomes an unhandled promise rejection with no user-visible feedback — the button just appears to do nothing. Other fire-and-forget calls in this codebase (e.g. the dynamic imports in main.tsx) attach a .catch() that at least console.errors. Low severity since this mirrors an SDK call that almost always navigates away successfully, but worth a .catch((error) => console.error(...)) for consistency.

Confidence: low.

}, [loginWithRedirect]);

if (isLoading) {
return (
<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>
);
}

// Covers an unreachable tenant as much as a login Auth0 itself refused (an
// Action denying an unapproved user answers `access_denied`). Without this the
// gate would sit on the sign-in card with no hint of why nothing happened.
if (error) {
return (
<AuthScreen alert>
<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">
{error.message || t("auth.unavailableDescription")}
</p>
</div>
<Button onClick={signIn}>{t("auth.retry")}</Button>
</AuthScreen>
);
}

if (!isAuthenticated) {
return (
<AuthScreen>
<div className="space-y-1">
<h1 className="text-lg font-semibold">{t("auth.signInTitle")}</h1>
<p className="max-w-md text-sm text-muted-foreground">{t("auth.signInDescription")}</p>
</div>
<Button onClick={signIn}>{t("auth.signIn")}</Button>
</AuthScreen>
);
}

return (
<>
{children}
<UserMenu />
</>
);
}

/**
* Optional whole-app sign-in gate for hosted web deployments, backed by Auth0.
*
* The sibling of {@link ../auth/ClerkGate.ClerkGate}: the two are configured
* independently and only one is ever loaded, since this module is dynamically
* imported only when an Auth0 domain and client ID are configured. Normal web,
* Tauri, mobile, and embedded builds initialize neither.
*
* Auth0 has no drop-in embedded sign-in card, so this uses Universal Login: the
* visitor is redirected to the tenant's hosted login page and returned here.
* That is Auth0's supported flow — embedded cross-origin login depends on
* third-party cookies that browsers now block.
*
* It gates *rendering* only, and is not a server authorization boundary: the
* deployment must still validate sessions (or another credential) at the reverse
* proxy for `/sidecar`, `/ai`, and any other upstream service. See the Auth0
* section of docs/getting-started.md.
*/
export function Auth0Gate({ domain, clientId, children }: Auth0GateProps) {
// Keep the unsaved-work prompt alive across the signed-out screens, for the
// same reason ClerkGate does: <App /> mounts the same guard, but it unmounts
// the moment the session ends — on an expiry as much as on a sign-out click —
// and the project state survives in the module-scope store, so the tab could
// otherwise be closed with unsaved changes and no "Leave site?" prompt.
useBeforeUnloadGuard();
return (
<Auth0Provider
domain={domain}
clientId={clientId}
authorizationParams={{ redirect_uri: redirectUri() }}
onRedirectCallback={onRedirectCallback}
// Persist the session across reloads. The default in-memory cache would
// restore it through a hidden silent-authentication iframe, which fails
// wherever third-party cookies are blocked (Safari by default), sending
// the visitor back to the sign-in card after every refresh.
//
// The trade is that the cached entry outlives the tab and is readable by
// anything running on this origin, plugins included. What it holds is
// bounded on purpose: no API audience is requested, so it is an identity
// assertion that opens no upstream service on its own, and
// `useRefreshTokens` is left off, so no refresh token is stored — renewal
// goes back through silent authentication against the tenant, which only
// succeeds while the Auth0 session cookie is there to answer it.
// Documented for operators in the Auth0 section of docs/getting-started.md.
cacheLocation="localstorage"
Comment thread
giswqs marked this conversation as resolved.
>
<Auth0Screens>{children}</Auth0Screens>
</Auth0Provider>
);
}
7 changes: 6 additions & 1 deletion apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1602,7 +1602,12 @@
"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"
"retry": "Try again",
"signInTitle": "Sign in to GeoLibre",
"signInDescription": "This deployment requires an account. You will be taken to a secure sign-in page and returned here.",
"signIn": "Sign in",
"signOut": "Sign out",
"account": "Account"
},
"basemapExtract": {
"title": "Extract Offline Basemap",
Expand Down
72 changes: 72 additions & 0 deletions apps/geolibre-desktop/src/lib/auth-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { AUTH0_CLIENT_ID_ENV, AUTH0_DOMAIN_ENV, resolveAuth0Config } from "./auth0-auth";
import {
CLERK_PUBLISHABLE_KEY_ENV,
resolveClerkPublishableKey,
resolveClerkWaitlistEnabled,
} from "./clerk-auth";
import { readDeploymentEnv, readDeploymentEnvValue, type EnvRecord } from "./deployment-env";

/** Which optional sign-in gate a hosted deployment has configured, if any. */
export type AuthGateConfig =
| { provider: "clerk"; publishableKey: string; waitlist: boolean }
| { provider: "auth0"; domain: string; clientId: string };

/**
* Pick the sign-in gate for a web deployment.
*
* Clerk and Auth0 are configured independently and only one is ever loaded, so
* a deployment that names both needs a rule. It is the same rule the individual
* settings already follow (see deployment-env.ts): the deployment env is the
* more specific statement, so a provider named there wins over one baked into
* the build. Naming both at the same level keeps Clerk, which shipped first —
* an image built with a Clerk key must not switch providers on its own. The
* Docker entrypoint refuses to boot when both are passed at runtime, so that
* tie only arises from build-time environment variables.
*
* @param webApp - Whether this is the hosted web build. Must be derived from
* the build target alone: a runtime signal the visitor controls (`?embed=1`)
* would let anyone switch a configured gate off.
* @param deploymentEnv - Runtime env; defaults to the value on `window`.
* @param buildEnv - Build-time env; defaults to `import.meta.env`.
* @returns The provider and its settings, or undefined when no gate is configured.
*/
export function resolveAuthGate(
webApp: boolean,
deploymentEnv?: EnvRecord,
buildEnv?: EnvRecord,
): AuthGateConfig | undefined {
if (!webApp) return undefined;
const deployment = deploymentEnv ?? readDeploymentEnv();
const build = buildEnv ?? (import.meta.env as EnvRecord);

const clerkKey = resolveClerkPublishableKey(true, deployment, build);
const auth0 = resolveAuth0Config(true, deployment, build);

const clerk = (): AuthGateConfig => ({
provider: "clerk",
publishableKey: clerkKey!,
// Resolved across both tiers rather than the winning one, so a build-time
// key can still have its waitlist screen turned on at runtime.
waitlist: resolveClerkWaitlistEnabled(true, deployment, build),
});

if (clerkKey && auth0) {
// Raw presence, not a full resolve: this only asks which tier *named* the
// provider. Re-resolving would re-report a partial Auth0 configuration that
// the merged read above already completed from the build env.
const clerkNamedAtRuntime = Boolean(
readDeploymentEnvValue(CLERK_PUBLISHABLE_KEY_ENV, deployment, {}),
);
// Either half counts: the pair can be split across the two tiers, so a
// deployment that supplies only the client ID at runtime has still named
// Auth0 there, and checking the domain alone would hand it back to Clerk.
const auth0NamedAtRuntime =
Boolean(readDeploymentEnvValue(AUTH0_DOMAIN_ENV, deployment, {})) ||
Boolean(readDeploymentEnvValue(AUTH0_CLIENT_ID_ENV, deployment, {}));
if (auth0NamedAtRuntime && !clerkNamedAtRuntime) return { provider: "auth0", ...auth0 };
return clerk();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (clerkKey) return clerk();
if (auth0) return { provider: "auth0", ...auth0 };
return undefined;
}
10 changes: 10 additions & 0 deletions apps/geolibre-desktop/src/lib/auth-return-url-boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Side-effect entry point for restoring a deep link after a sign-in redirect.
//
// This exists as its own module purely so the restore happens at *import* time.
// A call in `main.tsx`'s body would run after every one of its imports has been
// evaluated — including `./i18n`, which resolves the UI language from the query
// string while it loads. Importing this above `./i18n` is what puts the query
// back in time to be read. See `auth-return-url.ts` for why.
import { restoreAuthReturnQuery } from "./auth-return-url";

restoreAuthReturnQuery();
Loading
Loading