From bd8b3eec6b220a77b357ef71df377fd2c7585a71 Mon Sep 17 00:00:00 2001 From: Elias Hackradt Date: Thu, 15 Jan 2026 11:30:36 +0100 Subject: [PATCH 1/6] Initial commit --- lib/controllers.js | 125 ++++++++++++++++++ library.js | 44 +++--- plugin.json | 3 + static/lib/admin.js | 84 +++++++++++- static/lib/client.js | 20 +++ .../partials/edit-oauth2-strategy.tpl | 16 +++ 6 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 static/lib/client.js diff --git a/lib/controllers.js b/lib/controllers.js index b2a4a9d..ddbfe2d 100644 --- a/lib/controllers.js +++ b/lib/controllers.js @@ -9,6 +9,9 @@ const groups = require.main.require('./src/groups'); const slugify = require.main.require('./src/slugify'); const helpers = require.main.require('./src/controllers/helpers'); const userController = require.main.require('./src/controllers/user'); +const nconf = require.main.require('nconf'); +const path = require('path'); +const fs = require('fs/promises'); const main = require('../library'); @@ -62,6 +65,35 @@ Controllers.getStrategy = async (req, res) => { helpers.formatApiResponse(200, res, { strategy }); }; +Controllers.renderIconsCss = async (req, res) => { + const strategies = await main.listStrategies(true); + const iconStrategies = strategies.filter(strategy => strategy.iconUrl); + + const escapeCssUrl = (url) => url.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + + const baseStyles = ` +.sso-oauth2-icon { + display: inline-block; + width: 1em; + height: 1em; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + vertical-align: middle; +} +.sso-oauth2-icon::before { + content: "" !important; +} +`.trim(); + + const iconRules = iconStrategies.map(strategy => ( + `.sso-oauth2-icon-${strategy.name} { background-image: url("${escapeCssUrl(strategy.iconUrl)}"); }` + )).join('\n'); + + res.set('Content-Type', 'text/css; charset=utf-8'); + res.send(`${baseStyles}\n${iconRules}\n`); +}; + Controllers.editStrategy = async (req, res) => { const name = slugify(req.params.name || req.body.name); const payload = { ...req.body }; @@ -79,6 +111,99 @@ Controllers.editStrategy = async (req, res) => { payload[prop] = payload.hasOwnProperty(prop) && payload[prop] === 'on' ? 1 : 0; }); + const baseDir = nconf.get('base_dir') || process.cwd(); + const rawUploadPath = nconf.get('upload_path') || path.join(baseDir, 'public', 'uploads'); + const uploadPath = path.isAbsolute(rawUploadPath) ? rawUploadPath : path.join(baseDir, rawUploadPath); + const uploadUrl = nconf.get('upload_url') || '/uploads'; + const relativePath = nconf.get('relative_path') || ''; + const iconsDir = path.join(uploadPath, 'plugins', 'sso-oauth2-multiple'); + + const normalizeUrlPrefix = (prefix) => { + if (!prefix.startsWith('/')) { + prefix = `/${prefix}`; + } + return prefix.replace(/\/+$/, ''); + }; + const normalizeRelativePath = (value) => { + if (!value) { + return ''; + } + if (!value.startsWith('/')) { + value = `/${value}`; + } + return value.replace(/\/+$/, ''); + }; + const isAbsoluteUploadUrl = /^https?:\/\//i.test(uploadUrl); + const baseUploadUrl = isAbsoluteUploadUrl + ? uploadUrl.replace(/\/+$/, '') + : `${normalizeRelativePath(relativePath)}${normalizeUrlPrefix(uploadUrl)}`; + const uploadUrlPrefix = `${baseUploadUrl}/plugins/sso-oauth2-multiple/`; + const getIconFilePath = (url) => { + if (!url || typeof url !== 'string') { + return null; + } + if (!url.startsWith(uploadUrlPrefix)) { + return null; + } + const filename = url.slice(uploadUrlPrefix.length); + if (!filename) { + return null; + } + return path.join(iconsDir, filename); + }; + + if (payload.removeIcon === 'on') { + payload.iconUrl = ''; + } + delete payload.removeIcon; + + const existing = await main.getStrategy(name); + const existingIconPath = existing ? getIconFilePath(existing.iconUrl) : null; + + if (payload.iconUrl) { + const maxBytes = 100 * 1024; + const isDataUrl = payload.iconUrl.startsWith('data:image/'); + if (isDataUrl) { + const match = payload.iconUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/); + if (!match) { + throw new Error('[[error:invalid-data]]'); + } + const mime = match[1]; + const data = match[2]; + const buffer = Buffer.from(data, 'base64'); + if (buffer.length > maxBytes) { + throw new Error('[[error:invalid-data]]'); + } + + const extMap = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + }; + const ext = extMap[mime]; + if (!ext) { + throw new Error('[[error:invalid-data]]'); + } + + await fs.mkdir(iconsDir, { recursive: true }); + const filename = `${name}-${Date.now()}.${ext}`; + const filePath = path.join(iconsDir, filename); + await fs.writeFile(filePath, buffer); + payload.iconUrl = `${uploadUrlPrefix}${filename}`; + + if (existingIconPath) { + await fs.unlink(existingIconPath).catch(() => null); + } + } else if (!payload.iconUrl.startsWith(uploadUrlPrefix)) { + throw new Error('[[error:invalid-data]]'); + } + } else if (!payload.iconUrl && existingIconPath) { + await fs.unlink(existingIconPath).catch(() => null); + } + await Promise.all([ db.sortedSetAdd('oauth2-multiple:strategies', Date.now(), name), db.setObject(`oauth2-multiple:strategies:${name}`, payload), diff --git a/library.js b/library.js index 0161c03..0679fc2 100644 --- a/library.js +++ b/library.js @@ -19,6 +19,7 @@ OAuth.init = async (params) => { const controllers = require('./lib/controllers'); routeHelpers.setupAdminPageRoute(router, '/admin/plugins/sso-oauth2-multiple', controllers.renderAdminPage); + router.get('/plugins/sso-oauth2-multiple/icons.css', controllers.renderIconsCss); }; OAuth.addRoutes = async ({ router, middleware }) => { @@ -120,22 +121,35 @@ OAuth.loadStrategies = async (strategies) => { passport.use(configured[idx].name, strategy); }); - strategies.push(...configured.map(({ name, scope, loginLabel, registerLabel, faIcon }) => ({ + strategies.push(...configured.map(({ name, - url: `/auth/${name}`, - callbackURL: `/auth/${name}/callback`, - icon: faIcon || 'fa-right-to-bracket', - icons: { - normal: `fa ${faIcon || 'fa-right-to-bracket'}`, - square: `fa ${faIcon || 'fa-right-to-bracket'}`, - }, - labels: { - login: loginLabel || 'Log In', - register: registerLabel || 'Register', - }, - color: '#666', - scope: scope || 'openid email profile', - }))); + scope, + loginLabel, + registerLabel, + faIcon, + iconUrl, + }) => { + const hasCustomIcon = Boolean(iconUrl); + const fallbackIcon = faIcon || 'fa-right-to-bracket'; + const iconClass = hasCustomIcon ? `sso-oauth2-icon sso-oauth2-icon-${name}` : `fa ${fallbackIcon}`; + + return { + name, + url: `/auth/${name}`, + callbackURL: `/auth/${name}/callback`, + icon: hasCustomIcon ? `sso-oauth2-icon sso-oauth2-icon-${name}` : fallbackIcon, + icons: { + normal: iconClass, + square: iconClass, + }, + labels: { + login: loginLabel || 'Log In', + register: registerLabel || 'Register', + }, + color: '#666', + scope: scope || 'openid email profile', + }; + })); return strategies; }; diff --git a/plugin.json b/plugin.json index 39b7360..56ad2e5 100644 --- a/plugin.json +++ b/plugin.json @@ -15,6 +15,9 @@ "modules": { "../admin/plugins/sso-oauth2-multiple.js": "./static/lib/admin.js" }, + "scripts": [ + "static/lib/client.js" + ], "acpScripts": [ "static/lib/acp.js" ], diff --git a/static/lib/admin.js b/static/lib/admin.js index 28de9fa..d9e51b5 100644 --- a/static/lib/admin.js +++ b/static/lib/admin.js @@ -23,7 +23,10 @@ export function init() { message, size: 'xl', callback: handleEditStrategy, - onShown: handleAutoDiscovery, + onShown: function () { + handleAutoDiscovery.call(this); + handleIconUpload.call(this); + }, }); break; @@ -39,7 +42,10 @@ export function init() { message, size: 'xl', callback: handleEditStrategy, - onShown: handleAutoDiscovery, + onShown: function () { + handleAutoDiscovery.call(this); + handleIconUpload.call(this); + }, }); break; @@ -205,6 +211,80 @@ function handleAutoDiscovery() { }); } +function handleIconUpload() { + const modalEl = this; + const fileEl = modalEl.querySelector('#iconFile'); + const iconUrlEl = modalEl.querySelector('input[name="iconUrl"]'); + const previewEl = modalEl.querySelector('#iconPreview'); + const removeEl = modalEl.querySelector('#removeIcon'); + + if (!fileEl || !iconUrlEl) { + return; + } + + const updatePreview = (url) => { + if (!previewEl) { + return; + } + if (url) { + previewEl.src = url; + previewEl.classList.remove('d-none'); + } else { + previewEl.removeAttribute('src'); + previewEl.classList.add('d-none'); + } + }; + + updatePreview(iconUrlEl.value); + + fileEl.addEventListener('change', () => { + const file = fileEl.files && fileEl.files[0]; + if (!file) { + return; + } + + if (!file.type.startsWith('image/')) { + alert({ + type: 'danger', + message: 'Icon upload must be an image file.', + }); + fileEl.value = ''; + return; + } + + const maxBytes = 100 * 1024; + if (file.size > maxBytes) { + alert({ + type: 'danger', + message: 'Icon upload must be 100KB or smaller.', + }); + fileEl.value = ''; + return; + } + + const reader = new FileReader(); + reader.onload = () => { + iconUrlEl.value = reader.result; + if (removeEl) { + removeEl.checked = false; + } + updatePreview(reader.result); + }; + reader.readAsDataURL(file); + }); + + if (removeEl) { + removeEl.addEventListener('change', () => { + if (!removeEl.checked) { + return; + } + fileEl.value = ''; + iconUrlEl.value = ''; + updatePreview(''); + }); + } +} + function handleDeleteStrategy(ok, name) { if (!ok) { return; diff --git a/static/lib/client.js b/static/lib/client.js new file mode 100644 index 0000000..555ffd7 --- /dev/null +++ b/static/lib/client.js @@ -0,0 +1,20 @@ +'use strict'; + +(() => { + if (typeof document === 'undefined') { + return; + } + + const existing = document.querySelector('link[data-sso-oauth2-icons]'); + if (existing) { + return; + } + + const relativePath = (window.config && window.config.relative_path) ? window.config.relative_path : ''; + const cacheBuster = (window.config && (window.config['cache-buster'] || window.config.cacheBuster)) || ''; + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = `${relativePath}/plugins/sso-oauth2-multiple/icons.css${cacheBuster ? `?v=${cacheBuster}` : ''}`; + link.setAttribute('data-sso-oauth2-icons', '1'); + document.head.appendChild(link); +})(); diff --git a/static/templates/partials/edit-oauth2-strategy.tpl b/static/templates/partials/edit-oauth2-strategy.tpl index dcc4f65..09a977e 100644 --- a/static/templates/partials/edit-oauth2-strategy.tpl +++ b/static/templates/partials/edit-oauth2-strategy.tpl @@ -36,6 +36,22 @@ If none is set, then will be used.

