feat: no account creation here — the provider makes accounts
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 30s
Tests / e2e (push) Failing after 33s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-13 14:37:10 +02:00
parent e858f4c166
commit 963ca04cf8
14 changed files with 90 additions and 694 deletions

View file

@ -14,9 +14,9 @@ from app.database import get_db
from app.models.user import User from app.models.user import User
from app.models.ai_model_config import AIModelConfig from app.models.ai_model_config import AIModelConfig
from app.services import ai_service, site_settings, sso_roles, tts_voices 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.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() router = APIRouter()
@ -96,41 +96,6 @@ def delete_user(
db.commit() 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") @router.get("/models/available")
def list_available_models( def list_available_models(
task: str = Query("extraction"), task: str = Query("extraction"),

View file

@ -5,13 +5,13 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session 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.database import get_db
from app.models.user import User from app.models.user import User
from app.models.email_verification import EmailVerification from app.models.email_verification import EmailVerification
from app.models.password_reset import PasswordReset from app.models.password_reset import PasswordReset
from app.schemas.auth import ( from app.schemas.auth import (
UserCreate, UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest, UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest,
UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest, SsoExchangeRequest, UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest, SsoExchangeRequest,
) )
from app.services import email_service 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) @router.post("/login", response_model=Token)
async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None): async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
# Block password login if SSO-only mode # 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: 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. """No password door while the site is single sign-on.
Login and the login codes checked this; register, forgot-password and Login checked this; forgot-password and reset-password did not. Accounts
reset-password did not. So with SSO-only on, somebody could still be are made at the provider now, so what is left to guard is the password
issued a password account they could not use and if the flag were ever somebody already has: resetting it, or setting a new one.
turned off, that is a password account nobody vetted, sitting in the
users table waiting.
""" """
if _get_sso_settings()["sso_only"]: if _get_sso_settings()["sso_only"]:
raise HTTPException(status_code=403, detail=what) raise HTTPException(status_code=403, detail=what)

View file

@ -3,14 +3,6 @@ from datetime import datetime
from pydantic import BaseModel, EmailStr, Field 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): class UserResponse(BaseModel):
id: int id: int
email: str email: str

View file

@ -868,13 +868,6 @@
"200" "200"
] ]
}, },
"GET /api/v1/auth/signup-policy": {
"body": false,
"params": [],
"responses": [
"200"
]
},
"GET /api/v1/auth/sso/callback": { "GET /api/v1/auth/sso/callback": {
"body": false, "body": false,
"params": [], "params": [],
@ -2027,14 +2020,6 @@
"422" "422"
] ]
}, },
"POST /api/v1/admin/users": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/ai/conversations": { "POST /api/v1/ai/conversations": {
"body": false, "body": false,
"params": [], "params": [],
@ -2219,14 +2204,6 @@
"422" "422"
] ]
}, },
"POST /api/v1/auth/register": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/auth/resend-verification": { "POST /api/v1/auth/resend-verification": {
"body": true, "body": true,
"params": [], "params": [],

View file

@ -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()

View file

@ -21,11 +21,11 @@ from app.utils.auth import get_current_user
class SsoOnlyClosesThePasswordDoors(unittest.TestCase): 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 Registration is gone entirely accounts are made at the provider so
still issued and spent so the site could accumulate password accounts what is left to guard is the password somebody already has: resetting it,
nobody vetted, waiting for the flag to be turned off again. or setting a new one while signed in.
""" """
def setUp(self): def setUp(self):
@ -53,11 +53,8 @@ class SsoOnlyClosesThePasswordDoors(unittest.TestCase):
def calls(self): def calls(self):
return [ return [
("/auth/register", {"email": "new@example.com", "password": "abcdefgh",
"name": "New"}),
("/auth/forgot-password", {"email": "reader@example.com"}), ("/auth/forgot-password", {"email": "reader@example.com"}),
("/auth/reset-password", {"token": "x" * 20, "new_password": "abcdefgh"}), ("/auth/reset-password", {"token": "x" * 20, "new_password": "abcdefgh"}),
("/auth/resend-verification", {"email": "reader@example.com"}),
("/auth/me", {"new_password": "abcdefgh"}), ("/auth/me", {"new_password": "abcdefgh"}),
] ]
@ -119,43 +116,3 @@ class OneTimeCodeTests(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.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"])

View file

@ -13,7 +13,6 @@ import lazyPage from './utils/lazyPage'
import FirstRunTour from './components/FirstRunTour' import FirstRunTour from './components/FirstRunTour'
const LoginPage = lazyPage(() => import('./pages/LoginPage')) const LoginPage = lazyPage(() => import('./pages/LoginPage'))
const RegisterPage = lazyPage(() => import('./pages/RegisterPage'))
const DashboardPage = lazyPage(() => import('./pages/DashboardPage')) const DashboardPage = lazyPage(() => import('./pages/DashboardPage'))
const UploadPage = lazyPage(() => import('./pages/UploadPage')) const UploadPage = lazyPage(() => import('./pages/UploadPage'))
const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage')) const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage'))
@ -192,7 +191,9 @@ function AppRoutes() {
<Route path="/home" element={<LandingPage />} /> <Route path="/home" element={<LandingPage />} />
<Route element={<PublicLayout />}> <Route element={<PublicLayout />}>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} /> <Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} /> {/* Accounts are made at the identity provider. The address is kept
only so a bookmark lands somewhere sensible. */}
<Route path="/register" element={<Navigate to="/login" replace />} />
<Route path="/verify-email" element={<VerifyEmailPage />} /> <Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} /> <Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} /> <Route path="/reset-password" element={<ResetPasswordPage />} />

