From 294e743a2497683bce7bdcf0b873afdff81a3659 Mon Sep 17 00:00:00 2001 From: Ha Phan Tran Date: Mon, 15 Jun 2026 17:08:06 -0400 Subject: [PATCH 1/2] Show asset creator and add admin owner filter - Add a reusable CreatedBy label and surface 'Created by {email}' on the Models, Metamodels, Views, and Viewpoints listings. Owner data already rides on the API listings (ownerEmail for non-owned, current user otherwise); thread isOwner/ownerEmail through the frontend asset types and preserve them in the transformation-rule mapping. - Add a global, admin-only 'Filter by owner' control in the sidebar (OwnerFilterContext + OwnerFilterControl) so admins can narrow the browse lists to specific users and reduce on-screen clutter. Renders nothing for non-admins, so it has no effect for them. --- frontend/src/App.tsx | 15 ++++-- .../__tests__/components/CreatedBy.test.tsx | 34 ++++++++++++ frontend/src/components/common/CreatedBy.tsx | 32 ++++++++++++ .../components/common/OwnerFilterControl.tsx | 52 +++++++++++++++++++ frontend/src/components/common/index.ts | 2 + frontend/src/components/layout/Sidebar.tsx | 3 ++ .../components/metamodel/MetamodelManager.tsx | 7 ++- .../src/components/model/ModelManager.tsx | 14 +++-- .../viewpoints/ViewpointManager.tsx | 9 +++- frontend/src/contexts/OwnerFilterContext.tsx | 43 +++++++++++++++ frontend/src/models/types.ts | 12 +++++ frontend/src/services/common/ownership.ts | 18 +++++++ .../src/services/testing/testing-types.ts | 2 + .../transformation-api-sync.service.ts | 2 + 14 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 frontend/src/__tests__/components/CreatedBy.test.tsx create mode 100644 frontend/src/components/common/CreatedBy.tsx create mode 100644 frontend/src/components/common/OwnerFilterControl.tsx create mode 100644 frontend/src/contexts/OwnerFilterContext.tsx create mode 100644 frontend/src/services/common/ownership.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1695b13..141d4ea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,9 +51,10 @@ import ForgotPasswordPage from './components/auth/ForgotPasswordPage'; import ResetPasswordPage from './components/auth/ResetPasswordPage'; import RoleRequestDialog from './components/auth/RoleRequestDialog'; import { AdminPanel } from './components/admin'; -import { ShareDialog } from './components/common'; +import { ShareDialog, CreatedBy } from './components/common'; import { Sidebar } from './components/layout'; import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { OwnerFilterProvider, useOwnerFilterMatcher } from './contexts/OwnerFilterContext'; import { metamodelService } from './services/metamodel'; import { diagramService } from './services/diagram'; import { modelService } from './services/model'; @@ -183,7 +184,9 @@ const App: React.FC = () => { - + + + ); @@ -1391,6 +1394,7 @@ const HelpPage: React.FC = () => { const DiagramsPage: React.FC = () => { const navigate = useNavigate(); const { canCreate, canDelete, canShare } = useAuth(); + const matchesOwner = useOwnerFilterMatcher(); const [diagrams, setDiagrams] = useState(diagramService.getAllDiagrams()); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [models, setModels] = useState(modelService.getAllModels()); @@ -1436,14 +1440,14 @@ const DiagramsPage: React.FC = () => { const groupedDiagrams = useMemo(() => ( groupByParent( - diagrams, + diagrams.filter(matchesOwner), (diagram: Diagram) => { const model = models.find((candidate: Model) => candidate.id === diagram.modelId); return model?.conformsTo || model?.metamodelId; }, (parentId: string) => getMetamodelName(parentId) ) - ), [diagrams, models, getMetamodelName]); + ), [diagrams, models, getMetamodelName, matchesOwner]); const selectedViewpoint = useMemo( () => availableViewpoints.find(viewpoint => viewpoint.id === selectedViewpointId), @@ -1794,8 +1798,9 @@ const DiagramsPage: React.FC = () => { {diagram.description || 'No description'} + - Model: {model?.name || 'Unknown'} + Model: {model?.name || 'Unknown'} Metamodel: {metamodel?.name || 'Unknown'} diff --git a/frontend/src/__tests__/components/CreatedBy.test.tsx b/frontend/src/__tests__/components/CreatedBy.test.tsx new file mode 100644 index 0000000..12e5393 --- /dev/null +++ b/frontend/src/__tests__/components/CreatedBy.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { resolveOwnerEmail } from '../../services/common/ownership'; + +jest.mock('../../contexts/AuthContext', () => ({ + useAuth: () => ({ user: { email: 'me@example.com' } }), +})); + +// Imported after the mock so CreatedBy picks up the mocked useAuth. +import CreatedBy from '../../components/common/CreatedBy'; + +describe('resolveOwnerEmail', () => { + it('prefers the resource ownerEmail', () => { + expect(resolveOwnerEmail({ ownerEmail: 'other@example.com' }, 'me@example.com')).toBe( + 'other@example.com' + ); + }); + + it('falls back to the current user when ownerEmail is absent', () => { + expect(resolveOwnerEmail({ isOwner: true }, 'me@example.com')).toBe('me@example.com'); + }); +}); + +describe('CreatedBy', () => { + it("shows another user's email for resources the current user does not own", () => { + render(); + expect(screen.getByText('Created by other@example.com')).toBeInTheDocument(); + }); + + it('marks the current user as the owner', () => { + render(); + expect(screen.getByText('Created by me@example.com (you)')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/common/CreatedBy.tsx b/frontend/src/components/common/CreatedBy.tsx new file mode 100644 index 0000000..e384dbb --- /dev/null +++ b/frontend/src/components/common/CreatedBy.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Typography, TypographyProps } from '@mui/material'; +import { useAuth } from '../../contexts/AuthContext'; +import { ResourceOwnership, resolveOwnerEmail } from '../../services/common/ownership'; + +export type { ResourceOwnership } from '../../services/common/ownership'; +export { resolveOwnerEmail } from '../../services/common/ownership'; + +interface CreatedByProps extends ResourceOwnership { + variant?: TypographyProps['variant']; +} + +/** Inline "Created by {email}" label for an asset's properties/listing. */ +const CreatedBy: React.FC = ({ isOwner, ownerEmail, variant = 'caption' }) => { + const { user } = useAuth(); + const email = resolveOwnerEmail({ isOwner, ownerEmail }, user?.email); + if (!email) return null; + const label = email === user?.email ? `${email} (you)` : email; + return ( + + Created by {label} + + ); +}; + +export default CreatedBy; diff --git a/frontend/src/components/common/OwnerFilterControl.tsx b/frontend/src/components/common/OwnerFilterControl.tsx new file mode 100644 index 0000000..e2b4e25 --- /dev/null +++ b/frontend/src/components/common/OwnerFilterControl.tsx @@ -0,0 +1,52 @@ +import React, { useEffect, useState } from 'react'; +import { Autocomplete, Box, TextField } from '@mui/material'; +import { useAuth } from '../../contexts/AuthContext'; +import { useOwnerFilter } from '../../contexts/OwnerFilterContext'; +import { adminService } from '../../services/core'; + +/** + * Admin-only global filter that restricts every asset listing to the selected + * owners. Renders nothing for non-admins, so it has no effect for them. + */ +const OwnerFilterControl: React.FC = () => { + const { user } = useAuth(); + const { selectedOwnerEmails, setSelectedOwnerEmails } = useOwnerFilter(); + const [emails, setEmails] = useState([]); + const isAdmin = user?.role === 'ADMIN'; + + useEffect(() => { + if (!isAdmin) return; + let cancelled = false; + adminService + .getUsers({ pageSize: 100 }) + .then(res => { + if (!cancelled) setEmails(res.users.map(u => u.email)); + }) + .catch(() => { + /* If the user list can't load, the filter just stays empty (shows all). */ + }); + return () => { + cancelled = true; + }; + }, [isAdmin]); + + if (!isAdmin) return null; + + return ( + + setSelectedOwnerEmails(value)} + limitTags={1} + renderInput={params => ( + + )} + /> + + ); +}; + +export default OwnerFilterControl; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 0439876..9c112b1 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -4,3 +4,5 @@ export { default as ValidationErrorDialog } from './ValidationErrorDialog'; export { default as ShareDialog } from './ShareDialog'; export { default as SharedWithMe } from './SharedWithMe'; export { default as RoleIndicator, PermissionGate } from './RoleIndicator'; +export { default as CreatedBy, resolveOwnerEmail } from './CreatedBy'; +export type { ResourceOwnership } from './CreatedBy'; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index d891830..1659dc0 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -32,6 +32,7 @@ import AccountTreeIcon from '@mui/icons-material/AccountTree'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import ViewModuleIcon from '@mui/icons-material/ViewModule'; import { AuthUser } from '../../services/core'; +import OwnerFilterControl from '../common/OwnerFilterControl'; const EXPANDED_WIDTH = 264; const COLLAPSED_WIDTH = 72; @@ -189,6 +190,8 @@ const Sidebar: React.FC = ({ user, isAdmin, onLogout, onRoleReques + {!collapsed && isAdmin && } + {visibleNavItems.map(renderItem)} diff --git a/frontend/src/components/metamodel/MetamodelManager.tsx b/frontend/src/components/metamodel/MetamodelManager.tsx index fb99747..1725b2a 100644 --- a/frontend/src/components/metamodel/MetamodelManager.tsx +++ b/frontend/src/components/metamodel/MetamodelManager.tsx @@ -10,12 +10,14 @@ import { Metamodel } from '../../models/types'; import { metamodelService } from '../../services/metamodel'; import VisualMetamodelEditor from './VisualMetamodelEditor'; import { exportService, ecoreService } from '../../services/metamodel'; -import { ShareDialog } from '../common'; +import { ShareDialog, CreatedBy } from '../common'; import { useAuth } from '../../contexts/AuthContext'; +import { useOwnerFilterMatcher } from '../../contexts/OwnerFilterContext'; import { useNavigate, useParams } from 'react-router-dom'; const MetamodelManager: React.FC = () => { const { canShare, canCreate, canDelete, canEditMetamodel } = useAuth(); + const matchesOwner = useOwnerFilterMatcher(); const navigate = useNavigate(); const { id: routeMetamodelId } = useParams<{ id?: string }>(); const [metamodels, setMetamodels] = useState([]); @@ -196,7 +198,7 @@ const MetamodelManager: React.FC = () => { - {metamodels.map((metamodel) => ( + {metamodels.filter(matchesOwner).map((metamodel) => ( { {metamodel.description} )} + diff --git a/frontend/src/components/model/ModelManager.tsx b/frontend/src/components/model/ModelManager.tsx index 2b365e8..569d126 100644 --- a/frontend/src/components/model/ModelManager.tsx +++ b/frontend/src/components/model/ModelManager.tsx @@ -34,13 +34,15 @@ import { modelService, modelXmiExportService, modelXmiImportService } from '../. import { metamodelService } from '../../services/metamodel'; import { getParentGroupSurfaceColor, groupByParent } from '../../services/common/grouping.service'; import VisualModelEditor from './VisualModelEditor'; -import { ShareDialog } from '../common'; +import { ShareDialog, CreatedBy } from '../common'; import { useAuth } from '../../contexts/AuthContext'; +import { useOwnerFilterMatcher } from '../../contexts/OwnerFilterContext'; const ModelManager: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { canShare, canCreate, canDelete } = useAuth(); + const matchesOwner = useOwnerFilterMatcher(); const [models, setModels] = useState([]); const [metamodels, setMetamodels] = useState([]); const [selectedModel, setSelectedModel] = useState(null); @@ -371,7 +373,7 @@ const ModelManager: React.FC = () => { {groupByParent( - models, + models.filter(matchesOwner), model => model.conformsTo || model.metamodelId, parentId => getMetamodelName(parentId) ).map(group => ( @@ -460,7 +462,13 @@ const ModelManager: React.FC = () => { model.name.length > 20 ? theme.typography.body2.fontSize : theme.typography.body1.fontSize } }} - secondary={model.description ? `${group.parentName} - ${model.description}` : group.parentName} + secondary={ + <> + {model.description ? `${group.parentName} - ${model.description}` : group.parentName} + + + } + secondaryTypographyProps={{ component: 'div' }} /> diff --git a/frontend/src/components/viewpoints/ViewpointManager.tsx b/frontend/src/components/viewpoints/ViewpointManager.tsx index a43f6e4..7222bf6 100644 --- a/frontend/src/components/viewpoints/ViewpointManager.tsx +++ b/frontend/src/components/viewpoints/ViewpointManager.tsx @@ -54,6 +54,7 @@ import { diagramService } from '../../services/diagram'; import viewpointService from '../../services/viewpoint.service'; import { siriusInteropService } from '../../services/interoperability'; import ColorSwatchField from '../common/ColorSwatchField'; +import { CreatedBy } from '../common'; import { fileStorageService } from '../../services/core'; type ViewpointDraft = Pick; @@ -730,7 +731,13 @@ const ViewpointManager: React.FC = () => { {viewpoint.isDefault && } } - secondary={`${previewText(viewpoint.description) ? `${previewText(viewpoint.description)} · ` : ''}${viewpoint.representationDescriptions.length} representation(s)${usedViewpointCounts.get(viewpoint.id) ? ` · ${usedViewpointCounts.get(viewpoint.id)} view(s)` : ''}`} + secondary={ + <> + {`${previewText(viewpoint.description) ? `${previewText(viewpoint.description)} · ` : ''}${viewpoint.representationDescriptions.length} representation(s)${usedViewpointCounts.get(viewpoint.id) ? ` · ${usedViewpointCounts.get(viewpoint.id)} view(s)` : ''}`} + + + } + secondaryTypographyProps={{ component: 'div' }} /> {canEditMetamodel && ( diff --git a/frontend/src/contexts/OwnerFilterContext.tsx b/frontend/src/contexts/OwnerFilterContext.tsx new file mode 100644 index 0000000..f7a89c6 --- /dev/null +++ b/frontend/src/contexts/OwnerFilterContext.tsx @@ -0,0 +1,43 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import { useAuth } from './AuthContext'; +import { ResourceOwnership, resolveOwnerEmail } from '../services/common/ownership'; + +interface OwnerFilterContextValue { + /** Owner emails to restrict listings to. Empty means "show all". */ + selectedOwnerEmails: string[]; + setSelectedOwnerEmails: (emails: string[]) => void; +} + +// Defaults to "no filter" so components (and tests) work without a provider. +const OwnerFilterContext = createContext({ + selectedOwnerEmails: [], + setSelectedOwnerEmails: () => {}, +}); + +export const OwnerFilterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [selectedOwnerEmails, setSelectedOwnerEmails] = useState([]); + const value = useMemo( + () => ({ selectedOwnerEmails, setSelectedOwnerEmails }), + [selectedOwnerEmails] + ); + return {children}; +}; + +export const useOwnerFilter = (): OwnerFilterContextValue => useContext(OwnerFilterContext); + +/** + * Predicate that keeps only resources owned by the selected owners. When no + * owner is selected it keeps everything, so non-admin users are unaffected. + */ +export const useOwnerFilterMatcher = (): ((resource: ResourceOwnership) => boolean) => { + const { selectedOwnerEmails } = useOwnerFilter(); + const { user } = useAuth(); + return useCallback( + (resource: ResourceOwnership) => { + if (selectedOwnerEmails.length === 0) return true; + const email = resolveOwnerEmail(resource, user?.email); + return email !== undefined && selectedOwnerEmails.includes(email); + }, + [selectedOwnerEmails, user?.email] + ); +}; diff --git a/frontend/src/models/types.ts b/frontend/src/models/types.ts index d5fb430..fe58ed0 100644 --- a/frontend/src/models/types.ts +++ b/frontend/src/models/types.ts @@ -155,6 +155,8 @@ export interface Metamodel extends MetamodelElement { enums?: MetaEnum[]; conformsTo: string; // ID of the meta-metamodel package it conforms to constraints?: Constraint[]; // Global OCL and JavaScript constraints for the metamodel + isOwner?: boolean; // owner/permission metadata from the API listing + ownerEmail?: string; // creator/owner email; absent when the current user owns it } export interface ConcreteSyntax2D { @@ -250,6 +252,8 @@ export interface Viewpoint { representationDescriptions: RepresentationDescription[]; sharedConcreteSyntaxByMetaClassId?: Record; isDefault?: boolean; + isOwner?: boolean; // owner/permission metadata from the API listing + ownerEmail?: string; // creator/owner email; absent when the current user owns it } // Sirius Desktop interoperability types @@ -343,6 +347,8 @@ export interface Model { elements: ModelElement[]; connections?: ModelConnection[]; conformsTo: string; // ID of the metamodel it conforms to + isOwner?: boolean; // owner/permission metadata from the API listing + ownerEmail?: string; // creator/owner email; absent when the current user owns it } export interface ModelConnection { @@ -388,6 +394,8 @@ export interface TransformationRule { conditions?: string[]; // Additional application conditions priority: number; // Execution priority enabled: boolean; // Whether the rule is enabled + isOwner?: boolean; // owner/permission metadata from the API listing + ownerEmail?: string; // creator/owner email; absent when the current user owns it } export interface TransformationExecution { @@ -453,6 +461,8 @@ export interface Diagram { }; schemaVersion?: number; migrationWarnings?: string[]; + isOwner?: boolean; // owner/permission metadata from the API listing + ownerEmail?: string; // creator/owner email; absent when the current user owns it } // Types for Code Generation @@ -475,6 +485,8 @@ export interface CodeGenerationProject { createdAt: number; updatedAt: number; isExample?: boolean; // Flag to identify example projects + isOwner?: boolean; // owner/permission metadata from the API listing + ownerEmail?: string; // creator/owner email; absent when the current user owns it } export interface CodeGenerationResult { diff --git a/frontend/src/services/common/ownership.ts b/frontend/src/services/common/ownership.ts new file mode 100644 index 0000000..27537af --- /dev/null +++ b/frontend/src/services/common/ownership.ts @@ -0,0 +1,18 @@ +/** Owner/permission metadata that API listings attach to resources. */ +export interface ResourceOwnership { + isOwner?: boolean; + ownerEmail?: string; +} + +/** + * Resolve a resource's creator/owner email. Listings only carry `ownerEmail` + * for resources the current user does not own, so fall back to the current + * user's email for owned (or untagged) items. + */ +export function resolveOwnerEmail( + resource: ResourceOwnership, + currentUserEmail?: string +): string | undefined { + if (resource.ownerEmail) return resource.ownerEmail; + return currentUserEmail; +} diff --git a/frontend/src/services/testing/testing-types.ts b/frontend/src/services/testing/testing-types.ts index 40d4bc5..5645904 100644 --- a/frontend/src/services/testing/testing-types.ts +++ b/frontend/src/services/testing/testing-types.ts @@ -16,6 +16,8 @@ export interface TestCase { originalInput?: any; expectedOutput?: any; actualOutput?: any; + isOwner?: boolean; // owner/permission metadata from the API listing + ownerEmail?: string; // creator/owner email; absent when the current user owns it } export interface TestValue { diff --git a/frontend/src/services/transformation/transformation-api-sync.service.ts b/frontend/src/services/transformation/transformation-api-sync.service.ts index 85aee65..bd1c19b 100644 --- a/frontend/src/services/transformation/transformation-api-sync.service.ts +++ b/frontend/src/services/transformation/transformation-api-sync.service.ts @@ -60,6 +60,8 @@ export class TransformationApiSyncService { conditions: r.conditions || [], priority: r.priority || 0, enabled: r.enabled !== false, + isOwner: r.isOwner, + ownerEmail: r.ownerEmail, }; rules.push(rule); From ce6afd931accccc8b41b1d29e5a5c07151cf9008 Mon Sep 17 00:00:00 2001 From: Ha Phan Tran Date: Mon, 15 Jun 2026 17:21:24 -0400 Subject: [PATCH 2/2] Address Copilot review on owner filter - resolveOwnerEmail: treat explicit isOwner=false without ownerEmail as unknown instead of attributing the resource to the current user. - useOwnerFilterMatcher: gate on the ADMIN role so a stale owner selection can never filter a non-admin who signs in on the same session. --- frontend/src/__tests__/components/CreatedBy.test.tsx | 4 ++++ frontend/src/contexts/OwnerFilterContext.tsx | 10 ++++++---- frontend/src/services/common/ownership.ts | 5 ++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/components/CreatedBy.test.tsx b/frontend/src/__tests__/components/CreatedBy.test.tsx index 12e5393..ce478e2 100644 --- a/frontend/src/__tests__/components/CreatedBy.test.tsx +++ b/frontend/src/__tests__/components/CreatedBy.test.tsx @@ -19,6 +19,10 @@ describe('resolveOwnerEmail', () => { it('falls back to the current user when ownerEmail is absent', () => { expect(resolveOwnerEmail({ isOwner: true }, 'me@example.com')).toBe('me@example.com'); }); + + it('does not attribute to the current user when explicitly not owned and email is unknown', () => { + expect(resolveOwnerEmail({ isOwner: false }, 'me@example.com')).toBeUndefined(); + }); }); describe('CreatedBy', () => { diff --git a/frontend/src/contexts/OwnerFilterContext.tsx b/frontend/src/contexts/OwnerFilterContext.tsx index f7a89c6..8eb5338 100644 --- a/frontend/src/contexts/OwnerFilterContext.tsx +++ b/frontend/src/contexts/OwnerFilterContext.tsx @@ -26,18 +26,20 @@ export const OwnerFilterProvider: React.FC<{ children: React.ReactNode }> = ({ c export const useOwnerFilter = (): OwnerFilterContextValue => useContext(OwnerFilterContext); /** - * Predicate that keeps only resources owned by the selected owners. When no - * owner is selected it keeps everything, so non-admin users are unaffected. + * Predicate that keeps only resources owned by the selected owners. The filter + * applies to admins only (the control is admin-only) and keeps everything when + * no owner is selected, so a stale selection can never affect a non-admin who + * signs in on the same session. */ export const useOwnerFilterMatcher = (): ((resource: ResourceOwnership) => boolean) => { const { selectedOwnerEmails } = useOwnerFilter(); const { user } = useAuth(); return useCallback( (resource: ResourceOwnership) => { - if (selectedOwnerEmails.length === 0) return true; + if (user?.role !== 'ADMIN' || selectedOwnerEmails.length === 0) return true; const email = resolveOwnerEmail(resource, user?.email); return email !== undefined && selectedOwnerEmails.includes(email); }, - [selectedOwnerEmails, user?.email] + [selectedOwnerEmails, user?.email, user?.role] ); }; diff --git a/frontend/src/services/common/ownership.ts b/frontend/src/services/common/ownership.ts index 27537af..ad94fd1 100644 --- a/frontend/src/services/common/ownership.ts +++ b/frontend/src/services/common/ownership.ts @@ -7,12 +7,15 @@ export interface ResourceOwnership { /** * Resolve a resource's creator/owner email. Listings only carry `ownerEmail` * for resources the current user does not own, so fall back to the current - * user's email for owned (or untagged) items. + * user's email for owned (or untagged) items. A resource explicitly tagged + * `isOwner: false` without an `ownerEmail` is treated as unknown rather than + * attributed to the current user. */ export function resolveOwnerEmail( resource: ResourceOwnership, currentUserEmail?: string ): string | undefined { if (resource.ownerEmail) return resource.ownerEmail; + if (resource.isOwner === false) return undefined; return currentUserEmail; }