+
+ + + +

+ Optional — upload a small image (PNG, SVG, etc.) to use instead of the FontAwesome icon. + Max size 100KB. +

+
+ Custom icon preview +
+ + +
+
+
From aaae4316317eae42ab771a7a301e242987ed286b Mon Sep 17 00:00:00 2001 From: Elias Hackradt Date: Thu, 15 Jan 2026 11:39:42 +0100 Subject: [PATCH 2/6] Fixup nconf --- lib/controllers.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/controllers.js b/lib/controllers.js index ddbfe2d..1f5d273 100644 --- a/lib/controllers.js +++ b/lib/controllers.js @@ -9,7 +9,6 @@ const groups = require.main.require('./src/groups'); const slugify = require.main.require('./src/slugify'); const helpers = require.main.require('./src/controllers/helpers'); const userController = require.main.require('./src/controllers/user'); -const nconf = require.main.require('nconf'); const path = require('path'); const fs = require('fs/promises'); From 557cff655b526906346c8085df8ad6bd55da8fe3 Mon Sep 17 00:00:00 2001 From: Elias Hackradt Date: Mon, 23 Feb 2026 23:26:58 +0100 Subject: [PATCH 3/6] Rework PR --- lib/controllers.js | 162 ++++++------------ library.js | 17 +- plugin.json | 4 +- static/lib/admin.js | 34 +++- static/lib/client.js | 20 --- static/scss/oauth2-icons.scss | 13 ++ .../partials/edit-oauth2-strategy.tpl | 2 +- 7 files changed, 112 insertions(+), 140 deletions(-) delete mode 100644 static/lib/client.js create mode 100644 static/scss/oauth2-icons.scss diff --git a/lib/controllers.js b/lib/controllers.js index 1f5d273..7723380 100644 --- a/lib/controllers.js +++ b/lib/controllers.js @@ -1,16 +1,18 @@ 'use strict'; -const fetch = require('node-fetch'); +const path = require('path'); +const os = require('os'); +const fs = require('fs').promises; +const fetch = require('node-fetch'); const nconf = require.main.require('nconf'); const db = require.main.require('./src/database'); +const file = require.main.require('./src/file'); const groups = require.main.require('./src/groups'); const slugify = require.main.require('./src/slugify'); const helpers = require.main.require('./src/controllers/helpers'); const userController = require.main.require('./src/controllers/user'); -const path = require('path'); -const fs = require('fs/promises'); const main = require('../library'); @@ -64,33 +66,39 @@ Controllers.getStrategy = async (req, res) => { helpers.formatApiResponse(200, res, { strategy }); }; -Controllers.renderIconsCss = async (req, res) => { - const strategies = await main.listStrategies(true); - const iconStrategies = strategies.filter(strategy => strategy.iconUrl); - - const escapeCssUrl = (url) => url.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - - const baseStyles = ` -.sso-oauth2-icon { - display: inline-block; - width: 1em; - height: 1em; - background-position: center; - background-repeat: no-repeat; - background-size: contain; - vertical-align: middle; -} -.sso-oauth2-icon::before { - content: "" !important; -} -`.trim(); - - const iconRules = iconStrategies.map(strategy => ( - `.sso-oauth2-icon-${strategy.name} { background-image: url("${escapeCssUrl(strategy.iconUrl)}"); }` - )).join('\n'); - - res.set('Content-Type', 'text/css; charset=utf-8'); - res.send(`${baseStyles}\n${iconRules}\n`); +Controllers.uploadIcon = async (req, res) => { + const dataUrl = req.body && (req.body.dataUrl || req.body.iconFile); + if (!dataUrl || typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/')) { + return helpers.formatApiResponse(400, res, new Error('[[error:invalid-file]]')); + } + const maxBytes = 100 * 1024; + const match = dataUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/); + if (!match) { + return helpers.formatApiResponse(400, res, new Error('[[error:invalid-file]]')); + } + const mime = match[1]; + const base64Data = match[2]; + const buffer = Buffer.from(base64Data, 'base64'); + if (buffer.length > maxBytes) { + return helpers.formatApiResponse(400, res, new Error('[[error:file-too-big, 100]]')); + } + const allowedTypes = /^image\/(png|jpe?g|gif|webp|svg\+xml)$/i; + if (!allowedTypes.test(mime)) { + return helpers.formatApiResponse(400, res, new Error('[[error:invalid-file]]')); + } + const ext = file.typeToExtension(mime) || '.png'; + const iconsFolder = 'plugins/sso-oauth2-multiple'; + const filename = `${Date.now()}${ext}`; + const tempPath = path.join(os.tmpdir(), `oauth2-icon-${Date.now()}${ext}`); + try { + await fs.writeFile(tempPath, buffer); + const relativePath = nconf.get('relative_path') || ''; + const uploadResult = await file.saveFileToLocal(filename, iconsFolder, tempPath); + const url = relativePath + uploadResult.url; + helpers.formatApiResponse(200, res, { url }); + } finally { + await file.delete(tempPath); + } }; Controllers.editStrategy = async (req, res) => { @@ -110,97 +118,41 @@ Controllers.editStrategy = async (req, res) => { payload[prop] = payload.hasOwnProperty(prop) && payload[prop] === 'on' ? 1 : 0; }); - const baseDir = nconf.get('base_dir') || process.cwd(); - const rawUploadPath = nconf.get('upload_path') || path.join(baseDir, 'public', 'uploads'); - const uploadPath = path.isAbsolute(rawUploadPath) ? rawUploadPath : path.join(baseDir, rawUploadPath); - const uploadUrl = nconf.get('upload_url') || '/uploads'; + if (payload.removeIcon === 'on') { + payload.iconUrl = ''; + } + delete payload.removeIcon; + const relativePath = nconf.get('relative_path') || ''; - const iconsDir = path.join(uploadPath, 'plugins', 'sso-oauth2-multiple'); + const uploadPath = nconf.get('upload_path'); + const iconsFolder = 'plugins/sso-oauth2-multiple'; + const uploadUrlPrefix = `${relativePath}/assets/uploads/${iconsFolder}/`; - const normalizeUrlPrefix = (prefix) => { - if (!prefix.startsWith('/')) { - prefix = `/${prefix}`; - } - return prefix.replace(/\/+$/, ''); - }; - const normalizeRelativePath = (value) => { - if (!value) { - return ''; - } - if (!value.startsWith('/')) { - value = `/${value}`; - } - return value.replace(/\/+$/, ''); - }; - const isAbsoluteUploadUrl = /^https?:\/\//i.test(uploadUrl); - const baseUploadUrl = isAbsoluteUploadUrl - ? uploadUrl.replace(/\/+$/, '') - : `${normalizeRelativePath(relativePath)}${normalizeUrlPrefix(uploadUrl)}`; - const uploadUrlPrefix = `${baseUploadUrl}/plugins/sso-oauth2-multiple/`; - const getIconFilePath = (url) => { + const getIconFilePathFromUrl = (url) => { if (!url || typeof url !== 'string') { return null; } - if (!url.startsWith(uploadUrlPrefix)) { + const prefix = `${relativePath}/assets/uploads/${iconsFolder}/`; + if (!url.startsWith(prefix)) { return null; } - const filename = url.slice(uploadUrlPrefix.length); - if (!filename) { + const filename = url.slice(prefix.length); + if (!filename || filename.includes('/')) { return null; } - return path.join(iconsDir, filename); + return path.join(uploadPath, iconsFolder, filename); }; - if (payload.removeIcon === 'on') { - payload.iconUrl = ''; - } - delete payload.removeIcon; - const existing = await main.getStrategy(name); - const existingIconPath = existing ? getIconFilePath(existing.iconUrl) : null; + const existingIconPath = existing ? getIconFilePathFromUrl(existing.iconUrl) : null; if (payload.iconUrl) { - const maxBytes = 100 * 1024; - const isDataUrl = payload.iconUrl.startsWith('data:image/'); - if (isDataUrl) { - const match = payload.iconUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/); - if (!match) { - throw new Error('[[error:invalid-data]]'); - } - const mime = match[1]; - const data = match[2]; - const buffer = Buffer.from(data, 'base64'); - if (buffer.length > maxBytes) { - throw new Error('[[error:invalid-data]]'); - } - - const extMap = { - 'image/png': 'png', - 'image/jpeg': 'jpg', - 'image/jpg': 'jpg', - 'image/gif': 'gif', - 'image/webp': 'webp', - 'image/svg+xml': 'svg', - }; - const ext = extMap[mime]; - if (!ext) { - throw new Error('[[error:invalid-data]]'); - } - - await fs.mkdir(iconsDir, { recursive: true }); - const filename = `${name}-${Date.now()}.${ext}`; - const filePath = path.join(iconsDir, filename); - await fs.writeFile(filePath, buffer); - payload.iconUrl = `${uploadUrlPrefix}${filename}`; - - if (existingIconPath) { - await fs.unlink(existingIconPath).catch(() => null); - } - } else if (!payload.iconUrl.startsWith(uploadUrlPrefix)) { + const currentPrefix = `${relativePath}/assets/uploads/${iconsFolder}/`; + if (!payload.iconUrl.startsWith(currentPrefix)) { throw new Error('[[error:invalid-data]]'); } - } else if (!payload.iconUrl && existingIconPath) { - await fs.unlink(existingIconPath).catch(() => null); + } else if (existingIconPath) { + await file.delete(existingIconPath); } await Promise.all([ diff --git a/library.js b/library.js index 0679fc2..fe891a5 100644 --- a/library.js +++ b/library.js @@ -19,7 +19,6 @@ OAuth.init = async (params) => { const controllers = require('./lib/controllers'); routeHelpers.setupAdminPageRoute(router, '/admin/plugins/sso-oauth2-multiple', controllers.renderAdminPage); - router.get('/plugins/sso-oauth2-multiple/icons.css', controllers.renderIconsCss); }; OAuth.addRoutes = async ({ router, middleware }) => { @@ -30,7 +29,7 @@ OAuth.addRoutes = async ({ router, middleware }) => { ]; routeHelpers.setupApiRoute(router, 'get', '/oauth2-multiple/discover', middlewares, controllers.getOpenIdMetadata); - + routeHelpers.setupApiRoute(router, 'post', '/oauth2-multiple/upload-icon', middlewares, controllers.uploadIcon); routeHelpers.setupApiRoute(router, 'post', '/oauth2-multiple/strategies', middlewares, controllers.editStrategy); routeHelpers.setupApiRoute(router, 'get', '/oauth2-multiple/strategies/:name', middlewares, controllers.getStrategy); routeHelpers.setupApiRoute(router, 'delete', '/oauth2-multiple/strategies/:name', middlewares, controllers.deleteStrategy); @@ -63,12 +62,14 @@ OAuth.getStrategy = async (name) => { async function getStrategies(names, full) { const strategies = await db.getObjects(names.map(name => `oauth2-multiple:strategies:${name}`), full ? undefined : ['enabled']); strategies.forEach((strategy, idx) => { + if (!strategy) { + return; + } strategy.name = names[idx]; strategy.enabled = strategy.enabled === 'true' || strategy.enabled === true; strategy.callbackUrl = `${nconf.get('url')}/auth/${names[idx]}/callback`; }); - - return strategies; + return strategies.filter(Boolean); } OAuth.loadStrategies = async (strategies) => { @@ -131,16 +132,20 @@ OAuth.loadStrategies = async (strategies) => { }) => { const hasCustomIcon = Boolean(iconUrl); const fallbackIcon = faIcon || 'fa-right-to-bracket'; - const iconClass = hasCustomIcon ? `sso-oauth2-icon sso-oauth2-icon-${name}` : `fa ${fallbackIcon}`; + const iconClass = `fa ${fallbackIcon}`; + const escapedUrl = hasCustomIcon ? iconUrl.replace(/'/g, "\\'") : ''; return { name, url: `/auth/${name}`, callbackURL: `/auth/${name}/callback`, - icon: hasCustomIcon ? `sso-oauth2-icon sso-oauth2-icon-${name}` : fallbackIcon, + icon: fallbackIcon, icons: { normal: iconClass, square: iconClass, + ...(hasCustomIcon && { + svg: ``, + }), }, labels: { login: loginLabel || 'Log In', diff --git a/plugin.json b/plugin.json index 56ad2e5..d414fbf 100644 --- a/plugin.json +++ b/plugin.json @@ -15,8 +15,8 @@ "modules": { "../admin/plugins/sso-oauth2-multiple.js": "./static/lib/admin.js" }, - "scripts": [ - "static/lib/client.js" + "scss": [ + "static/scss/oauth2-icons.scss" ], "acpScripts": [ "static/lib/acp.js" diff --git a/static/lib/admin.js b/static/lib/admin.js index d9e51b5..4a2b956 100644 --- a/static/lib/admin.js +++ b/static/lib/admin.js @@ -237,9 +237,10 @@ function handleIconUpload() { updatePreview(iconUrlEl.value); - fileEl.addEventListener('change', () => { + fileEl.addEventListener('change', async () => { const file = fileEl.files && fileEl.files[0]; if (!file) { + updatePreview(iconUrlEl.value); return; } @@ -262,13 +263,34 @@ function handleIconUpload() { return; } + if (removeEl) { + removeEl.checked = false; + } + const reader = new FileReader(); reader.onload = () => { - iconUrlEl.value = reader.result; - if (removeEl) { - removeEl.checked = false; - } - updatePreview(reader.result); + const dataUrl = reader.result; + post('/plugins/oauth2-multiple/upload-icon', { dataUrl }).then((data) => { + const url = (data.response && data.response.url) || data.url; + if (url) { + iconUrlEl.value = url; + updatePreview(url); + } + fileEl.value = ''; + }).catch((err) => { + alert({ + type: 'danger', + message: err.message || err.status?.message || 'Icon upload failed.', + }); + fileEl.value = ''; + }); + }; + reader.onerror = () => { + alert({ + type: 'danger', + message: 'Failed to read file.', + }); + fileEl.value = ''; }; reader.readAsDataURL(file); }); diff --git a/static/lib/client.js b/static/lib/client.js deleted file mode 100644 index 555ffd7..0000000 --- a/static/lib/client.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict'; - -(() => { - if (typeof document === 'undefined') { - return; - } - - const existing = document.querySelector('link[data-sso-oauth2-icons]'); - if (existing) { - return; - } - - const relativePath = (window.config && window.config.relative_path) ? window.config.relative_path : ''; - const cacheBuster = (window.config && (window.config['cache-buster'] || window.config.cacheBuster)) || ''; - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = `${relativePath}/plugins/sso-oauth2-multiple/icons.css${cacheBuster ? `?v=${cacheBuster}` : ''}`; - link.setAttribute('data-sso-oauth2-icons', '1'); - document.head.appendChild(link); -})(); diff --git a/static/scss/oauth2-icons.scss b/static/scss/oauth2-icons.scss new file mode 100644 index 0000000..de81355 --- /dev/null +++ b/static/scss/oauth2-icons.scss @@ -0,0 +1,13 @@ +.sso-oauth2-icon { + display: inline-block; + width: 1em; + height: 1em; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + vertical-align: middle; + + &::before { + content: "" !important; + } +} diff --git a/static/templates/partials/edit-oauth2-strategy.tpl b/static/templates/partials/edit-oauth2-strategy.tpl index 09a977e..dfb361e 100644 --- a/static/templates/partials/edit-oauth2-strategy.tpl +++ b/static/templates/partials/edit-oauth2-strategy.tpl @@ -39,7 +39,7 @@
- +

Optional — upload a small image (PNG, SVG, etc.) to use instead of the FontAwesome icon. Max size 100KB. From 60b497074bdc1a076140bf54962b271b11334ae9 Mon Sep 17 00:00:00 2001 From: Elias Hackradt Date: Mon, 23 Feb 2026 23:32:39 +0100 Subject: [PATCH 4/6] Improve error handling --- lib/controllers.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/controllers.js b/lib/controllers.js index 7723380..c9996f2 100644 --- a/lib/controllers.js +++ b/lib/controllers.js @@ -96,6 +96,8 @@ Controllers.uploadIcon = async (req, res) => { const uploadResult = await file.saveFileToLocal(filename, iconsFolder, tempPath); const url = relativePath + uploadResult.url; helpers.formatApiResponse(200, res, { url }); + } catch (err) { + helpers.formatApiResponse(500, res, err); } finally { await file.delete(tempPath); } From 40c2d2b8f7391969907d8b24039408f46a95ae99 Mon Sep 17 00:00:00 2001 From: Elias Hackradt Date: Wed, 15 Apr 2026 18:27:54 +0200 Subject: [PATCH 5/6] Initial draft for username choice --- lib/controllers.js | 2 +- library.js | 157 ++++++++++++++++-- plugin.json | 7 +- static/languages/en-GB/oauth2-multiple.json | 6 + .../partials/edit-oauth2-strategy.tpl | 5 + .../oauth2-multiple/choose-username.tpl | 18 ++ 6 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 static/languages/en-GB/oauth2-multiple.json create mode 100644 static/templates/partials/oauth2-multiple/choose-username.tpl diff --git a/lib/controllers.js b/lib/controllers.js index c9996f2..adca864 100644 --- a/lib/controllers.js +++ b/lib/controllers.js @@ -115,7 +115,7 @@ Controllers.editStrategy = async (req, res) => { payload.enabled = !!req.body.enabled; - const checkboxes = ['forceUsernameViaEmail', 'usernameViaEmail', 'trustEmailVerified', 'syncFullname', 'syncPicture']; + const checkboxes = ['forceUsernameViaEmail', 'usernameViaEmail', 'usernameChoice', 'trustEmailVerified', 'syncFullname', 'syncPicture']; checkboxes.forEach((prop) => { payload[prop] = payload.hasOwnProperty(prop) && payload[prop] === 'on' ? 1 : 0; }); diff --git a/library.js b/library.js index fe891a5..d171e10 100644 --- a/library.js +++ b/library.js @@ -11,6 +11,8 @@ const meta = require.main.require('./src/meta'); const groups = require.main.require('./src/groups'); const authenticationController = require.main.require('./src/controllers/authentication'); const routeHelpers = require.main.require('./src/routes/helpers'); +const slugify = require.main.require('./src/slugify'); +const utils = require.main.require('./public/src/utils'); const OAuth = module.exports; @@ -104,7 +106,15 @@ OAuth.loadStrategies = async (strategies) => { handle: displayName, email, email_verified, - }); + }, req, profile); + + if (user.pending) { + // createOrQueue has populated req.session.registration; NodeBB core + // will route to /register/complete. Finalisation runs from action:user.create. + winston.verbose(`[plugin/sso-oauth2-multiple] Deferring new user via ${name} (remote id ${id}) to username-picker interstitial`); + return done(null, { uid: 0 }); + } + winston.verbose(`[plugin/sso-oauth2-multiple] Successful login to uid ${user.uid} via ${name} (remote id ${id})`); await authenticationController.onSuccessfulLogin(req, user.uid); await OAuth.assignGroups({ provider: name, user, profile }); @@ -230,38 +240,59 @@ OAuth.getAssociations = async () => { })); }; -OAuth.login = async (payload) => { +OAuth.login = async (payload, req, profile) => { let uid = await OAuth.getUidByOAuthid(payload.name, payload.oAuthid); if (uid !== null) { // Existing User return ({ uid }); } - const { trustEmailVerified } = await OAuth.getStrategy(payload.name); + const { trustEmailVerified, usernameChoice } = await OAuth.getStrategy(payload.name); const { email } = payload; const email_verified = parseInt(trustEmailVerified, 10) && (payload.email_verified || payload.email_verified === true); - // Check for user via email fallback + // Check for user via email fallback -- linking path, never shows the picker if (email && email_verified) { uid = await user.getUidByEmail(payload.email); } - if (!uid) { - // New user - uid = await user.create({ - username: payload.handle, - }); + if (uid) { + await user.setUserField(uid, `${payload.name}Id`, payload.oAuthid); + await db.setObjectField(`${payload.name}Id:uid`, payload.oAuthid, uid); + return { uid }; + } - // Automatically confirm user email - if (email) { - await user.setUserField(uid, 'email', email); + // Brand-new user -- hand off to NodeBB's interstitial pipeline when the + // strategy opts into user-chosen usernames. + if (parseInt(usernameChoice, 10) && req) { + const userData = { + username: payload.handle || (email ? email.split('@')[0] : ''), + email, + _oauth2Multiple: { + provider: payload.name, + oAuthid: payload.oAuthid, + email_verified: !!email_verified, + profile, + }, + }; + await user.createOrQueue(req, userData); + return { uid: 0, pending: true }; + } - if (email_verified) { - await user.email.confirmByUid(uid); - } + // Legacy path: create immediately using the provider-derived handle. + uid = await user.create({ + username: payload.handle, + }); + + // Automatically confirm user email + if (email) { + await user.setUserField(uid, 'email', email); + + if (email_verified) { + await user.email.confirmByUid(uid); } } @@ -345,3 +376,99 @@ OAuth.whitelistFields = async (params) => { return params; }; + +OAuth.addInterstitial = async (data) => { + const { req, userData, interstitials } = data; + const marker = (userData && userData._oauth2Multiple) || + (req && req.session && req.session.registration && req.session.registration._oauth2Multiple); + if (!marker) { + return data; + } + + const suggested = await OAuth.suggestAvailableUsername(userData.username, userData.email); + + interstitials.push({ + template: 'partials/oauth2-multiple/choose-username.tpl', + data: { + provider: marker.provider, + suggestedUsername: suggested, + email: userData.email || '', + }, + callback: OAuth.onUsernameSubmit, + }); + + return data; +}; + +OAuth.onUsernameSubmit = async (userData, formData) => { + const marker = userData && userData._oauth2Multiple; + if (!marker) { + return userData; + } + + const chosen = (formData.username || '').trim(); + if (!chosen || !utils.isUserNameValid(chosen)) { + throw new Error('[[error:invalid-username]]'); + } + + const slug = slugify(chosen); + if (!slug) { + throw new Error('[[error:invalid-username]]'); + } + + const [userExists, groupExists] = await Promise.all([ + user.existsBySlug(slug), + groups.exists(chosen), + ]); + if (userExists || groupExists) { + throw new Error('[[error:username-taken]]'); + } + + userData.username = chosen; + userData.userslug = slug; + return userData; +}; + +OAuth.onUserCreate = async ({ user: created, data: userData }) => { + const marker = userData && userData._oauth2Multiple; + if (!marker || !created || !created.uid) { + return; + } + + try { + if (userData.email && marker.email_verified) { + await user.email.confirmByUid(created.uid); + } + + await user.setUserField(created.uid, `${marker.provider}Id`, marker.oAuthid); + await db.setObjectField(`${marker.provider}Id:uid`, marker.oAuthid, created.uid); + + const linkedUser = { uid: created.uid }; + await OAuth.assignGroups({ provider: marker.provider, user: linkedUser, profile: marker.profile }); + await OAuth.updateProfile(created.uid, marker.profile); + + plugins.hooks.fire('action:oauth2.login', { name: marker.provider, user: linkedUser, profile: marker.profile }); + winston.verbose(`[plugin/sso-oauth2-multiple] Completed deferred registration for uid ${created.uid} via ${marker.provider}`); + } catch (err) { + winston.error(`[plugin/sso-oauth2-multiple] Failed to finalise OAuth registration for uid ${created.uid}: ${err.stack}`); + } +}; + +OAuth.suggestAvailableUsername = async (handle, email) => { + const base = ((handle && handle.trim()) || + (email && email.split('@')[0]) || 'user').trim(); + const baseSlug = slugify(base); + if (baseSlug && !(await user.existsBySlug(baseSlug)) && !(await groups.exists(base))) { + return base; + } + for (let i = 1; i < 100; i += 1) { + const candidate = `${base}${i}`; + const slug = slugify(candidate); + /* eslint-disable no-await-in-loop */ + if (slug && !(await user.existsBySlug(slug)) && !(await groups.exists(candidate))) { + return candidate; + } + /* eslint-enable no-await-in-loop */ + } + return base; +}; diff --git a/plugin.json b/plugin.json index d414fbf..bb47861 100644 --- a/plugin.json +++ b/plugin.json @@ -10,7 +10,9 @@ { "hook": "filter:admin.header.build", "method": "addAdminNavigation" }, { "hook": "static:user.delete", "method": "deleteUserData" }, { "hook": "filter:user.whitelistFields", "method": "whitelistFields" }, - { "hook": "filter:auth.init", "method": "loadStrategies" } + { "hook": "filter:auth.init", "method": "loadStrategies" }, + { "hook": "filter:register.interstitial", "method": "addInterstitial" }, + { "hook": "action:user.create", "method": "onUserCreate" } ], "modules": { "../admin/plugins/sso-oauth2-multiple.js": "./static/lib/admin.js" @@ -21,5 +23,6 @@ "acpScripts": [ "static/lib/acp.js" ], - "templates": "static/templates" + "templates": "static/templates", + "languages": "static/languages" } diff --git a/static/languages/en-GB/oauth2-multiple.json b/static/languages/en-GB/oauth2-multiple.json new file mode 100644 index 0000000..31ece9c --- /dev/null +++ b/static/languages/en-GB/oauth2-multiple.json @@ -0,0 +1,6 @@ +{ + "choose-username.title": "Choose your username", + "choose-username.intro": "You signed in via %1. Pick the username you want to use on this forum. This cannot be easily changed later.", + "choose-username.help": "Letters, numbers, spaces, periods, hyphens and underscores are allowed.", + "choose-username.email-provider": "Your email comes from your identity provider and cannot be edited here." +} diff --git a/static/templates/partials/edit-oauth2-strategy.tpl b/static/templates/partials/edit-oauth2-strategy.tpl index dfb361e..08b261f 100644 --- a/static/templates/partials/edit-oauth2-strategy.tpl +++ b/static/templates/partials/edit-oauth2-strategy.tpl @@ -132,6 +132,11 @@

+
+ + +
+
diff --git a/static/templates/partials/oauth2-multiple/choose-username.tpl b/static/templates/partials/oauth2-multiple/choose-username.tpl new file mode 100644 index 0000000..6f56bfa --- /dev/null +++ b/static/templates/partials/oauth2-multiple/choose-username.tpl @@ -0,0 +1,18 @@ +
+

[[oauth2-multiple:choose-username.title]]

+

[[oauth2-multiple:choose-username.intro, {provider}]]

+
+ +
+ + +

[[oauth2-multiple:choose-username.help]]

+
+ +{{{ if email }}} +
+ + +

[[oauth2-multiple:choose-username.email-provider]]

+
+{{{ end }}} From 10e779ddbbfde9e5cc41a445efb8752b52c655ba Mon Sep 17 00:00:00 2001 From: Elias Hackradt Date: Wed, 15 Apr 2026 19:31:31 +0200 Subject: [PATCH 6/6] Working state --- library.js | 107 +++++++++++++++++++--------------------------------- plugin.json | 3 +- 2 files changed, 39 insertions(+), 71 deletions(-) diff --git a/library.js b/library.js index d171e10..cd5de31 100644 --- a/library.js +++ b/library.js @@ -106,14 +106,7 @@ OAuth.loadStrategies = async (strategies) => { handle: displayName, email, email_verified, - }, req, profile); - - if (user.pending) { - // createOrQueue has populated req.session.registration; NodeBB core - // will route to /register/complete. Finalisation runs from action:user.create. - winston.verbose(`[plugin/sso-oauth2-multiple] Deferring new user via ${name} (remote id ${id}) to username-picker interstitial`); - return done(null, { uid: 0 }); - } + }, req); winston.verbose(`[plugin/sso-oauth2-multiple] Successful login to uid ${user.uid} via ${name} (remote id ${id})`); await authenticationController.onSuccessfulLogin(req, user.uid); @@ -240,7 +233,7 @@ OAuth.getAssociations = async () => { })); }; -OAuth.login = async (payload, req, profile) => { +OAuth.login = async (payload, req) => { let uid = await OAuth.getUidByOAuthid(payload.name, payload.oAuthid); if (uid !== null) { // Existing User @@ -265,41 +258,36 @@ OAuth.login = async (payload, req, profile) => { return { uid }; } - // Brand-new user -- hand off to NodeBB's interstitial pipeline when the - // strategy opts into user-chosen usernames. - if (parseInt(usernameChoice, 10) && req) { - const userData = { - username: payload.handle || (email ? email.split('@')[0] : ''), - email, - _oauth2Multiple: { - provider: payload.name, - oAuthid: payload.oAuthid, - email_verified: !!email_verified, - profile, - }, - }; - await user.createOrQueue(req, userData); - return { uid: 0, pending: true }; - } + // Brand-new user. When usernameChoice is on, create with a unique placeholder + // and stage a session marker so middleware.registrationComplete routes the + // logged-in user through /register/complete, where our interstitial fires. + const pickerMode = Boolean(parseInt(usernameChoice, 10) && req); + const username = pickerMode ? + `oauth2-tmp-${payload.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : + payload.handle; - // Legacy path: create immediately using the provider-derived handle. - uid = await user.create({ - username: payload.handle, - }); + uid = await user.create({ username }); - // Automatically confirm user email if (email) { await user.setUserField(uid, 'email', email); - if (email_verified) { await user.email.confirmByUid(uid); } } - // Save provider-specific information to the user await user.setUserField(uid, `${payload.name}Id`, payload.oAuthid); await db.setObjectField(`${payload.name}Id:uid`, payload.oAuthid, uid); + if (pickerMode) { + req.session.registration = req.session.registration || {}; + req.session.registration.uid = uid; + req.session.registration._oauth2MultipleRename = { + provider: payload.name, + suggestedBase: payload.handle || (email ? email.split('@')[0] : ''), + email: email || '', + }; + } + return { uid }; }; @@ -378,21 +366,20 @@ OAuth.whitelistFields = async (params) => { }; OAuth.addInterstitial = async (data) => { - const { req, userData, interstitials } = data; - const marker = (userData && userData._oauth2Multiple) || - (req && req.session && req.session.registration && req.session.registration._oauth2Multiple); - if (!marker) { + const { userData, interstitials } = data; + const marker = userData && userData._oauth2MultipleRename; + if (!marker || !userData.uid) { return data; } - const suggested = await OAuth.suggestAvailableUsername(userData.username, userData.email); + const suggested = await OAuth.suggestAvailableUsername(marker.suggestedBase, marker.email); interstitials.push({ template: 'partials/oauth2-multiple/choose-username.tpl', data: { provider: marker.provider, suggestedUsername: suggested, - email: userData.email || '', + email: marker.email || '', }, callback: OAuth.onUsernameSubmit, }); @@ -401,8 +388,8 @@ OAuth.addInterstitial = async (data) => { }; OAuth.onUsernameSubmit = async (userData, formData) => { - const marker = userData && userData._oauth2Multiple; - if (!marker) { + const marker = userData && userData._oauth2MultipleRename; + if (!marker || !userData.uid) { return userData; } @@ -416,42 +403,24 @@ OAuth.onUsernameSubmit = async (userData, formData) => { throw new Error('[[error:invalid-username]]'); } - const [userExists, groupExists] = await Promise.all([ - user.existsBySlug(slug), + const [existingUid, groupExists] = await Promise.all([ + user.getUidByUserslug(slug), groups.exists(chosen), ]); - if (userExists || groupExists) { + if ((existingUid && parseInt(existingUid, 10) !== parseInt(userData.uid, 10)) || groupExists) { throw new Error('[[error:username-taken]]'); } - userData.username = chosen; - userData.userslug = slug; - return userData; -}; + await user.updateProfile(userData.uid, { uid: userData.uid, username: chosen }, ['username']); -OAuth.onUserCreate = async ({ user: created, data: userData }) => { - const marker = userData && userData._oauth2Multiple; - if (!marker || !created || !created.uid) { - return; - } - - try { - if (userData.email && marker.email_verified) { - await user.email.confirmByUid(created.uid); + // Strip marker + temp fields so registerComplete's setUserFields doesn't overwrite the rename. + delete userData._oauth2MultipleRename; + Object.keys(userData).forEach((key) => { + if (key !== 'uid' && key !== 'returnTo') { + delete userData[key]; } - - await user.setUserField(created.uid, `${marker.provider}Id`, marker.oAuthid); - await db.setObjectField(`${marker.provider}Id:uid`, marker.oAuthid, created.uid); - - const linkedUser = { uid: created.uid }; - await OAuth.assignGroups({ provider: marker.provider, user: linkedUser, profile: marker.profile }); - await OAuth.updateProfile(created.uid, marker.profile); - - plugins.hooks.fire('action:oauth2.login', { name: marker.provider, user: linkedUser, profile: marker.profile }); - winston.verbose(`[plugin/sso-oauth2-multiple] Completed deferred registration for uid ${created.uid} via ${marker.provider}`); - } catch (err) { - winston.error(`[plugin/sso-oauth2-multiple] Failed to finalise OAuth registration for uid ${created.uid}: ${err.stack}`); - } + }); + return userData; }; OAuth.suggestAvailableUsername = async (handle, email) => { diff --git a/plugin.json b/plugin.json index bb47861..6811c41 100644 --- a/plugin.json +++ b/plugin.json @@ -11,8 +11,7 @@ { "hook": "static:user.delete", "method": "deleteUserData" }, { "hook": "filter:user.whitelistFields", "method": "whitelistFields" }, { "hook": "filter:auth.init", "method": "loadStrategies" }, - { "hook": "filter:register.interstitial", "method": "addInterstitial" }, - { "hook": "action:user.create", "method": "onUserCreate" } + { "hook": "filter:register.interstitial", "method": "addInterstitial" } ], "modules": { "../admin/plugins/sso-oauth2-multiple.js": "./static/lib/admin.js"