Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9e94581
chore: bump to React 19, Chakra v3, framer-motion 12, collecticons-ch…
alukach May 25, 2026
285342f
refactor: migrate root Provider + theme to Chakra v3
alukach May 25, 2026
ba84a21
refactor: sweep Chakra v2 → v3 component API drift
alukach May 25, 2026
7355c13
refactor: migrate toast API to Chakra v3 createToaster
alukach May 25, 2026
8bac0b1
test: update wrappers to ChakraProvider value={defaultSystem}, rebase…
alukach May 25, 2026
52a6cf6
fix: address reviewer findings on Chakra v3 migration sweep
alukach May 25, 2026
73635ab
chore: final lint and cleanup pass for Chakra v3 + React 19 migration
alukach May 25, 2026
e9e3823
fix: stash JsonEditor callback refs to avoid first-render stale closures
alukach May 26, 2026
c1f7d96
fix: move hook calls before conditional throws (Rules of Hooks)
alukach May 26, 2026
35ef829
fix: restructure UserInfo so Avatar isn't nested inside Button
alukach May 26, 2026
a117316
fix: colorPalette prop on Login button, WidgetRadio error message
alukach May 26, 2026
290e552
fix: complete Chakra v3 slot anatomies, ButtonGroup selector, Toast.I…
alukach May 26, 2026
a3af75a
fix: useCollections AbortController, drop fake debounce + duplicated …
alukach May 26, 2026
a60a3e0
fix: forward children through SmartLink asChild, restore Card filled …
alukach May 26, 2026
1d0035f
fix: visual + runtime regressions surfaced during smoke test
alukach May 26, 2026
5aa4a44
chore: remove dead files and unused type defs
alukach May 26, 2026
5ab6f9c
chore: dedup WidgetProps, drop commented-out blocks, fix border short…
alukach May 26, 2026
ad3b412
chore: drop forwardRef boilerplate from pure pass-through wrappers
alukach May 26, 2026
d5cc9fd
chore: update ItemMap component to use StacItem type and improve type…
alukach May 26, 2026
f193080
chore: untrack .env, add to .gitignore
alukach May 26, 2026
0d7af2c
chore: enhance pagination display in CollectionDetail component
alukach May 26, 2026
3f49d00
chore: implement token refresh mechanism in Api and AuthContext for i…
alukach May 26, 2026
9eb7b62
chore: refactor previewAsset logic in ItemMap component for improved …
alukach May 26, 2026
5d46cd6
chore: enhance ItemMap component to handle 204 No Content responses a…
alukach May 26, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@

node_modules
.worktrees
.env
.env.local
.env.*.local


################################################
Expand Down
10 changes: 10 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
import '@testing-library/jest-dom';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import { deserialize, serialize } from 'node:v8';

process.env.REACT_APP_STAC_API = 'https://fake-stac-api.net';

