Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
232 changes: 232 additions & 0 deletions apps/geolibre-desktop/src/components/auth/Auth0Gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
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 { 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 ["code", "state", "error", "error_description"]) {
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 and `useRefreshTokens`
// is left off, so it is a short-lived identity assertion that cannot be
// renewed and opens no upstream service on its own. 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