feat: no passwords here — sign-in belongs to the provider
Some checks failed
Tests / backend (push) Failing after 8s
Tests / frontend (push) Failing after 31s
Tests / e2e (push) Failing after 42s

The account settings offered "Change password", and there is no password
to change: accounts live at PedsHub SSO, which is also where a passkey,
an address or a second factor is set. A form that writes a credential
nothing checks is worse than no form — it invites somebody to secure
their account by a route that does not secure it. The panel now keeps
the display name and points at the provider for the rest.

Retired with it: GET /auth/verify-email, POST /auth/resend-verification,
POST /auth/forgot-password, POST /auth/reset-password, the new_password
branch of PUT /auth/me, the two schemas behind them, and the three pages
— VerifyEmailPage, ForgotPasswordPage, ResetPasswordPage — with their
routes and the links into them. An account with no password cannot
forget one.

POST /auth/login stays, still refused while sso_only is set. It is the
way back in if the provider is ever unreachable, together with the
DEFAULT_ADMIN_EMAIL seed at startup, and removing it would leave no door
at all on a bad day. The test that walked five password doors now walks
that one and asserts the other four answer 404 rather than 403 — gone,
not guarded.

Verified live: all four endpoints 404, and the account panel shows a
name field and "Manage your account ↗" to
sso.pedshub.com/if/user/#/settings, with no password field anywhere.

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

View file

