Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,27 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'

import { api } from '@/lib/api'

import { UserBindingDialog } from '../user-binding-dialog'

/**
* The dialog reads `/api/status` through the shared React Query cache, so it
* needs a provider. A fresh client per render keeps tests isolated.
*/
function renderWithQueryClient(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
)
}

type ApiMethod = (url: string) => Promise<{ data: unknown }>
type MockableApi = {
get: ApiMethod
Expand Down Expand Up @@ -117,7 +131,9 @@ describe('UserBindingDialog built-in bindings', () => {
return { data: { success: true, message: 'success' } }
}

render(<UserBindingDialog open userId={7} onOpenChange={() => undefined} />)
renderWithQueryClient(
<UserBindingDialog open userId={7} onOpenChange={() => undefined} />
)

const expectedBindings = [
['Email', 'email'],
Expand Down
18 changes: 7 additions & 11 deletions web/src/features/users/components/dialogs/user-binding-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import {
Mail,
Globe,
Expand Down Expand Up @@ -44,8 +45,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { api } from '@/lib/api'
import { indexCustomOAuthBindings, type CustomOAuthBinding } from '@/lib/oauth'
import { ensureStatus } from '@/lib/status-query'

import {
getUser,
Expand Down Expand Up @@ -168,6 +169,7 @@ export function UserBindingDialog(props: Props) {
const [showBoundOnly, setShowBoundOnly] = useState(true)
const [unbindTarget, setUnbindTarget] = useState<BindingItem | null>(null)
const [unbinding, setUnbinding] = useState(false)
const queryClient = useQueryClient()

const fetchData = useCallback(async () => {
if (!props.userId) return
Expand All @@ -179,29 +181,23 @@ export function UserBindingDialog(props: Props) {
success: false,
data: [],
})),
api
.get('/api/status')
.then((r) => r.data)
.catch(() => ({
success: false,
data: {},
})),
ensureStatus(queryClient).catch(() => null),
])
if (userRes.success && userRes.data) {
setUser(userRes.data)
}
if (oauthRes.success && oauthRes.data) {
setOauthBindings(oauthRes.data)
}
if (statusRes.success && statusRes.data) {
setStatusInfo(statusRes.data as StatusInfo)
if (statusRes) {
setStatusInfo(statusRes as StatusInfo)
}
} catch {
toast.error(t('Failed to load'))
} finally {
setLoading(false)
}
}, [props.userId, t])
}, [props.userId, queryClient, t])

