diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js
index 1e7856a..1fb86be 100644
--- a/frontend/js/views/admin.js
+++ b/frontend/js/views/admin.js
@@ -348,11 +348,17 @@ async function loadUsers() {
${users.map(u => `
-
${u.name || u.email}
${u.email}
-
${u.auth_provider}
+
+
${esc(u.name || u.email)}
${esc(u.email)}
+
${esc(u.auth_provider)}
${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : t('common.never')}
-
diff --git a/frontend/js/views/login.js b/frontend/js/views/login.js
index e31965d..f1585c0 100644
--- a/frontend/js/views/login.js
+++ b/frontend/js/views/login.js
@@ -601,17 +601,46 @@ function setupHandlers(config, isSetup) {
* mapping again on submit, so the slug is never published to the page. POST keeps the address
* out of the URL, browser history and any Referer the provider's page would send.
*/
+ /*
+ * A BUTTON that fetches and then navigates — not a form that submits.
+ *
+ * The dashboard's CSP is `form-action 'self'`, and Chrome applies it across the whole
+ * redirect chain, so a form POST that 302s on to the customer's identity provider was
+ * ABORTED with nothing shown to the user at all. The provider origins cannot be allowlisted
+ * because customers supply them. A script-initiated navigation is not covered by
+ * form-action, so the page asks the server where to go and goes there.
+ *
+ * Styled secondary: "Sign In" is the primary action while a password still works, and two
+ * identical blue buttons stacked one above the other sent people to their IdP by muscle
+ * memory after typing a password.
+ */
slot.innerHTML = `
-
+
${t('auth.sso_org_hint')}
`;
slot.style.display = '';
+
+ const btn = slot.querySelector('#orgSsoBtn');
+ if (btn) btn.addEventListener('click', async () => {
+ btn.disabled = true;
+ try {
+ const r = await fetch('/api/auth/sso/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ email }),
+ });
+ const body = await r.json().catch(() => ({}));
+ if (!r.ok || !body.start_url) throw new Error(body.error || `start ${r.status}`);
+ window.location.assign(body.start_url);
+ } catch {
+ btn.disabled = false;
+ showError(t('auth.sso_err_provider_unavailable'));
+ }
+ });
} catch {
// A failed lookup must never block a password login — the form still works, and the password
// box comes back rather than leaving someone staring at a form with no way to submit it.
diff --git a/server/routes/auth.js b/server/routes/auth.js
index 27338e8..b8e7d24 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -102,6 +102,15 @@ router.post('/register', (req, res) => {
}
const { email, password, name, createOrg } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
+ /*
+ * Registration accepted anything with an @ in it, so `@acme.test`
+ * became a real row — markup with no spaces, which is why it also slipped the asserted-email
+ * check. Rendering is escaped now, but an address that is not an address has no business being
+ * stored: it is displayed on operator screens, put in emails, and compared against domains.
+ */
+ if (!ASSERTED_EMAIL_RE.test(String(email).toLowerCase()) || /[<>"'`\\]/.test(String(email))) {
+ return res.status(400).json({ error: 'Enter a valid email address' });
+ }
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
/*
@@ -1168,10 +1177,32 @@ router.get('/sso/discover', (req, res) => {
*/
router.post('/sso/start', express.urlencoded({ extended: false }), (req, res) => {
const provider = oidcProviders.forEmail((req.body && req.body.email) || req.query.email);
- // An unknown domain is answered exactly like a known one that is disabled: back to the login page
- // with nothing learned.
- if (!provider) return res.redirect('/app#/login?sso_error=unknown_provider');
- res.redirect(`/api/auth/oidc/${encodeURIComponent(provider.slug)}/start`);
+ /*
+ * ⚠️ ANSWER WITH JSON when the page asks for it, rather than a redirect.
+ *
+ * This used to be a plain