diff --git a/apps/client/cypress/components/specs/repo-form.ct.ts b/apps/client/cypress/components/specs/repo-form.ct.ts index b87cd2409..d14feabdf 100644 --- a/apps/client/cypress/components/specs/repo-form.ct.ts +++ b/apps/client/cypress/components/specs/repo-form.ct.ts @@ -27,7 +27,10 @@ describe('RepoForm.vue', () => { const randomDbSetup = createRandomDbSetup({}) const projectStore = useProjectStore() - projectStore.selectedProject = randomDbSetup.project + projectStore.myProjectsById = { + [randomDbSetup.project.id]: randomDbSetup.project.id, + } + projectStore.setSelectedProject(randomDbSetup.project.id) cy.mount(RepoForm, { props }) @@ -95,7 +98,10 @@ describe('RepoForm.vue', () => { const randomDbSetup = createRandomDbSetup({}) const projectStore = useProjectStore() - projectStore.selectedProject = randomDbSetup.project + projectStore.myProjectsById = { + [randomDbSetup.project.id]: randomDbSetup.project.id, + } + projectStore.setSelectedProject(randomDbSetup.project.id) cy.mount(RepoForm, { props }) @@ -185,7 +191,10 @@ describe('RepoForm.vue', () => { const randomDbSetup = createRandomDbSetup({}) const projectStore = useProjectStore() - projectStore.selectedProject = randomDbSetup.project + projectStore.myProjectsById = { + [randomDbSetup.project.id]: randomDbSetup.project.id, + } + projectStore.setSelectedProject(randomDbSetup.project.id) cy.mount(RepoForm, { props }) @@ -230,7 +239,10 @@ describe('RepoForm.vue', () => { const randomDbSetup = createRandomDbSetup({}) const projectStore = useProjectStore() - projectStore.selectedProject = randomDbSetup.project + projectStore.myProjectsById = { + [randomDbSetup.project.id]: randomDbSetup.project.id, + } + projectStore.setSelectedProject(randomDbSetup.project.id) cy.mount(RepoForm, { props }) @@ -269,7 +281,10 @@ describe('RepoForm.vue', () => { const randomDbSetup = createRandomDbSetup({}) const projectStore = useProjectStore() - projectStore.selectedProject = randomDbSetup.project + projectStore.myProjectsById = { + [randomDbSetup.project.id]: randomDbSetup.project.id, + } + projectStore.setSelectedProject(randomDbSetup.project.id) cy.mount(RepoForm, { props }) diff --git a/apps/client/cypress/components/specs/team-ct.ct.ts b/apps/client/cypress/components/specs/team-ct.ct.ts index 4e9fb9bcf..97481802f 100644 --- a/apps/client/cypress/components/specs/team-ct.ct.ts +++ b/apps/client/cypress/components/specs/team-ct.ct.ts @@ -5,12 +5,12 @@ import '@gouvfr/dsfr/dist/dsfr.min.css' import '@gouvfr/dsfr/dist/utility/icons/icons.min.css' import '@gouvfr/dsfr/dist/utility/utility.main.min.css' import '@/main.css' -import { createRandomDbSetup, getRandomUser } from '@cpn-console/test-utils' import type { ProjectV2 } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import TeamCt from '@/components/TeamCt.vue' import { useProjectStore } from '@/stores/project.js' import { useUsersStore } from '@/stores/users.js' +import { useUserStore } from '@/stores/user.js' const ownerId = faker.string.uuid() const props: { @@ -87,10 +87,11 @@ describe('TeamCt.vue', () => { }) it('Should mount a TeamCt for user', () => { useProjectStore() - const { project } = createRandomDbSetup({ nbUsers: 4 }) - const newUser = getRandomUser() + const userStore = useUserStore() + // devrait tester que l'on peut toujours quitter un projet + userStore.userProfile = { id: props.project.members[0].id } - cy.intercept('GET', `api/v1/projects/${project.id}/users/match?letters=*`, { body: [newUser] }) + // cy.intercept('GET', `api/v1/projects/${project.id}/users/match?letters=*`, { body: [newUser] }) cy.mount(TeamCt, { props: { ...props, canTransfer: false, canManage: false } }) @@ -101,7 +102,9 @@ describe('TeamCt.vue', () => { cy.get('tbody > tr') .should('have.length', props.project.members.length + 1) // +1 cause owner is not a member cy.get('thead > tr > th') - .should('have.length', 3) + .should('have.length', 4) + // devrait tester que l'on peut toujours quitter un projet + // cy.get('.fr-fi-close-line').should('have.length', 1) }) cy.getByDataTestid('showTransferProjectBtn') .should('not.exist') diff --git a/apps/client/package.json b/apps/client/package.json index 19442abe1..c63a6cdb8 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -44,7 +44,8 @@ "pinia": "^2.1.7", "vue": "^3.4.38", "vue-router": "^4.4.5", - "vue3-json-viewer": "^2.2.2" + "vue3-json-viewer": "^2.2.2", + "xbytes": "^1.9.1" }, "optionalDependencies": { "@cypress/vue": "^6.0.1", diff --git a/apps/client/src/App.vue b/apps/client/src/App.vue index 2727d0538..edc2b9e1d 100644 --- a/apps/client/src/App.vue +++ b/apps/client/src/App.vue @@ -3,11 +3,15 @@ import { swaggerUiPath } from '@cpn-console/shared' import { getKeycloak } from './utils/keycloak/keycloak.js' import { useSnackbarStore } from './stores/snackbar.js' import { useSystemSettingsStore } from './stores/system-settings.js' +import { useProjectStore } from './stores/project.js' +import { useUserStore } from './stores/user.js' import { useServiceStore } from '@/stores/services-monitor.js' const keycloak = getKeycloak() const snackbarStore = useSnackbarStore() const systemStore = useSystemSettingsStore() +const projectStore = useProjectStore() +const userStore = useUserStore() const isLoggedIn = ref(keycloak.authenticated) @@ -35,6 +39,9 @@ const serviceStore = useServiceStore() onBeforeMount(() => { serviceStore.startHealthPolling() serviceStore.checkServicesHealth() + if (userStore.isLoggedIn) { + projectStore.getMyProjects() + } }) @@ -57,12 +64,14 @@ onBeforeMount(() => { + - diff --git a/apps/client/src/components/SideMenu.vue b/apps/client/src/components/SideMenu.vue index b92b64ec3..dbf4fd87f 100644 --- a/apps/client/src/components/SideMenu.vue +++ b/apps/client/src/components/SideMenu.vue @@ -33,7 +33,7 @@ function toggleExpand(key: keyof typeof isExpanded.value) { } watch(routePath, (routePath) => { - if (/^\/projects*/.test(routePath)) { + if (/^\/projects\/+/.test(routePath)) { isExpanded.value.projects = true isExpanded.value.administration = false return @@ -103,8 +103,19 @@ onMounted(() => { + + + Mes projets + + { control-id="projectsList" @toggle-expand="toggleExpand('projects')" > - Projets + Projet {{ projectStore.selectedProject?.name }} { :expanded="isExpanded.projects" :collapsable="true" > - - - - Mes projets - - -
+
{ :to="`/projects/${selectedProject?.id}/services`" > - Mes services + Services externes @@ -202,7 +202,7 @@ onMounted(() => { :to="`/projects/${selectedProject?.id}/environments`" > - Environments + Environnements
diff --git a/apps/client/src/components/TeamCt.vue b/apps/client/src/components/TeamCt.vue index 649640c69..e17b8a08a 100644 --- a/apps/client/src/components/TeamCt.vue +++ b/apps/client/src/components/TeamCt.vue @@ -12,6 +12,7 @@ import pDebounce from 'p-debounce' import { useProjectMemberStore } from '@/stores/project-member.js' import { useSnackbarStore } from '@/stores/snackbar.js' import { copyContent } from '@/utils/func.js' +import { useUserStore } from '@/stores/user.js' const props = withDefaults( defineProps<{ @@ -34,19 +35,15 @@ const emit = defineEmits<{ }>() const projectMemberStore = useProjectMemberStore() -const headers = props.canManage - ? [ - 'Identifiant', - 'E-mail', - 'Rôles', - 'Retirer du projet', - ] - : [ - 'Identifiant', - 'E-mail', - 'Rôles', - ] +const headers = [ + 'Identifiant', + 'E-mail', + 'Rôles', + 'Retirer du projet', +] + const snackbarStore = useSnackbarStore() +const userStore = useUserStore() const newUserInputKey = ref(getRandomId('input')) const newUserEmail = ref('') const usersToAdd = ref([]) @@ -58,13 +55,6 @@ const isUserAlreadyInTeam = computed(() => { return !!(newUserEmail.value && (props.project.owner.email === newUserEmail.value || props.project.members.find(member => member.email === newUserEmail.value))) }) -function removeUserHint(member: Member) { - if (props.canManage) { - return `retirer ${member.email} du projet` - } - return 'vous n\'avez pas les droits suffisants pour retirer un membre du projet' -} - const usersToSuggest = computed(() => usersToAdd.value.map(userToAdd => ({ value: userToAdd.email, furtherInfo: `${userToAdd.firstName} ${userToAdd.lastName}`, @@ -83,31 +73,33 @@ function getCopyIdComponent(id: string) { } function createMemberRow(member: Member) { - return props.canManage - ? [ - getCopyIdComponent(member.userId), - member.email, - props.project.ownerId === member.userId ? 'Propriétaire' : getRolesNames(member.roleIds), - props.project.ownerId !== member.userId - ? { - cellAttrs: { - class: 'fr-fi-close-line !flex justify-center cursor-pointer fr-text-default--warning', - title: removeUserHint(member), - onClick: () => removeUserFromProject(member.userId), - }, - } - : { - cellAttrs: { - class: 'fr-fi-close-line !flex justify-center cursor-not-allowed', - title: removeUserHint(member), - }, - }, - ] - : [ - getCopyIdComponent(member.userId), - member.email, - props.project.ownerId === member.userId ? 'Propriétaire' : getRolesNames(member.roleIds), - ] + const row: Array = [ + getCopyIdComponent(member.userId), + member.email, + props.project.ownerId === member.userId ? 'Propriétaire' : getRolesNames(member.roleIds), + ] + if (props.project.ownerId === member.userId) { + row.push('') + } else if (member.userId === userStore.userProfile?.id) { + row.push({ + cellAttrs: { + class: 'fr-fi-close-line !flex justify-center cursor-pointer fr-text-default--warning', + title: 'Quitter le projet', + onClick: () => removeUserFromProject(member.userId), + }, + }) + } else if (props.canManage) { + row.push({ + cellAttrs: { + class: 'fr-fi-close-line !flex justify-center cursor-pointer fr-text-default--warning', + title: `Retirer ${member.email} du projet`, + onClick: () => removeUserFromProject(member.userId), + }, + }) + } else { + row.push('') + } + return row } function setRows() { diff --git a/apps/client/src/router/index.ts b/apps/client/src/router/index.ts index 6f1eb468d..3bc035ae1 100644 --- a/apps/client/src/router/index.ts +++ b/apps/client/src/router/index.ts @@ -138,7 +138,7 @@ const routes: Readonly = [ }, ], async beforeEnter() { - await useProjectStore().listProjects() + await useProjectStore().getMyProjects() }, }, { diff --git a/apps/client/src/stores/log.ts b/apps/client/src/stores/log.ts index f90d12a60..61e3134bc 100644 --- a/apps/client/src/stores/log.ts +++ b/apps/client/src/stores/log.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import type { GetLogsQuery, Log } from '@cpn-console/shared' +import pDebounce from 'p-debounce' import { apiClient, extractData } from '@/api/xhr-client.js' export const useLogStore = defineStore('log', () => { @@ -16,10 +17,10 @@ export const useLogStore = defineStore('log', () => { logs.value = res.logs as Log[] } - const listLogs = async ({ offset, limit, clean, projectId }: GetLogsQuery = { offset: 0, limit: 10 }) => { + const listLogs = pDebounce(async ({ offset, limit, clean, projectId }: GetLogsQuery = { offset: 0, limit: 10 }) => { return apiClient.Logs.getLogs({ query: { offset, limit, clean, projectId } }) .then(response => extractData(response, 200)) - } + }, 300, { before: true }) return { logs, diff --git a/apps/client/src/stores/organization.ts b/apps/client/src/stores/organization.ts index 838e74c8b..9ef7e2237 100644 --- a/apps/client/src/stores/organization.ts +++ b/apps/client/src/stores/organization.ts @@ -8,6 +8,7 @@ import type { } from '@cpn-console/shared' import { resourceListToDict, + sortArrByObjKeyAsc, } from '@cpn-console/shared' import { apiClient, extractData } from '@/api/xhr-client.js' @@ -17,7 +18,7 @@ export const useOrganizationStore = defineStore('organization', () => { const listOrganizations = async (query?: typeof organizationContract.listOrganizations.query._type) => { organizations.value = await apiClient.Organizations.listOrganizations({ query }) - .then(response => extractData(response, 200)) + .then(response => sortArrByObjKeyAsc(extractData(response, 200), 'label')) return organizations.value } diff --git a/apps/client/src/stores/project.spec.ts b/apps/client/src/stores/project.spec.ts index 96495741a..51b7e2014 100644 --- a/apps/client/src/stores/project.spec.ts +++ b/apps/client/src/stores/project.spec.ts @@ -7,6 +7,8 @@ import { useOrganizationStore } from './organization.js' const listOrganizations = vi.spyOn(apiClient.Organizations, 'listOrganizations') const listProjects = vi.spyOn(apiClient.Projects, 'listProjects') +const listEnvironments = vi.spyOn(apiClient.Environments, 'listEnvironments') +const listRepositories = vi.spyOn(apiClient.Repositories, 'listRepositories') const apiClientPost = vi.spyOn(apiClient.Projects, 'createProject') const apiClientPut = vi.spyOn(apiClient.Projects, 'updateProject') const apiClientReplayHooks = vi.spyOn(apiClient.Projects, 'replayHooksForProject') @@ -23,7 +25,7 @@ describe('project Store', () => { it('should set working project and its owner', async () => { const projectStore = useProjectStore() const user = { id: 'userId', firstName: 'Michel' } - projectStore.projectsById = { + projectStore.myProjectsById = { projectId: { id: 'projectId', roles: [{ @@ -38,7 +40,7 @@ describe('project Store', () => { projectStore.setSelectedProject('projectId') - expect(projectStore.selectedProject).toMatchObject(projectStore.projects[0]) + expect(projectStore.selectedProject).toMatchObject(projectStore.myProjects[0]) }) it('should retrieve user\'s projects by api call', async () => { @@ -73,16 +75,18 @@ describe('project Store', () => { const projects = [randomDbSetup.project] const organizations = [randomDbSetup.organization] - projectStore.selectedProject = randomDbSetup.project + projectStore.setSelectedProject(randomDbSetup.project.id) listOrganizations.mockReturnValueOnce(Promise.resolve({ status: 200, body: organizations, headers: {} })) listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + listEnvironments.mockReturnValueOnce(Promise.resolve({ status: 200, body: [], headers: {} })) + listRepositories.mockReturnValueOnce(Promise.resolve({ status: 200, body: [], headers: {} })) - await projectStore.listProjects({ filter: 'member' }) + await projectStore.getMyProjects() expect(listOrganizations).toHaveBeenCalledTimes(1) expect(listProjects).toHaveBeenCalledTimes(1) - expect(projectStore.projects).toMatchObject(projects) + expect(projectStore.myProjects).toMatchObject(projects) expect(projectStore.selectedProject).toMatchObject(projects[0]) expect(organizationStore.organizations).toMatchObject(organizations) }) diff --git a/apps/client/src/stores/project.ts b/apps/client/src/stores/project.ts index a89e7736c..b78dcab91 100644 --- a/apps/client/src/stores/project.ts +++ b/apps/client/src/stores/project.ts @@ -4,7 +4,8 @@ import { defineStore } from 'pinia' import type { Ref } from 'vue' import { ref } from 'vue' import type { CreateProjectBody, Environment, Organization, ProjectV2, Repo, Role, projectContract, projectRoleContract } from '@cpn-console/shared' -import { PROJECT_PERMS, getPermsByUserRoles, resourceListToDict, sortArrByObjKeyAsc } from '@cpn-console/shared' +import { PROJECT_PERMS, ProjectAuthorized, getPermsByUserRoles, resourceListToDict, sortArrByObjKeyAsc } from '@cpn-console/shared' +import pDebounce from 'p-debounce' import { useUserStore } from './user.js' import { useOrganizationStore } from './organization.js' import { apiClient, extractData } from '@/api/xhr-client.js' @@ -23,30 +24,77 @@ export type ProjectOperations = 'create' export type ProjectWithOrganization = ProjectV2 & { organization: Organization operationsInProgress: Ref> - repositories?: Repo[] - environments?: Environment[] + repositories: Repo[] + environments: Environment[] addOperation: (name: ProjectOperations) => { fn: (name: ProjectOperations) => boolean, args: ProjectOperations } removeOperation: (name: ProjectOperations) => boolean + myPerms?: bigint } +function calculateProjectPerms(project: ProjectV2 | undefined, userId: string | undefined) { + if (!project || !userId) return 0n + if (userId === project?.ownerId) return PROJECT_PERMS.MANAGE + const selfMember = project.members.find(member => member.userId === userId) + if (!selfMember) return 0n + + return getPermsByUserRoles(selfMember.roleIds, resourceListToDict(project.roles), project.everyonePerms) +} export const useProjectStore = defineStore('project', () => { - const selectedProject = ref() - const projectsById = ref>({}) - const projects = computed(() => sortArrByObjKeyAsc(Object.values(projectsById.value), 'name')) - const userStore = useUserStore() const organizationStore = useOrganizationStore() - const selectedProjectPerms = computed(() => { - if (!selectedProject.value) return 0n - const selfId = userStore.userProfile?.id - if (selfId === selectedProject.value?.ownerId) return PROJECT_PERMS.MANAGE - const selfMember = selectedProject.value.members.find(member => member.userId === selfId) - if (!selfMember) return 0n - return getPermsByUserRoles(selfMember.roleIds, resourceListToDict(selectedProject.value.roles), selectedProject.value.everyonePerms) - }) + function mergeOrCreateProject(projectReceived: ProjectV2, projectInStore?: Omit): Omit { + if (projectInStore) { + return { + ...projectInStore, + ...projectReceived, + } + } + const operationsInProgress = ref(new Set()) + + function removeOperation(operationName: ProjectOperations) { + return operationsInProgress.value.delete(operationName) + } + + function addOperation(operationName: ProjectOperations) { + if (operationsInProgress.value.has(operationName)) { + operationName += getRandomId() + } + if (operationsInProgress.value.size <= 1) { + operationsInProgress.value.add(operationName) + } else { + return { fn: (_: string) => false, args: operationName } + } + + return { fn: removeOperation, args: operationName } + } + return { + ...projectReceived, + operationsInProgress, + addOperation, + removeOperation, + organization: organizationStore.organizationsById[projectReceived.organizationId] as Organization, + } + } + + const userStore = useUserStore() + + // mostly for admin views + const projectsById = ref>>({}) + const projects = computed(() => sortArrByObjKeyAsc(Object.values(projectsById.value), 'name')) + + // mostly for project views + const selectedProjectId = ref('') + const myProjectsById = ref>({}) + const myProjects = computed(() => sortArrByObjKeyAsc([ + ...Object.values(myProjectsById.value), + ], 'name')) + + const selectedProject = computed(() => myProjectsById.value[selectedProjectId.value]) + + const selectedProjectPerms = computed(() => calculateProjectPerms(selectedProject.value, userStore.userProfile?.id)) const setSelectedProject = (id: string) => { - selectedProject.value = projects.value.find(project => project.id === id) + selectedProjectId.value = id } const listProjects = async (query: typeof projectContract.listProjects.query._type = { filter: 'member', statusNotIn: 'archived' }) => { @@ -60,45 +108,50 @@ export const useProjectStore = defineStore('project', () => { } } for (const project of res) { - if (projectsById.value[project.id]) { - projectsById.value[project.id] = { - ...projectsById.value[project.id], - ...project, - } - } else { - const operationsInProgress = projectsById.value[project.id] - ? projectsById.value[project.id].operationsInProgress - : ref(new Set()) - - function removeOperation(operationName: ProjectOperations) { - return operationsInProgress.value.delete(operationName) - } - - function addOperation(operationName: ProjectOperations) { - if (operationsInProgress.value.has(operationName)) { - operationName += getRandomId() - } - if (operationsInProgress.value.size <= 1) { - operationsInProgress.value.add(operationName) - } else { - return { fn: (_: string) => false, args: operationName } - } - - return { fn: removeOperation, args: operationName } - } - projectsById.value[project.id] = { - ...project, - operationsInProgress, - addOperation, - removeOperation, - organization: organizationStore.organizationsById[project.organizationId] as Organization, - } - } + projectsById.value[project.id] = mergeOrCreateProject(project, projectsById.value[project.id]) } if (selectedProject.value) { setSelectedProject(selectedProject.value.id) } } + const getMyProjects = pDebounce(async () => { + const res = await apiClient.Projects.listProjects({ query: { filter: 'member', statusNotIn: 'archived' } }) + .then(response => extractData(response, 200)) + await organizationStore.listOrganizations() + // remove old projects not in response + for (const project of projects.value) { + if (!res.find(({ id }) => id === project.id)) { + delete myProjectsById.value[project.id] + } + } + for (const project of res) { + const mergedProject = mergeOrCreateProject(project, myProjectsById.value[project.id]) + + const myPerms = calculateProjectPerms(mergedProject, userStore.userProfile?.id) + const [environments, repositories] = await Promise.all([ + ProjectAuthorized.ListEnvironments({ projectPermissions: myPerms }) + ? apiClient.Environments.listEnvironments({ query: { projectId: project.id } }) + .then(res => extractData(res, 200)) + : [], + ProjectAuthorized.ListEnvironments({ projectPermissions: myPerms }) + ? apiClient.Repositories.listRepositories({ query: { projectId: project.id } }) + .then(res => extractData(res, 200)) + : [], + ]) + + myProjectsById.value[project.id] = { + environments, + repositories, + myPerms, + ...mergedProject, + } + } + if (selectedProject.value) { + setSelectedProject(selectedProject.value.id) + } else { + setSelectedProject(myProjects.value[0]?.id) + } + }, 300, { before: true }) const updateProject = async (projectId: string, data: typeof projectContract.updateProject.body._type) => { return apiClient.Projects.updateProject({ body: data, params: { projectId } }) @@ -120,7 +173,6 @@ export const useProjectStore = defineStore('project', () => { const archiveProject = async (projectId: string) => { await apiClient.Projects.archiveProject({ params: { projectId } }) .then(response => extractData(response, 204)) - selectedProject.value = undefined } const getProjectSecrets = (projectId: string) => apiClient.Projects.getProjectSecrets({ params: { projectId } }) @@ -147,12 +199,16 @@ export const useProjectStore = defineStore('project', () => { .then(response => extractData(response, 200)) return { - handleProjectLocking, - generateProjectsData, selectedProject, + selectedProjectId, + myProjects, + myProjectsById, projects, projectsById, selectedProjectPerms, + getMyProjects, + handleProjectLocking, + generateProjectsData, setSelectedProject, listProjects, updateProject, diff --git a/apps/client/src/utils/func.ts b/apps/client/src/utils/func.ts index aff62fee8..0d40e1671 100644 --- a/apps/client/src/utils/func.ts +++ b/apps/client/src/utils/func.ts @@ -1,3 +1,5 @@ +import type { Quota } from '@cpn-console/shared' +import xbytes from 'xbytes' import { useSnackbarStore } from '@/stores/snackbar.js' export async function copyContent(content: string): Promise { @@ -42,3 +44,25 @@ export function truncateDescription(description: string) { innerHTML, } } + +export interface Consumption { + cpu?: number + memory?: number +} + +export function listQuotasToConsumption(quotas: Quota[]): Consumption { + return quotas.reduce((acc, value) => { + if (!value) return acc + acc.cpu += value.cpu + if (!value.memory.endsWith('B')) value.memory = `${value.memory}B` + const memorySize = xbytes.parseSize(value.memory) + + if (memorySize && Number.isInteger(memorySize)) { + acc.memory += memorySize + } + return acc + }, { + cpu: 0, + memory: 0, + }) +} diff --git a/apps/client/src/views/admin/ListPlugins.vue b/apps/client/src/views/admin/ListPlugins.vue index eea19990a..088da37b3 100644 --- a/apps/client/src/views/admin/ListPlugins.vue +++ b/apps/client/src/views/admin/ListPlugins.vue @@ -1,6 +1,6 @@ + + +
+ +
-
-
- - -
+ diff --git a/apps/client/src/views/admin/ListProjects.vue b/apps/client/src/views/admin/ListProjects.vue index d4a5af565..1b1d7fdb7 100644 --- a/apps/client/src/views/admin/ListProjects.vue +++ b/apps/client/src/views/admin/ListProjects.vue @@ -15,7 +15,7 @@ import type { ProjectWithOrganization } from '@/stores/project.js' import { useProjectStore } from '@/stores/project.js' import { useStageStore } from '@/stores/stage.js' import { useProjectMemberStore } from '@/stores/project-member.js' -import { bts, truncateDescription } from '@/utils/func.js' +import { bts } from '@/utils/func.js' import { useLogStore } from '@/stores/log.js' const projectStore = useProjectStore() @@ -54,7 +54,6 @@ const title = 'Liste des projets' const headers = [ 'Organisation', 'Nom', - 'Description', 'Souscripteur', 'Status', 'Verrouillage', @@ -76,22 +75,15 @@ const filterMethods: FilterMethods = { Vérrouillés: { filter: 'all', locked: true, statusNotIn: 'archived' }, } -interface DomElement extends Event { - target: HTMLElement & { - open?: string - } -} - const projectRows = computed(() => { let rows = projectStore.projects - ?.map(({ id, organization, name, description, status, locked, createdAt, updatedAt, owner }) => ( + .map(({ id, organization, name, status, locked, createdAt, updatedAt, owner }) => ( { status, locked, rowAttrs: { - onClick: (event: DomElement) => { + onClick: () => { if (status === 'archived') return snackbarStore.setMessage('Le projet est archivé, pas d\'action possible', 'info') - if (event.target.id === 'description' && event.target.getAttribute('open') === 'false') return untruncateDescription(event.target) selectProject(id) }, class: 'cursor-pointer', @@ -100,7 +92,6 @@ const projectRows = computed(() => { rowData: [ organization.label, name, - truncateDescription(description ?? ''), owner.email, { component: 'v-icon', @@ -409,13 +400,6 @@ async function getProjectLogs({ offset, limit }: { offset: number, limit: number totalLength.value = res.total isUpdating.value = false } - -// Utils Functions -function untruncateDescription(span: HTMLElement) { - span.innerHTML = span.title - - span.setAttribute('open', 'true') -} diff --git a/apps/client/src/views/projects/DsoProjectWrapper.vue b/apps/client/src/views/projects/DsoProjectWrapper.vue index 407ebc348..98b7c1afc 100644 --- a/apps/client/src/views/projects/DsoProjectWrapper.vue +++ b/apps/client/src/views/projects/DsoProjectWrapper.vue @@ -1,5 +1,4 @@ diff --git a/apps/client/src/views/projects/DsoRoles.vue b/apps/client/src/views/projects/DsoRoles.vue index 07fb634e1..156c660ce 100644 --- a/apps/client/src/views/projects/DsoRoles.vue +++ b/apps/client/src/views/projects/DsoRoles.vue @@ -78,7 +78,7 @@ async function saveEveryoneRole(role: { permissions: bigint }) { await projectStore.updateProject(projectStore.selectedProject.id, { everyonePerms: role.permissions.toString(), }) - await projectStore.listProjects() + await projectStore.getMyProjects() } const cancel = () => selectedId.value = undefined diff --git a/apps/client/src/views/projects/DsoSelectedProject.vue b/apps/client/src/views/projects/DsoSelectedProject.vue index 82c182caf..c8b7a89a5 100644 --- a/apps/client/src/views/projects/DsoSelectedProject.vue +++ b/apps/client/src/views/projects/DsoSelectedProject.vue @@ -11,6 +11,6 @@ const projectStore = useProjectStore() :description="projectStore.selectedProject.locked ? `Le projet ${projectStore.selectedProject?.name} est verrouillé. Veuillez contacter un administrateur` : `Le projet courant est : ${projectStore.selectedProject?.name} (${projectStore.selectedProject?.organization?.label})`" data-testid="currentProjectInfo" small - class="w-max fr-mb-2w" + class="w-max fr-mb-2w lg:hidden" /> diff --git a/apps/client/src/views/projects/DsoTeam.vue b/apps/client/src/views/projects/DsoTeam.vue index 921ae9c0e..fe62ba599 100644 --- a/apps/client/src/views/projects/DsoTeam.vue +++ b/apps/client/src/views/projects/DsoTeam.vue @@ -6,6 +6,7 @@ import { useProjectStore } from '@/stores/project.js' import { useProjectMemberStore } from '@/stores/project-member.js' import { useUserStore } from '@/stores/user.js' import { useSnackbarStore } from '@/stores/snackbar.js' +import router from '@/router/index.js' const projectStore = useProjectStore() const projectMemberStore = useProjectMemberStore() @@ -26,15 +27,19 @@ async function removeUserFromProject(userId: string) { if (!projectStore.selectedProject) return snackbarStore.isWaitingForResponse = true projectStore.selectedProject.members = await projectMemberStore.removeMember(projectStore.selectedProject.id, userId) + await projectStore.getMyProjects() teamKey.value = getRandomId('team') snackbarStore.isWaitingForResponse = false + if (userId === userStore.userProfile?.id) { + router.push('/projects') + } } async function transferOwnerShip(nextOwnerId: string) { if (!projectStore.selectedProject) return snackbarStore.isWaitingForResponse = true await projectStore.updateProject(projectStore.selectedProject.id, { ownerId: nextOwnerId }) - await projectStore.listProjects() + await projectStore.getMyProjects() teamKey.value = getRandomId('team') snackbarStore.isWaitingForResponse = false } diff --git a/apps/server/src/resources/log/router.spec.ts b/apps/server/src/resources/log/router.spec.ts index 534474adf..d6c144f75 100644 --- a/apps/server/src/resources/log/router.spec.ts +++ b/apps/server/src/resources/log/router.spec.ts @@ -51,7 +51,7 @@ describe('test logContract', () => { }) it('should return logs for non-admin, with projectId', async () => { - const projectPerms = getProjectMockInfos({ projectPermissions: 2n }) + const projectPerms = getProjectMockInfos({ projectPermissions: 1n }) const user = getUserMockInfos(false, undefined, projectPerms) const projectId = faker.string.uuid() diff --git a/apps/server/src/resources/log/router.ts b/apps/server/src/resources/log/router.ts index 7ae9e51eb..e3e3247cf 100644 --- a/apps/server/src/resources/log/router.ts +++ b/apps/server/src/resources/log/router.ts @@ -1,7 +1,8 @@ -import type { CleanLog, Log } from '@cpn-console/shared' -import { AdminAuthorized, ProjectAuthorized, logContract } from '@cpn-console/shared' +import type { CleanLog, Log, XOR } from '@cpn-console/shared' +import { AdminAuthorized, logContract } from '@cpn-console/shared' import { getLogs } from './business.js' import { serverInstance } from '@/app.js' +import type { UserProfile, UserProjectProfile } from '@/utils/controller.js' import { authUser } from '@/utils/controller.js' import { Forbidden403 } from '@/utils/errors.js' @@ -9,12 +10,12 @@ export function logRouter() { return serverInstance.router(logContract, { // Récupérer des logs getLogs: async ({ request: req, query }) => { - const perms = query.projectId + const perms: XOR = query.projectId ? await authUser(req, { id: query.projectId }) : await authUser(req) if (!AdminAuthorized.isAdmin(perms.adminPermissions)) { - if (!ProjectAuthorized.Manage(perms)) { + if (!perms.projectPermissions) { return new Forbidden403() } query.clean = true diff --git a/apps/server/src/resources/project-member/router.ts b/apps/server/src/resources/project-member/router.ts index 74bace391..e2911c5a0 100644 --- a/apps/server/src/resources/project-member/router.ts +++ b/apps/server/src/resources/project-member/router.ts @@ -61,14 +61,15 @@ export function projectMemberRouter() { }, removeMember: async ({ request: req, params }) => { - const { projectId } = params + const { projectId, userId } = params const perms = await authUser(req, { id: projectId }) - if (!perms.projectPermissions && !AdminAuthorized.isAdmin(perms.adminPermissions)) return new NotFound404() - if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() - if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') - if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') - + if (typeof perms.projectPermissions !== 'undefined' && userId !== perms.user?.id) { + if (!perms.projectPermissions && !AdminAuthorized.isAdmin(perms.adminPermissions)) return new NotFound404() + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + } const resBody = await removeMember(projectId, params.userId) return { diff --git a/apps/server/src/resources/project-service/business.ts b/apps/server/src/resources/project-service/business.ts index 6746da186..d6a12f236 100644 --- a/apps/server/src/resources/project-service/business.ts +++ b/apps/server/src/resources/project-service/business.ts @@ -49,7 +49,7 @@ export async function getProjectServices(projectId: Project['id'], permissionTar const publicClusters = await getPublicClusters() project.clusters = project.clusters.concat(publicClusters) - return Object.values(servicesInfos).map(({ name, title, to, imgSrc }) => { + return Object.values(servicesInfos).map(({ name, title, to, imgSrc, description }) => { let urls: ServiceUrl[] = [] const toResponse = to ? to({ @@ -79,7 +79,7 @@ export async function getProjectServices(projectId: Project['id'], permissionTar project: true, }, }) - return { imgSrc, title, name, urls, manifest } + return { imgSrc, title, name, urls, manifest, description } }).filter(s => s.urls.length || s.manifest.global?.length || s.manifest.project?.length) } diff --git a/apps/server/src/resources/system/config/business.ts b/apps/server/src/resources/system/config/business.ts index 21621b761..9b89e9ef0 100644 --- a/apps/server/src/resources/system/config/business.ts +++ b/apps/server/src/resources/system/config/business.ts @@ -24,7 +24,7 @@ export function objToDb(obj: PluginsUpdateBody): ConfigRecords { export async function getPluginsConfig() { const globalConfig = await getAdminPlugin() - return Object.values(servicesInfos).map(({ name, title, imgSrc }) => { + return Object.values(servicesInfos).map(({ name, title, imgSrc, description }) => { const manifest = populatePluginManifests({ data: { global: globalConfig, @@ -36,7 +36,7 @@ export async function getPluginsConfig() { project: false, }, }) - return { imgSrc, title, name, manifest: manifest.global ?? [] } + return { imgSrc, title, name, manifest: manifest.global ?? [], description } }).filter(plugin => plugin.manifest.length > 0) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e9dadcba..2c0cac4b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: vue3-json-viewer: specifier: ^2.2.2 version: 2.2.2(vue@3.5.6(typescript@5.5.3)) + xbytes: + specifier: ^1.9.1 + version: 1.9.1 optionalDependencies: '@cypress/vue': specifier: ^6.0.1 @@ -6810,6 +6813,10 @@ packages: utf-8-validate: optional: true + xbytes@1.9.1: + resolution: {integrity: sha512-29E0ygMFWrM5JW2W1ypmezjyR2FOS5aCszdLe620ymSJBoZzL0/RCLJKCoUFu3DfQGhZ/FWykEBp5dfEy6+hjA==} + engines: {node: '>=1'} + xcase@2.0.1: resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} @@ -14000,6 +14007,8 @@ snapshots: ws@8.18.0: {} + xbytes@1.9.1: {} + xcase@2.0.1: {} xml-name-validator@4.0.0: {}