@ -12,12 +12,11 @@ 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 (
UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest, UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest,
UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest, SsoExchangeRequest, UserUpdateMe, SsoExchangeRequest,
) )
from app.services import email_service from app.services import email_service
from app.utils.auth import ( from app.utils.auth import (
check_rate_limit, get_password_hash, verify_password, create_access_token, check_rate_limit, verify_password, create_access_token, get_current_user,
get_current_user,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -215,95 +214,17 @@ def end_session(family: str, db: Session = Depends(get_db),
refresh_tokens.revoke_family(db, current_user, family) refresh_tokens.revoke_family(db, current_user, family)
@router.get("/verify-email") # ── No password lifecycle ─────────────────────────────────────────────
def verify_email(token: str, db: Session = Depends(get_db)): #
record = db.query(EmailVerification).filter(EmailVerification.token == token).first() # Verifying an address, resending that mail, forgetting a password and
if not record: # resetting one all lived here. Accounts are at the identity provider now: it
raise HTTPException(status_code=400, detail="Invalid verification token") # owns the address, the passkey, the second factor and any password there is,
if record.verified_at is not None: # so a reset link minted here would set a credential nothing checks — and an
return {"message": "Email already verified. You can log in."} # account with no password cannot forget one.
if datetime.utcnow() > record.expires_at: #
raise HTTPException(status_code=400, detail="Verification link has expired. Please register again or request a new link.") # /login stays, refused while `sso_only` is set. It is the way back in if the
# provider is ever unreachable, together with the DEFAULT_ADMIN_EMAIL seed at
record.verified_at = datetime.utcnow() # startup, and taking it out would leave no door at all on a bad day.
db.commit()
return {"message": "Email verified successfully! You can now log in."}
@router.post("/resend-verification")
async def resend_verification(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
_refuse_when_sso_only("Accounts are created through SSO on this site.")
email_normalized = data.email.lower().strip()
user = db.query(User).filter(User.email == email_normalized).first()
if not user:
return {"message": "If that email exists, a verification link has been sent."}
record = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if record and record.verified_at is not None:
# The same sentence as every other outcome. Saying "already verified"
# here told anybody who asked that the address has an account and that
# it is in use — which is the whole of what the wording below is for.
return {"message": "If that email exists, a verification link has been sent."}
token = secrets.token_urlsafe(32)
if record:
record.token = token
record.expires_at = datetime.utcnow() + timedelta(hours=24)
else:
db.add(EmailVerification(user_id=user.id, token=token, expires_at=datetime.utcnow() + timedelta(hours=24)))
db.commit()
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
return {"message": "If that email exists, a verification link has been sent."}
@router.post("/forgot-password")
async def forgot_password(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
_refuse_when_sso_only("There is no password to reset. Please use SSO.")
email_normalized = data.email.lower().strip()
_check_reset_rate_limit(db, email_normalized)
user = db.query(User).filter(User.email == email_normalized).first()
# Always return same message to avoid email enumeration
if not user:
return {"message": "If that email is registered, a reset link has been sent."}
token = secrets.token_urlsafe(32)
reset = PasswordReset(
user_id=user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=1),
)
db.add(reset)
db.commit()
background_tasks.add_task(email_service.send_password_reset_email, user.email, user.name, token)
return {"message": "If that email is registered, a reset link has been sent."}
@router.post("/reset-password")
def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
_refuse_when_sso_only("There is no password to reset. Please use SSO.")
if len(data.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
record = db.query(PasswordReset).filter(
PasswordReset.token == data.token,
PasswordReset.used == False,
).first()
if not record:
raise HTTPException(status_code=400, detail="Invalid or already used reset token")
if datetime.utcnow() > record.expires_at:
raise HTTPException(status_code=400, detail="Reset link has expired. Please request a new one.")
user = db.query(User).filter(User.id == record.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.hashed_password = get_password_hash(data.new_password)
record.used = True
db.commit()
return {"message": "Password reset successfully. You can now log in."}
@router.get("/me/settings") @router.get("/me/settings")
@ -370,25 +291,13 @@ def get_me(current_user: User = Depends(get_current_user)):
@router.put("/me") @router.put("/me")
def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if data.new_password: """Your name. Your sign-in belongs to the provider.
# Setting one is the same door. A password nobody can log in with is
# not harmless: it is a credential waiting for the flag to be turned
# off again.
_refuse_when_sso_only("Passwords are not used on this site. Please use SSO.")
# A first password is not a change of one. Somebody who signed in with
# a code or through single sign-on has none to confirm, and asking for
# it locked them out of ever setting one — the only way through was
# "forgot password", which is a strange thing to click when you never
# had one.
if current_user.hashed_password:
if not data.current_password:
raise HTTPException(status_code=400, detail="Current password required to set a new one")
if not verify_password(data.current_password, current_user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(data.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
current_user.hashed_password = get_password_hash(data.new_password)
This used to set a password as well. There is nowhere for one to be used
that the provider does not own, so writing one here would store a
credential nothing checks and the settings page that offered it invited
somebody to secure their account by a route that secures nothing.
"""
if data.name: if data.name:
current_user.name = data.name current_user.name = data.name

View file

@ -72,17 +72,6 @@ class UserUpdateRole(BaseModel):
class UserUpdateMe(BaseModel): class UserUpdateMe(BaseModel):
name: str | None = None name: str | None = None
current_password: str | None = None
new_password: str | None = None
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: str
class SsoExchangeRequest(BaseModel): class SsoExchangeRequest(BaseModel):

View file

@ -889,16 +889,6 @@
"200" "200"
] ]
}, },
"GET /api/v1/auth/verify-email": {
"body": false,
"params": [
"query:token"
],
"responses": [
"200",
"422"
]
},
"GET /api/v1/categories/": { "GET /api/v1/categories/": {
"body": false, "body": false,
"params": [], "params": [],
@ -2172,14 +2162,6 @@
"422" "422"
] ]
}, },
"POST /api/v1/auth/forgot-password": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/auth/login": { "POST /api/v1/auth/login": {
"body": true, "body": true,
"params": [], "params": [],
@ -2204,22 +2186,6 @@
"422" "422"
] ]
}, },
"POST /api/v1/auth/resend-verification": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/auth/reset-password": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/auth/sso/exchange": { "POST /api/v1/auth/sso/exchange": {
"body": true, "body": true,
"params": [], "params": [],

View file

@ -238,9 +238,13 @@ class DraftPromptTests(unittest.TestCase):
ai.return_value = json.dumps(DRAFT_RESPONSE) ai.return_value = json.dumps(DRAFT_RESPONSE)
generate_article_draft('job-refine', 3, 'Refine me', '', article.id) generate_article_draft('job-refine', 3, 'Refine me', '', article.id)
prompt = ai.call_args.kwargs['messages'][0]['content'] prompt = ai.call_args.kwargs['messages'][0]['content']
self.assertIn('## [clinical] At the bedside', prompt) # Variant and id both travel: the variant so a bedside section is
self.assertIn('## [short] High yield', prompt) # not rewritten as part of the long read, the id so the links
# pointing at it survive the refine.
self.assertIn(f"## [clinical] [id:{'a' * 32}] At the bedside", prompt)
self.assertIn(f"## [short] [id:{'b' * 32}] High yield", prompt)
self.assertIn("must be kept", prompt) self.assertIn("must be kept", prompt)
self.assertIn("returned unchanged", prompt)
finally: finally:
patch.stopall() patch.stopall()
self.bank.tearDown() self.bank.tearDown()

View file

@ -6,6 +6,7 @@ import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import unittest import unittest
import unittest.mock
from unittest.mock import patch from unittest.mock import patch
from fastapi import FastAPI from fastapi import FastAPI
@ -20,12 +21,15 @@ from app.routers import auth
from app.utils.auth import get_current_user from app.utils.auth import get_current_user
class SsoOnlyClosesThePasswordDoors(unittest.TestCase): class SsoOnlyClosesTheLastPasswordDoor(unittest.TestCase):
"""Login checked the flag. The other password doors did not. """Password recovery is not guarded any more — it does not exist.
Registration is gone entirely accounts are made at the provider so Verifying an address, resending that mail, forgetting a password, resetting
what is left to guard is the password somebody already has: resetting it, one and setting one through the profile have all gone: the identity
or setting a new one while signed in. provider owns the address and the credential, so a reset link minted here
would set something nothing checks. What is left is /auth/login, kept as
the way back in if the provider is ever unreachable, and refused while the
site is single sign-on.
""" """
def setUp(self): def setUp(self):
@ -33,17 +37,12 @@ class SsoOnlyClosesThePasswordDoors(unittest.TestCase):
poolclass=StaticPool) poolclass=StaticPool)
Base.metadata.create_all(self.engine) Base.metadata.create_all(self.engine)
self.db = Session(self.engine) self.db = Session(self.engine)
# No password of their own, so the "set a first password" path in self.db.add(User(id=1, name="Reader", email="reader@example.com",
# PUT /auth/me is the one under test rather than bcrypt's opinion of a hashed_password=None))
# fake hash.
self.user = User(id=1, name="Reader", email="reader@example.com",
hashed_password=None)
self.db.add(self.user)
self.db.commit() self.db.commit()
app = FastAPI() app = FastAPI()
app.include_router(auth.router, prefix="/auth") app.include_router(auth.router, prefix="/auth")
app.dependency_overrides[get_db] = lambda: self.db app.dependency_overrides[get_db] = lambda: self.db
app.dependency_overrides[get_current_user] = lambda: self.user
self.client = TestClient(app) self.client = TestClient(app)
def tearDown(self): def tearDown(self):
@ -51,28 +50,25 @@ class SsoOnlyClosesThePasswordDoors(unittest.TestCase):
self.db.close() self.db.close()
self.engine.dispose() self.engine.dispose()
def calls(self): def test_login_is_refused_while_the_provider_is_the_only_way_in(self):
return [ with unittest.mock.patch.object(auth, "_get_sso_settings",
("/auth/forgot-password", {"email": "reader@example.com"}), return_value={"sso_only": True}):
("/auth/reset-password", {"token": "x" * 20, "new_password": "abcdefgh"}), response = self.client.post("/auth/login", json={
("/auth/me", {"new_password": "abcdefgh"}), "email": "reader@example.com", "password": "whatever"})
] self.assertEqual(response.status_code, 403)
self.assertIn("SSO", response.json()["detail"])
def test_every_password_door_is_shut(self): def test_the_recovery_routes_are_gone_rather_than_guarded(self):
with patch.object(auth, "_get_sso_settings", return_value={"sso_only": True}): for path in ("/auth/forgot-password", "/auth/reset-password",
for path, body in self.calls(): "/auth/resend-verification"):
method = self.client.put if path == "/auth/me" else self.client.post self.assertEqual(self.client.post(path, json={}).status_code, 404, path)
response = method(path, json=body) self.assertEqual(self.client.get("/auth/verify-email?token=x").status_code, 404)
self.assertEqual(response.status_code, 403, f"{path}: {response.text}")
self.assertIn("SSO", response.json()["detail"], path) def test_the_profile_no_longer_sets_a_password(self):
from app.schemas.auth import UserUpdateMe
self.assertNotIn("new_password", UserUpdateMe.model_fields)
self.assertNotIn("current_password", UserUpdateMe.model_fields)
def test_and_open_again_when_the_site_is_not_sso_only(self):
# Not asserting success — these have their own rules — only that the
# flag is no longer the thing refusing them.
with patch.object(auth, "_get_sso_settings", return_value={"sso_only": False}):
for path, body in self.calls():
method = self.client.put if path == "/auth/me" else self.client.post
self.assertNotEqual(method(path, json=body).status_code, 403, path)
class OneTimeCodeTests(unittest.TestCase): class OneTimeCodeTests(unittest.TestCase):

