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..ce478e2
--- /dev/null
+++ b/frontend/src/__tests__/components/CreatedBy.test.tsx
@@ -0,0 +1,38 @@
+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');
+ });
+
+ 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', () => {
+ 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..8eb5338
--- /dev/null
+++ b/frontend/src/contexts/OwnerFilterContext.tsx
@@ -0,0 +1,45 @@
+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. 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 (user?.role !== 'ADMIN' || selectedOwnerEmails.length === 0) return true;
+ const email = resolveOwnerEmail(resource, user?.email);
+ return email !== undefined && selectedOwnerEmails.includes(email);
+ },
+ [selectedOwnerEmails, user?.email, user?.role]
+ );
+};
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..ad94fd1
--- /dev/null
+++ b/frontend/src/services/common/ownership.ts
@@ -0,0 +1,21 @@
+/** 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. 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;
+}
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);