From 0843a3338354e1d8df0d5891144965411ddf9df9 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:17:47 +0200 Subject: [PATCH] refactor: replace hand-rolled OIDC with @logto/react SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/package-lock.json | 138 +++++++++++++++++++++++++ ui/package.json | 1 + ui/src/api/client.ts | 18 +++- ui/src/auth/CallbackPage.tsx | 61 +++-------- ui/src/auth/LoginPage.tsx | 48 +++------ ui/src/auth/ProtectedRoute.tsx | 14 ++- ui/src/auth/auth-store.ts | 61 ----------- ui/src/auth/pkce.ts | 38 ------- ui/src/auth/useAuth.ts | 43 ++++++++ ui/src/components/EnvironmentTree.tsx | 4 +- ui/src/components/Layout.tsx | 5 +- ui/src/hooks/usePermissions.ts | 4 +- ui/src/main.tsx | 74 +++++++++++-- ui/src/pages/AppDetailPage.tsx | 6 +- ui/src/pages/DashboardPage.tsx | 6 +- ui/src/pages/EnvironmentDetailPage.tsx | 6 +- ui/src/pages/EnvironmentsPage.tsx | 6 +- ui/src/pages/LicensePage.tsx | 4 +- ui/src/router.tsx | 7 -- 19 files changed, 320 insertions(+), 224 deletions(-) delete mode 100644 ui/src/auth/auth-store.ts delete mode 100644 ui/src/auth/pkce.ts create mode 100644 ui/src/auth/useAuth.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index b360f93..aa88a79 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@cameleer/design-system": "0.1.31", + "@logto/react": "^4.0.13", "@tanstack/react-query": "^5.90.0", "lucide-react": "^1.7.0", "react": "^19.0.0", @@ -814,6 +815,52 @@ "@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": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1171,6 +1218,16 @@ "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": { "version": "5.96.2", "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_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": { "version": "1.0.30001785", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", @@ -1505,6 +1592,21 @@ "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": { "version": "4.0.0", "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" } }, + "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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1639,6 +1753,18 @@ "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": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -1802,6 +1928,18 @@ "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": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/ui/package.json b/ui/package.json index 1a6af20..ac3a918 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@cameleer/design-system": "0.1.31", + "@logto/react": "^4.0.13", "@tanstack/react-query": "^5.90.0", "lucide-react": "^1.7.0", "react": "^19.0.0", diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index c7b9514..28c96ec 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -1,9 +1,19 @@ -import { useAuthStore } from '../auth/auth-store'; - const API_BASE = '/api'; +let tokenProvider: (() => Promise) | null = null; + +export function setTokenProvider(provider: (() => Promise) | null) { + tokenProvider = provider; +} + +let logoutHandler: (() => void) | null = null; + +export function setLogoutHandler(handler: (() => void) | null) { + logoutHandler = handler; +} + async function apiFetch(path: string, options: RequestInit = {}): Promise { - const token = useAuthStore.getState().accessToken; + const token = tokenProvider ? await tokenProvider() : null; const headers: Record = { ...(options.headers as Record || {}), }; @@ -17,7 +27,7 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise const response = await fetch(`${API_BASE}${path}`, { ...options, headers }); if (response.status === 401) { - useAuthStore.getState().logout(); + if (logoutHandler) logoutHandler(); window.location.href = '/login'; throw new Error('Unauthorized'); } diff --git a/ui/src/auth/CallbackPage.tsx b/ui/src/auth/CallbackPage.tsx index e5eb393..c647826 100644 --- a/ui/src/auth/CallbackPage.tsx +++ b/ui/src/auth/CallbackPage.tsx @@ -1,58 +1,21 @@ -import { useEffect } from 'react'; +import { useHandleSignInCallback } from '@logto/react'; import { useNavigate } from 'react-router'; -import { useAuthStore } from './auth-store'; import { Spinner } from '@cameleer/design-system'; -import { fetchConfig } from '../config'; -import { getCodeVerifier } from './pkce'; export function CallbackPage() { const navigate = useNavigate(); - const login = useAuthStore((s) => s.login); - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const code = params.get('code'); - if (!code) { - navigate('/login'); - return; - } + const { isLoading } = useHandleSignInCallback(() => { + navigate('/', { replace: true }); + }); - const codeVerifier = getCodeVerifier(); - if (!codeVerifier) { - navigate('/login'); - return; - } + if (isLoading) { + return ( +
+ +
+ ); + } - const redirectUri = `${window.location.origin}/callback`; - - 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 ( -
- -
- ); + return null; } diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index bc4370c..5fd2c0d 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -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 { fetchConfig } from '../config'; -import { generatePkce, storeCodeVerifier } from './pkce'; export function LoginPage() { - const [config, setConfig] = useState<{ logtoEndpoint: string; logtoClientId: string; logtoResource: string } | null>(null); - const [loading, setLoading] = useState(true); + const { signIn, isAuthenticated, isLoading } = useLogto(); + const navigate = useNavigate(); useEffect(() => { - fetchConfig().then((c) => { - setConfig(c); - setLoading(false); - }); - }, []); + if (isAuthenticated) { + navigate('/', { replace: true }); + } + }, [isAuthenticated, navigate]); - if (loading) { + if (isLoading) { return (
@@ -22,23 +21,8 @@ export function LoginPage() { ); } - const handleLogin = async () => { - if (!config?.logtoClientId) return; - 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}`; + const handleLogin = () => { + signIn(`${window.location.origin}/callback`); }; return ( @@ -48,13 +32,7 @@ export function LoginPage() {

Managed Apache Camel Runtime

- {config?.logtoClientId ? ( - - ) : ( -

- Identity provider not configured. Run the bootstrap script or check HOWTO.md. -

- )} +
); diff --git a/ui/src/auth/ProtectedRoute.tsx b/ui/src/auth/ProtectedRoute.tsx index 3752246..6433b20 100644 --- a/ui/src/auth/ProtectedRoute.tsx +++ b/ui/src/auth/ProtectedRoute.tsx @@ -1,8 +1,18 @@ 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 }) { - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const { isAuthenticated, isLoading } = useLogto(); + + if (isLoading) { + return ( +
+ +
+ ); + } + if (!isAuthenticated) return ; return <>{children}; } diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts deleted file mode 100644 index 021a080..0000000 --- a/ui/src/auth/auth-store.ts +++ /dev/null @@ -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 { - try { - const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); - return JSON.parse(atob(base64)); - } catch { - return {}; - } -} - -export const useAuthStore = create((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 }); - } - }, -})); diff --git a/ui/src/auth/pkce.ts b/ui/src/auth/pkce.ts deleted file mode 100644 index 4c4e360..0000000 --- a/ui/src/auth/pkce.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/ui/src/auth/useAuth.ts b/ui/src/auth/useAuth.ts new file mode 100644 index 0000000..2c1ca3d --- /dev/null +++ b/ui/src/auth/useAuth.ts @@ -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(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, + }; +} diff --git a/ui/src/components/EnvironmentTree.tsx b/ui/src/components/EnvironmentTree.tsx index a0db25b..fef1ac0 100644 --- a/ui/src/components/EnvironmentTree.tsx +++ b/ui/src/components/EnvironmentTree.tsx @@ -1,7 +1,7 @@ import { useState, useCallback } from 'react'; import { useNavigate, useLocation } from 'react-router'; 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 type { EnvironmentResponse } from '../types/api'; @@ -45,7 +45,7 @@ function EnvWithApps({ } export function EnvironmentTree() { - const tenantId = useAuthStore((s) => s.tenantId); + const { tenantId } = useAuth(); const { data: environments } = useEnvironments(tenantId ?? ''); const navigate = useNavigate(); const location = useLocation(); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 9df1430..032e1de 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -5,7 +5,7 @@ import { Sidebar, TopBar, } from '@cameleer/design-system'; -import { useAuthStore } from '../auth/auth-store'; +import { useAuth } from '../auth/useAuth'; import { EnvironmentTree } from './EnvironmentTree'; // Simple SVG logo mark for the sidebar header @@ -91,8 +91,7 @@ function UserIcon() { export function Layout() { const navigate = useNavigate(); - const username = useAuthStore((s) => s.username); - const logout = useAuthStore((s) => s.logout); + const { username, logout } = useAuth(); const [envSectionOpen, setEnvSectionOpen] = useState(true); const [collapsed, setCollapsed] = useState(false); diff --git a/ui/src/hooks/usePermissions.ts b/ui/src/hooks/usePermissions.ts index 6fd5a82..a6cc005 100644 --- a/ui/src/hooks/usePermissions.ts +++ b/ui/src/hooks/usePermissions.ts @@ -1,4 +1,4 @@ -import { useAuthStore } from '../auth/auth-store'; +import { useAuth } from '../auth/useAuth'; const ROLE_PERMISSIONS: Record = { 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 = { }; export function usePermissions() { - const roles = useAuthStore((s) => s.roles); + const { roles } = useAuth(); const permissions = new Set(); for (const role of roles) { diff --git a/ui/src/main.tsx b/ui/src/main.tsx index a255f95..6953155 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,10 +1,13 @@ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import ReactDOM from 'react-dom/client'; +import { LogtoProvider, useLogto } from '@logto/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 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 { AppRouter } from './router'; +import { fetchConfig } from './config'; +import { setTokenProvider, setLogoutHandler } from './api/client'; const queryClient = new QueryClient({ 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 ( +
+ +
+ ); + } + + return ( + + + + + + + + + ); +} + ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - + diff --git a/ui/src/pages/AppDetailPage.tsx b/ui/src/pages/AppDetailPage.tsx index b8ead18..29da674 100644 --- a/ui/src/pages/AppDetailPage.tsx +++ b/ui/src/pages/AppDetailPage.tsx @@ -1,5 +1,5 @@ import React, { useRef, useState } from 'react'; -import { useNavigate, useParams, Link } from 'react-router-dom'; +import { useNavigate, useParams, Link } from 'react-router'; import { Badge, Button, @@ -17,7 +17,7 @@ import { useToast, } 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 { useApp, useDeployment, @@ -118,7 +118,7 @@ export function AppDetailPage() { const { envId = '', appId = '' } = useParams<{ envId: string; appId: string }>(); const navigate = useNavigate(); const { toast } = useToast(); - const tenantId = useAuthStore((s) => s.tenantId); + const { tenantId } = useAuth(); const { canManageApps, canDeploy } = usePermissions(); // Active tab diff --git a/ui/src/pages/DashboardPage.tsx b/ui/src/pages/DashboardPage.tsx index 62b8a92..6cb4c1e 100644 --- a/ui/src/pages/DashboardPage.tsx +++ b/ui/src/pages/DashboardPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { Badge, Button, @@ -8,7 +8,7 @@ import { KpiStrip, Spinner, } from '@cameleer/design-system'; -import { useAuthStore } from '../auth/auth-store'; +import { useAuth } from '../auth/useAuth'; import { useTenant, useEnvironments, useApps } from '../api/hooks'; import { RequirePermission } from '../components/RequirePermission'; import type { EnvironmentResponse, AppResponse } from '../types/api'; @@ -41,7 +41,7 @@ function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' { export function DashboardPage() { const navigate = useNavigate(); - const tenantId = useAuthStore((s) => s.tenantId); + const { tenantId } = useAuth(); const { data: tenant, isLoading: tenantLoading } = useTenant(tenantId ?? ''); const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? ''); diff --git a/ui/src/pages/EnvironmentDetailPage.tsx b/ui/src/pages/EnvironmentDetailPage.tsx index 49cffdc..0724656 100644 --- a/ui/src/pages/EnvironmentDetailPage.tsx +++ b/ui/src/pages/EnvironmentDetailPage.tsx @@ -1,5 +1,5 @@ import React, { useRef, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router'; import { Badge, Button, @@ -15,7 +15,7 @@ import { useToast, } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; -import { useAuthStore } from '../auth/auth-store'; +import { useAuth } from '../auth/useAuth'; import { useEnvironments, useUpdateEnvironment, @@ -77,7 +77,7 @@ export function EnvironmentDetailPage() { const navigate = useNavigate(); const { envId } = useParams<{ envId: string }>(); const { toast } = useToast(); - const tenantId = useAuthStore((s) => s.tenantId); + const { tenantId } = useAuth(); const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? ''); const environment = environments?.find((e) => e.id === envId); diff --git a/ui/src/pages/EnvironmentsPage.tsx b/ui/src/pages/EnvironmentsPage.tsx index 183b7fa..e53ec8b 100644 --- a/ui/src/pages/EnvironmentsPage.tsx +++ b/ui/src/pages/EnvironmentsPage.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { Badge, Button, @@ -13,7 +13,7 @@ import { useToast, } 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 { RequirePermission } from '../components/RequirePermission'; import type { EnvironmentResponse } from '../types/api'; @@ -67,7 +67,7 @@ const columns: Column[] = [ export function EnvironmentsPage() { const navigate = useNavigate(); const { toast } = useToast(); - const tenantId = useAuthStore((s) => s.tenantId); + const { tenantId } = useAuth(); const { data: environments, isLoading } = useEnvironments(tenantId ?? ''); const createMutation = useCreateEnvironment(tenantId ?? ''); diff --git a/ui/src/pages/LicensePage.tsx b/ui/src/pages/LicensePage.tsx index 8f1cbd2..e6e376d 100644 --- a/ui/src/pages/LicensePage.tsx +++ b/ui/src/pages/LicensePage.tsx @@ -5,7 +5,7 @@ import { EmptyState, Spinner, } from '@cameleer/design-system'; -import { useAuthStore } from '../auth/auth-store'; +import { useAuth } from '../auth/useAuth'; import { useLicense } from '../api/hooks'; const FEATURE_LABELS: Record = { @@ -39,7 +39,7 @@ function daysRemaining(expiresAt: string): number { } export function LicensePage() { - const tenantId = useAuthStore((s) => s.tenantId); + const { tenantId } = useAuth(); const { data: license, isLoading, isError } = useLicense(tenantId ?? ''); const [tokenExpanded, setTokenExpanded] = useState(false); diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e6b625a..6efc03c 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -1,6 +1,4 @@ import { Routes, Route } from 'react-router'; -import { useEffect } from 'react'; -import { useAuthStore } from './auth/auth-store'; import { LoginPage } from './auth/LoginPage'; import { CallbackPage } from './auth/CallbackPage'; import { ProtectedRoute } from './auth/ProtectedRoute'; @@ -12,11 +10,6 @@ import { AppDetailPage } from './pages/AppDetailPage'; import { LicensePage } from './pages/LicensePage'; export function AppRouter() { - const loadFromStorage = useAuthStore((s) => s.loadFromStorage); - useEffect(() => { - loadFromStorage(); - }, [loadFromStorage]); - return ( } />