diff --git a/.env.example b/.env.example deleted file mode 100644 index e521fd9..0000000 --- a/.env.example +++ /dev/null @@ -1,9 +0,0 @@ -# Local PostgreSQL service only. Replace only in an untracked .env file. -POSTGRES_DB=quickcop_dev -POSTGRES_USER=quickcop_dev -POSTGRES_PASSWORD=change-me-local-only -POSTGRES_PORT=5432 - -# Future API configuration: supply real values through a secret manager, never Git. -DATABASE_URL=postgresql://quickcop_dev:change-me-local-only@postgres:5432/quickcop_dev -ADDRESS_ENCRYPTION_KEY=replace-with-a-secret-manager-reference diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1 @@ +.env diff --git a/apps/mobile/__tests__/auth-flow.test.tsx b/apps/mobile/__tests__/auth-flow.test.tsx index c575217..1a0c54a 100644 --- a/apps/mobile/__tests__/auth-flow.test.tsx +++ b/apps/mobile/__tests__/auth-flow.test.tsx @@ -1,46 +1,198 @@ -import { fireEvent, render } from "@testing-library/react-native"; +import { fireEvent, render, waitFor } from "@testing-library/react-native"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { CreateAccountScreen } from "../features/auth/CreateAccountScreen"; import { LoginScreen } from "../features/auth/LoginScreen"; import { HomeScreen } from "../features/home/HomeScreen"; +import { supabase } from "../lib/supabase"; -describe("local authentication prototype", () => { - it("opens the home screen after the user submits a username and password", () => { - const onLogin = jest.fn(); - const screen = render( +jest.mock("../lib/supabase", () => ({ + supabase: { + rpc: jest.fn(), + auth: { + signUp: jest.fn(), + signInWithPassword: jest.fn(), + signOut: jest.fn(), + }, + }, +})); + +// Loosely typed so the mocks accept any resolved value +type MockFn = jest.Mock<(...args: unknown[]) => Promise>; + +const mockSupabase = supabase as unknown as { + rpc: MockFn; + auth: { + signUp: MockFn; + signInWithPassword: MockFn; + signOut: MockFn; + }; +}; + +beforeEach(() => { + jest.clearAllMocks(); + + // username_available -> true, get_email_for_username -> an email + mockSupabase.rpc.mockImplementation((fn: unknown) => + Promise.resolve({ + data: fn === "username_available" ? true : "person@example.com", + error: null, + }), + ); + mockSupabase.auth.signUp.mockResolvedValue({ + data: { session: {} }, + error: null, + }); + mockSupabase.auth.signInWithPassword.mockResolvedValue({ error: null }); + mockSupabase.auth.signOut.mockResolvedValue({ error: null }); +}); + +describe("login", () => { + async function submitLogin(onLogin = jest.fn()) { + const screen = await render( , ); - fireEvent.changeText(screen.getByLabelText("Username"), "quickcop_user"); - fireEvent.changeText(screen.getByLabelText("Password"), "password123"); - fireEvent.press(screen.getByRole("button", { name: "Log in" })); + await fireEvent.changeText( + screen.getByLabelText("Username"), + "quickcop_user", + ); + await fireEvent.changeText( + screen.getByLabelText("Password"), + "password123", + ); + await fireEvent.press(screen.getByRole("button", { name: "Log in" })); + + return { screen, onLogin }; + } + + it("looks up the email, signs in, and opens the home screen", async () => { + const { onLogin } = await submitLogin(); + + await waitFor(() => expect(onLogin).toHaveBeenCalledTimes(1)); + expect(mockSupabase.rpc).toHaveBeenCalledWith("get_email_for_username", { + p_username: "quickcop_user", + }); + expect(mockSupabase.auth.signInWithPassword).toHaveBeenCalledWith({ + email: "person@example.com", + password: "password123", + }); + }); + + it("shows an error when the password is wrong", async () => { + mockSupabase.auth.signInWithPassword.mockResolvedValue({ + error: { message: "Invalid login credentials" }, + }); + + const { screen, onLogin } = await submitLogin(); + + await waitFor(() => + expect(screen.getByText("Invalid username or password.")).toBeTruthy(), + ); + expect(onLogin).not.toHaveBeenCalled(); + }); + + it("shows the same error when the username does not exist", async () => { + mockSupabase.rpc.mockResolvedValue({ data: null, error: null }); + + const { screen, onLogin } = await submitLogin(); - expect(onLogin).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(screen.getByText("Invalid username or password.")).toBeTruthy(), + ); + expect(mockSupabase.auth.signInWithPassword).not.toHaveBeenCalled(); + expect(onLogin).not.toHaveBeenCalled(); }); +}); - it("allows account creation with an email and no phone number", () => { - const onAccountCreated = jest.fn(); - const screen = render( +describe("create account", () => { + async function submitSignUp(onAccountCreated = jest.fn()) { + const screen = await render( , ); - fireEvent.changeText(screen.getByLabelText("Email"), "person@example.com"); - fireEvent.changeText(screen.getByLabelText("Username"), "quickcop_user"); - fireEvent.changeText(screen.getByLabelText("Password"), "password123"); - fireEvent.press(screen.getByRole("button", { name: "Create account" })); + await fireEvent.changeText( + screen.getByLabelText("Email"), + "person@example.com", + ); + await fireEvent.changeText( + screen.getByLabelText("Username"), + "quickcop_user", + ); + await fireEvent.changeText( + screen.getByLabelText("Password"), + "password123", + ); + await fireEvent.press( + screen.getByRole("button", { name: "Create account" }), + ); + + return { screen, onAccountCreated }; + } + + it("allows account creation with an email and no phone number", async () => { + const { onAccountCreated } = await submitSignUp(); + + await waitFor(() => expect(onAccountCreated).toHaveBeenCalledTimes(1)); + expect(mockSupabase.auth.signUp).toHaveBeenCalledWith({ + email: "person@example.com", + password: "password123", + options: { + data: { + username: "quickcop_user", + display_name: "quickcop_user", + phone: null, + }, + }, + }); + }); + + it("rejects a taken username without creating an account", async () => { + mockSupabase.rpc.mockResolvedValue({ data: false, error: null }); + + const { screen, onAccountCreated } = await submitSignUp(); + + await waitFor(() => + expect(screen.getByText("That username is taken.")).toBeTruthy(), + ); + expect(mockSupabase.auth.signUp).not.toHaveBeenCalled(); + expect(onAccountCreated).not.toHaveBeenCalled(); + }); + + it("asks the user to confirm their email when no session is returned", async () => { + mockSupabase.auth.signUp.mockResolvedValue({ + data: { session: null }, + error: null, + }); + + const { screen, onAccountCreated } = await submitSignUp(); - expect(onAccountCreated).toHaveBeenCalledTimes(1); + await waitFor(() => + expect( + screen.getByText( + "Check your email to confirm your account, then log in.", + ), + ).toBeTruthy(), + ); + expect(onAccountCreated).not.toHaveBeenCalled(); }); +}); - it("returns to login when the user logs out from the empty home screen", () => { +describe("logout", () => { + it("signs out and returns to login from the home menu", async () => { const onLogout = jest.fn(); - const screen = render(); + const screen = await render( + , + ); - fireEvent.press(screen.getByRole("button", { name: "Log out" })); + // The Log out button lives inside the hamburger menu + await fireEvent.press(screen.getByRole("button", { name: "Open menu" })); + await fireEvent.press(screen.getByRole("button", { name: "Log out" })); - expect(onLogout).toHaveBeenCalledTimes(1); + await waitFor(() => expect(onLogout).toHaveBeenCalledTimes(1)); + expect(mockSupabase.auth.signOut).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/mobile/__tests__/notifications-screen.test.tsx b/apps/mobile/__tests__/notifications-screen.test.tsx index 9493865..8b73764 100644 --- a/apps/mobile/__tests__/notifications-screen.test.tsx +++ b/apps/mobile/__tests__/notifications-screen.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render } from "@testing-library/react-native"; +import { describe, it, expect } from "@jest/globals"; import { NotificationsScreen, diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 9aae25a..0ed90e5 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,11 +1,34 @@ -import { Stack } from "expo-router"; +import { Stack, useRouter, useSegments } from "expo-router"; import { StatusBar } from "expo-status-bar"; +import { useEffect } from "react"; + +import { AuthProvider, useAuth } from "../features/auth/AuthProvider"; + +function RootNavigator() { + const { session, loading } = useAuth(); + const segments = useSegments(); + const router = useRouter(); + + useEffect(() => { + if (loading) return; + + const onHome = segments[0] === "home"; + + if (session && !onHome) { + router.replace("/home"); + } else if (!session && onHome) { + router.replace("/"); + } + }, [session, loading, segments]); + + return ; +} export default function RootLayout() { return ( - <> + - - + + ); } diff --git a/apps/mobile/features/auth/AuthProvider.tsx b/apps/mobile/features/auth/AuthProvider.tsx new file mode 100644 index 0000000..276a77c --- /dev/null +++ b/apps/mobile/features/auth/AuthProvider.tsx @@ -0,0 +1,42 @@ +import { Session } from "@supabase/supabase-js"; +import { createContext, useContext, useEffect, useState } from "react"; + +import { supabase } from "../../lib/supabase"; + +type AuthContextType = { + session: Session | null; + loading: boolean; +}; + +const AuthContext = createContext({ + session: null, + loading: true, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + supabase.auth.getSession().then(({ data }) => { + setSession(data.session); + setLoading(false); + }); + + const { data: listener } = supabase.auth.onAuthStateChange( + (_event, newSession) => { + setSession(newSession); + }, + ); + + return () => listener.subscription.unsubscribe(); + }, []); + + return ( + + {children} + + ); +} + +export const useAuth = () => useContext(AuthContext); diff --git a/apps/mobile/features/auth/CreateAccountScreen.tsx b/apps/mobile/features/auth/CreateAccountScreen.tsx index 45fb682..7d3af24 100644 --- a/apps/mobile/features/auth/CreateAccountScreen.tsx +++ b/apps/mobile/features/auth/CreateAccountScreen.tsx @@ -10,6 +10,7 @@ import { View, } from "react-native"; +import { supabase } from "../../lib/supabase"; import { authStyles } from "./styles"; type CreateAccountScreenProps = { @@ -17,6 +18,9 @@ type CreateAccountScreenProps = { onBackToLogin: () => void; }; +const USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,20}$/; +const PHONE_PATTERN = /^\+[1-9]\d{7,14}$/; + export function CreateAccountScreen({ onAccountCreated, onBackToLogin, @@ -26,14 +30,79 @@ export function CreateAccountScreen({ const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleCreateAccount() { + const trimmedEmail = email.trim(); + const trimmedPhone = phone.trim(); + const trimmedUsername = username.trim(); - function handleCreateAccount() { - if ((!email.trim() && !phone.trim()) || !username.trim() || !password) { - setError("Add an email or phone number, a username, and a password."); + setNotice(""); + + if (!trimmedEmail || !trimmedUsername || !password) { + setError("Add an email, a username, and a password."); + return; + } + if (!USERNAME_PATTERN.test(trimmedUsername)) { + setError("Username must be 3-20 letters, numbers, or underscores."); + return; + } + if (trimmedPhone && !PHONE_PATTERN.test(trimmedPhone)) { + setError("Phone must include country code, like +15305551234."); + return; + } + if (password.length < 6) { + setError("Password must be at least 6 characters."); return; } setError(""); + setLoading(true); + + // 1. Check the username is free + const { data: available, error: rpcError } = await supabase.rpc( + "username_available", + { p_username: trimmedUsername }, + ); + if (rpcError) { + console.log("username_available error:", rpcError); + setLoading(false); + setError(rpcError.message); + return; + } + if (!available) { + setLoading(false); + setError("That username is taken."); + return; + } + + // 2. Create the account (the database trigger creates the profile row) + const { data, error: signUpError } = await supabase.auth.signUp({ + email: trimmedEmail, + password, + options: { + data: { + username: trimmedUsername, + display_name: trimmedUsername, + phone: trimmedPhone || null, + }, + }, + }); + console.log("signUp result:", { data, error: signUpError }); + setLoading(false); + + if (signUpError) { + setError(signUpError.message); + return; + } + + // If email confirmation is ON in Supabase, there's no session yet + if (!data.session) { + setNotice("Check your email to confirm your account, then log in."); + return; + } + onAccountCreated(); } @@ -76,7 +145,7 @@ export function CreateAccountScreen({ autoComplete="tel" keyboardType="phone-pad" onChangeText={setPhone} - placeholder="For future text alerts" + placeholder="+15305551234" placeholderTextColor="#7A7A7A" style={authStyles.input} value={phone} @@ -120,13 +189,22 @@ export function CreateAccountScreen({ {error} ) : null} + {notice ? ( + + {notice} + + ) : null} - Create account + + {loading ? "Creating..." : "Create account"} + void; }; +const INVALID_MESSAGE = "Invalid username or password."; + export function LoginScreen({ onCreateAccount, onLogin }: LoginScreenProps) { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleLogin() { + const trimmedUsername = username.trim(); - function handleLogin() { - if (!username.trim() || !password) { + if (!trimmedUsername || !password) { setError("Enter your username and password to continue."); return; } setError(""); + setLoading(true); + + // 1. Look up the email that belongs to this username + const { data: email, error: lookupError } = await supabase.rpc( + "get_email_for_username", + { p_username: trimmedUsername }, + ); + + if (lookupError) { + console.log("get_email_for_username error:", lookupError); + setLoading(false); + setError("Something went wrong. Try again."); + return; + } + + if (!email) { + setLoading(false); + setError(INVALID_MESSAGE); // same message on purpose + return; + } + + // 2. Sign in with that email + the password + const { error: signInError } = await supabase.auth.signInWithPassword({ + email, + password, + }); + setLoading(false); + + if (signInError) { + console.log("signIn error:", signInError.message); + setError(INVALID_MESSAGE); + return; + } + onLogin(); } @@ -86,10 +126,14 @@ export function LoginScreen({ onCreateAccount, onLogin }: LoginScreenProps) { - Log in + + {loading ? "Logging in..." : "Log in"} + void; onOpenNotifications: () => void; @@ -24,6 +26,11 @@ export function HomeScreen({ const [categoriesOpen, setCategoriesOpen] = useState(false); const [storesOpen, setStoresOpen] = useState(false); + async function handleLogout() { + await supabase.auth.signOut(); + onLogout(); + } + return ( @@ -105,7 +112,7 @@ export function HomeScreen({ Settings - + Log out diff --git a/apps/mobile/lib/supabase.ts b/apps/mobile/lib/supabase.ts new file mode 100644 index 0000000..58a6b50 --- /dev/null +++ b/apps/mobile/lib/supabase.ts @@ -0,0 +1,16 @@ +import "react-native-url-polyfill/auto"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createClient } from "@supabase/supabase-js"; + +export const supabase = createClient( + process.env.EXPO_PUBLIC_SUPABASE_URL!, + process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!, + { + auth: { + storage: AsyncStorage, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, + }, +); diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 16c3a30..5cbe107 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@expo/metro-runtime": "~57.0.16", "@expo/ui": "~57.0.9", + "@react-native-async-storage/async-storage": "2.2.0", + "@supabase/supabase-js": "^2.117.3", "expo": "~57.0.10", "expo-constants": "~57.0.9", "expo-device": "~57.0.1", @@ -30,6 +32,7 @@ "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", + "react-native-url-polyfill": "^4.0.0", "react-native-web": "^0.21.2", "react-native-worklets": "0.10.1" }, @@ -2629,6 +2632,18 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", @@ -2969,6 +2984,98 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@supabase/auth-js": { + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.117.3.tgz", + "integrity": "sha512-XOZV+jllcU2Hzij8SwgM78zl0lOEr6BTjzi2MYkBb39nOAQzb5AelGpMUvVj7lwGSB11JmpAs/FEQroAyFzWEA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.117.3.tgz", + "integrity": "sha512-6GuizbP4N5xWaxEVUsI+UQpJdmMmn0t3sYNpObo9gCAldvM22gFjnYzRVmkHxp5ylU21azEYmiWVwk9iDgCMkQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.117.3.tgz", + "integrity": "sha512-NpyTgullu6tw11ojYKTdZg9HannZ4mu4FUlhc9V5zDXOVllUJzEmuyLM+WWoYF0BurfrRvarimpm3SR8P0Gzfw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.117.3.tgz", + "integrity": "sha512-4W4ISbreRygNXbhwqxeqyuRul8RPx+SF+Hp2IT8wctebQEHSVCULfKatiRiEhmasfoXvYm2AHV4XFsOxhoousA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.117.3.tgz", + "integrity": "sha512-QhUxFC33Crlw6WDeA4hLADdi1PzjI3c4UJs7ivWHHEnU7ulmCWEZSDaRCuIfmsjUwbHYaIfRNFQNsm+IsGYS6w==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.117.3", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.117.3.tgz", + "integrity": "sha512-K+f8PimXDunPWQ63CKRXaF5pbVPJxmfw5ryAnh0s9S9PkwcuqBjiSpIaqpF7KLDljeYwXIL85ST8QxaO0+XuvA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.117.3", + "@supabase/functions-js": "2.117.3", + "@supabase/postgrest-js": "2.117.3", + "@supabase/realtime-js": "2.117.3", + "@supabase/storage-js": "2.117.3" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@testing-library/react-native": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-14.0.1.tgz", @@ -6299,6 +6406,15 @@ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6462,6 +6578,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8382,6 +8507,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -9935,6 +10072,15 @@ "react-native": "*" } }, + "node_modules/react-native-url-polyfill": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-4.0.0.tgz", + "integrity": "sha512-eqYM3wBAA0eL1sPYbBAoNfbES3+NkgcxUdelQ7QzmoVtqKB5qGG0U13MPTRUroAWK+y2EoJFS3MZUK0fwTf0pA==", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ded33f0..2696e86 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -17,6 +17,8 @@ "dependencies": { "@expo/metro-runtime": "~57.0.16", "@expo/ui": "~57.0.9", + "@react-native-async-storage/async-storage": "2.2.0", + "@supabase/supabase-js": "^2.117.3", "expo": "~57.0.10", "expo-constants": "~57.0.9", "expo-device": "~57.0.1", @@ -37,6 +39,7 @@ "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", + "react-native-url-polyfill": "^4.0.0", "react-native-web": "^0.21.2", "react-native-worklets": "0.10.1" },