diff --git a/apps/web/src/api-clients/_common/composables/use-scoped-query.ts b/apps/web/src/api-clients/_common/composables/use-scoped-query.ts deleted file mode 100644 index 77d4f3d733..0000000000 --- a/apps/web/src/api-clients/_common/composables/use-scoped-query.ts +++ /dev/null @@ -1,82 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck -/** - * useScopedQuery - A custom wrapper around `useQuery` to enforce scope-based API fetching. - * - * ## Why this hook exists? - * This hook was created to integrate **scope-based API access control** with Vue Query. - * It ensures that queries are only executed when the user's granted scope matches the required scope. - * Additionally, it automatically handles loading states and prevents unnecessary queries. - * - * ## Functionality - * - Extends `useQuery` with **grant scope validation**. - * - Runs queries only when **the user’s scope is valid** and the app is **ready**. - * - Uses Vue’s **reactivity** to dynamically compute the `enabled` state. - * - Supports both **static and reactive `enabled` values**. - * - * ## Parameters: - * - `options`: Standard **Vue Query options** (`UseQueryOptions`). - * - `requiredScopes`: A list of **required grant scopes** to determine if the query should execute. - * - * ## Supported Query Options: - * The following options are commonly used within our team: - * - `queryKey`: **Unique key** to identify the query in the cache. - * - `queryFn`: **Function** to fetch data from the API. - * - `select`: **Transform function** to modify response data. - * - `initialData`: **Default value** for the query when no data is available. - * - `staleTime`: **Cache duration** before the query becomes stale. - * - `enabled`: **Boolean or reactive ref** to control when the query should execute. - * - * ## Example Usage: - * - * const myQuery = useScopedQuery({ - * queryKey: ['dashboard', dashboardId], - * queryFn: () => fetchDashboardData(dashboardId), - * select: (data) => data.results, - * initialData: { results: [] }, - * staleTime: 1000 * 60 * 5, // 5 minutes - * enabled: computed(() => isUserAuthorized.value), - * }, ['DOMAIN', 'WORKSPACE']); - * - */ - - -import type { MaybeRef } from '@vueuse/core'; -import { toValue } from '@vueuse/core'; -import { computed, reactive } from 'vue'; - -import type { - UseQueryOptions, -} from '@tanstack/vue-query'; -import { useQuery } from '@tanstack/vue-query'; - -import type { GrantScope } from '@/api-clients/identity/token/schema/type'; - -import { useAppContextStore } from '@/store/app-context/app-context-store'; -import { useUserStore } from '@/store/user/user-store'; - -export const useScopedQuery = ( - options: UseQueryOptions, - requiredScopes: GrantScope[] = [], -) => { - const appContextStore = useAppContextStore(); - const userStore = useUserStore(); - - const _state = reactive({ - currentGrantScope: computed(() => userStore.state.currentGrantInfo?.scope || 'USER'), - isLoading: computed(() => appContextStore.getters.globalGrantLoading), - isValidScope: computed(() => requiredScopes.includes(_state.currentGrantScope)), - }); - - const queryEnabled = computed(() => { - const _inheritedEnabled = options?.enabled as MaybeRef | undefined; - if (_inheritedEnabled === undefined) return undefined; - if (_inheritedEnabled !== undefined && !toValue(_inheritedEnabled)) return false; - return _state.isValidScope && !_state.isLoading; - }); - - return useQuery({ - ...options, - enabled: queryEnabled, - }); -}; diff --git a/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-list-query.ts b/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-list-query.ts index 00e1c3ca4a..e3f64b72f7 100644 --- a/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-list-query.ts +++ b/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-list-query.ts @@ -3,11 +3,11 @@ import { computed } from 'vue'; import type { QueryKey } from '@tanstack/vue-query'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import { usePrivateDataTableApi } from '@/api-clients/dashboard/private-data-table/composables/use-private-data-table-api'; import { usePublicDataTableApi } from '@/api-clients/dashboard/public-data-table/composables/use-public-data-table-api'; import type { DataTableListParameters } from '@/api-clients/dashboard/public-data-table/schema/api-verbs/list'; import type { DataTableUpdateParameters } from '@/api-clients/dashboard/public-data-table/schema/api-verbs/update'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; import type { DataTableModel } from '@/common/modules/widgets/types/widget-data-table-type'; diff --git a/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-query.ts b/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-query.ts index 032d0fa9cb..da12af8543 100644 --- a/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-query.ts +++ b/apps/web/src/common/modules/widgets/_composables/use-widget-data-table-query.ts @@ -1,10 +1,10 @@ import type { ComputedRef } from 'vue'; import { computed } from 'vue'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import { usePrivateDataTableApi } from '@/api-clients/dashboard/private-data-table/composables/use-private-data-table-api'; import type { DataTableGetParameters } from '@/api-clients/dashboard/private-data-table/schema/api-verbs/get'; import { usePublicDataTableApi } from '@/api-clients/dashboard/public-data-table/composables/use-public-data-table-api'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; diff --git a/apps/web/src/common/modules/widgets/_composables/use-widget-query.ts b/apps/web/src/common/modules/widgets/_composables/use-widget-query.ts index 6702f3d924..e06be200e0 100644 --- a/apps/web/src/common/modules/widgets/_composables/use-widget-query.ts +++ b/apps/web/src/common/modules/widgets/_composables/use-widget-query.ts @@ -3,12 +3,12 @@ import { computed } from 'vue'; import type { QueryKey } from '@tanstack/vue-query'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import type { WidgetModel, WidgetUpdateParams } from '@/api-clients/dashboard/_types/widget-type'; import { usePrivateWidgetApi } from '@/api-clients/dashboard/private-widget/composables/use-private-widget-api'; import type { PrivateWidgetGetParameters } from '@/api-clients/dashboard/private-widget/schema/api-verbs/get'; import { usePublicWidgetApi } from '@/api-clients/dashboard/public-widget/composables/use-public-widget-api'; import type { PublicWidgetGetParameters } from '@/api-clients/dashboard/public-widget/schema/api-verbs/get'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; diff --git a/apps/web/src/query/composables/use-scoped-query.ts b/apps/web/src/query/composables/use-scoped-query.ts new file mode 100644 index 0000000000..895c182156 --- /dev/null +++ b/apps/web/src/query/composables/use-scoped-query.ts @@ -0,0 +1,135 @@ +/** + * useScopedQuery - A custom wrapper around `useQuery` to enforce scope-based API fetching. + * + * ## Why this hook exists? + * This hook was created to integrate **scope-based API access control** with Vue Query. + * It ensures that queries are only executed when the user's granted scope matches the required scope. + * Additionally, it automatically handles loading states and prevents unnecessary queries. + * + * ## Functionality + * - Extends `useQuery` with **grant scope validation**. + * - Runs queries only when **the user's scope is valid** and the app is **ready**. + * - Uses Vue's **reactivity** to dynamically compute the `enabled` state. + * - Supports both **static and reactive `enabled` values**. + * + * ## Parameters: + * - `options`: Standard **Vue Query options** (`UseQueryOptions`). + * - `requiredScopes`: A list of **required grant scopes** to determine if the query should execute. + * + * ## Supported Query Options: + * - `queryKey`, `queryFn`, `select`, `initialData`, `staleTime`, `enabled` (static or reactive) + * + * ## Example: + * const query = useScopedQuery( + * { + * queryKey: ['dashboard', dashboardId], + * queryFn: () => fetchDashboardData(dashboardId), + * enabled: computed(() => isUserAuthorized.value), + * }, + * ['DOMAIN', 'WORKSPACE'] + * ); + */ + +import type { MaybeRef } from '@vueuse/core'; +import { toValue } from '@vueuse/core'; +import { computed, type ComputedRef } from 'vue'; + +import { + useQuery, type UseQueryOptions, type UseQueryReturnType, +} from '@tanstack/vue-query'; + +import type { GrantScope } from '@/api-clients/identity/token/schema/type'; +import type { QueryKeyArray } from '@/query/query-key/_types/query-key-type'; + +import { useAppContextStore } from '@/store/app-context/app-context-store'; +import { useUserStore } from '@/store/user/user-store'; + + +type ScopedEnabled = MaybeRef; + + +export const useScopedQuery = ( + options: UseQueryOptions, + requiredScopes: [GrantScope, ...GrantScope[]], +): UseQueryReturnType => { + // [Dev Warning] This query is missing `requiredScopes`. + // All scoped queries must explicitly define at least one valid scope for clarity and safety. + if (import.meta.env.DEV && (!requiredScopes || requiredScopes.length === 0)) { + _warnOncePerTick(() => { + console.warn('[useScopedQuery] `requiredScopes` is missing or empty.', { + queryKey: _extractQueryKey((options as any).queryKey), + suggestion: 'Pass at least one valid scope like [\'DOMAIN\'], [\'WORKSPACE\'], etc.', + }); + return true; + }); + } + + const appContextStore = useAppContextStore(); + const userStore = useUserStore(); + + const currentGrantScope = computed( + () => userStore.state.currentGrantInfo?.scope, + ); + const isAppReady = computed(() => !appContextStore.getters.globalGrantLoading); + + const isValidScope = computed(() => currentGrantScope.value !== undefined + && requiredScopes.includes(currentGrantScope.value)); + + const rawEnabled = (options as { enabled?: ScopedEnabled }).enabled; + const queryEnabled = computed(() => { + const inheritedEnabled = rawEnabled !== undefined ? toValue(rawEnabled) : true; + return inheritedEnabled && isValidScope.value && isAppReady.value; + }); + + // [Dev Warning] The current user's scope is not included in the allowed `requiredScopes`. + // This usually indicates a configuration mistake in the query declaration. + if (import.meta.env.DEV) { + const currentScope = currentGrantScope.value; + if (isAppReady.value && currentScope && !requiredScopes.includes(currentScope)) { + _warnOncePerTick(() => { + console.warn('[useScopedQuery] Invalid requiredScopes for current scope:', { + queryKey: _extractQueryKey((options as any).queryKey), + requiredScopes, + currentScope, + }); + return true; + }); + } + } + + return useQuery({ + ...options, + enabled: queryEnabled, + }); +}; + +const _extractQueryKey = (input: unknown): QueryKeyArray => toValue(input as ComputedRef); + + + +/* Warning Logger Utilities */ +const _warnedKeys = new Set(); +const _getCallerKey = (): string => { + try { + const err = new Error(); + const stack = err.stack?.split('\n') || []; + + const caller = stack.find((line, i) => i > 1 + && (line.includes('.ts') || line.includes('.vue')) + && !line.includes('use-scoped-query')); + + return caller?.trim() ?? 'UNKNOWN_CALLSITE'; + } catch { + return 'UNKNOWN_CALLSITE'; + } +}; +const _warnOncePerTick = (log: () => boolean) => { + const key = _getCallerKey(); + if (_warnedKeys.has(key)) return; + const didLog = log(); + + if (didLog) { + _warnedKeys.add(key); + queueMicrotask(() => _warnedKeys.delete(key)); + } +}; diff --git a/apps/web/src/query/query-key/_types/query-key-type.ts b/apps/web/src/query/query-key/_types/query-key-type.ts index 88695444ed..8db52a2a3e 100644 --- a/apps/web/src/query/query-key/_types/query-key-type.ts +++ b/apps/web/src/query/query-key/_types/query-key-type.ts @@ -1,7 +1,7 @@ import type { API_DOC } from '@/api-clients/_common/constants/api-doc-constant'; -export type QueryKeyArray = unknown[]; +export type QueryKeyArray = readonly unknown[]; export type QueryScope = 'service' | 'reference'; diff --git a/apps/web/src/services/dashboards/composables/use-dashboard-folder-query.ts b/apps/web/src/services/dashboards/composables/use-dashboard-folder-query.ts index 69d1d4bab3..b57e199935 100644 --- a/apps/web/src/services/dashboards/composables/use-dashboard-folder-query.ts +++ b/apps/web/src/services/dashboards/composables/use-dashboard-folder-query.ts @@ -7,7 +7,6 @@ import type { QueryKey } from '@tanstack/vue-query'; import { ApiQueryHelper } from '@cloudforet/core-lib/space-connector/helper'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import type { ListResponse } from '@/api-clients/_common/schema/api-verbs/list'; import type { FolderModel, FolderUpdateParams } from '@/api-clients/dashboard/_types/folder-type'; import { usePrivateFolderApi } from '@/api-clients/dashboard/private-folder/composables/use-private-folder-api'; @@ -16,6 +15,7 @@ import type { PrivateFolderModel } from '@/api-clients/dashboard/private-folder/ import { usePublicFolderApi } from '@/api-clients/dashboard/public-folder/composables/use-public-folder-api'; import type { PublicFolderUpdateParameters } from '@/api-clients/dashboard/public-folder/schema/api-verbs/update'; import type { PublicFolderModel } from '@/api-clients/dashboard/public-folder/schema/model'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; import { useAppContextStore } from '@/store/app-context/app-context-store'; diff --git a/apps/web/src/services/dashboards/composables/use-dashboard-get-query.ts b/apps/web/src/services/dashboards/composables/use-dashboard-get-query.ts index ac013223ad..5b2c3d5706 100644 --- a/apps/web/src/services/dashboards/composables/use-dashboard-get-query.ts +++ b/apps/web/src/services/dashboards/composables/use-dashboard-get-query.ts @@ -3,7 +3,6 @@ import { computed } from 'vue'; import type { QueryKey } from '@tanstack/vue-query'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import type { DashboardModel, DashboardUpdateParams } from '@/api-clients/dashboard/_types/dashboard-type'; import { usePrivateDashboardApi } from '@/api-clients/dashboard/private-dashboard/composables/use-private-dashboard-api'; import type { PrivateDashboardGetParameters } from '@/api-clients/dashboard/private-dashboard/schema/api-verbs/get'; @@ -13,6 +12,7 @@ import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/ import type { PublicDashboardGetParameters } from '@/api-clients/dashboard/public-dashboard/schema/api-verbs/get'; import type { PublicDashboardUpdateParameters } from '@/api-clients/dashboard/public-dashboard/schema/api-verbs/update'; import type { PublicDashboardModel } from '@/api-clients/dashboard/public-dashboard/schema/model'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; const STALE_TIME = 1000 * 60 * 5; diff --git a/apps/web/src/services/dashboards/composables/use-dashboard-query.ts b/apps/web/src/services/dashboards/composables/use-dashboard-query.ts index ef6135ba60..dab1ca745c 100644 --- a/apps/web/src/services/dashboards/composables/use-dashboard-query.ts +++ b/apps/web/src/services/dashboards/composables/use-dashboard-query.ts @@ -7,16 +7,16 @@ import type { QueryKey } from '@tanstack/vue-query'; import { ApiQueryHelper } from '@cloudforet/core-lib/space-connector/helper'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; -import type { ListResponse } from '@/api-clients/_common/schema/api-verbs/list'; import { usePrivateDashboardApi } from '@/api-clients/dashboard/private-dashboard/composables/use-private-dashboard-api'; import type { PrivateDashboardModel } from '@/api-clients/dashboard/private-dashboard/schema/model'; import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/composables/use-public-dashboard-api'; import type { PublicDashboardModel } from '@/api-clients/dashboard/public-dashboard/schema/model'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; import { useAppContextStore } from '@/store/app-context/app-context-store'; + const DEFAULT_LIST_DATA = { results: [] }; const STALE_TIME = 1000 * 60 * 5; @@ -67,7 +67,7 @@ export const useDashboardQuery = (): UseDashboardQueryReturn => { }); /* Querys */ - const publicDashboardListQuery = useScopedQuery, unknown, PublicDashboardModel[]>({ + const publicDashboardListQuery = useScopedQuery({ queryKey: publicDashboardListQueryKey, queryFn: () => publicDashboardAPI.list(publicDashboardListParams.value), select: (data) => data?.results ?? [], @@ -76,7 +76,7 @@ export const useDashboardQuery = (): UseDashboardQueryReturn => { staleTime: STALE_TIME, enabled: computed(() => !!_state.publicDashboardListApiQuery?.filter), }, ['DOMAIN', 'WORKSPACE']); - const privateDashboardListQuery = useScopedQuery, unknown, PrivateDashboardModel[]>({ + const privateDashboardListQuery = useScopedQuery({ queryKey: privateDashboardListQueryKey, queryFn: () => privateDashboardAPI.list(privateDashboardListParams.value), select: (data) => data?.results || [], diff --git a/apps/web/src/services/dashboards/composables/use-dashboard-search-query.ts b/apps/web/src/services/dashboards/composables/use-dashboard-search-query.ts index 877f5ff1aa..237b0b8741 100644 --- a/apps/web/src/services/dashboards/composables/use-dashboard-search-query.ts +++ b/apps/web/src/services/dashboards/composables/use-dashboard-search-query.ts @@ -9,13 +9,13 @@ import type { ConsoleFilter } from '@cloudforet/core-lib/query/type'; import { ApiQueryHelper } from '@cloudforet/core-lib/space-connector/helper'; import type { Query } from '@cloudforet/core-lib/space-connector/type'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import type { ListResponse } from '@/api-clients/_common/schema/api-verbs/list'; import { usePrivateDashboardApi } from '@/api-clients/dashboard/private-dashboard/composables/use-private-dashboard-api'; import type { PrivateDashboardModel } from '@/api-clients/dashboard/private-dashboard/schema/model'; import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/composables/use-public-dashboard-api'; import type { PublicDashboardModel } from '@/api-clients/dashboard/public-dashboard/schema/model'; import { ROLE_TYPE } from '@/api-clients/identity/role/constant'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; import { useAppContextStore } from '@/store/app-context/app-context-store'; diff --git a/apps/web/src/services/dashboards/composables/use-dashboard-widget-list-query.ts b/apps/web/src/services/dashboards/composables/use-dashboard-widget-list-query.ts index 683e6ec0b0..58f964b851 100644 --- a/apps/web/src/services/dashboards/composables/use-dashboard-widget-list-query.ts +++ b/apps/web/src/services/dashboards/composables/use-dashboard-widget-list-query.ts @@ -3,7 +3,6 @@ import { computed } from 'vue'; import type { QueryKey } from '@tanstack/vue-query'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import type { WidgetModel, WidgetUpdateParams } from '@/api-clients/dashboard/_types/widget-type'; import { usePrivateWidgetApi } from '@/api-clients/dashboard/private-widget/composables/use-private-widget-api'; import type { PrivateWidgetListParameters } from '@/api-clients/dashboard/private-widget/schema/api-verbs/list'; @@ -13,6 +12,7 @@ import { usePublicWidgetApi } from '@/api-clients/dashboard/public-widget/compos import type { PublicWidgetListParameters } from '@/api-clients/dashboard/public-widget/schema/api-verbs/list'; import type { PublicWidgetUpdateParameters } from '@/api-clients/dashboard/public-widget/schema/api-verbs/update'; import type { PublicWidgetModel } from '@/api-clients/dashboard/public-widget/schema/model'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; const DEFAULT_LIST_DATA = { results: [] }; diff --git a/apps/web/src/services/project/v2/components/ProjectDetailTab.vue b/apps/web/src/services/project/v2/components/ProjectDetailTab.vue index 7adb277281..0dd147982f 100644 --- a/apps/web/src/services/project/v2/components/ProjectDetailTab.vue +++ b/apps/web/src/services/project/v2/components/ProjectDetailTab.vue @@ -4,11 +4,11 @@ import { computed, ref, watch } from 'vue'; import { PTab } from '@cloudforet/mirinae'; import type { TabItem } from '@cloudforet/mirinae/types/navigation/tabs/tab/type'; -import { useScopedQuery } from '@/api-clients/_common/composables/use-scoped-query'; import { usePublicDashboardApi } from '@/api-clients/dashboard/public-dashboard/composables/use-public-dashboard-api'; import type { PublicDashboardListParameters } from '@/api-clients/dashboard/public-dashboard/schema/api-verbs/list'; import { usePublicFolderApi } from '@/api-clients/dashboard/public-folder/composables/use-public-folder-api'; import type { PublicFolderListParameters } from '@/api-clients/dashboard/public-folder/schema/api-verbs/list'; +import { useScopedQuery } from '@/query/composables/use-scoped-query'; import { useServiceQueryKey } from '@/query/query-key/use-service-query-key'; import ProjectDashboard from '@/services/project/v2/components/ProjectDashboard.vue';