diff --git a/AGENTS.md b/AGENTS.md index 9ba6277e..056b3488 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,4 +73,5 @@ Read before changing anything in the corresponding area: - [`docs/architecture/email-trust-and-site-isolation.md`](docs/architecture/email-trust-and-site-isolation.md) -- how SSO email claims are verified and how site-level SSO trust is confined so a compromised site config cannot escalate to superadmin or cross-site takeover. Required reading for changes to auth providers, `cleanUser`, `authProviderLoginCallback`, `adminMode`, or the change-host flow. - [`docs/architecture/session-theft-protections.md`](docs/architecture/session-theft-protections.md) -- what protects a session whose cookies were copied: the split between the short lived `id_token` and the exchange token, server sessions and their recorded origin, the IP binding of superadmin sessions, and single use exchange tokens with reuse detection. Required reading for changes to `setSessionCookies`, `keepalive`, `logout`, or the `ServerSession` schema. - [`docs/architecture/emails.md`](docs/architecture/emails.md) -- the outbound email pipeline: the two `/api/mails*` endpoints, sanitization/escape at the trust boundary, MJML template substitution, and `sendMailI18n`. Required reading for changes under `api/src/mails/`, the MJML templates, the mail schemas, or any caller that posts to `/api/mails`. +- [`docs/architecture/main-site-config.md`](docs/architecture/main-site-config.md) -- what the main site reads from its database document versus from environment variables, the `MAIN_SITE_FROM_DB` category list, and why the API reports rather than refuses. Required reading for changes to `api/src/sites/main-site.ts`, the `/api/sites/_*` presentation endpoints, `getSiteExtraParams` in `api/src/sites/spa-params.ts`, or the mail theming path. - [`docs/architecture/non-human-identities.md`](docs/architecture/non-human-identities.md) -- how NHI (service account) JWT exchange is verified and confined to a single org with short-lived non-refreshable sessions. Required reading for changes to the nhi-token exchange, `api/src/nhis/`, or NHI-related guards. diff --git a/api/config/custom-environment-variables.cjs b/api/config/custom-environment-variables.cjs index fad244c8..2b0f4cc2 100644 --- a/api/config/custom-environment-variables.cjs +++ b/api/config/custom-environment-variables.cjs @@ -323,6 +323,7 @@ module.exports = { depAdminIsOrgAdmin: 'DEP_ADMIN_IS_ORG_ADMIN', manageSites: 'MANAGE_SITES', acceptUnknownSite: 'ACCEPT_UNKNOWN_SITE', + mainSiteFromDb: jsonEnv('MAIN_SITE_FROM_DB'), managePartners: 'MANAGE_PARTNERS', manageNhis: 'MANAGE_NHIS', nhisAllowInsecureIssuers: 'NHIS_ALLOW_INSECURE_ISSUERS', diff --git a/api/config/default.cjs b/api/config/default.cjs index b23791b5..9406a676 100644 --- a/api/config/default.cjs +++ b/api/config/default.cjs @@ -273,6 +273,14 @@ module.exports = { depAdminIsOrgAdmin: false, manageSites: false, acceptUnknownSite: false, + // Which categories of main-site configuration are read from its site document + // in the database instead of the environment. The "main site document" is a + // site whose host+path matches publicUrl. Empty means everything comes from + // env, which is the 8.x default; 9.0 will default to the full list. + // Never read from that document whatever this contains: authMode, + // authOnlyOtherSite, authProviders, applications, isAccountMain, owner, + // and user scoping. See docs/architecture/main-site-config.md + mainSiteFromDb: [], managePartners: false, manageNhis: false, nhisAllowInsecureIssuers: false, diff --git a/api/config/type/schema.json b/api/config/type/schema.json index 5c49ad2f..c76865d3 100644 --- a/api/config/type/schema.json +++ b/api/config/type/schema.json @@ -45,7 +45,8 @@ "cleanup", "avatars", "noBirthday", - "passwordValidation" + "passwordValidation", + "mainSiteFromDb" ], "properties": { "info": { @@ -212,6 +213,13 @@ "acceptUnknownSite": { "type": "boolean" }, + "mainSiteFromDb": { + "type": "array", + "items": { + "type": "string", + "enum": ["theme", "title", "mails", "registration"] + } + }, "managePartners": { "type": "boolean" }, diff --git a/api/i18n/en.js b/api/i18n/en.js index 8ffef85b..e3892df5 100644 --- a/api/i18n/en.js +++ b/api/i18n/en.js @@ -194,7 +194,8 @@ Can be 'anonymous', 'authenticated' or 'admin'.`, sites: { createSite: 'Define a new site', loginOnSite: 'Sign in on the site', - colorWarnings: 'Contrast warnings' + colorWarnings: 'Contrast warnings', + mainSite: 'Main site', }, passwordLists: { help1: 'You can upload password lists from CSV files. Those too well known passwords will then be rejected if users try to use them.', @@ -204,7 +205,8 @@ Can be 'anonymous', 'authenticated' or 'admin'.`, confirmDelete: 'Delete this password list?' }, site: { - title: 'Site configuration' + title: 'Site configuration', + mainSiteExplanation: 'This site matches this service\'s main domain. Its database document drives presentation only, and only for the categories listed in MAIN_SITE_FROM_DB. Authentication, identity providers and account scoping always come from environment variables.' } }, contact: { @@ -559,5 +561,11 @@ Feel free to contact us at {contact}. acceptedPartnerInvitation: 'The organization {partnerName} ({email}) has joined the organization {orgName} as a partner.', addMemberTopic: 'a member has been added', addMember: 'The user {name} ({email}) has joined the organization {orgName}.' + }, + // server-side only (not in publicMessages): reported through + // mainSiteWarnings on the sites API and by the boot check + mainSite: { + ignoredField: 'The "{field}" field is ignored on the main site: this configuration comes from environment variables.', + ignoredCategory: 'The stored value for "{category}" is ignored: MAIN_SITE_FROM_DB does not contain this category.' } } diff --git a/api/i18n/fr.js b/api/i18n/fr.js index d9280425..1bb8876c 100644 --- a/api/i18n/fr.js +++ b/api/i18n/fr.js @@ -195,6 +195,7 @@ Peut valoir 'anonymous', 'authenticated' ou 'admin'.`, createSite: 'Déclarer un nouveau site', loginOnSite: 'Se connecter sur le site', colorWarnings: 'Avertissements de contraste', + mainSite: 'Site principal', }, passwordLists: { help1: 'Vous pouvez charger des listes de mots de passe à partir de fichiers CSV. Ces mots de passe trop connus seront alors rejetés si des utilisateurs tentent de les utiliser.', @@ -204,7 +205,8 @@ Peut valoir 'anonymous', 'authenticated' ou 'admin'.`, confirmDelete: 'Supprimer cette liste de mots de passe ?' }, site: { - title: 'Configuration du site' + title: 'Configuration du site', + mainSiteExplanation: 'Ce site correspond au domaine principal de ce service. Son document en base de données ne pilote que la présentation, et seulement pour les catégories listées dans MAIN_SITE_FROM_DB. L\'authentification, les fournisseurs d\'identité et le périmètre des comptes proviennent toujours des variables d\'environnement.' } }, contact: { @@ -559,5 +561,11 @@ N'hésitez pas à nous contacter à {contact}. acceptedPartnerInvitation: 'L\'organisation {partnerName} ({email}) a rejoint l\'organisation {orgName} en tant que partenaire.', addMemberTopic: 'un membre a été ajouté', addMember: 'L\'utilisateur {name} ({email}) a rejoint l\'organisation {orgName}.' + }, + // server-side only (not in publicMessages): reported through + // mainSiteWarnings on the sites API and by the boot check + mainSite: { + ignoredField: 'Le champ "{field}" est ignoré sur le site principal : cette configuration provient des variables d\'environnement.', + ignoredCategory: 'La valeur enregistrée pour "{category}" est ignorée : MAIN_SITE_FROM_DB ne contient pas cette catégorie.' } } diff --git a/api/src/app.ts b/api/src/app.ts index ab732ca6..cdee3ceb 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -22,18 +22,7 @@ import tokens from './tokens/router.ts' import sites from './sites/router.ts' import accounts from './accounts/router.ts' import passwordLists from './password-lists/router.ts' -import { getSiteByUrl } from '#services' -import { defaultThemeCssHash, getThemeCssHash } from './utils/theme.ts' -import { defaultPublicSiteInfoHash, getPublicSiteInfoHash } from './utils/public-site-info.ts' - -// the site title is injected as text into the served HTML, it must not be able to break out of its tag. -// it must also survive the micro-template passes that follow: '{' is neutralized so a title cannot -// smuggle a later placeholder (CSP_NONCE is substituted after us), and '$' is doubled because -// microTemplate interpolates through String.replace, where $&, $` and $' are replacement patterns. -const escapeHtml = (value: string) => value - .replace(/&/g, '&').replace(//g, '>') - .replace(/\{/g, '{') - .replace(/\$/g, '$$$$') +import { getSiteExtraParams } from './sites/spa-params.ts' const app = express() export default app @@ -122,16 +111,7 @@ app.use(tokens) if (process.env.NODE_ENV !== 'test') { app.use(await createSpaMiddleware(resolve(import.meta.dirname, '../../ui/dist'), uiConfig, { csp: { nonce: true, header: true }, - getSiteExtraParams: async (siteUrl: string) => { - const site = await getSiteByUrl(siteUrl) - return { - THEME_CSS_HASH: site ? getThemeCssHash(site) : defaultThemeCssHash, - PUBLIC_SITE_INFO_HASH: site ? getPublicSiteInfoHash(site) : defaultPublicSiteInfoHash, - // the SPA sets the definitive title, this one fills the of the served - // document, which the W3C validator requires (RGAA 8.2) - SITE_TITLE: escapeHtml(site?.title || 'Simple Directory') - } - } + getSiteExtraParams })) } diff --git a/api/src/mails/service.ts b/api/src/mails/service.ts index a190f8dd..fa06ced7 100644 --- a/api/src/mails/service.ts +++ b/api/src/mails/service.ts @@ -6,7 +6,8 @@ import config from '#config' import { flatten } from 'flat' import EventEmitter from 'node:events' import mailsTransport from './transport.ts' -import { getSiteByUrl, getSiteByHost } from '#services' +import { getSiteByUrl, getSiteByHost, isMainSiteDoc } from '#services' +import { getEffectiveMainSite } from '../sites/main-site.ts' import { internalError } from '@data-fair/lib-node/observer.js' import { mailLimiter } from '../utils/limiter.ts' @@ -108,19 +109,34 @@ export const sendMail = async (to: string, params: SendMailParams, attachments?: if (!site && params.host) { site = await getSiteByHost(params.host, params.path ?? '') } + // on the main host the document is only honoured through the category list, + // never read raw: this path used to bypass reqSite() entirely + const mainSite = !!site && isMainSiteDoc(site) + 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 from = config.mails.from let contact = config.contact + // the main site keeps the main template in both cases — it *is* the main + // site, only its colours and sender can change let template = params.htmlButton ? mainSiteTemplate : mainSiteNoButtonTemplate - if (site?.mails?.from) { - from = site.mails.from - Object.assign(flatTheme, flatten({ theme: site.theme })) - logo = site.theme.logo || logo - template = params.htmlButton ? genericTemplate : genericNoButtonTemplate + + if (mainSite) { + const effectiveSite = await getEffectiveMainSite() + Object.assign(flatTheme, flatten({ theme: effectiveSite.theme })) + logo = effectiveSite.theme.logo || logo + from = effectiveSite.mails?.from ?? from + contact = effectiveSite.mails?.contact ?? contact + } else { + if (site?.mails?.from) { + from = site.mails.from + Object.assign(flatTheme, flatten({ theme: site.theme })) + logo = site.theme.logo || logo + template = params.htmlButton ? genericTemplate : genericNoButtonTemplate + } + if (site?.mails?.contact) contact = site.mails.contact } - if (site?.mails?.contact) contact = site.mails.contact const tmplParams: SendMailTmplParams = { ...params, diff --git a/api/src/server.ts b/api/src/server.ts index c969343c..d9320455 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -1,7 +1,7 @@ import { resolve } from 'node:path' import { createServer } from 'node:http' import { session } from '@data-fair/lib-express/index.js' -import { startObserver, stopObserver } from '@data-fair/lib-node/observer.js' +import { startObserver, stopObserver, internalError } from '@data-fair/lib-node/observer.js' import locks from '@data-fair/lib-node/locks.js' import upgradeScripts from '@data-fair/lib-node/upgrade-scripts.js' import mongo from '#mongo' @@ -18,6 +18,8 @@ import config from '#config' import * as eventsQueue from '#events-queue' import { publicGlobalProviders } from './auth/providers.ts' import { getSiteColorsWarnings } from '@data-fair/lib-common-types/theme/index.js' +import { getMainSiteDoc } from './sites/service.ts' +import { getMainSiteWarnings, getMainSiteIgnoredFields } from './sites/main-site.ts' const server = createServer(app) const httpTerminator = createHttpTerminator({ server }) @@ -56,6 +58,16 @@ export const start = async () => { for (const cw of colorWarnings) console.error(' - ' + cw) } + const mainSiteDoc = await getMainSiteDoc() + if (mainSiteDoc) { + console.log(`Main site document ${mainSiteDoc._id} found on ${mainSiteDoc.host}${mainSiteDoc.path ?? ''}; categories read from the database: ${config.mainSiteFromDb.join(', ') || '(none)'}`) + for (const w of getMainSiteWarnings(config.i18n.defaultLocale, mainSiteDoc)) console.warn(' - ' + w) + const ignoredFields = getMainSiteIgnoredFields(mainSiteDoc) + if (ignoredFields.length) { + internalError('main-site-ignored-fields', `main site document ${mainSiteDoc._id} carries fields that are never honoured on the main host: ${ignoredFields.join(', ')}`) + } + } + server.listen(config.port) await new Promise(resolve => server.once('listening', resolve)) diff --git a/api/src/services.ts b/api/src/services.ts index e0ccffd7..5bc25d7b 100644 --- a/api/src/services.ts +++ b/api/src/services.ts @@ -7,7 +7,7 @@ export * from './mails/service.ts' export { initOidcProvider, oauthGlobalProviders, getOidcProviderId, getOAuthProviderById, getOAuthProviderByState } from './oauth/service.ts' export * from './oauth-tokens/service.ts' export { saml2ServiceProvider, saml2GlobalProviders, getSamlProviderId, getSamlConfigId, getSamlProviderById } from './saml2/service.ts' -export { reqSite, getSiteByUrl, getRedirectSite, getSiteBaseUrl, getSiteByHost, reqAccountMainSite, resolveAccountMainSite } from './sites/service.ts' +export { reqSite, getSiteByUrl, getRedirectSite, getSiteBaseUrl, getSiteByHost, reqAccountMainSite, resolveAccountMainSite, isMainSiteDoc } from './sites/service.ts' export * from './tokens/service.ts' export * from './utils/passwords.ts' export * from './utils/partners.ts' diff --git a/api/src/sites/main-site.ts b/api/src/sites/main-site.ts new file mode 100644 index 00000000..f4139500 --- /dev/null +++ b/api/src/sites/main-site.ts @@ -0,0 +1,104 @@ +import config from '#config' +import { type Site } from '#types' +import { getMessage } from '#i18n' +import { getMainSiteDoc } from './service.ts' +import { type EffectiveSite, envMainSite, clearPublicSiteInfoHashCache } from '../utils/public-site-info.ts' +import { clearThemeCssHashCache } from '../utils/theme.ts' + +type MainSiteCategory = 'theme' | 'title' | 'mails' | 'registration' + +const mainSiteCategories: MainSiteCategory[] = ['theme', 'title', 'mails', 'registration'] + +// Fields of a site document that are never honoured on the main host, whatever +// config.mainSiteFromDb contains. They are inert here, not refused: the API +// blocks no write (see docs/architecture/main-site-config.md for why a write +// barrier was rejected). +const mainSiteIgnoredFields = ['authMode', 'authOnlyOtherSite', 'authProviders', 'applications', 'isAccountMain'] as const + +const mainSiteCategoryFields: Record<MainSiteCategory, (keyof Site)[]> = { + theme: ['theme'], + title: ['title'], + mails: ['mails'], + registration: ['tosMessage', 'reducedPersonalInfoAtCreation'] +} + +// read config at call time, not at module load: tests mutate it through +// PATCH /api/test-env/config +const enabled = (category: MainSiteCategory) => config.mainSiteFromDb.includes(category) + +/** + * The main site as it should be served: env config, with the presentation + * fields of its document overlaid for each enabled category. + * + * The result is an ordinary EffectiveSite, so every consumer runs it through + * the same getPublicSiteInfo / getThemeCss / hash functions as a real site — + * there is no parallel rendering path for the main host. + */ +export const getEffectiveMainSite = async (): Promise<EffectiveSite> => { + const site = envMainSite() + const doc = await getMainSiteDoc() + if (!doc) return site + + const used: MainSiteCategory[] = [] + if (enabled('theme') && doc.theme) { + site.theme = doc.theme + // unlike an ordinary site the owner avatar is the last resort, not the + // first: the main site's identity is the operator's, not the owner org's + if (!site.theme.logo && !config.theme.logo) { + site.theme = { ...site.theme, logo: `/simple-directory/api/avatars/${doc.owner.type}/${doc.owner.id}/avatar.png` } + } + used.push('theme') + } + if (enabled('title') && doc.title) { + site.title = doc.title + used.push('title') + } + if (enabled('mails') && doc.mails) { + site.mails = { + from: doc.mails.from ?? site.mails?.from, + contact: doc.mails.contact ?? site.mails?.contact + } + used.push('mails') + } + if (enabled('registration') && (doc.tosMessage !== undefined || doc.reducedPersonalInfoAtCreation !== undefined)) { + site.tosMessage = doc.tosMessage + site.reducedPersonalInfoAtCreation = doc.reducedPersonalInfoAtCreation + used.push('registration') + } + + // the shared hash caches key on _id + updatedAt; fold the contributing + // categories into the id so the key also changes when the category list does + if (used.length) { + site._id = `_main-${doc._id}-${used.join(',')}` + site.updatedAt = doc.updatedAt + } + return site +} + +// Only needed by tests, which flip config.mainSiteFromDb at runtime — the +// shared hash caches key on _id + updatedAt and cannot see that change. +export const clearSiteResourceCaches = () => { + clearPublicSiteInfoHashCache() + clearThemeCssHashCache() +} + +// Fields the document carries that have no effect on the main host. +export const getMainSiteIgnoredFields = (site: Site): string[] => + mainSiteIgnoredFields.filter(field => site[field] !== undefined) + +// Human readable report for the admin UI and the boot check. Nothing here +// blocks a write: the admin form round-trips the whole document, so a write +// barrier would reject an idempotent save. See +// docs/architecture/main-site-config.md +export const getMainSiteWarnings = (localeCode: string, site: Site): string[] => { + const warnings: string[] = [] + for (const field of getMainSiteIgnoredFields(site)) { + warnings.push(getMessage(localeCode, 'mainSite.ignoredField', { field })) + } + for (const category of mainSiteCategories) { + if (config.mainSiteFromDb.includes(category)) continue + if (!mainSiteCategoryFields[category].some(field => site[field] !== undefined)) continue + warnings.push(getMessage(localeCode, 'mainSite.ignoredCategory', { category })) + } + return warnings +} diff --git a/api/src/sites/router.ts b/api/src/sites/router.ts index 848470aa..173585df 100644 --- a/api/src/sites/router.ts +++ b/api/src/sites/router.ts @@ -4,8 +4,8 @@ import config from '#config' import { reqUser, reqUserAuthenticated, reqSiteUrl, httpError, reqSessionAuthenticated, type AccountKeys } from '@data-fair/lib-express' import { nanoid } from 'nanoid' import { findAllSites, findOwnerSites, patchSite, deleteSite, getSite, toggleMainSite, findMainSite } from './service.ts' -import { getThemeCss, getThemeCssHash, defaultThemeCssHash, defaultThemeCss } from '../utils/theme.ts' -import { isOIDCProvider, reqSite } from '#services' +import { getThemeCss, getThemeCssHash } from '../utils/theme.ts' +import { isOIDCProvider, reqSite, isMainSiteDoc } from '#services' import { reqI18n } from '#i18n' import { getOidcProviderId } from '../oauth/oidc.ts' import { getSiteColorsWarnings, fillTheme } from '@data-fair/lib-common-types/theme/index.js' @@ -13,7 +13,8 @@ import clone from '@data-fair/lib-utils/clone.js' import Debug from 'debug' import { cipher } from '../utils/cipher.ts' import { type OpenIDConnect } from '#types/site/index.ts' -import { defaultPublicSiteInfo, defaultPublicSiteInfoHash, getPublicSiteInfo, getPublicSiteInfoHash } from '../utils/public-site-info.ts' +import { getPublicSiteInfo, getPublicSiteInfoHash } from '../utils/public-site-info.ts' +import { getEffectiveMainSite, getMainSiteWarnings } from './main-site.ts' import serialize from 'serialize-javascript' const debugPostSite = Debug('post-site') @@ -37,9 +38,10 @@ const prepareFullSite = (req: Request, site: Site) => { } } } - const resultWithColorWarnings: any = site as any + const resultWithWarnings: any = site as any const { localeCode } = reqI18n(req) - resultWithColorWarnings.colorWarnings = getSiteColorsWarnings(localeCode as 'fr' | 'en', site.theme, site.authProviders as { title?: string, color?: string }[]) + resultWithWarnings.colorWarnings = getSiteColorsWarnings(localeCode as 'fr' | 'en', site.theme, site.authProviders as { title?: string, color?: string }[]) + resultWithWarnings.mainSiteWarnings = isMainSiteDoc(site) ? getMainSiteWarnings(localeCode, site) : [] } router.get('', async (req, res, next) => { @@ -191,7 +193,10 @@ router.patch('/:id', async (req, res, next) => { const patchedSite = await patchSite({ _id: req.params.id, updatedAt: new Date().toISOString(), ...patch }) - if (patch.isAccountMain) { + // isAccountMain is inert on the main site document (it already *is* the main + // site), and the admin form round-trips the whole document, so firing + // toggleMainSite here would rewrite the owner's other sites on every save + if (patch.isAccountMain && !isMainSiteDoc(patchedSite)) { // toggle the main site await toggleMainSite(patchedSite) } @@ -211,16 +216,15 @@ router.get('/_public', async (req, res, next) => { res.setHeader('Cache-Control', 'public, max-age=60') // force buffering (necessary for caching) of this response in the reverse proxy res.setHeader('X-Accel-Buffering', 'yes') - const site = await reqSite(req) - const publicSiteInfo = site ? await getPublicSiteInfo(site) : defaultPublicSiteInfo - res.send(publicSiteInfo) + const site = await reqSite(req) ?? await getEffectiveMainSite() + res.send(getPublicSiteInfo(site)) }) router.get('/_public.js', async (req, res, next) => { res.setHeader('Cache-Control', 'public, max-age=60') // force buffering (necessary for caching) of this response in the reverse proxy res.setHeader('X-Accel-Buffering', 'yes') - const site = await reqSite(req) - const publicSiteInfo = site ? await getPublicSiteInfo(site) : defaultPublicSiteInfo + const site = await reqSite(req) ?? await getEffectiveMainSite() + const publicSiteInfo = getPublicSiteInfo(site) res.contentType('application/javascript') res.send(`window.__PUBLIC_SITE_INFO=${serialize(publicSiteInfo)}`) }) @@ -228,23 +232,23 @@ router.get('/:hash/_public.js', async (req, res, next) => { res.setHeader('Cache-Control', `public, max-age=${hashedMaxAge}, immutable`) // force buffering (necessary for caching) of this response in the reverse proxy res.setHeader('X-Accel-Buffering', 'yes') - const site = await reqSite(req) - const publicSiteInfo = site ? await getPublicSiteInfo(site) : defaultPublicSiteInfo + const site = await reqSite(req) ?? await getEffectiveMainSite() + const publicSiteInfo = getPublicSiteInfo(site) // TODO: fail if hash doesn't match ? res.contentType('application/javascript') res.send(`window.__PUBLIC_SITE_INFO=${serialize(publicSiteInfo)}`) }) router.get('/_default_theme', async (req, res, next) => { - res.send(config.theme) + res.send((await getEffectiveMainSite()).theme) }) router.get('/_theme.css', async (req, res, next) => { res.setHeader('Cache-Control', 'public, max-age=60') // force buffering (necessary for caching) of this response in the reverse proxy res.setHeader('X-Accel-Buffering', 'yes') - const site = await reqSite(req) - const css = site ? getThemeCss(site.theme, site.path ?? '') : defaultThemeCss + const site = await reqSite(req) ?? await getEffectiveMainSite() + const css = getThemeCss(site.theme, site.path ?? '') res.contentType('css') res.send(css) }) @@ -252,25 +256,25 @@ router.get('/:hash/_theme.css', async (req, res, next) => { res.setHeader('Cache-Control', `public, max-age=${hashedMaxAge}, immutable`) // force buffering (necessary for caching) of this response in the reverse proxy res.setHeader('X-Accel-Buffering', 'yes') - const site = await reqSite(req) + const site = await reqSite(req) ?? await getEffectiveMainSite() // TODO: fail if hash doesn't match ? - const css = site ? getThemeCss(site.theme, site.path ?? '') : defaultThemeCss + const css = getThemeCss(site.theme, site.path ?? '') res.contentType('css') res.send(css) }) router.get('/_hashes', async (req, res, next) => { - const site = await reqSite(req) + const site = await reqSite(req) ?? await getEffectiveMainSite() res.send({ - publicInfo: site ? getPublicSiteInfoHash(site) : defaultPublicSiteInfoHash, - themeCss: site ? getThemeCssHash(site) : defaultThemeCssHash, - preloadLinks: site?.theme.preloadLinks ?? config.theme.preloadLinks ?? [] + publicInfo: getPublicSiteInfoHash(site), + themeCss: getThemeCssHash(site), + preloadLinks: site.theme.preloadLinks ?? config.theme.preloadLinks ?? [] }) }) router.get('/:id/_theme_warnings', async (req, res, next) => { - const site = await reqSite(req) + const site = await reqSite(req) ?? await getEffectiveMainSite() const { localeCode } = reqI18n(req) - res.send(getSiteColorsWarnings(localeCode as 'fr' | 'en', site?.theme ?? config.theme, site?.authProviders as { title?: string, color?: string }[])) + res.send(getSiteColorsWarnings(localeCode as 'fr' | 'en', site.theme, site.authProviders as { title?: string, color?: string }[])) }) router.get('/:id', async (req, res, next) => { diff --git a/api/src/sites/service.ts b/api/src/sites/service.ts index bcd8aa96..934f8fa4 100644 --- a/api/src/sites/service.ts +++ b/api/src/sites/service.ts @@ -27,6 +27,24 @@ export const getSiteBaseUrl = (site: Site) => { return `${publicUrl.protocol}//${site.host}${site.path ?? ''}` } +// The "main site document" is a site doc whose (host, path) matches publicUrl. +// It is honoured for presentation only — reqSite() below still returns +// undefined on that host, so identity and trust rules are untouched. +// See docs/architecture/main-site-config.md +export const isMainSiteUrl = (siteUrl: string) => config.publicUrl.startsWith(siteUrl) + +export const isMainSiteDoc = (site: Pick<Site, 'host' | 'path'>) => + isMainSiteUrl(`${publicUrl.protocol}//${site.host}${site.path ?? ''}`) + +export const getMainSiteDoc = memoize(async (): Promise<Site | undefined> => { + if (!config.manageSites) return undefined + const sites = await mongo.sites.find({ host: publicUrl.host }).toArray() + return sites.find(isMainSiteDoc) +}, { + promise: true, + maxAge: 2000 // 2s, same as getSiteByHost +}) + export const getRedirectSite = async (req: Request, redirect: string) => { const currentSiteUrl = reqSiteUrl(req) const currentSite = await reqSite(req) diff --git a/api/src/sites/spa-params.ts b/api/src/sites/spa-params.ts new file mode 100644 index 00000000..2510db0c --- /dev/null +++ b/api/src/sites/spa-params.ts @@ -0,0 +1,34 @@ +import { getSiteByUrl, isMainSiteUrl } from './service.ts' +import { getThemeCssHash } from '../utils/theme.ts' +import { getPublicSiteInfoHash } from '../utils/public-site-info.ts' +import { getEffectiveMainSite } from './main-site.ts' + +// the site title is injected as text into the served HTML, it must not be able to break out of its tag. +// it must also survive the micro-template passes that follow: '{' is neutralized so a title cannot +// smuggle a later placeholder (CSP_NONCE is substituted after us), and '$' is doubled because +// microTemplate interpolates through String.replace, where $&, $` and $' are replacement patterns. +const escapeHtml = (value: string) => value + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/\{/g, '{') + .replace(/\$/g, '$$$$') + +/** + * Values injected into the served index.html for a given site URL. + * + * This mirrors reqSite(): on the main host the document is never read raw, it + * goes through getEffectiveMainSite like everywhere else. Reading it raw here + * while the /api/sites/_* endpoints read env is what made the immutable + * theme-css cache key describe bytes it was not serving — the hash came from + * the document, the CSS came from the environment, and the mismatch was cached + * for a year. + */ +export const getSiteExtraParams = async (siteUrl: string) => { + const site = (isMainSiteUrl(siteUrl) ? undefined : await getSiteByUrl(siteUrl)) ?? await getEffectiveMainSite() + return { + THEME_CSS_HASH: getThemeCssHash(site), + PUBLIC_SITE_INFO_HASH: getPublicSiteInfoHash(site), + // the SPA sets the definitive title, this one fills the <title> of the + // served document, which the W3C validator requires (RGAA 8.2) + SITE_TITLE: escapeHtml(site.title || 'Simple Directory') + } +} diff --git a/api/src/test-env.ts b/api/src/test-env.ts index a4c092a3..dfcf74e2 100644 --- a/api/src/test-env.ts +++ b/api/src/test-env.ts @@ -53,6 +53,15 @@ router.delete('/', async (req, res) => { await mongo.passwordLists.deleteMany() await mongo.db.collection('sd-rate-limiter-auth').deleteMany() await mongo.db.collection('sd-rate-limiter-contact').deleteMany() + // the per-recipient mail budget is the one limiter whose window outlives a + // test run: the dev server runs with NODE_ENV=development, so it uses the + // production default of 500 mails per recipient per DAY rather than the tiny + // test.cjs window. Shared fixture addresses (admin@test.com, user@test.com) + // burn that budget a few mails per run and eventually exhaust it, which + // surfaces as an unrelated `waitForMail timeout` somewhere else entirely. + // mails-rate-limit.api.spec.ts is unaffected: it pre-fills its own bucket + // inside each test, with a unique recipient, after this cleanup has run. + await mongo.db.collection('sd-rate-limiter-mail').deleteMany() const { getSiteByHost } = await import('./sites/service.ts') getSiteByHost.clear() // Force a fresh SAML cert mint on the next request — exercises createCert end-to-end @@ -148,10 +157,14 @@ router.post('/run-user-cleanup', async (req, res) => { res.status(200).send('ok') }) -// POST /api/test-env/clear-site-cache — clear the getSiteByHost memoized cache +// POST /api/test-env/clear-site-cache — clear the memoized site lookups and +// the derived main-site resources router.post('/clear-site-cache', async (req, res) => { - const { getSiteByHost } = await import('./sites/service.ts') + const { getSiteByHost, getMainSiteDoc } = await import('./sites/service.ts') + const { clearSiteResourceCaches } = await import('./sites/main-site.ts') getSiteByHost.clear() + getMainSiteDoc.clear() + clearSiteResourceCaches() res.status(200).send('ok') }) diff --git a/api/src/utils/public-site-info.ts b/api/src/utils/public-site-info.ts index 0960dd36..5c0c9e3f 100644 --- a/api/src/utils/public-site-info.ts +++ b/api/src/utils/public-site-info.ts @@ -24,12 +24,43 @@ const lighterTheme = (fullTheme: Theme) => { return theme } +/** + * A site as it is served. Either a real document, or the synthetic main site + * built by sites/main-site.ts from env config overlaid with its document. + * + * The main site has no owner (its identity is the operator's, not an + * organization's), so `owner` is optional here where the stored Site requires + * it. Everything downstream treats both the same way. + */ +export type EffectiveSite = Omit<Site, 'owner'> & { owner?: Site['owner'], main?: true } + const publicHost = new URL(config.publicUrl).host -export const getPublicSiteInfo = (site: Site): SitePublic => { + +// The env-only main site. Kept free of any mongo access: ui/vite.config.ts +// imports defaultPublicSiteInfoHash below to inject the dev server's HTML. +// `path` is deliberately left undefined so the generated theme css keeps the +// empty SITE_PATH it has always used, even on a prefixed publicUrl. +export const envMainSite = (): EffectiveSite => ({ + _id: '_main', + main: true, + host: publicHost, + theme: config.theme, + mails: { from: config.mails.from, contact: config.contact }, + isAccountMain: true, + authMode: 'onlyLocal' +}) + +export const getPublicSiteInfo = (site: EffectiveSite): SitePublic => { const authMode = site.authMode ?? 'onlyBackOffice' let authOnlyOtherSite = site.authOnlyOtherSite if (authMode === 'onlyBackOffice') authOnlyOtherSite = publicHost + // an ordinary site falls back to its owner's avatar; the main site arrives + // with its logo already resolved and has no owner to fall back to + const logo = site.theme.logo || (site.owner && `/simple-directory/api/avatars/${site.owner.type}/${site.owner.id}/avatar.png`) return { + // only emitted for the main site, so an ordinary site's payload — and + // therefore its hash — is byte for byte what it was before + ...(site.main ? { main: true } : {}), host: site.host, path: site.path, tmp: site.tmp, @@ -37,28 +68,27 @@ export const getPublicSiteInfo = (site: Site): SitePublic => { title: site.title, isAccountMain: site.isAccountMain, tosMessage: site.tosMessage, + reducedPersonalInfoAtCreation: site.reducedPersonalInfoAtCreation, theme: { ...lighterTheme(site.theme ?? config.theme), - logo: site.theme.logo || `/simple-directory/api/avatars/${site.owner.type}/${site.owner.id}/avatar.png` + ...(logo ? { logo } : {}) }, authMode, authOnlyOtherSite - } + } as SitePublic } const publicSiteInfoHashCache: Record<string, string> = {} -export const getPublicSiteInfoHash = (site: Site) => { +export const getPublicSiteInfoHash = (site: EffectiveSite) => { const publicInfo = getPublicSiteInfo(site) const cacheKey = site?._id + '-' + site?.updatedAt publicSiteInfoHashCache[cacheKey] = publicSiteInfoHashCache[cacheKey] ?? crypto.createHash('md5').update(serialize(publicInfo)).digest('hex') return publicSiteInfoHashCache[cacheKey] } -export const defaultPublicSiteInfo = { - main: true, - host: publicHost, - theme: lighterTheme(config.theme), - isAccountMain: true, - authMode: 'onlyLocal', +export const clearPublicSiteInfoHashCache = () => { + for (const key of Object.keys(publicSiteInfoHashCache)) delete publicSiteInfoHashCache[key] } + +export const defaultPublicSiteInfo = getPublicSiteInfo(envMainSite()) export const defaultPublicSiteInfoHash = crypto.createHash('md5').update(serialize(defaultPublicSiteInfo)).digest('hex') diff --git a/api/src/utils/theme.ts b/api/src/utils/theme.ts index 48ff2a63..f4dbdcff 100644 --- a/api/src/utils/theme.ts +++ b/api/src/utils/theme.ts @@ -1,6 +1,6 @@ import config from '../config.ts' import crypto from 'node:crypto' -import { type Site } from '../../types/index.ts' +import { type EffectiveSite } from './public-site-info.ts' import microTemplate from '@data-fair/lib-utils/micro-template.js' import { getTextColorsCss, type Theme } from '@data-fair/lib-common-types/theme/index.js' @@ -29,11 +29,15 @@ export const getThemeCss = (theme: Theme, sitePath: string = '') => { } const themeCssHashCache: Record<string, string> = {} -export const getThemeCssHash = (site: Site) => { +export const getThemeCssHash = (site: EffectiveSite) => { const cacheKey = site._id + '-' + site.updatedAt themeCssHashCache[cacheKey] = themeCssHashCache[cacheKey] ?? crypto.createHash('md5').update(getThemeCss(site.theme, site.path)).digest('hex') return themeCssHashCache[cacheKey] } +export const clearThemeCssHashCache = () => { + for (const key of Object.keys(themeCssHashCache)) delete themeCssHashCache[key] +} + export const defaultThemeCss = getThemeCss(config.theme) export const defaultThemeCssHash = crypto.createHash('md5').update(defaultThemeCss).digest('hex') diff --git a/api/types/site/schema.js b/api/types/site/schema.js index ec0dc3da..b4450e9a 100644 --- a/api/types/site/schema.js +++ b/api/types/site/schema.js @@ -111,6 +111,10 @@ export default { }, isAccountMain: { type: 'boolean', + // inert on the main site document (it already *is* the main site), and + // the admin form round-trips the whole document, so offering it there + // would invite a save that rewrites the owner's other sites + layout: { if: '!context.isMainSite' }, title: 'Site principal du compte', 'x-i18n-title': { fr: 'Site principal du compte', diff --git a/dev/fixtures.ts b/dev/fixtures.ts index 643609a6..d9de3e92 100644 --- a/dev/fixtures.ts +++ b/dev/fixtures.ts @@ -32,6 +32,7 @@ import { axios, axiosAuth, waitForMail, testEnvAx, getServerConfig } from '../te const EMAIL_DOMAIN = 'dev-fixtures.org' const PASSWORD = 'TestPasswd01' const SITE_ID = 'dev-fixtures-portal' +const MAIN_SITE_ID = 'dev-fixtures-main' const CORP_NAME = 'Dev Fixtures Corp' const PARTNER_NAME = 'Dev Fixtures Partner' const MEMBERS_LIMIT = 10 @@ -270,6 +271,36 @@ const main = async () => { console.log(` ~ site ${SITE_ID} on http://${siteHost}/simple-directory (ssoBackOffice)`) console.log(' note: a test run wipes the sites collection, re-run this script to get it back') + // the main site document: a site on the publicUrl host. It drives + // presentation only, and only for the categories in MAIN_SITE_FROM_DB — + // which is empty by default, so nothing visibly changes until you set + // MAIN_SITE_FROM_DB='["theme","title","mails","registration"]' in .env. + // See docs/architecture/main-site-config.md + const mainSiteHost = new URL(config.publicUrl).host + const mainSquatter = allSites.results.find((s: any) => s.host === mainSiteHost && s._id !== MAIN_SITE_ID) + if (mainSquatter) { + if (!mainSquatter._id.startsWith('test_')) { + throw new Error(`site ${mainSquatter._id} already uses host ${mainSiteHost}, refusing to touch it — delete it or free the host first`) + } + await anonymousAx.delete(`/api/sites/${mainSquatter._id}`, { params: { key: config.secretKeys.sites } }) + console.log(` - removed leftover test site ${mainSquatter._id} from ${mainSiteHost}`) + } + await anonymousAx.post('/api/sites', { + _id: MAIN_SITE_ID, + owner: { type: 'organization', id: corp.id, name: corp.name }, + host: mainSiteHost, + title: 'Annuaire Dev Fixtures', + theme: { primaryColor: '#6A1B9A' } + }, { params: { key: config.secretKeys.sites } }) + await superAdminAx.patch(`/api/sites/${MAIN_SITE_ID}`, { + mails: { contact: 'contact-main@fixtures.dev' }, + tosMessage: 'CGU du site principal (fixtures)' + }) + await testEnvAx.post('/clear-site-cache') + console.log(` ~ main site document ${MAIN_SITE_ID} on ${config.publicUrl}`) + console.log(` MAIN_SITE_FROM_DB is currently ${process.env.MAIN_SITE_FROM_DB ?? '[]'}; set it in .env to see it take effect`) + console.log(' note: a test run wipes the sites collection, re-run this script to get it back') + console.log(`\n✔ Fixtures applied. Log in at ${config.publicUrl}/login with ${email('owner')} / ${PASSWORD}`) } diff --git a/docs/architecture/main-site-config.md b/docs/architecture/main-site-config.md new file mode 100644 index 00000000..217aa044 --- /dev/null +++ b/docs/architecture/main-site-config.md @@ -0,0 +1,195 @@ +# Main site configuration: database document vs environment + +What the main site reads from a database document and what it reads from +environment variables, and why the API reports rather than refuses. Required +reading before changing `api/src/sites/main-site.ts`, the `/api/sites/_*` +presentation endpoints, `getSiteExtraParams` in `api/src/sites/spa-params.ts`, +or the mail theming path in `api/src/mails/service.ts`. + +See also [`email-trust-and-site-isolation.md`](email-trust-and-site-isolation.md) +for why the main site is a load-bearing security concept. + +## The problem this solves + +An install whose `publicUrl` host *also* carries a site document in mongo had +two writable sources of configuration for the same host, and three resolution +paths disagreeing about which one wins. + +**Path A — `reqSite(req)`** (`api/src/sites/service.ts`) is authoritative for +nearly everything: + +```ts +if (siteUrl && !config.publicUrl.startsWith(siteUrl) && siteUrl !== `http://simple-directory:${config.port}`) { + … look the site up in mongo … +} +// else: returns undefined +``` + +On the `publicUrl` host it returns `undefined` and the document is never even +looked up, so every consumer falls back to `config.*`. + +**Path B — `getSiteByUrl` / `getSiteByHost`** is a raw mongo lookup with no +`publicUrl` exclusion. Two callers used it instead of `reqSite`: the SPA HTML +injection and the mail templating. + +**Path C — `config.*`**, the environment. + +With no document on the `publicUrl` host all three agree and the problem is +invisible. With one, A and B disagreed *within the same request*. + +That produced a live bug: `getSiteExtraParams` read the document for +`THEME_CSS_HASH` while `GET /api/sites/:hash/_theme.css` called `reqSite` and +served the **env** CSS — under `Cache-Control: max-age=31536000, immutable`. The +hash did not describe the bytes served under it, so an env theme change never +busted the cache and a change to the ignored document busted it for nothing. + +## The main site document + +A **main site document** is a site doc whose `host` + `path` matches +`config.publicUrl` (`isMainSiteDoc` in `api/src/sites/service.ts`). It is +honoured for **presentation only**. + +`reqSite()` still returns `undefined` on that host. This is deliberate and +load-bearing: `reqSite() === undefined` *is* the definition of "main site" +across the trust model. + +- `isAdmin = !user.host` — storage rule, invariant #1 +- `getUserByEmail(email, undefined)` filters `host: {$exists: false}` +- `adminMode` is refused on any session where `reqSite()` returns a site — + invariant #2 + +Making `reqSite` return the document would scope every main-host account to +that host: existing unscoped users become unreachable and no session can obtain +`adminMode`. Nothing under `api/src/auth/`, `api/src/tokens/` or +`api/src/storages/` participates in this feature. + +When `config.manageSites` is false there is never a main site document. + +## `MAIN_SITE_FROM_DB` + +`config.mainSiteFromDb` lists which categories come from the document. +Environment: `MAIN_SITE_FROM_DB='["theme","title","mails","registration"]'`. The +items are constrained by an `enum` in `api/config/type/schema.json`, so an +unknown category refuses to start. + +| Category | Site fields | Env fallback | +|---|---|---| +| `theme` | `theme` (colors, logo, fonts, `preloadLinks`) | `config.theme` | +| `title` | `title` | `'Simple Directory'` | +| `mails` | `mails.from`, `mails.contact` | `config.mails.from`, `config.contact` | +| `registration` | `tosMessage`, `reducedPersonalInfoAtCreation` | none | + +Fallback is per-category, not per-key: a document's `theme` is already +`fillTheme`'d against `config.theme` when written, so a stored theme is always +complete. + +Logo order on the main site is `doc.theme.logo` → `config.theme.logo` → the +document owner's avatar. Unlike an ordinary site the owner avatar is the last +resort, because the main site's identity is the operator's, not the owning +organisation's. + +**Never read from the document, whatever the list contains:** `authMode`, +`authOnlyOtherSite`, `authProviders`, `applications`, `isAccountMain`, `owner`, +and user scoping. `getPublicSiteInfo` for the main site forces `main: true`, +`isAccountMain: true` and `authMode: 'onlyLocal'`. + +The default is `[]` in 8.x, so no existing install changes behaviour on +upgrade. 9.0 will default to the full list. + +## One merge, no second rendering path + +`api/src/sites/main-site.ts` owns the merge and nothing else. Its only job is +`getEffectiveMainSite()`: take the env baseline and overlay the document's +presentation fields for each enabled category. What it returns is an ordinary +`EffectiveSite`, so every consumer renders it with the **same** functions it +uses for a real site — there is deliberately no main-site variant of +`getPublicSiteInfo`, `getThemeCss` or the hash caches: + +```ts +const site = await reqSite(req) ?? await getEffectiveMainSite() +res.send(getPublicSiteInfo(site)) +``` + +That shape holds in the `/api/sites/_*` endpoints +(`api/src/sites/router.ts`), in `getSiteExtraParams` +(`api/src/sites/spa-params.ts`) and in `api/src/mails/service.ts`. Because the +renderer is shared, paths A and B cannot disagree and the hashes cannot stop +describing the bytes served under them. + +`EffectiveSite` (`api/src/utils/public-site-info.ts`) is `Site` with an optional +`owner` and a `main?: true` flag — the main site has no owning organization, and +`main` is the one key `getPublicSiteInfo` emits conditionally so an ordinary +site's payload, and therefore its hash, is byte for byte what it was before this +change. + +Two details the merge handles so the shared renderer stays dumb: + +- **Logo precedence differs.** An ordinary site falls back to its owner's + avatar; the main site prefers `config.theme.logo` and uses the owner avatar + only as a last resort. The merge resolves the logo before returning, so + `getPublicSiteInfo` only ever reads `theme.logo`. +- **Cache keys.** The shared hash caches key on `_id + updatedAt`, which cannot + see a change to `mainSiteFromDb`. The merge folds the contributing categories + into the synthetic `_id` (`_main-<docId>-<categories>`). This only matters for + tests, which flip the config at runtime; `clearSiteResourceCaches()` backs + `POST /api/test-env/clear-site-cache`. + +`envMainSite()` and the `defaultPublicSiteInfo` / `defaultThemeCss` constants +stay free of any mongo access: `ui/vite.config.ts` imports the hashes to inject +the dev server's HTML. + +### Blast radius beyond simple-directory + +`GET /api/sites/_hashes` is what every other data-fair service calls +(`@data-fair/lib-express/serve-spa.js`, with `x-forwarded-host`). Enabling the +`theme` category propagates the document's theme to data-fair, processings, +catalogs, metrics and events served on that host, not only to +simple-directory's own pages. This is intended. + +## The API refuses nothing + +An earlier design had `PATCH /api/sites/:id` return 400 on the main document for +the never-honoured fields. It was rejected, and should not be reintroduced: + +- `ui/src/pages/admin/sites/[id].vue` builds its patch as a **full-document + round-trip** (it clones the fetched site and deletes only `_id`, + `colorWarnings`, `mainSiteWarnings`, `owner`, `host`, `path`). `authMode` is + `required` in the Site schema, so it is always present. Changing a single + colour on the main document would have returned 400. Hiding the field in the + VJSF layout would not help — the key stays in the data. +- The guard protected nothing. The only non-UI writer is portals + (`api/src/portals/service.ts` in the portals repo), which only `POST`s, and + `POST`'s body schema is already limited to `_id, owner, host, path, tmp, + title, theme` and `contact`. + +The risk being managed is operator *confusion*, not privilege: these fields are +inert here. Confusion is addressed where it occurs, in the admin UI. + +**Instead, `mainSiteWarnings`.** `GET /api/sites/:id` and the `showAll` list +carry it alongside `colorWarnings`, built in `prepareFullSite` and localised +through `reqI18n`: one entry per never-honoured field the document carries, one +per category stored but absent from `MAIN_SITE_FROM_DB`. The boot check in +`api/src/server.ts` logs the same report and raises an `internalError` when the +document carries an inert field. + +**One side effect is suppressed, not refused.** `PATCH` runs `toggleMainSite()` +whenever `patch.isAccountMain` is truthy, rewriting every *other* site of the +owner to `authMode: 'onlyOtherSite'`. With a full-document round-trip that +re-fires on every save, so it is skipped on the main document. The request still +succeeds; only the effect is skipped, so no caller breaks. + +## Admin UI + +`/admin/sites` badges the main site document and folds `mainSiteWarnings` into +the existing warnings menu. Its edit page shows a banner explaining the split +and lists the warnings. + +The auth sections stay **visible**: the form round-trips them, so hiding would +conceal the values the warnings refer to. Only `isAccountMain` is withheld +(`layout.if: '!context.isMainSite'` on the property in +`api/types/site/schema.js`), being the one field whose write reaches beyond the +document. + +Because the patch schema is `additionalProperties: false`, the computed +`mainSiteWarnings` must be stripped from the patch body like `colorWarnings` +is — otherwise every save of the main document fails with a 400. diff --git a/tests/features/main-site-config.unit.spec.ts b/tests/features/main-site-config.unit.spec.ts new file mode 100644 index 00000000..a054692c --- /dev/null +++ b/tests/features/main-site-config.unit.spec.ts @@ -0,0 +1,30 @@ +// The MAIN_SITE_FROM_DB category list is constrained by an enum in +// api/config/type/schema.json so that a typo refuses to start the server +// rather than silently disabling a category. + +import { strict as assert } from 'node:assert' +import { test } from '@playwright/test' + +process.env.NODE_CONFIG_DIR = process.env.NODE_CONFIG_DIR || './api/config/' +process.env.NODE_ENV = process.env.NODE_ENV || 'test' +process.env.SUPPRESS_NO_CONFIG_WARNING = '1' + +test.describe('mainSiteFromDb config', () => { + test('defaults to an empty list', async () => { + delete process.env.MAIN_SITE_FROM_DB + const mod = await import(`../../api/src/config.ts?mainsite-default=${Date.now()}`) + assert.deepEqual(mod.default.mainSiteFromDb, []) + }) + + test('accepts the four known categories', async () => { + process.env.MAIN_SITE_FROM_DB = '["theme","title","mails","registration"]' + const mod = await import(`../../api/src/config.ts?mainsite-ok=${Date.now()}`) + assert.deepEqual(mod.default.mainSiteFromDb, ['theme', 'title', 'mails', 'registration']) + }) + + test('refuses an unknown category', async () => { + process.env.MAIN_SITE_FROM_DB = '["theme","authProviders"]' + await assert.rejects(import(`../../api/src/config.ts?mainsite-ko=${Date.now()}`)) + delete process.env.MAIN_SITE_FROM_DB + }) +}) diff --git a/tests/features/main-site.api.spec.ts b/tests/features/main-site.api.spec.ts new file mode 100644 index 00000000..318beea2 --- /dev/null +++ b/tests/features/main-site.api.spec.ts @@ -0,0 +1,207 @@ +// End-to-end behaviour of the main site document over HTTP. +// The main host is the one in publicUrl; a site document on it drives +// presentation only, and only for the categories in config.mainSiteFromDb. + +import { strict as assert } from 'node:assert' +import { test } from '@playwright/test' +import { axios, axiosAuth, testEnvAx, createUser, getServerConfig, maildevAx, deleteAllEmails } from '../support/axios.ts' + +const findEmailTo = async (address: string) => { + await new Promise(resolve => setTimeout(resolve, 50)) + const emails: any[] = (await maildevAx.get('/email')).data + return emails.find(m => m.envelope?.to?.[0]?.address === address) +} + +const setCategories = async (categories: string[]) => { + await testEnvAx.patch('/config', { mainSiteFromDb: categories }) + await testEnvAx.post('/clear-site-cache') +} + +const seedMainSiteDoc = async () => { + const config = await getServerConfig() + const mainHost = new URL(config.publicUrl).host + const { ax } = await createUser('test-main-site@test.com') + const org = (await ax.post('/api/organizations', { name: 'test_main_site_org' })).data + const owner = { type: 'organization', id: org.id, name: org.name } + const anonymousAx = await axios() + await anonymousAx.post('/api/sites', + { _id: 'test_main_site', owner, host: mainHost, title: 'Portail de test', theme: { primaryColor: '#FF00FF' } }, + { params: { key: config.secretKeys.sites } }) + const adminAx = (await createUser('admin@test.com', true)).ax + await adminAx.patch('/api/sites/test_main_site', { + mails: { from: 'portal@test.com', contact: 'hello@test.com' }, + tosMessage: 'CGU du portail', + reducedPersonalInfoAtCreation: true + }) + await testEnvAx.post('/clear-site-cache') + return { adminAx, owner } +} + +test.describe('main site document', () => { + test.beforeEach(async () => { + await testEnvAx.delete('/') + await setCategories([]) + }) + + test.afterEach(async () => { + await setCategories([]) + }) + + test('is ignored when no category is enabled', async () => { + await seedMainSiteDoc() + const anonymousAx = await axios() + const publicSite = (await anonymousAx.get('/api/sites/_public')).data + assert.equal(publicSite.main, true) + assert.equal(publicSite.title, undefined) + assert.notEqual(publicSite.theme.colors.primary, '#FF00FF') + }) + + test('drives theme and title when those categories are enabled', async () => { + await seedMainSiteDoc() + await setCategories(['theme', 'title']) + const anonymousAx = await axios() + const publicSite = (await anonymousAx.get('/api/sites/_public')).data + assert.equal(publicSite.title, 'Portail de test') + assert.equal(publicSite.theme.colors.primary, '#FF00FF') + assert.equal(publicSite.authMode, 'onlyLocal') + assert.equal(publicSite.authProviders, undefined) + }) + + test('drives registration copy when that category is enabled', async () => { + await seedMainSiteDoc() + await setCategories(['registration']) + const anonymousAx = await axios() + const publicSite = (await anonymousAx.get('/api/sites/_public')).data + assert.equal(publicSite.tosMessage, 'CGU du portail') + assert.equal(publicSite.reducedPersonalInfoAtCreation, true) + }) + + test('the theme css hash matches the css served under it', async () => { + await seedMainSiteDoc() + const anonymousAx = await axios() + + // the css does not carry the raw primary colour, it carries the + // contrast-computed text colours derived from it, so compare the two + // states rather than searching for a literal + await setCategories([]) + const envCss = (await anonymousAx.get<string>('/api/sites/_theme.css')).data + + await setCategories(['theme']) + const hashes = (await anonymousAx.get('/api/sites/_hashes')).data + const hashedCss = (await anonymousAx.get<string>(`/api/sites/${hashes.themeCss}/_theme.css`)).data + const plainCss = (await anonymousAx.get<string>('/api/sites/_theme.css')).data + + assert.equal(hashedCss, plainCss) + assert.notEqual(plainCss, envCss, 'the document theme must change the css served on the main host') + }) + + test('the theme css hash matches the css served under it with no document', async () => { + const anonymousAx = await axios() + const hashes = (await anonymousAx.get('/api/sites/_hashes')).data + const hashedCss = (await anonymousAx.get<string>(`/api/sites/${hashes.themeCss}/_theme.css`)).data + const plainCss = (await anonymousAx.get<string>('/api/sites/_theme.css')).data + assert.equal(hashedCss, plainCss) + }) + + test('mails use the document sender only when the mails category is enabled', async () => { + await seedMainSiteDoc() + + await setCategories([]) + await deleteAllEmails() + await createUser('test-mail-env@test.com') + const envMail = await findEmailTo('test-mail-env@test.com') + assert.ok(envMail, 'no mail captured for the env case') + assert.equal(envMail.envelope.from.address, 'no-reply@test.com') + + await setCategories(['mails']) + await deleteAllEmails() + await createUser('test-mail-db@test.com') + const dbMail = await findEmailTo('test-mail-db@test.com') + assert.ok(dbMail, 'no mail captured for the db case') + assert.equal(dbMail.envelope.from.address, 'portal@test.com') + }) + + test('reports fields that are never honoured on the main host', async () => { + const { adminAx } = await seedMainSiteDoc() + await adminAx.patch('/api/sites/test_main_site', { authMode: 'ssoBackOffice' }) + await setCategories(['theme', 'title', 'mails', 'registration']) + const site = (await adminAx.get('/api/sites/test_main_site')).data + assert.ok(Array.isArray(site.mainSiteWarnings)) + assert.ok(site.mainSiteWarnings.some((w: string) => w.includes('authMode')), site.mainSiteWarnings.join(' | ')) + }) + + test('reports stored values whose category is disabled', async () => { + const { adminAx } = await seedMainSiteDoc() + await setCategories(['title']) + const site = (await adminAx.get('/api/sites/test_main_site')).data + const joined = site.mainSiteWarnings.join(' | ') + assert.ok(joined.includes('MAIN_SITE_FROM_DB'), joined) + assert.ok(!site.mainSiteWarnings.some((w: string) => w.includes('MAIN_SITE_FROM_DB') && w.includes('title')), joined) + }) + + test('an ordinary site has no main-site warnings', async () => { + const serverConfig = await getServerConfig() + const { ax } = await createUser('test-other-site@test.com') + const org = (await ax.post('/api/organizations', { name: 'test_other_org' })).data + const anonymousAx = await axios() + await anonymousAx.post('/api/sites', + { _id: 'test_other_site', owner: { type: 'organization', id: org.id, name: org.name }, host: '127.0.0.1:' + process.env.NGINX_PORT2 }, + { params: { key: serverConfig.secretKeys.sites } }) + const adminAx = (await createUser('admin@test.com', true)).ax + const site = (await adminAx.get('/api/sites/test_other_site')).data + assert.deepEqual(site.mainSiteWarnings, []) + }) + + test('a full-document round-trip patch succeeds and does not toggle other sites', async () => { + const serverConfig = await getServerConfig() + const { adminAx, owner } = await seedMainSiteDoc() + const anonymousAx = await axios() + await anonymousAx.post('/api/sites', + { _id: 'test_sibling_site', owner, host: '127.0.0.1:' + process.env.NGINX_PORT2 }, + { params: { key: serverConfig.secretKeys.sites } }) + await adminAx.patch('/api/sites/test_sibling_site', { authMode: 'onlyLocal' }) + + // exactly what ui/src/pages/admin/sites/[id].vue sends: the fetched + // document minus _id / colorWarnings / owner / host / path + const fetched = (await adminAx.get('/api/sites/test_main_site')).data + const roundTrip = { ...fetched, isAccountMain: true } + delete roundTrip._id + delete roundTrip.colorWarnings + delete roundTrip.mainSiteWarnings + delete roundTrip.owner + delete roundTrip.host + delete roundTrip.path + delete roundTrip.updatedAt + // the theme is in assisted mode, so fillTheme recomputes colors from + // assistedModeColors on save — editing colors.primary directly would be + // overwritten + assert.equal(roundTrip.theme.assistedMode, true) + roundTrip.theme.assistedModeColors.primary = '#00FF00' + + await adminAx.patch('/api/sites/test_main_site', roundTrip) + + const sibling = (await adminAx.get('/api/sites/test_sibling_site')).data + assert.equal(sibling.authMode, 'onlyLocal', 'toggleMainSite must not have rewritten the sibling site') + const patched = (await adminAx.get('/api/sites/test_main_site')).data + assert.equal(patched.theme.colors.primary, '#00FF00') + + // the patch schema is additionalProperties:false, so the computed + // mainSiteWarnings must be stripped from the body like colorWarnings is — + // otherwise every save of the main document 400s + await assert.rejects( + adminAx.patch('/api/sites/test_main_site', { ...roundTrip, mainSiteWarnings: ['x'] }), + { status: 400 } + ) + }) + + test('a session on the main host is still a back-office session', async () => { + await seedMainSiteDoc() + await setCategories(['theme', 'title', 'mails', 'registration']) + // adminMode requires reqSite() === undefined on the main host; the + // document must not have changed that + const adminAx = await axiosAuth({ email: 'admin@test.com', adminMode: true }) + const me = (await adminAx.get('/api/auth/me')).data + assert.ok(me.adminMode) + assert.equal(me.host, undefined) + }) +}) diff --git a/tests/features/main-site.e2e.spec.ts b/tests/features/main-site.e2e.spec.ts new file mode 100644 index 00000000..a6a2a49a --- /dev/null +++ b/tests/features/main-site.e2e.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '../support/e2e-fixtures.ts' +import { axios, axiosAuth, getServerConfig, testEnvAx } from '../support/axios.ts' + +test.describe('main site document admin page', () => { + let config: any + + test.beforeEach(async () => { + await testEnvAx.post('/seed') + config = await getServerConfig() + + const adminAx = await axiosAuth({ email: '_superadmin@test.com', password: 'Test1234', adminMode: true }) + const org = (await adminAx.post('/api/organizations', { name: 'test_main-site-org' })).data + const anonymAx = await axios() + + // seeded through the sites secret, exactly as portals-manager does + await anonymAx.post('/api/sites', { + _id: 'test_main_site', + owner: { type: 'organization', id: org.id, name: org.name }, + host: new URL(config.publicUrl).host, + title: 'Portail de test', + theme: { primaryColor: '#FF00FF' } + }, { params: { key: config.secretKeys.sites } }) + + // tosMessage is not in the POST body schema, only a superadmin can set it, + // and with the registration category disabled it produces a warning + await adminAx.patch('/api/sites/test_main_site', { tosMessage: 'CGU du portail' }) + + await testEnvAx.patch('/config', { mainSiteFromDb: ['theme', 'title'] }) + await testEnvAx.post('/clear-site-cache') + }) + + test.afterEach(async () => { + await testEnvAx.patch('/config', { mainSiteFromDb: [] }) + await testEnvAx.post('/clear-site-cache') + }) + + test('explains the split, keeps auth sections, hides isAccountMain', async ({ page, appUrl, loginExisting }) => { + await loginExisting('_superadmin@test.com', { password: 'Test1234', adminMode: true }) + await page.goto(appUrl('/admin/sites/test_main_site')) + + await expect(page.getByTestId('main-site-banner')).toBeVisible({ timeout: 15_000 }) + // the registration category is disabled but tosMessage is stored + await expect(page.getByTestId('main-site-warnings')).toContainText('MAIN_SITE_FROM_DB') + + // auth sections stay visible: the form round-trips them, so hiding would + // conceal the values the warnings refer to. The generous timeout covers a + // cold vite compile of this route on the first navigation. + await expect(page.getByText('Gestion des utilisateurs')).toBeVisible({ timeout: 15_000 }) + + // isAccountMain is the one control not offered on the main document. + // Matched by label, not by text: the deprecated authMode field's own label + // quotes "Site principal du compte" and would match a text locator. + await expect(page.getByLabel('Site principal du compte', { exact: true })).toHaveCount(0) + // the sibling field of that section is still there, so the section itself + // did render and the assertion above is meaningful + await expect(page.getByLabel('Titre du site', { exact: true })).toBeVisible() + + // and the form still saves + await expect(page.getByRole('textbox', { name: 'Couleur principale', exact: true })).toHaveValue('#FF00FF') + await page.getByRole('button', { name: /enregistrer|save/i }).click() + await page.waitForTimeout(1000) + const adminAx = await axiosAuth({ email: '_superadmin@test.com', password: 'Test1234', adminMode: true }) + const after = (await adminAx.get('/api/sites/test_main_site')).data + expect(after.title).toBe('Portail de test') + }) + + test('marks the main site in the sites list', async ({ page, appUrl, loginExisting }) => { + await loginExisting('_superadmin@test.com', { password: 'Test1234', adminMode: true }) + await page.goto(appUrl('/admin/sites')) + await expect(page.getByText('Site principal', { exact: true })).toBeVisible({ timeout: 15_000 }) + }) +}) diff --git a/tests/features/main-site.unit.spec.ts b/tests/features/main-site.unit.spec.ts new file mode 100644 index 00000000..a8093ac0 --- /dev/null +++ b/tests/features/main-site.unit.spec.ts @@ -0,0 +1,154 @@ +// The main site document is the site whose host+path matches publicUrl. +// getEffectiveMainSite merges its presentation fields over config.* according +// to config.mainSiteFromDb, and nothing else. The result is an ordinary +// EffectiveSite that runs through the same renderers as any other site. + +import { strict as assert } from 'node:assert' +import { test } from '@playwright/test' +import { initMongo } from '../support/unit.ts' + +test.describe('main site document resolver', () => { + // the mongo client from initMongo is shared by every unit spec in this + // worker process — do not close it here, the next spec's initMongo() would + // try to reconnect an already-closed client and take the rest of the + // project down with it + test.beforeAll(async () => { await initMongo() }) + + test.beforeEach(async () => { + const mongo = (await import('../../api/src/mongo.ts')).default + await mongo.sites.deleteMany({ _id: { $regex: /^test_/ } }) + const { getMainSiteDoc } = await import('../../api/src/sites/service.ts') + const { clearSiteResourceCaches } = await import('../../api/src/sites/main-site.ts') + getMainSiteDoc.clear() + clearSiteResourceCaches() + }) + + const seedMainSiteDoc = async (doc: any = {}) => { + const config = (await import('../../api/src/config.ts')).default + const mongo = (await import('../../api/src/mongo.ts')).default + const publicUrl = new URL(config.publicUrl) + await mongo.sites.insertOne({ + _id: 'test_main_site', + owner: { type: 'organization', id: 'test_org' }, + host: publicUrl.host, + updatedAt: new Date().toISOString(), + theme: { ...config.theme, colors: { ...config.theme.colors, primary: '#FF00FF' } }, + title: 'Portail de test', + mails: { from: 'portal@test.com', contact: 'hello@test.com' }, + tosMessage: 'CGU du portail', + reducedPersonalInfoAtCreation: true, + authMode: 'onlyLocal', + ...doc + } as any) + const { getMainSiteDoc } = await import('../../api/src/sites/service.ts') + getMainSiteDoc.clear() + } + + const withCategories = async (categories: string[]) => { + const config = (await import('../../api/src/config.ts')).default + Object.defineProperty(config, 'mainSiteFromDb', { value: categories, writable: true, configurable: true }) + const { clearSiteResourceCaches } = await import('../../api/src/sites/main-site.ts') + clearSiteResourceCaches() + } + + test('finds the document sitting on the publicUrl host', async () => { + await seedMainSiteDoc() + const { getMainSiteDoc } = await import('../../api/src/sites/service.ts') + assert.equal((await getMainSiteDoc())?._id, 'test_main_site') + }) + + test('ignores a document on another host', async () => { + await seedMainSiteDoc({ host: 'somewhere-else.test' }) + const { getMainSiteDoc } = await import('../../api/src/sites/service.ts') + assert.equal(await getMainSiteDoc(), undefined) + }) + + test('with an empty category list everything comes from env', async () => { + await seedMainSiteDoc() + await withCategories([]) + const config = (await import('../../api/src/config.ts')).default + const { getEffectiveMainSite } = await import('../../api/src/sites/main-site.ts') + const presentation = await getEffectiveMainSite() + assert.equal(presentation.theme.colors.primary, config.theme.colors.primary) + assert.notEqual(presentation.theme.colors.primary, '#FF00FF') + assert.equal(presentation.title, undefined) + assert.equal(presentation.mails?.from, config.mails.from) + assert.equal(presentation.tosMessage, undefined) + // no category contributed, so the id stays the env-only one + assert.equal(presentation._id, '_main') + }) + + test('each category is honoured independently', async () => { + await seedMainSiteDoc() + const config = (await import('../../api/src/config.ts')).default + const { getEffectiveMainSite } = await import('../../api/src/sites/main-site.ts') + + await withCategories(['theme']) + let presentation = await getEffectiveMainSite() + assert.equal(presentation.theme.colors.primary, '#FF00FF') + assert.equal(presentation.title, undefined) + assert.equal(presentation.mails?.from, config.mails.from) + + await withCategories(['title', 'mails', 'registration']) + presentation = await getEffectiveMainSite() + assert.equal(presentation.theme.colors.primary, config.theme.colors.primary) + assert.equal(presentation.title, 'Portail de test') + assert.equal(presentation.mails?.from, 'portal@test.com') + assert.equal(presentation.mails?.contact, 'hello@test.com') + assert.equal(presentation.tosMessage, 'CGU du portail') + assert.equal(presentation.reducedPersonalInfoAtCreation, true) + }) + + test('never exposes trust-bearing fields', async () => { + await seedMainSiteDoc({ authProviders: [{ type: 'saml2', title: 'evil' }], applications: [{ id: 'x' }] }) + await withCategories(['theme', 'title', 'mails', 'registration']) + const { getEffectiveMainSite } = await import('../../api/src/sites/main-site.ts') + const { getPublicSiteInfo } = await import('../../api/src/utils/public-site-info.ts') + const publicInfo = getPublicSiteInfo(await getEffectiveMainSite()) + assert.equal((publicInfo as any).authProviders, undefined) + assert.equal((publicInfo as any).applications, undefined) + assert.equal((publicInfo as any).owner, undefined) + assert.equal(publicInfo.authMode, 'onlyLocal') + assert.equal(publicInfo.main, true) + assert.equal(publicInfo.isAccountMain, true) + }) + + test('the theme css hash describes the css actually produced', async () => { + await seedMainSiteDoc() + await withCategories(['theme']) + const crypto = await import('node:crypto') + const { getEffectiveMainSite } = await import('../../api/src/sites/main-site.ts') + const { getThemeCss, getThemeCssHash } = await import('../../api/src/utils/theme.ts') + const site = await getEffectiveMainSite() + const themeCss = getThemeCss(site.theme, site.path ?? '') + assert.equal(crypto.createHash('md5').update(themeCss).digest('hex'), getThemeCssHash(site)) + }) + + // Regression: getSiteExtraParams used to call getSiteByUrl directly, with no + // publicUrl exclusion, while the /api/sites/_* endpoints call reqSite. On a + // host that is both the publicUrl host and carries a document, the served + // HTML asked for /api/sites/<document-hash>/_theme.css and got the *env* CSS + // back under max-age=31536000, immutable. + test('the hashes injected into the html match the resources actually served', async () => { + await seedMainSiteDoc() + const config = (await import('../../api/src/config.ts')).default + const { getSiteExtraParams } = await import('../../api/src/sites/spa-params.ts') + const { getEffectiveMainSite } = await import('../../api/src/sites/main-site.ts') + const { getThemeCssHash } = await import('../../api/src/utils/theme.ts') + const { getPublicSiteInfoHash } = await import('../../api/src/utils/public-site-info.ts') + const siteUrl = config.publicUrl.replace(/\/simple-directory$/, '') + + for (const categories of [[], ['theme'], ['theme', 'title']]) { + await withCategories(categories) + const params = await getSiteExtraParams(siteUrl) + const site = await getEffectiveMainSite() + assert.equal(params.THEME_CSS_HASH, getThemeCssHash(site), `categories=${categories.join(',')}`) + assert.equal(params.PUBLIC_SITE_INFO_HASH, getPublicSiteInfoHash(site), `categories=${categories.join(',')}`) + } + + await withCategories(['title']) + assert.equal((await getSiteExtraParams(siteUrl)).SITE_TITLE, 'Portail de test') + await withCategories([]) + assert.equal((await getSiteExtraParams(siteUrl)).SITE_TITLE, 'Simple Directory') + }) +}) diff --git a/tests/support/unit.ts b/tests/support/unit.ts index 7c20b53a..edb23dca 100644 --- a/tests/support/unit.ts +++ b/tests/support/unit.ts @@ -13,6 +13,11 @@ export const initMongo = async () => { initialized = true } +// Closes the client that initMongo shares across every unit spec in the worker +// process. Do NOT call this from a spec's afterAll: the next spec's initMongo() +// reconnects the same closed client and throws MongoNotConnectedError, taking +// the rest of the unit project down with it. Specs just call initMongo() and +// let the process exit clean up. export const closeMongo = async () => { if (!initialized) return const mongo = (await import('../../api/src/mongo.ts')).default diff --git a/ui/src/composables/use-store.ts b/ui/src/composables/use-store.ts index 28737d40..ffaba4f5 100644 --- a/ui/src/composables/use-store.ts +++ b/ui/src/composables/use-store.ts @@ -29,8 +29,16 @@ function createStore () { const mainPublicUrl = new URL($uiConfig.publicUrl) const isAccountMainSite = host === mainPublicUrl.host + // a site document is *the main site document* when its host+path matches + // publicUrl. Its presentation drives the main site for the categories in + // MAIN_SITE_FROM_DB; its auth configuration never applies. + // See docs/architecture/main-site-config.md + const isMainSiteDoc = (site: { host: string, path?: string }) => + $uiConfig.publicUrl.startsWith(`${mainPublicUrl.protocol}//${site.host}${site.path ?? ''}`) + return { sitePublic, + isMainSiteDoc, userDetailsFetch, authProvidersFetch, patchOrganization, diff --git a/ui/src/pages/admin/sites/[id].vue b/ui/src/pages/admin/sites/[id].vue index 7ca9f7f4..f47bea01 100644 --- a/ui/src/pages/admin/sites/[id].vue +++ b/ui/src/pages/admin/sites/[id].vue @@ -20,6 +20,27 @@ :href="siteHref" class="simple-link" >{{ siteHref }}</a> - {{ site.data.value?._id }} + <v-alert + v-if="isMainSite" + data-testid="main-site-banner" + type="info" + variant="tonal" + class="my-4" + > + <p>{{ $t('pages.admin.site.mainSiteExplanation') }}</p> + <ul + v-if="site.data.value?.mainSiteWarnings?.length" + data-testid="main-site-warnings" + class="mt-2 ml-4" + > + <li + v-for="(warning, i) of site.data.value.mainSiteWarnings" + :key="i" + > + {{ warning }} + </li> + </ul> + </v-alert> <vjsf-patch-req-body v-model="patch" :locale="locale" @@ -74,6 +95,7 @@ const vjsfOptions = computed(() => { density: 'comfortable', initialValidation: 'always', context: { + isMainSite: isMainSite.value, hasAccountMainSite: otherSites?.some(s => s.isAccountMain), otherSites: otherSites?.map(site => site.host), otherSitesProviders: otherSites?.reduce((a, site) => { a[site.host] = (site.authProviders || []).filter(p => p.type === 'oidc').map(p => `${p.type}:${p.id}`); return a }, {} as Record<string, string[]>) @@ -81,7 +103,7 @@ const vjsfOptions = computed(() => { } }) -type SiteWithColorWarnings = Site & { colorWarnings: string[] } +type SiteWithColorWarnings = Site & { colorWarnings: string[], mainSiteWarnings?: string[] } const siteId = useRoute<'/admin/sites/[id]'>().params.id const sites = useFetch<{ count: number, results: SiteWithColorWarnings[] }>($apiPath + '/sites', { query: { showAll: true } }) @@ -89,13 +111,15 @@ const site = useFetch<SiteWithColorWarnings>($apiPath + '/sites/' + siteId, { qu const siteHref = computed(() => `${site.data.value?.host.startsWith('localhost:') ? 'http' : 'https'}://${site.data.value?.host}${site.data.value?.path ?? ''}`) -const { patchSite } = useStore() +const { patchSite, isMainSiteDoc } = useStore() +const isMainSite = computed(() => !!site.data.value && isMainSiteDoc(site.data.value)) watch(site.data, () => { if (!site.data.value) return const siteClone = JSON.parse(JSON.stringify(site.data.value)) delete siteClone._id delete siteClone.colorWarnings + delete siteClone.mainSiteWarnings delete siteClone.owner delete siteClone.host delete siteClone.path diff --git a/ui/src/pages/admin/sites/index.vue b/ui/src/pages/admin/sites/index.vue index f02fc46a..ad3857ff 100644 --- a/ui/src/pages/admin/sites/index.vue +++ b/ui/src/pages/admin/sites/index.vue @@ -39,6 +39,14 @@ target="blank" class="text-primary" >{{ `${props.item.host}${props.item.path ?? ''}` }}</a> + <v-chip + v-if="isMainSiteDoc(props.item)" + size="x-small" + color="primary" + class="ml-2" + > + {{ $t('pages.admin.sites.mainSite') }} + </v-chip> </td> <td>{{ props.item._id }}</td> <td> @@ -114,7 +122,7 @@ :icon="mdiLoginVariant" @click="siteRedirect(props.item)" /> - <v-menu v-if="props.item.colorWarnings.length"> + <v-menu v-if="allWarnings(props.item).length"> <template #activator="{props: colorWarningsMenuProps}"> <v-btn :title="$t('pages.admin.sites.colorWarnings')" @@ -128,7 +136,7 @@ </template> <v-list class="border-sm"> <v-list-item - v-for="(warning, i) of props.item.colorWarnings" + v-for="(warning, i) of allWarnings(props.item)" :key="i" > <v-list-item-title> @@ -146,11 +154,14 @@ <script setup lang="ts"> -type SiteWithColorWarnings = Site & { colorWarnings: string[] } +type SiteWithColorWarnings = Site & { colorWarnings: string[], mainSiteWarnings?: string[] } + +const allWarnings = (site: SiteWithColorWarnings) => [...site.colorWarnings, ...(site.mainSiteWarnings ?? [])] const { t } = useI18n() const sites = useFetch<{ count: number, results: SiteWithColorWarnings[] }>($apiPath + '/sites', { query: { showAll: true } }) const protocol = window.location.protocol +const { isMainSiteDoc } = useStore() const deleteSite = useAsyncAction(async (site: Site) => { await $fetch(`sites/${site._id}`, { method: 'DELETE' })