refactor(oidc): extract common (email + username)

There is common functionality between all of the GUI code for actions on
protected endpoints. Update UIWindowChangeEmail and
UIWindowChangeUsername to both use a new utility function called
openRevalidatePopup in util/openid.js.

This file is called `openid.js` instead of `oidc.js` so that it's more
easily recognized by contributors who might be more familiar with the
name of the organization than the name of the standard itself.

After these changes, UIWindowChangePassword and the "disable 2FA" button
in UITabSecurity still need to be updated to use `util/openid.js`
instead of duplicating this functionality.

The justification for following DRY here instead of leaving the
implementation as-is is because these flows are particularly error
prone and will be difficult to maintain without this consistency. Some
subtle bugs I previously wasn't aware of got fixed in the process.
This commit is contained in:
KernelDeimos
2026-02-12 18:27:01 -05:00
parent 0b8eafa128
commit df1f5c44cc
3 changed files with 141 additions and 133 deletions
+49 -80
View File
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { openRevalidatePopup } from '../../util/openid.js';
import Placeholder from '../../util/Placeholder.js';
import PasswordEntry from '../Components/PasswordEntry.js';
import UIWindow from '../UIWindow.js';
@@ -107,6 +108,25 @@ async function UIWindowChangeEmail (options) {
const apiUrl = `${origin}/user-protected/change-email`;
let revalidated = false;
const hint = $(el_window).find('.change-email-oidc-hint');
const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.';
const myOpenRevalidatePopup = async (revalidateUrl) => {
revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url);
$(el_window).find('.change-email-btn').addClass('disabled');
hint.text(REVALIDATE_POPUP_TEXT).show();
try {
await openRevalidatePopup(revalidateUrl);
} catch (e) {
onError(e.message || 'Authentication failed');
return;
} finally {
hint.hide();
}
$(el_window).find('.change-email-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show();
$(el_window).find('.change-email-revalidate-btn').hide();
};
$(el_window).find('.change-email-btn').on('click', async function (e) {
$(el_window).find('.form-success-msg, .form-error-msg').hide();
@@ -120,11 +140,38 @@ async function UIWindowChangeEmail (options) {
return;
}
if ( oidc_only && !revalidated && !password ) {
await myOpenRevalidatePopup();
const res = await doSubmit({ new_email });
const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({}));
if ( res.ok ) onSuccess();
else onError(data.message || 'Request failed');
return;
}
$(el_window).find('.form-error-msg').hide();
$(el_window).find('.change-email-btn').addClass('disabled');
$(el_window).find('.new-email').attr('disabled', true);
const doSubmit = () => fetch(apiUrl, {
let res = await doSubmit({ new_email, password });
const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({}));
if ( res.ok ) {
onSuccess();
return;
}
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
await myOpenRevalidatePopup(data.revalidate_url);
const r = await doSubmit();
if ( r.ok ) onSuccess();
else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed'));
return;
}
onError(data.message || 'Request failed');
});
function doSubmit ({ new_email, password }) {
return fetch(apiUrl, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
@@ -133,88 +180,10 @@ async function UIWindowChangeEmail (options) {
password: password !== undefined && password !== '' ? password : undefined,
}),
});
if ( oidc_only && !revalidated && !password ) {
openRevalidatePopup(null, async (err) => {
if ( err ) {
onError(err.message || 'Re-validation required.');
return;
}
const res = await doSubmit();
const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({}));
if ( res.ok ) onSuccess();
else onError(data.message || 'Request failed');
});
return;
}
let res = await doSubmit();
const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({}));
if ( res.ok ) {
onSuccess();
return;
}
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
openRevalidatePopup(data.revalidate_url, async (err) => {
if ( err ) {
onError(err.message || 'Re-validation required.');
return;
}
const r2 = await doSubmit();
const d2 = r2.ok ? await r2.json().catch(() => ({})) : await r2.json().catch(() => ({}));
if ( r2.ok ) onSuccess();
else onError(d2.message || 'Request failed');
});
return;
}
onError(data.message || 'Request failed');
});
function openRevalidatePopup (revalidateUrl, onDone) {
const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url);
if ( ! url ) {
onDone && onDone(new Error('No revalidate URL'));
return null;
}
let doneCalled = false;
const hint = $(el_window).find('.change-email-oidc-hint');
hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show();
const popup = window.open(url, 'puter-revalidate', 'width=500,height=600');
const onMessage = (ev) => {
if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return;
if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return;
if ( doneCalled ) return;
doneCalled = true;
window.removeEventListener('message', onMessage);
revalidated = true;
hint.hide();
$(el_window).find('.change-email-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show();
$(el_window).find('.change-email-revalidate-btn').hide();
onDone && onDone();
};
window.addEventListener('message', onMessage);
const checkClosed = setInterval(() => {
if ( popup && popup.closed ) {
clearInterval(checkClosed);
window.removeEventListener('message', onMessage);
hint.hide();
if ( ! doneCalled ) {
doneCalled = true;
onDone && onDone(new Error('Popup closed'));
}
}
}, 300);
return popup;
}
$(el_window).find('.change-email-revalidate-btn').on('click', function () {
openRevalidatePopup(null, (err) => {
if ( err ) {
$(el_window).find('.form-error-msg').html(html_encode(err.message || 'Re-validation required.'));
$(el_window).find('.form-error-msg').fadeIn();
}
});
myOpenRevalidatePopup();
});
function onError (message) {
+31 -53
View File
@@ -18,6 +18,7 @@
*/
import update_username_in_gui from '../helpers/update_username_in_gui.js';
import { openRevalidatePopup } from '../util/openid.js';
import UIWindow from './UIWindow.js';
async function UIWindowChangeUsername (options) {
@@ -93,6 +94,25 @@ async function UIWindowChangeUsername (options) {
const apiUrl = `${origin}/user-protected/change-username`;
let revalidated = false;
const hint = $(el_window).find('.change-username-oidc-hint');
const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.';
const myOpenRevalidatePopup = async (revalidateUrl) => {
revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url);
$(el_window).find('.change-username-btn').addClass('disabled');
hint.text(REVALIDATE_POPUP_TEXT).show();
try {
await openRevalidatePopup(revalidateUrl);
} catch (e) {
onError(e.message || 'Authentication failed');
return;
} finally {
hint.hide();
}
$(el_window).find('.change-username-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show();
$(el_window).find('.change-username-revalidate-btn').hide();
};
$(el_window).find('.change-username-btn').on('click', async function (e) {
$(el_window).find('.form-success-msg, .form-error-msg').hide();
const new_username = $(el_window).find('.new-username').val();
@@ -106,16 +126,12 @@ async function UIWindowChangeUsername (options) {
}
if ( oidc_only && !revalidated && !password ) {
$(el_window).find('.change-username-btn').addClass('disabled');
openRevalidatePopup(null, async (err) => {
if ( err ) {
onError(err.message || 'Re-validation required.');
return;
}
const res = await doSubmit();
const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({}));
if ( res.ok ) onSuccess();
else onError(data.message || 'Request failed');
});
myOpenRevalidatePopup();
const res = await doSubmit();
const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({}));
if ( res.ok ) onSuccess();
else onError(data.message || 'Request failed');
return;
}
$(el_window).find('.form-error-msg').hide();
@@ -130,55 +146,17 @@ async function UIWindowChangeUsername (options) {
return;
}
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
openRevalidatePopup(data.revalidate_url, async () => {
const r = await doSubmit();
if ( r.ok ) onSuccess();
else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed'));
});
myOpenRevalidatePopup(data.revalidate_url);
const r = await doSubmit();
if ( r.ok ) onSuccess();
else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed'));
return;
}
onError(data.message || 'Request failed');
});
function openRevalidatePopup (revalidateUrl, onDone) {
const url = revalidateUrl || (window.user && window.user.oidc_revalidate_url);
if ( ! url ) {
onDone && onDone(new Error('No revalidate URL'));
return null;
}
let doneCalled = false;
const hint = $(el_window).find('.change-username-oidc-hint');
hint.text(i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.').show();
const popup = window.open(url, 'puter-revalidate', 'width=500,height=600');
const onMessage = (ev) => {
if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return;
if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return;
if ( doneCalled ) return;
doneCalled = true;
window.removeEventListener('message', onMessage);
revalidated = true;
hint.hide();
$(el_window).find('.change-username-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show();
$(el_window).find('.change-username-revalidate-btn').hide();
onDone && onDone();
};
window.addEventListener('message', onMessage);
const checkClosed = setInterval(() => {
if ( popup && popup.closed ) {
clearInterval(checkClosed);
window.removeEventListener('message', onMessage);
hint.hide();
if ( ! doneCalled ) {
doneCalled = true;
onDone && onDone(new Error('Popup closed'));
}
}
}, 300);
return popup;
}
$(el_window).find('.change-username-revalidate-btn').on('click', function () {
openRevalidatePopup();
myOpenRevalidatePopup();
});
function doSubmit (password) {
+61
View File
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2026-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import TeePromise from './TeePromise.js';
/**
* This file contains common functions that are used to re-authenticate an
* OIDC-authenticated user when performing actions on protected endpoints.
*
* No design patterns, no abstractions; only simple functions.
* (this is not merely a description; it is a guideline for future changes)
*/
const POPUP_FEATURES = 'width=500,height=600';
export const openRevalidatePopup = async (revalidateUrl) => {
const donePromise = new TeePromise();
const url = revalidateUrl;
if ( ! url ) {
throw new Error('No revalidate URL');
}
let doneCalled = false;
const popup = window.open(url, 'puter-revalidate', POPUP_FEATURES);
const onMessage = ev => {
if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return;
if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return;
if ( doneCalled ) return;
doneCalled = true;
window.removeEventListener('message', onMessage);
donePromise.resolve();
};
window.addEventListener('message', onMessage);
const checkClosed = setInterval(() => {
if ( popup && popup.closed ) {
clearInterval(checkClosed);
window.removeEventListener('message', onMessage);
if ( ! doneCalled ) {
doneCalled = true;
donePromise.reject(new Error('Popup closed'));
}
}
}, 300);
await donePromise;
};