fix: ProtectedRoute spinner fix, TokenSync cleanup, dev hot-reload
- ProtectedRoute: only gate on initial auth load, not every async op - TokenSync: OrgResolver is sole source of org data, remove fetchUserInfo - docker-compose.dev: mount ui/dist for hot-reload Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,11 @@ services:
|
|||||||
cameleer-saas:
|
cameleer-saas:
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./ui/dist:/app/static
|
||||||
environment:
|
environment:
|
||||||
SPRING_PROFILES_ACTIVE: dev
|
SPRING_PROFILES_ACTIVE: dev
|
||||||
|
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
|
||||||
|
|
||||||
cameleer3-server:
|
cameleer3-server:
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
import { Navigate } from 'react-router';
|
import { Navigate } from 'react-router';
|
||||||
import { useLogto } from '@logto/react';
|
import { useLogto } from '@logto/react';
|
||||||
import { Spinner } from '@cameleer/design-system';
|
import { Spinner } from '@cameleer/design-system';
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useLogto();
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { fetchConfig } from './config';
|
|||||||
import { setTokenProvider } from './api/client';
|
import { setTokenProvider } from './api/client';
|
||||||
import { useOrgStore } from './auth/useOrganization';
|
import { useOrgStore } from './auth/useOrganization';
|
||||||
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
@@ -20,33 +21,12 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function TokenSync({ resource }: { resource: string }) {
|
function TokenSync({ resource }: { resource: string }) {
|
||||||
const { getAccessToken, isAuthenticated, fetchUserInfo } = useLogto();
|
const { getAccessToken, isAuthenticated } = useLogto();
|
||||||
const { currentOrgId, setCurrentOrg, setOrganizations } = useOrgStore();
|
const { currentOrgId } = useOrgStore();
|
||||||
|
|
||||||
// After auth, resolve user's organizations from Logto
|
// Set token provider — org-scoped if org selected, plain otherwise.
|
||||||
useEffect(() => {
|
// OrgResolver is the sole source of org data (via /api/me).
|
||||||
if (!isAuthenticated) {
|
// eslint-disable-next-line react-hooks/exhaustive-deps — getAccessToken is unstable (new ref each render)
|
||||||
setOrganizations([]);
|
|
||||||
setCurrentOrg(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchUserInfo().then((info) => {
|
|
||||||
const orgData = (info as Record<string, unknown>)?.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
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && resource) {
|
if (isAuthenticated && resource) {
|
||||||
if (currentOrgId) {
|
if (currentOrgId) {
|
||||||
@@ -57,7 +37,7 @@ function TokenSync({ resource }: { resource: string }) {
|
|||||||
} else {
|
} else {
|
||||||
setTokenProvider(null);
|
setTokenProvider(null);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, getAccessToken, resource, currentOrgId]);
|
}, [isAuthenticated, resource, currentOrgId]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user