diff --git a/api/package.json b/api/package.json
index f57e6c1f..288b0431 100644
--- a/api/package.json
+++ b/api/package.json
@@ -21,7 +21,7 @@
"@types/ws": "^8.5.12"
},
"dependencies": {
- "@data-fair/lib-express": "^1.25.0",
+ "@data-fair/lib-express": "^1.26.0",
"@data-fair/lib-node": "^2.13.3",
"@data-fair/lib-node-registry": "^0.7.1",
"@data-fair/lib-utils": "^1.13.1",
diff --git a/api/src/misc/routers/identities.ts b/api/src/misc/routers/identities.ts
index f6051564..a12bd304 100644
--- a/api/src/misc/routers/identities.ts
+++ b/api/src/misc/routers/identities.ts
@@ -1,9 +1,10 @@
-// Webhooks for Simple Directory
+// Webhooks for Simple Directory: keep the copies of identity data in sync and clean up after a deletion
import Debug from 'debug'
import { createIdentitiesRouter } from '@data-fair/lib-express/identities/index.js'
import config from '#config'
import mongo from '#mongo'
+import { deleteProcessing } from '../../runs/service.ts'
const debug = Debug('webhooks-simple-directory')
@@ -20,46 +21,81 @@ export default createIdentitiesRouter(
// onUpdate
async (identity) => {
debug('Incoming sd webhook for update', identity)
+ const { type, id, name } = identity
// Update owner name
- await updateAllCollections(
- { 'owner.type': identity.type, 'owner.id': identity.id },
- { $set: { 'owner.name': identity.name } }
- )
+ await updateAllCollections({ 'owner.type': type, 'owner.id': id }, { $set: { 'owner.name': name } })
+ await mongo.limits.updateMany({ type, id }, { $set: { name } })
- if (identity.type === 'user') {
+ if (type === 'user') {
// Update created/updated name
await Promise.all([
- updateAllCollections(
- { 'created.id': identity.id },
- { $set: { 'created.name': identity.name } }
- ),
- updateAllCollections(
- { 'updated.id': identity.id },
- { $set: { 'updated.name': identity.name } }
- )
+ updateAllCollections({ 'created.id': id }, { $set: { 'created.name': name } }),
+ updateAllCollections({ 'updated.id': id }, { $set: { 'updated.name': name } })
])
}
+ if (type === 'organization') {
+ // the organization as a partner granted permissions on the processings of others
+ await mongo.processings.updateMany(
+ { permissions: { $elemMatch: { 'target.type': 'partner', 'target.organization.id': id } } },
+ { $set: { 'permissions.$[p].target.organization.name': name } },
+ { arrayFilters: [{ 'p.target.type': 'partner', 'p.target.organization.id': id }] }
+ )
+ // partner permissions are only meaningful inside a partnership: the directory sends the complete
+ // list of partners, what is not in it was withdrawn
+ if (identity.partners) {
+ const partnerIds = identity.partners.map(p => p.id)
+ await mongo.processings.updateMany(
+ { 'owner.type': 'organization', 'owner.id': id, permissions: { $elemMatch: { 'target.type': 'partner', 'target.organization.id': { $nin: partnerIds } } } },
+ { $pull: { permissions: { 'target.type': 'partner', 'target.organization.id': { $nin: partnerIds } } } } as any
+ )
+ }
+ }
+
// If the identity has departments, update the department names in processings and runs
if (identity.departments) {
const departmentUpdates = identity.departments.map(department =>
updateAllCollections(
- { 'owner.type': identity.type, 'owner.id': identity.id, 'owner.department': department.id },
+ { 'owner.type': type, 'owner.id': id, 'owner.department': department.id },
{ $set: { 'owner.departmentName': department.name } }
)
)
await Promise.all(departmentUpdates)
+ // the directory sends the complete list of departments: a department missing from it was
+ // deleted, its resources keep the id (still reachable by the organization admins) but not the name
+ await updateAllCollections(
+ { 'owner.type': type, 'owner.id': id, 'owner.department': { $exists: true, $nin: identity.departments.map(d => d.id) } },
+ { $unset: { 'owner.departmentName': 1 } }
+ )
}
},
// onDelete
async (identity) => {
debug('Incoming sd webhook for delete', identity)
- // Delete all processings and runs for this identity
- const filter = { 'owner.type': identity.type, 'owner.id': identity.id }
- await mongo.processings.deleteMany(filter)
- await mongo.runs.deleteMany(filter)
- // When departments are deleted, do nothing
+ const { type, id } = identity
+
+ // Delete all processings for this identity, with their runs and their directory
+ for await (const processing of mongo.processings.find({ 'owner.type': type, 'owner.id': id })) {
+ await mongo.processings.deleteOne({ _id: processing._id })
+ await deleteProcessing(mongo, processing)
+ }
+ await mongo.runs.deleteMany({ 'owner.type': type, 'owner.id': id })
+ await mongo.limits.deleteMany({ type, id })
+
+ if (type === 'organization') {
+ await mongo.processings.updateMany(
+ { permissions: { $elemMatch: { 'target.type': 'partner', 'target.organization.id': id } } },
+ { $pull: { permissions: { 'target.type': 'partner', 'target.organization.id': id } } } as any
+ )
+ }
+ if (type === 'user') {
+ // what the user authored on the processings of others keeps the trace of the action without the name
+ await Promise.all([
+ updateAllCollections({ 'created.id': id }, { $unset: { 'created.name': 1 } }),
+ updateAllCollections({ 'updated.id': id }, { $unset: { 'updated.name': 1 } })
+ ])
+ }
}
)
diff --git a/api/types/processing/schema.js b/api/types/processing/schema.js
index 1b67c5ae..6aa5360e 100644
--- a/api/types/processing/schema.js
+++ b/api/types/processing/schema.js
@@ -48,9 +48,9 @@ export default {
created: {
type: 'object',
additionalProperties: false,
+ // no name once the user is deleted from the directory
required: [
'id',
- 'name',
'date'
],
readOnly: true,
@@ -128,7 +128,6 @@ export default {
readOnly: true,
required: [
'id',
- 'name',
'date'
],
properties: {
diff --git a/docker-compose.yml b/docker-compose.yml
index ad13ce63..6532823c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -39,6 +39,8 @@ services:
- CONTACT=contact@test.com
- ADMINS=["superadmin@test.com","test_superadmin@test.com"]
- PUBLIC_URL=http://${DEV_HOST}:${NGINX_PORT1}/simple-directory
+ # in dev simple-directory only notifies this service, directly (the shared identities router refuses calls through the proxy)
+ - IDENTITIES_WEBHOOKS=[{"base":"http://localhost:${DEV_API_PORT}/api/identities","key":"secret-identities"}]
- MAILDEV_ACTIVE=true
- MONGO_URL=mongodb://localhost:${MONGO_PORT}/simple-directory
- STORAGE_TYPE=file
diff --git a/package-lock.json b/package-lock.json
index 144f6f79..57a37525 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -45,7 +45,7 @@
},
"api": {
"dependencies": {
- "@data-fair/lib-express": "^1.25.0",
+ "@data-fair/lib-express": "^1.26.0",
"@data-fair/lib-node": "^2.13.3",
"@data-fair/lib-node-registry": "^0.7.1",
"@data-fair/lib-utils": "^1.13.1",
@@ -500,9 +500,9 @@
}
},
"node_modules/@data-fair/lib-express": {
- "version": "1.25.0",
- "resolved": "https://registry.npmjs.org/@data-fair/lib-express/-/lib-express-1.25.0.tgz",
- "integrity": "sha512-Haqd+OUDgFcduVX0K+xs0dP4+FoS1aE2LDTzdoed2wHZO/WwJ60c/e2QtdijzWH04sRarzC9ujKJQxnrBScA6A==",
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/@data-fair/lib-express/-/lib-express-1.26.0.tgz",
+ "integrity": "sha512-sSPl09vhgaMfAIqZXVcbcCWT6T8G07R/5BBWqunVG1M9PeEWA0R2gShikQmNJM4kKE6Jqe2e0qmXaqzqcNPflg==",
"license": "MIT",
"dependencies": {
"@data-fair/lib-common-types": "^1.7.1",
@@ -649,9 +649,9 @@
}
},
"node_modules/@data-fair/lib-vue": {
- "version": "1.30.1",
- "resolved": "https://registry.npmjs.org/@data-fair/lib-vue/-/lib-vue-1.30.1.tgz",
- "integrity": "sha512-ZGb9LRih5idfHDnIcA6J5pAuIBxVUvx+4bJiqidZIXbuAKt/pvtyM0lkgIW/1wfG5sY05AfeJvYr8890PZpjDQ==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/@data-fair/lib-vue/-/lib-vue-1.31.1.tgz",
+ "integrity": "sha512-SD7jnWerCKZZM658nIvqPK6/efdBfVIA0wln7tRmQyFYdF2PZYTTREbauBT7j92fKDs4Nm7oXJJqeA4yMsoTsA==",
"license": "MIT",
"dependencies": {
"@data-fair/lib-common-types": "^1.7.1",
@@ -684,9 +684,9 @@
}
},
"node_modules/@data-fair/lib-vuetify": {
- "version": "2.4.3",
- "resolved": "https://registry.npmjs.org/@data-fair/lib-vuetify/-/lib-vuetify-2.4.3.tgz",
- "integrity": "sha512-GqaiGZ4BxMi1hDo/uzGujYXl23qpp4R5OxMNmI//4380rOD83bvxQUb+12lQENscWMKYwx+3RfYNhDecDJk7Ag==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@data-fair/lib-vuetify/-/lib-vuetify-2.5.1.tgz",
+ "integrity": "sha512-efLqHNIC3B2DsgsRB4TzCuoIaVIC9tcucDEOLDiiWuvx1fHuHi08f0MxM7PjAv1eQmD5a8Dw1g7HJ9u1+1iIlg==",
"license": "MIT",
"dependencies": {
"@data-fair/lib-common-types": "^1.10.4",
@@ -694,7 +694,7 @@
"@vueuse/core": "^14.0.0"
},
"peerDependencies": {
- "@data-fair/lib-vue": "^1.15.0",
+ "@data-fair/lib-vue": "^1.31.1",
"ofetch": "1",
"vue-i18n": "10 || 11",
"vuetify": "4"
@@ -12517,8 +12517,8 @@
"dependencies": {
"@data-fair/frame": "^0.18.4",
"@data-fair/lib-utils": "^1.13.1",
- "@data-fair/lib-vue": "^1.30.1",
- "@data-fair/lib-vuetify": "^2.4.3",
+ "@data-fair/lib-vue": "^1.31.1",
+ "@data-fair/lib-vuetify": "^2.5.1",
"@data-fair/processings-shared": "*",
"@intlify/unplugin-vue-i18n": "^11.0.7",
"@koumoul/vjsf": "^4.2.0",
diff --git a/package.json b/package.json
index afbb9edc..22177b1b 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,10 @@
"worker",
"shared"
],
+ "relativeDependencies": {
+ "@data-fair/lib-vue": "../lib/packages/vue",
+ "@data-fair/lib-vuetify": "../lib/packages/vuetify"
+ },
"bugs": {
"url": "https://github.com/data-fair/processings/issues"
},
diff --git a/tests/features/processings/identities.api.spec.ts b/tests/features/processings/identities.api.spec.ts
new file mode 100644
index 00000000..853299bf
--- /dev/null
+++ b/tests/features/processings/identities.api.spec.ts
@@ -0,0 +1,91 @@
+import { test, expect } from '@playwright/test'
+import fs from 'node:fs'
+import path from 'node:path'
+import { axios, anonymousAx, axiosAuth, apiUrl, clean } from '../../support/axios.ts'
+import { publishFixturePlugin } from '../../support/registry.ts'
+
+// identity webhooks are internal calls: simple-directory reaches the API directly, not through the proxy
+const axIdentities = axios({ baseURL: apiUrl, headers: { 'x-secret-key': 'secret-identities' } })
+const rawProcessing = async (id: string) => (await anonymousAx.get(`${apiUrl}/api/v1/test-env/raw-processing/${id}`, { validateStatus: () => true }))
+const processingsDir = path.resolve(import.meta.dirname, '../../../data/development/processings')
+
+const seedProcessing = async () => {
+ const plugin = await publishFixturePlugin({ name: '@data-fair/processing-hello-world', version: '1.2.2' })
+ const admin = await axiosAuth({ email: 'test_admin1@test.com', org: 'test_org1' })
+ const processing = (await admin.post('/api/v1/processings', { title: 'Identities processing', plugin: plugin.pluginId })).data
+ await admin.patch(`/api/v1/processings/${processing._id}`, {
+ permissions: [
+ { profile: 'read', target: { type: 'partner', organization: { name: 'Test Org 2', id: 'test_org2' }, roles: ['admin'] } },
+ { profile: 'read', target: { type: 'partner', organization: { name: 'Test Org 3', id: 'test_org3' }, roles: ['admin'] } },
+ { profile: 'read', target: { type: 'userEmail', email: 'test_alone@test.com' } }
+ ]
+ })
+ return processing
+}
+
+test.describe('identity webhooks', () => {
+ test.beforeEach(clean)
+ test.afterAll(clean)
+
+ test('should follow renames and the end of a partnership', async () => {
+ const processing = await seedProcessing()
+
+ await axIdentities.post('/api/identities/user/test_admin1', { name: 'Renamed Admin', organizations: [{ id: 'test_org1', role: 'admin' }] })
+ let raw = (await rawProcessing(processing._id)).data
+ expect(raw.created.name).toBe('Renamed Admin')
+ expect(raw.updated.name).toBe('Renamed Admin')
+
+ await axIdentities.post('/api/identities/organization/test_org2', { name: 'Renamed Org 2' })
+ raw = (await rawProcessing(processing._id)).data
+ expect(raw.permissions[0].target.organization.name).toBe('Renamed Org 2')
+
+ // test_org3 is no longer a partner of the owner, the user email permission is not concerned
+ await axIdentities.post('/api/identities/organization/test_org1', { name: 'Test Org 1', partners: [{ id: 'test_org2', name: 'Renamed Org 2' }] })
+ raw = (await rawProcessing(processing._id)).data
+ expect(raw.permissions.map((p: any) => p.target.type + ':' + (p.target.organization?.id ?? p.target.email))).toEqual(['partner:test_org2', 'userEmail:test_alone@test.com'])
+ })
+
+ test('should rename a department and forget the name of a deleted one', async () => {
+ const plugin = await publishFixturePlugin({ name: '@data-fair/processing-hello-world', version: '1.2.2' })
+ const depAdmin = await axiosAuth({ email: 'test_dep_admin@test.com', org: 'test_org1', dep: 'dep1' })
+ const processing = (await depAdmin.post('/api/v1/processings', { title: 'Department processing', plugin: plugin.pluginId })).data
+ expect(processing.owner.department).toBe('dep1')
+
+ await axIdentities.post('/api/identities/organization/test_org1', { name: 'Test Org 1', departments: [{ id: 'dep1', name: 'Renamed Department' }] })
+ let raw = (await rawProcessing(processing._id)).data
+ expect(raw.owner.departmentName).toBe('Renamed Department')
+
+ // dep1 is missing from the complete list of departments: it was deleted, only its id remains
+ await axIdentities.post('/api/identities/organization/test_org1', { name: 'Test Org 1', departments: [{ id: 'dep2', name: 'Department 2' }] })
+ raw = (await rawProcessing(processing._id)).data
+ expect(raw.owner.department).toBe('dep1')
+ expect(raw.owner.departmentName).toBeUndefined()
+ })
+
+ test('should keep only the id of a deleted user and drop a deleted partner', async () => {
+ const processing = await seedProcessing()
+
+ await axIdentities.delete('/api/identities/user/test_admin1')
+ let raw = (await rawProcessing(processing._id)).data
+ expect(raw.created.name).toBeUndefined()
+ expect(raw.created.id).toBe('test_admin1')
+ expect(raw.updated.name).toBeUndefined()
+
+ await axIdentities.delete('/api/identities/organization/test_org2')
+ raw = (await rawProcessing(processing._id)).data
+ expect(raw.permissions.map((p: any) => p.target.organization?.id ?? p.target.email)).toEqual(['test_org3', 'test_alone@test.com'])
+ })
+
+ test('should delete everything a deleted organization owns, its directory included', async () => {
+ const processing = await seedProcessing()
+ const superadmin = await axiosAuth({ email: 'test_superadmin@test.com', adminMode: true })
+ await superadmin.post('/api/v1/limits/organization/test_org1', { lastUpdate: new Date().toISOString(), processings_seconds: { limit: 100 } })
+ fs.mkdirSync(path.join(processingsDir, processing._id), { recursive: true })
+ fs.writeFileSync(path.join(processingsDir, processing._id, 'log.txt'), 'kept between runs')
+
+ await axIdentities.delete('/api/identities/organization/test_org1')
+ expect((await rawProcessing(processing._id)).status).toBe(404)
+ expect(fs.existsSync(path.join(processingsDir, processing._id))).toBe(false)
+ expect((await superadmin.get('/api/v1/limits', { params: { type: 'organization', id: 'test_org1' } })).data.results).toHaveLength(0)
+ })
+})
diff --git a/ui/package.json b/ui/package.json
index ebfcf4a9..115a0a1f 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -18,8 +18,8 @@
"dependencies": {
"@data-fair/frame": "^0.18.4",
"@data-fair/lib-utils": "^1.13.1",
- "@data-fair/lib-vue": "^1.30.1",
- "@data-fair/lib-vuetify": "^2.4.3",
+ "@data-fair/lib-vue": "^1.31.1",
+ "@data-fair/lib-vuetify": "^2.5.1",
"@data-fair/processings-shared": "*",
"@intlify/unplugin-vue-i18n": "^11.0.7",
"@koumoul/vjsf": "^4.2.0",
diff --git a/ui/src/components/processing/processing-activity.vue b/ui/src/components/processing/processing-activity.vue
index 991e897d..ca40448e 100644
--- a/ui/src/components/processing/processing-activity.vue
+++ b/ui/src/components/processing/processing-activity.vue
@@ -7,13 +7,13 @@
import type { Processing } from '#api/types'
+const { t } = useI18n()
+const { departmentLabel } = useDisplayOwner()
const { dayjs } = useLocaleDayjs()
const { processing, pluginTitle } = defineProps<{
@@ -35,7 +37,7 @@ const { processing, pluginTitle } = defineProps<{
const ownerName = computed(() => {
if (!processing.owner) return ''
const baseName = processing.owner.name || processing.owner.id
- const departmentInfo = processing.owner.departmentName || processing.owner.department
+ const departmentInfo = departmentLabel(processing.owner.department, processing.owner.departmentName)
return departmentInfo
? `${baseName} - ${departmentInfo}`
: baseName
@@ -47,5 +49,12 @@ const avatarUrl = computed(() => {
+
+ en:
+ formerUser: Former user
+ fr:
+ formerUser: Ancien utilisateur
+
+
diff --git a/ui/src/components/processings-actions.vue b/ui/src/components/processings-actions.vue
index d9212cd5..daa157c0 100644
--- a/ui/src/components/processings-actions.vue
+++ b/ui/src/components/processings-actions.vue
@@ -103,6 +103,7 @@ import SearchField from '@data-fair/lib-vuetify/search-field.vue'
import '@data-fair/frame/lib/d-frame.js'
const { t } = useI18n()
+const { departmentLabel } = useDisplayOwner()
const router = useRouter()
const session = useSessionAuthenticated()
const processingsProps = defineProps<{
@@ -206,7 +207,7 @@ const ownersItems = computed(() => {
owner.departments.forEach(department => {
// Ajout d'un élément pour chaque département
items.push({
- display: `${owner.name} - ${department.departmentName || department.department} (${department.count})`,
+ display: `${owner.name} - ${departmentLabel(department.department, department.departmentName)} (${department.count})`,
ownerKey: `organization:${owner.id}:${department.department}`
})
})