diff --git a/README.md b/README.md index f1742229..0e79a06a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/config/default.cjs b/api/config/default.cjs index 9406a676..68fe74d2 100644 --- a/api/config/default.cjs +++ b/api/config/default.cjs @@ -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: [], diff --git a/api/i18n/de.js b/api/i18n/de.js index ec640a90..24eb3d82 100644 --- a/api/i18n/de.js +++ b/api/i18n/de.js @@ -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 kennwortlose Authentifizierung', 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', @@ -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)', diff --git a/api/i18n/en.js b/api/i18n/en.js index e3892df5..64aaa292 100644 --- a/api/i18n/en.js +++ b/api/i18n/en.js @@ -219,7 +219,6 @@ Can be 'anonymous', 'authenticated' or 'admin'.`, login: { title: 'Identify yourself', emailLabel: 'Your email address', - emailCaption: 'Learn more about passwordless 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', @@ -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)', diff --git a/api/i18n/es.js b/api/i18n/es.js index 5957d6f0..671f3ae8 100644 --- a/api/i18n/es.js +++ b/api/i18n/es.js @@ -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 sin contraseñ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', @@ -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)', diff --git a/api/i18n/fr.js b/api/i18n/fr.js index 1bb8876c..25f44df3 100644 --- a/api/i18n/fr.js +++ b/api/i18n/fr.js @@ -219,7 +219,6 @@ Peut valoir 'anonymous', 'authenticated' ou 'admin'.`, login: { title: 'Identifiez-vous', emailLabel: 'Adresse mail', - emailCaption: 'En savoir plus sur l\'authentification sans mot de passe', 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', @@ -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)', diff --git a/api/i18n/it.js b/api/i18n/it.js index 5060f5df..0d575aa7 100644 --- a/api/i18n/it.js +++ b/api/i18n/it.js @@ -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 senza password', 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', @@ -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)', diff --git a/api/i18n/pt.js b/api/i18n/pt.js index a467073e..bb696718 100644 --- a/api/i18n/pt.js +++ b/api/i18n/pt.js @@ -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 sem senha', 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', @@ -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)', diff --git a/api/resources/logo.png b/api/resources/logo.png new file mode 100644 index 00000000..2e1fa0c7 Binary files /dev/null and b/api/resources/logo.png differ diff --git a/api/src/avatars/router.ts b/api/src/avatars/router.ts index 8bd0e625..48fb1759 100644 --- a/api/src/avatars/router.ts +++ b/api/src/avatars/router.ts @@ -131,14 +131,20 @@ const isAdmin: RequestHandler = 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 = (req, res, next) => { + if (!config.avatars[req.params.type === 'user' ? 'users' : 'orgs']) throw httpError(403, 'avatar upload is disabled') + next() +} + const writeAvatar: RequestHandler = 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 = async (req, res, next) => { if (!['user', 'organization'].includes(req.params.type)) { diff --git a/api/src/mails/generic-mail-nobutton.mjml b/api/src/mails/generic-mail-nobutton.mjml index 61a954b7..400f873c 100644 --- a/api/src/mails/generic-mail-nobutton.mjml +++ b/api/src/mails/generic-mail-nobutton.mjml @@ -3,11 +3,11 @@ - + {htmlMsg} - - + + {htmlCaption} diff --git a/api/src/mails/generic-mail.mjml b/api/src/mails/generic-mail.mjml index 9a8d150b..47058fc9 100644 --- a/api/src/mails/generic-mail.mjml +++ b/api/src/mails/generic-mail.mjml @@ -10,7 +10,7 @@ - + {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 @@