-
+
{htmlMsg}
@@ -19,11 +19,11 @@
{htmlAlternativeLink}
-
+
{link}
-
-
+
+
{htmlCaption}
diff --git a/api/src/mails/router.ts b/api/src/mails/router.ts
index ce1675f0..c8000dab 100644
--- a/api/src/mails/router.ts
+++ b/api/src/mails/router.ts
@@ -7,7 +7,8 @@ import { RateLimiterMongo } from 'rate-limiter-flexible'
import emailValidator from 'email-validator'
import multer from 'multer'
import { reqI18n } from '#i18n'
-import { sendMail } from './service.ts'
+import { sendMail, defaultLogoPng } from './service.ts'
+import { crossOriginResourcePolicy } from 'helmet'
import { textToSafeHtml, sanitizeMailHtml } from './escape.ts'
import type { FindMembersParams } from '../storages/interface.ts'
import { reqSite } from '#services'
@@ -87,6 +88,13 @@ router.post('/', async (req, res, next) => {
res.send(results)
})
+// the url built by the mails service carries a hash of the content, the image can be cached forever
+router.get('/logo.png', crossOriginResourcePolicy({ policy: 'cross-origin' }), (req, res) => {
+ res.set('Content-Type', 'image/png')
+ res.set('Cache-Control', 'public, max-age=31536000, immutable')
+ res.send(defaultLogoPng)
+})
+
// protect contact route with rate limiting to prevent spam
let _contactLimiter: RateLimiterMongo | undefined
router.post('/contact', async (req, res) => {
@@ -124,9 +132,14 @@ router.post('/contact', async (req, res) => {
return res.status(429).send('Trop de messages dans un bref interval. Veuillez patienter avant d\'essayer de nouveau.')
}
- const text = `Message transmis par le formulaire de contact de ${reqSiteUrl(req)}
-
- ${req.body.text}`
+ const siteUrl = reqSiteUrl(req)
+ const intro = `Message transmis par le formulaire de contact de ${siteUrl} émis par ${req.body.from}`
+ const body: string = req.body.text ?? ''
+ const text = `${intro}\n\n${body}`
+ // the portal contact form sends html, the simple-directory one plain text whose line breaks must survive
+ const bodyHtml = /<\/?[a-z][^>]*>/i.test(body) ? body : `${textToSafeHtml(body)}
`
+ const [safeUrl, safeFrom] = [textToSafeHtml(siteUrl), textToSafeHtml(req.body.from)]
+ const html = `Message transmis par le formulaire de contact de ${safeUrl} émis par ${safeFrom}
${bodyHtml}`
const site = await reqSite(req)
@@ -141,7 +154,7 @@ router.post('/contact', async (req, res) => {
// escape so that structure renders while scripts/dangerous hrefs are
// stripped. The body is partly anonymous-visitor-controlled, so the
// sanitizer (not raw passthrough) stays the trust boundary.
- htmlMsg: sanitizeMailHtml(text),
+ htmlMsg: sanitizeMailHtml(html),
htmlCaption: ''
})
res.send(req.body)
diff --git a/api/src/mails/service.ts b/api/src/mails/service.ts
index fa06ced7..249cb9cf 100644
--- a/api/src/mails/service.ts
+++ b/api/src/mails/service.ts
@@ -2,6 +2,7 @@ import mjml2html from 'mjml'
import microTemplate from '@data-fair/lib-utils/micro-template.js'
import { join } from 'path'
import { readFileSync, existsSync } from 'node:fs'
+import { createHash } from 'node:crypto'
import config from '#config'
import { flatten } from 'flat'
import EventEmitter from 'node:events'
@@ -13,6 +14,11 @@ import { mailLimiter } from '../utils/limiter.ts'
export const events = new EventEmitter()
+// logo used when neither the site nor the config define one, served by GET /api/mails/logo.png;
+// the content hash in the url lets it be cached forever and still change with a new image
+export const defaultLogoPng = readFileSync(join(import.meta.dirname, '../../resources/logo.png'))
+const defaultLogoUrl = `${config.publicUrl}/api/mails/logo.png?v=${createHash('sha256').update(defaultLogoPng).digest('hex').slice(0, 8)}`
+
const genericTplPath = join(import.meta.dirname, 'generic-mail.mjml')
const genericTemplate = readFileSync(genericTplPath, 'utf8')
let mainSiteTemplate = genericTemplate
@@ -115,7 +121,7 @@ export const sendMail = async (to: string, params: SendMailParams, attachments?:
if (mainSite) site = undefined
const flatTheme: FlatTheme = flatten({ theme: config.theme })
- let logo = config.theme.logo || 'https://cdn.rawgit.com/koumoul-dev/simple-directory/v0.12.3/public/assets/logo-150x150.png'
+ let logo = config.theme.logo || defaultLogoUrl
let from = config.mails.from
let contact = config.contact
// the main site keeps the main template in both cases — it *is* the main
@@ -138,6 +144,10 @@ export const sendMail = async (to: string, params: SendMailParams, attachments?:
if (site?.mails?.contact) contact = site.mails.contact
}
+ // a mail without caption (the contact form for instance) does not need the divider that announces it
+ // ponytail: matches the divider + caption block shape of the bundled and documented custom templates only
+ if (!params.htmlCaption) template = template.replace(/]*>\s*<\/mj-divider>\s*]*>\s*\{htmlCaption\}\s*<\/mj-text>/, '')
+
const tmplParams: SendMailTmplParams = {
...params,
...flatTheme,
diff --git a/api/src/organizations/router.ts b/api/src/organizations/router.ts
index cfffc375..5f6c4656 100644
--- a/api/src/organizations/router.ts
+++ b/api/src/organizations/router.ts
@@ -221,6 +221,17 @@ router.get('/:organizationId/members', async (req, res, next) => {
if (!org) return res.status(404).send('organization not found')
logContext.account = { type: 'organization', id: org.id, name: org.name }
+ // the export is for admins only (of the organization, or of the single department exported)
+ if (req.query.format === 'csv') {
+ const dep = typeof req.query.department === 'string' && !req.query.department.includes(',') ? req.query.department : undefined
+ const userRole = getAccountRole(
+ reqSession(req),
+ { type: 'organization', id: req.params.organizationId, department: dep },
+ { acceptDepAsRoot: config.depAdminIsOrgAdmin }
+ )
+ if (userRole !== 'admin') throw httpError(403, reqI18n(req).messages.errors.permissionDenied)
+ }
+
const orgStorages: (SdStorage & { orgStorage?: boolean })[] = [storages.globalStorage]
// org_storage can be yes, no or both (both is default)
diff --git a/api/src/storages/mongo.ts b/api/src/storages/mongo.ts
index 642a0eea..545aab40 100644
--- a/api/src/storages/mongo.ts
+++ b/api/src/storages/mongo.ts
@@ -492,12 +492,19 @@ class MongodbStorage implements SdStorage {
const dupUserOrg = user.organizations.find(o => {
return o.id === organizationId && (o.department || null) === (patch.department || null) && o.role === patch.role
})
- if (dupUserOrg) return
+ if (dupUserOrg) {
+ // the target membership already exists: merge by dropping the patched one
+ if (dupUserOrg !== userOrg) {
+ await mongo.users.updateOne({ _id: userId }, { $set: { organizations: user.organizations.filter(o => o !== userOrg) } })
+ }
+ return
+ }
// if we are switching department remove potential conflict
if ((patch.department || null) !== (department || null)) {
user.organizations = user.organizations.filter(o => {
- if (config.multiRoles && o.role !== patch.role) return false
+ // in multi-roles mode only a membership with the same role conflicts
+ if (config.multiRoles && o.role !== patch.role) return true
const isConflict = o.id === organizationId && (o.department || null) === (patch.department || null)
return !isConflict
})
diff --git a/package.json b/package.json
index 30750df1..28e36dc7 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,6 @@
"test-api": "playwright test --project api --max-failures=1",
"test-inproc": "playwright test --project api-inproc-ldap --project api-inproc-oidc --max-failures=1",
"test-e2e": "playwright test --project e2e --max-failures=1",
- "report": "nyc report --reporter=html",
"lint": "eslint . && npm -w ui run lint",
"lint-fix": "eslint --fix . && npm -w ui run lint-fix",
"dev-api": "npm -w api run dev",
@@ -25,7 +24,7 @@
},
"repository": {
"type": "git",
- "url": "git+https://github.com/koumoul-dev/simple-directory.git"
+ "url": "git+https://github.com/data-fair/simple-directory.git"
},
"workspaces": [
"ui",
@@ -35,9 +34,9 @@
"author": "",
"license": "MIT",
"bugs": {
- "url": "https://github.com/koumoul-dev/simple-directory/issues"
+ "url": "https://github.com/data-fair/simple-directory/issues"
},
- "homepage": "https://github.com/koumoul-dev/simple-directory#readme",
+ "homepage": "https://github.com/data-fair/simple-directory#readme",
"devDependencies": {
"@commitlint/config-conventional": "^19.8.1",
"@data-fair/lib-express": "^1.27.0",
diff --git a/tests/features/avatars.api.spec.ts b/tests/features/avatars.api.spec.ts
index 56d39942..9c136d6d 100644
--- a/tests/features/avatars.api.spec.ts
+++ b/tests/features/avatars.api.spec.ts
@@ -94,6 +94,21 @@ test.describe('avatars api', () => {
await assertUnknown(ax, removedPath, 'unknown-department.png')
})
+ test('should refuse uploads but keep deletion when avatars are disabled', async () => {
+ const { ax, user } = await createUser('avatar-disabled@test.com')
+ const path = `/api/avatars/user/${user.id}/avatar.png`
+ assert.equal((await uploadAvatar(ax, path)).status, 201)
+
+ await testEnvAx.patch('/config', { avatars: { users: false, orgs: true } })
+ try {
+ await assert.rejects(uploadAvatar(ax, path), { status: 403 })
+ assert.equal((await ax.delete(path)).status, 204)
+ assert.equal((await ax.get(path)).headers['x-avatar-custom'], 'false')
+ } finally {
+ await testEnvAx.patch('/config', { avatars: { users: true, orgs: true } })
+ }
+ })
+
test('should delete a custom avatar and revert to default initials avatar', async () => {
const { ax, user } = await createUser('avatar-reset@test.com')
const path = `/api/avatars/user/${user.id}/avatar.png`
diff --git a/tests/features/mails.api.spec.ts b/tests/features/mails.api.spec.ts
index dfcf379d..aa2781c8 100644
--- a/tests/features/mails.api.spec.ts
+++ b/tests/features/mails.api.spec.ts
@@ -145,6 +145,34 @@ test.describe('mails', () => {
assert.ok(!email.html.includes('alert(1)'), 'script content must be stripped')
})
+ test('Contact form: plain text keeps its line breaks', async () => {
+ await testEnvAx.patch('/config', { anonymousContactForm: true })
+ const ax = await axios()
+ const token = (await ax.get('/api/auth/anonymous-action')).data
+ const res = await ax.post('/api/mails/contact', {
+ token,
+ from: 'visitor@test.com',
+ subject: 'contact-plain',
+ text: 'first line\nsecond line'
+ })
+ assert.equal(res.status, 200)
+ const email = await findEmail('contact-plain')
+ assert.ok(email)
+ assert.ok(email.html.includes('first line
second line') || email.html.includes('first line
second line'), 'line breaks should render')
+ assert.ok(email.html.includes('href="mailto:visitor@test.com"'), 'the sender is named by simple-directory, as a link')
+ })
+
+ test('Default logo is served by simple-directory and cached for good', async () => {
+ const ax = await axios()
+ await ax.post('/api/mails', { to: ['logo@test.com'], subject: 'logo-test', text: 'logo' }, { params: { key: 'testkey' } })
+ const email = await findEmail('logo-test')
+ const logoUrl = email.html.match(/src="([^"]*\/api\/mails\/logo\.png\?v=[0-9a-f]{8})"/)?.[1]
+ assert.ok(logoUrl, 'the mail should use the bundled logo with a content hash')
+ const res = await ax.get(logoUrl, { responseType: 'arraybuffer' })
+ assert.equal(res.headers['content-type'], 'image/png')
+ assert.equal(res.headers['cache-control'], 'public, max-age=31536000, immutable')
+ })
+
test('Send email to address and with attachments', async () => {
const ax = await axios()
const readmeBuffer = fs.readFileSync('./README.md')
diff --git a/tests/features/organizations.api.spec.ts b/tests/features/organizations.api.spec.ts
index ec538d43..ecca493a 100644
--- a/tests/features/organizations.api.spec.ts
+++ b/tests/features/organizations.api.spec.ts
@@ -212,6 +212,13 @@ test.describe('organizations api', () => {
const newMember = members.find((m: any) => m.email === 'test-member1@test.com')
assert.equal(newMember.role, 'user')
+ // a simple member can list the members but not export them
+ await axMember.post('/api/auth/keepalive')
+ axMember.setOrg(org.id)
+ assert.equal((await axMember.get(`/api/organizations/${org.id}/members`)).status, 200)
+ await assert.rejects(axMember.get(`/api/organizations/${org.id}/members`, { params: { format: 'csv' } }), { status: 403 })
+ assert.ok((await ax.get(`/api/organizations/${org.id}/members`, { params: { format: 'csv' } })).data.includes('test-member1@test.com'))
+
// the member cannot change his own role as a simple user
await assert.rejects(
axMember.patch(`/api/organizations/${org.id}/members/${memberUser.id}`, { role: 'admin' }),
@@ -274,6 +281,44 @@ test.describe('organizations api', () => {
await testEnvAx.patch('/config', { alwaysAcceptInvitation: false })
})
+ test('multi-roles: changing a membership to a role the member already has merges them', async () => {
+ await testEnvAx.patch('/config', { alwaysAcceptInvitation: true, multiRoles: true })
+ try {
+ const { ax } = await createUser('test-owner-mr1@test.com')
+ const { ax: axMember, user: member } = await createUser('test-member-mr1@test.com')
+ const org = (await ax.post('/api/organizations', { name: 'test' })).data
+ ax.setOrg(org.id)
+ await ax.post('/api/invitations', { id: org.id, name: org.name, email: member.email, role: 'user' })
+ await ax.post('/api/invitations', { id: org.id, name: org.name, email: member.email, role: 'admin' })
+
+ await ax.patch(`/api/organizations/${org.id}/members/${member.id}`, { role: 'admin' }, { params: { role: 'user' } })
+ const memberships = (await axMember.get(`/api/users/${member.id}`)).data.organizations.filter((o: any) => o.id === org.id)
+ assert.deepEqual(memberships.map((o: any) => o.role), ['admin'])
+ } finally {
+ await testEnvAx.patch('/config', { alwaysAcceptInvitation: false, multiRoles: false })
+ }
+ })
+
+ test('multi-roles: moving a membership to another department keeps the other memberships', async () => {
+ await testEnvAx.patch('/config', { alwaysAcceptInvitation: true, multiRoles: true })
+ try {
+ const { ax } = await createUser('test-owner-mr2@test.com')
+ const { ax: axMember, user: member } = await createUser('test-member-mr2@test.com')
+ // the member administers an organization of their own
+ const ownOrg = (await axMember.post('/api/organizations', { name: 'own' })).data
+ const org = (await ax.post('/api/organizations', { name: 'test', departments: [{ id: 'dep1', name: 'Department 1' }, { id: 'dep2', name: 'Department 2' }] })).data
+ ax.setOrg(org.id)
+ await ax.post('/api/invitations', { id: org.id, name: org.name, department: 'dep1', email: member.email, role: 'user' })
+
+ await ax.patch(`/api/organizations/${org.id}/members/${member.id}`, { role: 'user', department: 'dep2' }, { params: { role: 'user', department: 'dep1' } })
+ const memberships = (await axMember.get(`/api/users/${member.id}`)).data.organizations
+ .map((o: any) => `${o.id}/${o.department ?? ''}/${o.role}`).sort()
+ assert.deepEqual(memberships, [`${org.id}/dep2/user`, `${ownOrg.id}//admin`].sort())
+ } finally {
+ await testEnvAx.patch('/config', { alwaysAcceptInvitation: false, multiRoles: false })
+ }
+ })
+
test('should send emails based on roles and departments', async () => {
await getServerConfig()
await testEnvAx.patch('/config', { alwaysAcceptInvitation: true })
diff --git a/ui/src/components/add-nhi-menu.vue b/ui/src/components/add-nhi-menu.vue
index d220be81..5f085fd1 100644
--- a/ui/src/components/add-nhi-menu.vue
+++ b/ui/src/components/add-nhi-menu.vue
@@ -7,7 +7,7 @@
diff --git a/ui/src/components/add-partner-superadmin-menu.vue b/ui/src/components/add-partner-superadmin-menu.vue
index 254673e4..839abf18 100644
--- a/ui/src/components/add-partner-superadmin-menu.vue
+++ b/ui/src/components/add-partner-superadmin-menu.vue
@@ -55,7 +55,7 @@
{{ $t('common.confirmCancel') }}
diff --git a/ui/src/components/edit-nhi-menu.vue b/ui/src/components/edit-nhi-menu.vue
index 9411065d..e25f2cc0 100644
--- a/ui/src/components/edit-nhi-menu.vue
+++ b/ui/src/components/edit-nhi-menu.vue
@@ -23,6 +23,7 @@
@@ -122,7 +123,7 @@
{{ $t('common.confirmCancel') }}
diff --git a/ui/src/components/organization-members.vue b/ui/src/components/organization-members.vue
index 049b3261..a6f3f9fa 100644
--- a/ui/src/components/organization-members.vue
+++ b/ui/src/components/organization-members.vue
@@ -27,6 +27,7 @@
:topics="notifyTopics"
/>
{
}
})
-const csvUrl = computed(() => $sdUrl + `/api/organizations/${orga.id}/members?size=10000&format=csv`)
+const csvUrl = computed(() => $sdUrl + `/api/organizations/${orga.id}/members?size=10000&format=csv` + (adminDepartment ? `&department=${encodeURIComponent(adminDepartment)}` : ''))
const filterMemberCols = $uiConfig.alwaysAcceptInvitation ? 6 : 4
const departmentsList = computed(() => {
diff --git a/ui/src/components/organization-partners.vue b/ui/src/components/organization-partners.vue
index 32f8ad07..dec87d75 100644
--- a/ui/src/components/organization-partners.vue
+++ b/ui/src/components/organization-partners.vue
@@ -72,9 +72,9 @@
+
diff --git a/ui/src/components/partner-invitation.vue b/ui/src/components/partner-invitation.vue
index 0e3ecd7e..1791585e 100644
--- a/ui/src/components/partner-invitation.vue
+++ b/ui/src/components/partner-invitation.vue
@@ -84,7 +84,7 @@
color="primary"
:label="userOrg.name"
hide-details
- :disabled="userOrg.role !== 'admin'"
+ :disabled="userOrg.role !== 'admin' || !!userOrg.department"
@update:model-value="v => toggleSelectedUserOrg(userOrg, v as boolean)"
/>
{
if (!invit) return []
- return user.value?.organizations.filter(o => invit.o !== o.id)
+ const byId = new Map['organizations'][number]>()
+ for (const o of user.value?.organizations ?? []) {
+ if (o.id === invit.o) continue
+ if (!byId.has(o.id) || (o.role === 'admin' && !o.department)) byId.set(o.id, o)
+ }
+ return [...byId.values()]
})
if (invit) {
createOrganizationName.value = invit.n
diff --git a/ui/src/pages/admin/users.vue b/ui/src/pages/admin/users.vue
index 4df8b21e..fae6c293 100644
--- a/ui/src/pages/admin/users.vue
+++ b/ui/src/pages/admin/users.vue
@@ -112,7 +112,7 @@
class="text-primary"
:to="`/organization/${orga.id}`"
>{{ orga.name }}
- {{ orga.departmentName || orga.department }}
+ / {{ orga.departmentName || orga.department }}
({{ orga.roleLabel || orga.role }})
@@ -196,7 +196,7 @@
max-width="500px"
>
-
+
{{ $t('common.confirmDeleteTitle', {name: currentUser.name}) }}
@@ -226,7 +226,7 @@
max-width="500px"
>
-
+
{{ $t('pages.admin.users.editUserEmailTitle', {name: currentUser.name}) }}
@@ -263,7 +263,7 @@
max-width="500px"
>
-
+
{{ $t('common.editTitle', {name: currentUser.name}) }}
@@ -308,7 +308,7 @@
max-width="500px"
>
-
+
{{ $t('pages.admin.users.drop2FATitle', {name: currentUser.name}) }}
@@ -343,7 +343,7 @@
max-width="500px"
>
-
+
{{ $t('pages.admin.users.transferTitle', {name: currentUser.name}) }}
diff --git a/ui/src/pages/organization/[id]/index.vue b/ui/src/pages/organization/[id]/index.vue
index 2eab3623..6cd10421 100644
--- a/ui/src/pages/organization/[id]/index.vue
+++ b/ui/src/pages/organization/[id]/index.vue
@@ -28,7 +28,7 @@
-
+
{{ orga.name }}
- {{ orga.departmentName || orga.department }}
+ / {{ orga.departmentName || orga.department }}
({{ orga.roleLabel || orga.role }})
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index 3fdcfb28..6ad5f3e4 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -15,7 +15,7 @@ import { commonjsDeps } from '@koumoul/vjsf/utils/build.js'
// const devSitePath = '/site-prefix'
const devSitePath = ''
-// https://vitejs.dev/config/
+// https://vite.dev/config/
export default defineConfig({
base: devSitePath + '/simple-directory',
optimizeDeps: { include: commonjsDeps },