View file

@ -165,17 +165,6 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
const [peek, setPeek] = useState(false) const [peek, setPeek] = useState(false)
// Registered by the quiz player while it is on screen without a rail. // Registered by the quiz player while it is on screen without a rail.
const sessionDrawer = useSessionDrawer() 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 //: 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 //: the button over, that is the page's drawer; otherwise it is the site
//: menu. Either way the bars fold into a cross. //: 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: '/study-plans', label: 'Study plans' },
{ to: '/articles', label: 'Reading' }, { to: '/articles', label: 'Reading' },
{ to: '/flashcards', label: 'Cards' }, { 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 ( return (
@ -284,19 +269,13 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
</div> </div>
) : ( ) : (
/* Logged-out: Sign In + Register */ /* Logged-out. One door: the provider makes accounts, so there is
nothing to register here. */
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{onSignIn {onSignIn
? <button onClick={onSignIn} style={{ fontSize: '0.875rem', color: 'var(--navbar-fg)', opacity: 0.8, background: 'none', border: 'none', cursor: 'pointer', padding: '6px 10px' }}>Sign In</button> ? <button onClick={onSignIn} style={{ fontSize: '0.875rem', color: 'var(--navbar-fg)', opacity: 0.8, background: 'none', border: 'none', cursor: 'pointer', padding: '6px 10px' }}>Sign In</button>
: <Link to="/login" style={{ fontSize: '0.875rem', color: 'var(--navbar-fg)', opacity: 0.8, textDecoration: 'none', padding: '6px 10px' }}>Sign In</Link> : <Link to="/login" style={{ fontSize: '0.875rem', color: 'var(--navbar-fg)', opacity: 0.8, textDecoration: 'none', padding: '6px 10px' }}>Sign In</Link>
} }
{/* 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
? <button onClick={onRegister} className="btn btn-primary" style={{ fontSize: '0.85rem', padding: '6px 14px', borderRadius: 8 }}>Register</button>
: <Link to="/register" className="btn btn-primary" style={{ fontSize: '0.85rem', padding: '6px 14px', textDecoration: 'none', borderRadius: 8 }}>Register</Link>
)}
</div> </div>
)} )}
</div> </div>
@ -318,16 +297,10 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
a mouse the last links were unreachable. */} a mouse the last links were unreachable. */}
<ScrollStrip as="nav" className="nav-sections" label="Sections"> <ScrollStrip as="nav" className="nav-sections" label="Sections">
{navLinks.map(l => ( {navLinks.map(l => (
l.external ? (
<a key={l.to} href={l.to} target="_blank" rel="noopener noreferrer">
{l.label}
</a>
) : (
<Link key={l.to} to={l.to} className={location.pathname === l.to ? 'is-current' : undefined} <Link key={l.to} to={l.to} className={location.pathname === l.to ? 'is-current' : undefined}
aria-current={location.pathname === l.to ? 'page' : undefined}> aria-current={location.pathname === l.to ? 'page' : undefined}>
{l.label} {l.label}
</Link> </Link>
)
))} ))}
</ScrollStrip> </ScrollStrip>
</div> </div>
@ -349,11 +322,7 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
opacity: location.pathname === l.to ? 1 : 0.75, opacity: location.pathname === l.to ? 1 : 0.75,
borderBottom: '1px solid rgba(255,255,255,0.05)', borderBottom: '1px solid rgba(255,255,255,0.05)',
} }
return l.external ? ( return (
<a key={l.to} href={l.to} target="_blank" rel="noopener noreferrer" style={style}>
{l.label}
</a>
) : (
<Link key={l.to} to={l.to} style={style}> <Link key={l.to} to={l.to} style={style}>
{l.label} {l.label}
</Link> </Link>

View file

@ -198,6 +198,12 @@ html, body { overflow-x: hidden; }
.navbar-sections.is-hidden { height: 0; border-bottom-color: transparent; } .navbar-sections.is-hidden { height: 0; border-bottom-color: transparent; }
.navbar-sections:focus-within { height: 46px; } .navbar-sections:focus-within { height: 46px; }
.navbar-sections-inner { display: flex; align-items: center; gap: 14px; 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 /* 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. */ what says so, since a hard cut just looks like a broken layout. */

View file

@ -232,58 +232,43 @@ function Rotator({ calm }) {
} }
// Contact form // Contact form
function AuthModal({ mode, onClose, onSwitch }) { function AuthModal({ onClose }) {
const { login, loginWithToken } = useAuth() const { login, loginWithToken } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [name, setName] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [unverified, setUnverified] = useState(false) const [unverified, setUnverified] = useState(false)
const [captchaToken, setCaptchaToken] = useState('')
const [resendSent, setResendSent] = useState(false) const [resendSent, setResendSent] = useState(false)
const [resending, setResending] = 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 //: 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 //: /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 //: way in is single sign-on it offered an email and a password and a
//: "Forgot password?" three things that cannot work. //: "Forgot password?" three things that cannot work.
const [ssoOnly, setSsoOnly] = useState(false) //: Null until the answer arrives. Defaulting to "no provider" drew the
const [ssoEnabled, setSsoEnabled] = useState(false) //: 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') const [providerName, setProviderName] = useState('single sign-on')
useEffect(() => { useEffect(() => {
let live = true 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 => { .then(res => {
if (!live) return if (!live) return
// Registration closed by the switch, or because the site signs in setSsoEnabled(res.data?.sso_enabled === true)
// 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)
setSsoOnly(res.data?.sso_only === true) setSsoOnly(res.data?.sso_only === true)
setProviderName(res.data?.provider_name || 'single sign-on') setProviderName(res.data?.provider_name || 'single sign-on')
}) })
.catch(() => {}) .catch(() => { if (live) { setSsoEnabled(false); setSsoOnly(false) } })
api.get('/auth/sso/config')
.then(res => { if (live) setSsoEnabled(res.data?.sso_enabled === true) })
.catch(() => {})
return () => { live = false } return () => { live = false }
}, []) }, [])
// Opened straight into Register from the header button, or a stale tab const reset = () => { setError(''); setUnverified(false); setResendSent(false) }
// 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 handleLogin = async (e) => { const handleLogin = async (e) => {
e.preventDefault() e.preventDefault()
@ -299,31 +284,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
} finally { setLoading(false) } } 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 () => { const resendVerification = async () => {
setResending(true) setResending(true)
try { await api.post('/auth/resend-verification', { email }) } catch {} try { await api.post('/auth/resend-verification', { email }) } catch {}
@ -331,47 +291,28 @@ function AuthModal({ mode, onClose, onSwitch }) {
setResending(false) setResending(false)
} }
const isLogin = mode === 'login'
return ( return (
<div onClick={onClose} className="lp-modal-backdrop"> <div onClick={onClose} className="lp-modal-backdrop">
<div onClick={e => e.stopPropagation()} className="lp-modal"> <div onClick={e => e.stopPropagation()} className="lp-modal">
<button onClick={onClose} className="lp-modal-close" aria-label="Close"></button> <button onClick={onClose} className="lp-modal-close" aria-label="Close"></button>
{/* Tabs */} {ssoEnabled === null && <div className="loading"><div className="spinner" /></div>}
<div className="lp-tabs">
{[['login', 'Sign In'], ...(signupOpen ? [['register', 'Register']] : [])].map(([m, label]) => (
<button key={m} onClick={() => switchMode(m)} aria-pressed={mode === m}>{label}</button>
))}
</div>
{/* Registration success */}
{registered && (
<div className="lp-modal-note">
<div className="lp-modal-mark" aria-hidden="true">📧</div>
<h3>Check your email</h3>
<p>
We sent a verification link to <strong>{email}</strong>. Click it to activate your account.
</p>
<button className="btn btn-primary" onClick={() => { setRegistered(false); switchMode('login') }}>
Go to Sign In
</button>
</div>
)}
{/* The provider, first, and on an SSO-only site the only thing here. */} {/* The provider, first, and on an SSO-only site the only thing here. */}
{isLogin && !registered && ssoEnabled && ( {ssoEnabled === true && (
<> <>
<a href="/api/auth/sso/login" className="btn btn-primary btn-block" <a href="/api/auth/sso/login" className="btn btn-primary btn-block"
style={{ display: 'block', textAlign: 'center', textDecoration: 'none' }}> style={{ display: 'block', textAlign: 'center', textDecoration: 'none' }}>
Sign in with {providerName} Sign in with {providerName}
</a> </a>
{!ssoOnly && <p className="lp-modal-or">or sign in with email</p>} {ssoOnly === false && <p className="lp-modal-or">or sign in with email</p>}
</> </>
)} )}
{/* Login form */} {/* Login form */}
{isLogin && !registered && !ssoOnly && ( {ssoOnly === false && (
<> <>
{unverified && !resendSent && ( {unverified && !resendSent && (
<div className="lp-unverified"> <div className="lp-unverified">
@ -402,52 +343,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
</> </>
)} )}
{isLogin && !registered && ssoOnly && (
<p className="lp-modal-or">
This site signs in through {providerName}.
</p>
)}
{/* Register form */}
{!isLogin && !registered && !ssoOnly && (
<>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleRegister}>
{/* Labelled by id: these two were bare <label>s that neither
wrapped their input nor named it, so a screen reader met two
boxes with no names and clicking the word did nothing. */}
<div className="form-group">
<label htmlFor="modal-reg-name">Name</label>
<input id="modal-reg-name" type="text" value={name} required autoFocus
autoComplete="name" onChange={e => setName(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="modal-reg-email">Email</label>
<input id="modal-reg-email" type="email" value={email} required
autoComplete="email" onChange={e => setEmail(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="modal-reg-password">Password</label>
<input id="modal-reg-password" type="password" value={password} required minLength={8}
autoComplete="new-password" onChange={e => setPassword(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="modal-reg-confirm">Confirm password</label>
<input id="modal-reg-confirm" type="password" value={confirm} required minLength={8}
autoComplete="new-password" onChange={e => setConfirm(e.target.value)} />
{confirm && password !== confirm && (
<small className="lp-mismatch">These do not match yet.</small>
)}
</div>
<Captcha onVerify={setCaptchaToken} />
<button className="btn btn-primary btn-block"
disabled={loading || !password || password !== confirm
|| (captchaSiteKey() && !captchaToken)}>
{loading ? 'Creating account…' : 'Sign Up'}
</button>
</form>
</>
)}
</div> </div>
</div> </div>
) )
@ -698,20 +593,16 @@ function SlideStudio({ calm }) {
export default function LandingPage() { export default function LandingPage() {
const { user } = useAuth() const { user } = useAuth()
const calm = useCalmMotion() 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 ( return (
<div className="lp-page"> <div className="lp-page">
{authModal && ( {authOpen && <AuthModal onClose={() => setAuthOpen(false)} />}
<AuthModal
mode={authModal}
onClose={() => setAuthModal(null)}
onSwitch={setAuthModal}
/>
)}
<Navbar onSignIn={() => setAuthModal('login')} onRegister={() => setAuthModal('register')} /> <Navbar onSignIn={() => setAuthOpen(true)} />
{/* ── Hero ───────────────────────────────────────────────────────────── */} {/* ── Hero ───────────────────────────────────────────────────────────── */}
<section className="lp-hero"> <section className="lp-hero">
@ -733,8 +624,10 @@ export default function LandingPage() {
<Link to="/study/new" className="btn lp-cta-ghost">Start a session</Link> <Link to="/study/new" className="btn lp-cta-ghost">Start a session</Link>
<Link to="/sessions" className="btn lp-cta-ghost">Your analysis</Link> <Link to="/sessions" className="btn lp-cta-ghost">Your analysis</Link>
</> : <> </> : <>
<button onClick={() => setAuthModal('register')} className="btn btn-primary">Create an account</button> {/* One button. "Create an account" led to a form that no longer
<button onClick={() => setAuthModal('login')} className="btn lp-cta-ghost">Sign in</button> exists an account is made by following an invitation from
the identity provider, not from here. */}
<button onClick={() => setAuthOpen(true)} className="btn btn-primary">Sign in</button>
</>} </>}
</div> </div>
</div> </div>
@ -823,7 +716,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={() => setAuthModal('login')}>Sign In</button> <button onClick={() => setAuthOpen(true)}>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

@ -128,25 +128,33 @@ describe('reduced motion', () => {
}) })
describe('the auth modal', () => { 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() mount()
await userEvent.click(screen.getByRole('button', { name: 'Open login' })) await userEvent.click(screen.getByRole('button', { name: 'Open login' }))
expect(screen.getByRole('form', { name: 'Sign in' })).toBeInTheDocument() expect(await screen.findByRole('form', { name: 'Sign in' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Register' })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Register' })) expect(screen.queryByLabelText('Confirm password')).toBeNull()
expect(screen.getByLabelText('Confirm password')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument()
expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Close' })) await userEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('form', { name: 'Sign in' })).not.toBeInTheDocument() 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() mount()
await userEvent.click(screen.getByRole('button', { name: 'Open registration' })) await userEvent.click(screen.getByRole('button', { name: 'Open login' }))
expect(screen.getByLabelText('Confirm password')).toBeInTheDocument() expect(await screen.findByRole('link', { name: /Sign in with PedsHub SSO/ }))
expect(api.get).toHaveBeenCalledWith('/auth/signup-policy') .toHaveAttribute('href', '/api/auth/sso/login')
expect(screen.queryByRole('form', { name: 'Sign in' })).toBeNull()
expect(screen.queryByText(/signs in through/)).toBeNull()
}) })
}) })

View file

@ -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.hoisted(() => { window.__APP_CONFIG__ = { CAP_SITE_KEY: 'configured-test-site-key' } })
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } })) vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
vi.mock('../components/Navbar', () => ({ default: ({ onSignIn, onRegister }) => <nav><button onClick={onSignIn}>Open login</button><button onClick={onRegister}>Open registration</button></nav> })) vi.mock('../components/Navbar', () => ({ default: ({ onSignIn }) => <nav><button onClick={onSignIn}>Open login</button></nav> }))
import LoginPage from './LoginPage' import LoginPage from './LoginPage'
import LandingPage from './LandingPage' import LandingPage from './LandingPage'
@ -26,7 +26,10 @@ function mount(Component) {
async function submitLogin() { async function submitLogin() {
// Both pages ask for the password outright now. Sign-in codes were the // 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. // 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() 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('Email'), 'owner@example.test')
await userEvent.type(within(form).getByLabelText('Password'), 'synthetic-password') 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() 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) mount(LandingPage)
await userEvent.click(screen.getByRole('button', { name: 'Open registration' })) await userEvent.click(screen.getByRole('button', { name: 'Open login' }))
const widgets = document.querySelectorAll('cap-widget') expect(screen.queryByRole('button', { name: 'Sign Up', exact: true })).toBeNull()
// One now, not two: registration's. The contact form's went with the form. expect(screen.queryByLabelText('Confirm password')).toBeNull()
expect(widgets).toHaveLength(1) expect(document.querySelectorAll('cap-widget')).toHaveLength(0)
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',
}))
}) })
it('retains SSO-only mode on the standalone login page', async () => { it('retains SSO-only mode on the standalone login page', async () => {

View file

@ -24,9 +24,6 @@ export default function LoginPage() {
const [resendSent, setResendSent] = useState(false) const [resendSent, setResendSent] = useState(false)
const [resending, setResending] = useState(false) const [resending, setResending] = useState(false)
const [ssoConfig, setSsoConfig] = useState(null) 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 { login } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const searchParams = new URLSearchParams(window.location.search) const searchParams = new URLSearchParams(window.location.search)
@ -34,7 +31,6 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
api.get('/auth/sso/config').then(r => setSsoConfig(r.data)).catch(() => {}) 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) => { const handleSubmit = async (e) => {
@ -94,6 +90,11 @@ export default function LoginPage() {
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{ssoError && <div className="alert alert-error">SSO login failed. Please try again.</div>} {ssoError && <div className="alert alert-error">SSO login failed. Please try again.</div>}
{/* 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 && <div className="loading"><div className="spinner" /></div>}
{ssoConfig?.sso_enabled && ( {ssoConfig?.sso_enabled && (
<> <>
<a href="/api/auth/sso/login" className="btn btn-primary" style={{ width: '100%', display: 'block', textAlign: 'center', textDecoration: 'none', marginBottom: 16 }}> <a href="/api/auth/sso/login" className="btn btn-primary" style={{ width: '100%', display: 'block', textAlign: 'center', textDecoration: 'none', marginBottom: 16 }}>
@ -103,7 +104,7 @@ export default function LoginPage() {
</> </>
)} )}
{!ssoConfig?.sso_only && ( {ssoConfig && !ssoConfig.sso_only && (
<> <>
<form aria-label="Sign in" onSubmit={handleSubmit}> <form aria-label="Sign in" onSubmit={handleSubmit}>
<div className="form-group"> <div className="form-group">
@ -125,14 +126,6 @@ export default function LoginPage() {
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link> <Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
</div> </div>
{/* Only where there is a door. Registration can be turned off
site-wide, and this offered a locked one the only way to find
out was to fill the form in and be refused. */}
{policy?.registration_open !== false && (
<div className="auth-link">
Don't have an account? <Link to="/register">Sign up</Link>
</div>
)}
</> </>
)} )}
</div> </div>

View file

@ -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 <div className="auth-page"><div className="auth-card"><div className="loading"><div className="spinner" /></div></div></div>
}
// Nothing to fill in on a site where accounts come from the provider.
if (policy.sso_only) {
return (
<div className="auth-page">
<div className="auth-card" style={{ textAlign: 'center' }}>
<h1>Accounts come by invitation</h1>
<p style={{ color: 'var(--text-muted)', marginBottom: 20 }}>
This site signs in through {policy.provider_name || 'single sign-on'}.
Ask an administrator for a sign-up link.
</p>
<Link to="/login" className="btn btn-primary" style={{ display: 'inline-block' }}>
Go to sign in
</Link>
</div>
</div>
)
}
if (done) {
return (
<div className="auth-page">
<div className="auth-card" style={{ textAlign: 'center' }}>
<div style={{ fontSize: '3rem', marginBottom: 16 }}>📧</div>
<h1 style={{ marginBottom: 12 }}>Check your email</h1>
<p style={{ color: '#64748b', marginBottom: 8 }}>
We sent a verification link to <strong>{email}</strong>.
</p>
<p style={{ color: '#64748b', fontSize: '0.875rem', marginBottom: 24 }}>
Click the link in the email to activate your account, then you can log in.
</p>
<Link to="/login" className="btn btn-primary" style={{ display: 'inline-block' }}>Go to Login</Link>
<div className="auth-link" style={{ marginTop: 16 }}>
Wrong email? <button onClick={() => setDone(false)} style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer', padding: 0 }}>Go back</button>
</div>
</div>
</div>
)
}
return (
<div className="auth-page">
<div className="auth-card">
<h1>Create Account</h1>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="reg-name">Name</label>
<input id="reg-name" type="text" value={name} onChange={e => setName(e.target.value)} required />
</div>
<div className="form-group">
<label htmlFor="reg-email">Email</label>
<input id="reg-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div className="form-group">
<label htmlFor="reg-password">Password</label>
<input id="reg-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} />
</div>
<div className="form-group">
<label htmlFor="reg-password-2">Confirm password</label>
<input id="reg-password-2" type="password" value={confirm} required minLength={8}
autoComplete="new-password"
onChange={e => setConfirm(e.target.value)} />
{confirm && password !== confirm && (
<small style={{ color: 'var(--wrong-fg)', fontSize: '0.8rem' }}>
These do not match yet.
</small>
)}
</div>
<Captcha onVerify={setCaptchaToken} />
<button className="btn btn-primary" style={{ width: '100%' }}
disabled={loading || !password || password !== confirm
|| (captchaSiteKey() && !captchaToken)}>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
</form>
<div className="auth-link">
Already have an account? <Link to="/login">Sign in</Link>
</div>
</div>
</div>
)
}