diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 1193d67a56..96e453e5c3 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -30,12 +30,11 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - cache: true - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Run API tests run: bun run --cwd packages/api test 2>&1 | tee /tmp/api-tests-output.log diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 1fe5950108..1acf7bdc60 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -27,11 +27,10 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - cache: true - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Run Biome (check mode) if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.fix == true) }} run: bun biome check diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 1dec145d77..2c325aaa86 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -66,7 +66,6 @@ jobs: uses: oven-sh/setup-bun@v2 with: bun-version: ${{ steps.bun-version.outputs.version }} - cache: true # Sanity-check that the runner satisfies the repo's engine constraints. - name: Verify runtime versions diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 406917d829..1b06126693 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -84,7 +84,7 @@ jobs: - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Setup Expo uses: expo/expo-github-action@v8 @@ -343,7 +343,7 @@ jobs: - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Setup Expo uses: expo/expo-github-action@v8 diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 11a9522370..b538ce8adf 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -29,7 +29,6 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - cache: true - name: Install dependencies env: @@ -60,7 +59,6 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - cache: true - name: Install dependencies env: diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 3de2a2a9ad..dcdb91c2ea 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -39,12 +39,11 @@ jobs: uses: oven-sh/setup-bun@v2 with: bun-version: latest - cache: true - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Determine target environment id: env diff --git a/.github/workflows/release-ios.yml b/.github/workflows/release-ios.yml index c4312a8923..b89481eb1b 100644 --- a/.github/workflows/release-ios.yml +++ b/.github/workflows/release-ios.yml @@ -47,7 +47,6 @@ jobs: uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.10 - cache: true - name: Validate release tag and app versions run: bun .github/scripts/validate-release-version.ts "${{ steps.release_tag.outputs.tag }}" diff --git a/.github/workflows/sync-guides-r2.yml b/.github/workflows/sync-guides-r2.yml index a04404334c..deb022d7ed 100644 --- a/.github/workflows/sync-guides-r2.yml +++ b/.github/workflows/sync-guides-r2.yml @@ -32,14 +32,13 @@ jobs: uses: oven-sh/setup-bun@v2 with: bun-version: latest - cache: true - name: Authenticate with GitHub for private packages run: | echo "PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile timeout-minutes: 5 - name: Sync guides to R2 bucket diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 86d528bb3a..3faee06299 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -50,12 +50,11 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - cache: true - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Run API unit tests run: bun run --cwd packages/api test:unit:coverage @@ -80,12 +79,11 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - cache: true - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Run Expo unit tests run: bun run --cwd apps/expo test:coverage diff --git a/apps/admin/package.json b/apps/admin/package.json index a0026c80e4..c182e21755 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "packrat-admin-app", - "version": "2.0.25", + "version": "2.0.26", "private": true, "scripts": { "build": "next build", diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 66bba184c2..a60680d0cc 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -37,7 +37,7 @@ export default (): ExpoConfig => { name: getAppName(), slug: 'packrat', - version: '2.0.25', + version: '2.0.26', scheme: 'packrat', web: { bundler: 'metro', diff --git a/apps/expo/app/(app)/trip/location-search.tsx b/apps/expo/app/(app)/trip/location-search.tsx index 6926aab0cd..bd725f4ba5 100644 --- a/apps/expo/app/(app)/trip/location-search.tsx +++ b/apps/expo/app/(app)/trip/location-search.tsx @@ -44,6 +44,7 @@ export default function LocationSearchScreen() { )}&key=${GOOGLE_MAPS_API_KEY}`, ); + if (!response.ok) throw new Error(`Geocode request failed: ${response.status}`); const data = await response.json(); if (data.status === 'OK' && data.results.length > 0) { 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 95ed336fae..e7be9f4966 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 { asBoolean, asString } from '@packrat/guards'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { @@ -188,19 +189,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..94ae708bc5 100644 --- a/apps/expo/lib/api/packrat.ts +++ b/apps/expo/lib/api/packrat.ts @@ -1,20 +1,48 @@ 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 } } +// HTTPS servers (remote dev/prod) prefix the cookie name with __Secure-; HTTP (local) does not. +function parseSessionToken(cookieJson: string | null): string | null { + if (!cookieJson) return null; + const cookies = fromZod(CookieStoreSchema)(JSON.parse(cookieJson)); + if (!cookies) return null; + return ( + cookies['better-auth.session_token']?.value ?? + cookies['__Secure-better-auth.session_token']?.value ?? + 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/apps/expo/lib/hooks/useColorScheme.tsx b/apps/expo/lib/hooks/useColorScheme.tsx index 3c637c8480..f351657df1 100644 --- a/apps/expo/lib/hooks/useColorScheme.tsx +++ b/apps/expo/lib/hooks/useColorScheme.tsx @@ -18,7 +18,7 @@ function useColorScheme() { } function toggleColorScheme() { - return setColorScheme(colorScheme === 'light' ? 'dark' : 'light'); + return setColorScheme((colorScheme ?? 'light') === 'light' ? 'dark' : 'light'); } return { diff --git a/apps/expo/package.json b/apps/expo/package.json index 66c209211d..b329b41dab 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -1,6 +1,6 @@ { "name": "packrat-expo-app", - "version": "2.0.25", + "version": "2.0.26", "private": true, "main": "expo-router/entry", "scripts": { diff --git a/apps/expo/utils/__tests__/weight.test.ts b/apps/expo/utils/__tests__/weight.test.ts index ab4af15c27..9a93706335 100644 --- a/apps/expo/utils/__tests__/weight.test.ts +++ b/apps/expo/utils/__tests__/weight.test.ts @@ -28,34 +28,34 @@ function makeItem( // --------------------------------------------------------------------------- describe('convertWeight', () => { it('returns the same value when from === to', () => { - expect(convertWeight(100, 'g', 'g')).toBe(100); - expect(convertWeight(5, 'oz', 'oz')).toBe(5); - expect(convertWeight(2, 'kg', 'kg')).toBe(2); - expect(convertWeight(1, 'lb', 'lb')).toBe(1); + expect(convertWeight(100, { from: 'g', to: 'g' })).toBe(100); + expect(convertWeight(5, { from: 'oz', to: 'oz' })).toBe(5); + expect(convertWeight(2, { from: 'kg', to: 'kg' })).toBe(2); + expect(convertWeight(1, { from: 'lb', to: 'lb' })).toBe(1); }); it('converts grams to ounces', () => { - expect(convertWeight(100, 'g', 'oz')).toBeCloseTo(3.53, 1); + expect(convertWeight(100, { from: 'g', to: 'oz' })).toBeCloseTo(3.53, 1); }); it('converts ounces to grams', () => { - expect(convertWeight(1, 'oz', 'g')).toBeCloseTo(28.349523125, 8); + expect(convertWeight(1, { from: 'oz', to: 'g' })).toBeCloseTo(28.349523125, 8); }); it('converts grams to kilograms', () => { - expect(convertWeight(1000, 'g', 'kg')).toBe(1); + expect(convertWeight(1000, { from: 'g', to: 'kg' })).toBe(1); }); it('converts kilograms to grams', () => { - expect(convertWeight(1, 'kg', 'g')).toBe(1000); + expect(convertWeight(1, { from: 'kg', to: 'g' })).toBe(1000); }); it('converts grams to pounds', () => { - expect(convertWeight(453.59, 'g', 'lb')).toBeCloseTo(1, 1); + expect(convertWeight(453.59, { from: 'g', to: 'lb' })).toBeCloseTo(1, 1); }); it('converts pounds to grams', () => { - expect(convertWeight(1, 'lb', 'g')).toBeCloseTo(453.59237, 4); + expect(convertWeight(1, { from: 'lb', to: 'g' })).toBeCloseTo(453.59237, 4); }); }); diff --git a/apps/guides/package.json b/apps/guides/package.json index d8e0ffd7af..3c10d2e22f 100644 --- a/apps/guides/package.json +++ b/apps/guides/package.json @@ -1,6 +1,6 @@ { "name": "packrat-guides-app", - "version": "2.0.25", + "version": "2.0.26", "private": true, "scripts": { "build": "bun run generate-og-images && bun run build-content && next build", diff --git a/apps/landing/package.json b/apps/landing/package.json index 63ad6e9d7f..dda2904edc 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -1,6 +1,6 @@ { "name": "packrat-landing-app", - "version": "2.0.25", + "version": "2.0.26", "private": true, "scripts": { "build": "bun run generate-og-images && next build", diff --git a/apps/trails/components/AuthGate.tsx b/apps/trails/components/AuthGate.tsx index be1718de6d..d71388dfd8 100644 --- a/apps/trails/components/AuthGate.tsx +++ b/apps/trails/components/AuthGate.tsx @@ -15,7 +15,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@packrat/web-ui/compon import { Loader2 } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; -import { VerifyEmail } from 'trails-app/components/VerifyEmail'; import { trailsAuthClient } from 'trails-app/lib/auth-client'; import { useAuth } from 'trails-app/lib/useAuth'; @@ -24,7 +23,7 @@ type Tab = (typeof TABS)[number]; const isTab = makeEnumGuard(TABS); export function AuthGate() { - const { authGateOpen, closeAuthGate, register, login, pendingEmail } = useAuth(); + const { authGateOpen, closeAuthGate, register, login } = useAuth(); const [tab, setTab] = useState('register'); const [loading, setLoading] = useState(false); @@ -97,182 +96,174 @@ export function AuthGate() { !open && closeAuthGate()}> - - {pendingEmail ? 'Verify your email' : 'Search trails on PackRat'} - + Search trails on PackRat - {pendingEmail - ? 'Enter the 6-digit code we sent you to unlock search.' - : 'Create a free account to search trails by name or location.'} + Create a free account to search trails by name or location. - {pendingEmail ? ( - - ) : ( - { - if (isTab(v)) setTab(v); - }} - > - - Create account - Log in - + { + if (isTab(v)) setTab(v); + }} + > + + Create account + Log in + - -
-
- - setRegFirstName(e.target.value)} - autoComplete="given-name" - /> -
-
- - setRegEmail(e.target.value)} - required - autoComplete="email" - /> -
-
- - setRegPassword(e.target.value)} - required - minLength={8} - autoComplete="new-password" - /> + + +
+ + setRegFirstName(e.target.value)} + autoComplete="given-name" + /> +
+
+ + setRegEmail(e.target.value)} + required + autoComplete="email" + /> +
+
+ + setRegPassword(e.target.value)} + required + minLength={8} + autoComplete="new-password" + /> +
+ +

