diff --git a/apps/expo/app/auth/index.tsx b/apps/expo/app/auth/index.tsx index 9ef7e948d5..82dbff5f14 100644 --- a/apps/expo/app/auth/index.tsx +++ b/apps/expo/app/auth/index.tsx @@ -119,7 +119,7 @@ export default function AuthIndexScreen() { size={Platform.select({ ios: 'lg', default: 'md' })} onPress={signInWithApple} > - + {''} {t('auth.continueWithApple')} )} diff --git a/apps/expo/app/auth/one-time-password.tsx b/apps/expo/app/auth/one-time-password.tsx index 7702a0cedc..57222fc452 100644 --- a/apps/expo/app/auth/one-time-password.tsx +++ b/apps/expo/app/auth/one-time-password.tsx @@ -15,7 +15,6 @@ import { type NativeSyntheticEvent, Platform, Pressable, - type TargetedEvent, type TextInput, type TextInputKeyPressEventData, View, @@ -27,7 +26,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; const LOGO_SOURCE = require('expo-app/assets/packrat-app-icon-gradient.png'); const COUNTDOWN_SECONDS_TO_RESEND_CODE = 60; -const NUM_OF_CODE_CHARACTERS = 5; +const NUM_OF_CODE_CHARACTERS = 6; const SCREEN_OPTIONS = { headerBackTitle: 'Back', headerTransparent: true, @@ -266,12 +265,6 @@ function OTPField({ } } - function onFocus(_e: NativeSyntheticEvent) { - inputRef.current?.setNativeProps({ - selection: { start: 0, end: value?.toString().length }, - }); - } - function onChangeText(text: string) { setCodeValues((prev) => { const values = [...prev]; @@ -311,7 +304,6 @@ ios:border ios:border-border ios:rounded-lg " clearButtonMode="never" materialHideActionIcons materialRingColor={hasError ? colors.destructive : undefined} - onFocus={onFocus} onKeyPress={onKeyPress} onChangeText={onChangeText} onSubmitEditing={ diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index e908830db5..c80857b729 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -1,3 +1,4 @@ +import { clientEnvs } from '@packrat/env/expo-client'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { GoogleSignin, @@ -187,19 +188,27 @@ export function useAuthActions() { }; const forgotPassword = async (email: string) => { - const { error } = await authClient.requestPasswordReset({ - email, - redirectTo: 'packrat://reset-password', + const res = await fetch(`${clientEnvs.EXPO_PUBLIC_API_URL}/api/password-reset/request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), }); - if (error) throw new Error(error.message ?? 'Forgot password failed'); + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error ?? 'Forgot password failed'); + } }; - const resetPassword = async (_email: string, opts: { token: string; newPassword: string }) => { - const { error } = await authClient.resetPassword({ - token: opts.token, - newPassword: opts.newPassword, + const resetPassword = async (email: string, opts: { token: string; newPassword: string }) => { + const res = await fetch(`${clientEnvs.EXPO_PUBLIC_API_URL}/api/password-reset/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, code: opts.token, newPassword: opts.newPassword }), }); - if (error) throw new Error(error.message ?? 'Reset password failed'); + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error ?? 'Reset password failed'); + } }; const verifyEmail = async (_email: string, token: string) => { diff --git a/apps/expo/lib/api/packrat.ts b/apps/expo/lib/api/packrat.ts index 05dac97f7a..3e616f0146 100644 --- a/apps/expo/lib/api/packrat.ts +++ b/apps/expo/lib/api/packrat.ts @@ -1,20 +1,52 @@ import { createApiClient } from '@packrat/api-client'; import { clientEnvs } from '@packrat/env/expo-client'; +import { fromZod } from '@packrat/guards'; import { store } from 'expo-app/atoms/store'; import { needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms'; import { authClient } from 'expo-app/lib/auth-client'; +import * as SecureStore from 'expo-secure-store'; +import { z } from 'zod'; + +// The expoClient plugin serialises all cookies into SecureStore under this key. +// Parsing it locally avoids a network round-trip on every API request. +const COOKIE_STORE_KEY = 'packrat_cookie'; + +const CookieStoreSchema = z.record(z.object({ value: z.string() })); + +// expoClient stores cookies as JSON: { "better-auth.session_token": { value, expires } } +function parseSessionToken(cookieJson: string | null): string | null { + console.log('[auth] Parsing session token from cookie string:', cookieJson); + if (!cookieJson) return null; + try { + const cookies = fromZod(CookieStoreSchema)(JSON.parse(cookieJson)); + console.log( + '[auth] Parsed session token from cookie string:', + cookies?.['better-auth.session_token']?.value ?? 'null', + ); + return cookies?.['better-auth.session_token']?.value ?? null; + } catch (err) { + console.warn('[auth] Failed to parse session token from cookie string:', err); + return null; + } +} export const apiClient = createApiClient({ baseUrl: clientEnvs.EXPO_PUBLIC_API_URL, auth: { + // Read the token from SecureStore — no network call on every API request. getAccessToken: async () => { - const { data } = await authClient.getSession(); - return data?.session?.token ?? null; + const cookieStr = await SecureStore.getItemAsync(COOKIE_STORE_KEY); + return parseSessionToken(cookieStr); }, - // Better Auth manages session renewal internally — no separate refresh token flow. + // Better Auth has no separate refresh-token endpoint; the 7-day session + // token is the only credential. Returning null here is intentional. getRefreshToken: () => null, onAccessTokenRefreshed: () => {}, - onNeedsReauth: () => { + onNeedsReauth: async () => { + // A 401 can be transient (e.g. the server briefly returned an error). + // Verify the session is actually gone before alarming the user. + const { data } = await authClient.getSession(); + if (data?.session) return; store.set(needsReauthAtom, true); }, }, diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index db72d1d13f..ed9ffdac47 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -9,6 +9,7 @@ import { guidesRoutes } from './guides'; import { knowledgeBaseRoutes } from './knowledgeBase'; import { packsRoutes } from './packs'; import { packTemplatesRoutes } from './packTemplates'; +import { passwordResetRoutes } from './passwordReset'; import { seasonSuggestionsRoutes } from './seasonSuggestions'; import { trailConditionsRoutes } from './trailConditions'; import { trailsRoutes } from './trails'; @@ -35,6 +36,7 @@ export const routes = new Elysia({ prefix: '/api' }) .use(weatherRoutes) .use(packTemplatesRoutes) .use(seasonSuggestionsRoutes) + .use(passwordResetRoutes) .use(userRoutes) .use(uploadRoutes) .use(trailConditionsRoutes) diff --git a/packages/api/src/routes/passwordReset.ts b/packages/api/src/routes/passwordReset.ts new file mode 100644 index 0000000000..3144533cb2 --- /dev/null +++ b/packages/api/src/routes/passwordReset.ts @@ -0,0 +1,47 @@ +import { ForgotPasswordRequestSchema, ResetPasswordRequestSchema } from '@packrat/api/schemas/auth'; +import { + requestPasswordReset, + verifyOtpAndResetPassword, +} from '@packrat/api/services/passwordResetService'; +import { Elysia, status } from 'elysia'; + +export const passwordResetRoutes = new Elysia({ prefix: '/password-reset' }) + // public-route: unauthenticated users need this to initiate a password reset + .post( + '/request', + async ({ body }) => { + await requestPasswordReset(body.email); + return { success: true, message: 'If an account exists, a reset code has been sent.' }; + }, + { + body: ForgotPasswordRequestSchema, + detail: { + tags: ['Auth'], + summary: 'Request password reset', + description: + 'Send a 6-digit OTP to the user email. Always returns success to prevent email enumeration.', + }, + }, + ) + // public-route: unauthenticated users need this to verify OTP and set a new password + .post( + '/verify', + async ({ body }) => { + try { + await verifyOtpAndResetPassword(body); + return { success: true, message: 'Password reset successfully.' }; + } catch (error) { + return status(400, { + error: error instanceof Error ? error.message : 'Password reset failed', + }); + } + }, + { + body: ResetPasswordRequestSchema, + detail: { + tags: ['Auth'], + summary: 'Verify OTP and reset password', + description: 'Validate the 6-digit OTP and set a new password.', + }, + }, + ); diff --git a/packages/api/src/schemas/auth.ts b/packages/api/src/schemas/auth.ts index b6784b07fb..69fb82e5c8 100644 --- a/packages/api/src/schemas/auth.ts +++ b/packages/api/src/schemas/auth.ts @@ -79,7 +79,7 @@ export const ForgotPasswordResponseSchema = z.object({ export const ResetPasswordRequestSchema = z.object({ email: z.string().email(), - code: z.string().length(5), + code: z.string().length(6), newPassword: z.string().min(8), }); diff --git a/packages/api/src/services/passwordResetService.ts b/packages/api/src/services/passwordResetService.ts new file mode 100644 index 0000000000..0f7dbe0a03 --- /dev/null +++ b/packages/api/src/services/passwordResetService.ts @@ -0,0 +1,82 @@ +import { hashPassword } from '@better-auth/utils/password'; +import { createDb } from '@packrat/api/db'; +import { account, users, verification } from '@packrat/api/db/schema'; +import { timingSafeEqual } from '@packrat/api/utils/auth'; +import { sendPasswordResetEmail } from '@packrat/api/utils/email'; +import { and, eq, gt } from 'drizzle-orm'; + +const OTP_LENGTH = 6; +const OTP_TTL_MS = 15 * 60 * 1000; // 15 minutes +const IDENTIFIER_PREFIX = 'password-reset:'; + +function generateOtp(): string { + return Array.from({ length: OTP_LENGTH }, () => Math.floor(Math.random() * 10)).join(''); +} + +export async function requestPasswordReset(email: string): Promise { + const db = createDb(); + + const user = await db.query.users.findFirst({ where: eq(users.email, email) }); + if (!user) return; // Don't reveal whether the email is registered + + const code = generateOtp(); + const identifier = `${IDENTIFIER_PREFIX}${email}`; + const now = new Date(); + const expiresAt = new Date(now.getTime() + OTP_TTL_MS); + + await db.delete(verification).where(eq(verification.identifier, identifier)); + await db.insert(verification).values({ + id: crypto.randomUUID(), + identifier, + value: code, + expiresAt, + createdAt: now, + updatedAt: now, + }); + + await sendPasswordResetEmail({ to: email, code }); +} + +export async function verifyOtpAndResetPassword({ + email, + code, + newPassword, +}: { + email: string; + code: string; + newPassword: string; +}): Promise { + const db = createDb(); + const identifier = `${IDENTIFIER_PREFIX}${email}`; + + const record = await db.query.verification.findFirst({ + where: and(eq(verification.identifier, identifier), gt(verification.expiresAt, new Date())), + }); + + if (!record || !timingSafeEqual(record.value, code)) { + throw new Error('Invalid or expired reset code'); + } + + const user = await db.query.users.findFirst({ where: eq(users.email, email) }); + if (!user) throw new Error('User not found'); + + const hashedPassword = await hashPassword(newPassword); + const now = new Date(); + + // Update the credential account record (Better Auth email/password users) + const updated = await db + .update(account) + .set({ password: hashedPassword, updatedAt: now }) + .where(and(eq(account.userId, user.id), eq(account.providerId, 'credential'))) + .returning(); + + // Fallback for legacy users whose password lives on the users row + if (updated.length === 0) { + await db + .update(users) + .set({ passwordHash: hashedPassword, updatedAt: now }) + .where(eq(users.id, user.id)); + } + + await db.delete(verification).where(eq(verification.identifier, identifier)); +}