-
+
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 @@
+