+ By creating an account you agree to our{' '} + + Terms + {' '} + and{' '} + + Privacy Policy + + . +

+ +
+ + +
+
+ + setLoginEmail(e.target.value)} + required + autoComplete="email" + /> +
+
+
+ +
-
+ +
+
+ + + {forgotSent ? ( +
+

Check your inbox

+

+ We sent a password reset link to{' '} + {forgotEmail}. +

+ -

- By creating an account you agree to our{' '} - - Terms - {' '} - and{' '} - - Privacy Policy - - . +

+ ) : ( +
+

+ Enter your email and we'll send you a link to reset your password.

-
-
- - -
- + setLoginEmail(e.target.value)} + value={forgotEmail} + onChange={(e) => setForgotEmail(e.target.value)} required autoComplete="email" />
-
-
- - -
- setLoginPassword(e.target.value)} - required - autoComplete="current-password" - /> -
+
-
- - - {forgotSent ? ( -
-

Check your inbox

-

- We sent a password reset link to{' '} - {forgotEmail}. -

- -
- ) : ( -
-

- Enter your email and we'll send you a link to reset your password. -

-
- - setForgotEmail(e.target.value)} - required - autoComplete="email" - /> -
- - -
- )} -
- - )} + )} + +
); diff --git a/apps/trails/components/VerifyEmail.tsx b/apps/trails/components/VerifyEmail.tsx index 67443d8134..3ec221ae57 100644 --- a/apps/trails/components/VerifyEmail.tsx +++ b/apps/trails/components/VerifyEmail.tsx @@ -1,49 +1,8 @@ 'use client'; -import { OTPInput, REGEXP_ONLY_DIGITS } from 'input-otp'; -import { Loader2, Mail } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; -import { toast } from 'sonner'; -import { useAuth } from 'trails-app/lib/useAuth'; - -export function VerifyEmail() { - const { pendingEmail, verifyEmail, resendVerification } = useAuth(); - const [otp, setOtp] = useState(''); - const [loading, setLoading] = useState(false); - const [resendCooldown, setResendCooldown] = useState(0); - - useEffect(() => { - if (resendCooldown <= 0) return; - const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000); - return () => clearTimeout(timer); - }, [resendCooldown]); - - const handleComplete = useCallback( - async (value: string) => { - setLoading(true); - try { - await verifyEmail(value); - toast.success('Email verified! Search unlocked.'); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Invalid code. Try again.'); - setOtp(''); - } finally { - setLoading(false); - } - }, - [verifyEmail], - ); - - const handleResend = useCallback(async () => { - try { - await resendVerification(); - setResendCooldown(60); - toast.success('Verification email sent!'); - } catch { - toast.error('Failed to resend. Try again.'); - } - }, [resendVerification]); +import { Mail } from 'lucide-react'; +export function VerifyEmail({ email }: { email: string }) { return (
@@ -52,52 +11,11 @@ export function VerifyEmail() {

Check your email

- We sent a 6-digit code to{' '} - {pendingEmail} + We sent a verification link to{' '} + {email}. Click the link to verify + your account.

- - ( - <> - {slots.map((slot, i) => ( -
- {slot.char ?? - (slot.isActive ? ( - | - ) : null)} -
- ))} - - )} - /> - - {loading && } - -
- {"Didn't receive it? "} - {resendCooldown > 0 ? ( - Resend in {resendCooldown}s - ) : ( - - )} -
); } diff --git a/apps/trails/lib/apiClient.ts b/apps/trails/lib/apiClient.ts index f5e1dd6dab..a5856a5376 100644 --- a/apps/trails/lib/apiClient.ts +++ b/apps/trails/lib/apiClient.ts @@ -1,46 +1,26 @@ 'use client'; import { createApiClient } from '@packrat/api-client'; -import { - clearTokens, - clearUser, - getAccessToken, - getRefreshToken, - setTokens, -} from 'trails-app/lib/auth'; +import { trailsAuthClient } from 'trails-app/lib/auth-client'; import { trailsEnv } from 'trails-app/lib/env'; -export const apiClient = createApiClient({ - baseUrl: trailsEnv.NEXT_PUBLIC_API_URL, - auth: { - getAccessToken, - getRefreshToken, - onAccessTokenRefreshed: (token) => { - const refresh = getRefreshToken(); - if (refresh) setTokens(token, refresh); - else { - clearTokens(); - clearUser(); - } - }, - onRefreshTokenRefreshed: (token) => { - const access = getAccessToken(); - if (access) setTokens(access, token); - else { - clearTokens(); - clearUser(); - } - }, - onNeedsReauth: () => { - clearTokens(); - clearUser(); - }, - }, -}); - export class AuthExpiredError extends Error { constructor() { super('Session expired. Please log in again.'); this.name = 'AuthExpiredError'; } } + +export const apiClient = createApiClient({ + baseUrl: trailsEnv.NEXT_PUBLIC_API_URL, + auth: { + getAccessToken: async () => { + const { data } = await trailsAuthClient.getSession(); + return data?.session?.token ?? null; + }, + // Better Auth manages session refresh internally via cookies + getRefreshToken: async () => null, + onAccessTokenRefreshed: () => {}, + onNeedsReauth: () => {}, + }, +}); diff --git a/apps/trails/lib/useAuth.tsx b/apps/trails/lib/useAuth.tsx index ef4c8b473f..7ef0a3e850 100644 --- a/apps/trails/lib/useAuth.tsx +++ b/apps/trails/lib/useAuth.tsx @@ -26,6 +26,7 @@ interface AuthActions { resendVerification(): Promise; login(email: string, password: string): Promise; logout(): Promise; + forgotPassword(email: string): Promise; openAuthGate(): void; closeAuthGate(): void; authGateOpen: boolean; @@ -49,22 +50,23 @@ function parseAuthUser(user: { } export function AuthProvider({ children }: { children: React.ReactNode }) { + const [authGateOpen, setAuthGateOpen] = useState(false); const [state, setState] = useState({ isAuthed: false, user: null, pendingEmail: null, }); - const [authGateOpen, setAuthGateOpen] = useState(false); - // Hydrate from localStorage on mount useEffect(() => { + const storedUser = getUser(); const token = getAccessToken(); - const user = getUser(); - if (token && user) { - setState({ isAuthed: true, user, pendingEmail: null }); + if (storedUser && token) { + setState({ isAuthed: true, user: storedUser, pendingEmail: null }); } }, []); + const { isAuthed, user, pendingEmail } = state; + const register = useCallback( async (email: string, opts: { password: string; firstName?: string }) => { const name = opts.firstName ?? email; @@ -75,7 +77,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }); if (error) throw new Error(error.message ?? 'Registration failed'); if (data?.token) { - // autoSignIn: true succeeded β€” token is the Bearer session token const parsedUser = parseAuthUser(data.user as Parameters[0]); if (!parsedUser) throw new Error('Registration failed: unexpected user shape'); setTokens(data.token, ''); @@ -135,29 +136,44 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setState({ isAuthed: false, user: null, pendingEmail: null }); }, []); + const forgotPassword = useCallback(async (email: string) => { + const redirectTo = + typeof window !== 'undefined' + ? `${window.location.origin}/reset-password` + : '/reset-password'; + const { error } = await trailsAuthClient.requestPasswordReset({ email, redirectTo }); + if (error) throw new Error(error.message ?? 'Failed to send reset email'); + }, []); + const openAuthGate = useCallback(() => setAuthGateOpen(true), []); const closeAuthGate = useCallback(() => setAuthGateOpen(false), []); const value = useMemo( () => ({ - ...state, + isAuthed, + user, + pendingEmail, authGateOpen, register, verifyEmail, resendVerification, login, logout, + forgotPassword, openAuthGate, closeAuthGate, }), [ - state, + isAuthed, + user, + pendingEmail, authGateOpen, register, verifyEmail, resendVerification, login, logout, + forgotPassword, openAuthGate, closeAuthGate, ], diff --git a/apps/trails/package.json b/apps/trails/package.json index 7d6afa4681..551680ad78 100644 --- a/apps/trails/package.json +++ b/apps/trails/package.json @@ -1,6 +1,6 @@ { "name": "packrat-trails-app", - "version": "2.0.24", + "version": "2.0.26", "private": true, "scripts": { "build": "next build", diff --git a/apps/web/app/auth/page.tsx b/apps/web/app/auth/page.tsx index d47266cf9d..005ab60a0f 100644 --- a/apps/web/app/auth/page.tsx +++ b/apps/web/app/auth/page.tsx @@ -1,46 +1,9 @@ 'use client'; -import { webEnv } from '@packrat/env/web'; import { useMutation } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import type React from 'react'; import { useState } from 'react'; -import { setTokens } from 'web-app/lib/auth'; - -const API_BASE = webEnv.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787'; - -function useLoginMutation() { - return useMutation({ - mutationFn: async (body: { email: string; password: string }) => { - const res = await fetch(`${API_BASE}/api/auth/sign-in/email`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!res.ok) throw new Error('Login failed'); - return res.json() as Promise<{ token?: string; user?: unknown }>; - }, - }); -} - -function useRegisterMutation() { - return useMutation({ - mutationFn: async (body: { - email: string; - password: string; - firstName?: string; - lastName?: string; - }) => { - const name = [body.firstName, body.lastName].filter(Boolean).join(' ') || body.email; - const res = await fetch(`${API_BASE}/api/auth/sign-up/email`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: body.email, password: body.password, name }), - }); - if (!res.ok) throw new Error('Registration failed'); - return res.json(); - }, - }); -} +import { authClient } from 'web-app/lib/auth-client'; export default function AuthPage() { const [tab, setTab] = useState<'login' | 'register'>('login'); @@ -50,44 +13,36 @@ export default function AuthPage() { const [info, setInfo] = useState(null); const router = useRouter(); - const loginMutation = useLoginMutation(); - const registerMutation = useRegisterMutation(); + const loginMutation = useMutation({ + mutationFn: async (body: { email: string; password: string }) => { + const { error } = await authClient.signIn.email(body); + if (error) throw new Error(error.message ?? 'Login failed'); + }, + onSuccess: () => router.push('/'), + }); + + const registerMutation = useMutation({ + mutationFn: async (body: { email: string; password: string; name: string }) => { + const { error } = await authClient.signUp.email(body); + if (error) throw new Error(error.message ?? 'Registration failed'); + }, + onSuccess: () => { + setTab('login'); + setInfo('Account created! Please check your email to verify, then sign in.'); + }, + }); function handleLogin(e: React.FormEvent) { e.preventDefault(); setInfo(null); - loginMutation.mutate( - { email, password }, - { - onSuccess: (data) => { - const token = (data as { token?: string }).token ?? ''; - if (!token) return; - setTokens(token, ''); - router.push('/'); - }, - }, - ); + loginMutation.mutate({ email, password }); } function handleRegister(e: React.FormEvent) { e.preventDefault(); - setInfo(null); - const [firstName, ...rest] = username.trim().split(' '); - const lastName = rest.join(' ') || undefined; - registerMutation.mutate( - { email, password, firstName: firstName ?? username, lastName }, - { - onSuccess: () => { - setTab('login'); - setInfo('Account created! Please check your email to verify, then sign in.'); - }, - }, - ); + registerMutation.mutate({ email, password, name: username || email }); } - const loginError = loginMutation.error?.message ?? null; - const registerError = registerMutation.error?.message ?? null; - return (
@@ -129,7 +84,9 @@ export default function AuthPage() { required /> {info &&

{info}

} - {loginError &&

{loginError}

} + {loginMutation.error && ( +

{loginMutation.error.message}

+ )}
)} - {messages.map((msg) => ( - - ))} + {messages + .filter((msg) => msg.role === 'user' || msg.role === 'assistant') + .map((msg) => ( + + ))} {/* Typing indicator */} {isTyping && ( -
- {activeTools.length > 0 && ( -
- {activeTools.map((tool) => ( - - {tool} - +
+
+ +
+
+
+ {[0, 0.15, 0.3].map((delay) => ( +
))}
- )} -
-
- -
-
-
- {[0, 0.15, 0.3].map((delay) => ( -
- ))} -
-
)} @@ -237,25 +137,25 @@ export function AIScreen() { {/* Input */}
-
+