Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,17 @@ 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 behind **two** guards, `rememberMe` and the address matching the remembered one. The login form pre-fills from that store, so skipping the write leaves a filled-in password that has just stopped working; writing it on `rememberMe` alone puts credentials on disk for a user who did not opt in. The address check is specific to reset, the only password flow that runs while signed out and therefore the only one that can be run for an account other than the remembered one - on a shared machine, writing unconditionally would replace someone else's remembered login with this one. That write is wrapped in its own `try`, separate from the request. By the time it runs the password has already changed and the code is spent, so letting a disk failure decide the return value would report a failure for a reset that succeeded and send the user to retry with a code that can no longer work - the same trap the login form avoids when it persists remember-me. `test/password-reset.test.mjs` pins all of it, including the failed-reset case and a store that throws.

The final step latches on success. `loading` is already back to false while the two-second redirect runs, so a live button there would let a second click resend a code the backend has just spent, toasting a guaranteed failure over the success still on screen.

It also carries its own way out, which the signup wizard does not need. `AuthLayout` renders this card and nothing else - no navigation of its own - and the reset code expires on `PASSWORD_RESET_CODE_EXPIRE_MINUTES` while the user is choosing a password. A failure there is therefore both likely and unrecoverable in place, since retrying the same dead code cannot succeed, so the step offers `Start over` (back to step one, address kept and code dropped) and a link to sign in, and the failure copy sends the user for a new code rather than telling them to try again.

### Window and Stealth Mode

Expand Down
27 changes: 27 additions & 0 deletions src/main/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
import {
AuthToken,
ChangePasswordRequest,
ForgotPasswordRequest,
LoginRequest,
ResetPasswordRequest,
SendVerificationCodeRequest,
SignupRequest,
VerifyEmailCodeRequest,
VerifyPasswordResetCodeRequest,
} from '../types/auth.js';
import { ApiClient, ApiResponse } from './client.js';

Expand Down Expand Up @@ -55,4 +58,28 @@ export class AuthApi extends ApiClient {
async changePassword(data: ChangePasswordRequest): Promise<ApiResponse<void>> {
return this.post<void>('/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<ApiResponse<void>> {
return this.post<void>('/api/auth/forgot-password', data);
}

/**
* Check a password reset code without spending it
*/
async verifyPasswordResetCode(data: VerifyPasswordResetCodeRequest): Promise<ApiResponse<void>> {
return this.post<void>('/api/auth/verify-password-reset-code', data);
}

/**
* Set a new password from a reset code
*/
async resetPassword(data: ResetPasswordRequest): Promise<ApiResponse<void>> {
return this.post<void>('/api/auth/reset-password', data);
}
}
18 changes: 18 additions & 0 deletions src/main/ipc/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
);
}
8 changes: 7 additions & 1 deletion src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -65,6 +66,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: {
Expand Down
108 changes: 105 additions & 3 deletions src/main/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -176,6 +185,99 @@ 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 if what it holds
// is this account then the password in it is the one that just stopped working.
//
// Two guards, not one. `rememberMe` is the same check changePassword makes: login and
// logout leave the store empty when the user did not opt in, and writing here would put
// credentials back on disk behind their back. The address check is specific to reset,
// which is the only password flow that runs while signed out and can therefore be run
// for an account other than the remembered one. On a shared machine that would
// otherwise replace someone else's remembered login with this one.
//
// Guarded separately from the request, because by this line the reset has already
// happened and the code is spent. Letting a disk write decide the return value reports
// a failure for a reset that succeeded, and the retry that invites cannot work - the
// same trap the login form avoids when it persists remember-me.
try {
const config = configStore.getConfig();
const isRememberedAccount =
(config.email ?? '').trim().toLowerCase() === email.trim().toLowerCase();
if (config.rememberMe && isRememberedAccount) {
configStore.updateConfig({ password: newPassword });
}
} catch (err) {
console.error('Failed to store the new password after reset:', err);
}

return { success: true };
} catch {
return { success: false, error: 'Password reset failed' };
}
}
}

export const authService = new AuthService();
15 changes: 15 additions & 0 deletions src/main/types/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
81 changes: 80 additions & 1 deletion src/renderer/hooks/use-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<boolean> => {
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<boolean> => {
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<boolean> => {
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 {
Expand All @@ -151,6 +227,9 @@ export default function useAuth() {
signup,
logout,
changePassword,
forgotPassword,
verifyPasswordResetCode,
resetPassword,
loading,
error,
setError,
Expand Down
Loading