useEffect(() => {
if (props.open && props.userId) {
Expand Down
49 changes: 4 additions & 45 deletions web/src/hooks/use-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,63 +19,22 @@ For commercial licensing, please contact support@quantumnous.com
import { useQuery } from '@tanstack/react-query'

import type { SystemStatus } from '@/features/auth/types'
import { getStatus } from '@/lib/api'
import { useSystemConfigStore } from '@/stores/system-config-store'

import { mapStatusDataToConfig } from './use-system-config'
import { readCachedStatus, statusQueryOptions } from '@/lib/status-query'

// Get initial cache from localStorage
function getInitialStatus(): SystemStatus | undefined {
try {
if (typeof window !== 'undefined') {
const saved = window.localStorage.getItem('status')
return saved ? (JSON.parse(saved) as SystemStatus) : undefined
}
} catch {
/* empty */
}
return undefined
return (readCachedStatus() as SystemStatus | null) ?? undefined
}

export function useStatus() {
const { data, isLoading, error } = useQuery({
queryKey: ['status'],
queryFn: async () => {
const status = await getStatus()
try {
if (status) {
const { setConfig } = useSystemConfigStore.getState()
setConfig(mapStatusDataToConfig(status))
}
} catch (err) {
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.warn(
'[useStatus] Failed to sync status to system config',
err
)
}
}
// Save to localStorage
try {
if (typeof window !== 'undefined' && status) {
window.localStorage.setItem('status', JSON.stringify(status))
}
} catch {
/* empty */
}
return status as SystemStatus | null
},
...statusQueryOptions,
// Use localStorage data as initial data
placeholderData: getInitialStatus(),
// Data becomes stale after 5 minutes
staleTime: 5 * 60 * 1000,
// Cache expires after 30 minutes
gcTime: 30 * 60 * 1000,
})

return {
status: data ?? null,
status: (data as SystemStatus | null) ?? null,
loading: isLoading,
error,
}
Expand Down
112 changes: 12 additions & 100 deletions web/src/hooks/use-system-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,103 +16,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useCallback } from 'react'

import { DEFAULT_SYSTEM_NAME, DEFAULT_LOGO } from '@/lib/constants'
import { DEFAULT_LOGO } from '@/lib/constants'
import { applyFaviconToDom } from '@/lib/dom-utils'
import {
useSystemConfigStore,
type CurrencyConfig,
type CurrencyDisplayType,
type SystemConfig,
DEFAULT_CURRENCY_CONFIG,
} from '@/stores/system-config-store'
import { ensureStatus } from '@/lib/status-query'
import { useSystemConfigStore } from '@/stores/system-config-store'

interface UseSystemConfigOptions {
/** Automatically fetch config from backend (use only in root component) */
autoLoad?: boolean
}

interface StatusApiResponse {
success: boolean
data: {
system_name?: string
logo?: string
footer_html?: string
demo_site_enabled?: boolean
display_token_stat_enabled?: boolean
display_in_currency?: boolean
quota_display_type?: CurrencyDisplayType
quota_per_unit?: number
usd_exchange_rate?: number
custom_currency_symbol?: string
custom_currency_exchange_rate?: number
}
}

function toNumber(value: unknown, fallback: number): number {
if (typeof value === 'number' && !Number.isNaN(value)) return value
if (typeof value === 'string') {
const parsed = Number(value)
if (!Number.isNaN(parsed)) return parsed
}
return fallback
}

/**
* Map `/api/status` response data to our persisted system config structure
*/
export function mapStatusDataToConfig(
data: StatusApiResponse['data'] | undefined
): Partial<SystemConfig> {
if (!data) return {}

const quotaDisplayType =
(data.quota_display_type as CurrencyDisplayType | undefined) ??
DEFAULT_CURRENCY_CONFIG.quotaDisplayType

const currency: CurrencyConfig = {
displayInCurrency:
data.display_in_currency ?? DEFAULT_CURRENCY_CONFIG.displayInCurrency,
quotaDisplayType,
quotaPerUnit: toNumber(
data.quota_per_unit,
DEFAULT_CURRENCY_CONFIG.quotaPerUnit
),
usdExchangeRate: toNumber(
data.usd_exchange_rate,
DEFAULT_CURRENCY_CONFIG.usdExchangeRate
),
customCurrencySymbol:
data.custom_currency_symbol?.trim() ||
DEFAULT_CURRENCY_CONFIG.customCurrencySymbol,
customCurrencyExchangeRate: toNumber(
data.custom_currency_exchange_rate,
DEFAULT_CURRENCY_CONFIG.customCurrencyExchangeRate
),
}

return {
systemName: data.system_name || DEFAULT_SYSTEM_NAME,
logo: data.logo || DEFAULT_LOGO,
footerHtml: data.footer_html,
demoSiteEnabled: data.demo_site_enabled,
displayTokenStatEnabled: data.display_token_stat_enabled,
currency,
}
}

// Fetch system config from API
async function fetchSystemConfig(): Promise<Partial<SystemConfig>> {
const response = await fetch('/api/status')
if (!response.ok) throw new Error('Failed to fetch status')

const data: StatusApiResponse = await response.json()
if (!data.success) throw new Error('API returned error')

return mapStatusDataToConfig(data.data)
}

// Preload image and return cleanup function
function preloadImage(
src: string,
Expand Down Expand Up @@ -143,28 +59,24 @@ function preloadImage(
*/
export function useSystemConfig(options: UseSystemConfigOptions = {}) {
const { autoLoad = false } = options
const {
config,
loading,
loadedLogoUrl,
setConfig,
setLoadedLogoUrl,
setLoading,
} = useSystemConfigStore()
const queryClient = useQueryClient()
const { config, loading, loadedLogoUrl, setLoadedLogoUrl, setLoading } =
useSystemConfigStore()

// Load config from backend
// Load config from backend via the shared `/api/status` cache.
// `ensureStatus` writes the mapped config into this store itself, so there is
// no second request and no second mapping path here.
const loadConfig = useCallback(async () => {
try {
setLoading(true)
const newConfig = await fetchSystemConfig()
setConfig(newConfig)
await ensureStatus(queryClient)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load system config:', error)
} finally {
setLoading(false)
}
}, [setConfig, setLoading])
}, [queryClient, setLoading])

useEffect(() => {
if (autoLoad) loadConfig()
Expand Down
40 changes: 15 additions & 25 deletions web/src/lib/nav-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import { getStatus } from '@/lib/api'
import type { QueryClient } from '@tanstack/react-query'

import { ensureStatus, readCachedStatus } from '@/lib/status-query'

export type ModuleAccess = { enabled: boolean; requireAuth: boolean }

Expand Down Expand Up @@ -142,26 +144,6 @@ export function parseHeaderNavModulesFromStatus(
return parseHeaderNavModules(status?.HeaderNavModules)
}

function getCachedStatus(): Record<string, unknown> | null {
try {
if (typeof window === 'undefined') return null
const raw = window.localStorage.getItem('status')
return raw ? (JSON.parse(raw) as Record<string, unknown>) : null
} catch {
return null
}
}

function cacheStatus(status: Record<string, unknown> | null): void {
try {
if (typeof window !== 'undefined' && status) {
window.localStorage.setItem('status', JSON.stringify(status))
}
} catch {
/* empty */
}
}

export function getModuleAccessFromStatus(
status: Record<string, unknown> | null,
module: HeaderNavModule
Expand All @@ -170,15 +152,23 @@ export function getModuleAccessFromStatus(
}

export function getModuleAccess(module: HeaderNavModule): ModuleAccess {
return getModuleAccessFromStatus(getCachedStatus(), module)
return getModuleAccessFromStatus(readCachedStatus(), module)
}

/**
* Resolve module access for a router guard.
*
* Goes through the shared `['status']` cache, so a guard on a fresh page load
* reuses the request already started during boot instead of issuing its own.
* Once the cache is warm this resolves without blocking on the network — see
* `ensureStatus` for the exact fresh/stale resolution rules.
*/
export async function getFreshModuleAccess(
queryClient: QueryClient,
module: HeaderNavModule
): Promise<ModuleAccess> {
try {
const status = (await getStatus()) as Record<string, unknown> | null
cacheStatus(status)
const status = await ensureStatus(queryClient)
return getModuleAccessFromStatus(status, module)
} catch {
return { enabled: false, requireAuth: true }
Expand All @@ -189,7 +179,7 @@ export function isSidebarModuleEnabled(
section: string,
module: string
): boolean {
const status = getCachedStatus()
const status = readCachedStatus()
if (!status) return true

const raw = status.SidebarModulesAdmin
Expand Down
Loading