diff --git a/frontend/src/components/drawer/PasskeyModal.tsx b/frontend/src/components/drawer/PasskeyModal.tsx index 97940d9..bf1b5ad 100644 --- a/frontend/src/components/drawer/PasskeyModal.tsx +++ b/frontend/src/components/drawer/PasskeyModal.tsx @@ -1,26 +1,26 @@ -import { LockKeyIcon } from "@phosphor-icons/react"; +import { HourglassSimpleMediumIcon } from "@phosphor-icons/react"; +import { useAuth } from "../../hooks/useAuth"; import { Modal } from "../ui/Modal"; -interface PasskeyModalProps { - onUnlock: (password: string) => Promise; -} +export function PasskeyModal() { + const { unlock } = useAuth(); -export function PasskeyModal({ onUnlock }: PasskeyModalProps) { return ( -

- Authentication Required + You've been away a while.

- We need your passkey to open your letters + Your letters are still there. Just need the key once more.

- Your passkey is used to decrypt your data locally. + Nothing was lost.

{ isInitializing: true, }); useKeyStore.setState({ masterKey: null }); + + vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({ + masterKey: mockMasterKey, + authHash: "mock-hash", + }); }); describe("isAuthenticated", () => { @@ -201,3 +208,68 @@ describe("initialize", () => { expect(useKeyStore.getState().masterKey).not.toBeNull(); }); }); + +describe("unlock", () => { + beforeEach(() => { + useAuthStore.setState({ + accessToken: "valid-token", + user: mockUser, + isInitializing: false, + }); + }); + + it("should derive the master key from the user password, validate it via API, and persist it", async () => { + let loginCalled = false; + server.use( + http.post(`${VITE_API_URL}/api/auth/login/`, async () => { + loginCalled = true; + return HttpResponse.json({ access: "token", user: mockUser }); + }), + ); + const { result } = renderHook(() => useAuth()); + + await act(async () => { + await result.current.unlock("password"); + }); + + expect(CryptoUtils.deriveKeyBundle).toHaveBeenCalledWith( + "password", + mockUser.email, + ); + expect(loginCalled).toBe(true); + expect(saveMasterKey).toHaveBeenCalledWith(mockMasterKey); + expect(useKeyStore.getState().masterKey).toEqual(mockMasterKey); + }); + + it("should logout if user is not present", async () => { + useAuthStore.setState({ user: null }); + const { result } = renderHook(() => useAuth()); + + await act(async () => { + await result.current.unlock("password"); + }); + + expect(CryptoUtils.deriveKeyBundle).not.toHaveBeenCalled(); + expect(saveMasterKey).not.toHaveBeenCalled(); + expect(useAuthStore.getState().accessToken).toBeNull(); + expect(clearMasterKey).toHaveBeenCalled(); + }); + + it("should throw an error and not persist the key if validation fails", async () => { + server.use( + http.post( + `${VITE_API_URL}/api/auth/login/`, + () => new HttpResponse(null, { status: 400 }), + ), + ); + + const { result } = renderHook(() => useAuth()); + + await act(async () => { + await expect(result.current.unlock("wrong-password")).rejects.toThrow(); + }); + + expect(saveMasterKey).not.toHaveBeenCalled(); + expect(useKeyStore.getState().masterKey).toBeNull(); + }); +}); diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 9cd9815..ca2b8ca 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -57,7 +57,6 @@ export const useAuth = () => { } try { - // try session refresh const { data: refreshData } = await publicApi.post(endpoints.REFRESH); const { data: userData } = await api.get(endpoints.ME, { headers: { Authorization: `Bearer ${refreshData.access}` }, @@ -71,16 +70,24 @@ export const useAuth = () => { }, [setMasterKey]); const unlock = async (password: string) => { - if (!user) return; + if (!user) { + await logout(); + return; + } - try { - const { masterKey } = await CryptoUtils.deriveKeyBundle( - password, - user.email, - ); - await saveMasterKey(masterKey); - setMasterKey(masterKey); - } catch {} + const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle( + password, + user.email, + ); + + // Validate password by calling login endpoint + await api.post(endpoints.LOGIN, { + email: user.email, + password: authHash, + }); + + await saveMasterKey(masterKey); + setMasterKey(masterKey); }; return { diff --git a/frontend/src/pages/Drawer.test.tsx b/frontend/src/pages/Drawer.test.tsx index e8657c6..05feade 100644 --- a/frontend/src/pages/Drawer.test.tsx +++ b/frontend/src/pages/Drawer.test.tsx @@ -9,73 +9,75 @@ import Drawer from "./Drawer"; vi.mock("../hooks/useLetters"); describe("Drawer Page", () => { - beforeEach(() => { - // Setup authenticated state for the test - useAuthStore.setState({ - user: mockUser, - accessToken: "fake-token", - isInitializing: false, + beforeEach(() => { + // Setup authenticated state for the test + useAuthStore.setState({ + user: mockUser, + accessToken: "fake-token", + isInitializing: false, + }); + + vi.mocked(useLetters).mockReturnValue({ + drafts: [], + kept: [], + sent: [], + vault: [], + loading: false, + isAuthRequired: false, + }); }); - vi.mocked(useLetters).mockReturnValue({ - drafts: [], - kept: [], - sent: [], - vault: [], - loading: false, - isAuthRequired: false, - }); - }); + it("renders the cabinet sections and empty state message", () => { + render( + + + , + ); - it("renders the cabinet sections and empty state message", () => { - render( - - - , - ); - - expect(screen.getByText(/Drafts/i)).toBeInTheDocument(); - expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getByText(/Vault/i)).toBeInTheDocument(); - expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument(); - }); - - it("renders the loading state", () => { - vi.mocked(useLetters).mockReturnValue({ - drafts: [], - kept: [], - sent: [], - vault: [], - loading: true, - isAuthRequired: false, + expect(screen.getByText(/Drafts/i)).toBeInTheDocument(); + expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(/Vault/i)).toBeInTheDocument(); + expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument(); }); - render( - - - , - ); + it("renders the loading state", () => { + vi.mocked(useLetters).mockReturnValue({ + drafts: [], + kept: [], + sent: [], + vault: [], + loading: true, + isAuthRequired: false, + }); - expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument(); - }); + render( + + + , + ); - it("renders the authentication required modal when api requires auth", () => { - vi.mocked(useLetters).mockReturnValue({ - drafts: [], - kept: [], - sent: [], - vault: [], - loading: false, - isAuthRequired: true, + expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument(); }); - render( - - - , - ); + it("renders the authentication required modal when api requires auth", () => { + vi.mocked(useLetters).mockReturnValue({ + drafts: [], + kept: [], + sent: [], + vault: [], + loading: false, + isAuthRequired: true, + }); - expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument(); - expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument(); - }); + render( + + + , + ); + + expect( + screen.getByText(/You've been away a while./i), + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument(); + }); }); diff --git a/frontend/src/pages/Drawer.tsx b/frontend/src/pages/Drawer.tsx index 19a958b..5fe14f4 100644 --- a/frontend/src/pages/Drawer.tsx +++ b/frontend/src/pages/Drawer.tsx @@ -15,7 +15,7 @@ import { } from "../utils/dateFormat.ts"; export default function Drawer() { - const { user, logout, unlock } = useAuth(); + const { user, logout } = useAuth(); const [openSection, setOpenSection] = useState(null); const navigate = useNavigate(); @@ -30,7 +30,7 @@ export default function Drawer() {
- {isAuthRequired && } + {isAuthRequired && }