Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
02b4a8c
fix(partners): the removal warning says partner permissions are withd…
BatLeDev Oct 1, 2026
a379626
fix(organizations): lock the settings form for members who are not ad…
BatLeDev Oct 1, 2026
1d66b82
fix(nhis): hide the avatar widget of a service account when user avat…
BatLeDev Oct 1, 2026
d052bba
fix(ui): use the admin color on every superadmin-only action
BatLeDev Oct 1, 2026
babf9d8
fix(partners): show the default organization avatar for a pending par…
BatLeDev Oct 1, 2026
42a236f
fix(partners): only offer to accept as an organization the user admin…
BatLeDev Oct 1, 2026
ecb05c6
fix(admin): let the user dialog titles wrap instead of overflowing
BatLeDev Oct 1, 2026
68a15be
fix(mails): contact form mail names its sender and keeps line breaks
BatLeDev Oct 1, 2026
333fab8
chore(i18n): drop the unused emailCaption message
BatLeDev Oct 1, 2026
7d58c6d
fix(organizations): reserve the members CSV export to admins
BatLeDev Oct 1, 2026
de2f4b5
fix(mails): no divider under a mail without caption, even out the mar…
BatLeDev Oct 1, 2026
b11a1d9
chore: refresh outdated links (repository urls, release badge, samlif…
BatLeDev Oct 1, 2026
4e39386
fix(mails): serve the default mail logo from simple-directory, cached…
BatLeDev Oct 1, 2026
d92257f
fix(avatars): refuse uploads when avatars are disabled, keep deletion…
BatLeDev Oct 1, 2026
18ce840
fix(organizations): multi-roles member patch merges duplicates and ke…
BatLeDev Oct 1, 2026
0582a58
fix(admin): separate the department from the organization name in the…
BatLeDev Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

This service aims to provide easy access to user and organizations structures and authentication mechanism using Json Web tokens.

[![Build Status](https://travis-ci.org/koumoul-dev/simple-directory.svg?branch=master)](https://travis-ci.org/koumoul-dev/simple-directory)
[![Coverage Status](https://coveralls.io/repos/github/koumoul-dev/simple-directory/badge.svg?branch=master)](https://coveralls.io/github/koumoul-dev/simple-directory?branch=master)
[![Release](https://github.com/data-fair/simple-directory/actions/workflows/releases.yml/badge.svg)](https://github.com/data-fair/simple-directory/actions/workflows/releases.yml)

## Sponsors

Expand Down
4 changes: 2 additions & 2 deletions api/config/default.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,10 @@ module.exports = {
},
saml2: {
// certsDirectory: './security/saml2',
// Accepts all samlify options for service providers https://samlify.js.org/#/sp-configuration
// Accepts all samlify options for service providers https://samlify.js.org/sp-configuration
sp: {},
// providers have the usual title/color/icon/img attributes and all extra options accepted by samlify
// for identify provider https://samlify.js.org/#/idp-configuration
// for identify provider https://samlify.js.org/idp-configuration
providers: []
},
applications: [],
Expand Down
3 changes: 1 addition & 2 deletions api/i18n/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,6 @@ Peut valoir 'anonym', 'authentifiziert' oder 'admin'.`,
login: {
title: 'Identifizieren Sie sich',
emailLabel: 'Deine E-Mail',
emailCaption: 'Erfahren Sie mehr über die <a href="https://koumoul.com/blog/passwordless">kennwortlose Authentifizierung</a>',
success: 'Sie erhalten eine E-Mail an die angegebene Adresse, die einen Link enthält. Bitte öffnen Sie diesen Link, um Ihre Identifikation zu vervollständigen.',
maildevLink: 'Auf das Entwicklungspostfach zugreifen',
newPassword: 'Neues Kennwort',
Expand Down Expand Up @@ -300,7 +299,7 @@ Peut valoir 'anonym', 'authentifiziert' oder 'admin'.`,
deletePartner: 'Löschen Sie diesen Partner',
depSortCreation: 'Zuletzt erstellt',
depSortAlpha: 'Alphabetische Reihenfolge',
deletePartnerWarning: 'Achtung! Die Berechtigungen, die der Partnerorganisation gewährt wurden, werden durch diese Operation nicht geändert. Sie sollten diese wahrscheinlich selbst ändern.',
deletePartnerWarning: 'Die Berechtigungen, die dieser Organisation für Ihre Ressourcen gewährt wurden, werden ihr entzogen.',
fromCache: 'Letzte Synchronisierung dieser Liste mit dem Identitätsanbieter: {fromNow}.',
roleLabel: 'Bezeichnung der Rolle "{role}"',
nhisTitle: 'Dienstkonten (nicht-menschliche Identitäten)',
Expand Down
3 changes: 1 addition & 2 deletions api/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,6 @@ Can be 'anonymous', 'authenticated' or 'admin'.`,
login: {
title: 'Identify yourself',
emailLabel: 'Your email address',
emailCaption: 'Learn more about <a href="https://medium.com/@ninjudd/passwords-are-obsolete-9ed56d483eb">passwordless</a> authentication',
success: 'You will receive an email at the specified address. Please use the link in this email to conclude your identification.',
maildevLink: 'Open the development mail box',
newPassword: 'New password',
Expand Down Expand Up @@ -302,7 +301,7 @@ Can be 'anonymous', 'authenticated' or 'admin'.`,
deletePartner: 'Delete this partner',
depSortCreation: 'Last created',
depSortAlpha: 'Alphabetical order',
deletePartnerWarning: 'Warning: permissions granted to the partner organization will not be modified by this operation. You should probably modify them yourself.',
deletePartnerWarning: 'Permissions granted to this organization on your resources will be withdrawn.',
fromCache: 'Last synchronization of this list with the identity provider: {fromNow}.',
roleLabel: 'Label for the role "{role}"',
nhisTitle: 'Service accounts (non-human identities)',
Expand Down
3 changes: 1 addition & 2 deletions api/i18n/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,6 @@ Puede ser 'anonymous', 'authenticated' o 'admin'.`,
login: {
title: 'Identifícate',
emailLabel: 'Tu correo electrónico',
emailCaption: 'Más información sobre la autenticación <a href="https://koumoul.com/blog/passwordless">sin contraseña</a>',
success: 'Recibirá un correo electrónico a la dirección proporcionada que contendrá un enlace. Abra este enlace para completar su identificación.',
maildevLink: 'Acceder al buzón de desarrollo',
newPassword: 'Nueva contraseña',
Expand Down Expand Up @@ -300,7 +299,7 @@ Puede ser 'anonymous', 'authenticated' o 'admin'.`,
deletePartner: 'Eliminar este socio',
depSortCreation: 'Última creación',
depSortAlpha: 'Orden alfabético',
deletePartnerWarning: 'Atención, los permisos concedidos a la organización asociada no se modificarán con esta operación. Probablemente deberá modificarlos usted mismo.',
deletePartnerWarning: 'Se retirarán los permisos concedidos a esta organización sobre sus recursos.',
fromCache: 'Última sincronización de esta lista con el proveedor de identidad: {fromNow}.',
roleLabel: 'Etiqueta del rol "{role}"',
nhisTitle: 'Cuentas de servicio (identidades no humanas)',
Expand Down
3 changes: 1 addition & 2 deletions api/i18n/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,6 @@ Peut valoir 'anonymous', 'authenticated' ou 'admin'.`,
login: {
title: 'Identifiez-vous',
emailLabel: 'Adresse mail',
emailCaption: 'En savoir plus sur l\'authentification <a href="https://koumoul.com/blog/passwordless">sans mot de passe</a>',
success: 'Vous allez recevoir un email à l\'adresse renseignée qui contiendra un lien. Veuillez ouvrir ce lien pour terminer votre identification.',
maildevLink: 'Accéder à la boîte mail de développement',
newPassword: 'Nouveau mot de passe',
Expand Down Expand Up @@ -302,7 +301,7 @@ Peut valoir 'anonymous', 'authenticated' ou 'admin'.`,
deletePartner: 'Supprimer ce partenaire',
depSortCreation: 'Derniers créés',
depSortAlpha: 'Ordre alphabétique',
deletePartnerWarning: 'Attention les permissions accordées à l\'organisation partenaire ne seront pas modifiées par cette opération. Vous devriez probablement aller les modifier vous-même.',
deletePartnerWarning: 'Les permissions accordées à cette organisation sur vos ressources lui seront retirées.',
fromCache: 'Dernière synchronisation de cette liste avec le fournisseur d\'identités : {fromNow}.',
roleLabel: 'Libellé du rôle "{role}"',
nhisTitle: 'Comptes de service (identités non humaines)',
Expand Down
3 changes: 1 addition & 2 deletions api/i18n/it.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,6 @@ Può essere 'anonimo', 'autenticato' o 'admin'.`,
login: {
title: 'Accedi al tuo conto',
emailLabel: 'Il suo indirizzo e-mail',
emailCaption: 'Per saperne di più sull\'autenticazione <a href="https://koumoul.com/blog/passwordless">senza password</a>',
success: "Riceverà un'e-mail all'indirizzo fornito che conterrà un link. La preghiamo di aprire questo link per completare la sua identificazione.",
maildevLink: 'Accedere alla mailbox di sviluppo',
newPassword: 'Nuova password',
Expand Down Expand Up @@ -300,7 +299,7 @@ Può essere 'anonimo', 'autenticato' o 'admin'.`,
deletePartner: 'Elimina questo partner',
depSortCreation: 'Ultimo creato',
depSortAlpha: 'Ordine alfabetico',
deletePartnerWarning: 'Attenzione le autorizzazioni concesse all\'organizzazione partner non saranno modificate da questa operazione. Dovresti probabilmente modificarle tu stesso.',
deletePartnerWarning: 'Le autorizzazioni concesse a questa organizzazione sulle vostre risorse le saranno revocate.',
fromCache: 'Ultima sincronizzazione di questo elenco con il provider di identità: {fromNow}.',
roleLabel: 'Etichetta del ruolo "{role}"',
nhisTitle: 'Account di servizio (identità non umane)',
Expand Down
3 changes: 1 addition & 2 deletions api/i18n/pt.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,6 @@ Pode ser 'anónimo', 'autenticado' ou 'administrador'.`,
login: {
title: 'Faça o login na sua conta',
emailLabel: 'O seu endereço de e-mail',
emailCaption: 'Saiba mais sobre autenticação <a href="https://koumoul.com/blog/passwordless">sem senha</a>',
success: 'Receberá um e-mail no endereço fornecido que conterá um link. Por favor, abra este link para completar a sua identificação.',
maildevLink: 'Ir para a caixa de correio de desenvolvimento',
newPassword: 'Nova palavra-passe',
Expand Down Expand Up @@ -300,7 +299,7 @@ Pode ser 'anónimo', 'autenticado' ou 'administrador'.`,
deletePartner: 'Eliminar este parceiro',
depSortCreation: 'Último criado',
depSortAlpha: 'Ordem alfabética',
deletePartnerWarning: 'Atenção, as permissões concedidas à organização parceira não serão modificadas por esta operação. Provavelmente deve ir modificá-las.',
deletePartnerWarning: 'As permissões concedidas a esta organização sobre os seus recursos serão retiradas.',
fromCache: 'Última sincronização desta lista com o provedor de identidade: {fromNow}.',
roleLabel: 'Nome do papel "{role}"',
nhisTitle: 'Contas de serviço (identidades não humanas)',
Expand Down
Binary file added api/resources/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 8 additions & 2 deletions api/src/avatars/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,20 @@ const isAdmin: RequestHandler<AvatarParams> = async (req, res, next) => {
return next()
}

// AVATARS_USERS / AVATARS_ORGS = false stops new uploads; deleting stays open so existing avatars can be moderated
const uploadEnabled: RequestHandler<AvatarParams> = (req, res, next) => {
if (!config.avatars[req.params.type === 'user' ? 'users' : 'orgs']) throw httpError(403, 'avatar upload is disabled')
next()
}

const writeAvatar: RequestHandler<AvatarParams> = async (req, res, next) => {
if (!req.file) throw httpError(400)
await setAvatar({ owner: req.params as unknown as Account, buffer: req.file.buffer })
res.status(201).send()
}

router.post('/:type/:id/avatar.png', isAdmin, upload.single('avatar'), writeAvatar)
router.post('/:type/:id/:department/avatar.png', isAdmin, upload.single('avatar'), writeAvatar)
router.post('/:type/:id/avatar.png', isAdmin, uploadEnabled, upload.single('avatar'), writeAvatar)
router.post('/:type/:id/:department/avatar.png', isAdmin, uploadEnabled, upload.single('avatar'), writeAvatar)

const deleteAvatar: RequestHandler<AvatarParams> = async (req, res, next) => {
if (!['user', 'organization'].includes(req.params.type)) {
Expand Down
6 changes: 3 additions & 3 deletions api/src/mails/generic-mail-nobutton.mjml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
<mj-section>
<mj-column>
<mj-image width="100px" src="{logo}"></mj-image>
<mj-text font-size="14px" font-weight="500" line-height="21px" color="#212121" font-family="helvetica" padding-top="16px" padding-bottom="24px">
<mj-text font-size="14px" font-weight="500" line-height="21px" color="#212121" font-family="helvetica" padding-top="16px" padding-bottom="32px">
{htmlMsg}
</mj-text>
<mj-divider border-width="1px" border-color="#757575" padding-top="48px"></mj-divider>
<mj-text font-size="12px" font-weight="400" line-height="18px" color="#424242" font-family="helvetica">
<mj-divider border-width="1px" border-color="#757575" padding="0px 25px"></mj-divider>
<mj-text font-size="12px" font-weight="400" line-height="18px" color="#424242" font-family="helvetica" padding-top="32px">
{htmlCaption}
</mj-text>
</mj-column>
Expand Down
8 changes: 4 additions & 4 deletions api/src/mails/generic-mail.mjml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<mj-section>
<mj-column>
<mj-image width="100px" src="{logo}"></mj-image>
<mj-text font-size="14px" font-weight="500" line-height="21px" color="#212121" font-family="helvetica" padding-top="16px" padding-bottom="24px">
<mj-text font-size="14px" font-weight="500" line-height="21px" color="#212121" font-family="helvetica" padding-top="16px" padding-bottom="32px">
{htmlMsg}
</mj-text>
<mj-button background-color="{theme.colors.primary}" color="#fff" href="{link}" border-radius="4px">
Expand All @@ -19,11 +19,11 @@
<mj-text font-size="14px" font-weight="500" line-height="21px" color="#212121" font-family="helvetica" padding-top="25px">
{htmlAlternativeLink}
</mj-text>
<mj-text font-size="10px" font-weight="400" line-height="14px" color="#424242" font-family="helvetica" padding-top="0px" css-class="raw-link">
<mj-text font-size="10px" font-weight="400" line-height="14px" color="#424242" font-family="helvetica" padding-top="0px" padding-bottom="32px" css-class="raw-link">
{link}
</mj-text>
<mj-divider border-width="1px" border-color="#757575" padding-top="20px"></mj-divider>
<mj-text font-size="12px" font-weight="400" line-height="18px" color="#424242" font-family="helvetica">
<mj-divider border-width="1px" border-color="#757575" padding="0px 25px"></mj-divider>
<mj-text font-size="12px" font-weight="400" line-height="18px" color="#424242" font-family="helvetica" padding-top="32px">
{htmlCaption}
</mj-text>
</mj-column>
Expand Down
23 changes: 18 additions & 5 deletions api/src/mails/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 : `<p>${textToSafeHtml(body)}</p>`
const [safeUrl, safeFrom] = [textToSafeHtml(siteUrl), textToSafeHtml(req.body.from)]
const html = `<p>Message transmis par le formulaire de contact de <a href="${safeUrl}">${safeUrl}</a> émis par <a href="mailto:${safeFrom}">${safeFrom}</a></p>${bodyHtml}`

const site = await reqSite(req)

Expand All @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion api/src/mails/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(/<mj-divider[^>]*>\s*<\/mj-divider>\s*<mj-text[^>]*>\s*\{htmlCaption\}\s*<\/mj-text>/, '')

const tmplParams: SendMailTmplParams = {
...params,
...flatTheme,
Expand Down
11 changes: 11 additions & 0 deletions api/src/organizations/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions api/src/storages/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down
Loading
Loading