diff --git a/frontend/src/components/project/labels/LabelSchemeCard.vue b/frontend/src/components/project/labels/LabelSchemeCard.vue index 969e5096..9c51d9e8 100644 --- a/frontend/src/components/project/labels/LabelSchemeCard.vue +++ b/frontend/src/components/project/labels/LabelSchemeCard.vue @@ -74,10 +74,10 @@ import {useToast} from '@/composables/useToast'; import LabelChip from './LabelChip.vue'; import CreateLabelForm from './CreateLabelForm.vue'; import Button from '@/components/common/Button.vue'; +import {PERMISSIONS} from '@/services/auth/permissions.types'; import Card from '@/components/common/Card.vue'; import ModalWindow from '@/components/common/modal/ModalWindow.vue'; import {AppLogger} from '@/core/logger/logger'; -import {PERMISSIONS} from '@/services/auth/permissions.types'; const logger = AppLogger.createComponentLogger('LabelSchemeCard'); @@ -216,4 +216,4 @@ onMounted(fetchLabels); opacity: 0.7; border-color: var(--color-gray-300); } - \ No newline at end of file + diff --git a/frontend/src/components/project/workflow/WorkflowCard.vue b/frontend/src/components/project/workflow/WorkflowCard.vue index ee62befd..1447cb2d 100644 --- a/frontend/src/components/project/workflow/WorkflowCard.vue +++ b/frontend/src/components/project/workflow/WorkflowCard.vue @@ -36,6 +36,7 @@ v-else :to="pipelineUrl" class="action-button secondary" + v-permission="{ permission: PERMISSIONS.WORKFLOW.UPDATE }" > View Pipeline @@ -47,6 +48,7 @@ :to="pipelineUrl" class="action-button secondary small" title="View workflow pipeline" + v-permission="{ permission: PERMISSIONS.WORKFLOW.UPDATE }" > Pipeline @@ -63,6 +65,7 @@ import { type ProjectRole } from '@/services/project/project.types'; import { WorkflowNavigationHelper } from '@/core/workflow'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { faDiagramProject } from '@fortawesome/free-solid-svg-icons'; +import { PERMISSIONS } from '@/services/auth/permissions.types'; interface Props { workflow: Workflow; @@ -233,4 +236,4 @@ const formatDate = (dateString: string): string => { } } } - \ No newline at end of file + diff --git a/frontend/src/composables/usePermissions.ts b/frontend/src/composables/usePermissions.ts index 9cebcff3..af7fe970 100644 --- a/frontend/src/composables/usePermissions.ts +++ b/frontend/src/composables/usePermissions.ts @@ -1,6 +1,6 @@ -import { computed, type ComputedRef } from 'vue'; +import { computed, reactive, type ComputedRef } from 'vue'; import { useAuthStore } from '@/stores/authStore'; -import { usePermissionStore } from '@/stores/permissionStore'; +import { authorizationService } from '@/services/auth'; import { useProjectStore } from '@/stores/projectStore'; /** @@ -9,7 +9,35 @@ import { useProjectStore } from '@/stores/projectStore'; */ export function usePermissions() { const authStore = useAuthStore(); - const permissionStore = usePermissionStore(); + // Lightweight reactive cache for authorization checks + const resultsCache = reactive>({}); + const pending = new Map>(); + + const makeKey = (perm: string, ctx?: { projectId?: number; global?: boolean }): string => { + const pid = ctx?.projectId ?? projectStore.currentProject?.id ?? 0; + const scope = ctx?.global ? 'g' : 'p'; + return `${scope}:${pid}:${perm}`; + }; + + const ensure = async (perm: string, ctx?: { projectId?: number; global?: boolean }): Promise => { + const key = makeKey(perm, ctx); + if (key in resultsCache) return resultsCache[key]; + if (pending.has(key)) return pending.get(key)!; + const promise = authorizationService + .authorize({ permission: perm, context: ctx?.projectId ? { projectId: ctx.projectId } : undefined }) + .then((allow) => { + resultsCache[key] = allow; + pending.delete(key); + return allow; + }) + .catch(() => { + resultsCache[key] = false; + pending.delete(key); + return false; + }); + pending.set(key, promise); + return promise; + }; const projectStore = useProjectStore(); // Permission-based checks @@ -18,37 +46,81 @@ export function usePermissions() { * Checks if user has a global permission (available regardless of project) */ const hasGlobalPermission = (permission: string): boolean => { - return permissionStore.hasGlobalPermission(permission); + // Trigger async fetch; return cached value if present + void ensure(permission, { global: true }); + const key = makeKey(permission, { global: true }); + return !!resultsCache[key]; }; /** * Checks if user has any of the specified global permissions */ const hasAnyGlobalPermission = (permissions: string[]): boolean => { - return permissionStore.hasAnyGlobalPermission(permissions); + permissions.forEach(p => void ensure(p, { global: true })); + return permissions.some(p => !!resultsCache[makeKey(p, { global: true })]); }; /** * Checks if user has all of the specified global permissions */ const hasAllGlobalPermissions = (permissions: string[]): boolean => { - return permissionStore.hasAllGlobalPermissions(permissions); + permissions.forEach(p => void ensure(p, { global: true })); + return permissions.every(p => !!resultsCache[makeKey(p, { global: true })]); }; /** - * Checks if user has a permission in the current project + * Checks if user has a permission in the current project (sync; warms cache asynchronously) */ const hasProjectPermission = (permission: string): boolean => { const currentProjectId = projectStore.currentProject?.id; if (!currentProjectId) return false; - return permissionStore.hasProjectPermission(permission, currentProjectId); + void ensure(permission, { projectId: currentProjectId }); + return !!resultsCache[makeKey(permission, { projectId: currentProjectId })]; + }; + + /** + * Asynchronously checks if user has a permission in the current project. + * Resolves after the authoritative backend check. + */ + const hasProjectPermissionAsync = async (permission: string): Promise => { + const currentProjectId = projectStore.currentProject?.id; + if (!currentProjectId) return false; + return ensure(permission, { projectId: currentProjectId }); }; /** * Checks if user has a permission in a specific project */ const hasPermissionInProject = (permission: string, projectId: number): boolean => { - return permissionStore.hasProjectPermission(permission, projectId); + if (!projectId) return false; + void ensure(permission, { projectId }); + return !!resultsCache[makeKey(permission, { projectId })]; + }; + + /** + * Prefetch a project-scoped permission to warm the cache. + */ + const prefetchProjectPermission = (permission: string, projectId?: number) => { + const pid = projectId ?? projectStore.currentProject?.id; + if (!pid) return Promise.resolve(false); + return ensure(permission, { projectId: pid }); + }; + + /** + * Wraps an action with a project-scoped permission check. If denied, logs and does nothing. + * Usage: const onClick = guardProjectAction(PERMISSIONS.TASK.READ, () => {...}) + */ + const guardProjectAction = (permission: string, action: (...args: any[]) => any, projectId?: number) => { + return async (...args: any[]) => { + const pid = projectId ?? projectStore.currentProject?.id; + if (!pid) return; + const allowed = await ensure(permission, { projectId: pid }); + if (!allowed) { + // TODO: soft-fail: optional place to integrate a toast/notification + return; + } + return action(...args); + }; }; /** @@ -57,7 +129,8 @@ export function usePermissions() { const hasAnyProjectPermission = (permissions: string[]): boolean => { const currentProjectId = projectStore.currentProject?.id; if (!currentProjectId) return false; - return permissionStore.hasAnyProjectPermission(permissions, currentProjectId); + permissions.forEach(p => void ensure(p, { projectId: currentProjectId })); + return permissions.some(p => !!resultsCache[makeKey(p, { projectId: currentProjectId })]); }; /** @@ -66,33 +139,24 @@ export function usePermissions() { const hasAllProjectPermissions = (permissions: string[]): boolean => { const currentProjectId = projectStore.currentProject?.id; if (!currentProjectId) return false; - return permissionStore.hasAllProjectPermissions(permissions, currentProjectId); + permissions.forEach(p => void ensure(p, { projectId: currentProjectId })); + return permissions.every(p => !!resultsCache[makeKey(p, { projectId: currentProjectId })]); }; /** * Checks if user has a permission anywhere (global or any project) */ const hasAnyPermission = (permission: string): boolean => { - return permissionStore.hasAnyPermission(permission); - }; - - /** - * Checks if user is a member of the current project - */ - const isCurrentProjectMember = computed(() => { + // Prefer project context when available const currentProjectId = projectStore.currentProject?.id; - if (!currentProjectId) return false; - return permissionStore.isProjectMember(currentProjectId); - }); + if (currentProjectId) { + return hasProjectPermission(permission); + } + return hasGlobalPermission(permission); + }; - /** - * Gets all permissions for the current project - */ - const currentProjectPermissions = computed(() => { - const currentProjectId = projectStore.currentProject?.id; - if (!currentProjectId) return new Set(); - return permissionStore.getProjectPermissions(currentProjectId); - }); + // Project membership proxy: treat project:read as membership capability + const isCurrentProjectMember = computed(() => hasProjectPermission('project:read')); // Reactive computed permission checkers @@ -126,13 +190,33 @@ export function usePermissions() { const canUpdateProject = computed(() => hasProjectPermission('project:update')); const canDeleteProject = computed(() => hasProjectPermission('project:delete')); const canManageProjectMembers = computed(() => hasProjectPermission('projectMember:invite')); - const canManageDataSources = computed(() => hasProjectPermission('dataSource:create')); - const canManageWorkflows = computed(() => hasProjectPermission('workflow:create')); - const canManageLabelSchemes = computed(() => hasProjectPermission('labelScheme:create')); + const canManageDataSources = computed(() => hasProjectPermission('dataSource:update')); + const canManageWorkflows = computed(() => hasProjectPermission('workflow:update')); + const canManageLabelSchemes = computed(() => hasProjectPermission('labelScheme:update')); const canCreateAnnotations = computed(() => hasProjectPermission('annotation:create')); const canReviewAnnotations = computed(() => hasProjectPermission('annotation:review')); const canAssignTasks = computed(() => hasProjectPermission('task:assign')); + // Manager role check: use authoritative role from backend + const isManager = (projectId: number): boolean => { + if (!projectId) return false; + if (projectId === projectStore.currentProject?.id) { + if (!projectStore.currentUserMembership) { + // Load membership in background if missing + void projectStore.loadCurrentUserMembership(projectId); + return false; + } + return projectStore.isCurrentUserManager; + } + // Not the current project: require caller to load membership explicitly via store if needed + return false; + }; + + const isManagerCurrentProject = computed(() => { + const pid = projectStore.currentProjectId; + return pid ? isManager(pid) : false; + }); + return { // Global permission checks hasGlobalPermission, @@ -141,6 +225,9 @@ export function usePermissions() { // Project permission checks hasProjectPermission, + hasProjectPermissionAsync, + prefetchProjectPermission, + guardProjectAction, hasPermissionInProject, hasAnyProjectPermission, hasAllProjectPermissions, @@ -148,7 +235,6 @@ export function usePermissions() { // General permission checks hasAnyPermission, isCurrentProjectMember, - currentProjectPermissions, // Reactive permission checkers canDoGlobally, @@ -168,10 +254,10 @@ export function usePermissions() { canCreateAnnotations, canReviewAnnotations, canAssignTasks, + isManager, + isManagerCurrentProject, - // Store references for advanced usage - permissionStore, authStore, projectStore, }; -} \ No newline at end of file +} diff --git a/frontend/src/directives/vPermission.ts b/frontend/src/directives/vPermission.ts index f934f622..bda0555e 100644 --- a/frontend/src/directives/vPermission.ts +++ b/frontend/src/directives/vPermission.ts @@ -1,7 +1,7 @@ import type { App, DirectiveBinding } from 'vue'; import { watch } from 'vue'; -import { usePermissionStore } from '@/stores/permissionStore'; import { useProjectStore } from '@/stores/projectStore'; +import { authorizationService } from '@/services/auth'; import { AppLogger } from '@/core/logger/logger'; import type { PermissionDirectiveBinding } from '@/services/auth/permissions.types'; @@ -9,32 +9,25 @@ const logger = AppLogger.createServiceLogger('vPermission'); export const vPermission = { mounted(el: HTMLElement, binding: DirectiveBinding) { - const permissionStore = usePermissionStore(); const projectStore = useProjectStore(); - // Initial check - checkPermission(el, binding); + // Initial check (async) + void checkPermission(el, binding); - // Watch for changes in permission store initialization and project ID - const unwatchPermissions = watch( - () => permissionStore.isInitialized, - () => { - checkPermission(el, binding); - } - ); + // Watch for project changes to re-evaluate const unwatchProject = watch( () => projectStore.currentProjectId, () => { - checkPermission(el, binding); + void checkPermission(el, binding); } ); // Store cleanup functions on the element for unmount - (el as any)._permissionWatchers = [unwatchPermissions, unwatchProject]; + (el as any)._permissionWatchers = [unwatchProject]; }, updated(el: HTMLElement, binding: DirectiveBinding) { - checkPermission(el, binding); + void checkPermission(el, binding); }, unmounted(el: HTMLElement) { // Cleanup watchers when directive is unmounted @@ -46,15 +39,8 @@ export const vPermission = { } }; -function checkPermission(el: HTMLElement, binding: DirectiveBinding) { - const permissionStore = usePermissionStore(); +async function checkPermission(el: HTMLElement, binding: DirectiveBinding) { const projectStore = useProjectStore(); - - // If permission store is not initialized, hide element by default - if (!permissionStore.isInitialized) { - hideElement(el); - return; - } const value = binding.value || {}; const { @@ -82,35 +68,41 @@ function checkPermission(el: HTMLElement, binding: DirectiveBinding ({ permission: p })) + ); + hasPermission = results.every(Boolean); } else { - hasPermission = permissionStore.hasAnyGlobalPermission(permissionsToCheck); + const { results } = await authorizationService.authorizeBatch( + permissionsToCheck.map(p => ({ permission: p })) + ); + hasPermission = results.some(Boolean); } } else { - // Check project permissions const projectId = project || projectStore.currentProject?.id; - if (!projectId) { logger.debug('No project ID available for permission check (project still loading)'); - hideElement(el); - return; + return; // remain hidden } - if (mode === 'all') { - hasPermission = permissionStore.hasAllProjectPermissions(permissionsToCheck, projectId); + const { results } = await authorizationService.authorizeBatch( + permissionsToCheck.map(p => ({ permission: p, context: { projectId } })) + ); + hasPermission = results.every(Boolean); } else { - hasPermission = permissionStore.hasAnyProjectPermission(permissionsToCheck, projectId); + const { results } = await authorizationService.authorizeBatch( + permissionsToCheck.map(p => ({ permission: p, context: { projectId } })) + ); + hasPermission = results.some(Boolean); } } - if (hasPermission) { - showElement(el); - } else { - hideElement(el); - } + if (hasPermission) showElement(el); } function showElement(el: HTMLElement) { @@ -127,4 +119,4 @@ export function registerPermissionDirective(app: App) { app.directive('permission', vPermission); } -export default vPermission; \ No newline at end of file +export default vPermission; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 2290d9d8..49778d06 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -9,7 +9,6 @@ import router from './router' import {setupInterceptors} from '@/services/apiClient' import {useAuthStore} from '@/stores/authStore' -import {usePermissionStore} from '@/stores/permissionStore' import {logger, piniaLogger, AppLogger} from '@/core/logger/logger' import {useErrorHandler} from '@/composables/useErrorHandler' import {registerPermissionDirective} from '@/directives/vPermission'; @@ -45,8 +44,7 @@ async function initializeApp() { app.directive('click-outside', clickOutside); const authStore = useAuthStore(); - const permissionStore = usePermissionStore(); - setupInterceptors(authStore, permissionStore); + setupInterceptors(authStore); // Auth will be initialized lazily by the router when needed // This prevents unnecessary token refresh attempts on public pages @@ -101,4 +99,4 @@ initializeApp().catch(error => { `; -}); \ No newline at end of file +}); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 58b753ff..ae813cf0 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -5,9 +5,10 @@ import DefaultLayout from '@/layouts/DefaultLayout.vue'; import WorkspaceLayout from '@/layouts/WorkspaceLayout.vue'; import DataExplorerLayout from '@/layouts/DataExplorerLayout.vue'; import { useAuthStore } from '@/stores/authStore'; -import { usePermissionStore } from '@/stores/permissionStore'; +import { authorizationService } from '@/services/auth'; import { AppLogger } from '@/core/logger/logger'; import { PUBLIC_ROUTE_NAMES, AUTH_ROUTE_NAMES } from './routes'; +import { PERMISSIONS } from '@/services/auth/permissions.types'; const routes: Array = [ { @@ -80,6 +81,7 @@ const routes: Array = [ props: true, meta: { layout: WorkspaceLayout, + permissions: [PERMISSIONS.TASK.READ], } }, { @@ -97,6 +99,7 @@ const routes: Array = [ props: true, meta: { layout: DefaultLayout, + permissions: [PERMISSIONS.PROJECT.READ], }, children: [ { @@ -104,18 +107,21 @@ const routes: Array = [ name: 'ProjectDashboard', component: () => import('@/views/project/ProjectDashboardView.vue'), props: true, + meta: { permissions: [PERMISSIONS.PROJECT.READ] }, }, { path: 'label-schemes', name: 'ProjectLabels', component: () => import('@/views/project/LabelSchemesView.vue'), props: true, + meta: { permissions: [PERMISSIONS.LABEL_SCHEME.READ] }, }, { path: 'data-sources', name: 'ProjectDataSources', component: () => import('@/views/project/DataSourcesView.vue'), props: true, + meta: { permissions: [PERMISSIONS.DATA_SOURCE.UPDATE] }, }, { path: 'data-explorer/:dataSourceId', @@ -124,6 +130,7 @@ const routes: Array = [ props: true, meta: { layout: DataExplorerLayout, + permissions: [PERMISSIONS.DATA_SOURCE.UPDATE], } }, { @@ -131,24 +138,28 @@ const routes: Array = [ name: 'ProjectWorkflows', component: () => import('@/views/project/WorkflowsView.vue'), props: true, + meta: { permissions: [PERMISSIONS.WORKFLOW.READ] }, }, { path: 'workflows/:workflowId/pipeline', name: 'WorkflowPipeline', component: () => import('@/views/project/WorkflowPipelineView.vue'), props: true, + meta: { permissions: [PERMISSIONS.WORKFLOW.UPDATE] }, }, { path: 'workflows/:workflowId/stages/:stageId/tasks', name: 'StageTasks', component: () => import('@/views/project/TasksView.vue'), props: true, + meta: { permissions: [PERMISSIONS.TASK.READ] }, }, { path: 'settings', name: 'ProjectSettings', component: () => import('@/views/project/ProjectSettingsView.vue'), props: true, + meta: { permissions: [PERMISSIONS.PROJECT_SETTINGS.READ] }, }, ], }, @@ -177,7 +188,6 @@ const logger = AppLogger.createServiceLogger('Router'); // Navigation guards router.beforeEach(async (to, from, next) => { const authStore = useAuthStore(); - const permissionStore = usePermissionStore(); const isPublicRoute = PUBLIC_ROUTE_NAMES.includes(to.name as typeof PUBLIC_ROUTE_NAMES[number]); const isAuthRoute = AUTH_ROUTE_NAMES.includes(to.name as typeof AUTH_ROUTE_NAMES[number]); @@ -228,70 +238,27 @@ router.beforeEach(async (to, from, next) => { } else if (!isPublicRoute && !authStore.isAuthenticated) { // If user is not authenticated and trying to access protected routes, redirect to login next({ name: 'Login' }); - } else if (authStore.isAuthenticated && to.params.projectId) { - // Project-specific route - validate project membership using permission store - try { - const projectId = Number(to.params.projectId); - if (projectId && !isNaN(projectId)) { - // Ensure permissions are loaded - if (!permissionStore.isInitialized) { - await permissionStore.loadUserPermissions(); - } - - // Check if user is a member of the project - if (!permissionStore.isProjectMember(projectId)) { - logger.warn(`User is not a member of project ${projectId}`); - next({ name: 'Error', params: { type: 'unauthorized' } }); - return; - } - - // Check if route requires specific permissions - const requiredPermissions = to.meta?.permissions as string[] | undefined; - if (requiredPermissions && requiredPermissions.length > 0) { - const hasRequiredPermissions = permissionStore.hasAllProjectPermissions( - requiredPermissions, - projectId - ); - - if (!hasRequiredPermissions) { - logger.warn( - `User lacks required permissions for route ${String(to.name)}: ${requiredPermissions.join(', ')}` - ); - next({ name: 'Error', params: { type: 'forbidden' } }); - return; - } - } - - logger.info(`User has access to project ${projectId}`); - } + } else if (authStore.isAuthenticated) { + // If route defines permissions, verify via backend authorize (batch) + const requiredPermissions = (to.meta?.permissions as string[] | undefined) || []; + if (requiredPermissions.length === 0) { next(); - } catch (error) { - logger.error('Error validating project access', error); - // If validation fails, redirect to error page - next({ name: 'Error', params: { type: 'unauthorized' } }); + return; } - } else if (authStore.isAuthenticated && to.meta?.permissions) { - // Global route with permission requirements + try { - // Ensure permissions are loaded - if (!permissionStore.isInitialized) { - await permissionStore.loadUserPermissions(); - } - - const requiredPermissions = to.meta.permissions as string[]; - const hasRequiredPermissions = permissionStore.hasAllGlobalPermissions(requiredPermissions); - - if (!hasRequiredPermissions) { - logger.warn( - `User lacks required global permissions for route ${String(to.name)}: ${requiredPermissions.join(', ')}` - ); + const projectId = to.params.projectId ? Number(to.params.projectId) : undefined; + const checks = requiredPermissions.map(p => ({ permission: p, context: projectId ? { projectId } : undefined })); + const { results } = await authorizationService.authorizeBatch(checks); + const allow = results.every(Boolean); + if (!allow) { + logger.warn(`Permission denied for route ${String(to.name)} with perms: ${requiredPermissions.join(', ')}`); next({ name: 'Error', params: { type: 'forbidden' } }); return; } - next(); } catch (error) { - logger.error('Error validating global permissions', error); + logger.error('Authorization check failed', error); next({ name: 'Error', params: { type: 'unauthorized' } }); } } else { diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index 8c4f12b1..66e65496 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -2,7 +2,6 @@ import axios from 'axios'; import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import { env } from '@/config/env'; import type { useAuthStore } from '@/stores/authStore'; -import type { usePermissionStore } from '@/stores/permissionStore'; import { isAuthOrPublicPath, isAuthPath } from '@/router/routes'; import { isPermissionChangingEndpoint, getPermissionChangeDescription } from '@/core/interceptors'; import { AppLogger } from '@/core/logger/logger'; @@ -41,11 +40,9 @@ const processQueue = (error: Error | null, token: string | null = null) => { * Sets up the Axios interceptors for handling authentication and token refresh. * This should be called once when the application initializes. * @param authStore An instance of the authentication store. - * @param permissionStore An instance of the permission store. */ export function setupInterceptors( - authStore: ReturnType, - permissionStore: ReturnType + authStore: ReturnType ): void { // Request Interceptor: Add JWT token whenever available apiClient.interceptors.request.use( @@ -69,18 +66,9 @@ export function setupInterceptors( const { url, method } = response.config; if (response.status >= 200 && response.status < 300 && isPermissionChangingEndpoint(url, method || 'GET')) { - const description = getPermissionChangeDescription(url, method || 'GET'); logger.info(`Permission-changing request completed: ${method?.toUpperCase()} ${url} - ${description}`); - - // Automatically refresh permissions in the background - try { - await permissionStore.refreshPermissions(); - logger.info('Automatically refreshed permissions after API call'); - } catch (error) { - // Don't fail the original request if permission refresh fails - logger.warn('Failed to automatically refresh permissions after API call', error); - } + // FE now uses on-demand authorization checks; no global refresh required. } return response; @@ -158,4 +146,4 @@ export function setupInterceptors( ); } -export default apiClient; \ No newline at end of file +export default apiClient; diff --git a/frontend/src/services/auth/authorizationService.ts b/frontend/src/services/auth/authorizationService.ts new file mode 100644 index 00000000..34567a63 --- /dev/null +++ b/frontend/src/services/auth/authorizationService.ts @@ -0,0 +1,46 @@ +import { BaseService } from '../base/baseService'; +import { AppLogger } from '@/core/logger/logger'; + +export interface AuthorizationCheckContext { + projectId?: number; + [key: string]: any; +} + +export interface AuthorizationCheck { + permission: string; + context?: AuthorizationCheckContext; +} + +export interface AuthorizationBatchResponse { + results: boolean[]; + policyVersion?: string; +} + +/** + * AuthorizationService calls backend to evaluate permissions authoritatively. + * This replaces frontend-cached permission context and supports batching. + */ +class AuthorizationService extends BaseService { + protected readonly baseUrl = '/permissions'; + private readonly log = AppLogger.createServiceLogger('AuthorizationService'); + + async authorize(check: AuthorizationCheck): Promise { + const resp = await this.post( + this.getBaseUrl('authorize'), + check + ); + this.log.info(`authorize(${check.permission}) => ${resp.allow}`); + return resp.allow; + } + + async authorizeBatch(checks: AuthorizationCheck[]): Promise { + const resp = await this.post<{ checks: AuthorizationCheck[] }, AuthorizationBatchResponse>( + this.getBaseUrl('authorize/batch'), + { checks } + ); + this.log.info(`authorizeBatch(${checks.length}) => [${resp.results.join(',')}]`); + return resp; + } +} + +export const authorizationService = new AuthorizationService("AuthorizationService"); diff --git a/frontend/src/services/auth/index.ts b/frontend/src/services/auth/index.ts index 79a358ac..abd5cf81 100644 --- a/frontend/src/services/auth/index.ts +++ b/frontend/src/services/auth/index.ts @@ -1,2 +1,2 @@ export { authService } from './authService'; -export { permissionService } from './permissionService'; \ No newline at end of file +export { authorizationService } from './authorizationService'; diff --git a/frontend/src/services/auth/permissionService.ts b/frontend/src/services/auth/permissionService.ts deleted file mode 100644 index ff0b9f33..00000000 --- a/frontend/src/services/auth/permissionService.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { BaseService } from '../base/baseService'; -import type { UserPermissionContext } from './permissions.types'; - -/** - * Service class for managing application configuration and permissions. - */ -class PermissionService extends BaseService { - protected readonly baseUrl = '/configuration'; // TODO: Check BE and change endpoint to permission - - constructor() { - super('ConfigurationService'); - } - - /** - * Gets the full user permission context including all project memberships and global permissions. - * This is used for comprehensive permission caching on the frontend. - */ - async getUserPermissionContext(): Promise { - this.logger.info('Fetching user permission context...'); - - const response = await this.get( - this.getBaseUrl('permissions/user-context') - ); - - this.logger.info( - `Fetched permission context with ${response.permissions.length} total permissions across ${Object.keys(response.projectPermissions).length} projects` - ); - return response; - } - - /** - * Gets page-specific permissions for the current user. - * Hybrid mode endpoint that provides permissions on-demand for specific pages/routes. - */ - async getPagePermissions(page: string, projectId?: number): Promise { - this.logger.info(`Fetching page permissions for page '${page}'${projectId ? ` with project ${projectId}` : ''}...`); - - const params: Record = { page }; - if (projectId) { - params.projectId = projectId; - } - - const response = await this.get( - this.getBaseUrl('permissions/page'), - { params } - ); - - this.logger.info(`Fetched ${response.length} permissions for page '${page}'`); - return response; - } - - /** - * Admin-only endpoint to reload permission configuration from file - */ - async reloadPermissionConfiguration(): Promise { - this.logger.info('Reloading permission configuration...'); - - await this.post(this.getBaseUrl('permissions/reload'), undefined); - - this.logger.info('Permission configuration reloaded successfully'); - } -} - -// Export singleton instance -export const permissionService = new PermissionService(); diff --git a/frontend/src/services/auth/permissions.types.ts b/frontend/src/services/auth/permissions.types.ts index 5062eb96..43c3cf78 100644 --- a/frontend/src/services/auth/permissions.types.ts +++ b/frontend/src/services/auth/permissions.types.ts @@ -1,11 +1,4 @@ -/** - * User permission context containing all applicable permissions for a user - */ -export interface UserPermissionContext { - permissions: string[]; - projectPermissions: Record; - globalPermissions: string[]; -} +// FE no longer stores permission contexts; backend is source of truth. /** * Permission directive binding interface for v-permission directive @@ -89,4 +82,4 @@ export const PERMISSIONS = { DELETE: 'annotation:delete', REVIEW: 'annotation:review' } -} as const; \ No newline at end of file +} as const; diff --git a/frontend/src/stores/__tests__/authStore.test.ts b/frontend/src/stores/__tests__/authStore.test.ts index dbcbeb75..8455aef8 100644 --- a/frontend/src/stores/__tests__/authStore.test.ts +++ b/frontend/src/stores/__tests__/authStore.test.ts @@ -11,12 +11,7 @@ vi.mock("@/services/auth/authService", () => ({ }, })); -vi.mock("../permissionStore", () => ({ - usePermissionStore: vi.fn(() => ({ - loadUserPermissions: vi.fn().mockResolvedValue(undefined), - clearPermissions: vi.fn(), - })), -})); +// No permission store in FE anymore; authorization is backend-driven. import { authService } from "@/services/auth/authService.ts"; import { RoleEnum, type AuthTokens, type LoginDto, type UserDto } from "@/services/auth/auth.types"; diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index c2e983c2..03514aa1 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -4,7 +4,6 @@ import { authService } from "@/services/auth/authService"; import { env } from "@/config/env"; import { AppLogger } from "@/core/logger/logger"; import { LastProjectManager } from "@/core/persistence"; -import { usePermissionStore } from "./permissionStore"; import { isUnauthorizedError } from "@/core/errors/errors"; import type { AuthTokens, LoginDto, RegisterDto, RoleEnum, UserDto } from "@/services/auth/auth.types"; @@ -75,16 +74,7 @@ export const useAuthStore = defineStore("auth", { // Only fetch user data if we successfully got tokens await this.getCurrentUser(); - logger.info("User data fetched successfully, loading permissions"); - // Load permissions after getting user data - try { - const permissionStore = usePermissionStore(); - await permissionStore.loadUserPermissions(); - logger.info("User permissions loaded during auth initialization"); - } catch (permissionError) { - logger.warn("Failed to load permissions during initialization", permissionError); - // Don't fail initialization if permissions fail to load - } + logger.info("User data fetched successfully"); } else { logger.info("Token refresh failed during initialization - user will start as guest"); } @@ -105,15 +95,7 @@ export const useAuthStore = defineStore("auth", { this.user = response.user; this.tokens = response.tokens; - // Load user permissions after successful login - try { - const permissionStore = usePermissionStore(); - await permissionStore.loadUserPermissions(); - logger.info("User permissions loaded after login"); - } catch (permissionError) { - logger.warn("Failed to load permissions after login", permissionError); - // Don't fail login if permissions fail to load - } + // Permissions now checked on-demand via backend authorize } catch (error) { logger.error("Login failed", error); @@ -146,9 +128,7 @@ export const useAuthStore = defineStore("auth", { } catch (error) { logger.error("Logout request failed", error); } finally { - // Clear permission data on logout - const permissionStore = usePermissionStore(); - permissionStore.clearPermissions(); + // No frontend permission cache to clear this.user = null; this.tokens = null; @@ -162,9 +142,7 @@ export const useAuthStore = defineStore("auth", { LastProjectManager.clearLastProject(this.user.email); } - // Clear permission data - const permissionStore = usePermissionStore(); - permissionStore.clearPermissions(); + // No frontend permission cache to clear this.user = null; this.tokens = null; diff --git a/frontend/src/stores/permissionStore.ts b/frontend/src/stores/permissionStore.ts deleted file mode 100644 index 213def28..00000000 --- a/frontend/src/stores/permissionStore.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { defineStore } from "pinia"; -import { permissionService } from "@/services/auth"; -import type { UserPermissionContext } from "@/services/auth/permissions.types"; -import { AppLogger } from "@/core/logger/logger"; - -const logger = AppLogger.createStoreLogger('PermissionStore'); - -export const usePermissionStore = defineStore("permissions", { - state: () => ({ - userContext: null as UserPermissionContext | null, - isLoading: false, - isInitialized: false, - error: null as string | null, - }), - - getters: { - /** - * Gets all permissions for the current user (global + all project permissions) - */ - allPermissions(state): Set { - if (!state.userContext) return new Set(); - return new Set(state.userContext.permissions); - }, - - /** - * Gets global permissions (available regardless of project context) - */ - globalPermissions(state): Set { - if (!state.userContext) return new Set(); - return new Set(state.userContext.globalPermissions); - }, - - /** - * Gets permissions for a specific project - */ - getProjectPermissions: (state) => (projectId: number): Set => { - if (!state.userContext || !projectId) return new Set(); - const projectPermissions = state.userContext.projectPermissions[projectId]; - return new Set(projectPermissions || []); - }, - - /** - * Checks if user has a specific permission globally - */ - hasGlobalPermission: (state) => (permission: string): boolean => { - return state.userContext?.globalPermissions.includes(permission) || false; - }, - - /** - * Checks if user has a specific permission in any project - */ - hasAnyPermission: (state) => (permission: string): boolean => { - return state.userContext?.permissions.includes(permission) || false; - }, - - /** - * Checks if user has a specific permission in a specific project - */ - hasProjectPermission: (state) => (permission: string, projectId: number): boolean => { - if (!state.userContext || !projectId) return false; - const projectPermissions = state.userContext.projectPermissions[projectId]; - return projectPermissions?.includes(permission) || false; - }, - - /** - * Checks if user has any of the specified permissions globally - */ - hasAnyGlobalPermission: (state) => (permissions: string[]): boolean => { - if (!state.userContext) return false; - return permissions.some(permission => - state.userContext!.globalPermissions.includes(permission) - ); - }, - - /** - * Checks if user has all of the specified permissions globally - */ - hasAllGlobalPermissions: (state) => (permissions: string[]): boolean => { - if (!state.userContext) return false; - return permissions.every(permission => - state.userContext!.globalPermissions.includes(permission) - ); - }, - - /** - * Checks if user has any of the specified permissions in a project - */ - hasAnyProjectPermission: (state) => (permissions: string[], projectId: number): boolean => { - if (!state.userContext || !projectId) return false; - const projectPermissions = state.userContext.projectPermissions[projectId] || []; - return permissions.some(permission => projectPermissions.includes(permission)); - }, - - /** - * Checks if user has all of the specified permissions in a project - */ - hasAllProjectPermissions: (state) => (permissions: string[], projectId: number): boolean => { - if (!state.userContext || !projectId) return false; - const projectPermissions = state.userContext.projectPermissions[projectId] || []; - return permissions.every(permission => projectPermissions.includes(permission)); - }, - - /** - * Gets all project IDs where user has membership - */ - projectIds(state): number[] { - if (!state.userContext) return []; - return Object.keys(state.userContext.projectPermissions).map(id => parseInt(id)); - }, - - /** - * Checks if user is a member of a specific project - */ - isProjectMember: (state) => (projectId: number): boolean => { - if (!state.userContext || !projectId) return false; - return Object.prototype.hasOwnProperty.call(state.userContext.projectPermissions, projectId); - }, - }, - - actions: { - /** - * Loads the full user permission context from the backend - */ - async loadUserPermissions(): Promise { - if (this.isLoading) return; - - this.isLoading = true; - this.error = null; - - try { - logger.info("Loading user permission context"); - this.userContext = await permissionService.getUserPermissionContext(); - this.isInitialized = true; - - const permissionCount = this.userContext.permissions.length; - const projectCount = this.projectIds.length; - - logger.info(`Loaded ${permissionCount} total permissions for ${projectCount} projects`); - } catch (error) { - this.error = "Failed to load user permissions"; - logger.error("Failed to load user permission context", error); - throw error; - } finally { - this.isLoading = false; - } - }, - - /** - * Gets page-specific permissions (hybrid approach) - */ - async getPagePermissions(page: string, projectId?: number): Promise { - try { - logger.debug(`Getting page permissions for: ${page}${projectId ? ` (project: ${projectId})` : ''}`); - return await permissionService.getPagePermissions(page, projectId); - } catch (error) { - logger.error(`Failed to get page permissions for ${page}`, error); - return []; - } - }, - - /** - * Clears all permission data (for logout) - */ - clearPermissions(): void { - this.userContext = null; - this.isInitialized = false; - this.error = null; - logger.info("Cleared user permissions"); - }, - - /** - * Refreshes permission data from the backend - */ - async refreshPermissions(): Promise { - this.isInitialized = false; - await this.loadUserPermissions(); - }, - - /** - * Admin-only: Reload permission configuration from file - */ - async reloadConfiguration(): Promise { - try { - await permissionService.reloadPermissionConfiguration(); - // Refresh user permissions after configuration reload - await this.refreshPermissions(); - logger.info("Permission configuration reloaded successfully"); - } catch (error) { - logger.error("Failed to reload permission configuration", error); - throw error; - } - }, - }, -}); \ No newline at end of file diff --git a/frontend/src/stores/projectStore.ts b/frontend/src/stores/projectStore.ts index 604e24ad..ea5bdbf7 100644 --- a/frontend/src/stores/projectStore.ts +++ b/frontend/src/stores/projectStore.ts @@ -16,6 +16,7 @@ const logger = AppLogger.createServiceLogger("ProjectStore"); export const useProjectStore = defineStore("project", { state: (): ProjectState => ({ currentProject: null, + currentUserMembership: null, teamMembers: [], projectLabels: [], loading: false, @@ -48,6 +49,12 @@ export const useProjectStore = defineStore("project", { }, labelCount(state): number { return state.projectLabels.length; + }, + currentUserRole(state): ProjectRole | null { + return state.currentUserMembership?.role || null; + }, + isCurrentUserManager(state): boolean { + return state.currentUserMembership?.role === 'MANAGER'; } }, @@ -75,6 +82,7 @@ export const useProjectStore = defineStore("project", { // Load related data in parallel await Promise.all([ this.loadTeamMembers(projectId), + this.loadCurrentUserMembership(projectId), ]); } catch (error) { logger.error(`Failed to load project ${projectId}`, error); @@ -101,6 +109,18 @@ export const useProjectStore = defineStore("project", { } }, + async loadCurrentUserMembership(projectId: number): Promise { + try { + const membership = await projectMemberService.getCurrentUserMembership(projectId); + this.currentUserMembership = membership; + logger.info(`Loaded current user membership for project ${projectId}: ${membership?.role || 'none'}`); + } catch (error) { + logger.error(`Failed to load current user membership for project ${projectId}`, error); + // Don't throw error for membership loading as user might not be a member + this.currentUserMembership = null; + } + }, + addTeamMember(member: ProjectMember): void { this.teamMembers.push(member); logger.info(`Added team member: ${member.userName || member.email}`); @@ -150,6 +170,7 @@ export const useProjectStore = defineStore("project", { clearProject(): void { this.currentProject = null; + this.currentUserMembership = null; this.teamMembers = []; this.projectLabels = []; this.currentStageType = ''; diff --git a/frontend/src/stores/projectStore.types.ts b/frontend/src/stores/projectStore.types.ts index 6f4cdf3d..17718c00 100644 --- a/frontend/src/stores/projectStore.types.ts +++ b/frontend/src/stores/projectStore.types.ts @@ -4,6 +4,7 @@ import type { Label } from "@/services/project/labelScheme/label.types"; export interface ProjectState { currentProject: Project | null; + currentUserMembership: ProjectMember | null; teamMembers: ProjectMember[]; projectLabels: Label[]; loading: boolean; diff --git a/frontend/src/views/AnnotationWorkspace.vue b/frontend/src/views/AnnotationWorkspace.vue index f5d7c7bd..5b6cedf9 100644 --- a/frontend/src/views/AnnotationWorkspace.vue +++ b/frontend/src/views/AnnotationWorkspace.vue @@ -139,7 +139,7 @@ import ModalWindow from "@/components/common/modal/ModalWindow.vue"; import {FontAwesomeIcon} from "@fortawesome/vue-fontawesome"; import {faPause, faForward, faUndo} from "@fortawesome/free-solid-svg-icons"; import {usePermissions} from "@/composables/usePermissions"; -import {PERMISSIONS} from "@/services/auth/permissions.types"; +import { PERMISSIONS } from '@/services/auth/permissions.types'; import {useToast} from "@/composables/useToast"; const props = defineProps({ diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index ada022e5..e102a272 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -67,9 +67,7 @@ const projects = ref([]); const loading = ref(false); const error = ref(null); -const canCreateProject = computed(() => { - return hasGlobalPermission(PERMISSIONS.PROJECT.CREATE); -}); +const canCreateProject = computed(() => hasGlobalPermission(PERMISSIONS.PROJECT.CREATE)); const openModal = () => isModalOpen.value = true; const closeModal = () => isModalOpen.value = false; @@ -183,4 +181,4 @@ onMounted(() => { transform: scale(1.1); } } - \ No newline at end of file + diff --git a/frontend/src/views/dataExplorer/DataExplorerView.vue b/frontend/src/views/dataExplorer/DataExplorerView.vue index ccb4a13f..84b9fbbe 100644 --- a/frontend/src/views/dataExplorer/DataExplorerView.vue +++ b/frontend/src/views/dataExplorer/DataExplorerView.vue @@ -1,10 +1,10 @@