View file

@ -28,9 +28,6 @@ const AnalysisPage = lazyPage(() => import('./pages/AnalysisPage'))
const JobsPage = lazyPage(() => import('./pages/JobsPage')) const JobsPage = lazyPage(() => import('./pages/JobsPage'))
const TrashPage = lazyPage(() => import('./pages/TrashPage')) const TrashPage = lazyPage(() => import('./pages/TrashPage'))
const QuizEditPage = lazyPage(() => import('./pages/QuizEditPage')) const QuizEditPage = lazyPage(() => import('./pages/QuizEditPage'))
const VerifyEmailPage = lazyPage(() => import('./pages/VerifyEmailPage'))
const ForgotPasswordPage = lazyPage(() => import('./pages/ForgotPasswordPage'))
const ResetPasswordPage = lazyPage(() => import('./pages/ResetPasswordPage'))
const NotFoundPage = lazyPage(() => import('./pages/NotFoundPage')) const NotFoundPage = lazyPage(() => import('./pages/NotFoundPage'))
const LandingPage = lazyPage(() => import('./pages/LandingPage')) const LandingPage = lazyPage(() => import('./pages/LandingPage'))
const FlashcardsPage = lazyPage(() => import('./pages/FlashcardsPage')) const FlashcardsPage = lazyPage(() => import('./pages/FlashcardsPage'))
@ -194,9 +191,6 @@ function AppRoutes() {
{/* Accounts are made at the identity provider. The address is kept {/* Accounts are made at the identity provider. The address is kept
only so a bookmark lands somewhere sensible. */} only so a bookmark lands somewhere sensible. */}
<Route path="/register" element={<Navigate to="/login" replace />} /> <Route path="/register" element={<Navigate to="/login" replace />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/sso-callback" element={<SsoCallbackPage />} /> <Route path="/sso-callback" element={<SsoCallbackPage />} />
<Route path="/share/:token" element={<PublicQuizPage />} /> <Route path="/share/:token" element={<PublicQuizPage />} />
</Route> </Route>

View file

@ -1,73 +0,0 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [sent, setSent] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await api.post('/auth/forgot-password', { email })
setSent(true)
} catch (err) {
if (err.response?.status === 429) {
setError('Too many reset requests. Please wait before trying again.')
} else {
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.')
else setError('Something went wrong.')
}
} finally {
setLoading(false)
}
}
return (
<div style={{ minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div style={{ width: '100%', maxWidth: 400, padding: '0 16px' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#2563eb' }}>🏥 PedsHub</div>
</div>
<div className="card">
{sent ? (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📧</div>
<h2 style={{ marginBottom: 8 }}>Check your email</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 20 }}>
If that email is registered, we've sent a password reset link. Check your inbox (and spam folder).
</p>
<Link to="/login" style={{ color: '#2563eb', fontSize: '0.9rem' }}>Back to login</Link>
</div>
) : (
<>
<h2 style={{ marginBottom: 4 }}>Forgot password?</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 20 }}>
Enter your email and we'll send you a reset link.
</p>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required placeholder="you@example.com" />
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: '0.9rem', color: '#64748b' }}>
<Link to="/login" style={{ color: '#2563eb' }}>Back to login</Link>
</p>
</>
)}
</div>
</div>
</div>
)
}

