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 @@
-
+
-
+
+
-
-
-
-
-
-
- {{ t('events') }}
-
-
+
+
+
+
+
+
+ {{ t('events') }}
+
+
+
@@ -249,7 +257,7 @@ import { computedAsync } from '@vueuse/core'
const { t } = useI18n()
const session = useSessionAuthenticated()
const router = useRouter()
-const { pageFetch, hasDraftDiff, pageId, page, pageUrl } = usePageStore()
+const { pageFetch, hasDraftDiff, pageId, page, pageUrl, canWritePage, canAdminPage } = usePageStore()
const { previewPortalId } = usePreviewPortal()
const showChangeOwnerMenu = ref(false)
const showCancelDraftMenu = ref(false)
diff --git a/ui/src/components/page/page-card.vue b/ui/src/components/page/page-card.vue
index 0ce714a2..7fd6ab2b 100644
--- a/ui/src/components/page/page-card.vue
+++ b/ui/src/components/page/page-card.vue
@@ -54,6 +54,26 @@
+
+
+
+
+
+ {{ t('public') }}
+
+
+
+
+
+ {{ t('protected') }}
+
+
+
+
+
+ {{ t('private') }}
+
+
@@ -68,8 +88,9 @@