From 963ca04cf86515c59381cb8aeec0b52acf9b4e08 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 14:37:10 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20no=20account=20creation=20here=20?= =?UTF-8?q?=E2=80=94=20the=20provider=20makes=20accounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gone: /auth/register, /auth/signup-policy, POST /admin/users, the RegisterPage, the register half of the landing modal, the Register button, the "Sign up" link, the "Create an account" hero button, and the UserCreate schema. /register redirects to /login for anybody holding a bookmark. A first admin on a fresh install still comes from DEFAULT_ADMIN_EMAIL at startup, so nothing is locked out. And no flash of the old way in. Both sign-in surfaces defaulted to "no provider" and drew the email form while /auth/sso/config was in flight, then swapped it — so a reload showed a form that does not exist, briefly, every time. They render nothing until the answer arrives. The landing modal is now one button, "Sign in with PedsHub SSO", with no sentence under it: the button already says where you are going. Also, the section strip takes the width it has. It sat inside the 1200px measure that keeps an article readable, so on a wide desktop the last entries fell off the end and a scroll arrow appeared beside acres of empty space. Verified at 1280, 1600 and 1920: ten links, no arrows. And "Make a deck" comes out of the strip and the phone menu — that was an over-reach on my part. The landing CTA keeps it, pointing at app.pedshub.com/#resources, which is what was actually asked for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/admin.py | 39 +---- backend/app/routers/auth.py | 106 +------------ backend/app/schemas/auth.py | 8 - backend/tests/api-contract.json | 23 --- backend/tests/test_login_without_captcha.py | 108 ------------- backend/tests/test_sso_hardening.py | 51 +------ frontend/src/App.jsx | 5 +- frontend/src/components/Navbar.jsx | 37 +---- frontend/src/index.css | 6 + frontend/src/pages/LandingPage.jsx | 161 ++++---------------- frontend/src/pages/LandingPage.test.jsx | 32 ++-- frontend/src/pages/LoginCaptcha.test.jsx | 39 ++--- frontend/src/pages/LoginPage.jsx | 19 +-- frontend/src/pages/RegisterPage.jsx | 150 ------------------ 14 files changed, 90 insertions(+), 694 deletions(-) delete mode 100644 backend/tests/test_login_without_captcha.py delete mode 100644 frontend/src/pages/RegisterPage.jsx diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index f0d8d4a..ee14075 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -14,9 +14,9 @@ from app.database import get_db from app.models.user import User from app.models.ai_model_config import AIModelConfig from app.services import ai_service, site_settings, sso_roles, tts_voices -from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate +from app.schemas.auth import UserResponse, UserUpdateRole from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate -from app.utils.auth import require_admin, get_current_user, get_password_hash +from app.utils.auth import require_admin, get_current_user router = APIRouter() @@ -96,41 +96,6 @@ def delete_user( db.commit() -@router.post("/users", response_model=UserResponse) -def create_user( - user_data: UserCreate, - db: Session = Depends(get_db), - admin: User = Depends(require_admin), -): - """Admin creates a user directly — email is auto-verified.""" - from app.models.email_verification import EmailVerification - from datetime import datetime - - email_normalized = user_data.email.lower().strip() - if db.query(User).filter(User.email == email_normalized).first(): - raise HTTPException(status_code=400, detail="Email already registered") - - user = User( - email=email_normalized, - hashed_password=get_password_hash(user_data.password), - name=user_data.name, - role="user", - ) - db.add(user) - db.flush() - db.add(EmailVerification( - user_id=user.id, - token=f"admin_created_{user.id}", - expires_at=datetime.utcnow(), - verified_at=datetime.utcnow(), - )) - db.commit() - db.refresh(user) - return user - - -# --- AI Model Configuration --- - @router.get("/models/available") def list_available_models( task: str = Query("extraction"), diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 5b514cd..34c44f9 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -5,13 +5,13 @@ from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request from sqlalchemy.orm import Session -from app.services import captcha, refresh_tokens, site_settings +from app.services import refresh_tokens, site_settings from app.database import get_db from app.models.user import User from app.models.email_verification import EmailVerification from app.models.password_reset import PasswordReset from app.schemas.auth import ( - UserCreate, UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest, + UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest, UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest, SsoExchangeRequest, ) from app.services import email_service @@ -104,100 +104,6 @@ def _check_reset_rate_limit(db: Session, email: str): ) -@router.get("/signup-policy") -def signup_policy(db: Session = Depends(get_db)): - """What a would-be member needs, before they are anybody. - - Unauthenticated on purpose: the registration form has to know whether to - ask for a code, and it is asking before it has an account to ask with. It - says only whether one is needed — never whether a given code is valid, - which would turn this into somewhere to guess them. - """ - # The very first account is always allowed, or a new install could lock - # itself out before an administrator exists to issue a code. - if db.query(User).count() == 0: - return {"first_user": True, "registration_open": True} - # Whether anyone may join at all, so the sign-in page can stop offering a - # door that is locked. It was offering one: registration can be turned off - # site-wide, and the only way to find out was to fill the form in and be - # refused. - # Single sign-on closes the password door, and this is the one question - # the sign-up form already asks. Saying so here means the form can decline - # to draw itself rather than collect a name, an email and a password - # twice, and then be refused by the POST. - from app.config import settings as cfg - sso_only = _get_sso_settings()["sso_only"] - return {"first_user": False, - "sso_only": sso_only, - # So the page can name the provider rather than say "single - # sign-on" at somebody who only knows it as PedsHub SSO. - "provider_name": (cfg.OIDC_PROVIDER_NAME - if cfg.OIDC_PROVIDER_URL and cfg.OIDC_CLIENT_ID else None), - "registration_open": (not sso_only - and site_settings.get_flag("registration_enabled"))} - - -@router.post("/register") -async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): - _refuse_when_sso_only("Accounts are created through SSO on this site.") - # Failing open: an hCaptcha outage should cost the site a little spam, not - # every account that would have been created while it lasted. - await captcha.require_human(user_data.captcha_token, fail_open=True) - - # Check if registration is enabled (unless this is the first user - always allow admin creation) - is_first_user = db.query(User).count() == 0 - if not is_first_user: - try: - import redis as redis_lib - from app.config import settings - r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1) - registration_enabled = r.get("settings:registration_enabled") - # Default to enabled if not set, disabled if explicitly set to "false" - if registration_enabled == "false": - raise HTTPException(status_code=403, detail="Registration is currently disabled by administrator") - except HTTPException: - raise - except Exception as e: - import logging; logging.getLogger(__name__).warning(f"Redis registration check failed (failing open): {e}") - - if len(user_data.password) < 8: - raise HTTPException(status_code=400, detail="Password must be at least 8 characters") - - email_normalized = user_data.email.lower().strip() - existing = db.query(User).filter(User.email == email_normalized).first() - if existing: - raise HTTPException(status_code=400, detail="Email already registered") - user = User( - email=email_normalized, - hashed_password=get_password_hash(user_data.password), - name=user_data.name, - role="admin" if is_first_user else "user", - ) - db.add(user) - db.flush() - - # Create email verification record - token = secrets.token_urlsafe(32) - verification = EmailVerification( - user_id=user.id, - token=token, - expires_at=datetime.utcnow() + timedelta(hours=24), - verified_at=datetime.utcnow() if is_first_user else None, - ) - db.add(verification) - db.commit() - db.refresh(user) - - if is_first_user: - # Auto-verified admin — log them in immediately - access_token = create_access_token(data={"sub": user.email}) - return {"access_token": access_token, "token_type": "bearer"} - - # Regular user — must verify email before logging in - background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token) - return {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."} - - @router.post("/login", response_model=Token) async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None): # Block password login if SSO-only mode @@ -562,11 +468,9 @@ def sso_exchange(data: SsoExchangeRequest): def _refuse_when_sso_only(what: str = "Password login is disabled. Please use SSO.") -> None: """No password door while the site is single sign-on. - Login and the login codes checked this; register, forgot-password and - reset-password did not. So with SSO-only on, somebody could still be - issued a password account they could not use — and if the flag were ever - turned off, that is a password account nobody vetted, sitting in the - users table waiting. + Login checked this; forgot-password and reset-password did not. Accounts + are made at the provider now, so what is left to guard is the password + somebody already has: resetting it, or setting a new one. """ if _get_sso_settings()["sso_only"]: raise HTTPException(status_code=403, detail=what) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index eefa40f..716c1eb 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -3,14 +3,6 @@ from datetime import datetime from pydantic import BaseModel, EmailStr, Field -class UserCreate(BaseModel): - email: EmailStr - password: str - name: str - #: Solved hCaptcha challenge. Absent when the site has no secret configured. - captcha_token: str | None = None - - class UserResponse(BaseModel): id: int email: str diff --git a/backend/tests/api-contract.json b/backend/tests/api-contract.json index 87ecf7f..be5ad64 100644 --- a/backend/tests/api-contract.json +++ b/backend/tests/api-contract.json @@ -868,13 +868,6 @@ "200" ] }, - "GET /api/v1/auth/signup-policy": { - "body": false, - "params": [], - "responses": [ - "200" - ] - }, "GET /api/v1/auth/sso/callback": { "body": false, "params": [], @@ -2027,14 +2020,6 @@ "422" ] }, - "POST /api/v1/admin/users": { - "body": true, - "params": [], - "responses": [ - "200", - "422" - ] - }, "POST /api/v1/ai/conversations": { "body": false, "params": [], @@ -2219,14 +2204,6 @@ "422" ] }, - "POST /api/v1/auth/register": { - "body": true, - "params": [], - "responses": [ - "200", - "422" - ] - }, "POST /api/v1/auth/resend-verification": { "body": true, "params": [], diff --git a/backend/tests/test_login_without_captcha.py b/backend/tests/test_login_without_captcha.py deleted file mode 100644 index a2ac381..0000000 --- a/backend/tests/test_login_without_captcha.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Login carries no captcha; real auth routes, disposable DB, mocked external boundaries.""" -import sys -import unittest -from datetime import datetime, timedelta -from types import ModuleType -from unittest.mock import AsyncMock, patch - -import test_quiz_builder as fixtures -from app.config import settings -from app.models.email_verification import EmailVerification -from app.routers import auth -from app.schemas.auth import LoginRequest, UserCreate -from app.services import captcha -from app.utils.auth import get_password_hash - - -class MemoryRedis: - def __init__(self): self.values = {} - def get(self, key): return self.values.get(key) - def incr(self, key): - self.values[key] = int(self.values.get(key, 0)) + 1 - return self.values[key] - def expire(self, *args): return True - - -class LoginWithoutCaptchaTests(unittest.TestCase): - def setUp(self): - self.bank = fixtures.BuilderTests() - self.bank.setUp() - self.bank.owner.email = 'owner@example.com' - self.bank.owner.hashed_password = get_password_hash('synthetic-password') - self.bank.db.commit() - self.client = self.bank.client - self.client.app.include_router(auth.router, prefix='/auth') - self.redis = MemoryRedis() - module = ModuleType('redis') - module.from_url = lambda *args, **kwargs: self.redis - self.module_patch = patch.dict(sys.modules, {'redis': module}) - self.module_patch.start() - self.key_patch = patch.object(settings, 'CAP_SECRET_KEY', 'synthetic-configured-key') - self.key_patch.start() - # Stops at the network boundary; every routing decision above it is real. - self.verify_patch = patch.object(captcha, 'verify', new_callable=AsyncMock) - self.verify = self.verify_patch.start() - self.verify.return_value = False - - def tearDown(self): - self.verify_patch.stop() - self.key_patch.stop() - self.module_patch.stop() - self.bank.tearDown() - - def login(self, **overrides): - return self.client.post('/auth/login', json={'email': 'owner@example.com', 'password': 'synthetic-password', **overrides}) - - def test_valid_login_needs_no_token_even_with_cap_configured(self): - self.assertNotIn('captcha_token', LoginRequest.model_fields) - self.assertIn('captcha_token', UserCreate.model_fields) - response = self.login() - self.assertEqual(response.status_code, 200, response.text) - self.assertTrue(response.json()['access_token']) - # Old clients sending an extra token remain compatible; it is not verified. - self.assertEqual(self.login(captcha_token='obsolete-client-field').status_code, 200) - self.verify.assert_not_awaited() - - def test_password_email_verification_and_sso_rules_remain(self): - self.assertEqual(self.login(password='wrong').status_code, 401) - self.bank.db.add(EmailVerification(user_id=self.bank.owner.id, token='test-token', - expires_at=datetime.utcnow() + timedelta(hours=1), verified_at=None)) - self.bank.db.commit() - self.assertEqual(self.login().status_code, 403) - self.redis.values['settings:sso_only'] = 'true' - response = self.login() - self.assertEqual(response.status_code, 403) - self.assertIn('Please use SSO', response.json()['detail']) - self.verify.assert_not_awaited() - - def test_login_rate_limit_still_rejects_the_eleventh_attempt(self): - for _ in range(10): - self.assertEqual(self.login(email='missing@example.com', password='wrong').status_code, 401) - response = self.login(email='missing@example.com', password='wrong') - self.assertEqual(response.status_code, 429) - self.assertIn('Too many login attempts', response.json()['detail']) - self.verify.assert_not_awaited() - - def test_registration_still_requires_and_verifies_the_challenge(self): - payload = {'email': 'new@example.com', 'password': 'synthetic-password', 'name': 'Test'} - response = self.client.post('/auth/register', json=payload) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()['detail'], 'Bot verification required') - response = self.client.post('/auth/register', json={**payload, 'captcha_token': 'invalid-test-token'}) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()['detail'], 'Bot verification failed — please try again') - self.verify.assert_awaited_once_with('invalid-test-token', fail_open=True) - - def test_a_solved_challenge_lets_registration_through(self): - self.verify.return_value = True - with patch.object(auth.email_service, 'send_verification_email'): - response = self.client.post('/auth/register', json={ - 'email': 'new@example.com', 'password': 'synthetic-password', - 'name': 'Test', 'captcha_token': 'solved-test-token'}) - self.assertEqual(response.status_code, 200, response.text) - self.assertTrue(response.json()['requires_verification']) - self.verify.assert_awaited_once_with('solved-test-token', fail_open=True) - - -if __name__ == '__main__': - unittest.main() diff --git a/backend/tests/test_sso_hardening.py b/backend/tests/test_sso_hardening.py index 8f9c8db..46f81d3 100644 --- a/backend/tests/test_sso_hardening.py +++ b/backend/tests/test_sso_hardening.py @@ -21,11 +21,11 @@ from app.utils.auth import get_current_user class SsoOnlyClosesThePasswordDoors(unittest.TestCase): - """Login checked the flag. Everything else that makes a password did not. + """Login checked the flag. The other password doors did not. - With SSO-only on, an account could still be registered, and a reset link - still issued and spent — so the site could accumulate password accounts - nobody vetted, waiting for the flag to be turned off again. + Registration is gone entirely — accounts are made at the provider — so + what is left to guard is the password somebody already has: resetting it, + or setting a new one while signed in. """ def setUp(self): @@ -53,11 +53,8 @@ class SsoOnlyClosesThePasswordDoors(unittest.TestCase): def calls(self): return [ - ("/auth/register", {"email": "new@example.com", "password": "abcdefgh", - "name": "New"}), ("/auth/forgot-password", {"email": "reader@example.com"}), ("/auth/reset-password", {"token": "x" * 20, "new_password": "abcdefgh"}), - ("/auth/resend-verification", {"email": "reader@example.com"}), ("/auth/me", {"new_password": "abcdefgh"}), ] @@ -119,43 +116,3 @@ class OneTimeCodeTests(unittest.TestCase): if __name__ == "__main__": unittest.main() - - -class SignupPolicyTests(unittest.TestCase): - """The form asks this before it draws itself. - - Under SSO-only it used to draw the whole password sign-up — name, email, - password twice, invite code — and only the POST refused it. The page now - has what it needs to decline. - """ - - def setUp(self): - self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, - poolclass=StaticPool) - Base.metadata.create_all(self.engine) - self.db = Session(self.engine) - self.db.add(User(id=1, name="Someone", email="someone@example.com", - hashed_password=None)) - self.db.commit() - app = FastAPI() - app.include_router(auth.router, prefix="/auth") - app.dependency_overrides[get_db] = lambda: self.db - self.client = TestClient(app) - - def tearDown(self): - self.client.close() - self.db.close() - self.engine.dispose() - - def test_sso_only_closes_registration_in_the_policy(self): - with patch.object(auth, "_get_sso_settings", return_value={"sso_only": True}): - body = self.client.get("/auth/signup-policy").json() - self.assertTrue(body["sso_only"]) - self.assertFalse(body["registration_open"]) - - def test_otherwise_the_switch_decides(self): - with patch.object(auth, "_get_sso_settings", return_value={"sso_only": False}), \ - patch.object(auth.site_settings, "get_flag", return_value=True): - body = self.client.get("/auth/signup-policy").json() - self.assertFalse(body["sso_only"]) - self.assertTrue(body["registration_open"]) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 821ccbb..5e749ca 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,7 +13,6 @@ import lazyPage from './utils/lazyPage' import FirstRunTour from './components/FirstRunTour' const LoginPage = lazyPage(() => import('./pages/LoginPage')) -const RegisterPage = lazyPage(() => import('./pages/RegisterPage')) const DashboardPage = lazyPage(() => import('./pages/DashboardPage')) const UploadPage = lazyPage(() => import('./pages/UploadPage')) const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage')) @@ -192,7 +191,9 @@ function AppRoutes() { } /> }> : } /> - : } /> + {/* Accounts are made at the identity provider. The address is kept + only so a bookmark lands somewhere sensible. */} + } /> } /> } /> } /> diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 8ba2b02..db288f8 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -165,17 +165,6 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { const [peek, setPeek] = useState(false) // Registered by the quiz player while it is on screen without a rail. const sessionDrawer = useSessionDrawer() - //: Whether anybody may sign themselves up. Asked only when logged out — - //: there is nothing to draw otherwise, and it is one request per visit. - const [signupOpen, setSignupOpen] = useState(true) - useEffect(() => { - if (user) return undefined - let live = true - api.get('/auth/signup-policy') - .then(res => { if (live) setSignupOpen(res.data?.registration_open !== false) }) - .catch(() => {}) - return () => { live = false } - }, [user]) //: Is the thing this button opens currently open? On a page that has taken //: the button over, that is the page's drawer; otherwise it is the site //: menu. Either way the bars fold into a cross. @@ -227,10 +216,6 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { { to: '/study-plans', label: 'Study plans' }, { to: '/articles', label: 'Reading' }, { to: '/flashcards', label: 'Cards' }, - // The scribe, where a deck is written from your own material. A different - // application behind the same sign-in, so it is a real link out rather - // than a route — and it says so with the arrow. - { to: 'https://app.pedshub.com/#resources', label: 'Make a deck', external: true }, ] : [] return ( @@ -284,19 +269,13 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { ) : ( - /* Logged-out: Sign In + Register */ + /* Logged-out. One door: the provider makes accounts, so there is + nothing to register here. */
{onSignIn ? : Sign In } - {/* Only where there is a door. On a site that signs in through - a provider — or with registration simply switched off — - this led to a form whose only outcome was a refusal. */} - {signupOpen && (onRegister - ? - : Register - )}
)} @@ -318,16 +297,10 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { a mouse the last links were unreachable. */} {navLinks.map(l => ( - l.external ? ( - - {l.label} ↗ - - ) : ( {l.label} - ) ))} @@ -349,11 +322,7 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { opacity: location.pathname === l.to ? 1 : 0.75, borderBottom: '1px solid rgba(255,255,255,0.05)', } - return l.external ? ( - - {l.label} ↗ - - ) : ( + return ( {l.label} diff --git a/frontend/src/index.css b/frontend/src/index.css index cfe318b..61e8270 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -198,6 +198,12 @@ html, body { overflow-x: hidden; } .navbar-sections.is-hidden { height: 0; border-bottom-color: transparent; } .navbar-sections:focus-within { height: 46px; } .navbar-sections-inner { display: flex; align-items: center; gap: 14px; height: 46px; } +/* The section strip is not prose, so the 1200px measure that keeps an article + readable only squeezes it: on a wide desktop the last entries fell off the + end and the scroll arrow appeared beside acres of empty space. It takes the + width it has, still centred, and falls back to scrolling only when the + window really is too narrow for the row. */ +.navbar-sections > .container { max-width: 1600px; } /* The strip scrolls when the links outrun the width; the fade on the right is what says so, since a hard cut just looks like a broken layout. */ diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index 4d35444..b5ebb96 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -232,58 +232,43 @@ function Rotator({ calm }) { } // ── Contact form ────────────────────────────────────────────────────────────── -function AuthModal({ mode, onClose, onSwitch }) { +function AuthModal({ onClose }) { const { login, loginWithToken } = useAuth() const navigate = useNavigate() - const [name, setName] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) const [unverified, setUnverified] = useState(false) - const [captchaToken, setCaptchaToken] = useState('') const [resendSent, setResendSent] = useState(false) const [resending, setResending] = useState(false) - const [registered, setRegistered] = useState(false) - // Typed twice. The same field exists on /register; this is the other door. - const [confirm, setConfirm] = useState('') - const [signupOpen, setSignupOpen] = useState(true) //: The public page's own sign-in box. It is a different component from //: /login and knew nothing about the provider, so on a site where the only //: way in is single sign-on it offered an email and a password and a //: "Forgot password?" — three things that cannot work. - const [ssoOnly, setSsoOnly] = useState(false) - const [ssoEnabled, setSsoEnabled] = useState(false) + //: Null until the answer arrives. Defaulting to "no provider" drew the + //: email form for a moment on every open and then swapped it for the SSO + //: button — a flash of a way in that does not exist. + const [ssoOnly, setSsoOnly] = useState(null) + const [ssoEnabled, setSsoEnabled] = useState(null) const [providerName, setProviderName] = useState('single sign-on') useEffect(() => { let live = true - api.get('/auth/signup-policy') + // One question, one endpoint. This used to ask the sign-up policy as well, + // for a sign-up that no longer exists. + api.get('/auth/sso/config') .then(res => { if (!live) return - // Registration closed — by the switch, or because the site signs in - // through a provider. Either way there is no password sign-up to - // offer, and a Register tab that leads to a refusal is worse than no - // tab at all. - setSignupOpen(res.data?.registration_open !== false) + setSsoEnabled(res.data?.sso_enabled === true) setSsoOnly(res.data?.sso_only === true) setProviderName(res.data?.provider_name || 'single sign-on') }) - .catch(() => {}) - api.get('/auth/sso/config') - .then(res => { if (live) setSsoEnabled(res.data?.sso_enabled === true) }) - .catch(() => {}) + .catch(() => { if (live) { setSsoEnabled(false); setSsoOnly(false) } }) return () => { live = false } }, []) - // Opened straight into Register — from the header button, or a stale tab — - // on a site that has since closed it. The tab is gone; the form must go too. - useEffect(() => { - if (!signupOpen && mode === 'register') onSwitch('login') - }, [signupOpen, mode, onSwitch]) - - const reset = () => { setError(''); setUnverified(false); setResendSent(false); setRegistered(false) } - const switchMode = (m) => { reset(); setName(''); setEmail(''); setPassword(''); setConfirm(''); onSwitch(m) } + const reset = () => { setError(''); setUnverified(false); setResendSent(false) } const handleLogin = async (e) => { e.preventDefault() @@ -299,31 +284,6 @@ function AuthModal({ mode, onClose, onSwitch }) { } finally { setLoading(false) } } - const handleRegister = async (e) => { - e.preventDefault() - setError('') - if (password !== confirm) { setError('The two passwords do not match.'); return } - setLoading(true) - try { - const res = await api.post('/auth/register', { - email, password, name, - captcha_token: captchaToken || null, - }) - if (res.data.requires_verification) { - setRegistered(true) - } else { - await loginWithToken(res.data.access_token) - onClose() - navigate('/') - } - } catch (err) { - const detail = err.response?.data?.detail - if (typeof detail === 'string') setError(detail) - else if (Array.isArray(detail)) setError(detail.some(e => e.loc?.includes('email')) ? 'Invalid email address.' : 'Please check your input and try again.') - else setError('Registration failed') - } finally { setLoading(false) } - } - const resendVerification = async () => { setResending(true) try { await api.post('/auth/resend-verification', { email }) } catch {} @@ -331,47 +291,28 @@ function AuthModal({ mode, onClose, onSwitch }) { setResending(false) } - const isLogin = mode === 'login' - return (
e.stopPropagation()} className="lp-modal"> - {/* Tabs */} -
- {[['login', 'Sign In'], ...(signupOpen ? [['register', 'Register']] : [])].map(([m, label]) => ( - - ))} -
+ {ssoEnabled === null &&
} + - {/* Registration success */} - {registered && ( -
- -

Check your email

-

- We sent a verification link to {email}. Click it to activate your account. -

- -
- )} {/* The provider, first, and on an SSO-only site the only thing here. */} - {isLogin && !registered && ssoEnabled && ( + {ssoEnabled === true && ( <> Sign in with {providerName} - {!ssoOnly &&

or sign in with email

} + {ssoOnly === false &&

or sign in with email

} )} {/* Login form */} - {isLogin && !registered && !ssoOnly && ( + {ssoOnly === false && ( <> {unverified && !resendSent && (
@@ -402,52 +343,6 @@ function AuthModal({ mode, onClose, onSwitch }) { )} - {isLogin && !registered && ssoOnly && ( -

- This site signs in through {providerName}. -

- )} - - {/* Register form */} - {!isLogin && !registered && !ssoOnly && ( - <> - {error &&
{error}
} -
- {/* Labelled by id: these two were bare
) @@ -698,20 +593,16 @@ function SlideStudio({ calm }) { export default function LandingPage() { const { user } = useAuth() const calm = useCalmMotion() - const [authModal, setAuthModal] = useState(null) // null | 'login' | 'register' + //: One door. Accounts are made at the identity provider, so there is no + //: second mode for this to be in. + const [authOpen, setAuthOpen] = useState(false) return (
- {authModal && ( - setAuthModal(null)} - onSwitch={setAuthModal} - /> - )} + {authOpen && setAuthOpen(false)} />} - setAuthModal('login')} onRegister={() => setAuthModal('register')} /> + setAuthOpen(true)} /> {/* ── Hero ───────────────────────────────────────────────────────────── */}
@@ -733,8 +624,10 @@ export default function LandingPage() { Start a session Your analysis : <> - - + {/* One button. "Create an account" led to a form that no longer + exists — an account is made by following an invitation from + the identity provider, not from here. */} + }
@@ -823,7 +716,7 @@ export default function LandingPage() { 🏥 PedsHub© {new Date().getFullYear()}
- + Clinical Tools
diff --git a/frontend/src/pages/LandingPage.test.jsx b/frontend/src/pages/LandingPage.test.jsx index cac07a5..a16b0cc 100644 --- a/frontend/src/pages/LandingPage.test.jsx +++ b/frontend/src/pages/LandingPage.test.jsx @@ -128,25 +128,33 @@ describe('reduced motion', () => { }) describe('the auth modal', () => { - it('still opens from the navbar and switches between its two tabs', async () => { + it('opens from the navbar with a way in and no way to sign up', async () => { + // Accounts are made at the identity provider. There is no second tab, + // because there is no second thing this modal can do. mount() await userEvent.click(screen.getByRole('button', { name: 'Open login' })) - expect(screen.getByRole('form', { name: 'Sign in' })).toBeInTheDocument() - - await userEvent.click(screen.getByRole('button', { name: 'Register' })) - expect(screen.getByLabelText('Confirm password')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument() - expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument() + expect(await screen.findByRole('form', { name: 'Sign in' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Register' })).toBeNull() + expect(screen.queryByLabelText('Confirm password')).toBeNull() await userEvent.click(screen.getByRole('button', { name: 'Close' })) expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument() - expect(screen.queryByLabelText('Confirm password')).not.toBeInTheDocument() }) - it('opens straight onto registration from the navbar', async () => { + it('shows the provider and nothing else on an SSO-only site', async () => { + // And nothing at all before the answer arrives: drawing the email form + // and then replacing it is a flash of a way in that does not exist. + api.get.mockImplementation(url => Promise.resolve({ + data: url === '/public/stats' ? STATS + : url === '/auth/sso/config' + ? { sso_enabled: true, sso_only: true, provider_name: 'PedsHub SSO' } + : {}, + })) mount() - await userEvent.click(screen.getByRole('button', { name: 'Open registration' })) - expect(screen.getByLabelText('Confirm password')).toBeInTheDocument() - expect(api.get).toHaveBeenCalledWith('/auth/signup-policy') + await userEvent.click(screen.getByRole('button', { name: 'Open login' })) + expect(await screen.findByRole('link', { name: /Sign in with PedsHub SSO/ })) + .toHaveAttribute('href', '/api/auth/sso/login') + expect(screen.queryByRole('form', { name: 'Sign in' })).toBeNull() + expect(screen.queryByText(/signs in through/)).toBeNull() }) }) diff --git a/frontend/src/pages/LoginCaptcha.test.jsx b/frontend/src/pages/LoginCaptcha.test.jsx index a66381a..f3584b6 100644 --- a/frontend/src/pages/LoginCaptcha.test.jsx +++ b/frontend/src/pages/LoginCaptcha.test.jsx @@ -5,7 +5,7 @@ import { beforeEach, afterEach, expect, it, vi } from 'vitest' vi.hoisted(() => { window.__APP_CONFIG__ = { CAP_SITE_KEY: 'configured-test-site-key' } }) vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } })) -vi.mock('../components/Navbar', () => ({ default: ({ onSignIn, onRegister }) => })) +vi.mock('../components/Navbar', () => ({ default: ({ onSignIn }) => })) import LoginPage from './LoginPage' import LandingPage from './LandingPage' @@ -26,7 +26,10 @@ function mount(Component) { async function submitLogin() { // Both pages ask for the password outright now. Sign-in codes were the // standalone page's first offer; they are the identity provider's job. - const form = screen.getByRole('form', { name: 'Sign in' }) + // Awaited: neither page draws a form until it knows whether this site has a + // provider, because drawing one and then replacing it is a flash of a way in + // that may not exist. + const form = await screen.findByRole('form', { name: 'Sign in' }) expect(within(form).getByRole('button', { name: 'Sign In', exact: true })).toBeEnabled() await userEvent.type(within(form).getByLabelText('Email'), 'owner@example.test') await userEvent.type(within(form).getByLabelText('Password'), 'synthetic-password') @@ -55,30 +58,16 @@ it('leaves the landing login free of a challenge', async () => { await submitLogin() }) -it('keeps registration protected on the landing page', async () => { +it('offers no way to make an account on the landing page', async () => { + // There is no sign-up: an account is made by following an invitation from + // the identity provider. So no second tab, no form, and no challenge to + // protect one with — the contact form's widget went earlier, and + // registration's went with registration. mount(LandingPage) - await userEvent.click(screen.getByRole('button', { name: 'Open registration' })) - const widgets = document.querySelectorAll('cap-widget') - // One now, not two: registration's. The contact form's went with the form. - expect(widgets).toHaveLength(1) - const signup = screen.getByRole('button', { name: 'Sign Up', exact: true }) - expect(signup).toBeDisabled() - act(() => widgets.forEach(w => w.dispatchEvent( - new CustomEvent('solve', { detail: { token: 'synthetic-valid-token' } })))) - // Still not enough: the password has to be typed twice and agree. - expect(signup).toBeDisabled() - await userEvent.type(screen.getByLabelText('Password'), 'longenough1') - await userEvent.type(screen.getByLabelText('Confirm password'), 'longenough1') - expect(signup).toBeEnabled() - // Selected through the form rather than by label: the modal's name and email - // labels are unassociated, and the contact form below carries the same two. - const form = signup.closest('form') - await userEvent.type(form.querySelector('input[type="text"]'), 'Test Person') - await userEvent.type(form.querySelector('input[type="email"]'), 'new@example.test') - await userEvent.click(signup) - expect(api.post).toHaveBeenCalledWith('/auth/register', expect.objectContaining({ - captcha_token: 'synthetic-valid-token', - })) + await userEvent.click(screen.getByRole('button', { name: 'Open login' })) + expect(screen.queryByRole('button', { name: 'Sign Up', exact: true })).toBeNull() + expect(screen.queryByLabelText('Confirm password')).toBeNull() + expect(document.querySelectorAll('cap-widget')).toHaveLength(0) }) it('retains SSO-only mode on the standalone login page', async () => { diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 8777c2c..5e227bc 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -24,9 +24,6 @@ export default function LoginPage() { const [resendSent, setResendSent] = useState(false) const [resending, setResending] = useState(false) const [ssoConfig, setSsoConfig] = useState(null) - // Null until asked, and treated as open while unknown: a slow answer must - // not hide a way in that exists. - const [policy, setPolicy] = useState(null) const { login } = useAuth() const navigate = useNavigate() const searchParams = new URLSearchParams(window.location.search) @@ -34,7 +31,6 @@ export default function LoginPage() { useEffect(() => { api.get('/auth/sso/config').then(r => setSsoConfig(r.data)).catch(() => {}) - api.get('/auth/signup-policy').then(r => setPolicy(r.data)).catch(() => {}) }, []) const handleSubmit = async (e) => { @@ -94,6 +90,11 @@ export default function LoginPage() { {error &&
{error}
} {ssoError &&
SSO login failed. Please try again.
} + {/* Nothing until the answer is in: drawing the email form and then + replacing it with the provider's button is a flash of a way in that + does not exist. */} + {ssoConfig === null && diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx deleted file mode 100644 index 3b874c3..0000000 --- a/frontend/src/pages/RegisterPage.jsx +++ /dev/null @@ -1,150 +0,0 @@ -import { useState, useEffect } from 'react' -import { Link } from 'react-router-dom' -import { useAuth } from '../context/AuthContext' -import api from '../api/client' -import Captcha, { captchaSiteKey } from '../components/Captcha' - -export default function RegisterPage() { - const [name, setName] = useState('') - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - // Typed twice, because a password you cannot see is a password you can - // mistype into an account you then cannot get into. - const [confirm, setConfirm] = useState('') - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - const [done, setDone] = useState(false) - const [captchaToken, setCaptchaToken] = useState('') - //: Null until the policy is known. A site on single sign-on has no password - //: sign-up, and drawing the form and refusing the POST is asking somebody - //: for a name, an email and a password twice before telling - //: them the door does not exist. - const [policy, setPolicy] = useState(null) - const { loginWithToken } = useAuth() - - useEffect(() => { - let live = true - api.get('/auth/signup-policy') - .then(res => { - if (!live) return - setPolicy(res.data || {}) - }) - .catch(() => { if (live) setPolicy({}) }) - return () => { live = false } - }, []) - - const handleSubmit = async (e) => { - e.preventDefault() - setError('') - if (password !== confirm) { setError('The two passwords do not match.'); return } - setLoading(true) - try { - const res = await api.post('/auth/register', { - email, password, name, - captcha_token: captchaToken || null, - }) - if (res.data.requires_verification) { - setDone(true) - } else { - // First user (auto-verified admin) — log in immediately - await loginWithToken(res.data.access_token) - window.location.href = '/' - } - } catch (err) { - const detail = err.response?.data?.detail - if (typeof detail === 'string') setError(detail) - else if (Array.isArray(detail)) { - const hasEmail = detail.some(e => e.loc?.includes('email')) - setError(hasEmail ? 'Invalid email address.' : 'Please check your input and try again.') - } else setError('Registration failed') - } finally { - setLoading(false) - } - } - - if (policy === null) { - return
- } - - // Nothing to fill in on a site where accounts come from the provider. - if (policy.sso_only) { - return ( -
-
-

Accounts come by invitation

-

- This site signs in through {policy.provider_name || 'single sign-on'}. - Ask an administrator for a sign-up link. -

- - Go to sign in - -
-
- ) - } - - if (done) { - return ( -
-
-
📧
-

Check your email

-

- We sent a verification link to {email}. -

-

- Click the link in the email to activate your account, then you can log in. -

- Go to Login -
- Wrong email? -
-
-
- ) - } - - return ( -
-
-

Create Account

- {error &&
{error}
} - -
- - setName(e.target.value)} required /> -
-
- - setEmail(e.target.value)} required /> -
-
- - setPassword(e.target.value)} required minLength={8} /> -
-
- - setConfirm(e.target.value)} /> - {confirm && password !== confirm && ( - - These do not match yet. - - )} -
- - - -
- Already have an account? Sign in -
-
-
- ) -}