From e2919e73c2188eb47eccccefb6cf25bb2d09e33b Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 15:50:38 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20no=20passwords=20here=20=E2=80=94=20sig?= =?UTF-8?q?n-in=20belongs=20to=20the=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/auth.py | 129 ++++------------------ backend/app/schemas/auth.py | 11 -- backend/tests/api-contract.json | 34 ------ backend/tests/test_article_ai.py | 8 +- backend/tests/test_sso_hardening.py | 60 +++++----- frontend/src/App.jsx | 6 - frontend/src/pages/ForgotPasswordPage.jsx | 73 ------------ frontend/src/pages/LandingPage.jsx | 29 +---- frontend/src/pages/LoginPage.jsx | 43 +------- frontend/src/pages/ResetPasswordPage.jsx | 73 ------------ frontend/src/pages/SettingsPage.jsx | 81 ++++++-------- frontend/src/pages/VerifyEmailPage.jsx | 43 -------- 12 files changed, 91 insertions(+), 499 deletions(-) delete mode 100644 frontend/src/pages/ForgotPasswordPage.jsx delete mode 100644 frontend/src/pages/ResetPasswordPage.jsx delete mode 100644 frontend/src/pages/VerifyEmailPage.jsx diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 34c44f9..59b6f31 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -12,12 +12,11 @@ from app.models.email_verification import EmailVerification from app.models.password_reset import PasswordReset from app.schemas.auth import ( UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest, - UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest, SsoExchangeRequest, + UserUpdateMe, SsoExchangeRequest, ) from app.services import email_service from app.utils.auth import ( - check_rate_limit, get_password_hash, verify_password, create_access_token, - get_current_user, + check_rate_limit, verify_password, create_access_token, get_current_user, ) 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) -@router.get("/verify-email") -def verify_email(token: str, db: Session = Depends(get_db)): - record = db.query(EmailVerification).filter(EmailVerification.token == token).first() - if not record: - raise HTTPException(status_code=400, detail="Invalid verification token") - if record.verified_at is not None: - return {"message": "Email already verified. You can log in."} - if datetime.utcnow() > record.expires_at: - raise HTTPException(status_code=400, detail="Verification link has expired. Please register again or request a new link.") - - record.verified_at = datetime.utcnow() - 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."} +# ── No password lifecycle ───────────────────────────────────────────── +# +# Verifying an address, resending that mail, forgetting a password and +# resetting one all lived here. Accounts are at the identity provider now: it +# owns the address, the passkey, the second factor and any password there is, +# so a reset link minted here would set a credential nothing checks — and an +# account with no password cannot forget one. +# +# /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 +# startup, and taking it out would leave no door at all on a bad day. @router.get("/me/settings") @@ -370,25 +291,13 @@ def get_me(current_user: User = Depends(get_current_user)): @router.put("/me") def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - if data.new_password: - # 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) + """Your name. Your sign-in belongs to the provider. + 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: current_user.name = data.name diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 716c1eb..fa4395d 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -72,17 +72,6 @@ class UserUpdateRole(BaseModel): class UserUpdateMe(BaseModel): 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): diff --git a/backend/tests/api-contract.json b/backend/tests/api-contract.json index be5ad64..7364150 100644 --- a/backend/tests/api-contract.json +++ b/backend/tests/api-contract.json @@ -889,16 +889,6 @@ "200" ] }, - "GET /api/v1/auth/verify-email": { - "body": false, - "params": [ - "query:token" - ], - "responses": [ - "200", - "422" - ] - }, "GET /api/v1/categories/": { "body": false, "params": [], @@ -2172,14 +2162,6 @@ "422" ] }, - "POST /api/v1/auth/forgot-password": { - "body": true, - "params": [], - "responses": [ - "200", - "422" - ] - }, "POST /api/v1/auth/login": { "body": true, "params": [], @@ -2204,22 +2186,6 @@ "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": { "body": true, "params": [], diff --git a/backend/tests/test_article_ai.py b/backend/tests/test_article_ai.py index f6a8f0b..9a74704 100644 --- a/backend/tests/test_article_ai.py +++ b/backend/tests/test_article_ai.py @@ -238,9 +238,13 @@ class DraftPromptTests(unittest.TestCase): ai.return_value = json.dumps(DRAFT_RESPONSE) generate_article_draft('job-refine', 3, 'Refine me', '', article.id) prompt = ai.call_args.kwargs['messages'][0]['content'] - self.assertIn('## [clinical] At the bedside', prompt) - self.assertIn('## [short] High yield', prompt) + # Variant and id both travel: the variant so a bedside section is + # 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("returned unchanged", prompt) finally: patch.stopall() self.bank.tearDown() diff --git a/backend/tests/test_sso_hardening.py b/backend/tests/test_sso_hardening.py index 46f81d3..ad60f36 100644 --- a/backend/tests/test_sso_hardening.py +++ b/backend/tests/test_sso_hardening.py @@ -6,6 +6,7 @@ import os os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") import unittest +import unittest.mock from unittest.mock import patch from fastapi import FastAPI @@ -20,12 +21,15 @@ from app.routers import auth from app.utils.auth import get_current_user -class SsoOnlyClosesThePasswordDoors(unittest.TestCase): - """Login checked the flag. The other password doors did not. +class SsoOnlyClosesTheLastPasswordDoor(unittest.TestCase): + """Password recovery is not guarded any more — it does not exist. - 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. + Verifying an address, resending that mail, forgetting a password, resetting + one and setting one through the profile have all gone: the identity + 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): @@ -33,17 +37,12 @@ class SsoOnlyClosesThePasswordDoors(unittest.TestCase): poolclass=StaticPool) Base.metadata.create_all(self.engine) self.db = Session(self.engine) - # No password of their own, so the "set a first password" path in - # PUT /auth/me is the one under test rather than bcrypt's opinion of a - # fake hash. - self.user = User(id=1, name="Reader", email="reader@example.com", - hashed_password=None) - self.db.add(self.user) + self.db.add(User(id=1, name="Reader", email="reader@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 - app.dependency_overrides[get_current_user] = lambda: self.user self.client = TestClient(app) def tearDown(self): @@ -51,28 +50,25 @@ class SsoOnlyClosesThePasswordDoors(unittest.TestCase): self.db.close() self.engine.dispose() - def calls(self): - return [ - ("/auth/forgot-password", {"email": "reader@example.com"}), - ("/auth/reset-password", {"token": "x" * 20, "new_password": "abcdefgh"}), - ("/auth/me", {"new_password": "abcdefgh"}), - ] + def test_login_is_refused_while_the_provider_is_the_only_way_in(self): + with unittest.mock.patch.object(auth, "_get_sso_settings", + return_value={"sso_only": True}): + response = self.client.post("/auth/login", json={ + "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): - with patch.object(auth, "_get_sso_settings", return_value={"sso_only": True}): - for path, body in self.calls(): - method = self.client.put if path == "/auth/me" else self.client.post - response = method(path, json=body) - self.assertEqual(response.status_code, 403, f"{path}: {response.text}") - self.assertIn("SSO", response.json()["detail"], path) + def test_the_recovery_routes_are_gone_rather_than_guarded(self): + for path in ("/auth/forgot-password", "/auth/reset-password", + "/auth/resend-verification"): + self.assertEqual(self.client.post(path, json={}).status_code, 404, path) + self.assertEqual(self.client.get("/auth/verify-email?token=x").status_code, 404) + + 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): diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5e749ca..096614c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -28,9 +28,6 @@ const AnalysisPage = lazyPage(() => import('./pages/AnalysisPage')) const JobsPage = lazyPage(() => import('./pages/JobsPage')) const TrashPage = lazyPage(() => import('./pages/TrashPage')) 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 LandingPage = lazyPage(() => import('./pages/LandingPage')) const FlashcardsPage = lazyPage(() => import('./pages/FlashcardsPage')) @@ -194,9 +191,6 @@ 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/pages/ForgotPasswordPage.jsx b/frontend/src/pages/ForgotPasswordPage.jsx deleted file mode 100644 index 47a4de7..0000000 --- a/frontend/src/pages/ForgotPasswordPage.jsx +++ /dev/null @@ -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 ( -
-
-
-
🏥 PedsHub
-
-
- {sent ? ( -
-
📧
-

Check your email

-

- If that email is registered, we've sent a password reset link. Check your inbox (and spam folder). -

- Back to login -
- ) : ( - <> -

Forgot password?

-

- Enter your email and we'll send you a reset link. -

- {error &&
{error}
} -
-
- - setEmail(e.target.value)} required placeholder="you@example.com" /> -
- -
-

- Back to login -

- - )} -
-
-
- ) -} diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index a7070b3..73542db 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -239,9 +239,6 @@ function AuthModal({ onClose }) { const [password, setPassword] = useState('') const [error, setError] = useState('') 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 //: /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 @@ -268,29 +265,21 @@ function AuthModal({ onClose }) { return () => { live = false } }, []) - const reset = () => { setError(''); setUnverified(false); setResendSent(false) } + const reset = () => setError('') const handleLogin = async (e) => { e.preventDefault() - setError(''); setUnverified(false) + setError('') setLoading(true) try { await login(email, password) onClose() navigate('/') } catch (err) { - if (err.response?.status === 403) setUnverified(true) - else setError(err.response?.data?.detail || 'Login failed') + setError(err.response?.data?.detail || 'Login failed') } finally { setLoading(false) } } - const resendVerification = async () => { - setResending(true) - try { await api.post('/auth/resend-verification', { email }) } catch {} - setResendSent(true) - setResending(false) - } - return (
e.stopPropagation()} className="lp-modal"> @@ -314,15 +303,6 @@ function AuthModal({ onClose }) { {/* Login form */} {ssoOnly === false && ( <> - {unverified && !resendSent && ( -
-

Email not verified. Check your inbox.

- -
- )} - {resendSent &&
Verification email sent.
} {error &&
{error}
}
@@ -337,9 +317,6 @@ function AuthModal({ onClose }) { {loading ? 'Signing in…' : 'Sign In'} -
- Forgot password? -
)} diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 5e227bc..245402a 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -20,9 +20,6 @@ export default function LoginPage() { const [password, setPassword] = useState('') const [error, setError] = useState('') 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 { login } = useAuth() const navigate = useNavigate() @@ -36,56 +33,23 @@ export default function LoginPage() { const handleSubmit = async (e) => { e.preventDefault() setError('') - setUnverified(false) setLoading(true) try { await login(email, password) navigate('/') } catch (err) { - if (err.response?.status === 403) { - setUnverified(true) - } else { - const detail = err.response?.data?.detail - setError(typeof detail === 'string' ? detail : 'Login failed') - } + const detail = err.response?.data?.detail + setError(typeof detail === 'string' ? detail : 'Login failed') } finally { 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 (

Sign In

- {unverified && !resendSent && ( -
-

- Your email is not verified. Please check your inbox. -

- -
- )} - - {resendSent && ( -
- Verification email sent — check your inbox and spam folder. -
- )} {error &&
{error}
} {ssoError &&
SSO login failed. Please try again.
} @@ -122,9 +86,6 @@ export default function LoginPage() { -
- Forgot password? -
)} diff --git a/frontend/src/pages/ResetPasswordPage.jsx b/frontend/src/pages/ResetPasswordPage.jsx deleted file mode 100644 index 1f5fba8..0000000 --- a/frontend/src/pages/ResetPasswordPage.jsx +++ /dev/null @@ -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 ( -
-
-
-
🏥 PedsHub
-
-
- {success ? ( -
-
-

Password reset!

-

Redirecting you to login...

-
- ) : ( - <> -

Set new password

-

Choose a strong password for your account.

- {error &&
{error}
} -
-
- - setPassword(e.target.value)} required placeholder="At least 8 characters" /> -
-
- - setConfirm(e.target.value)} required /> -
- - {!token &&

Invalid or missing reset token.

} -
-

- Back to login -

- - )} -
-
-
- ) -} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 9b0f84f..d56d622 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -24,46 +24,43 @@ function Section({ title, description, children }) { } function ProfileSection({ user }) { - // Whether there is one at all — never anything about it. Reported by the - // server, because nothing on this side can tell an account with no password - // from one whose password it simply does not know. - const hasPassword = user?.has_password !== false + // Name here, everything else at the provider. + // + // This panel used to change a 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 an account by a route + // that does not secure it. const [name, setName] = useState(user?.name || '') - const [currentPassword, setCurrentPassword] = useState('') - const [newPassword, setNewPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = 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) => { e.preventDefault() setError(''); setSuccess('') - if (newPassword && newPassword !== confirmPassword) return setError('Passwords do not match') - 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') + if (name === user.name) return setError('No changes to save') setLoading(true) try { - await api.put('/auth/me', payload) + await api.put('/auth/me', { name }) setSuccess('Saved successfully') - setCurrentPassword(''); setNewPassword(''); setConfirmPassword('') - if (payload.name) setTimeout(() => window.location.reload(), 800) + setTimeout(() => window.location.reload(), 800) } catch (err) { setError(err.response?.data?.detail || 'Failed to save') } finally { setLoading(false) } } return ( -
+

{user?.email}   {success}

}
- - setName(e.target.value)} required /> -
-
-

- {hasPassword ? 'Change password' : 'Set a password'}{' '} - - {hasPassword - ? '(leave blank to keep current)' - : '— optional. You can keep signing in with a code or single sign-on.'} - -

- {hasPassword && ( -
- - setCurrentPassword(e.target.value)} placeholder="Required to change password" /> -
- )} -
- - setNewPassword(e.target.value)} placeholder="At least 8 characters" /> -
-
- - setConfirmPassword(e.target.value)} /> + + setName(e.target.value)} required />
+ {account && ( + <> +
+

+ Your sign-in — passkeys, your email address, a second factor — is + managed at {account.provider_name || 'your identity provider'}. +

+ Manage your account ↗ + + )} ) } diff --git a/frontend/src/pages/VerifyEmailPage.jsx b/frontend/src/pages/VerifyEmailPage.jsx deleted file mode 100644 index 6264531..0000000 --- a/frontend/src/pages/VerifyEmailPage.jsx +++ /dev/null @@ -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 ( -
-
- {status === 'verifying' && ( - <>

Verifying your email...

- )} - {status === 'success' && ( - <> -
-

Email Verified!

-

{message}

- Log In - - )} - {status === 'error' && ( - <> -
-

Verification Failed

-

{message}

- Back to Login - - )} -
-
- ) -}