View file

@ -239,9 +239,6 @@ function AuthModal({ onClose }) {
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 [resendSent, setResendSent] = useState(false)
const [resending, setResending] = useState(false)
//: 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
@ -268,29 +265,21 @@ function AuthModal({ onClose }) {
return () => { live = false } return () => { live = false }
}, []) }, [])
const reset = () => { setError(''); setUnverified(false); setResendSent(false) } const reset = () => setError('')
const handleLogin = async (e) => { const handleLogin = async (e) => {
e.preventDefault() e.preventDefault()
setError(''); setUnverified(false) setError('')
setLoading(true) setLoading(true)
try { try {
await login(email, password) await login(email, password)
onClose() onClose()
navigate('/') navigate('/')
} catch (err) { } catch (err) {
if (err.response?.status === 403) setUnverified(true) setError(err.response?.data?.detail || 'Login failed')
else setError(err.response?.data?.detail || 'Login failed')
} finally { setLoading(false) } } finally { setLoading(false) }
} }
const resendVerification = async () => {
setResending(true)
try { await api.post('/auth/resend-verification', { email }) } catch {}
setResendSent(true)
setResending(false)
}
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">
@ -314,15 +303,6 @@ function AuthModal({ onClose }) {
{/* Login form */} {/* Login form */}
{ssoOnly === false && ( {ssoOnly === false && (
<> <>
{unverified && !resendSent && (
<div className="lp-unverified">
<p>Email not verified. Check your inbox.</p>
<button className="btn btn-sm btn-primary" onClick={resendVerification} disabled={resending}>
{resending ? 'Sending…' : 'Resend verification email'}
</button>
</div>
)}
{resendSent && <div className="alert alert-success">Verification email sent.</div>}
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
<form aria-label="Sign in" onSubmit={handleLogin}> <form aria-label="Sign in" onSubmit={handleLogin}>
<div className="form-group"> <div className="form-group">
@ -337,9 +317,6 @@ function AuthModal({ onClose }) {
{loading ? 'Signing in…' : 'Sign In'} {loading ? 'Signing in…' : 'Sign In'}
</button> </button>
</form> </form>
<div className="lp-forgot">
<Link to="/forgot-password" onClick={onClose}>Forgot password?</Link>
</div>
</> </>
)} )}

View file

@ -20,9 +20,6 @@ export default function LoginPage() {
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 [resendSent, setResendSent] = useState(false)
const [resending, setResending] = useState(false)
const [ssoConfig, setSsoConfig] = useState(null) const [ssoConfig, setSsoConfig] = useState(null)
const { login } = useAuth() const { login } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
@ -36,56 +33,23 @@ export default function LoginPage() {
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
setUnverified(false)
setLoading(true) setLoading(true)
try { try {
await login(email, password) await login(email, password)
navigate('/') navigate('/')
} catch (err) { } catch (err) {
if (err.response?.status === 403) { const detail = err.response?.data?.detail
setUnverified(true) setError(typeof detail === 'string' ? detail : 'Login failed')
} else {
const detail = err.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Login failed')
}
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const resendVerification = async () => {
setResending(true)
try {
await api.post('/auth/resend-verification', { email })
} catch {
// Always shown as sent, so this cannot be used to learn who has an account.
} finally {
setResendSent(true)
setResending(false)
}
}
return ( return (
<div className="auth-page"> <div className="auth-page">
<div className="auth-card"> <div className="auth-card">
<h1>Sign In</h1> <h1>Sign In</h1>
{unverified && !resendSent && (
<div style={{ background: '#fffbeb', border: '1px solid #fcd34d', borderRadius: 8, padding: '14px 16px', marginBottom: 16 }}>
<p style={{ margin: '0 0 10px', fontSize: '0.875rem', color: '#92400e', fontWeight: 500 }}>
Your email is not verified. Please check your inbox.
</p>
<button className="btn btn-sm btn-primary" onClick={resendVerification} disabled={resending}>
{resending ? 'Sending…' : 'Resend verification email'}
</button>
</div>
)}
{resendSent && (
<div className="alert alert-success">
Verification email sent check your inbox and spam folder.
</div>
)}
{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>}
@ -122,9 +86,6 @@ export default function LoginPage() {
</button> </button>
</form> </form>
<div className="auth-link">
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
</div>
</> </>
)} )}

View file

@ -1,73 +0,0 @@
import { useState } from 'react'
import { useSearchParams, Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
export default function ResetPasswordPage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (password !== confirm) { setError('Passwords do not match'); return }
if (password.length < 8) { setError('Password must be at least 8 characters'); return }
setLoading(true)
try {
await api.post('/auth/reset-password', { token, new_password: password })
setSuccess(true)
setTimeout(() => navigate('/login'), 2500)
} catch (err) {
setError(err.response?.data?.detail || 'Reset failed. The link may have expired.')
} finally {
setLoading(false)
}
}
return (
<div style={{ minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div style={{ width: '100%', maxWidth: 400, padding: '0 16px' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#2563eb' }}>🏥 PedsHub</div>
</div>
<div className="card">
{success ? (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}></div>
<h2 style={{ marginBottom: 8 }}>Password reset!</h2>
<p style={{ color: '#64748b' }}>Redirecting you to login...</p>
</div>
) : (
<>
<h2 style={{ marginBottom: 4 }}>Set new password</h2>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 20 }}>Choose a strong password for your account.</p>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>New Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required placeholder="At least 8 characters" />
</div>
<div className="form-group">
<label>Confirm Password</label>
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required />
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || !token}>
{loading ? 'Resetting...' : 'Reset Password'}
</button>
{!token && <p style={{ color: '#dc2626', fontSize: '0.85rem', marginTop: 8 }}>Invalid or missing reset token.</p>}
</form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: '0.9rem' }}>
<Link to="/login" style={{ color: '#2563eb' }}>Back to login</Link>
</p>
</>
)}
</div>
</div>
</div>
)
}

View file

@ -24,46 +24,43 @@ function Section({ title, description, children }) {
} }
function ProfileSection({ user }) { function ProfileSection({ user }) {
// Whether there is one at all never anything about it. Reported by the // Name here, everything else at the provider.
// server, because nothing on this side can tell an account with no password //
// from one whose password it simply does not know. // This panel used to change a password, and there is no password to change:
const hasPassword = user?.has_password !== false // accounts live at PedsHub SSO, which is also where a passkey, an address or
// a second factor is set. A form that writes a credential nothing checks is
// worse than no form it invites somebody to secure an account by a route
// that does not secure it.
const [name, setName] = useState(user?.name || '') const [name, setName] = useState(user?.name || '')
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('')
const [account, setAccount] = useState(null)
useEffect(() => {
let live = true
api.get('/auth/sso/config')
.then(res => { if (live) setAccount(res.data?.sso_enabled ? res.data : false) })
.catch(() => { if (live) setAccount(false) })
return () => { live = false }
}, [])
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setError(''); setSuccess('') setError(''); setSuccess('')
if (newPassword && newPassword !== confirmPassword) return setError('Passwords do not match') if (name === user.name) return setError('No changes to save')
if (newPassword && newPassword.length < 8) return setError('Password must be at least 8 characters')
const payload = {}
if (name !== user.name) payload.name = name
// A first password has none to confirm. Somebody who arrived through
// single sign-on, or who only ever signs in with a code, has never chosen
// one asking for the current one locked them out of setting their first.
if (newPassword) {
payload.new_password = newPassword
if (hasPassword) payload.current_password = currentPassword
}
if (!Object.keys(payload).length) return setError('No changes to save')
setLoading(true) setLoading(true)
try { try {
await api.put('/auth/me', payload) await api.put('/auth/me', { name })
setSuccess('Saved successfully') setSuccess('Saved successfully')
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('') setTimeout(() => window.location.reload(), 800)
if (payload.name) setTimeout(() => window.location.reload(), 800)
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Failed to save') setError(err.response?.data?.detail || 'Failed to save')
} finally { setLoading(false) } } finally { setLoading(false) }
} }
return ( return (
<Section title="Account" description="Your name, and the password you sign in with."> <Section title="Account" description="Your name here; your sign-in at the provider.">
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 20 }}> <p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 20 }}>
{user?.email} &nbsp; {user?.email} &nbsp;
<span style={{ <span style={{
@ -76,34 +73,22 @@ function ProfileSection({ user }) {
{success && <div className="alert alert-success">{success}</div>} {success && <div className="alert alert-success">{success}</div>}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-group"> <div className="form-group">
<label>Display Name</label> <label htmlFor="set-name">Display Name</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} required /> <input id="set-name" type="text" value={name} onChange={e => setName(e.target.value)} required />
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<p style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: 12 }}>
{hasPassword ? 'Change password' : 'Set a password'}{' '}
<span style={{ fontWeight: 400 }}>
{hasPassword
? '(leave blank to keep current)'
: '— optional. You can keep signing in with a code or single sign-on.'}
</span>
</p>
{hasPassword && (
<div className="form-group">
<label>Current Password</label>
<input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} placeholder="Required to change password" />
</div>
)}
<div className="form-group">
<label>{hasPassword ? 'New Password' : 'Password'}</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="At least 8 characters" />
</div>
<div className="form-group">
<label>{hasPassword ? 'Confirm New Password' : 'Confirm Password'}</label>
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />
</div> </div>
<button className="btn btn-primary" type="submit" disabled={loading}>{loading ? 'Saving...' : 'Save Changes'}</button> <button className="btn btn-primary" type="submit" disabled={loading}>{loading ? 'Saving...' : 'Save Changes'}</button>
</form> </form>
{account && (
<>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '18px 0' }} />
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: 10 }}>
Your sign-in passkeys, your email address, a second factor is
managed at {account.provider_name || 'your identity provider'}.
</p>
<a className="btn btn-secondary" href="https://sso.pedshub.com/if/user/#/settings"
target="_blank" rel="noopener noreferrer">Manage your account </a>
</>
)}
</Section> </Section>
) )
} }

