feat: no passwords here — sign-in belongs to the provider
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:
parent
77361816d8
commit
e2919e73c2
12 changed files with 91 additions and 499 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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": [],
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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}
|
{user?.email}
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue