@stjoseph/prumo 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/.prumo-templates/api/auth.md +130 -0
  2. package/.prumo-templates/api/config.md +85 -0
  3. package/.prumo-templates/api/errors.md +82 -0
  4. package/.prumo-templates/api/logging.md +79 -0
  5. package/.prumo-templates/api/modules.md +117 -0
  6. package/.prumo-templates/api/observability.md +91 -0
  7. package/.prumo-templates/api/pagination.md +71 -0
  8. package/.prumo-templates/api/routes.md +89 -0
  9. package/.prumo-templates/client/data.md +124 -0
  10. package/.prumo-templates/client/forms.md +88 -0
  11. package/.prumo-templates/core/conventions.md +74 -0
  12. package/.prumo-templates/core/testing.md +100 -0
  13. package/.prumo-templates/core/tooling.md +118 -0
  14. package/.prumo-templates/database/entities.md +97 -0
  15. package/.prumo-templates/database/migrations.md +74 -0
  16. package/.prumo-templates/database/queries.md +79 -0
  17. package/.prumo-templates/database/testing.md +82 -0
  18. package/.prumo-templates/database/transactions.md +68 -0
  19. package/.prumo-templates/mobile/components.md +83 -0
  20. package/.prumo-templates/mobile/config.md +84 -0
  21. package/.prumo-templates/mobile/routing.md +74 -0
  22. package/.prumo-templates/mobile/storage.md +84 -0
  23. package/.prumo-templates/mobile/structure.md +79 -0
  24. package/.prumo-templates/monorepo/dependencies.md +84 -0
  25. package/.prumo-templates/monorepo/layout.md +89 -0
  26. package/.prumo-templates/monorepo/tasks.md +76 -0
  27. package/.prumo-templates/multi-tenancy/client.md +83 -0
  28. package/.prumo-templates/multi-tenancy/isolation.md +104 -0
  29. package/.prumo-templates/site/rendering.md +83 -0
  30. package/.prumo-templates/site/structure.md +68 -0
  31. package/.prumo-templates/web/components.md +106 -0
  32. package/.prumo-templates/web/routing.md +82 -0
  33. package/.prumo-templates/web/structure.md +92 -0
  34. package/LICENSE +21 -0
  35. package/README.md +110 -0
  36. package/dist/asker.js +28 -0
  37. package/dist/cli.js +62 -0
  38. package/dist/compose.js +222 -0
  39. package/dist/context.js +38 -0
  40. package/dist/generate.js +60 -0
  41. package/dist/jsonc.js +76 -0
  42. package/dist/names.js +10 -0
  43. package/dist/questions.js +63 -0
  44. package/package.json +55 -0
  45. package/templates/api/.env.example +12 -0
  46. package/templates/api/.githooks/install.mjs +18 -0
  47. package/templates/api/.githooks/pre-commit +2 -0
  48. package/templates/api/.githooks/pre-push +2 -0
  49. package/templates/api/README.md +35 -0
  50. package/templates/api/biome.jsonc +18 -0
  51. package/templates/api/docker/init/01-auth-schema.sql +1 -0
  52. package/templates/api/docker-compose.yml +20 -0
  53. package/templates/api/gitignore +5 -0
  54. package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
  55. package/templates/api/nest-cli.json +10 -0
  56. package/templates/api/package.json +69 -0
  57. package/templates/api/pnpm-workspace.yaml +9 -0
  58. package/templates/api/src/app.module.ts +46 -0
  59. package/templates/api/src/auth/auth.constants.ts +2 -0
  60. package/templates/api/src/auth/auth.controller.ts +19 -0
  61. package/templates/api/src/auth/auth.factory.spec.ts +29 -0
  62. package/templates/api/src/auth/auth.factory.ts +52 -0
  63. package/templates/api/src/auth/auth.guard.ts +43 -0
  64. package/templates/api/src/auth/auth.module.ts +30 -0
  65. package/templates/api/src/auth/auth.ts +4 -0
  66. package/templates/api/src/auth/current-user.decorator.ts +9 -0
  67. package/templates/api/src/auth/express.d.ts +10 -0
  68. package/templates/api/src/auth/public.decorator.ts +4 -0
  69. package/templates/api/src/config/env.module.ts +11 -0
  70. package/templates/api/src/config/env.ts +81 -0
  71. package/templates/api/src/errors/problem-details.filter.ts +86 -0
  72. package/templates/api/src/errors/validation-exception.factory.ts +30 -0
  73. package/templates/api/src/health/health.controller.ts +32 -0
  74. package/templates/api/src/health/health.module.ts +9 -0
  75. package/templates/api/src/main.ts +43 -0
  76. package/templates/api/src/mikro-orm.config.ts +4 -0
  77. package/templates/api/src/mikro-orm.factory.ts +14 -0
  78. package/templates/api/src/request-context/request-context.module.ts +9 -0
  79. package/templates/api/src/request-context/request-context.service.ts +17 -0
  80. package/templates/api/src/request-context/request-id.middleware.ts +19 -0
  81. package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
  82. package/templates/api/src/users/entities/profile.entity.ts +27 -0
  83. package/templates/api/src/users/users.controller.ts +23 -0
  84. package/templates/api/src/users/users.module.ts +10 -0
  85. package/templates/api/src/users/users.service.spec.ts +44 -0
  86. package/templates/api/src/users/users.service.ts +36 -0
  87. package/templates/api/test/factories/profile.factory.ts +26 -0
  88. package/templates/api/test/factories/user.factory.ts +21 -0
  89. package/templates/api/test/global-setup.ts +20 -0
  90. package/templates/api/test/setup.ts +80 -0
  91. package/templates/api/tsconfig.build.json +8 -0
  92. package/templates/api/tsconfig.json +29 -0
  93. package/templates/api/vitest.config.mts +17 -0
  94. package/templates/mobile/.env.example +1 -0
  95. package/templates/mobile/.githooks/install.mjs +18 -0
  96. package/templates/mobile/.githooks/pre-commit +2 -0
  97. package/templates/mobile/.githooks/pre-push +2 -0
  98. package/templates/mobile/README.md +30 -0
  99. package/templates/mobile/app.config.ts +13 -0
  100. package/templates/mobile/app.json +39 -0
  101. package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
  102. package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
  103. package/templates/mobile/assets/expo.icon/icon.json +35 -0
  104. package/templates/mobile/assets/images/android-icon-background.png +0 -0
  105. package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
  106. package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
  107. package/templates/mobile/assets/images/icon.png +0 -0
  108. package/templates/mobile/assets/images/splash-icon.png +0 -0
  109. package/templates/mobile/babel.config.js +6 -0
  110. package/templates/mobile/biome.jsonc +21 -0
  111. package/templates/mobile/gitignore +11 -0
  112. package/templates/mobile/global.d.ts +1 -0
  113. package/templates/mobile/jest.config.js +21 -0
  114. package/templates/mobile/metro.config.js +4 -0
  115. package/templates/mobile/nativewind-env.d.ts +1 -0
  116. package/templates/mobile/package.json +63 -0
  117. package/templates/mobile/src/api-contract/api-error.ts +51 -0
  118. package/templates/mobile/src/api-contract/client.ts +26 -0
  119. package/templates/mobile/src/api-contract/index.ts +10 -0
  120. package/templates/mobile/src/api-contract/profile.ts +27 -0
  121. package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
  122. package/templates/mobile/src/app/(app)/index.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
  125. package/templates/mobile/src/app/_layout.tsx +54 -0
  126. package/templates/mobile/src/components/ui/button.tsx +36 -0
  127. package/templates/mobile/src/components/ui/field.tsx +30 -0
  128. package/templates/mobile/src/config.ts +3 -0
  129. package/templates/mobile/src/features/auth/auth-client.ts +27 -0
  130. package/templates/mobile/src/features/auth/intended-route.ts +11 -0
  131. package/templates/mobile/src/features/auth/session.ts +17 -0
  132. package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
  133. package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
  134. package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
  135. package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
  136. package/templates/mobile/src/features/forms/form-error.tsx +16 -0
  137. package/templates/mobile/src/features/forms/server-errors.ts +33 -0
  138. package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
  139. package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
  140. package/templates/mobile/src/features/storage/query-persister.ts +15 -0
  141. package/templates/mobile/src/features/storage/storage.ts +11 -0
  142. package/templates/mobile/src/global.css +3 -0
  143. package/templates/mobile/tailwind.config.js +9 -0
  144. package/templates/mobile/test/fake-transport.ts +23 -0
  145. package/templates/mobile/tsconfig.json +21 -0
  146. package/templates/site/.githooks/install.mjs +18 -0
  147. package/templates/site/.githooks/pre-commit +2 -0
  148. package/templates/site/.githooks/pre-push +2 -0
  149. package/templates/site/README.md +28 -0
  150. package/templates/site/biome.jsonc +12 -0
  151. package/templates/site/components.json +25 -0
  152. package/templates/site/gitignore +7 -0
  153. package/templates/site/next.config.ts +7 -0
  154. package/templates/site/package.json +40 -0
  155. package/templates/site/pnpm-workspace.yaml +5 -0
  156. package/templates/site/postcss.config.mjs +7 -0
  157. package/templates/site/src/app/globals.css +129 -0
  158. package/templates/site/src/app/layout.tsx +13 -0
  159. package/templates/site/src/app/page.spec.ts +10 -0
  160. package/templates/site/src/app/page.tsx +17 -0
  161. package/templates/site/src/lib/utils.ts +1 -0
  162. package/templates/site/tsconfig.json +34 -0
  163. package/templates/site/vitest.config.mts +8 -0
  164. package/templates/web/.env.example +1 -0
  165. package/templates/web/.githooks/install.mjs +18 -0
  166. package/templates/web/.githooks/pre-commit +2 -0
  167. package/templates/web/.githooks/pre-push +2 -0
  168. package/templates/web/README.md +29 -0
  169. package/templates/web/biome.jsonc +24 -0
  170. package/templates/web/components.json +25 -0
  171. package/templates/web/gitignore +6 -0
  172. package/templates/web/index.html +12 -0
  173. package/templates/web/package.json +55 -0
  174. package/templates/web/src/api-contract/api-error.ts +51 -0
  175. package/templates/web/src/api-contract/client.ts +26 -0
  176. package/templates/web/src/api-contract/index.ts +10 -0
  177. package/templates/web/src/api-contract/profile.ts +27 -0
  178. package/templates/web/src/app.spec.tsx +12 -0
  179. package/templates/web/src/app.tsx +14 -0
  180. package/templates/web/src/components/ui/button.tsx +57 -0
  181. package/templates/web/src/components/ui/field.tsx +224 -0
  182. package/templates/web/src/components/ui/input.tsx +19 -0
  183. package/templates/web/src/components/ui/label.tsx +17 -0
  184. package/templates/web/src/components/ui/separator.tsx +20 -0
  185. package/templates/web/src/config.ts +7 -0
  186. package/templates/web/src/features/auth/auth-client.ts +12 -0
  187. package/templates/web/src/features/auth/session.ts +17 -0
  188. package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
  189. package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
  190. package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
  191. package/templates/web/src/features/forms/form-error.tsx +15 -0
  192. package/templates/web/src/features/forms/server-errors.ts +33 -0
  193. package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
  194. package/templates/web/src/features/profile/profile-form.tsx +74 -0
  195. package/templates/web/src/index.css +130 -0
  196. package/templates/web/src/lib/utils.ts +1 -0
  197. package/templates/web/src/main.tsx +28 -0
  198. package/templates/web/src/routeTree.gen.ts +125 -0
  199. package/templates/web/src/router.tsx +21 -0
  200. package/templates/web/src/routes/__root.tsx +10 -0
  201. package/templates/web/src/routes/_authenticated/index.tsx +21 -0
  202. package/templates/web/src/routes/_authenticated.tsx +15 -0
  203. package/templates/web/src/routes/sign-in.tsx +20 -0
  204. package/templates/web/src/routes/sign-up.tsx +17 -0
  205. package/templates/web/test/fake-transport.ts +23 -0
  206. package/templates/web/test/render-app.tsx +17 -0
  207. package/templates/web/test/setup.ts +7 -0
  208. package/templates/web/tsconfig.json +21 -0
  209. package/templates/web/vite.config.ts +18 -0
  210. package/templates/workspace/.githooks/install.mjs +18 -0
  211. package/templates/workspace/.githooks/pre-commit +2 -0
  212. package/templates/workspace/.githooks/pre-push +2 -0
  213. package/templates/workspace/README.md +25 -0
  214. package/templates/workspace/biome.jsonc +8 -0
  215. package/templates/workspace/gitignore +5 -0
  216. package/templates/workspace/package.json +17 -0
  217. package/templates/workspace/packages/api-contract/package.json +20 -0
  218. package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
  219. package/templates/workspace/pnpm-workspace.yaml +3 -0
  220. package/templates/workspace/tsconfig.base.json +10 -0
