diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ProjectServiceV1.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ProjectServiceV1.java index abae2fba7..970ff7527 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ProjectServiceV1.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ProjectServiceV1.java @@ -150,7 +150,6 @@ public CompletableFuture createProject(CreateProjectRequest request, *

Gets the {@link ProjectMetadata} of the specified {@code projectName}. */ @Get("/projects/{projectName}") - @RequiresProjectRole(ProjectRole.MEMBER) public CompletableFuture getProjectMetadata(@Param String projectName) { if (InternalProjectInitializer.INTERNAL_PROJECT_DOGMA.equals(projectName)) { return UnmodifiableFuture.completedFuture(DOGMA_PROJECT_METADATA); diff --git a/webapp/e2e/landing.spec.ts b/webapp/e2e/landing.spec.ts index ce7f6d2f2..2487cb87e 100644 --- a/webapp/e2e/landing.spec.ts +++ b/webapp/e2e/landing.spec.ts @@ -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({ @@ -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(); }); diff --git a/webapp/e2e/project-owners.spec.ts b/webapp/e2e/project-owners.spec.ts new file mode 100644 index 000000000..7ea084a5c --- /dev/null +++ b/webapp/e2e/project-owners.spec.ts @@ -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 }); + } +}); diff --git a/webapp/src/dogma/features/project/ProjectOwnersModal.tsx b/webapp/src/dogma/features/project/ProjectOwnersModal.tsx new file mode 100644 index 000000000..306a0e9fe --- /dev/null +++ b/webapp/src/dogma/features/project/ProjectOwnersModal.tsx @@ -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 ?? '', { + 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 ( + + + + Project members + + + {isLoading ? ( + Loading members... + ) : isError ? ( + + {ErrorMessageParser.parse(error) || 'Failed to load members.'} + + ) : owners.length === 0 ? ( + System administrators + ) : ( + <> + {owners.length > 0 && ( + <> + + + Owners + + + {owners.map((member) => ( + + + {member.login} + + + ))} + + + )} + {members.length > 0 && ( + <> + + + Members + + + {members.map((member) => ( + + + {member.login} + + + ))} + + + )} + + )} + + + + ); +}; diff --git a/webapp/src/dogma/features/project/Projects.tsx b/webapp/src/dogma/features/project/Projects.tsx index 92dfece27..c68994dd7 100644 --- a/webapp/src/dogma/features/project/Projects.tsx +++ b/webapp/src/dogma/features/project/Projects.tsx @@ -27,6 +27,7 @@ import { MenuOptionGroup, Spacer, Tooltip, + useDisclosure, } from '@chakra-ui/react'; import { FcServices } from 'react-icons/fc'; import { ChakraLink } from 'dogma/common/components/ChakraLink'; @@ -34,7 +35,7 @@ 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'; @@ -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'; @@ -63,6 +65,8 @@ function filterProjects(projects: ProjectDto[], projectFilterType: ProjectFilter export const Projects = () => { const columnHelper = createColumnHelper(); const dispatch = useAppDispatch(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const [ownersProjectName, setOwnersProjectName] = useState(null); const { user, isInAnonymousMode } = useAppSelector((state) => state.auth); const { projectFilter, isInitialProjectFilter } = useAppSelector(({ filter }) => filter); @@ -73,7 +77,6 @@ export const Projects = () => { } = useGetProjectsQuery({ systemAdmin: user?.systemAdmin || false, }); - let filteredProjects = projects; if (!isInAnonymousMode && !isLoading && !error) { filteredProjects = filterProjects(projects, projectFilter, user); @@ -86,6 +89,7 @@ export const Projects = () => { const columns = useMemo( () => [ columnHelper.accessor((row: ProjectDto) => row.name, { + id: 'name', cell: (info) => info.row.original.createdAt ? ( @@ -116,6 +120,7 @@ export const Projects = () => { header: 'Name', }), columnHelper.accessor((row: ProjectDto) => row.creator?.name, { + id: 'creator', cell: (info) => info.getValue() ? ( @@ -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() && , header: 'Created', }), columnHelper.accessor((row: ProjectDto) => row.name, { + id: 'members', + cell: (info) => { + if (!info.row.original.createdAt) { + return null; + } + return ( + + ); + }, + header: 'Members', + enableSorting: false, + }), + columnHelper.accessor((row: ProjectDto) => row.name, { + id: 'action', cell: (info) => { if (isInternalProject(info.row.original.name)) { return null; @@ -175,7 +205,7 @@ export const Projects = () => { enableSorting: false, }), ], - [columnHelper, user], + [columnHelper, onOpen, user.systemAdmin], ); return ( @@ -207,6 +237,14 @@ export const Projects = () => { )} + { + setOwnersProjectName(null); + onClose(); + }} + /> )}