fix: Sign in goes to the provider, not to a box containing one button
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Has been cancelled

The landing page's Sign in opened a modal whose entire content was a
single "Sign in with PedsHub SSO" link. That is a step that exists to be
clicked through.

Every sign-in control on the page — the header, the hero, the closing
call to action — now goes straight to /api/auth/sso/login when the site
is SSO-only. The modal is still built and still opens on a site that has
a password door, which is the only thing it was ever for.

/login is deliberately left as it is. It renders the one button rather
than redirecting, because it is also where the provider sends somebody
back when sign-in fails — ?error=sso_failed — and a page that redirected
on sight would bounce them into the provider again, forever.

Verified live: one click from the landing page lands on
sso.pedshub.com's flow with the client id, callback, scope, state and
nonce, and no modal is rendered on the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 15:31:50 +02:00
parent cdf4ab80b4
commit a8cc0c40df
2 changed files with 40 additions and 12 deletions

View file

@ -596,13 +596,31 @@ export default function LandingPage() {
//: One door. Accounts are made at the identity provider, so there is no //: One door. Accounts are made at the identity provider, so there is no
//: second mode for this to be in. //: second mode for this to be in.
const [authOpen, setAuthOpen] = useState(false) const [authOpen, setAuthOpen] = useState(false)
//: Where "Sign in" goes. On a site whose only way in is the provider there
//: is nothing to ask first, so the button is the provider's door rather than
//: a box containing one button a modal whose entire content is a link is a
//: step that exists to be clicked through.
const [ssoOnly, setSsoOnly] = useState(false)
useEffect(() => {
let live = true
api.get('/auth/sso/config')
.then(res => {
if (live) setSsoOnly(res.data?.sso_enabled === true && res.data?.sso_only === true)
})
.catch(() => {})
return () => { live = false }
}, [])
const signIn = () => {
if (ssoOnly) window.location.href = '/api/auth/sso/login'
else setAuthOpen(true)
}
return ( return (
<div className="lp-page"> <div className="lp-page">
{authOpen && <AuthModal onClose={() => setAuthOpen(false)} />} {authOpen && !ssoOnly && <AuthModal onClose={() => setAuthOpen(false)} />}
<Navbar onSignIn={() => setAuthOpen(true)} /> <Navbar onSignIn={signIn} />
{/* ── Hero ───────────────────────────────────────────────────────────── */} {/* ── Hero ───────────────────────────────────────────────────────────── */}
<section className="lp-hero"> <section className="lp-hero">
@ -627,7 +645,7 @@ export default function LandingPage() {
{/* One button. "Create an account" led to a form that no longer {/* One button. "Create an account" led to a form that no longer
exists an account is made by following an invitation from exists an account is made by following an invitation from
the identity provider, not from here. */} the identity provider, not from here. */}
<button onClick={() => setAuthOpen(true)} className="btn btn-primary">Sign in</button> <button onClick={signIn} className="btn btn-primary">Sign in</button>
</>} </>}
</div> </div>
</div> </div>
@ -716,7 +734,7 @@ export default function LandingPage() {
🏥 PedsHub<span>© {new Date().getFullYear()}</span> 🏥 PedsHub<span>© {new Date().getFullYear()}</span>
</span> </span>
<div className="lp-footer-links"> <div className="lp-footer-links">
<button onClick={() => setAuthOpen(true)}>Sign In</button> <button onClick={signIn}>Sign In</button>
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer">Clinical Tools</a> <a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer">Clinical Tools</a>
</div> </div>
</div> </div>

View file

@ -141,9 +141,16 @@ describe('the auth modal', () => {
expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument() expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument()
}) })
it('shows the provider and nothing else on an SSO-only site', async () => { it('goes straight to the provider on an SSO-only site, with no modal', async () => {
// And nothing at all before the answer arrives: drawing the email form // A box whose entire content is one link is a step that exists to be
// and then replacing it is a flash of a way in that does not exist. // clicked through. There is nothing to ask first when the provider is the
// only way in.
const go = vi.fn()
const real = Object.getOwnPropertyDescriptor(window, 'location')
Object.defineProperty(window, 'location', {
configurable: true,
value: { get href() { return '' }, set href(v) { go(v) } },
})
api.get.mockImplementation(url => Promise.resolve({ api.get.mockImplementation(url => Promise.resolve({
data: url === '/public/stats' ? STATS data: url === '/public/stats' ? STATS
: url === '/auth/sso/config' : url === '/auth/sso/config'
@ -151,10 +158,13 @@ describe('the auth modal', () => {
: {}, : {},
})) }))
mount() mount()
await userEvent.click(screen.getByRole('button', { name: 'Open login' })) const open = await screen.findByRole('button', { name: 'Open login' })
expect(await screen.findByRole('link', { name: /Sign in with PedsHub SSO/ })) // Given a tick for the config to arrive before the click is judged.
.toHaveAttribute('href', '/api/auth/sso/login') await waitFor(() => expect(api.get).toHaveBeenCalledWith('/auth/sso/config'))
expect(screen.queryByRole('form', { name: 'Sign in' })).toBeNull() await userEvent.click(open)
expect(screen.queryByText(/signs in through/)).toBeNull() await waitFor(() => expect(go).toHaveBeenCalledWith('/api/auth/sso/login'))
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
if (real) Object.defineProperty(window, 'location', real)
}) })
}) })