@@ -0,0 +1,21 @@
1
+ const preset = require('jest-expo/jest-preset')
2
+
3
+ const [nodeModules, ...rest] = preset.transformIgnorePatterns
4
+ const babel = preset.transform['\\.[jt]sx?$']
5
+
6
+ if (!nodeModules.includes('(?!(') || babel === undefined) {
7
+ throw new Error(
8
+ 'jest-expo changed its transform settings; re-check the Better Auth entries below',
9
+ )
10
+ }
11
+
12
+ module.exports = {
13
+ preset: 'jest-expo',
14
+ moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
15
+ // Better Auth ships ESM only, partly as .mjs, so it joins the packages jest-expo transforms, with the same Babel.
16
+ transform: { '\\.mjs$': babel },
17
+ transformIgnorePatterns: [
18
+ nodeModules.replace('(?!(', '(?!(better-auth|@better-auth|nanostores|'),
19
+ ...rest,
20
+ ],
21
+ }
@@ -0,0 +1,4 @@
1
+ const { getDefaultConfig } = require('expo/metro-config')
2
+ const { withNativeWind } = require('nativewind/metro')
3
+
4
+ module.exports = withNativeWind(getDefaultConfig(__dirname), { input: './src/global.css' })
@@ -0,0 +1 @@
1
+ /// <reference types="nativewind/types" />
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "mobile",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "main": "expo-router/entry",
6
+ "scripts": {
7
+ "start": "expo start",
8
+ "ios": "expo start --ios",
9
+ "android": "expo start --android",
10
+ "bundle": "expo export --platform ios --platform android",
11
+ "lint": "biome check",
12
+ "typecheck": "tsc --noEmit",
13
+ "test": "jest",
14
+ "prepare": "node .githooks/install.mjs"
15
+ },
16
+ "dependencies": {
17
+ "@better-auth/expo": "1.7.4",
18
+ "@hookform/resolvers": "5.9.1",
19
+ "@tanstack/query-sync-storage-persister": "5.102.8",
20
+ "@tanstack/react-query": "5.102.8",
21
+ "@tanstack/react-query-persist-client": "5.102.8",
22
+ "better-auth": "1.7.4",
23
+ "class-variance-authority": "0.7.1",
24
+ "expo": "57.0.22",
25
+ "expo-constants": "57.0.18",
26
+ "expo-linking": "57.0.10",
27
+ "expo-network": "57.0.2",
28
+ "expo-router": "57.0.21",
29
+ "expo-secure-store": "57.0.4",
30
+ "expo-splash-screen": "57.0.9",
31
+ "expo-status-bar": "57.0.1",
32
+ "expo-system-ui": "57.0.4",
33
+ "expo-web-browser": "57.0.3",
34
+ "nativewind": "4.2.6",
35
+ "react": "19.2.3",
36
+ "react-hook-form": "7.88.0",
37
+ "react-native": "0.86.3",
38
+ "react-native-css-interop": "0.2.6",
39
+ "react-native-gesture-handler": "2.32.0",
40
+ "react-native-mmkv": "4.3.2",
41
+ "react-native-nitro-modules": "0.37.1",
42
+ "react-native-reanimated": "4.5.1",
43
+ "react-native-safe-area-context": "5.7.0",
44
+ "react-native-screens": "4.26.2",
45
+ "react-native-worklets": "0.10.1",
46
+ "zod": "4.6.4"
47
+ },
48
+ "devDependencies": {
49
+ "@biomejs/biome": "2.5.13",
50
+ "@testing-library/react-native": "14.0.1",
51
+ "@types/jest": "29.5.14",
52
+ "@types/node": "24.13.4",
53
+ "@types/react": "19.2.18",
54
+ "babel-preset-expo": "57.0.11",
55
+ "jest": "29.7.0",
56
+ "jest-expo": "57.0.5",
57
+ "tailwindcss": "3.4.19",
58
+ "typescript": "6.0.3"
59
+ },
60
+ "engines": {
61
+ "node": ">=22.17.0"
62
+ }
63
+ }
@@ -0,0 +1,51 @@
1
+ export type FieldErrors = Record<string, string[]>
2
+
3
+ type ProblemDocument = {
4
+ title?: string
5
+ status?: number
6
+ detail?: string
7
+ requestId?: string
8
+ errors?: FieldErrors
9
+ }
10
+
11
+ type AuthClientError = {
12
+ status: number
13
+ statusText: string
14
+ message?: string
15
+ code?: string
16
+ }
17
+
18
+ export class ApiError extends Error {
19
+ constructor(
20
+ readonly status: number,
21
+ readonly title: string,
22
+ readonly detail: string | undefined,
23
+ readonly requestId: string | undefined,
24
+ readonly errors: FieldErrors,
25
+ ) {
26
+ super(detail ?? title)
27
+ }
28
+
29
+ static async fromResponse(response: Response): Promise<ApiError> {
30
+ const requestId = response.headers.get('x-request-id') ?? undefined
31
+ const isProblem = response.headers.get('content-type')?.includes('application/problem+json')
32
+
33
+ if (!isProblem) {
34
+ return new ApiError(response.status, response.statusText, undefined, requestId, {})
35
+ }
36
+
37
+ const problem = (await response.json()) as ProblemDocument
38
+
39
+ return new ApiError(
40
+ response.status,
41
+ problem.title ?? response.statusText,
42
+ problem.detail,
43
+ problem.requestId ?? requestId,
44
+ problem.errors ?? {},
45
+ )
46
+ }
47
+
48
+ static fromAuthError(error: AuthClientError): ApiError {
49
+ return new ApiError(error.status, error.code ?? error.statusText, error.message, undefined, {})
50
+ }
51
+ }
@@ -0,0 +1,26 @@
1
+ import { ApiError } from './api-error'
2
+
3
+ export type Transport = typeof fetch
4
+
5
+ export type ApiClient = ReturnType<typeof createClient>
6
+
7
+ export function createClient({ baseUrl, fetch }: { baseUrl: string; fetch: Transport }) {
8
+ async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
9
+ const response = await fetch(`${baseUrl}/api/v1${path}`, {
10
+ method,
11
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
12
+ body: body === undefined ? undefined : JSON.stringify(body),
13
+ })
14
+
15
+ if (!response.ok) {
16
+ throw await ApiError.fromResponse(response)
17
+ }
18
+
19
+ return (await response.json()) as T
20
+ }
21
+
22
+ return {
23
+ get: <T>(path: string) => request<T>('GET', path),
24
+ patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
25
+ }
26
+ }
@@ -0,0 +1,10 @@
1
+ export { ApiError, type FieldErrors } from './api-error'
2
+ export { type ApiClient, createClient, type Transport } from './client'
3
+ export {
4
+ LOCALES,
5
+ type Locale,
6
+ type Profile,
7
+ profileQuery,
8
+ type UpdateProfileRequest,
9
+ updateProfile,
10
+ } from './profile'
@@ -0,0 +1,27 @@
1
+ import { queryOptions } from '@tanstack/react-query'
2
+ import type { ApiClient } from './client'
3
+
4
+ export const LOCALES = ['en', 'pt-BR'] as const
5
+
6
+ export type Locale = (typeof LOCALES)[number]
7
+
8
+ export type Profile = {
9
+ id: string
10
+ userId: string
11
+ displayName: string
12
+ locale: Locale
13
+ timezone: string
14
+ createdAt: string
15
+ updatedAt: string
16
+ }
17
+
18
+ export type UpdateProfileRequest = Partial<Pick<Profile, 'displayName' | 'locale' | 'timezone'>>
19
+
20
+ export const profileQuery = (client: ApiClient) =>
21
+ queryOptions({
22
+ queryKey: ['profile', 'me'],
23
+ queryFn: () => client.get<Profile>('/users/me'),
24
+ })
25
+
26
+ export const updateProfile = (client: ApiClient, changes: UpdateProfileRequest) =>
27
+ client.patch<Profile>('/users/me', changes)
@@ -0,0 +1,18 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { Redirect, Stack, usePathname } from 'expo-router'
3
+ import { rememberIntendedRoute } from '@/features/auth/intended-route'
4
+ import { sessionQuery } from '@/features/auth/session'
5
+ import { useClients } from '@/features/clients/clients-context'
6
+
7
+ export default function AuthenticatedLayout() {
8
+ const { auth } = useClients()
9
+ const session = useQuery(sessionQuery(auth))
10
+ const pathname = usePathname()
11
+
12
+ if (session.data === null || session.data === undefined) {
13
+ rememberIntendedRoute(pathname)
14
+ return <Redirect href="/sign-in" />
15
+ }
16
+
17
+ return <Stack screenOptions={{ headerShown: false }} />
18
+ }
@@ -0,0 +1,18 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { Text } from 'react-native'
3
+ import { SafeAreaView } from 'react-native-safe-area-context'
4
+ import { profileQuery } from '@/api-contract'
5
+ import { useClients } from '@/features/clients/clients-context'
6
+ import { ProfileForm } from '@/features/profile/profile-form'
7
+
8
+ export default function ProfileScreen() {
9
+ const { api } = useClients()
10
+ const profile = useQuery(profileQuery(api))
11
+
12
+ return (
13
+ <SafeAreaView className="flex-1 justify-center gap-6 p-6">
14
+ <Text className="text-2xl font-semibold text-neutral-900">Your profile</Text>
15
+ {profile.data === undefined ? null : <ProfileForm api={api} profile={profile.data} />}
16
+ </SafeAreaView>
17
+ )
18
+ }
@@ -0,0 +1,24 @@
1
+ import { Link, useRouter } from 'expo-router'
2
+ import { Text, View } from 'react-native'
3
+ import { SafeAreaView } from 'react-native-safe-area-context'
4
+ import { takeIntendedRoute } from '@/features/auth/intended-route'
5
+ import { SignInForm } from '@/features/auth/sign-in-form'
6
+ import { useClients } from '@/features/clients/clients-context'
7
+
8
+ export default function SignInScreen() {
9
+ const { auth } = useClients()
10
+ const router = useRouter()
11
+
12
+ return (
13
+ <SafeAreaView className="flex-1 justify-center gap-6 p-6">
14
+ <Text className="text-2xl font-semibold text-neutral-900">Sign in</Text>
15
+ <SignInForm auth={auth} onSignedIn={() => router.replace(takeIntendedRoute() ?? '/')} />
16
+ <View className="flex-row gap-1">
17
+ <Text className="text-sm text-neutral-500">No account?</Text>
18
+ <Link href="/sign-up" className="text-sm text-neutral-900">
19
+ Sign up
20
+ </Link>
21
+ </View>
22
+ </SafeAreaView>
23
+ )
24
+ }
@@ -0,0 +1,23 @@
1
+ import { Link, useRouter } from 'expo-router'
2
+ import { Text, View } from 'react-native'
3
+ import { SafeAreaView } from 'react-native-safe-area-context'
4
+ import { SignUpForm } from '@/features/auth/sign-up-form'
5
+ import { useClients } from '@/features/clients/clients-context'
6
+
7
+ export default function SignUpScreen() {
8
+ const { auth } = useClients()
9
+ const router = useRouter()
10
+
11
+ return (
12
+ <SafeAreaView className="flex-1 justify-center gap-6 p-6">
13
+ <Text className="text-2xl font-semibold text-neutral-900">Create an account</Text>
14
+ <SignUpForm auth={auth} onSignedUp={() => router.replace('/')} />
15
+ <View className="flex-row gap-1">
16
+ <Text className="text-sm text-neutral-500">Already registered?</Text>
17
+ <Link href="/sign-in" className="text-sm text-neutral-900">
18
+ Sign in
19
+ </Link>
20
+ </View>
21
+ </SafeAreaView>
22
+ )
23
+ }
@@ -0,0 +1,54 @@
1
+ import '@/global.css'
2
+ import { QueryClient, useQuery } from '@tanstack/react-query'
3
+ import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
4
+ import { Stack } from 'expo-router'
5
+ import * as SplashScreen from 'expo-splash-screen'
6
+ import { useEffect, useState } from 'react'
7
+ import { createClient } from '@/api-contract'
8
+ import { config } from '@/config'
9
+ import { createAuth, withSessionCookie } from '@/features/auth/auth-client'
10
+ import { sessionQuery } from '@/features/auth/session'
11
+ import { ClientsProvider, useClients } from '@/features/clients/clients-context'
12
+ import { QUERY_CACHE_MAX_AGE, queryPersister } from '@/features/storage/query-persister'
13
+
14
+ SplashScreen.preventAutoHideAsync()
15
+
16
+ function createClients() {
17
+ const auth = createAuth({ baseUrl: config.apiUrl, fetch })
18
+ const api = createClient({ baseUrl: config.apiUrl, fetch: withSessionCookie(auth, fetch) })
19
+
20
+ return { auth, api }
21
+ }
22
+
23
+ function Navigation() {
24
+ const { auth } = useClients()
25
+ const session = useQuery(sessionQuery(auth))
26
+
27
+ useEffect(() => {
28
+ if (!session.isPending) {
29
+ SplashScreen.hideAsync()
30
+ }
31
+ }, [session.isPending])
32
+
33
+ if (session.isPending) {
34
+ return null
35
+ }
36
+
37
+ return <Stack screenOptions={{ headerShown: false }} />
38
+ }
39
+
40
+ export default function RootLayout() {
41
+ const [clients] = useState(createClients)
42
+ const [queryClient] = useState(() => new QueryClient())
43
+
44
+ return (
45
+ <PersistQueryClientProvider
46
+ client={queryClient}
47
+ persistOptions={{ persister: queryPersister, maxAge: QUERY_CACHE_MAX_AGE }}
48
+ >
49
+ <ClientsProvider clients={clients}>
50
+ <Navigation />
51
+ </ClientsProvider>
52
+ </PersistQueryClientProvider>
53
+ )
54
+ }
@@ -0,0 +1,36 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority'
2
+ import { Pressable, type PressableProps, Text } from 'react-native'
3
+
4
+ const buttonVariants = cva('h-11 items-center justify-center rounded-lg px-4', {
5
+ variants: {
6
+ variant: {
7
+ default: 'bg-neutral-900',
8
+ outline: 'border border-neutral-300 bg-transparent',
9
+ },
10
+ disabled: { true: 'opacity-50', false: '' },
11
+ },
12
+ defaultVariants: { variant: 'default', disabled: false },
13
+ })
14
+
15
+ const labelVariants = cva('text-base font-medium', {
16
+ variants: {
17
+ variant: { default: 'text-white', outline: 'text-neutral-900' },
18
+ },
19
+ defaultVariants: { variant: 'default' },
20
+ })
21
+
22
+ type ButtonProps = Omit<PressableProps, 'children'> &
23
+ VariantProps<typeof buttonVariants> & { label: string }
24
+
25
+ export function Button({ label, variant, disabled, className, ...props }: ButtonProps) {
26
+ return (
27
+ <Pressable
28
+ accessibilityRole="button"
29
+ disabled={disabled === true}
30
+ className={buttonVariants({ variant, disabled: disabled === true, className })}
31
+ {...props}
32
+ >
33
+ <Text className={labelVariants({ variant })}>{label}</Text>
34
+ </Pressable>
35
+ )
36
+ }
@@ -0,0 +1,30 @@
1
+ import { cva } from 'class-variance-authority'
2
+ import { forwardRef } from 'react'
3
+ import { Text, TextInput, type TextInputProps, View } from 'react-native'
4
+
5
+ const inputVariants = cva('h-11 rounded-lg border px-3 text-base text-neutral-900', {
6
+ variants: {
7
+ invalid: { true: 'border-red-600', false: 'border-neutral-300' },
8
+ },
9
+ defaultVariants: { invalid: false },
10
+ })
11
+
12
+ type FieldProps = TextInputProps & { label: string; error: string | undefined }
13
+
14
+ export const Field = forwardRef<TextInput, FieldProps>(function Field(
15
+ { label, error, className, ...props },
16
+ ref,
17
+ ) {
18
+ return (
19
+ <View testID={`field-${label}`} className="gap-1.5">
20
+ <Text className="text-sm font-medium text-neutral-900">{label}</Text>
21
+ <TextInput
22
+ ref={ref}
23
+ accessibilityLabel={label}
24
+ className={inputVariants({ invalid: error !== undefined, className })}
25
+ {...props}
26
+ />
27
+ {error === undefined ? null : <Text className="text-sm text-red-600">{error}</Text>}
28
+ </View>
29
+ )
30
+ })
@@ -0,0 +1,3 @@
1
+ export const config = {
2
+ apiUrl: process.env.EXPO_PUBLIC_API_URL as string,
3
+ }
@@ -0,0 +1,27 @@
1
+ import { expoClient } from '@better-auth/expo/client'
2
+ import { createAuthClient } from 'better-auth/react'
3
+ import * as SecureStore from 'expo-secure-store'
4
+ import type { Transport } from '@/api-contract'
5
+
6
+ export type AuthClient = ReturnType<typeof createAuth>
7
+
8
+ export type SessionAuth = Pick<AuthClient, 'signIn' | 'signUp' | 'getSession'>
9
+
10
+ export function createAuth({ baseUrl, fetch }: { baseUrl: string; fetch: Transport }) {
11
+ return createAuthClient({
12
+ baseURL: baseUrl,
13
+ basePath: '/api/auth',
14
+ fetchOptions: { customFetchImpl: fetch },
15
+ plugins: [expoClient({ storage: SecureStore })],
16
+ })
17
+ }
18
+
19
+ export function withSessionCookie(auth: AuthClient, fetch: Transport): Transport {
20
+ return async (input, init) => {
21
+ const headers = new Headers(init?.headers)
22
+
23
+ headers.set('cookie', await auth.getCookie())
24
+
25
+ return fetch(input, { ...init, headers, credentials: 'omit' })
26
+ }
27
+ }
@@ -0,0 +1,11 @@
1
+ let intended: string | undefined
2
+
3
+ export function rememberIntendedRoute(path: string): void {
4
+ intended = path
5
+ }
6
+
7
+ export function takeIntendedRoute(): string | undefined {
8
+ const path = intended
9
+ intended = undefined
10
+ return path
11
+ }
@@ -0,0 +1,17 @@
1
+ import { queryOptions } from '@tanstack/react-query'
2
+ import { ApiError } from '@/api-contract'
3
+ import type { SessionAuth } from './auth-client'
4
+
5
+ export const sessionQuery = (auth: SessionAuth) =>
6
+ queryOptions({
7
+ queryKey: ['session'],
8
+ queryFn: async () => {
9
+ const { data, error } = await auth.getSession()
10
+
11
+ if (error !== null) {
12
+ throw ApiError.fromAuthError(error)
13
+ }
14
+
15
+ return data
16
+ },
17
+ })
@@ -0,0 +1,37 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { render, screen, userEvent } from '@testing-library/react-native'
3
+ import { createAuthClient } from 'better-auth/react'
4
+ import { API_URL, fakeTransport, json } from '../../../test/fake-transport'
5
+ import { SignInForm } from './sign-in-form'
6
+
7
+ describe('SignInForm', () => {
8
+ it('shows a rejected sign-in as a form-level error', async () => {
9
+ const onSignedIn = jest.fn()
10
+ // jest-expo leaves the app manifest empty, and the Expo plugin needs it to build an origin; the form under test
11
+ // depends only on the error Better Auth returns, which the plugin does not change.
12
+ const auth = createAuthClient({
13
+ baseURL: API_URL,
14
+ basePath: '/api/auth',
15
+ fetchOptions: {
16
+ customFetchImpl: fakeTransport({
17
+ 'POST /api/auth/sign-in/email': () =>
18
+ json(401, { message: 'Invalid email or password', code: 'INVALID_EMAIL_OR_PASSWORD' }),
19
+ }),
20
+ },
21
+ })
22
+ const user = userEvent.setup()
23
+
24
+ await render(
25
+ <QueryClientProvider client={new QueryClient()}>
26
+ <SignInForm auth={auth} onSignedIn={onSignedIn} />
27
+ </QueryClientProvider>,
28
+ )
29
+
30
+ await user.type(screen.getByLabelText('Email'), 'ana@example.com')
31
+ await user.type(screen.getByLabelText('Password'), 'wrong-password')
32
+ await user.press(screen.getByRole('button', { name: 'Sign in' }))
33
+
34
+ expect(await screen.findByRole('alert')).toHaveTextContent('Invalid email or password')
35
+ expect(onSignedIn).not.toHaveBeenCalled()
36
+ })
37
+ })
@@ -0,0 +1,80 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useQueryClient } from '@tanstack/react-query'
3
+ import { Controller, useForm } from 'react-hook-form'
4
+ import { View } from 'react-native'
5
+ import { z } from 'zod'
6
+ import { ApiError } from '@/api-contract'
7
+ import { Button } from '@/components/ui/button'
8
+ import { Field } from '@/components/ui/field'
9
+ import { FormError } from '@/features/forms/form-error'
10
+ import { applyServerError } from '@/features/forms/server-errors'
11
+ import type { SessionAuth } from './auth-client'
12
+ import { sessionQuery } from './session'
13
+
14
+ const schema = z.object({
15
+ email: z.email(),
16
+ password: z.string().min(1),
17
+ })
18
+
19
+ type SignInValues = z.infer<typeof schema>
20
+
21
+ export function SignInForm({ auth, onSignedIn }: { auth: SessionAuth; onSignedIn: () => void }) {
22
+ const queryClient = useQueryClient()
23
+ const form = useForm<SignInValues>({
24
+ resolver: zodResolver(schema),
25
+ mode: 'onTouched',
26
+ reValidateMode: 'onChange',
27
+ defaultValues: { email: '', password: '' },
28
+ })
29
+ const { errors, isSubmitting } = form.formState
30
+
31
+ async function onSubmit(values: SignInValues) {
32
+ const { error } = await auth.signIn.email(values)
33
+
34
+ if (error !== null) {
35
+ applyServerError(ApiError.fromAuthError(error), ['email', 'password'], form.setError)
36
+ return
37
+ }
38
+
39
+ await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
40
+ onSignedIn()
41
+ }
42
+
43
+ return (
44
+ <View className="gap-6">
45
+ <FormError errors={errors} />
46
+ <Controller
47
+ control={form.control}
48
+ name="email"
49
+ render={({ field }) => (
50
+ <Field
51
+ label="Email"
52
+ autoCapitalize="none"
53
+ autoComplete="email"
54
+ keyboardType="email-address"
55
+ value={field.value}
56
+ onChangeText={field.onChange}
57
+ onBlur={field.onBlur}
58
+ error={errors.email?.message}
59
+ />
60
+ )}
61
+ />
62
+ <Controller
63
+ control={form.control}
64
+ name="password"
65
+ render={({ field }) => (
66
+ <Field
67
+ label="Password"
68
+ secureTextEntry
69
+ autoComplete="current-password"
70
+ value={field.value}
71
+ onChangeText={field.onChange}
72
+ onBlur={field.onBlur}
73
+ error={errors.password?.message}
74
+ />
75
+ )}
76
+ />
77
+ <Button label="Sign in" disabled={isSubmitting} onPress={form.handleSubmit(onSubmit)} />
78
+ </View>
79
+ )
80
+ }