diff --git a/Dockerfile b/Dockerfile index 3bd94a69..3a8b5af3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ COPY --from=package-strip /app/package-lock.json package-lock.json ADD ui/package.json ui/package.json ADD api/package.json api/package.json ADD portal/package.json portal/package.json +ADD types-portals/package.json types-portals/package.json ADD patches patches # full deps install used for types and ui building # also used to fill the npm cache for faster install of api deps @@ -45,6 +46,7 @@ FROM installer AS types ADD api/config api/config ADD api/types api/types ADD api/doc api/doc +ADD types-portals types-portals RUN npm run build-types # ============================= @@ -124,6 +126,7 @@ ADD /api api COPY --from=types /app/api/types api/types COPY --from=types /app/api/doc api/doc COPY --from=types /app/api/config api/config +COPY --from=types /app/types-portals types-portals COPY --from=api-installer /app/shared/node_modules shared/node_modules COPY --from=api-installer /app/api/node_modules api/node_modules COPY --from=ui /app/ui/dist ui/dist diff --git a/api/config/default.js b/api/config/default.js index a8166959..7de6a577 100644 --- a/api/config/default.js +++ b/api/config/default.js @@ -20,7 +20,7 @@ export default { ingressControllers: [], upgradeRoot: '/app/', elasticsearch: { - nodes: ['localhost:9200'], + nodes: ['http://localhost:9200'], // auth: {}, // ca: '', // the central authority for the ES cluster certificates }, diff --git a/api/doc/pages/patch-req-body/index.d.ts b/api/doc/pages/get-page-res/index.ts similarity index 100% rename from api/doc/pages/patch-req-body/index.d.ts rename to api/doc/pages/get-page-res/index.ts diff --git a/api/doc/pages/get-page-res/schema.js b/api/doc/pages/get-page-res/schema.js new file mode 100644 index 00000000..704cd6b7 --- /dev/null +++ b/api/doc/pages/get-page-res/schema.js @@ -0,0 +1,13 @@ +export default { + $id: 'https://github.com/data-fair/portals/pages/get-page-res', + 'x-exports': ['types'], + title: 'PageEnriched', + type: 'object', + allOf: [{ $ref: 'https://github.com/data-fair/portals/page' }], + properties: { + userPermissions: { + type: 'array', + items: { type: 'string', enum: ['read', 'write'] } + } + } +} diff --git a/api/doc/pages/get-res/schema.ts b/api/doc/pages/get-res/schema.ts index 9bbe8f59..dd949a21 100644 --- a/api/doc/pages/get-res/schema.ts +++ b/api/doc/pages/get-res/schema.ts @@ -19,7 +19,7 @@ export default { }, results: { type: 'array', - items: { $ref: 'https://github.com/data-fair/portals/page' } + items: { $ref: 'https://github.com/data-fair/portals/pages/get-page-res' } } }, $defs: { diff --git a/api/doc/pages/patch-permissions-req/index.ts b/api/doc/pages/patch-permissions-req/index.ts new file mode 100644 index 00000000..f5d615bd --- /dev/null +++ b/api/doc/pages/patch-permissions-req/index.ts @@ -0,0 +1 @@ +export * from './.type/index.js' diff --git a/api/doc/pages/patch-permissions-req/schema.ts b/api/doc/pages/patch-permissions-req/schema.ts new file mode 100644 index 00000000..e32f2fa3 --- /dev/null +++ b/api/doc/pages/patch-permissions-req/schema.ts @@ -0,0 +1,14 @@ +import jsonSchema from '@data-fair/lib-utils/json-schema.js' +import pageSchema from '#types/page/schema.js' + +export default { + ...jsonSchema(pageSchema) + .pickProperties(['public', 'permissions']) + .removeRequired() + .schema, + $id: 'https://github.com/data-fair/portals/pages/patch-permissions-req', + title: 'Patch page permissions req body', + 'x-exports': ['validate', 'types', 'vjsf'], + 'x-vjsf': { compName: 'page-permissions' }, + layout: { title: null } +} diff --git a/api/doc/pages/patch-req-body/schema.js b/api/doc/pages/patch-req-body/schema.ts similarity index 86% rename from api/doc/pages/patch-req-body/schema.js rename to api/doc/pages/patch-req-body/schema.ts index 6af79a19..e9441663 100644 --- a/api/doc/pages/patch-req-body/schema.js +++ b/api/doc/pages/patch-req-body/schema.ts @@ -3,7 +3,7 @@ import pageSchema from '#types/page/schema.js' export default { ...jsonSchema(pageSchema) - .pickProperties(['draftConfig', 'portals', 'requestedPortals', 'owner', 'isReference', 'title']) + .pickProperties(['draftConfig', 'portals', 'requestedPortals', 'owner', 'public', 'permissions', 'isReference', 'title']) .removeRequired() .schema, $id: 'https://github.com/data-fair/portals/pages/patch-req-body', diff --git a/api/doc/pages/post-req-body/schema.js b/api/doc/pages/post-req-body/schema.ts similarity index 100% rename from api/doc/pages/post-req-body/schema.js rename to api/doc/pages/post-req-body/schema.ts diff --git a/api/package.json b/api/package.json index 2ff111a1..6c191a23 100644 --- a/api/package.json +++ b/api/package.json @@ -37,6 +37,7 @@ "resolve-path": "^1.4.0", "sharp": "^0.34.3", "slugify": "^1.6.6", - "tinycolor2": "^1.6.0" + "tinycolor2": "^1.6.0", + "tough-cookie": "^5.1.2" } } diff --git a/api/src/pages/operations.ts b/api/src/pages/operations.ts new file mode 100644 index 00000000..d11aea90 --- /dev/null +++ b/api/src/pages/operations.ts @@ -0,0 +1,104 @@ +import type { Page, PageElement } from '#types/page/index.ts' +import { getAccountRole, type SessionStateAuthenticated } from '@data-fair/lib-express' +import { matchAccessRef, mongoFilterAccessRef } from '@data-fair/lib-common-types/access-ref/index.js' + +export const traversePageElements = async ( + pageElements: PageElement[] | undefined, + callback: (pageElement: PageElement) => Promise | void +) => { + if (!pageElements) return + for (const element of pageElements) { + await callback(element) + if (element.type === 'card') await traversePageElements(element.children, callback) + if (element.type === 'banner') await traversePageElements(element.children, callback) + if (element.type === 'responsive-grid') await traversePageElements(element.children, callback) + if (element.type === 'datasets-catalog') await traversePageElements(element.advancedFilters, callback) + if (element.type === 'applications-catalog') await traversePageElements(element.advancedFilters, callback) + if (element.type === 'reuses-catalog') await traversePageElements(element.advancedFilters, callback) + + if (element.type === 'two-columns') { + await traversePageElements(element.children, callback) + await traversePageElements(element.children2, callback) + } + + if (element.type === 'tabs') { + for (const tab of element.tabs) { + await traversePageElements(tab.children, callback) + } + } + + if (element.type === 'expansion-panels') { + for (const tab of element.panels) { + await traversePageElements(tab.children, callback) + } + } + } +} + +export const canReadPage = (session: SessionStateAuthenticated, page: Page): boolean => { + const accountRole = getAccountRole(session, page.owner) + if (accountRole === 'admin') return true + if (page.public) return true + const permissions = page.permissions ?? [] + return permissions.some(perm => + perm.operation.includes('read') && matchAccessRef(session, perm.access) + ) +} + +export const canWritePage = (session: SessionStateAuthenticated, page: Page): boolean => { + const accountRole = getAccountRole(session, page.owner) + if (accountRole === 'admin') return true + const permissions = page.permissions ?? [] + return permissions.some(perm => + perm.operation.includes('write') && matchAccessRef(session, perm.access) + ) +} + +export const getUserPermissions = (session: SessionStateAuthenticated, page: Page): ('read' | 'write')[] => { + const accountRole = getAccountRole(session, page.owner) + if (accountRole === 'admin') return ['read', 'write'] + const ops = new Set<'read' | 'write'>() + if (page.public) ops.add('read') + for (const perm of page.permissions ?? []) { + if (matchAccessRef(session, perm.access)) { + for (const op of perm.operation) ops.add(op as 'read' | 'write') + } + } + return [...ops] +} + +export const buildPageAccessFilter = (session: SessionStateAuthenticated): Record => { + return { + $or: [ + { public: true }, + { + permissions: { + $elemMatch: { + ...mongoFilterAccessRef(session), + operation: { $in: ['read'] } + } + } + } + ] + } +} + +export const buildDefaultPermissions = ( + session: SessionStateAuthenticated, + owner: SessionStateAuthenticated['account'] +): Page['permissions'] => { + const ownerRole = getAccountRole(session, owner) + if (ownerRole === 'contrib' && owner.type === 'organization') { + return [{ + access: { + mode: 'internal', + type: 'organization', + id: owner.id, + department: owner.department || '*', + roles: ['contrib'] + }, + operation: ['read', 'write'] + }] + } + return [] +} diff --git a/api/src/pages/router.ts b/api/src/pages/router.ts index 768027b6..bac373dc 100644 --- a/api/src/pages/router.ts +++ b/api/src/pages/router.ts @@ -1,12 +1,14 @@ import type { Page } from '#types/page/index.ts' +import type { Portal } from '#types/portal/index.ts' import { randomUUID } from 'node:crypto' import { Router } from 'express' import mongo from '#mongo' import findUtils from '../utils/find.ts' import * as postReqBody from '#doc/pages/post-req-body/index.ts' import * as patchReqBody from '#doc/pages/patch-req-body/index.ts' -import { httpError, reqSessionAuthenticated, assertAccountRole, assertAdminMode } from '@data-fair/lib-express/index.js' -import { createPage, validatePageDraft, cancelPageDraft, getPageAsContrib, patchPage, deletePage, generateUniqueSlug, duplicatePageElements, sendPageEvent } from './service.ts' +import { httpError, reqSessionAuthenticated, assertAccountRole, assertAdminMode, getAccountRole } from '@data-fair/lib-express/index.js' +import { createPage, validatePageDraft, cancelPageDraft, getPage, assertPageWrite, patchPage, deletePage, generateUniqueSlug, duplicatePageElements, sendPageEvent } from './service.ts' +import { buildDefaultPermissions, buildPageAccessFilter, getUserPermissions } from './operations.ts' import { reindexPage } from '../search-pages/service.ts' import { pageFacets } from './aggregations.ts' @@ -15,7 +17,6 @@ export default router router.get('', async (req, res, next) => { const session = reqSessionAuthenticated(req) - assertAccountRole(session, session.account, 'admin') const params = req.query as Record const sort = findUtils.sort(params.sort || 'createdAt:-1') @@ -27,19 +28,36 @@ router.get('', async (req, res, next) => { if (params.groupId === 'default') filters['config.genericMetadata.group._id'] = { $exists: false } // If isReference=true, we get all references pages, without owner filter - const query = params.isReference === 'true' ? { isReference: true } : findUtils.filterPermissions(params, session) + let query: Record + if (params.isReference === 'true') { + query = { isReference: true } + } else { + query = findUtils.filterPermissions(params, session) + // Non-admins: further filter by public or explicit read permission + const accountRole = getAccountRole(session, session.account) + if (accountRole !== 'admin') { + Object.assign(query, buildPageAccessFilter(session)) + } + } + const queryWithFilters = Object.assign(filters, query) // Filter by owner (if showAll) const showAll = params.showAll === 'true' if (showAll && params.owners) { - queryWithFilters.$or = params.owners.split(',').map(owner => { + const ownersOr = params.owners.split(',').map(owner => { const [type, id, department] = owner.split(':') if (!type || !id) throw httpError(400, 'Invalid owner format') const filter: any = { 'owner.type': type, 'owner.id': id } if (department) filter['owner.department'] = department return filter }) + if (queryWithFilters.$or) { + queryWithFilters.$and = [{ $or: queryWithFilters.$or }, { $or: ownersOr }] + delete queryWithFilters.$or + } else { + queryWithFilters.$or = ownersOr + } } const [count, results, facets] = await Promise.all([ @@ -48,7 +66,14 @@ router.get('', async (req, res, next) => { mongo.pages.aggregate(pageFacets(query, showAll)).toArray() ]) - res.json({ results, count, facets: facets[0] }) + const enrichedResults = results.map(page => { + const userPermissions = getUserPermissions(session, page as Page) + const accountRole = getAccountRole(session, (page as Page).owner) + if (accountRole !== 'admin') delete (page as any).permissions + return { ...page, userPermissions } + }) + + res.json({ results: enrichedResults, count, facets: facets[0] }) }) router.post('', async (req, res, next) => { @@ -80,6 +105,9 @@ router.post('', async (req, res, next) => { config.elements = await duplicatePageElements(session, body.sourcePageId, pageId, owner) } + // Allow admins and contributors to create pages + assertAccountRole(session, owner, ['admin', 'contrib']) + const page: Page = { _id: pageId, title: body.title || body.config.title, @@ -90,9 +118,10 @@ router.post('', async (req, res, next) => { config, draftConfig: config, portals: body.portals || [], - requestedPortals: [] + requestedPortals: [], + public: false, + permissions: buildDefaultPermissions(session, owner) } - assertAccountRole(session, page.owner, 'admin') const creationDetails = await createPage(page, body.sourcePageId) sendPageEvent(page, 'a été créée', 'create', session, creationDetails) @@ -105,15 +134,51 @@ router.post('', async (req, res, next) => { }) router.get('/:id', async (req, res, next) => { - res.send(await getPageAsContrib(reqSessionAuthenticated(req), req.params.id)) + res.send(await getPage(reqSessionAuthenticated(req), req.params.id)) }) router.patch('/:id', async (req, res, next) => { const session = reqSessionAuthenticated(req) - const page = await getPageAsContrib(session, req.params.id) + // Fetch full page (with permissions) for access checks + const page = await mongo.pages.findOne({ _id: req.params.id }) + if (!page) throw httpError(404, `page "${req.params.id}" not found`) + assertPageWrite(session, page) + const body = patchReqBody.returnValid(req.body, { name: 'body' }) if (body.isReference !== undefined) assertAdminMode(session) - if (body.portals) assertAccountRole(session, page.owner, 'admin') + + const ownerRole = getAccountRole(session, page.owner) + + // Restrict admin-only fields: owner, permissions, public + if (body.owner !== undefined || body.permissions !== undefined || body.public !== undefined) { + if (ownerRole !== 'admin') { + throw httpError(403, 'only page owner admins can modify owner, permissions and public fields') + } + } + + // Handle portals/requestedPortals changes + if (body.portals !== undefined || body.requestedPortals !== undefined) { + if (ownerRole !== 'admin') { + // Only contributors of the page owner can manage portals (non-admins) + if (ownerRole !== 'contrib') { + throw httpError(403, 'only admins and contributors of the page owner can manage portal publication') + } + // Contributors: direct publish (portals) only allowed for staging portals + if (body.portals !== undefined) { + const addedPortalIds = (body.portals as string[]).filter(id => !page.portals.includes(id)) + const removedPortalIds = page.portals.filter(id => !(body.portals as string[]).includes(id)) + const changedPortalIds = [...new Set([...addedPortalIds, ...removedPortalIds])] + if (changedPortalIds.length > 0) { + const changedPortals = await mongo.portals.find({ _id: { $in: changedPortalIds } }).project({ staging: 1 }).toArray() as Pick[] + const hasNonStaging = changedPortals.some(p => !p.staging) + if (hasNonStaging) { + throw httpError(403, 'contributors can only directly publish to staging portals, use requestedPortals for non-staging') + } + } + } + } + } + const updatedPage = await patchPage(page, body, session) for (const portalId of updatedPage.portals) { @@ -127,22 +192,26 @@ router.delete('/:id', async (req, res, next) => { const session = reqSessionAuthenticated(req) const page = await mongo.pages.findOne({ _id: req.params.id }) if (!page) throw httpError(404, `page "${req.params.id}" not found`) - assertAccountRole(session, page.owner, 'admin') + assertPageWrite(session, page) await deletePage(page) sendPageEvent(page, 'a été supprimée', 'delete', session) - res.status(201).send() + res.status(204).send() }) router.post('/:id/draft', async (req, res, next) => { const session = reqSessionAuthenticated(req) - const page = await getPageAsContrib(session, req.params.id) + const page = await mongo.pages.findOne({ _id: req.params.id }) + if (!page) throw httpError(404, `page "${req.params.id}" not found`) + assertPageWrite(session, page) await validatePageDraft(page, session) - res.status(201).send() + res.status(204).send() }) router.delete('/:id/draft', async (req, res, next) => { const session = reqSessionAuthenticated(req) - const page = await getPageAsContrib(session, req.params.id) + const page = await mongo.pages.findOne({ _id: req.params.id }) + if (!page) throw httpError(404, `page "${req.params.id}" not found`) + assertPageWrite(session, page) await cancelPageDraft(page, session) - res.status(201).send() + res.status(204).send() }) diff --git a/api/src/pages/service.ts b/api/src/pages/service.ts index 9f048f9a..5929f8c9 100644 --- a/api/src/pages/service.ts +++ b/api/src/pages/service.ts @@ -2,21 +2,31 @@ import type { ImageRef } from '#types/image-ref/index.ts' import type { Page, PageElement, PageConfig } from '#types/page/index.ts' import debugModule from 'debug' import slug from 'slugify' -import { assertAccountRole, httpError, type SessionStateAuthenticated } from '@data-fair/lib-express' +import { assertAccountRole, getAccountRole, httpError, type SessionStateAuthenticated } from '@data-fair/lib-express' import eventsQueue from '@data-fair/lib-node/events-queue.js' import { renderMarkdown } from '@data-fair/portals-shared-markdown' import mongo from '#mongo' import config from '#config' import { duplicateImage } from '../images/service.ts' +import { canReadPage, canWritePage, traversePageElements, getUserPermissions } from './operations.ts' import { deleteSearchPage } from '../search-pages/service.ts' const debug = debugModule('pages') -export const getPageAsContrib = async (sessionState: SessionStateAuthenticated, id: string) => { +export const getPage = async (sessionState: SessionStateAuthenticated, id: string) => { const page = await mongo.pages.findOne({ _id: id }) if (!page) throw httpError(404, `page "${id}" not found`) - assertAccountRole(sessionState, page.owner, ['admin', 'contrib']) - return page + if (!canReadPage(sessionState, page)) { + throw httpError(403, `you don't have read access to page "${id}"`) + } + const accountRole = getAccountRole(sessionState, page.owner) + const userPermissions = getUserPermissions(sessionState, page) + if (accountRole !== 'admin') delete page.permissions + return { ...page, userPermissions } +} + +export const assertPageWrite = (session: SessionStateAuthenticated, page: Page) => { + if (!canWritePage(session, page)) throw httpError(403, `you don't have write access to page "${page._id}"`) } /** @@ -340,36 +350,6 @@ const getElementsImageRefs = async (pageElements: PageElement[]) => { return imageRefs } -const traversePageElements = async (pageElements: PageElement[] | undefined, callback: (pageElement: PageElement) => Promise | void) => { - if (!pageElements) return - for (const element of pageElements) { - await callback(element) - if (element.type === 'card') await traversePageElements(element.children, callback) - if (element.type === 'banner') await traversePageElements(element.children, callback) - if (element.type === 'responsive-grid') await traversePageElements(element.children, callback) - if (element.type === 'datasets-catalog') await traversePageElements(element.advancedFilters, callback) - if (element.type === 'applications-catalog') await traversePageElements(element.advancedFilters, callback) - if (element.type === 'reuses-catalog') await traversePageElements(element.advancedFilters, callback) - - if (element.type === 'two-columns') { - await traversePageElements(element.children, callback) - await traversePageElements(element.children2, callback) - } - - if (element.type === 'tabs') { - for (const tab of element.tabs) { - await traversePageElements(tab.children, callback) - } - } - - if (element.type === 'expansion-panels') { - for (const tab of element.panels) { - await traversePageElements(tab.children, callback) - } - } - } -} - const validateMetadata = (page: Page, patch?: Partial) => { if (['event', 'news', 'generic'].includes(page.type) && (patch ?? page).draftConfig) { const metadataKey = `${page.type}Metadata` as 'eventMetadata' | 'newsMetadata' | 'genericMetadata' diff --git a/api/src/portals/router.ts b/api/src/portals/router.ts index 2a24d90d..7014d809 100644 --- a/api/src/portals/router.ts +++ b/api/src/portals/router.ts @@ -13,7 +13,7 @@ import { defaultTheme, fillTheme } from '@data-fair/lib-common-types/theme/index import { createPortal, validatePortalDraft, cancelPortalDraft, getPortalAsAdmin, patchPortal, deletePortal, sendPortalEvent, duplicatePortalConfig } from './service.ts' import dfEs from '../es.ts' import type { SearchEngineResult } from '@data-fair/types-portals/index.ts' -import { type SearchRequest } from '@elastic/elasticsearch/lib/api/types' +import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types' const router = Router() export default router @@ -171,21 +171,21 @@ router.delete('/:id', async (req, res, next) => { const portal = await getPortalAsAdmin(session, req.params.id) await deletePortal(portal, reqOrigin(req), req.headers.cookie) sendPortalEvent(portal, 'a été supprimé', 'delete', session) - res.status(201).send() + res.status(204).send() }) router.post('/:id/draft', async (req, res, next) => { const session = reqSessionAuthenticated(req) const portal = await getPortalAsAdmin(session, req.params.id) await validatePortalDraft(portal, session, reqOrigin(req), req.headers.cookie) - res.status(201).send() + res.status(204).send() }) router.delete('/:id/draft', async (req, res, next) => { const session = reqSessionAuthenticated(req) const portal = await getPortalAsAdmin(session, req.params.id) await cancelPortalDraft(portal, session, reqOrigin(req), req.headers.cookie) - res.status(201).send() + res.status(204).send() }) router.post('/:id/ingress', async (req, res, next) => { @@ -194,7 +194,7 @@ router.post('/:id/ingress', async (req, res, next) => { const portal = await getPortalAsAdmin(reqSessionAuthenticated(req), req.params.id) const ingress = postIngressReqBody.returnValid(req.body, { name: 'body' }) await patchPortal(portal, { ingress }, session, reqOrigin(req), ['ingress'], req.headers.cookie) - res.status(201).send() + res.status(204).send() }) router.get('/:id/search', async (req, res, next) => { diff --git a/api/src/reuses/router.ts b/api/src/reuses/router.ts index 459f1e43..b3771ed1 100644 --- a/api/src/reuses/router.ts +++ b/api/src/reuses/router.ts @@ -119,21 +119,21 @@ router.delete('/:id', async (req, res, next) => { const reuse = await getReuseAsAdmin(reqSessionAuthenticated(req), req.params.id) await deleteReuse(reuse) sendReuseEvent(reuse, 'a été supprimée', 'delete', reqSessionAuthenticated(req)) - res.status(201).send() + res.status(204).send() }) router.post('/:id/draft', async (req, res, next) => { const session = reqSessionAuthenticated(req) const reuse = await getReuseAsAdmin(session, req.params.id) await validateReuseDraft(reuse, session) - res.status(201).send() + res.status(204).send() }) router.delete('/:id/draft', async (req, res, next) => { const session = reqSessionAuthenticated(req) const reuse = await getReuseAsAdmin(session, req.params.id) await cancelReuseDraft(reuse, session) - res.status(201).send() + res.status(204).send() }) router.post('/:id/submit', async (req, res, next) => { @@ -143,5 +143,5 @@ router.post('/:id/submit', async (req, res, next) => { if (!portalId) throw httpError(400, 'portalId is required') await submitReuse(reuse, portalId, session) - res.status(201).send() + res.status(204).send() }) diff --git a/api/types/font-asset/schema.js b/api/types/font-asset/schema.js index 42b1286a..a23c74cd 100644 --- a/api/types/font-asset/schema.js +++ b/api/types/font-asset/schema.js @@ -9,7 +9,7 @@ export default { _id: { type: 'string' }, - owner: { $ref: 'https://github.com/data-fair/lib/session-state#/$defs/account' }, + owner: { $ref: 'https://github.com/data-fair/lib/account' }, createdAt: { $ref: 'https://github.com/data-fair/portals/partial#/$defs/createdAt' }, name: { type: 'string', diff --git a/api/types/group/schema.js b/api/types/group/schema.js index 1bc82c4a..0eeda08f 100644 --- a/api/types/group/schema.js +++ b/api/types/group/schema.js @@ -24,7 +24,7 @@ export default { title: 'Description', description: 'Une zone de texte libre pour décrire le groupe. Elle sera affichée sur la vignette du groupe.' }, - owner: { $ref: 'https://github.com/data-fair/lib/session-state#/$defs/account' }, + owner: { $ref: 'https://github.com/data-fair/lib/account' }, createdAt: { $ref: 'https://github.com/data-fair/portals/partial#/$defs/createdAt' }, updatedAt: { $ref: 'https://github.com/data-fair/portals/partial#/$defs/updatedAt' } } diff --git a/api/types/image/schema.js b/api/types/image/schema.js index 2f64b437..c91fc786 100644 --- a/api/types/image/schema.js +++ b/api/types/image/schema.js @@ -11,7 +11,7 @@ export default { title: 'Identifiant', readOnly: true }, - owner: { $ref: 'https://github.com/data-fair/lib/session-state#/$defs/account' }, + owner: { $ref: 'https://github.com/data-fair/lib/account' }, resource: { type: 'object', additionalProperties: false, diff --git a/api/types/page-element-applications/schema.js b/api/types/page-element-applications/schema.js index dec37f12..c982ecc1 100644 --- a/api/types/page-element-applications/schema.js +++ b/api/types/page-element-applications/schema.js @@ -1,5 +1,4 @@ /* eslint-disable no-template-curly-in-string */ - export default { $id: 'https://github.com/data-fair/portals/page-element-applications', 'x-exports': [], diff --git a/api/types/page-element-datasets/schema.js b/api/types/page-element-datasets/schema.js index 09836b8a..8d6e9836 100644 --- a/api/types/page-element-datasets/schema.js +++ b/api/types/page-element-datasets/schema.js @@ -1,5 +1,4 @@ /* eslint-disable no-template-curly-in-string */ - export default { $id: 'https://github.com/data-fair/portals/page-element-datasets', 'x-exports': [], diff --git a/api/types/page-element-events/schema.js b/api/types/page-element-events/schema.js index 3ab7e9a9..46e632c0 100644 --- a/api/types/page-element-events/schema.js +++ b/api/types/page-element-events/schema.js @@ -1,5 +1,4 @@ /* eslint-disable no-template-curly-in-string */ - export default { $id: 'https://github.com/data-fair/portals/page-element-events', 'x-exports': [], diff --git a/api/types/page-element-news/schema.js b/api/types/page-element-news/schema.js index 7cf99a64..31db6f17 100644 --- a/api/types/page-element-news/schema.js +++ b/api/types/page-element-news/schema.js @@ -1,5 +1,4 @@ /* eslint-disable no-template-curly-in-string */ - export default { $id: 'https://github.com/data-fair/portals/page-element-news', 'x-exports': [], diff --git a/api/types/page-element-reuses/schema.js b/api/types/page-element-reuses/schema.js index c6091854..fc2e7062 100644 --- a/api/types/page-element-reuses/schema.js +++ b/api/types/page-element-reuses/schema.js @@ -1,5 +1,4 @@ /* eslint-disable no-template-curly-in-string */ - export default { $id: 'https://github.com/data-fair/portals/page-element-reuses', 'x-exports': [], diff --git a/api/types/page/schema.js b/api/types/page/schema.js index daf08fd6..4394b88c 100644 --- a/api/types/page/schema.js +++ b/api/types/page/schema.js @@ -29,7 +29,7 @@ export default { }, readOnly: true }, - owner: { $ref: 'https://github.com/data-fair/lib/session-state#/$defs/account' }, + owner: { $ref: 'https://github.com/data-fair/lib/account' }, createdAt: { $ref: 'https://github.com/data-fair/portals/partial#/$defs/createdAt' }, updatedAt: { $ref: 'https://github.com/data-fair/portals/partial#/$defs/updatedAt' }, configUpdatedAt: { @@ -47,6 +47,42 @@ export default { requestedPortals: { type: 'array', items: { type: 'string' } + }, + public: { + type: 'boolean', + title: 'Public' + }, + permissions: { + type: 'array', + title: 'Permissions', + layout: { + messages: { + addItem: 'Ajouter une permission', + }, + listEditMode: 'inline' + }, + items: { + type: 'object', + additionalProperties: false, + required: ['access', 'operation'], + properties: { + access: { + $ref: 'https://github.com/data-fair/lib/access-ref', + layout: { title: null } + }, + operation: { + type: 'array', + title: 'Opérations autorisées', + items: { + type: 'string', + oneOf: [ + { const: 'read', title: 'Lecture' }, + { const: 'write', title: 'Écriture' } + ] + } + } + } + } } } } diff --git a/api/types/portal-config/schema.js b/api/types/portal-config/schema.js index 36ab2b71..fac9a9e6 100644 --- a/api/types/portal-config/schema.js +++ b/api/types/portal-config/schema.js @@ -434,9 +434,9 @@ export default { type: 'string', oneOf: [ { const: 'dataset', title: 'Jeux de données' }, - { const: 'application', title: 'Application' }, + { const: 'application', title: 'Visualisations' }, { const: 'page', title: 'Pages' }, - { const: 'reuse', title: 'Ré-utilisations' }, + { const: 'reuse', title: 'Réutilisations' }, ] } } diff --git a/api/types/portal/schema.js b/api/types/portal/schema.js index 91b32099..c75b2b09 100644 --- a/api/types/portal/schema.js +++ b/api/types/portal/schema.js @@ -35,7 +35,7 @@ export default { type: 'boolean', default: false }, - owner: { $ref: 'https://github.com/data-fair/lib/session-state#/$defs/account' }, + owner: { $ref: 'https://github.com/data-fair/lib/account' }, createdAt: { $ref: 'https://github.com/data-fair/portals/partial#/$defs/createdAt' }, updatedAt: { $ref: 'https://github.com/data-fair/portals/partial#/$defs/updatedAt' }, config: { $ref: 'https://github.com/data-fair/portals/portal-config' }, diff --git a/api/types/reuse/schema.js b/api/types/reuse/schema.js index 285ddc31..ebe8bbe7 100644 --- a/api/types/reuse/schema.js +++ b/api/types/reuse/schema.js @@ -16,10 +16,10 @@ export default { title: 'Slug', readOnly: true }, - owner: { $ref: 'https://github.com/data-fair/lib/session-state#/$defs/account' }, + owner: { $ref: 'https://github.com/data-fair/lib/account' }, submitter: { allOf: [ - { $ref: 'https://github.com/data-fair/lib/session-state#/$defs/account' }, + { $ref: 'https://github.com/data-fair/lib/account' }, { type: 'object', additionalProperties: false, diff --git a/dev/PAGES_ROUTING.md b/dev/PAGES_ROUTING.md index 7ad09a4d..b7dde280 100644 --- a/dev/PAGES_ROUTING.md +++ b/dev/PAGES_ROUTING.md @@ -89,25 +89,13 @@ All pages use **the same pattern**: `/portal/api/pages/[type]/[slug]` For unique pages (home, contact, accessibility,...), the `slug` is repeated and identical to the `type`. ```text -/portal/api/pages/[type] → Retrieves a list of pages (event/news only) /portal/api/pages/[type]/[slug] → Retrieves a specific page /portal/api/pages/[type]/[slug]/images/[id] → Retrieves an image from this page /portal/api/images → Retrieves a portal image /portal/api/portal → Retrieves portal information ``` -### 1. Route `/portal/api/pages/[type]` - GET - -This route **lists only** pages of type `event` or `news`. - -**Query Parameters:** - -- `limit` (optional): Number of results (1-100, default: 10) -- `skip` (optional): Number of results to skip for pagination (default: 0) -- `sort` (optional): `"asc"` or `"desc"` (default: desc, by update date) -- `group` (optional): Group slug for filtering - -### 2. Route `/portal/api/pages/[type]/[slug]` - GET +### 1. Route `/portal/api/pages/[type]/[slug]` - GET Retrieves the configuration of a specific page. diff --git a/package-lock.json b/package-lock.json index 11089023..a5be71f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@commitlint/config-conventional": "^19.8.1", + "@data-fair/lib-common-types": "^1.20.1", "@koumoul/vjsf-compiler": "^1.2.3", "@types/config": "^3.3.5", "@types/debug": "^4.1.12", @@ -70,7 +71,8 @@ "resolve-path": "^1.4.0", "sharp": "^0.34.3", "slugify": "^1.6.6", - "tinycolor2": "^1.6.0" + "tinycolor2": "^1.6.0", + "tough-cookie": "^5.1.2" } }, "node_modules/@analytics/cookie-utils": { @@ -224,7 +226,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -490,7 +491,6 @@ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.29.0" }, @@ -1038,7 +1038,6 @@ "resolved": "https://registry.npmjs.org/@data-fair/lib-node/-/lib-node-2.12.0.tgz", "integrity": "sha512-BX7pRj5vkLS2Oykdgd4/ubzlPPkxG5dVIEvYajSrdIPJyfiTJhMOfgQA49ytV1sTa3nxfEkkXdC1ApccKGy4jQ==", "license": "MIT", - "peer": true, "dependencies": { "@data-fair/lib-common-types": "^1.8.4", "@data-fair/lib-utils": "^1.1.0", @@ -1128,7 +1127,6 @@ "resolved": "https://registry.npmjs.org/@data-fair/lib-vue/-/lib-vue-1.27.1.tgz", "integrity": "sha512-vpUu7GXvdy3fNYiX6Y/heka+FBLrltcy/9ksCfVn0yTsOiXrS3tFwaD3RUMQUF+Q72KsGNI47A6TN9fgpOb/5w==", "license": "MIT", - "peer": true, "dependencies": { "@data-fair/lib-common-types": "^1.7.1", "@data-fair/lib-utils": "^1.9.0", @@ -3413,9 +3411,9 @@ "license": "MIT" }, "node_modules/@json-layout/core": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@json-layout/core/-/core-2.5.1.tgz", - "integrity": "sha512-3H/xIdOGMVbVp86QTlNfa0ZBF82p/yBosCUxK75wyoxmNHvIvNMpTzWLOw4TxdH4/8BkU9rAAsuPcWuASHEB/w==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@json-layout/core/-/core-2.5.2.tgz", + "integrity": "sha512-yp3VPlawQUZbaHLQEt+ISNwO7QQTA6L0CGYQRETfpTE+ylvMTlErnGdTKYGa+inrb1U5HL8RpUVQWillx1ablw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3437,7 +3435,6 @@ "resolved": "https://registry.npmjs.org/@json-layout/vocabulary/-/vocabulary-2.12.0.tgz", "integrity": "sha512-cykB9IDukrMGp87JcABmrzJ/56EK5k1OtnbZfDd+derOwDVUr+6sR1WxMZ9tCLBlf7k9AwtkiA1499u9CwEO3g==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.17.1", "ajv-errors": "^3.0.0", @@ -3445,19 +3442,6 @@ "debug": "^4.3.4" } }, - "node_modules/@koumoul/v-iframe": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/@koumoul/v-iframe/-/v-iframe-2.4.5.tgz", - "integrity": "sha512-Y7btXvMP96vs6H5lfAZ6SOElXp+kAHE7G2vu0z0q6WrL33gaIIi0LdhUkA1K2x3XVo7RitCjwtPlaWqqijjTEw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.2" - }, - "peerDependencies": { - "iframe-resizer": "4", - "vuetify": "3" - } - }, "node_modules/@koumoul/vjsf": { "version": "3.26.1", "resolved": "https://registry.npmjs.org/@koumoul/vjsf/-/vjsf-3.26.1.tgz", @@ -3480,7 +3464,6 @@ "integrity": "sha512-fsN0+paFY3v0iZk/O4mPREKWInkAnrLBBfqgcoSxIgworq77t/J8AbCby3lFtwSChrEZ4lUBEgfJ0drxe5wN2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@json-layout/core": "^2.0.0", "@json-layout/vocabulary": "^2.8.0", @@ -4069,8 +4052,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@nuxt/cli/node_modules/giget": { "version": "3.1.2", @@ -4313,7 +4295,6 @@ "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz", "integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==", "license": "MIT", - "peer": true, "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", @@ -4410,7 +4391,6 @@ "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz", "integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==", "license": "MIT", - "peer": true, "dependencies": { "@vue/shared": "^3.5.30", "defu": "^6.1.4", @@ -4702,7 +4682,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -6832,8 +6811,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", @@ -6885,7 +6863,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -7011,7 +6988,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -7672,7 +7648,6 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", @@ -7933,7 +7908,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7985,7 +7959,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -8596,7 +8569,6 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -8848,7 +8820,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9707,7 +9678,6 @@ "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -10887,7 +10857,6 @@ "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -10976,7 +10945,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11970,7 +11938,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -13117,21 +13084,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/iframe-resizer": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/iframe-resizer/-/iframe-resizer-4.4.5.tgz", - "integrity": "sha512-U8bCywf/Gh07O69RXo6dXAzTtODQrxaHGHRI7Nt4ipXsuq6EMxVsOP/jjaP43YtXz/ibESS0uSVDN3sOGCzSmw==", - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.0" - }, - "funding": { - "type": "individual", - "url": "https://iframe-resizer.com//pricing" - } - }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -15314,7 +15266,7 @@ "version": "12.1.1", "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=16.10" @@ -16197,7 +16149,6 @@ "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz", "integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==", "license": "MIT", - "peer": true, "dependencies": { "@dxup/nuxt": "^0.4.0", "@nuxt/cli": "^3.34.0", @@ -16880,7 +16831,6 @@ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.117.0.tgz", "integrity": "sha512-l3cbgK5wUvWDVNWM/JFU77qDdGZK1wudnLsFcrRyNo/bL1CyU8pC25vDhMHikVY29lbK2InTWsX42RxVSutUdQ==", "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "^0.117.0" }, @@ -17193,7 +17143,6 @@ "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", "license": "MIT", - "peer": true, "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", @@ -17302,7 +17251,6 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "license": "MIT", - "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -17372,7 +17320,6 @@ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz", "integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==", "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "^0.95.0" }, @@ -17893,7 +17840,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -18911,7 +18857,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -19227,12 +19172,32 @@ "entities": "^4.4.0" } }, + "node_modules/sass": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", + "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/sass-embedded": { "version": "1.98.0", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.98.0.tgz", "integrity": "sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==", "license": "MIT", - "peer": true, "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", @@ -19572,6 +19537,36 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "optional": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -20610,7 +20605,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -20722,7 +20716,6 @@ "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "devOptional": true, "license": "MIT", "dependencies": { "tldts-core": "^6.1.86" @@ -20735,7 +20728,6 @@ "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "devOptional": true, "license": "MIT" }, "node_modules/tmp": { @@ -20811,9 +20803,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "devOptional": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "tldts": "^6.1.32" }, @@ -20873,7 +20863,6 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -21831,7 +21820,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -22160,7 +22148,6 @@ "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz", "integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==", "license": "MIT", - "peer": true, "dependencies": { "@intlify/core-base": "11.3.0", "@intlify/devtools-types": "11.3.0", @@ -22182,7 +22169,6 @@ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^6.6.4" }, @@ -22226,7 +22212,6 @@ "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.12.3.tgz", "integrity": "sha512-7QzgftMu8OYKRz/jr2yntPEJ7WFmAzMn8jyeUcW7gz539MjQbDF3UrqZXW3aWi458UVJW9WWvzQn9x5B75Aijw==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/johnleider" @@ -22493,7 +22478,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -22576,7 +22560,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -22803,7 +22786,6 @@ "@data-fair/lib-vuetify": "^1.13.3", "@data-fair/portals-shared-markdown": "*", "@intlify/unplugin-vue-i18n": "^11.0.7", - "@koumoul/v-iframe": "^2.4.5", "@koumoul/vjsf": "^3.26.1", "@koumoul/vjsf-markdown": "^0.4.6", "@mdi/js": "^7.4.47", diff --git a/package.json b/package.json index 1943b64f..03b3747c 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "devDependencies": { "@commitlint/config-conventional": "^19.8.1", + "@data-fair/lib-common-types": "^1.20.1", "@koumoul/vjsf-compiler": "^1.2.3", "@types/config": "^3.3.5", "@types/debug": "^4.1.12", diff --git a/portal/app/components/notification-queue.vue b/portal/app/components/notification-queue.vue index 43989e73..136c8b97 100644 --- a/portal/app/components/notification-queue.vue +++ b/portal/app/components/notification-queue.vue @@ -62,7 +62,7 @@ diff --git a/ui/src/components/page-edit/page-edit-publication.vue b/ui/src/components/page-edit/page-edit-publication.vue index 62d30342..f06c6dbb 100644 --- a/ui/src/components/page-edit/page-edit-publication.vue +++ b/ui/src/components/page-edit/page-edit-publication.vue @@ -30,6 +30,13 @@

