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 -
-
-
- ) -}