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}
+
+ )}
-
- Continue
+
+ {errors.root && (
+
+ {errors.root.message}
+
+ )}
+
+
+ {isSubmitting ? "Creating account..." : "Continue"}
+
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;