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 ; } + if (window.location.pathname === "/forgot-password") { + return ; + } if (window.location.pathname.startsWith("/invite/")) { return ; } diff --git a/frontend/src/api.js b/frontend/src/api.js index 37dbfc0..8b56c67 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -28,6 +28,8 @@ async function request(path, { method = "GET", payload, token, basicAuth } = {}) export const login = (payload) => request("/auth/login", { method: "POST", payload }); +export const resetPassword = (payload) => + request("/auth/reset-password", { method: "POST", payload }); export const previewInvite = (token) => request(`/auth/invites/${encodeURIComponent(token)}`); export const acceptInvite = (token, payload) => diff --git a/frontend/src/components/ChangePasswordModal.jsx b/frontend/src/components/ChangePasswordModal.jsx index 040d046..47669af 100644 --- a/frontend/src/components/ChangePasswordModal.jsx +++ b/frontend/src/components/ChangePasswordModal.jsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { changePassword } from "../api"; +import PasswordField from "./PasswordField"; function validateForm(form) { const errors = {}; @@ -79,8 +80,7 @@ export default function ChangePasswordModal({ account, onComplete }) {