From d8e3250f84fd3582d1b2ff96289984d2ddf2f508 Mon Sep 17 00:00:00 2001 From: alpha Date: Wed, 19 Aug 2026 10:07:18 -0400 Subject: [PATCH 1/6] feat(auth): bridge the password reset endpoints to the renderer Adds forgotPassword, verifyPasswordResetCode, and resetPassword through the whole main-process path: API client, service, IPC handlers, preload, and the renderer type declarations. forgotPassword resolving true means the request went through, never that the address is registered. The backend answers identically either way so that the endpoint cannot be used to test who has an account, and reporting anything more specific up to the renderer would put that enumeration back over the UI. resetPassword rewrites the stored password when rememberMe is on, guarded the same way changePassword guards it. The login form pre-fills from that store, and the password sitting in it after a reset is the one that just stopped working. Refs #102 Co-Authored-By: Claude Opus 5 --- src/main/api/auth.ts | 27 ++++++++++ src/main/ipc/auth.ts | 18 +++++++ src/main/preload.cts | 5 ++ src/main/services/auth.service.ts | 76 ++++++++++++++++++++++++++++ src/main/types/auth.ts | 15 ++++++ src/renderer/types/electron-api.d.ts | 12 +++++ 6 files changed, 153 insertions(+) diff --git a/src/main/api/auth.ts b/src/main/api/auth.ts index 9e8c4bb..b8f5f52 100644 --- a/src/main/api/auth.ts +++ b/src/main/api/auth.ts @@ -6,10 +6,13 @@ import { AuthToken, ChangePasswordRequest, + ForgotPasswordRequest, LoginRequest, + ResetPasswordRequest, SendVerificationCodeRequest, SignupRequest, VerifyEmailCodeRequest, + VerifyPasswordResetCodeRequest, } from '../types/auth.js'; import { ApiClient, ApiResponse } from './client.js'; @@ -55,4 +58,28 @@ export class AuthApi extends ApiClient { async changePassword(data: ChangePasswordRequest): Promise> { return this.post('/api/auth/change-password', data); } + + /** + * Request a password reset code. + * + * Succeeds whether or not the address has an account - the backend answers identically + * either way so that the endpoint cannot be used to test who has one. + */ + async forgotPassword(data: ForgotPasswordRequest): Promise> { + return this.post('/api/auth/forgot-password', data); + } + + /** + * Check a password reset code without spending it + */ + async verifyPasswordResetCode(data: VerifyPasswordResetCodeRequest): Promise> { + return this.post('/api/auth/verify-password-reset-code', data); + } + + /** + * Set a new password from a reset code + */ + async resetPassword(data: ResetPasswordRequest): Promise> { + return this.post('/api/auth/reset-password', data); + } } diff --git a/src/main/ipc/auth.ts b/src/main/ipc/auth.ts index 7b8111a..6a5c5fa 100644 --- a/src/main/ipc/auth.ts +++ b/src/main/ipc/auth.ts @@ -38,4 +38,22 @@ export function registerAuthHandlers(): void { return authService.changePassword(currentPassword, newPassword); } ); + + // Request a password reset code + ipcMain.handle('auth:forgot-password', async (_event, email: string) => { + return authService.forgotPassword(email); + }); + + // Verify a password reset code + ipcMain.handle('auth:verify-password-reset-code', async (_event, email: string, code: string) => { + return authService.verifyPasswordResetCode(email, code); + }); + + // Set a new password from a reset code + ipcMain.handle( + 'auth:reset-password', + async (_event, email: string, code: string, newPassword: string) => { + return authService.resetPassword(email, code, newPassword); + } + ); } diff --git a/src/main/preload.cts b/src/main/preload.cts index 1988e42..23f7302 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -65,6 +65,11 @@ const electronApi = { logout: () => ipcRenderer.invoke('auth:logout'), changePassword: (currentPassword: string, newPassword: string) => ipcRenderer.invoke('auth:change-password', currentPassword, newPassword), + forgotPassword: (email: string) => ipcRenderer.invoke('auth:forgot-password', email), + verifyPasswordResetCode: (email: string, code: string) => + ipcRenderer.invoke('auth:verify-password-reset-code', email, code), + resetPassword: (email: string, code: string, newPassword: string) => + ipcRenderer.invoke('auth:reset-password', email, code, newPassword), }, account: { diff --git a/src/main/services/auth.service.ts b/src/main/services/auth.service.ts index b1e7f15..14c3451 100644 --- a/src/main/services/auth.service.ts +++ b/src/main/services/auth.service.ts @@ -176,6 +176,82 @@ export class AuthService { return { success: false, error: 'Change password failed' }; } } + + /** + * Request a password reset code for an address. + * + * The backend answers the same whether or not that address has an account, so a `true` + * here means "the request went through", never "this address is registered". Reporting + * anything more specific to the renderer would put back over the UI the enumeration the + * endpoint exists to avoid. + */ + async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> { + try { + const response = await this.client.forgotPassword({ email }); + if (response.error) { + return { + success: false, + error: response.error.message || 'Failed to send password reset code', + }; + } + return { success: true }; + } catch { + return { success: false, error: 'Failed to send password reset code' }; + } + } + + /** + * Check a password reset code without spending it. + */ + async verifyPasswordResetCode( + email: string, + code: string + ): Promise<{ success: boolean; error?: string }> { + try { + const response = await this.client.verifyPasswordResetCode({ email, code }); + if (response.error) { + return { + success: false, + error: response.error.message || 'Invalid or expired reset code', + }; + } + return { success: true }; + } catch { + return { success: false, error: 'Invalid or expired reset code' }; + } + } + + /** + * Set a new password from a reset code. + */ + async resetPassword( + email: string, + code: string, + newPassword: string + ): Promise<{ success: boolean; error?: string }> { + try { + const response = await this.client.resetPassword({ + email, + code, + new_password: newPassword, + }); + if (response.error) { + return { success: false, error: response.error.message || 'Password reset failed' }; + } + + // The login form pre-fills from the store when rememberMe is on, and the password it + // holds is the one that just stopped working. Same guard as changePassword: only write + // when the user opted in, since login/logout leave the store empty otherwise. + const config = configStore.getConfig(); + if (config.rememberMe) { + configStore.updateConfig({ email, password: newPassword }); + } + + return { success: true }; + } catch { + return { success: false, error: 'Password reset failed' }; + } + } } export const authService = new AuthService(); diff --git a/src/main/types/auth.ts b/src/main/types/auth.ts index 98f5bee..436cd31 100644 --- a/src/main/types/auth.ts +++ b/src/main/types/auth.ts @@ -30,3 +30,18 @@ export interface ChangePasswordRequest { current_password: string; new_password: string; } + +export interface ForgotPasswordRequest { + email: string; +} + +export interface VerifyPasswordResetCodeRequest { + email: string; + code: string; +} + +export interface ResetPasswordRequest { + email: string; + code: string; + new_password: string; +} diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 15c2827..925fbed 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -51,6 +51,18 @@ declare global { oldPassword: string, newPassword: string ) => Promise<{ success: boolean; error?: string }>; + // Succeeds whether or not the address has an account: the backend answers identically + // either way so it cannot be used to test who is registered. + forgotPassword: (email: string) => Promise<{ success: boolean; error?: string }>; + verifyPasswordResetCode: ( + email: string, + code: string + ) => Promise<{ success: boolean; error?: string }>; + resetPassword: ( + email: string, + code: string, + newPassword: string + ) => Promise<{ success: boolean; error?: string }>; }; // Account config management (full name, profile, context) - synced with the backend From f40ddc3755e7d15c57ccae275eba04a6a9d0de8c Mon Sep 17 00:00:00 2001 From: alpha Date: Wed, 19 Aug 2026 10:12:39 -0400 Subject: [PATCH 2/6] feat(auth): add the forgot-password wizard to the sign-in screen A three-step wizard at /auth/forgot-password (email -> code -> new password), shaped like the signup one, reached from a link on the login page. Code-based rather than an emailed link: a link opens the system browser, which has no way to hand a token back to the app without a registered deep-link protocol handler. Step one advances on success alone and never says whether the address has an account, and the copy on step two is conditional for the same reason. The backend answers that endpoint identically either way so it cannot be used to test who is registered, and a UI reporting the difference would hand the oracle straight back. The code step exists so a mistyped code fails there rather than after the user has typed a new password twice, and the password step says up front that the reset signs them out everywhere, since it does. test/password-reset.test.mjs pins the remember-me store writes in both directions and the failed-reset case. Closes #102 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 8 +- src/main/preload.cts | 3 +- src/main/services/auth.service.ts | 15 +- src/renderer/hooks/use-auth.ts | 81 ++++++++- src/renderer/pages/auth/forgot-password.tsx | 185 ++++++++++++++++++++ src/renderer/pages/auth/login.tsx | 15 +- src/renderer/router.tsx | 5 + src/renderer/types/electron-api.d.ts | 5 +- test/password-reset.test.mjs | 96 ++++++++++ test/run.mjs | 1 + 10 files changed, 403 insertions(+), 11 deletions(-) create mode 100644 src/renderer/pages/auth/forgot-password.tsx create mode 100644 test/password-reset.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 9d56dfa..dae4a58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,13 @@ Each `LiveSuggestion` still carries the `mode` it was *generated* under, and the ### Routing -Hash-based router (required for Electron `file://` protocol). Routes: `/` (index, redirects based on login state) -> `/auth/login` or `/auth/signup` -> `/main` (interview UI) -> `/payment`. +Hash-based router (required for Electron `file://` protocol). Routes: `/` (index, redirects based on login state) -> `/auth/login`, `/auth/signup`, or `/auth/forgot-password` -> `/main` (interview UI) -> `/payment`. + +`/auth/forgot-password` is a three-step wizard shaped like the signup one (email -> code -> password), and the reset is code-based rather than an emailed link because a link opens the system browser, which has no way to hand a token back without a registered deep-link protocol handler. + +**Step one advances on success alone and never reports "no such account".** The backend answers `forgot-password` identically for a registered and an unregistered address so that the endpoint cannot be used to test who has one, and a UI that reported the difference would hand that oracle straight back - which is why the copy on step two is conditional ("if an account exists for..."). `AuthService.forgotPassword` resolving true means the request went through, nothing more. + +`AuthService.resetPassword` rewrites the stored password when `rememberMe` is on, guarded exactly the way `changePassword` guards it. The login form pre-fills from that store, so skipping the write leaves a filled-in password that has just stopped working, and writing it unguarded puts credentials on disk for a user who did not opt in. `test/password-reset.test.mjs` pins both directions plus the failed-reset case. ### Window and Stealth Mode diff --git a/src/main/preload.cts b/src/main/preload.cts index 23f7302..f9b2da3 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -56,7 +56,8 @@ const electronApi = { }, auth: { - sendVerificationCode: (email: string) => ipcRenderer.invoke('auth:send-verification-code', email), + sendVerificationCode: (email: string) => + ipcRenderer.invoke('auth:send-verification-code', email), verifyEmailCode: (email: string, code: string) => ipcRenderer.invoke('auth:verify-email-code', email, code), signup: (username: string, email: string, password: string, verificationCode: string) => diff --git a/src/main/services/auth.service.ts b/src/main/services/auth.service.ts index 14c3451..eefb7a4 100644 --- a/src/main/services/auth.service.ts +++ b/src/main/services/auth.service.ts @@ -23,7 +23,10 @@ export class AuthService { try { const response = await this.client.sendVerificationCode({ email }); if (response.error) { - return { success: false, error: response.error.message || 'Failed to send verification code' }; + return { + success: false, + error: response.error.message || 'Failed to send verification code', + }; } return { success: true }; } catch { @@ -34,11 +37,17 @@ export class AuthService { /** * Verify an email verification code. */ - async verifyEmailCode(email: string, code: string): Promise<{ success: boolean; error?: string }> { + async verifyEmailCode( + email: string, + code: string + ): Promise<{ success: boolean; error?: string }> { try { const response = await this.client.verifyEmailCode({ email, code }); if (response.error) { - return { success: false, error: response.error.message || 'Invalid or expired verification code' }; + return { + success: false, + error: response.error.message || 'Invalid or expired verification code', + }; } return { success: true }; } catch { diff --git a/src/renderer/hooks/use-auth.ts b/src/renderer/hooks/use-auth.ts index 6b7dea0..6cd0720 100644 --- a/src/renderer/hooks/use-auth.ts +++ b/src/renderer/hooks/use-auth.ts @@ -88,7 +88,12 @@ export default function useAuth() { setLoading(true); setError(null); try { - const result = await window.electronAPI?.auth.signup(username, email, password, verificationCode); + const result = await window.electronAPI?.auth.signup( + username, + email, + password, + verificationCode + ); if (!result?.success) { const errMsg = result?.error || 'Signup failed'; setError(errMsg); @@ -142,6 +147,77 @@ export default function useAuth() { } }; + // Request a password reset code. A `true` result means the request went through, + // not that the address is registered - the backend answers the same either way so that + // the endpoint cannot be used to test who has an account, and the UI must not undo that + // by reporting "no such account" here. + const forgotPassword = async (email: string): Promise => { + setLoading(true); + setError(null); + try { + const result = await window.electronAPI?.auth.forgotPassword(email); + if (!result?.success) { + const errMsg = result?.error || 'Failed to send password reset code'; + setError(errMsg); + return false; + } + return true; + } catch (err) { + console.error('forgotPassword error:', err); + setError('Failed to send password reset code'); + return false; + } finally { + setLoading(false); + } + }; + + // Check a password reset code without spending it, so a mistyped one is caught before + // the user has typed a new password twice. + const verifyPasswordResetCode = async (email: string, code: string): Promise => { + setLoading(true); + setError(null); + try { + const result = await window.electronAPI?.auth.verifyPasswordResetCode(email, code); + if (!result?.success) { + const errMsg = result?.error || 'Invalid or expired reset code'; + setError(errMsg); + return false; + } + return true; + } catch (err) { + console.error('verifyPasswordResetCode error:', err); + setError('Invalid or expired reset code'); + return false; + } finally { + setLoading(false); + } + }; + + // Set a new password from a reset code. + const resetPassword = async ( + email: string, + code: string, + newPassword: string + ): Promise => { + setLoading(true); + setError(null); + try { + const result = await window.electronAPI?.auth.resetPassword(email, code, newPassword); + if (!result?.success) { + const errMsg = result?.error || 'Password reset failed'; + setError(errMsg); + return false; + } + return true; + } catch (err) { + console.error('resetPassword error:', err); + setError('Password reset failed'); + return false; + } finally { + setLoading(false); + } + }; + // Return stable object for consumers; `setError` is exposed so callers // can clear errors when appropriate (e.g. on input changes). return { @@ -151,6 +227,9 @@ export default function useAuth() { signup, logout, changePassword, + forgotPassword, + verifyPasswordResetCode, + resetPassword, loading, error, setError, diff --git a/src/renderer/pages/auth/forgot-password.tsx b/src/renderer/pages/auth/forgot-password.tsx new file mode 100644 index 0000000..abd5ed5 --- /dev/null +++ b/src/renderer/pages/auth/forgot-password.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; + +import { InputPassword } from '@/components/custom/input-password'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import useAuth from '@/hooks/use-auth'; + +type Step = 'email' | 'code' | 'password'; + +export default function ForgotPasswordPage() { + const { forgotPassword, verifyPasswordResetCode, resetPassword, loading, error, setError } = + useAuth(); + const navigate = useNavigate(); + + const [step, setStep] = useState('email'); + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + + const submitEmail = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (await forgotPassword(email.trim())) { + // Advances on success alone, and the copy on the next step is conditional ("if an + // account exists"). The backend answers the same for a registered and an unregistered + // address on purpose, so telling the user which one they typed here would hand back + // exactly the account-enumeration oracle that design removes. + setStep('code'); + } else { + toast.error('Could not send a reset code. Please try again.'); + } + }; + + const submitCode = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (await verifyPasswordResetCode(email.trim(), code.trim())) { + setStep('password'); + } else { + toast.error('Invalid or expired reset code.'); + } + }; + + const submitPassword = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (password !== passwordConfirm) { + setError('Passwords do not match'); + return; + } + + if (await resetPassword(email.trim(), code.trim(), password)) { + toast.success('Password reset. Please sign in with your new password.'); + setTimeout(() => { + navigate('/auth/login'); + }, 2000); + } else { + toast.error('Password reset failed. Please try again.'); + } + }; + + return ( + + + Reset password + Set a new password for your Power Interview AI account + + + {step === 'email' && ( +
+
+ + setEmail(e.target.value)} + maxLength={254} + required + /> +
+ + {error &&
{error}
} + + + +
+ + Back to sign in + +
+
+ )} + + {step === 'code' && ( +
+
+ +

+ If an account exists for {email}, we sent a reset code to it. Paste the code below. + It can only be used once, and the email says when it expires. +

+