@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.
- package/.prumo-templates/api/auth.md +130 -0
- package/.prumo-templates/api/config.md +85 -0
- package/.prumo-templates/api/errors.md +82 -0
- package/.prumo-templates/api/logging.md +79 -0
- package/.prumo-templates/api/modules.md +117 -0
- package/.prumo-templates/api/observability.md +91 -0
- package/.prumo-templates/api/pagination.md +71 -0
- package/.prumo-templates/api/routes.md +89 -0
- package/.prumo-templates/client/data.md +124 -0
- package/.prumo-templates/client/forms.md +88 -0
- package/.prumo-templates/core/conventions.md +74 -0
- package/.prumo-templates/core/testing.md +100 -0
- package/.prumo-templates/core/tooling.md +118 -0
- package/.prumo-templates/database/entities.md +97 -0
- package/.prumo-templates/database/migrations.md +74 -0
- package/.prumo-templates/database/queries.md +79 -0
- package/.prumo-templates/database/testing.md +82 -0
- package/.prumo-templates/database/transactions.md +68 -0
- package/.prumo-templates/mobile/components.md +83 -0
- package/.prumo-templates/mobile/config.md +84 -0
- package/.prumo-templates/mobile/routing.md +74 -0
- package/.prumo-templates/mobile/storage.md +84 -0
- package/.prumo-templates/mobile/structure.md +79 -0
- package/.prumo-templates/monorepo/dependencies.md +84 -0
- package/.prumo-templates/monorepo/layout.md +89 -0
- package/.prumo-templates/monorepo/tasks.md +76 -0
- package/.prumo-templates/multi-tenancy/client.md +83 -0
- package/.prumo-templates/multi-tenancy/isolation.md +104 -0
- package/.prumo-templates/site/rendering.md +83 -0
- package/.prumo-templates/site/structure.md +68 -0
- package/.prumo-templates/web/components.md +106 -0
- package/.prumo-templates/web/routing.md +82 -0
- package/.prumo-templates/web/structure.md +92 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/asker.js +28 -0
- package/dist/cli.js +62 -0
- package/dist/compose.js +222 -0
- package/dist/context.js +38 -0
- package/dist/generate.js +60 -0
- package/dist/jsonc.js +76 -0
- package/dist/names.js +10 -0
- package/dist/questions.js +63 -0
- package/package.json +55 -0
- package/templates/api/.env.example +12 -0
- package/templates/api/.githooks/install.mjs +18 -0
- package/templates/api/.githooks/pre-commit +2 -0
- package/templates/api/.githooks/pre-push +2 -0
- package/templates/api/README.md +35 -0
- package/templates/api/biome.jsonc +18 -0
- package/templates/api/docker/init/01-auth-schema.sql +1 -0
- package/templates/api/docker-compose.yml +20 -0
- package/templates/api/gitignore +5 -0
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
- package/templates/api/nest-cli.json +10 -0
- package/templates/api/package.json +69 -0
- package/templates/api/pnpm-workspace.yaml +9 -0
- package/templates/api/src/app.module.ts +46 -0
- package/templates/api/src/auth/auth.constants.ts +2 -0
- package/templates/api/src/auth/auth.controller.ts +19 -0
- package/templates/api/src/auth/auth.factory.spec.ts +29 -0
- package/templates/api/src/auth/auth.factory.ts +52 -0
- package/templates/api/src/auth/auth.guard.ts +43 -0
- package/templates/api/src/auth/auth.module.ts +30 -0
- package/templates/api/src/auth/auth.ts +4 -0
- package/templates/api/src/auth/current-user.decorator.ts +9 -0
- package/templates/api/src/auth/express.d.ts +10 -0
- package/templates/api/src/auth/public.decorator.ts +4 -0
- package/templates/api/src/config/env.module.ts +11 -0
- package/templates/api/src/config/env.ts +81 -0
- package/templates/api/src/errors/problem-details.filter.ts +86 -0
- package/templates/api/src/errors/validation-exception.factory.ts +30 -0
- package/templates/api/src/health/health.controller.ts +32 -0
- package/templates/api/src/health/health.module.ts +9 -0
- package/templates/api/src/main.ts +43 -0
- package/templates/api/src/mikro-orm.config.ts +4 -0
- package/templates/api/src/mikro-orm.factory.ts +14 -0
- package/templates/api/src/request-context/request-context.module.ts +9 -0
- package/templates/api/src/request-context/request-context.service.ts +17 -0
- package/templates/api/src/request-context/request-id.middleware.ts +19 -0
- package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
- package/templates/api/src/users/entities/profile.entity.ts +27 -0
- package/templates/api/src/users/users.controller.ts +23 -0
- package/templates/api/src/users/users.module.ts +10 -0
- package/templates/api/src/users/users.service.spec.ts +44 -0
- package/templates/api/src/users/users.service.ts +36 -0
- package/templates/api/test/factories/profile.factory.ts +26 -0
- package/templates/api/test/factories/user.factory.ts +21 -0
- package/templates/api/test/global-setup.ts +20 -0
- package/templates/api/test/setup.ts +80 -0
- package/templates/api/tsconfig.build.json +8 -0
- package/templates/api/tsconfig.json +29 -0
- package/templates/api/vitest.config.mts +17 -0
- package/templates/mobile/.env.example +1 -0
- package/templates/mobile/.githooks/install.mjs +18 -0
- package/templates/mobile/.githooks/pre-commit +2 -0
- package/templates/mobile/.githooks/pre-push +2 -0
- package/templates/mobile/README.md +30 -0
- package/templates/mobile/app.config.ts +13 -0
- package/templates/mobile/app.json +39 -0
- package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
- package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
- package/templates/mobile/assets/expo.icon/icon.json +35 -0
- package/templates/mobile/assets/images/android-icon-background.png +0 -0
- package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
- package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
- package/templates/mobile/assets/images/icon.png +0 -0
- package/templates/mobile/assets/images/splash-icon.png +0 -0
- package/templates/mobile/babel.config.js +6 -0
- package/templates/mobile/biome.jsonc +21 -0
- package/templates/mobile/gitignore +11 -0
- package/templates/mobile/global.d.ts +1 -0
- package/templates/mobile/jest.config.js +21 -0
- package/templates/mobile/metro.config.js +4 -0
- package/templates/mobile/nativewind-env.d.ts +1 -0
- package/templates/mobile/package.json +63 -0
- package/templates/mobile/src/api-contract/api-error.ts +51 -0
- package/templates/mobile/src/api-contract/client.ts +26 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/api-contract/profile.ts +27 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
- package/templates/mobile/src/app/(app)/index.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
- package/templates/mobile/src/app/_layout.tsx +54 -0
- package/templates/mobile/src/components/ui/button.tsx +36 -0
- package/templates/mobile/src/components/ui/field.tsx +30 -0
- package/templates/mobile/src/config.ts +3 -0
- package/templates/mobile/src/features/auth/auth-client.ts +27 -0
- package/templates/mobile/src/features/auth/intended-route.ts +11 -0
- package/templates/mobile/src/features/auth/session.ts +17 -0
- package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
- package/templates/mobile/src/features/forms/form-error.tsx +16 -0
- package/templates/mobile/src/features/forms/server-errors.ts +33 -0
- package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
- package/templates/mobile/src/features/storage/query-persister.ts +15 -0
- package/templates/mobile/src/features/storage/storage.ts +11 -0
- package/templates/mobile/src/global.css +3 -0
- package/templates/mobile/tailwind.config.js +9 -0
- package/templates/mobile/test/fake-transport.ts +23 -0
- package/templates/mobile/tsconfig.json +21 -0
- package/templates/site/.githooks/install.mjs +18 -0
- package/templates/site/.githooks/pre-commit +2 -0
- package/templates/site/.githooks/pre-push +2 -0
- package/templates/site/README.md +28 -0
- package/templates/site/biome.jsonc +12 -0
- package/templates/site/components.json +25 -0
- package/templates/site/gitignore +7 -0
- package/templates/site/next.config.ts +7 -0
- package/templates/site/package.json +40 -0
- package/templates/site/pnpm-workspace.yaml +5 -0
- package/templates/site/postcss.config.mjs +7 -0
- package/templates/site/src/app/globals.css +129 -0
- package/templates/site/src/app/layout.tsx +13 -0
- package/templates/site/src/app/page.spec.ts +10 -0
- package/templates/site/src/app/page.tsx +17 -0
- package/templates/site/src/lib/utils.ts +1 -0
- package/templates/site/tsconfig.json +34 -0
- package/templates/site/vitest.config.mts +8 -0
- package/templates/web/.env.example +1 -0
- package/templates/web/.githooks/install.mjs +18 -0
- package/templates/web/.githooks/pre-commit +2 -0
- package/templates/web/.githooks/pre-push +2 -0
- package/templates/web/README.md +29 -0
- package/templates/web/biome.jsonc +24 -0
- package/templates/web/components.json +25 -0
- package/templates/web/gitignore +6 -0
- package/templates/web/index.html +12 -0
- package/templates/web/package.json +55 -0
- package/templates/web/src/api-contract/api-error.ts +51 -0
- package/templates/web/src/api-contract/client.ts +26 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/api-contract/profile.ts +27 -0
- package/templates/web/src/app.spec.tsx +12 -0
- package/templates/web/src/app.tsx +14 -0
- package/templates/web/src/components/ui/button.tsx +57 -0
- package/templates/web/src/components/ui/field.tsx +224 -0
- package/templates/web/src/components/ui/input.tsx +19 -0
- package/templates/web/src/components/ui/label.tsx +17 -0
- package/templates/web/src/components/ui/separator.tsx +20 -0
- package/templates/web/src/config.ts +7 -0
- package/templates/web/src/features/auth/auth-client.ts +12 -0
- package/templates/web/src/features/auth/session.ts +17 -0
- package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
- package/templates/web/src/features/forms/form-error.tsx +15 -0
- package/templates/web/src/features/forms/server-errors.ts +33 -0
- package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/web/src/features/profile/profile-form.tsx +74 -0
- package/templates/web/src/index.css +130 -0
- package/templates/web/src/lib/utils.ts +1 -0
- package/templates/web/src/main.tsx +28 -0
- package/templates/web/src/routeTree.gen.ts +125 -0
- package/templates/web/src/router.tsx +21 -0
- package/templates/web/src/routes/__root.tsx +10 -0
- package/templates/web/src/routes/_authenticated/index.tsx +21 -0
- package/templates/web/src/routes/_authenticated.tsx +15 -0
- package/templates/web/src/routes/sign-in.tsx +20 -0
- package/templates/web/src/routes/sign-up.tsx +17 -0
- package/templates/web/test/fake-transport.ts +23 -0
- package/templates/web/test/render-app.tsx +17 -0
- package/templates/web/test/setup.ts +7 -0
- package/templates/web/tsconfig.json +21 -0
- package/templates/web/vite.config.ts +18 -0
- package/templates/workspace/.githooks/install.mjs +18 -0
- package/templates/workspace/.githooks/pre-commit +2 -0
- package/templates/workspace/.githooks/pre-push +2 -0
- package/templates/workspace/README.md +25 -0
- package/templates/workspace/biome.jsonc +8 -0
- package/templates/workspace/gitignore +5 -0
- package/templates/workspace/package.json +17 -0
- package/templates/workspace/packages/api-contract/package.json +20 -0
- package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
- package/templates/workspace/pnpm-workspace.yaml +3 -0
- 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 @@
|
|
|
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,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,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
|
+
}
|