diff --git a/frontend/js/components/toast.js b/frontend/js/components/toast.js index 966f9e6..2bff52a 100644 --- a/frontend/js/components/toast.js +++ b/frontend/js/components/toast.js @@ -1,3 +1,15 @@ +/* + * ⚠️ Messages are ESCAPED. This builds innerHTML, and callers pass server error strings straight + * in — including ones that reflect user input verbatim, such as the OIDC issuer in + * `not a URL: `. A review typed `` as an issuer and got script + * execution in the admin's own session. A toast is a place text goes, never markup. + */ +function esc(v) { + return String(v == null ? '' : v) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + export function showToast(message, type = 'info', duration = 4000) { const container = document.getElementById('toastContainer'); const toast = document.createElement('div'); @@ -10,7 +22,7 @@ export function showToast(message, type = 'info', duration = 4000) { type === 'error' ? '' : ''} - ${message} + ${esc(message)} `; container.appendChild(toast); setTimeout(() => { diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 0a2ae83..31ab7aa 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -1413,6 +1413,16 @@ export default { 'admin.orgs.ws_deleted': 'Workspace "{name}" deleted', 'admin.access_denied': 'Access Denied', 'admin.access_denied_desc': 'Platform admin access required.', + 'admin.sso_only.title': 'Single sign-on removal requests', + 'admin.sso_only.desc': 'An organization has asked to stop requiring its identity provider. Until you approve, nothing changes for them.', + 'admin.sso_only.requested_by': 'Requested by {who}', + 'admin.sso_only.effect': 'Approving re-opens password sign-in for everyone at this organization\u2019s verified domains.', + 'admin.sso_only.approve': 'Approve removal', + 'admin.sso_only.reject': 'Reject', + 'admin.sso_only.confirm': 'Re-open password sign-in for this organization?\n\nTheir identity provider will no longer be the only way in. Approve only if you are satisfied the request is genuine.', + 'admin.sso_only.approved': 'Approved. Password sign-in is re-opened for that organization.', + 'admin.sso_only.rejected': 'Rejected. Single sign-on is still required.', + 'admin.sso_only.failed': 'That did not work.', 'admin.all_users': 'All Users', 'admin.plans': 'Subscription Plans', 'admin.col.accounts': 'Accounts', diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js index 0647949..1e7856a 100644 --- a/frontend/js/views/admin.js +++ b/frontend/js/views/admin.js @@ -79,6 +79,15 @@ export async function render(container) { + + +

${t('admin.all_users')}

${t('common.loading')}

@@ -137,6 +146,7 @@ export async function render(container) { loadUsers(); loadOrgs(); + loadSsoOnlyRequests(); loadBranding(); loadPlans(); loadSystem(); @@ -146,6 +156,68 @@ export async function render(container) { // #36: list organizations with owner + resource counts; platform admin can // cascade-delete an org or an individual workspace (type-the-name confirm). +/* + * Pending "stop requiring single sign-on" requests. + * + * The notification email tells the operator to review this under Admin, and for a while it did not + * exist — the only way to approve was curl, while the customer sat locked out. The section hides + * itself when there is nothing pending so it is never noise. + */ +async function loadSsoOnlyRequests() { + const section = document.getElementById('ssoOnlySection'); + const host = document.getElementById('ssoOnlyRequests'); + if (!section || !host) return; + // NB: `api` is a map of named methods, not a generic client — there is no api.get(), and calling + // one silently hid this whole section behind the catch below. + const authed = (path, init = {}) => fetch(`/api${path}`, { + ...init, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...(init.headers || {}) }, + }); + + let requests = []; + try { + const res = await authed('/organizations/sso-only/removal-requests'); + if (!res.ok) throw new Error(String(res.status)); + requests = (await res.json()).requests || []; + } catch { + section.style.display = 'none'; + return; + } + if (!requests.length) { section.style.display = 'none'; return; } + section.style.display = ''; + + host.innerHTML = requests.map((r) => ` +
+
${esc(r.organization_name || r.organization_id)}
+
+ ${esc(t('admin.sso_only.requested_by', { who: r.requested_by_email || 'unknown' }))} +
+ ${r.reason ? `
${esc(r.reason)}
` : ''} +
${esc(t('admin.sso_only.effect'))}
+
+ + +
+
`).join(''); + + const decide = async (id, decision) => { + try { + const res = await authed(`/organizations/sso-only/removal-requests/${id}/${decision}`, { method: 'POST', body: '{}' }); + if (!res.ok) throw new Error(((await res.json().catch(() => ({}))).error) || String(res.status)); + showToast(t(decision === 'approve' ? 'admin.sso_only.approved' : 'admin.sso_only.rejected'), 'success'); + await loadSsoOnlyRequests(); + } catch (e) { + showToast((e && e.message) || t('admin.sso_only.failed'), 'error'); + } + }; + // Approving RE-OPENS password sign-in for a whole organization, so it is confirmed; rejecting + // only leaves the safe state in place and is not. + host.querySelectorAll('[data-sso-approve]').forEach((b) => b.addEventListener('click', () => { + if (window.confirm(t('admin.sso_only.confirm'))) decide(b.dataset.ssoApprove, 'approve'); + })); + host.querySelectorAll('[data-sso-reject]').forEach((b) => b.addEventListener('click', () => decide(b.dataset.ssoReject, 'reject'))); +} + async function loadOrgs() { const el = document.getElementById('orgsTable'); if (!el) return; diff --git a/frontend/js/views/login.js b/frontend/js/views/login.js index 9c0552d..35f330e 100644 --- a/frontend/js/views/login.js +++ b/frontend/js/views/login.js @@ -105,13 +105,19 @@ export async function render(container) {
- + + - - + list off the login page. + + ⚠️ BELOW the input, inside the same group. Above it, the button sat between the + "Password" label and its field — so the label described the SSO button and the + password box had none at all. It has to stay INSIDE the group, because hiding the + group is how the password is hidden and the button must survive that... which is + exactly why setPasswordVisible() hides the FIELD, never the container. --> +
${isSetup ? `
@@ -182,7 +188,7 @@ export async function render(container) {
${(config.providers || []).length ? ` -
+

${t('auth.divider_or')}
@@ -533,6 +539,17 @@ function setupHandlers(config, isSetup) { */ const instance = document.getElementById('instanceProviders'); if (instance) instance.style.display = show; + /* + * "Create Account" goes too. Registration at an SSO-only domain is refused by the server, and + * leaving the button was worse than useless: it was the ONLY action left on the card, so the + * page invited the one thing that cannot work. + */ + const reg = document.getElementById('showRegisterBtn'); + if (reg) reg.style.display = show; + // The OR divider sits outside #instanceProviders, so hiding those alone left a dangling rule + // with nothing beneath it. + const divider = document.getElementById('ssoDivider'); + if (divider) divider.style.display = show; // "Forgot your password?" sits in its own

; hide the wrapper so no empty gap is left. const forgot = document.getElementById('forgotLink'); if (forgot) { diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index d08da70..cc80010 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -706,7 +706,7 @@ export async function render(container) { const origin = `${window.location.protocol}//${window.location.host}`; listEl.innerHTML = providers.map((p) => `

-
+
${esc(p.name)} ${p.enabled ? '' : ` — ${esc(t('sso.disabled'))}`} @@ -716,7 +716,9 @@ export async function render(container) { ? `
⚠️ ${esc(t('sso.unverified_warning'))}
` : ''}
-
+ +