refactor: replace hand-rolled OIDC with @logto/react SDK
The hand-rolled OIDC flow (manual PKCE, token exchange, URL construction) was fragile and accumulated multiple bugs. Replaced with the official @logto/react SDK which handles PKCE, token exchange, storage, and refresh automatically. - Add @logto/react SDK dependency - Add LogtoProvider with runtime config in main.tsx - Add TokenSync component bridging SDK tokens to API client - Add useAuth hook replacing Zustand auth store - Simplify LoginPage to signIn(), CallbackPage to useHandleSignInCallback() - Delete pkce.ts and auth-store.ts (replaced by SDK) - Fix react-router-dom → react-router imports in page files - All 17 React Query hooks unchanged (token provider pattern) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
138
ui/package-lock.json
generated
138
ui/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cameleer/design-system": "0.1.31",
|
"@cameleer/design-system": "0.1.31",
|
||||||
|
"@logto/react": "^4.0.13",
|
||||||
"@tanstack/react-query": "^5.90.0",
|
"@tanstack/react-query": "^5.90.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -814,6 +815,52 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@logto/browser": {
|
||||||
|
"version": "3.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@logto/browser/-/browser-3.0.12.tgz",
|
||||||
|
"integrity": "sha512-Ec45IExLYS64bF22wS7dZuWgOMmC2w3FZmWWnVCv2fX2vKQVs0wiI+FE/PlNhEvi8up4AW0zHO4NTGwF7ipFsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@logto/client": "^3.1.7",
|
||||||
|
"@silverhand/essentials": "^2.9.3",
|
||||||
|
"js-base64": "^3.7.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@logto/client": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@logto/client/-/client-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-t/5wXMhiXtmbmP6Cmcl4uMsYetq21vSZuYZztPHXv6QX0dx7lSKBvYi/65ERoS+fmNmtV2/i4Ojf1U41o0TLPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@logto/js": "^6.1.1",
|
||||||
|
"@silverhand/essentials": "^2.9.3",
|
||||||
|
"camelcase-keys": "^9.1.3",
|
||||||
|
"jose": "^5.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@logto/js": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@logto/js/-/js-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-G0lRS7VyOXdB06WYajEh9Kq2E3m11JshiKIKLj6LRPI1qZ06JYQ+Jsej3K60/4OIZMSzUas4FVnY+ORrhDdktA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@silverhand/essentials": "^2.9.3",
|
||||||
|
"camelcase-keys": "^9.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@logto/react": {
|
||||||
|
"version": "4.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@logto/react/-/react-4.0.13.tgz",
|
||||||
|
"integrity": "sha512-CU4rjJmueY0CQoJZq7BDZt/9sQYpxKDwVBrGHR55ljl4zPFF2URJPixqCtEEfWq5/pFk7MEnIOePOYbj7BWKfQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@logto/browser": "^3.0.12",
|
||||||
|
"@silverhand/essentials": "^2.9.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1171,6 +1218,16 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@silverhand/essentials": {
|
||||||
|
"version": "2.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@silverhand/essentials/-/essentials-2.9.3.tgz",
|
||||||
|
"integrity": "sha512-OM9pyGc/yYJMVQw+fFOZZaTHXDWc45sprj+ky+QjC9inhf5w51L1WBmzAwFuYkHAwO1M19fxVf2sTH9KKP48yg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.12.0",
|
||||||
|
"pnpm": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.96.2",
|
"version": "5.96.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz",
|
||||||
@@ -1337,6 +1394,36 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/camelcase-keys": {
|
||||||
|
"version": "9.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz",
|
||||||
|
"integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^8.0.0",
|
||||||
|
"map-obj": "5.0.0",
|
||||||
|
"quick-lru": "^6.1.1",
|
||||||
|
"type-fest": "^4.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001785",
|
"version": "1.0.30001785",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz",
|
||||||
@@ -1505,6 +1592,21 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "5.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
||||||
|
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/js-base64": {
|
||||||
|
"version": "3.7.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
||||||
|
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1557,6 +1659,18 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/map-obj": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -1639,6 +1753,18 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/quick-lru": {
|
||||||
|
"version": "6.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||||
|
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
@@ -1802,6 +1928,18 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/type-fest": {
|
||||||
|
"version": "4.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
|
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cameleer/design-system": "0.1.31",
|
"@cameleer/design-system": "0.1.31",
|
||||||
|
"@logto/react": "^4.0.13",
|
||||||
"@tanstack/react-query": "^5.90.0",
|
"@tanstack/react-query": "^5.90.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { useAuthStore } from '../auth/auth-store';
|
|
||||||
|
|
||||||
const API_BASE = '/api';
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
let tokenProvider: (() => Promise<string | undefined>) | null = null;
|
||||||
|
|
||||||
|
export function setTokenProvider(provider: (() => Promise<string | undefined>) | null) {
|
||||||
|
tokenProvider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
let logoutHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function setLogoutHandler(handler: (() => void) | null) {
|
||||||
|
logoutHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = tokenProvider ? await tokenProvider() : null;
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
...(options.headers as Record<string, string> || {}),
|
...(options.headers as Record<string, string> || {}),
|
||||||
};
|
};
|
||||||
@@ -17,7 +27,7 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
|||||||
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
useAuthStore.getState().logout();
|
if (logoutHandler) logoutHandler();
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
throw new Error('Unauthorized');
|
throw new Error('Unauthorized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,21 @@
|
|||||||
import { useEffect } from 'react';
|
import { useHandleSignInCallback } from '@logto/react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useAuthStore } from './auth-store';
|
|
||||||
import { Spinner } from '@cameleer/design-system';
|
import { Spinner } from '@cameleer/design-system';
|
||||||
import { fetchConfig } from '../config';
|
|
||||||
import { getCodeVerifier } from './pkce';
|
|
||||||
|
|
||||||
export function CallbackPage() {
|
export function CallbackPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const login = useAuthStore((s) => s.login);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { isLoading } = useHandleSignInCallback(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
navigate('/', { replace: true });
|
||||||
const code = params.get('code');
|
});
|
||||||
if (!code) {
|
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeVerifier = getCodeVerifier();
|
if (isLoading) {
|
||||||
if (!codeVerifier) {
|
return (
|
||||||
navigate('/login');
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
return;
|
<Spinner />
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const redirectUri = `${window.location.origin}/callback`;
|
return null;
|
||||||
|
|
||||||
fetchConfig().then((config) => {
|
|
||||||
fetch(`${config.logtoEndpoint}/oidc/token`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
client_id: config.logtoClientId,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
code_verifier: codeVerifier,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.access_token) {
|
|
||||||
login(data.access_token, data.refresh_token || '');
|
|
||||||
navigate('/');
|
|
||||||
} else {
|
|
||||||
navigate('/login');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => navigate('/login'));
|
|
||||||
});
|
|
||||||
}, [login, navigate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { useLogto } from '@logto/react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { Button, Spinner } from '@cameleer/design-system';
|
import { Button, Spinner } from '@cameleer/design-system';
|
||||||
import { fetchConfig } from '../config';
|
|
||||||
import { generatePkce, storeCodeVerifier } from './pkce';
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const [config, setConfig] = useState<{ logtoEndpoint: string; logtoClientId: string; logtoResource: string } | null>(null);
|
const { signIn, isAuthenticated, isLoading } = useLogto();
|
||||||
const [loading, setLoading] = useState(true);
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig().then((c) => {
|
if (isAuthenticated) {
|
||||||
setConfig(c);
|
navigate('/', { replace: true });
|
||||||
setLoading(false);
|
}
|
||||||
});
|
}, [isAuthenticated, navigate]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
@@ -22,23 +21,8 @@ export function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = () => {
|
||||||
if (!config?.logtoClientId) return;
|
signIn(`${window.location.origin}/callback`);
|
||||||
const { codeVerifier, codeChallenge } = await generatePkce();
|
|
||||||
storeCodeVerifier(codeVerifier);
|
|
||||||
const redirectUri = `${window.location.origin}/callback`;
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: config.logtoClientId,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
response_type: 'code',
|
|
||||||
scope: 'openid profile email offline_access',
|
|
||||||
code_challenge: codeChallenge,
|
|
||||||
code_challenge_method: 'S256',
|
|
||||||
});
|
|
||||||
if (config.logtoResource) {
|
|
||||||
params.set('resource', config.logtoResource);
|
|
||||||
}
|
|
||||||
window.location.href = `${config.logtoEndpoint}/oidc/auth?${params}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,13 +32,7 @@ export function LoginPage() {
|
|||||||
<p style={{ marginBottom: '2rem', color: 'var(--color-text-secondary)' }}>
|
<p style={{ marginBottom: '2rem', color: 'var(--color-text-secondary)' }}>
|
||||||
Managed Apache Camel Runtime
|
Managed Apache Camel Runtime
|
||||||
</p>
|
</p>
|
||||||
{config?.logtoClientId ? (
|
<Button onClick={handleLogin}>Sign in with Logto</Button>
|
||||||
<Button onClick={handleLogin}>Sign in with Logto</Button>
|
|
||||||
) : (
|
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>
|
|
||||||
Identity provider not configured. Run the bootstrap script or check HOWTO.md.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { Navigate } from 'react-router';
|
import { Navigate } from 'react-router';
|
||||||
import { useAuthStore } from './auth-store';
|
import { useLogto } from '@logto/react';
|
||||||
|
import { Spinner } from '@cameleer/design-system';
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const { isAuthenticated, isLoading } = useLogto();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
interface AuthState {
|
|
||||||
accessToken: string | null;
|
|
||||||
refreshToken: string | null;
|
|
||||||
username: string | null;
|
|
||||||
roles: string[];
|
|
||||||
tenantId: string | null;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
login: (accessToken: string, refreshToken: string) => void;
|
|
||||||
logout: () => void;
|
|
||||||
loadFromStorage: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJwt(token: string): Record<string, unknown> {
|
|
||||||
try {
|
|
||||||
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
return JSON.parse(atob(base64));
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
|
||||||
accessToken: null,
|
|
||||||
refreshToken: null,
|
|
||||||
username: null,
|
|
||||||
roles: [],
|
|
||||||
tenantId: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
|
|
||||||
login: (accessToken: string, refreshToken: string) => {
|
|
||||||
localStorage.setItem('cameleer-access-token', accessToken);
|
|
||||||
localStorage.setItem('cameleer-refresh-token', refreshToken);
|
|
||||||
const claims = parseJwt(accessToken);
|
|
||||||
const username = (claims.sub as string) || (claims.email as string) || 'user';
|
|
||||||
const roles = (claims.roles as string[]) || [];
|
|
||||||
const tenantId = (claims.organization_id as string) || null;
|
|
||||||
localStorage.setItem('cameleer-username', username);
|
|
||||||
set({ accessToken, refreshToken, username, roles, tenantId, isAuthenticated: true });
|
|
||||||
},
|
|
||||||
|
|
||||||
logout: () => {
|
|
||||||
localStorage.removeItem('cameleer-access-token');
|
|
||||||
localStorage.removeItem('cameleer-refresh-token');
|
|
||||||
localStorage.removeItem('cameleer-username');
|
|
||||||
set({ accessToken: null, refreshToken: null, username: null, roles: [], tenantId: null, isAuthenticated: false });
|
|
||||||
},
|
|
||||||
|
|
||||||
loadFromStorage: () => {
|
|
||||||
const accessToken = localStorage.getItem('cameleer-access-token');
|
|
||||||
const refreshToken = localStorage.getItem('cameleer-refresh-token');
|
|
||||||
const username = localStorage.getItem('cameleer-username');
|
|
||||||
if (accessToken) {
|
|
||||||
const claims = parseJwt(accessToken);
|
|
||||||
const roles = (claims.roles as string[]) || [];
|
|
||||||
const tenantId = (claims.organization_id as string) || null;
|
|
||||||
set({ accessToken, refreshToken, username, roles, tenantId, isAuthenticated: true });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
const VERIFIER_KEY = 'pkce_code_verifier';
|
|
||||||
|
|
||||||
function generateRandomString(length: number): string {
|
|
||||||
const array = new Uint8Array(length);
|
|
||||||
crypto.getRandomValues(array);
|
|
||||||
return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sha256(plain: string): Promise<ArrayBuffer> {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
return crypto.subtle.digest('SHA-256', encoder.encode(plain));
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let binary = '';
|
|
||||||
for (const b of bytes) {
|
|
||||||
binary += String.fromCharCode(b);
|
|
||||||
}
|
|
||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generatePkce(): Promise<{ codeVerifier: string; codeChallenge: string }> {
|
|
||||||
const codeVerifier = generateRandomString(64);
|
|
||||||
const hashed = await sha256(codeVerifier);
|
|
||||||
const codeChallenge = base64UrlEncode(hashed);
|
|
||||||
return { codeVerifier, codeChallenge };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storeCodeVerifier(verifier: string): void {
|
|
||||||
sessionStorage.setItem(VERIFIER_KEY, verifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCodeVerifier(): string | null {
|
|
||||||
const verifier = sessionStorage.getItem(VERIFIER_KEY);
|
|
||||||
sessionStorage.removeItem(VERIFIER_KEY);
|
|
||||||
return verifier;
|
|
||||||
}
|
|
||||||
43
ui/src/auth/useAuth.ts
Normal file
43
ui/src/auth/useAuth.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useLogto } from '@logto/react';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface IdTokenClaims {
|
||||||
|
sub?: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
username?: string;
|
||||||
|
roles?: string[];
|
||||||
|
organization_id?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const { isAuthenticated, isLoading, getIdTokenClaims, signOut, signIn } = useLogto();
|
||||||
|
const [claims, setClaims] = useState<IdTokenClaims | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
getIdTokenClaims().then((c) => setClaims(c as IdTokenClaims));
|
||||||
|
} else {
|
||||||
|
setClaims(null);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, getIdTokenClaims]);
|
||||||
|
|
||||||
|
const username = claims?.username ?? claims?.name ?? claims?.email ?? claims?.sub ?? null;
|
||||||
|
const roles = (claims?.roles as string[]) ?? [];
|
||||||
|
const tenantId = (claims?.organization_id as string) ?? null;
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
signOut(window.location.origin + '/login');
|
||||||
|
}, [signOut]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
username,
|
||||||
|
roles,
|
||||||
|
tenantId,
|
||||||
|
logout,
|
||||||
|
signIn,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router';
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
import { SidebarTree, type SidebarTreeNode } from '@cameleer/design-system';
|
import { SidebarTree, type SidebarTreeNode } from '@cameleer/design-system';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { useEnvironments, useApps } from '../api/hooks';
|
import { useEnvironments, useApps } from '../api/hooks';
|
||||||
import type { EnvironmentResponse } from '../types/api';
|
import type { EnvironmentResponse } from '../types/api';
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ function EnvWithApps({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EnvironmentTree() {
|
export function EnvironmentTree() {
|
||||||
const tenantId = useAuthStore((s) => s.tenantId);
|
const { tenantId } = useAuth();
|
||||||
const { data: environments } = useEnvironments(tenantId ?? '');
|
const { data: environments } = useEnvironments(tenantId ?? '');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Sidebar,
|
Sidebar,
|
||||||
TopBar,
|
TopBar,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { EnvironmentTree } from './EnvironmentTree';
|
import { EnvironmentTree } from './EnvironmentTree';
|
||||||
|
|
||||||
// Simple SVG logo mark for the sidebar header
|
// Simple SVG logo mark for the sidebar header
|
||||||
@@ -91,8 +91,7 @@ function UserIcon() {
|
|||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const username = useAuthStore((s) => s.username);
|
const { username, logout } = useAuth();
|
||||||
const logout = useAuthStore((s) => s.logout);
|
|
||||||
|
|
||||||
const [envSectionOpen, setEnvSectionOpen] = useState(true);
|
const [envSectionOpen, setEnvSectionOpen] = useState(true);
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuth } from '../auth/useAuth';
|
||||||
|
|
||||||
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||||
OWNER: ['tenant:manage', 'billing:manage', 'team:manage', 'apps:manage', 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', 'settings:manage'],
|
OWNER: ['tenant:manage', 'billing:manage', 'team:manage', 'apps:manage', 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', 'settings:manage'],
|
||||||
@@ -8,7 +8,7 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function usePermissions() {
|
export function usePermissions() {
|
||||||
const roles = useAuthStore((s) => s.roles);
|
const { roles } = useAuth();
|
||||||
|
|
||||||
const permissions = new Set<string>();
|
const permissions = new Set<string>();
|
||||||
for (const role of roles) {
|
for (const role of roles) {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { LogtoProvider, useLogto } from '@logto/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { BrowserRouter } from 'react-router';
|
import { BrowserRouter } from 'react-router';
|
||||||
import { ThemeProvider, ToastProvider, BreadcrumbProvider } from '@cameleer/design-system';
|
import { ThemeProvider, ToastProvider, BreadcrumbProvider, Spinner } from '@cameleer/design-system';
|
||||||
import '@cameleer/design-system/style.css';
|
import '@cameleer/design-system/style.css';
|
||||||
import { AppRouter } from './router';
|
import { AppRouter } from './router';
|
||||||
|
import { fetchConfig } from './config';
|
||||||
|
import { setTokenProvider, setLogoutHandler } from './api/client';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -15,16 +18,73 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function TokenSync({ resource }: { resource: string }) {
|
||||||
|
const { getAccessToken, isAuthenticated, signOut } = useLogto();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && resource) {
|
||||||
|
setTokenProvider(() => getAccessToken(resource));
|
||||||
|
} else {
|
||||||
|
setTokenProvider(null);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, getAccessToken, resource]);
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
signOut(window.location.origin + '/login');
|
||||||
|
}, [signOut]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLogoutHandler(handleLogout);
|
||||||
|
return () => setLogoutHandler(null);
|
||||||
|
}, [handleLogout]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [config, setConfig] = useState<{
|
||||||
|
logtoEndpoint: string;
|
||||||
|
logtoClientId: string;
|
||||||
|
logtoResource: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig().then(setConfig);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!config?.logtoClientId) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogtoProvider
|
||||||
|
config={{
|
||||||
|
endpoint: config.logtoEndpoint,
|
||||||
|
appId: config.logtoClientId,
|
||||||
|
resources: config.logtoResource ? [config.logtoResource] : [],
|
||||||
|
scopes: ['openid', 'profile', 'email', 'offline_access'],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TokenSync resource={config.logtoResource} />
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRouter />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</LogtoProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<BreadcrumbProvider>
|
<BreadcrumbProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<App />
|
||||||
<BrowserRouter>
|
|
||||||
<AppRouter />
|
|
||||||
</BrowserRouter>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</BreadcrumbProvider>
|
</BreadcrumbProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
import { useNavigate, useParams, Link } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import type { Column, LogEntry as DSLogEntry } from '@cameleer/design-system';
|
import type { Column, LogEntry as DSLogEntry } from '@cameleer/design-system';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import {
|
import {
|
||||||
useApp,
|
useApp,
|
||||||
useDeployment,
|
useDeployment,
|
||||||
@@ -118,7 +118,7 @@ export function AppDetailPage() {
|
|||||||
const { envId = '', appId = '' } = useParams<{ envId: string; appId: string }>();
|
const { envId = '', appId = '' } = useParams<{ envId: string; appId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const tenantId = useAuthStore((s) => s.tenantId);
|
const { tenantId } = useAuth();
|
||||||
const { canManageApps, canDeploy } = usePermissions();
|
const { canManageApps, canDeploy } = usePermissions();
|
||||||
|
|
||||||
// Active tab
|
// Active tab
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
KpiStrip,
|
KpiStrip,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { useTenant, useEnvironments, useApps } from '../api/hooks';
|
import { useTenant, useEnvironments, useApps } from '../api/hooks';
|
||||||
import { RequirePermission } from '../components/RequirePermission';
|
import { RequirePermission } from '../components/RequirePermission';
|
||||||
import type { EnvironmentResponse, AppResponse } from '../types/api';
|
import type { EnvironmentResponse, AppResponse } from '../types/api';
|
||||||
@@ -41,7 +41,7 @@ function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' {
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const tenantId = useAuthStore((s) => s.tenantId);
|
const { tenantId } = useAuth();
|
||||||
|
|
||||||
const { data: tenant, isLoading: tenantLoading } = useTenant(tenantId ?? '');
|
const { data: tenant, isLoading: tenantLoading } = useTenant(tenantId ?? '');
|
||||||
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
|
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import type { Column } from '@cameleer/design-system';
|
import type { Column } from '@cameleer/design-system';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import {
|
import {
|
||||||
useEnvironments,
|
useEnvironments,
|
||||||
useUpdateEnvironment,
|
useUpdateEnvironment,
|
||||||
@@ -77,7 +77,7 @@ export function EnvironmentDetailPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { envId } = useParams<{ envId: string }>();
|
const { envId } = useParams<{ envId: string }>();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const tenantId = useAuthStore((s) => s.tenantId);
|
const { tenantId } = useAuth();
|
||||||
|
|
||||||
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
|
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
|
||||||
const environment = environments?.find((e) => e.id === envId);
|
const environment = environments?.find((e) => e.id === envId);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import type { Column } from '@cameleer/design-system';
|
import type { Column } from '@cameleer/design-system';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
|
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
|
||||||
import { RequirePermission } from '../components/RequirePermission';
|
import { RequirePermission } from '../components/RequirePermission';
|
||||||
import type { EnvironmentResponse } from '../types/api';
|
import type { EnvironmentResponse } from '../types/api';
|
||||||
@@ -67,7 +67,7 @@ const columns: Column<TableRow>[] = [
|
|||||||
export function EnvironmentsPage() {
|
export function EnvironmentsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const tenantId = useAuthStore((s) => s.tenantId);
|
const { tenantId } = useAuth();
|
||||||
|
|
||||||
const { data: environments, isLoading } = useEnvironments(tenantId ?? '');
|
const { data: environments, isLoading } = useEnvironments(tenantId ?? '');
|
||||||
const createMutation = useCreateEnvironment(tenantId ?? '');
|
const createMutation = useCreateEnvironment(tenantId ?? '');
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
EmptyState,
|
EmptyState,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { useLicense } from '../api/hooks';
|
import { useLicense } from '../api/hooks';
|
||||||
|
|
||||||
const FEATURE_LABELS: Record<string, string> = {
|
const FEATURE_LABELS: Record<string, string> = {
|
||||||
@@ -39,7 +39,7 @@ function daysRemaining(expiresAt: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LicensePage() {
|
export function LicensePage() {
|
||||||
const tenantId = useAuthStore((s) => s.tenantId);
|
const { tenantId } = useAuth();
|
||||||
const { data: license, isLoading, isError } = useLicense(tenantId ?? '');
|
const { data: license, isLoading, isError } = useLicense(tenantId ?? '');
|
||||||
const [tokenExpanded, setTokenExpanded] = useState(false);
|
const [tokenExpanded, setTokenExpanded] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Routes, Route } from 'react-router';
|
import { Routes, Route } from 'react-router';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useAuthStore } from './auth/auth-store';
|
|
||||||
import { LoginPage } from './auth/LoginPage';
|
import { LoginPage } from './auth/LoginPage';
|
||||||
import { CallbackPage } from './auth/CallbackPage';
|
import { CallbackPage } from './auth/CallbackPage';
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
@@ -12,11 +10,6 @@ import { AppDetailPage } from './pages/AppDetailPage';
|
|||||||
import { LicensePage } from './pages/LicensePage';
|
import { LicensePage } from './pages/LicensePage';
|
||||||
|
|
||||||
export function AppRouter() {
|
export function AppRouter() {
|
||||||
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
|
|
||||||
useEffect(() => {
|
|
||||||
loadFromStorage();
|
|
||||||
}, [loadFromStorage]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user