Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ public CompletableFuture<ProjectDto> createProject(CreateProjectRequest request,
* <p>Gets the {@link ProjectMetadata} of the specified {@code projectName}.
*/
@Get("/projects/{projectName}")
@RequiresProjectRole(ProjectRole.MEMBER)
public CompletableFuture<ProjectMetadata> getProjectMetadata(@Param String projectName) {
if (InternalProjectInitializer.INTERNAL_PROJECT_DOGMA.equals(projectName)) {
return UnmodifiableFuture.completedFuture(DOGMA_PROJECT_METADATA);
Expand Down
3 changes: 3 additions & 0 deletions webapp/e2e/landing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ test.beforeEach('Login', async ({ page }) => {
await expect(page.getByText(/Login/)).toBeVisible({ timeout: 10000 });
await page.getByPlaceholder('ID').fill('foo');
await page.getByPlaceholder('Password').fill('bar');
const loginResponse = page.waitForResponse((response) => response.url().includes('/api/v1/login'));
await page.getByRole('button', { name: 'Login' }).click();
await loginResponse;

// Wait for login to complete
await expect(page.getByRole('heading', { name: 'Welcome to Central Dogma!' })).toBeVisible({
Expand All @@ -15,6 +17,7 @@ test.beforeEach('Login', async ({ page }) => {
});

test('welcome message', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome to Central Dogma!' })).toBeVisible();
});

Expand Down
55 changes: 55 additions & 0 deletions webapp/e2e/project-owners.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { test, expect } from '@playwright/test';

test.beforeEach('Login', async ({ page }) => {
await page.goto('/');

await expect(page.getByText(/Login/)).toBeVisible();
await page.getByPlaceholder('ID').fill('foo');
await page.getByPlaceholder('Password').fill('bar');
const loginResponsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/login'));
await page.getByRole('button', { name: 'Login' }).click();
await loginResponsePromise;
});

test('view project members from list', async ({ page }) => {
await page.goto('/app/projects');

const projectRows = page.locator('tr', { has: page.getByRole('button', { name: 'View members' }) });
await expect(projectRows.first()).toBeVisible();
const rowCount = await projectRows.count();
let projectName = '';
let members: string[] = [];
let targetRowIndex = 0;
for (let i = 0; i < rowCount; i += 1) {
const row = projectRows.nth(i);
const candidateName = (await row.getByRole('link').first().innerText()).trim();
const metadataResponse = await page.request.get(`/api/v1/projects/${encodeURIComponent(candidateName)}`);
if (!metadataResponse.ok()) {
continue;
}
const metadata = await metadataResponse.json();
const candidateMembers = Object.entries(metadata.members).map(
([login, member]: [string, { login?: string }]) => member.login || login,
);
if (candidateMembers.length > 0) {
projectName = candidateName;
members = candidateMembers;
targetRowIndex = i;
break;
}
}
if (!projectName) {
const fallbackRow = projectRows.first();
projectName = (await fallbackRow.getByRole('link').first().innerText()).trim();
}

const projectRow = projectRows.nth(targetRowIndex);
await projectRow.getByRole('button', { name: 'View members' }).click();

const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Project members')).toBeVisible();
if (members.length > 0) {
await expect(dialog.getByTestId('project-member-login').first()).toBeVisible({ timeout: 15000 });
}
});
118 changes: 118 additions & 0 deletions webapp/src/dogma/features/project/ProjectOwnersModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
ListItem,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Text,
UnorderedList,
} from '@chakra-ui/react';
import { useGetMetadataByProjectNameQuery } from 'dogma/features/api/apiSlice';
import { FaCrown } from 'react-icons/fa';
import { FaUserGroup } from 'react-icons/fa6';
import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser';

interface ProjectOwnersModalProps {
projectName: string | null;
isOpen: boolean;
onClose: () => void;
}

export const ProjectOwnersModal = ({ projectName, isOpen, onClose }: ProjectOwnersModalProps) => {
const { data, isLoading, isError, error } = useGetMetadataByProjectNameQuery(projectName ?? '', {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question) This API seems to require @RequiresProjectRole(ProjectRole.MEMBER) - will GUEST/users who are not members of a project be able to view members?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed that point. 😅
In production, GUEST is not an anonymous user but may be authenticated by a third party IdP such as Okta. Therefore, it makes sense to allow GUEST to access the project metadata.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might need to add another api that only provides the member information later. I think it's okay for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. We can consider adding additional APIs when we have a chance.

refetchOnFocus: true,
skip: !projectName,
});
const allMembers = data
? Object.entries(data.members).map(([login, member]) => ({
...member,
login: member.login || login,
}))
: [];
const owners = allMembers.filter((member) => member.role === 'OWNER');
const members = allMembers.filter((member) => member.role !== 'OWNER');

return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Project members</ModalHeader>
<ModalCloseButton />
<ModalBody>
{isLoading ? (
<Text color="gray.500">Loading members...</Text>
) : isError ? (
<Text color="red.500" mb={2}>
{ErrorMessageParser.parse(error) || 'Failed to load members.'}
</Text>
) : owners.length === 0 ? (
<Text color="gray.600">System administrators</Text>
) : (
<>
{owners.length > 0 && (
<>
<Text fontWeight="semibold" mb={2}>
<FaCrown
style={{
marginRight: '8px',
display: 'inline-block',
color: '#3182CE',
marginTop: '1px',
marginBottom: '-1px',
}}
/>
Owners
</Text>
<UnorderedList spacing={2} mb={4} stylePosition="outside" pl={4}>
{owners.map((member) => (
<ListItem
key={member.login}
display="list-item"
sx={{ '::marker': { color: 'blue.500', fontWeight: 'bold' } }}
>
<Text data-testid="project-member-login" as="span" fontWeight="semibold">
{member.login}
</Text>
</ListItem>
))}
</UnorderedList>
</>
)}
{members.length > 0 && (
<>
<Text fontWeight="semibold" mb={2}>
<FaUserGroup
style={{
marginRight: '8px',
display: 'inline-block',
color: '#38A169',
marginTop: '1px',
marginBottom: '-1px',
}}
/>
Members
</Text>
<UnorderedList spacing={2} stylePosition="outside" pl={4}>
{members.map((member) => (
<ListItem
key={member.login}
display="list-item"
sx={{ '::marker': { color: 'green.500', fontWeight: 'bold' } }}
>
<Text data-testid="project-member-login" as="span" fontWeight="semibold">
{member.login}
</Text>
</ListItem>
))}
</UnorderedList>
</>
)}
</>
)}
</ModalBody>
</ModalContent>
</Modal>
);
};
44 changes: 41 additions & 3 deletions webapp/src/dogma/features/project/Projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@ import {
MenuOptionGroup,
Spacer,
Tooltip,
useDisclosure,
} from '@chakra-ui/react';
import { FcServices } from 'react-icons/fc';
import { ChakraLink } from 'dogma/common/components/ChakraLink';
import { ProjectDto } from 'dogma/features/project/ProjectDto';
import { DataTableClientPagination } from 'dogma/common/components/table/DataTableClientPagination';
import { createColumnHelper } from '@tanstack/react-table';
import { DateWithTooltip } from 'dogma/common/components/DateWithTooltip';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { RestoreProject } from 'dogma/features/project/RestoreProject';
import { Deferred } from 'dogma/common/components/Deferred';
import { useAppDispatch, useAppSelector } from 'dogma/hooks';
Expand All @@ -46,6 +47,7 @@ import { FiBox } from 'react-icons/fi';
import { FaFilter, FaTrashAlt } from 'react-icons/fa';
import { ChevronDownIcon } from '@chakra-ui/icons';
import { ProjectFilterType, setProjectFilter } from 'dogma/features/filter/filterSlice';
import { ProjectOwnersModal } from 'dogma/features/project/ProjectOwnersModal';
import { UserDto } from '../auth/UserDto';
import { UserRole } from '../../common/components/UserRole';

Expand All @@ -63,6 +65,8 @@ function filterProjects(projects: ProjectDto[], projectFilterType: ProjectFilter
export const Projects = () => {
const columnHelper = createColumnHelper<ProjectDto>();
const dispatch = useAppDispatch();
const { isOpen, onOpen, onClose } = useDisclosure();
const [ownersProjectName, setOwnersProjectName] = useState<string | null>(null);

const { user, isInAnonymousMode } = useAppSelector((state) => state.auth);
const { projectFilter, isInitialProjectFilter } = useAppSelector(({ filter }) => filter);
Expand All @@ -73,7 +77,6 @@ export const Projects = () => {
} = useGetProjectsQuery({
systemAdmin: user?.systemAdmin || false,
});

let filteredProjects = projects;
if (!isInAnonymousMode && !isLoading && !error) {
filteredProjects = filterProjects(projects, projectFilter, user);
Expand All @@ -86,6 +89,7 @@ export const Projects = () => {
const columns = useMemo(
() => [
columnHelper.accessor((row: ProjectDto) => row.name, {
id: 'name',
cell: (info) =>
info.row.original.createdAt ? (
<ChakraLink href={`/app/projects/${info.getValue()}`} fontWeight="bold">
Expand Down Expand Up @@ -116,6 +120,7 @@ export const Projects = () => {
header: 'Name',
}),
columnHelper.accessor((row: ProjectDto) => row.creator?.name, {
id: 'creator',
cell: (info) =>
info.getValue() ? (
<Author name={info.getValue()} />
Expand All @@ -130,14 +135,39 @@ export const Projects = () => {
header: 'Creator',
}),
columnHelper.accessor((row: ProjectDto) => row.userRole, {
id: 'role',
cell: (info) => UserRole({ role: info.getValue() }),
header: 'Role',
}),
columnHelper.accessor((row: ProjectDto) => row.createdAt, {
id: 'createdAt',
cell: (info) => info.getValue() && <DateWithTooltip date={info.getValue()} />,
header: 'Created',
}),
columnHelper.accessor((row: ProjectDto) => row.name, {
id: 'members',
cell: (info) => {
if (!info.row.original.createdAt) {
return null;
}
return (
<Button
size="sm"
variant="outline"
onClick={() => {
setOwnersProjectName(info.getValue());
onOpen();
}}
>
View members
</Button>
);
},
header: 'Members',
enableSorting: false,
}),
columnHelper.accessor((row: ProjectDto) => row.name, {
id: 'action',
cell: (info) => {
if (isInternalProject(info.row.original.name)) {
return null;
Expand Down Expand Up @@ -175,7 +205,7 @@ export const Projects = () => {
enableSorting: false,
}),
],
[columnHelper, user],
[columnHelper, onOpen, user.systemAdmin],
);
return (
<Deferred isLoading={isLoading} error={error}>
Expand Down Expand Up @@ -207,6 +237,14 @@ export const Projects = () => {
</Flex>
)}
<DataTableClientPagination columns={columns} data={filteredProjects} />
<ProjectOwnersModal
projectName={ownersProjectName}
isOpen={isOpen}
onClose={() => {
setOwnersProjectName(null);
onClose();
}}
/>
</Box>
)}
</Deferred>
Expand Down
Loading