-
-
Notifications
You must be signed in to change notification settings - Fork 679
Add an optional Auth0 sign-in gate alongside Clerk #1853
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 1 commit
a3bb41d
5492fa2
47ddcb8
ebd8011
67191c8
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,221 @@ | ||
| 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"; | ||
|
|
||
| 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()); | ||
| } | ||
|
|
||
| /** 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. | ||
| const signIn = useCallback(() => { | ||
| void loginWithRedirect({ appState: { returnTo: window.location.href } }); | ||
|
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. Minor: 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. Nothing here | ||
| // requests an API audience, so the cached token is an identity assertion | ||
| // and grants no access to any upstream service on its own. | ||
| cacheLocation="localstorage" | ||
|
giswqs marked this conversation as resolved.
|
||
| > | ||
| <Auth0Screens>{children}</Auth0Screens> | ||
| </Auth0Provider> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { 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, {}), | ||
| ); | ||
| const auth0NamedAtRuntime = Boolean(readDeploymentEnvValue(AUTH0_DOMAIN_ENV, deployment, {})); | ||
| if (auth0NamedAtRuntime && !clerkNamedAtRuntime) return { provider: "auth0", ...auth0 }; | ||
| return clerk(); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| if (clerkKey) return clerk(); | ||
| if (auth0) return { provider: "auth0", ...auth0 }; | ||
| return undefined; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.