View file

@ -1,43 +0,0 @@
import { useState, useEffect } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import api from '../api/client'
export default function VerifyEmailPage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const [status, setStatus] = useState('verifying') // verifying | success | error
const [message, setMessage] = useState('')
useEffect(() => {
if (!token) { setStatus('error'); setMessage('No verification token provided.'); return }
api.get(`/auth/verify-email?token=${token}`)
.then(res => { setStatus('success'); setMessage(res.data.message) })
.catch(err => { setStatus('error'); setMessage(err.response?.data?.detail || 'Verification failed.') })
}, [token])
return (
<div style={{ minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div className="card" style={{ maxWidth: 420, width: '100%', textAlign: 'center' }}>
{status === 'verifying' && (
<><div className="spinner" style={{ margin: '0 auto 16px' }}></div><p>Verifying your email...</p></>
)}
{status === 'success' && (
<>
<div style={{ fontSize: '3rem', marginBottom: 12 }}></div>
<h2 style={{ color: '#166534', marginBottom: 8 }}>Email Verified!</h2>
<p style={{ color: '#64748b', marginBottom: 24 }}>{message}</p>
<Link to="/login" className="btn btn-primary">Log In</Link>
</>
)}
{status === 'error' && (
<>
<div style={{ fontSize: '3rem', marginBottom: 12 }}></div>
<h2 style={{ color: '#dc2626', marginBottom: 8 }}>Verification Failed</h2>
<p style={{ color: '#64748b', marginBottom: 24 }}>{message}</p>
<Link to="/login" className="btn btn-secondary">Back to Login</Link>
</>
)}
</div>
</div>
)
}