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));
+}