diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index c18eb2e..121df49 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -23,6 +23,8 @@ from app.schemas import (
InvitePreview,
LoginRequest,
PasswordChangeRequest,
+ PasswordResetRequest,
+ PasswordResetResponse,
)
from app.services.member_invitations import get_valid_invitation
from app.services.welcome_email import send_welcome_email
@@ -306,6 +308,35 @@ async def accept_invite(
)
+@router.post("/reset-password", response_model=PasswordResetResponse)
+async def reset_password(
+ payload: PasswordResetRequest,
+ session: AsyncSession = Depends(get_postgres_session),
+) -> PasswordResetResponse:
+ validate_password(payload.new_password)
+ account = await session.scalar(
+ select(Account).where(Account.email == str(payload.email).lower())
+ )
+ password_hash.verify(
+ payload.new_password,
+ account.password_hash if account is not None else dummy_password_hash,
+ )
+ if account is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="No account was found for that email.",
+ )
+ if account.member_status == "invited":
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Accept your invitation email to set your password before resetting it.",
+ )
+ account.password_hash = password_hash.hash(payload.new_password)
+ account.must_change_password = False
+ await session.commit()
+ return PasswordResetResponse()
+
+
@router.post("/change-password", response_model=AccountRead)
async def change_password(
payload: PasswordChangeRequest,
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index abcc9ae..e8ac302 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -104,6 +104,15 @@ class PasswordChangeRequest(BaseModel):
new_password: str = Field(min_length=8, max_length=128)
+class PasswordResetRequest(BaseModel):
+ email: EmailStr
+ new_password: str = Field(min_length=8, max_length=128)
+
+
+class PasswordResetResponse(BaseModel):
+ ok: bool = True
+
+
class InvitePreview(BaseModel):
work_email: EmailStr
first_name: str
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 701da23..f2681a5 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,4 +1,5 @@
import DashboardPage from "./components/DashboardPage";
+import ForgotPasswordPage from "./components/ForgotPasswordPage";
import InviteAcceptPage from "./components/InviteAcceptPage";
import LoginPage from "./components/LoginPage";
import MobileLanding from "./components/MobileLanding";
@@ -13,6 +14,9 @@ export default function App() {
if (window.location.pathname === "/login") {
return