Skip to content
Merged
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
82 changes: 0 additions & 82 deletions apps/web/src/api-clients/_common/composables/use-scoped-query.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand Down
135 changes: 135 additions & 0 deletions apps/web/src/query/composables/use-scoped-query.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;


export const useScopedQuery = <TQueryFnData, TError = unknown, TData = TQueryFnData>(
options: UseQueryOptions<TQueryFnData, TError, TData>,
requiredScopes: [GrantScope, ...GrantScope[]],
): UseQueryReturnType<TData, TError> => {
// [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<GrantScope | undefined>(
() => 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<TQueryFnData, TError, TData>({
...options,
enabled: queryEnabled,
});
};

const _extractQueryKey = (input: unknown): QueryKeyArray => toValue(input as ComputedRef<QueryKeyArray>);



/* Warning Logger Utilities */
const _warnedKeys = new Set<string>();
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));
}
};
2 changes: 1 addition & 1 deletion apps/web/src/query/query-key/_types/query-key-type.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -67,7 +67,7 @@ export const useDashboardQuery = (): UseDashboardQueryReturn => {
});

/* Querys */
const publicDashboardListQuery = useScopedQuery<ListResponse<PublicDashboardModel>, unknown, PublicDashboardModel[]>({
const publicDashboardListQuery = useScopedQuery({
queryKey: publicDashboardListQueryKey,
queryFn: () => publicDashboardAPI.list(publicDashboardListParams.value),
select: (data) => data?.results ?? [],
Expand All @@ -76,7 +76,7 @@ export const useDashboardQuery = (): UseDashboardQueryReturn => {
staleTime: STALE_TIME,
enabled: computed(() => !!_state.publicDashboardListApiQuery?.filter),
}, ['DOMAIN', 'WORKSPACE']);
const privateDashboardListQuery = useScopedQuery<ListResponse<PrivateDashboardModel>, unknown, PrivateDashboardModel[]>({
const privateDashboardListQuery = useScopedQuery({
queryKey: privateDashboardListQueryKey,
queryFn: () => privateDashboardAPI.list(privateDashboardListParams.value),
select: (data) => data?.results || [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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: [] };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading