Skip to content
Open
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
15 changes: 10 additions & 5 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -183,7 +184,9 @@ const App: React.FC = () => {
<ThemeProvider theme={theme}>
<CssBaseline />
<AuthProvider>
<AuthenticatedApp />
<OwnerFilterProvider>
<AuthenticatedApp />
</OwnerFilterProvider>
</AuthProvider>
</ThemeProvider>
);
Expand Down Expand Up @@ -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<Diagram[]>(diagramService.getAllDiagrams());
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [models, setModels] = useState<Model[]>(modelService.getAllModels());
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1794,8 +1798,9 @@ const DiagramsPage: React.FC = () => {
<Typography color="textSecondary" gutterBottom>
{diagram.description || 'No description'}
</Typography>
<CreatedBy isOwner={diagram.isOwner} ownerEmail={diagram.ownerEmail} variant="body2" />
<Typography color="textSecondary" gutterBottom>
Model: {model?.name || 'Unknown'}
Model: {model?.name || 'Unknown'}
</Typography>
<Typography color="textSecondary" gutterBottom>
Metamodel: {metamodel?.name || 'Unknown'}
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/__tests__/components/CreatedBy.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CreatedBy isOwner={false} ownerEmail="other@example.com" />);
expect(screen.getByText('Created by other@example.com')).toBeInTheDocument();
});

it('marks the current user as the owner', () => {
render(<CreatedBy isOwner />);
expect(screen.getByText('Created by me@example.com (you)')).toBeInTheDocument();
});
});
32 changes: 32 additions & 0 deletions frontend/src/components/common/CreatedBy.tsx
Original file line number Diff line number Diff line change
@@ -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<CreatedByProps> = ({ 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 (
<Typography
variant={variant}
component="span"
color="text.secondary"
sx={{ display: 'block' }}
noWrap
>
Created by {label}
</Typography>
);
};

export default CreatedBy;
52 changes: 52 additions & 0 deletions frontend/src/components/common/OwnerFilterControl.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);
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));
})
Comment on lines +17 to +24
.catch(() => {
/* If the user list can't load, the filter just stays empty (shows all). */
});
return () => {
cancelled = true;
};
}, [isAdmin]);

if (!isAdmin) return null;

return (
<Box sx={{ px: 2, py: 1 }}>
<Autocomplete
multiple
size="small"
options={emails}
value={selectedOwnerEmails}
onChange={(_, value) => setSelectedOwnerEmails(value)}
limitTags={1}
renderInput={params => (
<TextField {...params} label="Filter by owner" placeholder="All users" />
)}
/>
</Box>
);
};

export default OwnerFilterControl;
2 changes: 2 additions & 0 deletions frontend/src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
3 changes: 3 additions & 0 deletions frontend/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -189,6 +190,8 @@ const Sidebar: React.FC<SidebarProps> = ({ user, isAdmin, onLogout, onRoleReques

<Divider />

{!collapsed && isAdmin && <OwnerFilterControl />}

<List sx={{ py: 1, flex: 1 }}>
{visibleNavItems.map(renderItem)}
</List>
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/components/metamodel/MetamodelManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metamodel[]>([]);
Expand Down Expand Up @@ -196,7 +198,7 @@ const MetamodelManager: React.FC = () => {
</Box>

<List sx={{ flexGrow: 1 }}>
{metamodels.map((metamodel) => (
{metamodels.filter(matchesOwner).map((metamodel) => (
<ListItem
key={metamodel.id}
disablePadding
Expand Down Expand Up @@ -237,6 +239,7 @@ const MetamodelManager: React.FC = () => {
{metamodel.description}
</Typography>
)}
<CreatedBy isOwner={metamodel.isOwner} ownerEmail={metamodel.ownerEmail} />
</Box>
</Tooltip>
</ListItemButton>
Expand Down
14 changes: 11 additions & 3 deletions frontend/src/components/model/ModelManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Model[]>([]);
const [metamodels, setMetamodels] = useState<Metamodel[]>([]);
const [selectedModel, setSelectedModel] = useState<Model | null>(null);
Expand Down Expand Up @@ -371,7 +373,7 @@ const ModelManager: React.FC = () => {

<List sx={{ flexGrow: 1 }}>
{groupByParent(
models,
models.filter(matchesOwner),
model => model.conformsTo || model.metamodelId,
parentId => getMetamodelName(parentId)
).map(group => (
Expand Down Expand Up @@ -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}
<CreatedBy isOwner={model.isOwner} ownerEmail={model.ownerEmail} />
</>
}
secondaryTypographyProps={{ component: 'div' }}
/>
</ListItemButton>
</ListItem>
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/viewpoints/ViewpointManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Viewpoint, 'name' | 'description' | 'isDefault'>;
Expand Down Expand Up @@ -730,7 +731,13 @@ const ViewpointManager: React.FC = () => {
{viewpoint.isDefault && <Chip size="small" label="Default" />}
</Stack>
}
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)` : ''}`}
<CreatedBy isOwner={metamodel?.isOwner} ownerEmail={metamodel?.ownerEmail} />
</>
}
secondaryTypographyProps={{ component: 'div' }}
/>
{canEditMetamodel && (
<Tooltip title="Delete viewpoint">
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/contexts/OwnerFilterContext.tsx
Original file line number Diff line number Diff line change
@@ -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<OwnerFilterContextValue>({
selectedOwnerEmails: [],
setSelectedOwnerEmails: () => {},
});

export const OwnerFilterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [selectedOwnerEmails, setSelectedOwnerEmails] = useState<string[]>([]);
const value = useMemo(
() => ({ selectedOwnerEmails, setSelectedOwnerEmails }),
[selectedOwnerEmails]
);
return <OwnerFilterContext.Provider value={value}>{children}</OwnerFilterContext.Provider>;
};

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]
);
};
Loading