+ + const portalsFetch = useFetch<{ results: PartialPortal[] }>($apiPath + '/portals', { query: { select: '_id,title,ingress,owner', size: 10000 } }) @@ -146,10 +153,11 @@ const isPublished = (portal: PartialPortal) => { return page.value.portals.includes(portal._id) || (portal.staging && page.value.requestedPortals.includes(portal._id)) } +// Restricted to contrib that have write access (or admin) const canPublish = (portal: PartialPortal) => { - const role = getAccountRole(session.state, portal.owner) - if (role === 'contrib' && portal.staging) return true - return role === 'admin' + if (!page.value) return + if (portal.staging) return canWritePage.value && getAccountRole(session.state, page.value.owner) === 'contrib' + return canAdminPage.value } // Check if unpublishing is allowed (cannot unpublish home page) @@ -159,9 +167,8 @@ const canUnpublish = (portal: PartialPortal) => { return true } -const canRequestPublication = (portal: PartialPortal) => { - const role = getAccountRole(session.state, portal.owner) - return role === 'contrib' || role === 'admin' +const canRequestPublication = (_portal: PartialPortal) => { + return canWritePage.value } const togglePortals = (portal: PartialPortal) => { @@ -194,6 +201,8 @@ const getPortalUrl = (portal: PartialPortal): string => { warning: title: Cannot publish until these warnings are fixed content: The page is empty + private: This page is private and will only be visible to its owner. + protected: This page is protected and will only be visible to authorized users. standardPage: cannotUnpublishHome: You cannot unpublish a home page. You can only replace it with another home page. willReplace: 'Publishing this {pageType} on this portal will replace the page "{pageTitle}" with this one.' @@ -219,6 +228,8 @@ const getPortalUrl = (portal: PartialPortal): string => { warning: title: Publication impossible tant que ces avertissements ne sont pas corrigés content: Le contenu de la page est vide + private: Cette page est privée et ne sera visible que par son propriétaire. + protected: Cette page est protégée et ne sera visible que par les utilisateurs autorisés. standardPage: cannotUnpublishHome: Vous ne pouvez pas dépublier une page d'accueil. Vous pouvez seulement la remplacer par une autre page d'accueil. willReplace: 'Publier cette {pageType} sur ce portail remplacera la page "{pageTitle}" par celle-ci.' diff --git a/ui/src/components/page/page-actions.vue b/ui/src/components/page/page-actions.vue index 8ea16a22..3c4ea19f 100644 --- a/ui/src/components/page/page-actions.vue +++ b/ui/src/components/page/page-actions.vue @@ -1,6 +1,9 @@