From 549bc051f12d0a81369ed42a08ab89e324812791 Mon Sep 17 00:00:00 2001 From: ArnaudTa <33383276+ArnaudTA@users.noreply.github.com> Date: Wed, 2 Oct 2024 04:36:25 +0200 Subject: [PATCH] feat: :children_crossing: rework project selection --- .github/workflows/cache.yml | 2 +- .../cypress/components/specs/repo-form.ct.ts | 25 +- .../components/specs/services-config.ct.ts | 36 +- .../cypress/components/specs/team-ct.ct.ts | 13 +- apps/client/cypress/e2e/specs/01-logs.e2e.ts | 27 +- .../e2e/specs/admin/organizations.e2e.ts | 1 + .../cypress/e2e/specs/admin/projects.e2e.ts | 33 +- .../cypress/e2e/specs/create-project.e2e.ts | 2 + .../cypress/e2e/specs/environments.e2e.ts | 4 +- .../cypress/e2e/specs/redirection.e2e.ts | 5 - apps/client/cypress/e2e/specs/roles.e2e.ts | 4 +- apps/client/cypress/e2e/specs/services.e2e.ts | 12 +- apps/client/cypress/e2e/specs/team.e2e.ts | 2 +- apps/client/cypress/e2e/support/commands.ts | 1 - apps/client/package.json | 4 +- apps/client/src/App.vue | 13 +- .../src/components/ConsumptionPanel.vue | 65 ++ apps/client/src/components/LogsViewer.vue | 21 +- .../src/components/ProjectLogsViewer.vue | 4 +- apps/client/src/components/SelectProject.vue | 116 ++++ apps/client/src/components/ServicesConfig.vue | 270 ++++----- apps/client/src/components/SideMenu.vue | 32 +- apps/client/src/components/TeamCt.vue | 80 ++- apps/client/src/router/index.ts | 16 +- apps/client/src/stores/log.ts | 5 +- apps/client/src/stores/project-environment.ts | 4 + apps/client/src/stores/project-repository.ts | 1 + apps/client/src/stores/project.spec.ts | 23 +- apps/client/src/stores/project.ts | 197 +++++-- apps/client/src/utils/func.ts | 24 + apps/client/src/views/CreateProject.vue | 4 +- apps/client/src/views/admin/AdminProject.vue | 491 ++++++++++++++++ apps/client/src/views/admin/ListPlugins.vue | 153 +++-- apps/client/src/views/admin/ListProjects.vue | 556 ++---------------- .../src/views/projects/DsoDashboard.vue | 63 +- .../src/views/projects/DsoProjectWrapper.vue | 4 +- .../client/src/views/projects/DsoProjects.vue | 25 +- apps/client/src/views/projects/DsoRepos.vue | 245 ++++---- apps/client/src/views/projects/DsoRoles.vue | 108 ++-- .../src/views/projects/DsoSelectedProject.vue | 2 +- .../client/src/views/projects/DsoServices.vue | 5 + apps/client/src/views/projects/DsoTeam.vue | 7 +- .../src/views/projects/ManageEnvironments.vue | 165 +++--- .../src/resources/environment/router.spec.ts | 10 +- apps/server/src/resources/log/router.spec.ts | 2 +- apps/server/src/resources/log/router.ts | 9 +- .../src/resources/project-member/router.ts | 13 +- .../src/resources/project-service/business.ts | 4 +- apps/server/src/resources/project/business.ts | 9 + apps/server/src/resources/project/queries.ts | 12 + apps/server/src/resources/project/router.ts | 17 + .../src/resources/repository/router.spec.ts | 14 +- .../src/resources/system/config/business.ts | 4 +- apps/server/src/utils/mocks.ts | 5 + packages/shared/src/contracts/environment.ts | 2 +- packages/shared/src/contracts/project.ts | 13 + packages/shared/src/contracts/repository.ts | 4 +- packages/shared/src/schemas/environment.ts | 3 +- packages/shared/src/schemas/repository.ts | 4 +- packages/shared/src/utils/schemas.spec.ts | 18 +- pnpm-lock.yaml | 65 +- 61 files changed, 1786 insertions(+), 1292 deletions(-) create mode 100644 apps/client/src/components/ConsumptionPanel.vue create mode 100644 apps/client/src/components/SelectProject.vue create mode 100644 apps/client/src/views/admin/AdminProject.vue diff --git a/.github/workflows/cache.yml b/.github/workflows/cache.yml index a329790b2..f741eb409 100644 --- a/.github/workflows/cache.yml +++ b/.github/workflows/cache.yml @@ -17,7 +17,7 @@ on: jobs: cleanup-cache: - name: Delete gituhb cache + name: Delete github cache runs-on: ubuntu-latest steps: - name: Check out code 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/services-config.ct.ts b/apps/client/cypress/components/specs/services-config.ct.ts index 0dba5cbc6..b32bd4bf2 100644 --- a/apps/client/cypress/components/specs/services-config.ct.ts +++ b/apps/client/cypress/components/specs/services-config.ct.ts @@ -7,7 +7,14 @@ import ServicesConfig from '@/components/ServicesConfig.vue' const argoto = { to: 'https://argocd.domain.com/applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=org-project', name: '' } const gitlabto = { to: 'https://gitlab.domain.com/forge-mi/projects/org/project', name: '' } -const services = [{ imgSrc: '/img/argocd.svg', title: 'ArgoCD', name: 'argocd', urls: [argoto, argoto, argoto], manifest: {} }, { imgSrc: '/img/gitlab.svg', title: 'Gitlab', name: 'gitlab', urls: [gitlabto, gitlabto], manifest: {} }, { imgSrc: '/img/harbor.svg', title: 'Harbor', name: 'registry', urls: [{ to: 'https://harbor.domain.com/harbor/projects/254', name: '' }], manifest: { global: [{ permissions: { admin: { read: true, write: true }, user: { read: true, write: false } }, key: 'publish-ro-robot-by-default', kind: 'switch', title: 'Publier le robot RO par défaut', value: 'default', initialValue: 'disabled' }], project: [{ permissions: { admin: { read: true, write: true }, user: { read: true, write: false } }, key: 'view-robot', kind: 'switch', title: 'Publier le robot', initialValue: 'disabled', value: 'default', description: 'Autoriser un robot de lecture sur le projet' }] } }, { imgSrc: '/img/sonarqube.svg', title: 'SonarQube', name: 'sonarqube', urls: [], manifest: {} }] +const services = [ + { imgSrc: '/img/argocd.svg', title: 'ArgoCD', name: 'argocd', urls: [argoto, argoto, argoto], manifest: {} }, + { imgSrc: '/img/gitlab.svg', title: 'Gitlab', name: 'gitlab', urls: [gitlabto, gitlabto], manifest: {} }, + { imgSrc: '/img/harbor.svg', title: 'Harbor', name: 'registry', urls: [{ to: 'https://harbor.domain.com/harbor/projects/254', name: '' }], manifest: { global: [{ permissions: { admin: { read: true, write: true }, user: { read: true, write: false } }, key: 'publish-ro-robot-by-default', kind: 'switch', title: 'Publier le robot RO par défaut', value: 'default', initialValue: 'disabled' }], project: [{ permissions: { admin: { read: true, write: true }, user: { read: true, write: false } }, key: 'view-robot', kind: 'switch', title: 'Publier le robot', initialValue: 'disabled', value: 'default', description: 'Autoriser un robot de lecture sur le projet' }] } }, + { imgSrc: '/img/sonarqube.svg', title: 'SonarQube', name: 'sonarqube', urls: [], manifest: {} }, +] + +const urlsLength = services.reduce((length, service) => length + service.urls.length, 0) describe('Service Configuration Component', () => { it('Affiche correctement les services et leurs configurations', () => { @@ -18,18 +25,14 @@ describe('Service Configuration Component', () => { displayGlobal: true, }, }) - // Vérifie que les services sont correctement affichés - cy.getByDataTestid('service-argocd').should('exist') - // Vérifie que les boutons de lien sont présents et fonctionnent - cy.getByDataTestid('service-argocd').find('a').should('have.length', 5) - cy.getByDataTestid('service-argocd').find('button').first().click() // Simule un clic sur le premier lien + // Vérifie que les services sont correctement affichés + cy.getByDataTestid('services-urls').find('a').should('have.length', urlsLength) - // Verifies qu'il n'y ait que 2 bouton "lien" si 2 to - cy.getByDataTestid('service-gitlab').find('a').should('have.length', 2) + cy.getByDataTestid('service-config-registry').should('exist') - // Verifies qu'il n'y ait pas de boutons "lien" - cy.getByDataTestid('service-sonarqube').find('a').should('not.exist') + // Vérifie que les boutons de lien sont présents et fonctionnent + cy.getByDataTestid('service-config-registry').click() // Simule un clic sur le premier lien // Vérifie que les boutons de rechargement sont présents cy.getByDataTestid('reloadBtn').should('exist') @@ -44,12 +47,11 @@ describe('Service Configuration Component', () => { }, }) // Simule un clic sur le bouton d'extension - cy.getByDataTestid('service-registry').within(() => { - cy.getByDataTestid('additional-config').should('not.be.visible') - cy.getByDataTestid('dropdown-button').click() - cy.getByDataTestid('additional-config').should('be.visible') - cy.getByDataTestid('dropdown-button').click() - cy.getByDataTestid('additional-config').should('not.be.visible') - }) + cy.getByDataTestid('service-config-registry') + .click() + cy.getByDataTestid('service-project-config-registry') + .should('exist') + cy.getByDataTestid('service-project-config-registry') + .should('exist') }) }) 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/cypress/e2e/specs/01-logs.e2e.ts b/apps/client/cypress/e2e/specs/01-logs.e2e.ts index d51991694..d531fd935 100644 --- a/apps/client/cypress/e2e/specs/01-logs.e2e.ts +++ b/apps/client/cypress/e2e/specs/01-logs.e2e.ts @@ -4,7 +4,7 @@ import { getModel } from '../support/func.js' const projects = getModel('project') const betaapp = projects.find(({ name }) => name === 'betaapp') as ProjectV2 -describe('Create Project', () => { +describe('Project Logs', () => { beforeEach(() => { }) @@ -26,6 +26,7 @@ describe('Create Project', () => { }) cy.getByDataTestid('replayHooksBtn') .click() + cy.wait('@listLogs') cy.getByDataTestid('displayLogsPanel') .should('be.visible') .within(() => { @@ -35,7 +36,7 @@ describe('Create Project', () => { cy.getByDataTestid('menuMyProjects').click() .url().should('contain', '/projects') cy.getByDataTestid('displayLogsPanel') - .should('not.be.visible') + .should('not.exist') }) it('Should handle display project logs as manager or memebr of project', () => { @@ -45,6 +46,7 @@ describe('Create Project', () => { // as owner cy.goToProjects() cy.getByDataTestid(`projectTile-${betaapp.name}`).click() + cy.wait('@listLogs') cy.getByDataTestid('displayLogsPanel') .should('not.be.visible') cy.getByDataTestid('displayLogsBtn') @@ -63,17 +65,24 @@ describe('Create Project', () => { }) // as member - cy.getByDataTestid('menuMyProjects').click() - .url().should('contain', '/projects') - cy.goToProjects() cy.getByDataTestid(`projectTile-candilib`).click() + cy.wait('@listLogs') cy.getByDataTestid('displayLogsPanel') - .should('not.exist') + .should('not.be.visible') cy.getByDataTestid('displayLogsBtn') - .should('not.exist') + .should('be.visible') + .click() + cy.getByDataTestid('displayLogsPanel') + .should('be.visible') + .within(() => { + cy.get('span').should('contain', '0 - 0 sur 0') + }) cy.getByDataTestid('menuRepos').click() - cy.getByDataTestid('displayLogsBtn') - .should('not.exist') + cy.getByDataTestid('displayLogsPanel') + .should('be.visible') + .within(() => { + cy.get('span').should('contain', '0 - 0 sur 0') + }) }) }) diff --git a/apps/client/cypress/e2e/specs/admin/organizations.e2e.ts b/apps/client/cypress/e2e/specs/admin/organizations.e2e.ts index 2ceab1139..1f4e94230 100644 --- a/apps/client/cypress/e2e/specs/admin/organizations.e2e.ts +++ b/apps/client/cypress/e2e/specs/admin/organizations.e2e.ts @@ -72,6 +72,7 @@ describe('Administration organizations', () => { cy.visit('/projects/create-project') cy.wait('@getAllOrganizations').its('response').then((response) => { + cy.log(organizations) cy.get('select#organizationId-select') .select((response.body.find(org => org.name === newOrg.name)).id) .should('have.value', (response.body.find(org => org.label === newOrg.label)).id) diff --git a/apps/client/cypress/e2e/specs/admin/projects.e2e.ts b/apps/client/cypress/e2e/specs/admin/projects.e2e.ts index 74f1a06d3..d7bd32788 100644 --- a/apps/client/cypress/e2e/specs/admin/projects.e2e.ts +++ b/apps/client/cypress/e2e/specs/admin/projects.e2e.ts @@ -1,7 +1,6 @@ import type { Organization, Project, ProjectV2 } from '@cpn-console/shared' import { formatDate, sortArrByObjKeyAsc, statusDict } from '@cpn-console/shared' import { getModel, getModelById } from '../../support/func.js' -import { truncateDescription } from '@/utils/func.js' function checkTableRowsLength(length: number) { if (!length) cy.get('tr:last-child>td:first-child').should('have.text', 'Aucun projet trouvé') @@ -40,15 +39,11 @@ describe('Administration projects', () => { cy.get(`tbody tr:nth-of-type(${index + 1})`).within(() => { cy.getSettled('td:nth-of-type(1)').should('contain', project.organization) cy.getSettled('td:nth-of-type(2)').should('contain', project.name) - cy.getByDataTestid('description').invoke('text').then((text) => { - cy.log(text) - expect(text).to.equal(truncateDescription(text).innerHTML) - }) - cy.getSettled('td:nth-of-type(4)').should('contain', project.owner.email) - cy.getSettled('td:nth-of-type(5) svg title').should('contain', `Le projet ${project.name} est ${statusDict.status[project.status].wording}`) - cy.getSettled('td:nth-of-type(6) svg title').should('contain', `Le projet ${project.name} est ${statusDict.locked[String(!!project.locked)].wording}`) - cy.getSettled('td:nth-of-type(7)').should('contain', formatDate(project.createdAt)) - cy.getSettled('td:nth-of-type(8)').should('contain', formatDate(project.updatedAt)) + cy.getSettled('td:nth-of-type(3)').should('contain', project.owner.email) + cy.getSettled('td:nth-of-type(4) svg title').should('contain', `Le projet ${project.name} est ${statusDict.status[project.status].wording}`) + cy.getSettled('td:nth-of-type(5) svg title').should('contain', `Le projet ${project.name} est ${statusDict.locked[String(!!project.locked)].wording}`) + cy.getSettled('td:nth-of-type(6)').should('contain', formatDate(project.createdAt)) + cy.getSettled('td:nth-of-type(7)').should('contain', formatDate(project.updatedAt)) }) }) }) @@ -321,12 +316,12 @@ describe('Administration projects', () => { }) cy.get('.fr-callout__title') .should('contain', project.name) - cy.get(`td[title="retirer ${member.email} du projet"]`) + cy.get(`td[title="Retirer ${member.email} du projet"]`) .click() cy.wait('@removeUser') .its('response.statusCode') .should('match', /^20\d$/) - cy.get(`td[title="retirer ${member.email} du projet"]`) + cy.get(`td[title="Retirer ${member.email} du projet"]`) .should('not.exist') cy.getByDataTestid('addUserSuggestionInput') .find('input') @@ -337,7 +332,7 @@ describe('Administration projects', () => { cy.wait('@addUser') .its('response.statusCode') .should('match', /^20\d$/) - cy.get(`td[title="retirer ${member.email} du projet"]`) + cy.get(`td[title="Retirer ${member.email} du projet"]`) .should('exist') }) @@ -418,12 +413,12 @@ describe('Administration projects', () => { cy.get('.fr-callout__title') .should('contain', project.name) cy.get('#servicesTable').should('exist') - cy.getByDataTestid('service-argocd').within(() => { - cy.get('a:first') - .should('have.attr', 'href', 'https://theuselessweb.com/') - cy.get('img:first') - .should('have.attr', 'src', '/img/argocd.svg') - }) + cy.getByDataTestid('service-config-argocd') + .click() + .within(() => { + cy.get('input') + .should('have.length', 1) + }) }) it('Should download projects informations, loggedIn as admin', () => { diff --git a/apps/client/cypress/e2e/specs/create-project.e2e.ts b/apps/client/cypress/e2e/specs/create-project.e2e.ts index cd6eaf69c..cfeb860ae 100644 --- a/apps/client/cypress/e2e/specs/create-project.e2e.ts +++ b/apps/client/cypress/e2e/specs/create-project.e2e.ts @@ -37,6 +37,8 @@ describe('Create Project', () => { cy.getByDataTestid('createProjectBtn').should('be.enabled').click() cy.wait('@postProject').its('response.statusCode').should('match', /^20\d$/) + cy.url().should('match', /projects\/.*\/dashboard/) + cy.wait('@listProjects').its('response.statusCode').should('match', /^20\d$/) cy.assertCreateProjects([project.name]) diff --git a/apps/client/cypress/e2e/specs/environments.e2e.ts b/apps/client/cypress/e2e/specs/environments.e2e.ts index 82a6625eb..e4c754ec4 100644 --- a/apps/client/cypress/e2e/specs/environments.e2e.ts +++ b/apps/client/cypress/e2e/specs/environments.e2e.ts @@ -139,9 +139,9 @@ describe('Manage project environments', () => { cy.get('#zone-select') .select(publicZone?.id) cy.get('#cluster-select') - .should('not.exist') - cy.getByDataTestid('noClusterOptionAlert') .should('exist') + cy.getByDataTestid('noClusterOptionAlert') + .should('not.exist') cy.get('#stage-select') .select(devStage?.id) diff --git a/apps/client/cypress/e2e/specs/redirection.e2e.ts b/apps/client/cypress/e2e/specs/redirection.e2e.ts index 5b3116b34..d6a305071 100644 --- a/apps/client/cypress/e2e/specs/redirection.e2e.ts +++ b/apps/client/cypress/e2e/specs/redirection.e2e.ts @@ -5,7 +5,6 @@ const organization = getModelById('organization', project.organizationId) describe('Redirection', () => { it('Should redirect to original page on reload', () => { - cy.intercept('GET', '/api/v1/stages').as('listStages') cy.intercept('GET', '/api/v1/projects?filter=member&statusNotIn=archived').as('listProjects') cy.intercept('POST', '/realms/cloud-pi-native/protocol/openid-connect/token').as('postToken') @@ -22,12 +21,10 @@ describe('Redirection', () => { cy.should('have.length', `${response?.body.length}`) cy.getByDataTestid(`projectTile-${project.name}`).click() cy.url().should('contain', `/projects/${project.id}/dashboard`) - cy.wait('@listStages') }) cy.reload() cy.wait('@postToken') cy.url().should('contain', `/projects/${project.id}/dashboard`) - cy.wait('@listStages') cy.wait('@listProjects').its('response').then((_response) => { cy.getByDataTestid('currentProjectInfo') cy.should('contain', `Le projet courant est : ${project.name} (${organization.label})`) @@ -35,7 +32,6 @@ describe('Redirection', () => { }) it('Should redirect to login page if not logged in', () => { - cy.intercept('GET', '/api/v1/stages').as('listStages') cy.intercept('GET', '/api/v1/projects?filter=member&statusNotIn=archived').as('listProjects') cy.intercept('POST', '/realms/cloud-pi-native/protocol/openid-connect/token').as('postToken') cy.intercept('GET', '/realms/cloud-pi-native/account').as('getAccount') @@ -47,7 +43,6 @@ describe('Redirection', () => { cy.get('input#kc-login').click() cy.wait('@postToken') cy.url().should('contain', `/projects/${project.id}/dashboard`) - cy.wait('@listStages') cy.wait('@listProjects', { timeout: 5_000 }).its('response').then((_response) => { cy.getByDataTestid('currentProjectInfo') cy.should('contain', `Le projet courant est : ${project.name} (${organization.label})`) diff --git a/apps/client/cypress/e2e/specs/roles.e2e.ts b/apps/client/cypress/e2e/specs/roles.e2e.ts index 2a7c46739..8955fb3f2 100644 --- a/apps/client/cypress/e2e/specs/roles.e2e.ts +++ b/apps/client/cypress/e2e/specs/roles.e2e.ts @@ -76,7 +76,7 @@ describe('Project roles', () => { cy.getByDataTestid('menuTeam').should('be.visible').click() cy.url().should('contain', `/projects/${project.id}/team`) - cy.getByDataTestid('teamTable').get('th').contains('Retirer du projet').should('not.exist') + cy.getByDataTestid('teamTable').get('th').contains('Retirer du projet').should('be.visible') cy.getByDataTestid('addUserSuggestionInput').should('not.exist') cy.getByDataTestid('showTransferProjectBtn').should('not.exist') @@ -118,7 +118,7 @@ describe('Project roles', () => { cy.getByDataTestid('menuTeam').should('be.visible').click() cy.url().should('contain', `/projects/${project.id}/team`) - cy.getByDataTestid('teamTable').get('th').contains('Retirer du projet').should('not.exist') + cy.getByDataTestid('teamTable').get('th').contains('Retirer du projet').should('be.visible') cy.getByDataTestid('addUserSuggestionInput').should('not.exist') cy.getByDataTestid('showTransferProjectBtn').should('not.exist') diff --git a/apps/client/cypress/e2e/specs/services.e2e.ts b/apps/client/cypress/e2e/specs/services.e2e.ts index 59301fc54..c7fde3b04 100644 --- a/apps/client/cypress/e2e/specs/services.e2e.ts +++ b/apps/client/cypress/e2e/specs/services.e2e.ts @@ -11,11 +11,11 @@ describe('Services view', () => { cy.goToProjects() cy.getByDataTestid(`projectTile-${project.name}`).click() cy.getByDataTestid('menuServices').click() - cy.getByDataTestid('service-argocd').within(() => { - cy.get('a:first') - .should('have.attr', 'href', 'https://theuselessweb.com/') - cy.get('img:first') - .should('have.attr', 'src', '/img/argocd.svg') - }) + cy.getByDataTestid('service-config-argocd') + .click() + .within(() => { + cy.get('input') + .should('have.length', 1) + }) }) }) diff --git a/apps/client/cypress/e2e/specs/team.e2e.ts b/apps/client/cypress/e2e/specs/team.e2e.ts index f0ec2be51..65b92a3f9 100644 --- a/apps/client/cypress/e2e/specs/team.e2e.ts +++ b/apps/client/cypress/e2e/specs/team.e2e.ts @@ -159,7 +159,7 @@ describe('Team view', () => { cy.getByDataTestid('teamTable') .find('tbody > tr') .should('have.length', project.members.length + 1 + 1) - .get(`td[title="retirer ${newMember.email} du projet"]`) + .get(`td[title="Retirer ${newMember.email} du projet"]`) .click() cy.wait('@removeUser') .its('response.statusCode') diff --git a/apps/client/cypress/e2e/support/commands.ts b/apps/client/cypress/e2e/support/commands.ts index 75e85bff5..39766f912 100644 --- a/apps/client/cypress/e2e/support/commands.ts +++ b/apps/client/cypress/e2e/support/commands.ts @@ -30,7 +30,6 @@ Cypress.Commands.add('goToProjects', () => { cy.intercept('GET', 'api/v1/projects?filter=member&statusNotIn=archived').as('listProjects') cy.visit('/') - .getByDataTestid('menuProjectsBtn').click() cy.getByDataTestid('menuMyProjects').click() cy.wait('@listProjects') cy.url().should('contain', '/projects') diff --git a/apps/client/package.json b/apps/client/package.json index 19442abe1..f0412434e 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -36,6 +36,7 @@ "@vue/tsconfig": "^0.5.1", "axios": "^1.7.7", "dotenv": "^16.4.5", + "javascript-time-ago": "^2.5.11", "js-yaml": "^4.1.0", "jszip": "^3.10.1", "keycloak-js": "^25.0.5", @@ -44,7 +45,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..4d9a58598 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,12 @@ onBeforeMount(() => { + - diff --git a/apps/client/src/components/SideMenu.vue b/apps/client/src/components/SideMenu.vue index b92b64ec3..8177e5c6a 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..8a4741c54 100644 --- a/apps/client/src/router/index.ts +++ b/apps/client/src/router/index.ts @@ -25,6 +25,7 @@ const DsoRepos = () => import('@/views/projects/DsoRepos.vue') const DsoAdmin = () => import('@/views/admin/DsoAdmin.vue') const ListUser = () => import('@/views/admin/ListUser.vue') const ListOrganizations = () => import('@/views/admin/ListOrganizations.vue') +const AdminProject = () => import('@/views/admin/AdminProject.vue') const ListProjects = () => import('@/views/admin/ListProjects.vue') const ListLogs = () => import('@/views/admin/ListLogs.vue') const AdminRoles = () => import('@/views/admin/AdminRoles.vue') @@ -39,7 +40,7 @@ const AdminTokens = () => import('@/views/admin/AdminTokens.vue') const MAIN_TITLE = 'Console Cloud π Native' -const routes: Readonly = [ +export const routes: Readonly = [ { path: '/login', name: 'Login', @@ -138,7 +139,7 @@ const routes: Readonly = [ }, ], async beforeEnter() { - await useProjectStore().listProjects() + await useProjectStore().getMyProjects() }, }, { @@ -161,10 +162,19 @@ const routes: Readonly = [ name: 'ListOrganizations', component: ListOrganizations, }, + { + path: 'projects/:id', + name: 'AdminProject', + component: AdminProject, + props(to) { + return { projectId: to.params.id } + }, + }, { path: 'projects', name: 'ListProjects', component: ListProjects, + strict: false, }, { path: 'logs', @@ -268,4 +278,6 @@ router.beforeEach(async (to, _from, next) => { next() }) +export const isInProject = computed(() => router.currentRoute.value.fullPath.match(/^\/projects\/.+/)) + export default router 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/project-environment.ts b/apps/client/src/stores/project-environment.ts index e3d5e9ae5..4f51c737d 100644 --- a/apps/client/src/stores/project-environment.ts +++ b/apps/client/src/stores/project-environment.ts @@ -11,18 +11,21 @@ export const useProjectEnvironmentStore = defineStore('project-environment', () const getProjectEnvironments = async (projectId: string) => { environments.value = await apiClient.Environments.listEnvironments({ query: { projectId } }) .then(response => extractData(response, 200)) + return environments.value } const addEnvironmentToProject = async (body: CreateEnvironmentBody) => { if (!projectStore.selectedProject) throw new Error(projectMissing) await apiClient.Environments.createEnvironment({ body }) .then(response => extractData(response, 201)) await getProjectEnvironments(projectStore.selectedProject.id) + return environments.value } const updateEnvironment = async (id: Environment['id'], environment: UpdateEnvironmentBody) => { await apiClient.Environments.updateEnvironment({ body: environment, params: { environmentId: id } }) .then(response => extractData(response, 200)) if (projectStore.selectedProject) await getProjectEnvironments(projectStore.selectedProject.id) + return environments.value } const deleteEnvironment = async (environmentId: Environment['id']) => { @@ -30,6 +33,7 @@ export const useProjectEnvironmentStore = defineStore('project-environment', () await apiClient.Environments.deleteEnvironment({ params: { environmentId } }) .then(response => extractData(response, 204)) await getProjectEnvironments(projectStore.selectedProject.id) + return environments.value } return { diff --git a/apps/client/src/stores/project-repository.ts b/apps/client/src/stores/project-repository.ts index bab5cb34d..22e35577d 100644 --- a/apps/client/src/stores/project-repository.ts +++ b/apps/client/src/stores/project-repository.ts @@ -11,6 +11,7 @@ export const useProjectRepositoryStore = defineStore('project-repository', () => const getProjectRepositories = async (projectId: string) => { repositories.value = await apiClient.Repositories.listRepositories({ query: { projectId } }) .then(response => extractData(response, 200)) + return repositories.value } const syncRepository = async (repositoryId: string, { branchName, syncAllBranches = false }: { branchName?: string, syncAllBranches?: boolean }) => { diff --git a/apps/client/src/stores/project.spec.ts b/apps/client/src/stores/project.spec.ts index 96495741a..d0608c65c 100644 --- a/apps/client/src/stores/project.spec.ts +++ b/apps/client/src/stores/project.spec.ts @@ -6,7 +6,10 @@ import { useProjectStore } from './project.js' import { useOrganizationStore } from './organization.js' const listOrganizations = vi.spyOn(apiClient.Organizations, 'listOrganizations') +const getProject = vi.spyOn(apiClient.Projects, 'getProject') 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 +26,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 +41,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,17 +76,18 @@ describe('project Store', () => { const projects = [randomDbSetup.project] const organizations = [randomDbSetup.organization] - projectStore.selectedProject = randomDbSetup.project listOrganizations.mockReturnValueOnce(Promise.resolve({ status: 200, body: organizations, headers: {} })) listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + listEnvironments.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.environments, headers: {} })) + listRepositories.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.repositories, headers: {} })) - await projectStore.listProjects({ filter: 'member' }) + await projectStore.getMyProjects() + projectStore.setSelectedProject(randomDbSetup.project.id) expect(listOrganizations).toHaveBeenCalledTimes(1) expect(listProjects).toHaveBeenCalledTimes(1) - expect(projectStore.projects).toMatchObject(projects) - expect(projectStore.selectedProject).toMatchObject(projects[0]) + expect(projectStore.selectedProject.id).toMatchObject(projects[0].id) expect(organizationStore.organizations).toMatchObject(organizations) }) @@ -107,9 +111,7 @@ describe('project Store', () => { await projectStore.createProject(newProject) expect(apiClientPost).toHaveBeenCalledTimes(1) - expect(listProjects).toHaveBeenCalledTimes(1) - expect(listOrganizations).toHaveBeenCalledTimes(1) - expect(projectStore.projects).toHaveLength(2) + expect(projectStore.myProjects).toHaveLength(1) }) it('should set a project description by api call', async () => { @@ -145,10 +147,11 @@ describe('project Store', () => { apiClientReplayHooks.mockReturnValueOnce(Promise.resolve({ status: 204, body: project, headers: {} })) listOrganizations.mockReturnValueOnce(Promise.resolve({ status: 200, body: [randomDbSetup.organization], headers: {} })) - listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: [project], headers: {} })) + getProject.mockReturnValue(Promise.resolve({ status: 200, body: project, headers: {} })) await projectStore.replayHooksForProject(project.id) + expect(getProject).toHaveBeenCalledTimes(1) expect(apiClientReplayHooks).toHaveBeenCalledTimes(1) }) diff --git a/apps/client/src/stores/project.ts b/apps/client/src/stores/project.ts index a89e7736c..1425df8ce 100644 --- a/apps/client/src/stores/project.ts +++ b/apps/client/src/stores/project.ts @@ -4,11 +4,13 @@ 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' +type StoresTarget = ('mine' | 'all')[] export type ProjectOperations = 'create' | 'delete' | 'envManagement' @@ -23,30 +25,107 @@ 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)) + + async function updateStores(project: ProjectV2, stores: StoresTarget = [], force = false) { + if (stores.includes('all')) { + projectsById.value[project.id] = mergeOrCreateProject(project, projectsById.value[project.id]) + } + if (stores.includes('mine')) { + const mergedProject = mergeOrCreateProject(project, myProjectsById.value[project.id]) + const myPerms = calculateProjectPerms(mergedProject, userStore.userProfile?.id) + const promises: [Promise | undefined, Promise | undefined] = [undefined, undefined] + if (ProjectAuthorized.ListEnvironments({ projectPermissions: myPerms })) { + promises[0] = !myProjectsById.value[project.id]?.environments || force + ? apiClient.Environments.listEnvironments({ query: { projectId: project.id } }) + .then(res => extractData(res, 200)) + : Promise.resolve(myProjectsById.value[project.id]?.environments) + } + if (ProjectAuthorized.ListRepositories({ projectPermissions: myPerms })) { + promises[1] = !myProjectsById.value[project.id]?.repositories || force + ? apiClient.Repositories.listRepositories({ query: { projectId: project.id } }) + .then(res => extractData(res, 200)) + : Promise.resolve(myProjectsById.value[project.id]?.repositories) + } + const [environments, repositories] = await Promise.all(promises) + myProjectsById.value[project.id] = { + ...mergedProject, + environments: environments ?? [], + repositories: repositories ?? [], + myPerms, + } + } + } 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,75 +139,62 @@ 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, - } - } + await updateStores(project, ['all']) + } + } + const getMyProjects = pDebounce(async (force: boolean = false) => { + const res = await apiClient.Projects.listProjects({ query: { filter: 'member', statusNotIn: 'archived' } }) + .then(response => extractData(response, 200)) + if (res.some(project => !organizationStore.organizationsById[project.organizationId])) { + await organizationStore.listOrganizations() } - if (selectedProject.value) { - setSelectedProject(selectedProject.value.id) + // remove old projects not in response + for (const project of myProjects.value) { + if (!res.find(({ id }) => id === project.id)) { + delete myProjectsById.value[project.id] + } } + await Promise.all(res.map(project => updateStores(project, ['mine'], force))) + }, 300, { before: true }) + + const getProject = async (projectId: ProjectV2['id'], stores?: StoresTarget) => { + const project = await apiClient.Projects.getProject({ params: { projectId } }) + .then(response => extractData(response, 200)) + await updateStores(project, stores) } - const updateProject = async (projectId: string, data: typeof projectContract.updateProject.body._type) => { - return apiClient.Projects.updateProject({ body: data, params: { projectId } }) + const updateProject = async (projectId: string, data: typeof projectContract.updateProject.body._type, stores?: StoresTarget) => { + const project = await apiClient.Projects.updateProject({ body: data, params: { projectId } }) .then(response => extractData(response, 200)) + await updateStores(project, stores) } const createProject = async (body: CreateProjectBody) => { - const res = await apiClient.Projects.createProject({ body }) + const project = await apiClient.Projects.createProject({ body }) .then(response => extractData(response, 201)) - await listProjects() - return res + await updateStores(project, ['mine']) } - const replayHooksForProject = async (projectId: string) => { + const replayHooksForProject = async (projectId: string, stores?: StoresTarget) => { await apiClient.Projects.replayHooksForProject({ params: { projectId } }) .then(response => extractData(response, 204)) + await getProject(projectId, stores) } const archiveProject = async (projectId: string) => { await apiClient.Projects.archiveProject({ params: { projectId } }) .then(response => extractData(response, 204)) - selectedProject.value = undefined + delete myProjectsById.value[projectId] } const getProjectSecrets = (projectId: string) => apiClient.Projects.getProjectSecrets({ params: { projectId } }) .then(response => extractData(response, 200)) - const handleProjectLocking = (projectId: string, lock: boolean) => - apiClient.Projects.updateProject({ body: { locked: lock }, params: { projectId } }) + async function handleProjectLocking(projectId: string, lock: boolean, stores?: StoresTarget) { + const project = await apiClient.Projects.updateProject({ body: { locked: lock }, params: { projectId } }) .then(response => extractData(response, 200)) + await updateStores(project, stores) + } const generateProjectsData = () => apiClient.Projects.getProjectsData() @@ -147,12 +213,17 @@ export const useProjectStore = defineStore('project', () => { .then(response => extractData(response, 200)) return { - handleProjectLocking, - generateProjectsData, selectedProject, + selectedProjectId, + myProjects, + myProjectsById, projects, projectsById, selectedProjectPerms, + getProject, + 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/CreateProject.vue b/apps/client/src/views/CreateProject.vue index dfaaeb149..6b64a28c8 100644 --- a/apps/client/src/views/CreateProject.vue +++ b/apps/client/src/views/CreateProject.vue @@ -110,7 +110,7 @@ onMounted(async () => { label-visible :options="orgOptions" :default-unselected-text="orgOptions.length ? 'Choisissez une organisation' : 'Aucune organisation disponible, veuillez contacter un administrateur'" - :disabled="!organizationStore.organizations.length" + :disabled="!organizationStore.organizations.length || buttonState.isCreating" @update:model-value="updateProject('organizationId', $event)" />
{ label-visible :hint="`Nom du projet dans l'offre Cloud π Native. Ne doit pas contenir d'espace, doit être unique pour l'organisation, doit être en minuscules, doit faire plus de 2 et moins de ${projectNameMaxLength} caractères.`" placeholder="candilib" + :disabled="buttonState.isCreating" @update:model-value="updateProject('name', $event)" />
@@ -148,6 +149,7 @@ onMounted(async () => { label-visible :hint="`Courte description expliquant la finalité du projet (${descriptionMaxLength} caractères maximum).`" placeholder="Application de réservation de places à l'examen du permis B." + :disabled="buttonState.isCreating" @update:model-value="updateProject('description', $event)" /> diff --git a/apps/client/src/views/admin/AdminProject.vue b/apps/client/src/views/admin/AdminProject.vue new file mode 100644 index 000000000..6c0feea7e --- /dev/null +++ b/apps/client/src/views/admin/AdminProject.vue @@ -0,0 +1,491 @@ + + + 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..a10bf8801 100644 --- a/apps/client/src/views/admin/ListProjects.vue +++ b/apps/client/src/views/admin/ListProjects.vue @@ -2,32 +2,23 @@ import { onBeforeMount, ref } from 'vue' // @ts-ignore '@gouvminint/vue-dsfr' missing types import { getRandomId } from '@gouvminint/vue-dsfr' -import type { Log, Organization, PluginsUpdateBody, ProjectService, projectContract } from '@cpn-console/shared' -import { formatDate, sortArrByObjKeyAsc, statusDict } from '@cpn-console/shared' +import type { Organization, projectContract } from '@cpn-console/shared' +import { statusDict } from '@cpn-console/shared' +import TimeAgo from 'javascript-time-ago' +import fr from 'javascript-time-ago/locale/fr' import { useSnackbarStore } from '@/stores/snackbar.js' import { useOrganizationStore } from '@/stores/organization.js' -import { useProjectEnvironmentStore } from '@/stores/project-environment.js' -import { useUserStore } from '@/stores/user.js' import { useQuotaStore } from '@/stores/quota.js' -import { useProjectServiceStore } from '@/stores/project-services.js' -import { useProjectRepositoryStore } from '@/stores/project-repository.js' -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 { useLogStore } from '@/stores/log.js' +import { bts } from '@/utils/func.js' +import router from '@/router/index.js' const projectStore = useProjectStore() const organizationStore = useOrganizationStore() -const projectServiceStore = useProjectServiceStore() -const userStore = useUserStore() const snackbarStore = useSnackbarStore() const quotaStore = useQuotaStore() const stageStore = useStageStore() -const projectMemberStore = useProjectMemberStore() -const projectRepositoryStore = useProjectRepositoryStore() -const projectEnvironmentStore = useProjectEnvironmentStore() type FileForDownload = File & { href?: string @@ -35,37 +26,26 @@ type FileForDownload = File & { title?: string } -const selectedProjectId = ref() const organizations = ref([]) const tableKey = ref(getRandomId('table')) -const selectedProject = computed(() => { - return (selectedProjectId.value && projectStore.projectsById[selectedProjectId.value]) || undefined -}) -const teamCtKey = ref(getRandomId('team')) -const environmentsCtKey = ref(getRandomId('environment')) -const repositoriesCtKey = ref(getRandomId('repository')) -const isArchivingProject = ref(false) -const projectToArchive = ref('') const inputSearchText = ref('') const activeFilter = ref('Non archivés') const file = ref(undefined) +// Add locale-specific relative date/time formatting rules. +TimeAgo.addLocale(fr) + +// Create relative date/time formatter. +const timeAgo = new TimeAgo('fr-FR') const title = 'Liste des projets' const headers = [ 'Organisation', 'Nom', - 'Description', 'Souscripteur', 'Status', 'Verrouillage', - 'Création', - 'Modification', + 'Date de création', ] -const membersId = 'membersTable' -const repositoriesId = 'repositoriesTable' -const environmentsId = 'environmentsTable' -const servicesId = 'servicesTable' -const logsId = 'logsView' type FilterMethods = Record const filterMethods: FilterMethods = { @@ -76,22 +56,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, 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 +73,6 @@ const projectRows = computed(() => { rowData: [ organization.label, name, - truncateDescription(description ?? ''), owner.email, { component: 'v-icon', @@ -114,8 +86,11 @@ const projectRows = computed(() => { title: `Le projet ${name} est ${statusDict.locked[bts(locked)].wording}`, fill: statusDict.locked[bts(locked)].color, }, - formatDate(createdAt), - formatDate(updatedAt), + { + text: timeAgo.format(new Date(createdAt)), + title: (new Date(createdAt)).toLocaleString(), + component: 'span', + }, ], }), ) @@ -123,7 +98,7 @@ const projectRows = computed(() => { rows = rows.filter((row) => { return row.rowData.some((data) => { if (typeof data === 'object') { - return data.title?.toString().toLowerCase().includes(inputSearchText.value.toLocaleLowerCase()) + return data.title?.toString().toLowerCase().includes(inputSearchText.value.toLocaleLowerCase()) || data.text?.toString().toLowerCase().includes(inputSearchText.value.toLocaleLowerCase()) } return data.toString().toLowerCase().includes(inputSearchText.value.toLocaleLowerCase()) }) @@ -140,197 +115,19 @@ const projectRows = computed(() => { } return rows }) -const envRows = computed(() => { - if (!selectedProject.value) return [] - if (!selectedProject.value.environments?.length) { - return [[{ - text: 'Aucun environnement existant', - cellAttrs: { - colspan: headers.length, - }, - }]] - } - return sortArrByObjKeyAsc(selectedProject.value.environments, 'name') - .map(({ id, name, quotaId, stageId }) => ( - [ - name, - stageStore.stages.find(stage => stage.id === stageId)?.name, - { - component: 'DsfrSelect', - modelValue: quotaId, - selectId: 'quota-select', - options: quotaStore.quotas.filter(quota => quota.stageIds.includes(stageId)).map(quota => ({ - text: `${quota.name} (${quota.cpu}CPU, ${quota.memory})`, - value: quota.id, - })), - 'onUpdate:model-value': (event: string) => updateEnvironmentQuota({ environmentId: id, quotaId: event }), - }, - ] - ), - ) -}) - -const repoRows = computed(() => { - if (!selectedProject.value) return [] - if (!selectedProject.value.repositories?.length) { - return [[{ - text: 'Aucun dépôt existant', - cellAttrs: { - colspan: headers.length, - }, - }]] - } - return sortArrByObjKeyAsc(selectedProject.value.repositories, 'internalRepoName') - ?.map(({ internalRepoName, isInfra, externalRepoUrl, isPrivate }) => ( - [ - internalRepoName, - isInfra ? 'Infra' : 'Applicatif', - isPrivate ? 'oui' : 'non', - externalRepoUrl, - ] - ), - ) -}) async function getAllProjects() { snackbarStore.isWaitingForResponse = true await projectStore.listProjects(filterMethods[activeFilter.value]) tableKey.value = getRandomId('table') - if (selectedProject.value) selectProject(selectedProject.value.id) snackbarStore.isWaitingForResponse = false } async function selectProject(projectId: string) { - selectedProjectId.value = projectId - if (!projectStore.projectsById[selectedProjectId.value]) return - await Promise.all([ - projectRepositoryStore.getProjectRepositories(projectId), - projectEnvironmentStore.getProjectEnvironments(projectId), - reloadProjectServices(), - showLogs(0), - ]) - projectStore.projectsById[selectedProjectId.value].environments = projectEnvironmentStore.environments - projectStore.projectsById[selectedProjectId.value].repositories = projectRepositoryStore.repositories - - environmentsCtKey.value = getRandomId('environment') - repositoriesCtKey.value = getRandomId('repository') -} - -function unSelectProject() { - selectedProjectId.value = undefined -} - -async function updateEnvironmentQuota({ environmentId, quotaId }: { environmentId: string, quotaId: string }) { - if (!selectedProject.value) { - return - } - const callback = selectedProject.value.addOperation('envManagement') - const environment = projectEnvironmentStore.environments.find(environment => environment.id === environmentId) - if (!environment) return - environment.quotaId = quotaId - try { - await projectEnvironmentStore.updateEnvironment(environment.id, environment) - } catch (error) { - snackbarStore.setMessage(error?.message, 'error') - } - await getAllProjects() - - callback.fn(callback.args) - await showLogs() -} - -async function handleProjectLocking(projectId: string, lock: boolean) { - if (!selectedProject.value) { - return - } - const callback = selectedProject.value.addOperation('lockHandling') - try { - await projectStore.handleProjectLocking(projectId, lock) - } catch (error) { - snackbarStore.setMessage(error?.message, 'error') - } - await getAllProjects() - - callback.fn(callback.args) - await showLogs() -} - -async function replayHooks() { - if (!selectedProject.value) { - return - } - const callback = selectedProject.value.addOperation('replay') - try { - await projectStore.replayHooksForProject(selectedProject.value.id) - snackbarStore.setMessage(`Le projet ${selectedProject.value.name} a été reprovisionné avec succès`, 'success') - await getAllProjects() - } catch (error) { - console.trace(error) - snackbarStore.setMessage(error?.message, 'error') - } - callback.fn(callback.args) - await showLogs() -} - -async function archiveProject(projectId: string) { - if (!selectedProject.value) return - const callback = selectedProject.value.addOperation('delete') - try { - await projectStore.archiveProject(projectId) - selectedProjectId.value = undefined - } catch (error) { - snackbarStore.setMessage(error?.message, 'error') - } - await getAllProjects() - - callback.fn(callback.args) -} - -async function addUserToProject(email: string) { - if (!selectedProject.value) return - const callback = selectedProject.value.addOperation('teamManagement') - try { - await projectMemberStore.addMember(selectedProject.value.id, email) - } catch (error) { - snackbarStore.setMessage(error?.message, 'error') - } - await getAllProjects() - - teamCtKey.value = getRandomId('team') - callback.fn(callback.args) - await showLogs() -} - -async function removeUserFromProject(userId: string) { - if (!selectedProject.value) return - const callback = selectedProject.value.addOperation('teamManagement') - try { - if (selectedProject.value.id) { - await projectMemberStore.removeMember(selectedProject.value.id, userId) - } - } catch (error) { - snackbarStore.setMessage(error?.message, 'error') - } - await getAllProjects() - - teamCtKey.value = getRandomId('team') - callback.fn(callback.args) - await showLogs() -} - -async function transferOwnerShip(nextOwnerId: string) { - if (!selectedProject.value) return - const callback = selectedProject.value.addOperation('teamManagement') - try { - await projectStore.updateProject(selectedProject.value.id, { ownerId: nextOwnerId }) - } catch (error) { - snackbarStore.setMessage(error?.message, 'error') - } - await getAllProjects() - - teamCtKey.value = getRandomId('team') - callback.fn(callback.args) - await showLogs() + router.push({ + name: 'AdminProject', + params: { id: projectId }, + }) } async function generateProjectsDataFile() { @@ -356,75 +153,16 @@ onBeforeMount(async () => { await getAllProjects(), ]) }) - -const projectServices = ref([]) -async function reloadProjectServices() { - if (!selectedProjectId.value) { - return - } - const resServices = await projectServiceStore.getProjectServices(selectedProjectId.value, 'admin') - projectServices.value = [] - await nextTick() - const filteredServices = resServices - projectServices.value = filteredServices -} - -async function saveProjectServices(data: PluginsUpdateBody) { - if (!selectedProject.value) { - return - } - const callback = selectedProject.value.addOperation('saveServices') - try { - await projectServiceStore.updateProjectServices(data, selectedProject.value.id) - snackbarStore.setMessage('Paramètres sauvegardés', 'success') - } catch (_error) { - snackbarStore.setMessage('Erreur lors de la sauvegarde', 'error') - } - await reloadProjectServices() - callback.fn(callback.args) -} - -// LOGS Rendering functions -const logStore = useLogStore() - -const step = 10 -const isUpdating = ref(false) -const page = ref(0) - -const logs = ref([]) -const totalLength = ref(0) - -async function showLogs(index?: number) { - page.value = index ?? page.value - getProjectLogs({ offset: page.value * step, limit: step }) -} - -async function getProjectLogs({ offset, limit }: { offset: number, limit: number }) { - if (!selectedProjectId.value) { - return - } - isUpdating.value = true - const res = await logStore.listLogs({ offset, limit, projectId: selectedProjectId.value, clean: false }) - logs.value = res.logs as Log[] - totalLength.value = res.total - isUpdating.value = false -} - -// Utils Functions -function untruncateDescription(span: HTMLElement) { - span.innerHTML = span.title - - span.setAttribute('open', 'true') -} -@/stores/project-member.js diff --git a/apps/client/src/views/projects/DsoDashboard.vue b/apps/client/src/views/projects/DsoDashboard.vue index ccfed3ae0..2613a0124 100644 --- a/apps/client/src/views/projects/DsoDashboard.vue +++ b/apps/client/src/views/projects/DsoDashboard.vue @@ -26,7 +26,7 @@ async function updateProject() { if (!projectStore.selectedProject) return const callback = projectStore.selectedProject.addOperation('update') await projectStore.updateProject(projectStore.selectedProject.id, { description: description.value }) - await projectStore.listProjects() + await projectStore.getMyProjects() isEditingDescription.value = false callback.fn(callback.args) logStore.needRefresh = true @@ -35,8 +35,8 @@ async function updateProject() { async function replayHooks() { if (!projectStore.selectedProject) return const callback = projectStore.selectedProject.addOperation('replay') - await useProjectStore().replayHooksForProject(projectStore.selectedProject.id) - await useProjectStore().listProjects() + await projectStore.replayHooksForProject(projectStore.selectedProject.id) + await projectStore.getMyProjects() snackbarStore.setMessage('Le projet a été reprovisionné avec succès', 'success') callback.fn(callback.args) logStore.needRefresh = true @@ -47,12 +47,13 @@ async function archiveProject(projectId: ProjectV2['id']) { const callback = projectStore.selectedProject.addOperation('delete') try { await projectStore.archiveProject(projectId) - await projectStore.listProjects() + await projectStore.getMyProjects() } catch (_error) { - await projectStore.listProjects() + await projectStore.getMyProjects() throw _error } router.push('/projects') + projectStore.setSelectedProject(projectStore.myProjects[0]?.id ?? '') callback.fn(callback.args) } @@ -86,6 +87,10 @@ function getRows(service: string) { onBeforeMount(async () => { allStages.value = await stageStore.getAllStages() + logStore.needRefresh = true +}) +onMounted(() => { + logStore.needRefresh = true }) @@ -93,10 +98,11 @@ onBeforeMount(async () => {

{

- - + + +
+
{ label="Reprovisionner le projet" :icon="{ name: 'ri:refresh-fill', animation: projectStore.selectedProject.operationsInProgress.has('replay') ? 'spin' : '' }" secondary - :disabled="projectStore.selectedProject.operationsInProgress.has('replay')" + :disabled="projectStore.selectedProject.locked || projectStore.selectedProject.operationsInProgress.has('replay')" @click="replayHooks" />
@@ -312,8 +325,4 @@ onBeforeMount(async () => { - - diff --git a/apps/client/src/views/projects/DsoProjectWrapper.vue b/apps/client/src/views/projects/DsoProjectWrapper.vue index 407ebc348..f3ce6d38f 100644 --- a/apps/client/src/views/projects/DsoProjectWrapper.vue +++ b/apps/client/src/views/projects/DsoProjectWrapper.vue @@ -1,6 +1,6 @@ @@ -11,7 +11,7 @@ const projectStore = useProjectStore() > diff --git a/apps/client/src/views/projects/DsoProjects.vue b/apps/client/src/views/projects/DsoProjects.vue index 8442e83f1..d5e099776 100644 --- a/apps/client/src/views/projects/DsoProjects.vue +++ b/apps/client/src/views/projects/DsoProjects.vue @@ -3,11 +3,13 @@ import { sortArrByObjKeyAsc } from '@cpn-console/shared' import { useProjectStore } from '@/stores/project.js' import router from '@/router/index.js' import { useLogStore } from '@/stores/log.js' +import { useQuotaStore } from '@/stores/quota.js' const projectStore = useProjectStore() +const quotaStore = useQuotaStore() const logStore = useLogStore() -const projectList = computed(() => sortArrByObjKeyAsc(projectStore.projects, 'name') +const projectList = computed(() => sortArrByObjKeyAsc(projectStore.myProjects, 'name') ?.map(project => ({ id: project.id, title: project.name, @@ -20,16 +22,17 @@ async function setSelectedProject(project: Record) { } function goToCreateProject() { - router.push('projects/create-project') + router.push('/projects/create-project') } -onBeforeMount(() => { +onBeforeMount(async () => { logStore.displayProjectLogs = false + await projectStore.getMyProjects() + await quotaStore.getAllQuotas() }) diff --git a/apps/client/src/views/projects/DsoRepos.vue b/apps/client/src/views/projects/DsoRepos.vue index bb393a7a7..fb001c0ba 100644 --- a/apps/client/src/views/projects/DsoRepos.vue +++ b/apps/client/src/views/projects/DsoRepos.vue @@ -93,149 +93,164 @@ projectRepositoryStore.$subscribe(() => { setReposTiles() }) +projectStore.$subscribe(async () => { + if (!projectStore.selectedProject) return + projectRepositoryStore.getProjectRepositories(projectStore.selectedProject.id) + setReposTiles() +}) + const canManageRepos = computed(() => !projectStore.selectedProject?.locked && ProjectAuthorized.ManageRepositories({ projectPermissions: projectStore.selectedProjectPerms })) - - + > + Vous n'avez pas les permissions pour afficher ces ressources +

diff --git a/apps/client/src/views/projects/DsoRoles.vue b/apps/client/src/views/projects/DsoRoles.vue index 07fb634e1..88edb4a33 100644 --- a/apps/client/src/views/projects/DsoRoles.vue +++ b/apps/client/src/views/projects/DsoRoles.vue @@ -1,5 +1,5 @@ diff --git a/apps/server/src/resources/environment/router.spec.ts b/apps/server/src/resources/environment/router.spec.ts index cf7b70575..648f5029c 100644 --- a/apps/server/src/resources/environment/router.spec.ts +++ b/apps/server/src/resources/environment/router.spec.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { PROJECT_PERMS, environmentContract } from '@cpn-console/shared' import app from '../../app.js' import * as utilsController from '../../utils/controller.js' -import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' import { BadRequest400 } from '../../utils/errors.js' import * as business from './business.js' @@ -63,14 +63,14 @@ describe('environmentRouter tests', () => { authUserMock.mockResolvedValueOnce(user) businessCheckEnvironmentInputMock.mockResolvedValueOnce(null) - businessCreateEnvironmentMock.mockResolvedValueOnce({ id: environmentId, ...environmentData }) + businessCreateEnvironmentMock.mockResolvedValueOnce({ id: environmentId, ...environmentData, ...atDates }) const response = await app.inject() .post(environmentContract.createEnvironment.path) .body(environmentData) .end() - expect(response.json()).toEqual({ id: environmentId, ...environmentData }) + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) expect(response.statusCode).toEqual(201) }) @@ -163,14 +163,14 @@ describe('environmentRouter tests', () => { authUserMock.mockResolvedValueOnce(user) businessCheckEnvironmentInputMock.mockResolvedValueOnce(null) - businessUpdateEnvironmentMock.mockResolvedValueOnce({ id: environmentId, ...environmentData }) + businessUpdateEnvironmentMock.mockResolvedValueOnce({ id: environmentId, ...environmentData, ...atDates }) const response = await app.inject() .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) .body(updateData) .end() - expect(response.json()).toEqual({ id: environmentId, ...environmentData }) + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) expect(response.statusCode).toEqual(200) }) 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/project/business.ts b/apps/server/src/resources/project/business.ts index 8858656b0..b25e05921 100644 --- a/apps/server/src/resources/project/business.ts +++ b/apps/server/src/resources/project/business.ts @@ -11,6 +11,7 @@ import { getAllProjectsDataForExport, getOrganizationById, getProjectByNames, + getProjectOrThrow, initializeProject, listProjects as listProjectsQuery, lockProject, @@ -82,6 +83,14 @@ export async function createProject(dataDto: typeof projectContract.createProjec } } +export async function getProject(projectId: Project['id']) { + return getProjectOrThrow(projectId).then(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString() })), + everyonePerms: project.everyonePerms.toString(), + })) +} export async function updateProject( { description, ownerId, everyonePerms, locked }: typeof projectContract.updateProject.body._type, projectId: Project['id'], diff --git a/apps/server/src/resources/project/queries.ts b/apps/server/src/resources/project/queries.ts index 104a47420..ab431294c 100644 --- a/apps/server/src/resources/project/queries.ts +++ b/apps/server/src/resources/project/queries.ts @@ -71,6 +71,18 @@ export async function listProjects({ }) } +export function getProjectOrThrow(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: { + clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, + }, + }) +} + export function getProjectInfosByIdOrThrow(projectId: Project['id']) { return prisma.project.findUniqueOrThrow({ where: { diff --git a/apps/server/src/resources/project/router.ts b/apps/server/src/resources/project/router.ts index 72641d60f..bf53b606f 100644 --- a/apps/server/src/resources/project/router.ts +++ b/apps/server/src/resources/project/router.ts @@ -4,6 +4,7 @@ import { archiveProject, createProject, generateProjectsData, + getProject, getProjectSecrets, listProjects, replayHooks, @@ -71,6 +72,22 @@ export function projectRouter() { } }, + // Récuperer un seul projet + getProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + const isAdmin = AdminAuthorized.isAdmin(perms.adminPermissions) + + if (!perms.projectPermissions && !isAdmin) return new NotFound404() + + const body = await getProject(projectId) + + return { + status: 200, + body, + } + }, + // Mettre à jour un projet updateProject: async ({ request: req, params, body: data }) => { const projectId = params.projectId diff --git a/apps/server/src/resources/repository/router.spec.ts b/apps/server/src/resources/repository/router.spec.ts index 82dd3c8e2..5672253a2 100644 --- a/apps/server/src/resources/repository/router.spec.ts +++ b/apps/server/src/resources/repository/router.spec.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { PROJECT_PERMS, repositoryContract } from '@cpn-console/shared' import app from '../../app.js' import * as utilsController from '../../utils/controller.js' -import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' import { BadRequest400 } from '../../utils/errors.js' import * as business from './business.js' @@ -142,14 +142,14 @@ describe('repositoryRouter tests', () => { const user = getUserMockInfos(false, undefined, projectPerms) authUserMock.mockResolvedValueOnce(user) - businessCreateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData }) + businessCreateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...atDates }) const response = await app.inject() .post(repositoryContract.createRepository.path) .body(repositoryData) .end() expect(response.statusCode).toEqual(201) - expect(response.json()).toEqual({ id: repositoryId, ...repositoryData }) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData }) }) it('should return 403 if project is locked', async () => { @@ -226,14 +226,14 @@ describe('repositoryRouter tests', () => { const user = getUserMockInfos(false, undefined, projectPerms) authUserMock.mockResolvedValueOnce(user) - businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) const response = await app.inject() .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) .body(repoUpdateData) .end() expect(response.statusCode).toEqual(200) - expect(response.json()).toEqual({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) }) it('should update repository and drop creds if is not private', async () => { @@ -242,15 +242,15 @@ describe('repositoryRouter tests', () => { authUserMock.mockResolvedValueOnce(user) const repoUpdateData = { isPrivate: false, externalUserName: 'test' } - businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) const response = await app.inject() .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) .body(repoUpdateData) .end() expect(businessUpdateMock).toHaveBeenCalledWith({ data: { isPrivate: false }, repositoryId, requestId: expect.any(String), userId: user.user.id }) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) expect(response.statusCode).toEqual(200) - expect(response.json()).toEqual({ id: repositoryId, ...repositoryData, ...repoUpdateData }) }) it('should return 403 if project is locked', async () => { 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/apps/server/src/utils/mocks.ts b/apps/server/src/utils/mocks.ts index 2d11bc984..bb56e266a 100644 --- a/apps/server/src/utils/mocks.ts +++ b/apps/server/src/utils/mocks.ts @@ -221,3 +221,8 @@ export function getProjectMockInfos({ projectId, projectLocked, projectOwnerId, projectPermissions: projectPermissions ?? PROJECT_PERMS.MANAGE, } } + +export const atDates = { + createdAt: new Date(), + updatedAt: new Date(), +} diff --git a/packages/shared/src/contracts/environment.ts b/packages/shared/src/contracts/environment.ts index bd86355e0..12c718b74 100644 --- a/packages/shared/src/contracts/environment.ts +++ b/packages/shared/src/contracts/environment.ts @@ -13,7 +13,7 @@ export const environmentContract = contractInstance.router({ contentType: 'application/json', summary: 'Create environment', description: 'Create new environment.', - body: EnvironmentSchema.omit({ id: true }), + body: EnvironmentSchema.omit({ id: true, createdAt: true, updatedAt: true }), responses: { 201: EnvironmentSchema, 400: ErrorSchema, diff --git a/packages/shared/src/contracts/project.ts b/packages/shared/src/contracts/project.ts index 0946a8c05..73233ce8b 100644 --- a/packages/shared/src/contracts/project.ts +++ b/packages/shared/src/contracts/project.ts @@ -28,6 +28,19 @@ export const projectContract = contractInstance.router({ }, }, + getProject: { + method: 'GET', + path: '/:projectId', + pathParams: ProjectParams, + summary: 'Get a project', + description: 'Get a project', + responses: { + 200: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + listProjects: { method: 'GET', path: '', diff --git a/packages/shared/src/contracts/repository.ts b/packages/shared/src/contracts/repository.ts index 088c05575..25eea4826 100644 --- a/packages/shared/src/contracts/repository.ts +++ b/packages/shared/src/contracts/repository.ts @@ -11,7 +11,7 @@ export const repositoryContract = contractInstance.router({ contentType: 'application/json', summary: 'Create repo', description: 'Create new repo.', - body: RepoSchema.omit({ id: true }), + body: RepoSchema.omit({ id: true, createdAt: true, updatedAt: true }), responses: { 201: RepoSchema, 400: ErrorSchema, @@ -70,7 +70,7 @@ export const repositoryContract = contractInstance.router({ repositoryId: z.string() .uuid(), }), - body: RepoSchema.partial(), + body: RepoSchema.omit({ createdAt: true, updatedAt: true }).partial(), responses: { 200: RepoSchema, 500: ErrorSchema, diff --git a/packages/shared/src/schemas/environment.ts b/packages/shared/src/schemas/environment.ts index 117759fce..4fc1dec88 100644 --- a/packages/shared/src/schemas/environment.ts +++ b/packages/shared/src/schemas/environment.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { longestEnvironmentName } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' export const EnvironmentSchema = z.object({ id: z.string() @@ -14,6 +15,6 @@ export const EnvironmentSchema = z.object({ quotaId: z.string().uuid(), clusterId: z.string() .uuid(), -}) +}).extend(AtDatesToStringExtend) export type Environment = Zod.infer diff --git a/packages/shared/src/schemas/repository.ts b/packages/shared/src/schemas/repository.ts index 3803e349e..84ec69577 100644 --- a/packages/shared/src/schemas/repository.ts +++ b/packages/shared/src/schemas/repository.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { invalidGitUrl, invalidInternalRepoName, missingCredentials } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' export const RepoSchema = z.object({ id: z.string() @@ -21,10 +22,11 @@ export const RepoSchema = z.object({ .optional(), projectId: z.string() .uuid(), -}) +}).extend(AtDatesToStringExtend) // To only use in frontend form export const RepoFormSchema = RepoSchema + .omit({ createdAt: true, updatedAt: true }) .extend({ isStandalone: z.boolean() }) export const UpdateRepoFormSchema = RepoFormSchema diff --git a/packages/shared/src/utils/schemas.spec.ts b/packages/shared/src/utils/schemas.spec.ts index 20d8abb7c..900db3595 100644 --- a/packages/shared/src/utils/schemas.spec.ts +++ b/packages/shared/src/utils/schemas.spec.ts @@ -22,7 +22,10 @@ describe('schemas utils', () => { externalUserName: 'clai+re-nlet_', } - expect(RepoSchema.safeParse(toParse)).toStrictEqual({ data: toParse, success: true }) + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) }) it('should validate a correct environment schema', () => { @@ -35,7 +38,10 @@ describe('schemas utils', () => { stageId: faker.string.uuid(), } - expect(EnvironmentSchema.safeParse(toParse)).toStrictEqual({ data: toParse, success: true }) + expect(EnvironmentSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) }) it('should validate a correct organization schema', () => { @@ -167,6 +173,7 @@ describe('schemas utils', () => { } expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) .safeParse(toParse)) .toStrictEqual({ data: toParse, success: true }) }) @@ -243,12 +250,14 @@ describe('schemas utils', () => { // @ts-ignore expect(parseZodError(RepoSchema + .omit({ createdAt: true, updatedAt: true }) .safeParse(toParse) .error)) .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') toParse.internalRepoName = 'candi-lib' expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) .safeParse(toParse)) .toStrictEqual({ data: toParse, success: true }) }) @@ -324,6 +333,7 @@ describe('schemas utils', () => { const toParse = { internalRepoName: 'candi lib' } expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) .pick({ internalRepoName: true }) .safeParse(toParse) // @ts-ignore @@ -331,7 +341,7 @@ describe('schemas utils', () => { }) it('should return truthy schema', () => { - expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toStrictEqual({ + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ internalRepoName: true, externalRepoUrl: true, externalToken: true, @@ -343,7 +353,7 @@ describe('schemas utils', () => { }) it('should return true schema', () => { - expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toStrictEqual({ + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ internalRepoName: true, externalRepoUrl: true, externalToken: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e9dadcba..989d95ada 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.4.5 + javascript-time-ago: + specifier: ^2.5.11 + version: 2.5.11 js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -88,6 +91,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 @@ -4563,6 +4569,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + javascript-time-ago@2.5.11: + resolution: {integrity: sha512-Zeyf5R7oM1fSMW9zsU3YgAYwE0bimEeF54Udn2ixGd8PUwu+z1Yc5t4Y8YScJDMHD6uCx6giLt3VJR5K4CMwbg==} + jiti@1.21.6: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true @@ -5618,6 +5627,9 @@ packages: resolution: {integrity: sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==} hasBin: true + relative-time-format@1.1.6: + resolution: {integrity: sha512-aCv3juQw4hT1/P/OrVltKWLlp15eW1GRcwP1XdxHrPdZE9MtgqFpegjnTjLhi2m2WI9MT/hQQtE+tjEWG1hgkQ==} + request-compose@2.1.6: resolution: {integrity: sha512-S07L+2VbJB32WddD/o/PnYGKym63zLVbymygVWXvt8L79VAngcjAxhHaGuFOICLxEV90EasEPzqPKKHPspXP8w==} engines: {node: '>=12.0.0'} @@ -6810,6 +6822,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==} @@ -8819,7 +8835,7 @@ snapshots: '@typescript-eslint/types': 7.16.0 '@typescript-eslint/typescript-estree': 7.16.0(typescript@5.5.3) '@typescript-eslint/visitor-keys': 7.16.0 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) eslint: 9.7.0 optionalDependencies: typescript: 5.5.3 @@ -8832,7 +8848,7 @@ snapshots: '@typescript-eslint/types': 8.3.0 '@typescript-eslint/typescript-estree': 8.3.0(typescript@5.5.3) '@typescript-eslint/visitor-keys': 8.3.0 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) eslint: 9.7.0 optionalDependencies: typescript: 5.5.3 @@ -8853,7 +8869,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.16.0(typescript@5.5.3) '@typescript-eslint/utils': 7.16.0(eslint@9.7.0)(typescript@5.5.3) - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) eslint: 9.7.0 ts-api-utils: 1.3.0(typescript@5.5.3) optionalDependencies: @@ -8865,7 +8881,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.3.0(typescript@5.5.3) '@typescript-eslint/utils': 8.3.0(eslint@9.7.0)(typescript@5.5.3) - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) ts-api-utils: 1.3.0(typescript@5.5.3) optionalDependencies: typescript: 5.5.3 @@ -8883,7 +8899,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.16.0 '@typescript-eslint/visitor-keys': 7.16.0 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -8898,7 +8914,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.3.0 '@typescript-eslint/visitor-keys': 8.3.0 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 @@ -9110,7 +9126,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -9977,7 +9993,7 @@ snapshots: cypress-vite@1.5.0(vite@5.3.3(@types/node@20.14.10)): dependencies: chokidar: 3.6.0 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) vite: 5.3.3(@types/node@20.14.10) transitivePeerDependencies: - supports-color @@ -10092,7 +10108,6 @@ snapshots: ms: 2.1.2 optionalDependencies: supports-color: 8.1.1 - optional: true debug@4.3.6: dependencies: @@ -10469,7 +10484,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.3.0(typescript@5.5.3) '@typescript-eslint/utils': 8.3.0(eslint@9.7.0)(typescript@5.5.3) - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) doctrine: 3.0.0 eslint: 9.7.0 eslint-import-resolver-node: 0.3.9 @@ -10558,7 +10573,7 @@ snapshots: eslint-plugin-toml@0.11.1(eslint@9.7.0): dependencies: - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) eslint: 9.7.0 eslint-compat-utils: 0.5.1(eslint@9.7.0) lodash: 4.17.21 @@ -10608,7 +10623,7 @@ snapshots: eslint-plugin-yml@1.14.0(eslint@9.7.0): dependencies: - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) eslint: 9.7.0 eslint-compat-utils: 0.5.1(eslint@9.7.0) lodash: 4.17.21 @@ -11528,7 +11543,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.25 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -11544,6 +11559,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + javascript-time-ago@2.5.11: + dependencies: + relative-time-format: 1.1.6 + jiti@1.21.6: {} jiti@2.0.0-beta.3: {} @@ -11620,7 +11639,7 @@ snapshots: json-schema-resolver@2.0.0: dependencies: - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) rfdc: 1.4.1 uri-js: 4.4.1 transitivePeerDependencies: @@ -11754,7 +11773,7 @@ snapshots: dependencies: chalk: 5.3.0 commander: 12.1.0 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) execa: 8.0.1 lilconfig: 3.1.2 listr2: 8.2.3 @@ -11939,7 +11958,7 @@ snapshots: micromark@2.11.4: dependencies: - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) parse-entities: 2.0.0 transitivePeerDependencies: - supports-color @@ -12644,6 +12663,8 @@ snapshots: dependencies: jsesc: 0.5.0 + relative-time-format@1.1.6: {} + request-compose@2.1.6: {} request-oauth@1.0.1: @@ -13230,7 +13251,7 @@ snapshots: cosmiconfig: 9.0.0(typescript@5.5.3) css-functions-list: 3.2.2 css-tree: 2.3.1 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) fast-glob: 3.3.2 fastest-levenshtein: 1.0.16 file-entry-cache: 9.0.0 @@ -13662,7 +13683,7 @@ snapshots: '@antfu/utils': 0.7.10 '@rollup/pluginutils': 5.1.0(rollup@4.18.1) chokidar: 3.6.0 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) fast-glob: 3.3.2 local-pkg: 0.5.0 magic-string: 0.30.10 @@ -13733,7 +13754,7 @@ snapshots: vite-node@1.6.0(@types/node@20.14.10): dependencies: cac: 6.7.14 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) pathe: 1.1.2 picocolors: 1.0.1 vite: 5.3.3(@types/node@20.14.10) @@ -13793,7 +13814,7 @@ snapshots: '@vitest/utils': 1.6.0 acorn-walk: 8.3.3 chai: 4.4.1 - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) execa: 8.0.1 local-pkg: 0.5.0 magic-string: 0.30.10 @@ -13860,7 +13881,7 @@ snapshots: vue-eslint-parser@9.4.3(eslint@9.7.0): dependencies: - debug: 4.3.5(supports-color@5.5.0) + debug: 4.3.5(supports-color@8.1.1) eslint: 9.7.0 eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 @@ -14000,6 +14021,8 @@ snapshots: ws@8.18.0: {} + xbytes@1.9.1: {} + xcase@2.0.1: {} xml-name-validator@4.0.0: {}