diff --git a/bun.lock b/bun.lock index ac26f98..1baf995 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "react-native-crud", "dependencies": { "@expo/metro-runtime": "~6.1.2", + "@hookform/resolvers": "^5.2.2", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.1.31", "@rn-primitives/avatar": "^1.2.0", @@ -20,7 +21,7 @@ "drizzle-orm": "^0.45.1", "expo": "~54.0.29", "expo-constants": "~18.0.12", - "expo-crypto": "~15.0.8", + "expo-crypto": "15.0.8", "expo-linking": "~8.0.10", "expo-localization": "~17.0.8", "expo-router": "~6.0.19", @@ -33,6 +34,7 @@ "nativewind": "^4.2.2", "react": "19.1.0", "react-dom": "19.1.0", + "react-hook-form": "^7.71.2", "react-i18next": "^16.5.4", "react-native": "0.81.5", "react-native-reanimated": "~4.1.1", @@ -44,6 +46,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", + "zod": "^4.0.0", }, "devDependencies": { "@babel/core": "^7.26.0", @@ -383,6 +386,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="], @@ -539,6 +544,8 @@ "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -1245,6 +1252,8 @@ "react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="], + "react-hook-form": ["react-hook-form@7.71.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA=="], + "react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="], "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], @@ -1529,6 +1538,8 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], "@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index e417b33..7d7b78c 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,171 +1,169 @@ { - "version": "6", - "dialect": "sqlite", - "id": "a80d7cec-989f-4bd1-97a4-b7d30ff894c8", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "items": { - "name": "items", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "password_resets": { - "name": "password_resets", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "code": { - "name": "code", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "sessions": { - "name": "sessions", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "users": { - "name": "users", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "password_hash": { - "name": "password_hash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "users_email_unique": { - "name": "users_email_unique", - "columns": [ - "email" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file + "version": "6", + "dialect": "sqlite", + "id": "a80d7cec-989f-4bd1-97a4-b7d30ff894c8", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "items": { + "name": "items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "password_resets": { + "name": "password_resets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index efc55fc..69f7322 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,13 +1,13 @@ { - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1772274776298, - "tag": "0000_wet_zemo", - "breakpoints": true - } - ] -} \ No newline at end of file + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1772274776298, + "tag": "0000_wet_zemo", + "breakpoints": true + } + ] +} diff --git a/drizzle/migrations.js b/drizzle/migrations.js index e80d2ac..b9853c0 100644 --- a/drizzle/migrations.js +++ b/drizzle/migrations.js @@ -1,12 +1,11 @@ // This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo -import journal from './meta/_journal.json'; -import m0000 from './0000_wet_zemo.sql'; +import m0000 from "./0000_wet_zemo.sql"; +import journal from "./meta/_journal.json"; - export default { - journal, - migrations: { - m0000 - } - } - \ No newline at end of file +export default { + journal, + migrations: { + m0000, + }, +}; diff --git a/package.json b/package.json index 8556bf4..e5242c2 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@expo/metro-runtime": "~6.1.2", + "@hookform/resolvers": "^5.2.2", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.1.31", "@rn-primitives/avatar": "^1.2.0", @@ -41,7 +42,7 @@ "drizzle-orm": "^0.45.1", "expo": "~54.0.29", "expo-constants": "~18.0.12", - "expo-crypto": "~15.0.8", + "expo-crypto": "15.0.8", "expo-linking": "~8.0.10", "expo-localization": "~17.0.8", "expo-router": "~6.0.19", @@ -54,6 +55,7 @@ "nativewind": "^4.2.2", "react": "19.1.0", "react-dom": "19.1.0", + "react-hook-form": "^7.71.2", "react-i18next": "^16.5.4", "react-native": "0.81.5", "react-native-reanimated": "~4.1.1", @@ -64,7 +66,8 @@ "react-native-worklets": "0.5.1", "tailwind-merge": "^3.5.0", "tailwindcss": "^3.4.17", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^4.0.0" }, "devDependencies": { "@babel/core": "^7.26.0", diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 6786a7c..246c8a6 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -1,10 +1,10 @@ -import { useAuth } from "@/shared/hooks/useAuth"; import { Redirect } from "expo-router"; +import { useAuth } from "@/shared/hooks/useAuth"; export default function Index() { - const { user, isBootstrapping } = useAuth(); + const { user, isAuthReady } = useAuth(); - if (isBootstrapping) return null; + if (isAuthReady) return null; return ; } diff --git a/src/app/(auth)/_layout.tsx b/src/app/(auth)/_layout.tsx index 6d5b9aa..373ca5d 100644 --- a/src/app/(auth)/_layout.tsx +++ b/src/app/(auth)/_layout.tsx @@ -1,9 +1,9 @@ -import { useAuth } from "@/shared/hooks/useAuth"; import { Redirect, Stack } from "expo-router"; +import { useAuth } from "@/shared/hooks/useAuth"; export default function AuthLayout() { - const { isBootstrapping, user } = useAuth(); - if (isBootstrapping) return null; + const { isAuthReady, user } = useAuth(); + if (isAuthReady) return null; if (user) return ; return ; diff --git a/src/app/(auth)/forgot-password.tsx b/src/app/(auth)/forgot-password.tsx index b2314ea..cb94aec 100644 --- a/src/app/(auth)/forgot-password.tsx +++ b/src/app/(auth)/forgot-password.tsx @@ -1,10 +1,7 @@ import { View } from "react-native"; -import { useTranslation } from "react-i18next"; import { ForgotPasswordForm } from "@/shared/components/forgot-password-form"; export default function ForgotPassword() { - const { t } = useTranslation(); - return ( diff --git a/src/app/(auth)/reset-password.tsx b/src/app/(auth)/reset-password.tsx index 21d9298..7e8ed17 100644 --- a/src/app/(auth)/reset-password.tsx +++ b/src/app/(auth)/reset-password.tsx @@ -1,10 +1,7 @@ import { View } from "react-native"; -import { useTranslation } from "react-i18next"; import { ResetPasswordForm } from "@/shared/components/reset-password-form"; export default function ResetPassword() { - const { t } = useTranslation(); - return ( diff --git a/src/app/(auth)/signin.tsx b/src/app/(auth)/signin.tsx index b6c6267..7bdd965 100644 --- a/src/app/(auth)/signin.tsx +++ b/src/app/(auth)/signin.tsx @@ -1,10 +1,7 @@ import { View } from "react-native"; -import { useTranslation } from "react-i18next"; import { SignInForm } from "@/shared/components/sign-in-form"; export default function SignIn() { - const { t } = useTranslation(); - return ( diff --git a/src/app/(auth)/signup.tsx b/src/app/(auth)/signup.tsx index c279b8a..e20f6dd 100644 --- a/src/app/(auth)/signup.tsx +++ b/src/app/(auth)/signup.tsx @@ -1,10 +1,7 @@ import { View } from "react-native"; -import { useTranslation } from "react-i18next"; import { SignUpForm } from "@/shared/components/sign-up-form"; export default function SignUp() { - const { t } = useTranslation(); - return ( diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 83ace60..ef46efc 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,12 +1,12 @@ import { ThemeProvider } from "@react-navigation/native"; import { Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; +import { useColorScheme } from "nativewind"; import { Suspense } from "react"; import { ActivityIndicator, View } from "react-native"; -import { useColorScheme } from "nativewind"; -import { NAV_THEME } from "@/shared/lib/theme"; import { AuthProvider } from "@/shared/auth/provider"; import { DbProvider } from "@/shared/db/provider"; +import { NAV_THEME } from "@/shared/lib/theme"; import "@/shared/global.css"; import "../../i18n"; diff --git a/src/shared/auth/index.ts b/src/shared/auth/index.ts index 52b3c77..17769a3 100644 --- a/src/shared/auth/index.ts +++ b/src/shared/auth/index.ts @@ -1,3 +1,3 @@ -export { AuthProvider } from "./provider"; export { useAuth } from "@/shared/hooks/useAuth"; -export type { AuthUser, AuthContextType } from "@/shared/types/auth"; +export type { AuthContextType, AuthUser } from "@/shared/types/auth"; +export { AuthProvider } from "./provider"; diff --git a/src/shared/auth/provider.tsx b/src/shared/auth/provider.tsx index d06ea9b..30ff8fb 100644 --- a/src/shared/auth/provider.tsx +++ b/src/shared/auth/provider.tsx @@ -1,25 +1,44 @@ -import React, { useEffect, useState } from "react"; -import { AuthContext } from "./context"; -import { getSessionUser, clearSession } from "./service"; +import type React from "react"; +import { useEffect, useState } from "react"; import type { AuthUser } from "@/shared/types/auth"; +import { + getSessionUser, + signInUser, + signOutUser, + signUpUser, +} from "../services/userService"; +import { AuthContext } from "./context"; export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); - const [isBootstrapping, setIsBootstrapping] = useState(true); + const [isAuthReady, setIsAuthReady] = useState(false); useEffect(() => { getSessionUser() .then(setUser) - .finally(() => setIsBootstrapping(false)); + .finally(() => setIsAuthReady(false)); }, []); - async function signOut() { - await clearSession(); + async function signIn(email: string, password: string): Promise { + const user = await signInUser(email, password); + setUser(user); + } + + async function signUp(email: string, password: string): Promise { + const user = await signUpUser(email, password); + setUser(user); + } + + async function signOut(): Promise { + if (!user) return; + await signOutUser(user.id); setUser(null); } return ( - + {children} ); diff --git a/src/shared/auth/service.ts b/src/shared/auth/service.ts deleted file mode 100644 index 77fb181..0000000 --- a/src/shared/auth/service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { db } from "@/shared/db/client"; -import { sessions, users } from "@/shared/db/schema"; -import { eq } from "drizzle-orm"; -import type { AuthUser } from "@/shared/types/auth"; - -export async function getSessionUser(): Promise { - const [session] = await db.select().from(sessions).limit(1); - if (!session) return null; - - const [user] = await db - .select() - .from(users) - .where(eq(users.id, Number(session.userId))) - .limit(1); - - if (!user) return null; - - return { id: user.id, email: user.email }; -} - -export async function clearSession(userId: number): Promise { - await db.delete(sessions).where(eq(sessions.userId, String(userId))); -} diff --git a/src/shared/components/forgot-password-form.tsx b/src/shared/components/forgot-password-form.tsx index 64d8458..7023e35 100644 --- a/src/shared/components/forgot-password-form.tsx +++ b/src/shared/components/forgot-password-form.tsx @@ -1,3 +1,4 @@ +import { View } from "react-native"; import { Button } from "@/shared/components/ui/button"; import { Card, @@ -9,7 +10,6 @@ import { import { Input } from "@/shared/components/ui/input"; import { Label } from "@/shared/components/ui/label"; import { Text } from "@/shared/components/ui/text"; -import { View } from "react-native"; export function ForgotPasswordForm() { function onSubmit() { diff --git a/src/shared/components/reset-password-form.tsx b/src/shared/components/reset-password-form.tsx index 28132b0..d4aeadc 100644 --- a/src/shared/components/reset-password-form.tsx +++ b/src/shared/components/reset-password-form.tsx @@ -1,3 +1,5 @@ +import * as React from "react"; +import { type TextInput, View } from "react-native"; import { Button } from "@/shared/components/ui/button"; import { Card, @@ -9,8 +11,6 @@ import { import { Input } from "@/shared/components/ui/input"; import { Label } from "@/shared/components/ui/label"; import { Text } from "@/shared/components/ui/text"; -import * as React from "react"; -import { TextInput, View } from "react-native"; export function ResetPasswordForm() { const codeInputRef = React.useRef(null); diff --git a/src/shared/components/sign-in-form.tsx b/src/shared/components/sign-in-form.tsx index e201f8a..201c7cc 100644 --- a/src/shared/components/sign-in-form.tsx +++ b/src/shared/components/sign-in-form.tsx @@ -1,3 +1,8 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "expo-router/build/hooks"; +import * as React from "react"; +import { Controller, useForm } from "react-hook-form"; +import { Pressable, type TextInput, View } from "react-native"; import { SocialConnections } from "@/shared/components/social-connections"; import { Button } from "@/shared/components/ui/button"; import { @@ -11,18 +16,33 @@ import { Input } from "@/shared/components/ui/input"; import { Label } from "@/shared/components/ui/label"; import { Separator } from "@/shared/components/ui/separator"; import { Text } from "@/shared/components/ui/text"; -import * as React from "react"; -import { Pressable, type TextInput, View } from "react-native"; +import { useAuth } from "../hooks/useAuth"; +import { type SignInSchema, signInSchema } from "../types/auth"; export function SignInForm() { + const router = useRouter(); + const { signIn } = useAuth(); const passwordInputRef = React.useRef(null); - function onEmailSubmitEditing() { - passwordInputRef.current?.focus(); - } + const { + control, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(signInSchema), + defaultValues: { email: "", password: "" }, + }); - function onSubmit() { - // TODO: Submit form and navigate to protected screen if successful + async function onSubmit({ email, password }: SignInSchema) { + try { + await signIn(email, password); + router.replace("/(app)"); + } catch (err) { + setError("root", { + message: err instanceof Error ? err.message : "Something went wrong", + }); + } } return ( @@ -40,17 +60,31 @@ export function SignInForm() { - ( + passwordInputRef.current?.focus()} + onChangeText={onChange} + value={value} + /> + )} /> + {errors.email && ( + + {errors.email.message} + + )} + @@ -58,39 +92,59 @@ export function SignInForm() { variant="link" size="sm" className="web:h-fit ml-auto h-4 px-1 py-0 sm:h-4" - onPress={() => { - // TODO: Navigate to forgot password screen - }} + onPress={() => router.push("/(auth)/forgot-password")} > Forgot your password? - ( + + )} /> + {errors.password && ( + + {errors.password.message} + + )} - + - Don't have an account?{" "} - { - // TODO: Navigate to sign up screen - }} - > + Don't have an account?{" "} + router.push("/(auth)/signup")}> Sign up + or diff --git a/src/shared/components/sign-up-form.tsx b/src/shared/components/sign-up-form.tsx index dc924b7..14d9cd4 100644 --- a/src/shared/components/sign-up-form.tsx +++ b/src/shared/components/sign-up-form.tsx @@ -1,3 +1,8 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "expo-router"; +import * as React from "react"; +import { Controller, useForm } from "react-hook-form"; +import { Pressable, type TextInput, View } from "react-native"; import { SocialConnections } from "@/shared/components/social-connections"; import { Button } from "@/shared/components/ui/button"; import { @@ -11,18 +16,33 @@ import { Input } from "@/shared/components/ui/input"; import { Label } from "@/shared/components/ui/label"; import { Separator } from "@/shared/components/ui/separator"; import { Text } from "@/shared/components/ui/text"; -import * as React from "react"; -import { Pressable, TextInput, View } from "react-native"; +import { type SignUpSchema, signUpSchema } from "@/shared/types/auth"; +import { useAuth } from "../hooks/useAuth"; export function SignUpForm() { + const router = useRouter(); + const { signUp } = useAuth(); const passwordInputRef = React.useRef(null); - function onEmailSubmitEditing() { - passwordInputRef.current?.focus(); - } + const { + control, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(signUpSchema), + defaultValues: { email: "", password: "" }, + }); - function onSubmit() { - // TODO: Submit form and navigate to protected screen if successful + async function onSubmit({ email, password }: SignUpSchema) { + try { + await signUp(email, password); + router.replace("/(app)"); + } catch (err) { + setError("root", { + message: err instanceof Error ? err.message : "Something went wrong", + }); + } } return ( @@ -40,45 +60,79 @@ export function SignUpForm() { - ( + passwordInputRef.current?.focus()} + onChangeText={onChange} + value={value} + /> + )} /> + {errors.email && ( + + {errors.email.message} + + )} + - - - - Password + ( + + )} /> + {errors.password && ( + + {errors.password.message} + + )} - + Already have an account?{" "} - { - // TODO: Navigate to sign in screen - }} - > + router.push("/(auth)/signin")}> Sign in + or diff --git a/src/shared/components/social-connections.tsx b/src/shared/components/social-connections.tsx index d1c56c6..56c60ec 100644 --- a/src/shared/components/social-connections.tsx +++ b/src/shared/components/social-connections.tsx @@ -1,7 +1,7 @@ -import { cn } from "@/shared/lib/utils"; -import { Button } from "@/shared/components/ui/button"; import { useColorScheme } from "nativewind"; import { Image, Platform, View } from "react-native"; +import { Button } from "@/shared/components/ui/button"; +import { cn } from "@/shared/lib/utils"; const SOCIAL_CONNECTION_STRATEGIES = [ { diff --git a/src/shared/components/ui/avatar.tsx b/src/shared/components/ui/avatar.tsx index a12d4e7..727fd7b 100644 --- a/src/shared/components/ui/avatar.tsx +++ b/src/shared/components/ui/avatar.tsx @@ -1,5 +1,5 @@ -import { cn } from "@/shared/lib/utils"; import * as AvatarPrimitive from "@rn-primitives/avatar"; +import { cn } from "@/shared/lib/utils"; function Avatar({ className, diff --git a/src/shared/components/ui/card.tsx b/src/shared/components/ui/card.tsx index 798e46e..38b4695 100644 --- a/src/shared/components/ui/card.tsx +++ b/src/shared/components/ui/card.tsx @@ -1,6 +1,6 @@ +import { View, type ViewProps } from "react-native"; import { Text, TextClassContext } from "@/shared/components/ui/text"; import { cn } from "@/shared/lib/utils"; -import { View, type ViewProps } from "react-native"; function Card({ className, ...props }: ViewProps & React.RefAttributes) { return ( diff --git a/src/shared/components/ui/input.tsx b/src/shared/components/ui/input.tsx index 35eeb7a..333baf1 100644 --- a/src/shared/components/ui/input.tsx +++ b/src/shared/components/ui/input.tsx @@ -1,5 +1,5 @@ -import { cn } from "@/shared/lib/utils"; import { Platform, TextInput, type TextInputProps } from "react-native"; +import { cn } from "@/shared/lib/utils"; function Input({ className, diff --git a/src/shared/components/ui/label.tsx b/src/shared/components/ui/label.tsx index 0fd5525..2be54cb 100644 --- a/src/shared/components/ui/label.tsx +++ b/src/shared/components/ui/label.tsx @@ -1,6 +1,6 @@ -import { cn } from "@/shared/lib/utils"; import * as LabelPrimitive from "@rn-primitives/label"; import { Platform } from "react-native"; +import { cn } from "@/shared/lib/utils"; function Label({ className, diff --git a/src/shared/components/ui/popover.tsx b/src/shared/components/ui/popover.tsx index 733942d..c99e881 100644 --- a/src/shared/components/ui/popover.tsx +++ b/src/shared/components/ui/popover.tsx @@ -1,11 +1,11 @@ -import { NativeOnlyAnimatedView } from "@/shared/components/ui/native-only-animated-view"; -import { TextClassContext } from "@/shared/components/ui/text"; -import { cn } from "@/shared/lib/utils"; import * as PopoverPrimitive from "@rn-primitives/popover"; import * as React from "react"; import { Platform, StyleSheet } from "react-native"; import { FadeIn, FadeOut } from "react-native-reanimated"; import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"; +import { NativeOnlyAnimatedView } from "@/shared/components/ui/native-only-animated-view"; +import { TextClassContext } from "@/shared/components/ui/text"; +import { cn } from "@/shared/lib/utils"; const Popover = PopoverPrimitive.Root; diff --git a/src/shared/components/ui/separator.tsx b/src/shared/components/ui/separator.tsx index f1f7fa7..fd93c34 100644 --- a/src/shared/components/ui/separator.tsx +++ b/src/shared/components/ui/separator.tsx @@ -1,5 +1,5 @@ -import { cn } from "@/shared/lib/utils"; import * as SeparatorPrimitive from "@rn-primitives/separator"; +import { cn } from "@/shared/lib/utils"; function Separator({ className, diff --git a/src/shared/components/ui/skeleton.tsx b/src/shared/components/ui/skeleton.tsx index 74cb13e..5c53ad4 100644 --- a/src/shared/components/ui/skeleton.tsx +++ b/src/shared/components/ui/skeleton.tsx @@ -1,5 +1,5 @@ -import { cn } from "@/shared/lib/utils"; import { View } from "react-native"; +import { cn } from "@/shared/lib/utils"; function Skeleton({ className, diff --git a/src/shared/components/ui/tabs.tsx b/src/shared/components/ui/tabs.tsx index aa39e86..7e7c747 100644 --- a/src/shared/components/ui/tabs.tsx +++ b/src/shared/components/ui/tabs.tsx @@ -1,7 +1,7 @@ -import { TextClassContext } from "@/shared/components/ui/text"; -import { cn } from "@/shared/lib/utils"; import * as TabsPrimitive from "@rn-primitives/tabs"; import { Platform } from "react-native"; +import { TextClassContext } from "@/shared/components/ui/text"; +import { cn } from "@/shared/lib/utils"; function Tabs({ className, diff --git a/src/shared/components/user-menu.tsx b/src/shared/components/user-menu.tsx index c8588e1..59a00b1 100644 --- a/src/shared/components/user-menu.tsx +++ b/src/shared/components/user-menu.tsx @@ -1,3 +1,7 @@ +import type { TriggerRef } from "@rn-primitives/popover"; +import { LogOutIcon, PlusIcon, SettingsIcon } from "lucide-react-native"; +import * as React from "react"; +import { View } from "react-native"; import { Avatar, AvatarFallback, @@ -12,10 +16,6 @@ import { } from "@/shared/components/ui/popover"; import { Text } from "@/shared/components/ui/text"; import { cn } from "@/shared/lib/utils"; -import type { TriggerRef } from "@rn-primitives/popover"; -import { LogOutIcon, PlusIcon, SettingsIcon } from "lucide-react-native"; -import * as React from "react"; -import { View } from "react-native"; const USER = { fullName: "Zach Nugent", diff --git a/src/shared/components/verify-email-form.tsx b/src/shared/components/verify-email-form.tsx index 4f237e7..bf3fae9 100644 --- a/src/shared/components/verify-email-form.tsx +++ b/src/shared/components/verify-email-form.tsx @@ -1,3 +1,5 @@ +import * as React from "react"; +import { type TextStyle, View } from "react-native"; import { Button } from "@/shared/components/ui/button"; import { Card, @@ -9,8 +11,6 @@ import { import { Input } from "@/shared/components/ui/input"; import { Label } from "@/shared/components/ui/label"; import { Text } from "@/shared/components/ui/text"; -import * as React from "react"; -import { type TextStyle, View } from "react-native"; const RESEND_CODE_INTERVAL_SECONDS = 30; diff --git a/src/shared/db/client.ts b/src/shared/db/client.ts index c3667df..412544a 100644 --- a/src/shared/db/client.ts +++ b/src/shared/db/client.ts @@ -1,5 +1,5 @@ -import * as SQLite from "expo-sqlite"; import { drizzle } from "drizzle-orm/expo-sqlite"; +import * as SQLite from "expo-sqlite"; import * as schema from "./schema"; const sqlite = SQLite.openDatabaseSync("app.db"); diff --git a/src/shared/db/db-migration.tsx b/src/shared/db/db-migration.tsx index f82913a..1d2edfa 100644 --- a/src/shared/db/db-migration.tsx +++ b/src/shared/db/db-migration.tsx @@ -1,8 +1,8 @@ -import React from "react"; -import { useSQLiteContext } from "expo-sqlite"; import { drizzle } from "drizzle-orm/expo-sqlite"; import { useMigrations } from "drizzle-orm/expo-sqlite/migrator"; -import { ActivityIndicator, View, Text } from "react-native"; +import { useSQLiteContext } from "expo-sqlite"; +import type React from "react"; +import { ActivityIndicator, Text, View } from "react-native"; import migrations from "../../../drizzle/migrations"; import * as schema from "./schema"; diff --git a/src/shared/db/index.ts b/src/shared/db/index.ts index 2bc8a27..792dd1b 100644 --- a/src/shared/db/index.ts +++ b/src/shared/db/index.ts @@ -1,3 +1,3 @@ -export { DbProvider } from "./provider"; export { db } from "./client"; +export { DbProvider } from "./provider"; export * as schema from "./schema"; diff --git a/src/shared/db/provider.tsx b/src/shared/db/provider.tsx index 9eebc18..8240548 100644 --- a/src/shared/db/provider.tsx +++ b/src/shared/db/provider.tsx @@ -1,5 +1,5 @@ -import React from "react"; import { SQLiteProvider } from "expo-sqlite"; +import type React from "react"; import { MigrationGate } from "./db-migration"; export function DbProvider({ children }: { children: React.ReactNode }) { diff --git a/src/shared/db/schema.ts b/src/shared/db/schema.ts index b4ec8c5..e3ed9e2 100644 --- a/src/shared/db/schema.ts +++ b/src/shared/db/schema.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const users = sqliteTable("users", { id: integer("id").primaryKey().notNull(), diff --git a/src/shared/services/userService.ts b/src/shared/services/userService.ts new file mode 100644 index 0000000..0595b97 --- /dev/null +++ b/src/shared/services/userService.ts @@ -0,0 +1,86 @@ +import { eq } from "drizzle-orm"; +import * as Crypto from "expo-crypto"; +import { db } from "@/shared/db/client"; +import { sessions, users } from "@/shared/db/schema"; +import type { AuthUser } from "@/shared/types/auth"; + +async function hashPassword(password: string): Promise { + return Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.SHA256, + password, + ); +} + +export async function getSessionUser(): Promise { + const [session] = await db.select().from(sessions).limit(1); + if (!session) return null; + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, Number(session.userId))) + .limit(1); + + if (!user) return null; + return { id: user.id, email: user.email }; +} + +export async function signUpUser( + email: string, + password: string, +): Promise { + const existing = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (existing.length) { + throw new Error("Email already in use"); + } + + const passwordHash = await hashPassword(password); + const now = Date.now(); + + const [user] = await db + .insert(users) + .values({ email, passwordHash, createdAt: now }) + .returning(); + + await db.insert(sessions).values({ + userId: String(user.id), + createdAt: now, + }); + + return { id: user.id, email: user.email }; +} + +export async function signInUser( + email: string, + password: string, +): Promise { + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (!user) throw new Error("Invalid credentials"); + + const passwordHash = await hashPassword(password); + if (passwordHash !== user.passwordHash) { + throw new Error("Invalid credentials"); + } + + await db.delete(sessions).where(eq(sessions.userId, String(user.id))); + await db.insert(sessions).values({ + userId: String(user.id), + createdAt: Date.now(), + }); + + return { id: user.id, email: user.email }; +} + +export async function signOutUser(userId: number): Promise { + await db.delete(sessions).where(eq(sessions.userId, String(userId))); +} diff --git a/src/shared/types/auth.ts b/src/shared/types/auth.ts index 9ad4386..b18cfa1 100644 --- a/src/shared/types/auth.ts +++ b/src/shared/types/auth.ts @@ -1,10 +1,27 @@ -export type AuthUser = { - id: number; - email: string; -}; +import { z } from "zod"; export type AuthContextType = { user: AuthUser | null; - isBootstrapping: boolean; + isAuthReady: boolean; + signIn: (email: string, password: string) => Promise; + signUp: (email: string, password: string) => Promise; signOut: () => Promise; }; + +export type AuthUser = { + id: number; + email: string; +}; + +export const signInSchema = z.object({ + email: z.email("Invalid email"), + password: z.string().min(6, "Password must be at least 6 characters"), +}); + +export const signUpSchema = z.object({ + email: z.email("Invalid email"), + password: z.string().min(6, "Password must be at least 6 characters"), +}); + +export type SignInSchema = z.infer; +export type SignUpSchema = z.infer;