// jsdom (v20, shipped with jest-environment-jsdom@29) does not expose
// `structuredClone`, which Chakra UI v3 calls internally. The jsdom test
// environment replaces Node's globals, so we attach a v8-backed
// implementation if it's missing.
if (typeof globalThis.structuredClone === 'undefined') {
globalThis.structuredClone = <T>(value: T): T => deserialize(serialize(value));
}
10,527 changes: 5,794 additions & 4,733 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lerna": "^8.1.8",
"prettier": "^3.3.3",
Expand Down
20 changes: 10 additions & 10 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
"@parcel/reporter-bundle-analyzer": "^2.12.0",
"@parcel/reporter-bundle-buddy": "^2.12.0",
"@types/babel__core": "^7",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"fancy-log": "^2.0.0",
Expand All @@ -49,9 +49,9 @@
"watcher": "^2.3.1"
},
"dependencies": {
"@chakra-ui/react": "^2.8.2",
"@developmentseed/stac-react": "^0.1.0-alpha.10",
"@devseed-ui/collecticons-chakra": "^3.0.3",
"@chakra-ui/react": "^3.8.1",
"@developmentseed/stac-react": "^1.0.0-alpha.3",
"@devseed-ui/collecticons-chakra": "^4.0.0",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@floating-ui/react": "^0.26.25",
Expand All @@ -70,17 +70,17 @@
"@turf/bbox-polygon": "^7.1.0",
"@types/jest": "^29.5.14",
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"formik": "^2.4.6",
"framer-motion": "^10.16.5",
"framer-motion": "^12.0.0",
"mapbox-gl-draw-rectangle-mode": "^1.0.4",
"maplibre-gl": "^3.6.2",
"oidc-client-ts": "^3.0.1",
"polished": "^4.3.1",
"react": "^18.3.1",
"react": "^19.2.0",
"react-cool-dimensions": "^3.0.1",
"react-dom": "^18.3.1",
"react-dom": "^19.2.0",
"react-hook-form": "^7.53.1",
"react-icons": "^4.12.0",
"react-map-gl": "^7.1.7",
Expand Down
58 changes: 33 additions & 25 deletions packages/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import {
Heading,
Text,
Badge,
Divider,
Fade,
Separator,
Image
} from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import { keyframes } from '@emotion/react';
import { Route, Routes } from 'react-router-dom';
import {
Expand Down Expand Up @@ -53,27 +53,35 @@ export function App() {

return (
<>
<Fade in={isLoading} unmountOnExit>
<Flex
minW='100vw'
minH='100vh'
bg='white'
align='center'
justify='center'
>
<CollecticonCog
size='5em'
color='base.300'
animation={`${rotate} 4s linear infinite`}
/>
<CollecticonCog
ml={-2}
size='5em'
color='base.300'
animation={`${rotate2} 4s linear infinite reverse`}
/>
</Flex>
</Fade>
<AnimatePresence>
{isLoading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Flex
minW='100vw'
minH='100vh'
bg='white'
align='center'
justify='center'
>
<CollecticonCog
boxSize='5em'
color='base.300'
animation={`${rotate} 4s linear infinite`}
/>
<CollecticonCog
ml={-2}
boxSize='5em'
color='base.300'
animation={`${rotate2} 4s linear infinite reverse`}
/>
</Flex>
</motion.div>
)}
</AnimatePresence>
{!isLoading && (
<Container
maxW='container.xl'
Expand All @@ -96,7 +104,7 @@ export function App() {
aspectRatio={1}
borderRadius='md'
/>
<Divider
<Separator
orientation='vertical'
borderColor='base.200a'
h='1rem'
Expand Down Expand Up @@ -160,7 +168,7 @@ function AppFooter() {
</Badge>
</strong>{' '}
</Text>
<Divider orientation='vertical' borderColor='base.200a' h='1em' />
<Separator orientation='vertical' borderColor='base.200a' h='1em' />
{new Date().getFullYear()}
<Text as='span' ml='auto'>
Made with <CollecticonHeart meaningful title='love' /> by{' '}
Expand Down
82 changes: 56 additions & 26 deletions packages/client/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ export const STAC_API_URL: string | undefined =
class Api {
private token: string | undefined;
private stacBaseUrl: string | undefined;
private refreshAuth?: () => Promise<string | undefined>;

constructor(
token: string | undefined,
stacBaseUrl: string | undefined = STAC_API_URL
stacBaseUrl: string | undefined = STAC_API_URL,
refreshAuth?: () => Promise<string | undefined>
) {
this.token = token;
this.stacBaseUrl = stacBaseUrl;
this.refreshAuth = refreshAuth;
}

// Scope guard: only attach Authorization for URLs under the configured STAC
Expand All @@ -32,43 +35,70 @@ class Api {
return url === this.stacBaseUrl || url.startsWith(`${this.stacBaseUrl}/`);
}

fetch(url: string, options: GenericObject = {}) {
// Build the final fetch options, injecting the Authorization header for
// STAC-scoped URLs. `tokenOverride` lets the 401-retry path swap in a
// freshly-refreshed token without rebuilding the instance.
private buildOptions(
url: string,
options: GenericObject,
tokenOverride?: string
): GenericObject {
const t = tokenOverride ?? this.token;
const injected =
this.token && this.isStacUrl(url)
? { Authorization: `Bearer ${this.token}` }
: {};

t && this.isStacUrl(url) ? { Authorization: `Bearer ${t}` } : {};
// Caller-provided headers win on collision, so a caller can override the
// injected Authorization (e.g. force an explicit anonymous request).
const finalOptions: GenericObject = {
return {
...options,
headers: {
...injected,
...(options.headers || {})
}
};
}

return fetch(url, finalOptions).then(async (response) => {
if (response.ok) {
return response.json();
}
async fetch(url: string, options: GenericObject = {}) {
let response = await fetch(url, this.buildOptions(url, options));

const { status, statusText } = response;
const e: ApiError = {
status,
statusText
};
// Some STAC APIs return errors as JSON others as string.
// Clone the response so we can read the body as text if json fails.
const clone = response.clone();
try {
e.detail = await response.json();
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
} catch (err) {
e.detail = await clone.text();
// Self-heal a single 401 on a STAC-authed request: refresh the access
// token via the OIDC client and retry once with the new bearer. Guards
// against infinite loops by only retrying when a refresh callback is
// wired and the original request was for a STAC URL with a token.
if (
response.status === 401 &&
this.refreshAuth &&
this.token &&
this.isStacUrl(url)
) {
const fresh = await this.refreshAuth();
if (fresh && fresh !== this.token) {
response = await fetch(url, this.buildOptions(url, options, fresh));
}
return Promise.reject(e);
});
}

if (response.ok) {
// 204 No Content has no body; calling .json() on it would throw a
// parse error. Return undefined for empty-body responses so callers
// that issue PUT/DELETE against STAC APIs don't have to unwrap.
if (response.status === 204) return undefined;
return response.json();
}

const { status, statusText } = response;
const e: ApiError = {
status,
statusText
};
// Some STAC APIs return errors as JSON others as string.
// Clone the response so we can read the body as text if json fails.
const clone = response.clone();
try {
e.detail = await response.json();
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
} catch (err) {
e.detail = await clone.text();
}
throw e;
}
}

Expand Down
61 changes: 58 additions & 3 deletions packages/client/src/auth/Context.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import React, { createContext, useContext, useMemo } from 'react';
import React, { createContext, useContext, useEffect, useMemo } from 'react';
import {
AuthProvider as OidcAuthProvider,
useAuth as useOidcAuth
} from 'react-oidc-context';
import { WebStorageStateStore } from 'oidc-client-ts';

// Trigger a silent renewal this many seconds before the access token expires.
// Matches oidc-client-ts's default; restated here so the visibility-change
// top-up uses the same threshold.
const REFRESH_THRESHOLD_SECONDS = 60;

export type AuthProfile = {
username?: string;
email?: string;
Expand All @@ -21,14 +26,21 @@ export type AuthContextValue = {
token?: string;
login: (opts?: { redirectUri?: string }) => Promise<void>;
logout: (opts?: { redirectUri?: string }) => Promise<void>;
/**
* Force a silent refresh and return the new access token. Used by the API
* layer to self-heal a 401 caused by a stale token. Resolves to undefined
* if auth is disabled, the user isn't signed in, or the refresh fails.
*/
refreshAuth: () => Promise<string | undefined>;
};

const DisabledContext: AuthContextValue = {
isEnabled: false,
isLoading: false,
isAuthenticated: false,
login: () => Promise.resolve(),
logout: () => Promise.resolve()
logout: () => Promise.resolve(),
refreshAuth: () => Promise.resolve(undefined)
};

const AuthContext = createContext<AuthContextValue>(DisabledContext);
Expand All @@ -44,6 +56,32 @@ const config: { authority: string; clientId: string } | undefined =
function EnabledAuthBridge(props: { children: React.ReactNode }) {
const oidc = useOidcAuth();

// Top up the access token whenever the tab comes back from hibernation.
// automaticSilentRenew uses setTimeout, which browsers can throttle or
// skip entirely while the tab is hidden — so a token that "should" have
// refreshed in the background may be expired when the user returns.
useEffect(() => {
if (!oidc.isAuthenticated) return;

const maybeRefresh = () => {
if (document.visibilityState !== 'visible') return;
const expiresAt = oidc.user?.expires_at; // seconds since epoch
if (!expiresAt) return;
const now = Math.floor(Date.now() / 1000);
if (expiresAt - now > REFRESH_THRESHOLD_SECONDS) return;
// Token is expired or expiring soon — refresh now.
oidc.signinSilent().catch((err) => {
// eslint-disable-next-line no-console
console.warn('Silent token refresh on visibility change failed:', err);
});
};

document.addEventListener('visibilitychange', maybeRefresh);
return () => {
document.removeEventListener('visibilitychange', maybeRefresh);
};
}, [oidc]);

const value = useMemo<AuthContextValue>(() => {
const p = oidc.user?.profile;
const profile: AuthProfile | undefined = p
Expand All @@ -69,7 +107,17 @@ function EnabledAuthBridge(props: { children: React.ReactNode }) {
logout: (opts) =>
oidc.signoutRedirect({
post_logout_redirect_uri: opts?.redirectUri ?? window.location.href
})
}),
refreshAuth: async () => {
try {
const user = await oidc.signinSilent();
return user?.access_token;
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Silent token refresh failed:', err);
return undefined;
}
}
};
}, [oidc]);

Expand Down Expand Up @@ -99,6 +147,13 @@ export function AuthProvider(props: { children: React.ReactNode }) {
post_logout_redirect_uri={
window.location.origin + window.location.pathname
}
// `offline_access` asks the IdP for a refresh token. Without it most
// providers issue access-token-only sessions, and oidc-client-ts falls
// back to iframe-based silent renewal — which is unreliable under
// modern third-party-cookie restrictions. With a refresh token, silent
// renewal uses the refresh_token grant and just works.
scope='openid profile email offline_access'
automaticSilentRenew={true}
userStore={new WebStorageStateStore({ store: window.localStorage })}
onSigninCallback={() => {
// Remove code/state params from URL after successful login
Expand Down
Loading
Loading