diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 979ddae..6e6c69b 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -13,8 +13,11 @@ services:
cameleer-saas:
ports:
- "8080:8080"
+ volumes:
+ - ./ui/dist:/app/static
environment:
SPRING_PROFILES_ACTIVE: dev
+ SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
cameleer3-server:
ports:
diff --git a/ui/src/auth/ProtectedRoute.tsx b/ui/src/auth/ProtectedRoute.tsx
index 6433b20..0c08c06 100644
--- a/ui/src/auth/ProtectedRoute.tsx
+++ b/ui/src/auth/ProtectedRoute.tsx
@@ -1,11 +1,20 @@
+import { useRef } from 'react';
import { Navigate } from 'react-router';
import { useLogto } from '@logto/react';
import { Spinner } from '@cameleer/design-system';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useLogto();
+ // The Logto SDK sets isLoading=true for EVERY async method (getAccessToken, etc.),
+ // not just initial auth. Only gate on the initial load — once isLoading is false
+ // for the first time, never show the spinner again.
+ const initialLoadDone = useRef(false);
- if (isLoading) {
+ if (!isLoading) {
+ initialLoadDone.current = true;
+ }
+
+ if (!initialLoadDone.current) {
return (
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index 480d4b0..45f7f2c 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -10,6 +10,7 @@ import { fetchConfig } from './config';
import { setTokenProvider } from './api/client';
import { useOrgStore } from './auth/useOrganization';
+
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -20,33 +21,12 @@ const queryClient = new QueryClient({
});
function TokenSync({ resource }: { resource: string }) {
- const { getAccessToken, isAuthenticated, fetchUserInfo } = useLogto();
- const { currentOrgId, setCurrentOrg, setOrganizations } = useOrgStore();
+ const { getAccessToken, isAuthenticated } = useLogto();
+ const { currentOrgId } = useOrgStore();
- // After auth, resolve user's organizations from Logto
- useEffect(() => {
- if (!isAuthenticated) {
- setOrganizations([]);
- setCurrentOrg(null);
- return;
- }
-
- fetchUserInfo().then((info) => {
- const orgData = (info as Record)?.organization_data as
- Array<{ id: string; name: string }> | undefined;
- const orgs = orgData ?? [];
- setOrganizations(orgs.map((o) => ({ id: o.id, name: o.name })));
-
- // Auto-select if user has exactly one org
- if (orgs.length === 1 && !currentOrgId) {
- setCurrentOrg(orgs[0].id);
- }
- }).catch(() => {
- // fetchUserInfo may fail if token is being refreshed
- });
- }, [isAuthenticated]);
-
- // Set token provider — org-scoped if org selected, plain otherwise
+ // Set token provider — org-scoped if org selected, plain otherwise.
+ // OrgResolver is the sole source of org data (via /api/me).
+ // eslint-disable-next-line react-hooks/exhaustive-deps — getAccessToken is unstable (new ref each render)
useEffect(() => {
if (isAuthenticated && resource) {
if (currentOrgId) {
@@ -57,7 +37,7 @@ function TokenSync({ resource }: { resource: string }) {
} else {
setTokenProvider(null);
}
- }, [isAuthenticated, getAccessToken, resource, currentOrgId]);
+ }, [isAuthenticated, resource, currentOrgId]);
return null;
}