@stjoseph/prumo 0.2.0 → 0.4.0

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 (252) hide show
  1. package/.prumo-templates/api/auth.md +51 -60
  2. package/.prumo-templates/api/config.md +2 -2
  3. package/.prumo-templates/api/routes.md +6 -4
  4. package/.prumo-templates/api/structure.md +4 -1
  5. package/.prumo-templates/client/data.md +16 -18
  6. package/.prumo-templates/client/forms.md +2 -2
  7. package/.prumo-templates/core/testing.md +4 -4
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/email/codes.md +62 -0
  10. package/.prumo-templates/email/mailer.md +47 -0
  11. package/.prumo-templates/mcp/server.md +55 -14
  12. package/.prumo-templates/mobile/routing.md +1 -1
  13. package/.prumo-templates/mobile/storage.md +7 -6
  14. package/.prumo-templates/monorepo/layout.md +4 -4
  15. package/.prumo-templates/multi-tenancy/client.md +6 -6
  16. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  17. package/.prumo-templates/social/sign-in.md +77 -0
  18. package/README.md +8 -6
  19. package/dist/asker.js +13 -0
  20. package/dist/clean.js +2 -2
  21. package/dist/cli.js +16 -2
  22. package/dist/commands.js +7 -0
  23. package/dist/compose.js +3 -11
  24. package/dist/context.js +4 -0
  25. package/dist/features.js +230 -0
  26. package/dist/generate.js +20 -7
  27. package/dist/questions.js +64 -1
  28. package/package.json +1 -1
  29. package/templates/api/.env.example +21 -5
  30. package/templates/api/README.md +4 -5
  31. package/templates/api/docker-compose.yml +0 -1
  32. package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
  33. package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
  34. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  35. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  36. package/templates/api/package.json +7 -8
  37. package/templates/api/scripts/database.mjs +3 -11
  38. package/templates/api/scripts/ports.mjs +1 -1
  39. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  40. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  41. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  42. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  43. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  44. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  45. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  46. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  47. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  48. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  49. package/templates/api/src/domain/auth/entities/session.entity.ts +78 -0
  50. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  51. package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
  52. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  53. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  54. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  55. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  56. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  57. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  58. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  59. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  60. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  61. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  62. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  63. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  64. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  65. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  66. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  67. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  68. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  69. package/templates/api/src/domain/auth/repositories/session.repository.ts +12 -0
  70. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  71. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  72. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  73. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  74. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  75. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  76. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  77. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +94 -0
  78. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  79. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  80. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  81. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +38 -0
  82. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  83. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
  84. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
  85. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  86. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  87. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  88. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
  89. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
  90. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  91. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
  92. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  93. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  94. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  95. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  96. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  97. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  98. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
  99. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  100. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
  101. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  102. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  103. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  104. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
  105. package/templates/api/src/infra/config/env.ts +17 -3
  106. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -0
  107. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  108. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  109. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  110. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -0
  111. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  112. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +19 -0
  113. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
  114. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  115. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  116. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  117. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +25 -0
  118. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  119. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  120. package/templates/api/src/infra/di/auth.di.ts +21 -0
  121. package/templates/api/src/infra/di/email.di.ts +23 -0
  122. package/templates/api/src/infra/di/index.ts +8 -10
  123. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  124. package/templates/api/src/infra/di/social.di.ts +17 -0
  125. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  126. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  127. package/templates/api/src/infra/http/app.ts +15 -1
  128. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  129. package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
  130. package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
  131. package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
  132. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  133. package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
  134. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  135. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  136. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  137. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  138. package/templates/api/src/infra/http/server.ts +1 -4
  139. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  140. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  141. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  142. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  143. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  144. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  145. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  146. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  147. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  148. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  149. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  150. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
  151. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  152. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +53 -0
  153. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  154. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  155. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  156. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  157. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  158. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  159. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +168 -0
  160. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
  161. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  162. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
  163. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  164. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  165. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  166. package/templates/api/test/support/factories/user.factory.ts +9 -11
  167. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  168. package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
  169. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  170. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  171. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  172. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  173. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  174. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  175. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  176. package/templates/api/test/support/fakes/in-memory-session.repository.ts +27 -0
  177. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  178. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  179. package/templates/api/test/support/setup.ts +6 -28
  180. package/templates/api/test/support/test-app.ts +65 -0
  181. package/templates/mobile/README.md +1 -1
  182. package/templates/mobile/jest.config.js +0 -17
  183. package/templates/mobile/package.json +0 -4
  184. package/templates/mobile/scripts/ports.mjs +1 -1
  185. package/templates/mobile/src/api-contract/api-error.ts +4 -11
  186. package/templates/mobile/src/api-contract/auth.ts +221 -0
  187. package/templates/mobile/src/api-contract/index.ts +14 -0
  188. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  189. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  190. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  191. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  192. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  193. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  194. package/templates/mobile/src/app/_layout.tsx +5 -5
  195. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  196. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  197. package/templates/mobile/src/features/auth/sign-in-form.tsx +21 -7
  198. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  199. package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
  200. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  201. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  202. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  203. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  204. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  205. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  206. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  207. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  208. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  209. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  210. package/templates/site/scripts/ports.mjs +1 -1
  211. package/templates/web/package.json +0 -3
  212. package/templates/web/scripts/ports.mjs +1 -1
  213. package/templates/web/src/api-contract/api-error.ts +4 -11
  214. package/templates/web/src/api-contract/auth.ts +221 -0
  215. package/templates/web/src/api-contract/index.ts +14 -0
  216. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  217. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  218. package/templates/web/src/features/auth/sign-in-form.tsx +24 -14
  219. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  220. package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
  221. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  222. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  223. package/templates/web/src/features/oauth/consent-form.tsx +72 -35
  224. package/templates/web/src/main.tsx +6 -4
  225. package/templates/web/src/routeTree.gen.ts +73 -2
  226. package/templates/web/src/router.tsx +1 -2
  227. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  228. package/templates/web/src/routes/_authenticated.tsx +1 -1
  229. package/templates/web/src/routes/forgot-password.tsx +17 -0
  230. package/templates/web/src/routes/reset-password.tsx +20 -0
  231. package/templates/web/src/routes/sign-in.tsx +10 -3
  232. package/templates/web/src/routes/sign-up.tsx +4 -0
  233. package/templates/web/src/routes/verify-email.tsx +20 -0
  234. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  235. package/templates/web/test/app.spec.tsx +47 -22
  236. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  237. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  238. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  239. package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
  240. package/templates/web/test/support/render-app.tsx +4 -4
  241. package/templates/workspace/scripts/ports.mjs +1 -1
  242. package/dist/mcp.js +0 -100
  243. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  244. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  245. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  246. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  247. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  248. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  249. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  250. package/templates/mobile/src/features/auth/session.ts +0 -17
  251. package/templates/web/src/features/auth/auth-client.ts +0 -17
  252. package/templates/web/src/features/auth/session.ts +0 -17
@@ -2,6 +2,7 @@ import { Link, useRouter } from 'expo-router'
2
2
  import { Text, View } from 'react-native'
3
3
  import { SafeAreaView } from 'react-native-safe-area-context'
4
4
  import { SignUpForm } from '@/features/auth/sign-up-form'
5
+ import { SocialButtons } from '@/features/auth/social-buttons' // prumo:social
5
6
  import { useClients } from '@/features/clients/clients-context'
6
7
 
7
8
  export default function SignUpScreen() {
@@ -11,6 +12,9 @@ export default function SignUpScreen() {
11
12
  return (
12
13
  <SafeAreaView className="flex-1 justify-center gap-6 p-6">
13
14
  <Text className="text-2xl font-semibold text-neutral-900">Create an account</Text>
15
+ {/* prumo:social */}
16
+ <SocialButtons auth={auth} onSignedIn={() => router.replace('/')} />
17
+ {/* prumo:end-social */}
14
18
  <SignUpForm auth={auth} onSignedUp={() => router.replace('/')} />
15
19
  <View className="flex-row gap-1">
16
20
  <Text className="text-sm text-neutral-500">Already registered?</Text>
@@ -0,0 +1,18 @@
1
+ import { useLocalSearchParams, useRouter } from 'expo-router'
2
+ import { Text } from 'react-native'
3
+ import { SafeAreaView } from 'react-native-safe-area-context'
4
+ import { VerifyEmailForm } from '@/features/auth/verify-email-form'
5
+ import { useClients } from '@/features/clients/clients-context'
6
+
7
+ export default function VerifyEmailScreen() {
8
+ const { auth } = useClients()
9
+ const router = useRouter()
10
+ const { email } = useLocalSearchParams<{ email: string }>()
11
+
12
+ return (
13
+ <SafeAreaView className="flex-1 justify-center gap-6 p-6">
14
+ <Text className="text-2xl font-semibold text-neutral-900">Confirm your email</Text>
15
+ <VerifyEmailForm auth={auth} email={email} onVerified={() => router.replace('/')} />
16
+ </SafeAreaView>
17
+ )
18
+ }
@@ -4,18 +4,18 @@ import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client
4
4
  import { Stack } from 'expo-router'
5
5
  import * as SplashScreen from 'expo-splash-screen'
6
6
  import { useEffect, useState } from 'react'
7
- import { createClient } from '@/api-contract'
7
+ import { createAuthClient, createClient, sessionQuery } from '@/api-contract'
8
8
  import { config } from '@/config'
9
- import { createAuth, withSessionCookie } from '@/features/auth/auth-client'
10
- import { sessionQuery } from '@/features/auth/session'
9
+ import { secureTokenStore } from '@/features/auth/token-store'
11
10
  import { ClientsProvider, useClients } from '@/features/clients/clients-context'
12
11
  import { QUERY_CACHE_MAX_AGE, queryPersister } from '@/features/storage/query-persister'
13
12
 
14
13
  SplashScreen.preventAutoHideAsync()
15
14
 
16
15
  function createClients() {
17
- const auth = createAuth({ baseUrl: config.apiUrl, fetch })
18
- const api = createClient({ baseUrl: config.apiUrl, fetch: withSessionCookie(auth, fetch) })
16
+ const auth = createAuthClient({ baseUrl: config.apiUrl, fetch, tokens: secureTokenStore })
17
+ // Through the auth client, so every request carries the bearer and an expired one is refreshed and repeated.
18
+ const api = createClient({ baseUrl: config.apiUrl, fetch: auth.fetch })
19
19
 
20
20
  return { auth, api }
21
21
  }
@@ -0,0 +1,67 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { Controller, useForm } from 'react-hook-form'
3
+ import { View } from 'react-native'
4
+ import { z } from 'zod'
5
+ import type { AuthClient } from '@/api-contract'
6
+ import { Button } from '@/components/ui/button'
7
+ import { Field } from '@/components/ui/field'
8
+ import { FormError } from '@/features/forms/form-error'
9
+ import { applyServerError } from '@/features/forms/server-errors'
10
+
11
+ const schema = z.object({ email: z.email() })
12
+
13
+ type ForgotPasswordValues = z.infer<typeof schema>
14
+
15
+ export function ForgotPasswordForm({
16
+ auth,
17
+ onSent,
18
+ }: {
19
+ auth: Pick<AuthClient, 'requestPasswordReset'>
20
+ onSent: (email: string) => void
21
+ }) {
22
+ const form = useForm<ForgotPasswordValues>({
23
+ resolver: zodResolver(schema),
24
+ mode: 'onTouched',
25
+ defaultValues: { email: '' },
26
+ })
27
+ const { errors, isSubmitting } = form.formState
28
+
29
+ async function onSubmit(values: ForgotPasswordValues) {
30
+ try {
31
+ await auth.requestPasswordReset(values.email)
32
+ } catch (error) {
33
+ applyServerError(error, ['email'], form.setError)
34
+ return
35
+ }
36
+
37
+ // The API answers alike whether the account exists, so the next screen cannot tell either.
38
+ onSent(values.email)
39
+ }
40
+
41
+ return (
42
+ <View className="gap-6">
43
+ <FormError errors={errors} />
44
+ <Controller
45
+ control={form.control}
46
+ name="email"
47
+ render={({ field }) => (
48
+ <Field
49
+ label="Email"
50
+ autoCapitalize="none"
51
+ autoComplete="email"
52
+ keyboardType="email-address"
53
+ value={field.value}
54
+ onChangeText={field.onChange}
55
+ onBlur={field.onBlur}
56
+ error={errors.email?.message}
57
+ />
58
+ )}
59
+ />
60
+ <Button
61
+ label="Send a reset code"
62
+ disabled={isSubmitting}
63
+ onPress={form.handleSubmit(onSubmit)}
64
+ />
65
+ </View>
66
+ )
67
+ }
@@ -0,0 +1,93 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useQueryClient } from '@tanstack/react-query'
3
+ import { Controller, useForm } from 'react-hook-form'
4
+ import { Text, View } from 'react-native'
5
+ import { z } from 'zod'
6
+ import { type AuthClient, sessionQuery } 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
+
12
+ const schema = z.object({
13
+ code: z.string().regex(/^\d{6}$/, 'Enter the 6 digits from the email'),
14
+ password: z.string().min(8).max(128),
15
+ })
16
+
17
+ type ResetPasswordValues = z.infer<typeof schema>
18
+
19
+ export function ResetPasswordForm({
20
+ auth,
21
+ email,
22
+ onReset,
23
+ }: {
24
+ auth: Pick<AuthClient, 'resetPassword' | 'getSession'>
25
+ email: string
26
+ onReset: () => void
27
+ }) {
28
+ const queryClient = useQueryClient()
29
+ const form = useForm<ResetPasswordValues>({
30
+ resolver: zodResolver(schema),
31
+ mode: 'onTouched',
32
+ defaultValues: { code: '', password: '' },
33
+ })
34
+ const { errors, isSubmitting } = form.formState
35
+
36
+ async function onSubmit(values: ResetPasswordValues) {
37
+ try {
38
+ await auth.resetPassword({ email, ...values })
39
+ } catch (error) {
40
+ applyServerError(error, ['code', 'password'], form.setError)
41
+ return
42
+ }
43
+
44
+ // Every other session was ended; this one starts signed in.
45
+ await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
46
+ onReset()
47
+ }
48
+
49
+ return (
50
+ <View className="gap-6">
51
+ <Text className="text-sm text-neutral-500">
52
+ If {email} has an account, a 6-digit code is on its way to it.
53
+ </Text>
54
+ <FormError errors={errors} />
55
+ <Controller
56
+ control={form.control}
57
+ name="code"
58
+ render={({ field }) => (
59
+ <Field
60
+ label="Code"
61
+ keyboardType="number-pad"
62
+ autoComplete="one-time-code"
63
+ textContentType="oneTimeCode"
64
+ value={field.value}
65
+ onChangeText={field.onChange}
66
+ onBlur={field.onBlur}
67
+ error={errors.code?.message}
68
+ />
69
+ )}
70
+ />
71
+ <Controller
72
+ control={form.control}
73
+ name="password"
74
+ render={({ field }) => (
75
+ <Field
76
+ label="New password"
77
+ secureTextEntry
78
+ autoComplete="new-password"
79
+ value={field.value}
80
+ onChangeText={field.onChange}
81
+ onBlur={field.onBlur}
82
+ error={errors.password?.message}
83
+ />
84
+ )}
85
+ />
86
+ <Button
87
+ label="Set the new password"
88
+ disabled={isSubmitting}
89
+ onPress={form.handleSubmit(onSubmit)}
90
+ />
91
+ </View>
92
+ )
93
+ }
@@ -1,15 +1,14 @@
1
1
  import { zodResolver } from '@hookform/resolvers/zod'
2
2
  import { useQueryClient } from '@tanstack/react-query'
3
+ import { useRouter } from 'expo-router' // prumo:email
3
4
  import { Controller, useForm } from 'react-hook-form'
4
5
  import { View } from 'react-native'
5
6
  import { z } from 'zod'
6
- import { ApiError } from '@/api-contract'
7
+ import { type AuthClient, sessionQuery } from '@/api-contract'
7
8
  import { Button } from '@/components/ui/button'
8
9
  import { Field } from '@/components/ui/field'
9
10
  import { FormError } from '@/features/forms/form-error'
10
11
  import { applyServerError } from '@/features/forms/server-errors'
11
- import type { SessionAuth } from './auth-client'
12
- import { sessionQuery } from './session'
13
12
 
14
13
  const schema = z.object({
15
14
  email: z.email(),
@@ -18,8 +17,15 @@ const schema = z.object({
18
17
 
19
18
  type SignInValues = z.infer<typeof schema>
20
19
 
21
- export function SignInForm({ auth, onSignedIn }: { auth: SessionAuth; onSignedIn: () => void }) {
20
+ export function SignInForm({
21
+ auth,
22
+ onSignedIn,
23
+ }: {
24
+ auth: Pick<AuthClient, 'signIn' | 'getSession'>
25
+ onSignedIn: () => void
26
+ }) {
22
27
  const queryClient = useQueryClient()
28
+ const router = useRouter() // prumo:email
23
29
  const form = useForm<SignInValues>({
24
30
  resolver: zodResolver(schema),
25
31
  mode: 'onTouched',
@@ -29,10 +35,18 @@ export function SignInForm({ auth, onSignedIn }: { auth: SessionAuth; onSignedIn
29
35
  const { errors, isSubmitting } = form.formState
30
36
 
31
37
  async function onSubmit(values: SignInValues) {
32
- const { error } = await auth.signIn.email(values)
38
+ try {
39
+ await auth.signIn(values)
40
+ } catch (error) {
41
+ // prumo:email
42
+ // The password was right and the email is not confirmed yet: the code screen offers a new code.
43
+ if ((error as { code?: string }).code === 'email_not_verified') {
44
+ router.push({ pathname: '/verify-email', params: { email: values.email } })
45
+ return
46
+ }
33
47
 
34
- if (error !== null) {
35
- applyServerError(ApiError.fromAuthError(error), ['email', 'password'], form.setError)
48
+ // prumo:end-email
49
+ applyServerError(error, ['email', 'password'], form.setError)
36
50
  return
37
51
  }
38
52
 
@@ -1,7 +1,7 @@
1
1
  import { useQueryClient } from '@tanstack/react-query'
2
+ import type { AuthClient } from '@/api-contract'
2
3
  import { Button } from '@/components/ui/button'
3
4
  import { clearUserData } from '@/features/storage/storage'
4
- import type { AuthClient } from './auth-client'
5
5
 
6
6
  export type SignOutAuth = Pick<AuthClient, 'signOut'>
7
7
 
@@ -18,11 +18,11 @@ export function SignOutButton({
18
18
  try {
19
19
  await auth.signOut()
20
20
  } catch {
21
- // A failed request leaves nothing on the device to act on: the Expo plugin deletes the stored cookie before it
22
- // sends. The server's session outlives it until it expires, and signing out locally is still what was asked.
21
+ // A failed request leaves nothing on the device to act on: the auth client forgets the tokens either way. The
22
+ // server's session outlives them until it expires, and signing out locally is still what was asked.
23
23
  }
24
24
 
25
- // What the plugin cannot know is the rest of this user's data: the query cache and every user-scoped key, or the
25
+ // What the auth client cannot know is the rest of this user's data: the query cache and every user-scoped key, or the
26
26
  // next person on this device reads them.
27
27
  queryClient.clear()
28
28
  clearUserData()
@@ -1,26 +1,32 @@
1
1
  import { zodResolver } from '@hookform/resolvers/zod'
2
2
  import { useQueryClient } from '@tanstack/react-query'
3
+ import { useRouter } from 'expo-router' // prumo:email
3
4
  import { Controller, useForm } from 'react-hook-form'
4
5
  import { View } from 'react-native'
5
6
  import { z } from 'zod'
6
- import { ApiError } from '@/api-contract'
7
+ import { type AuthClient, sessionQuery } from '@/api-contract'
7
8
  import { Button } from '@/components/ui/button'
8
9
  import { Field } from '@/components/ui/field'
9
10
  import { FormError } from '@/features/forms/form-error'
10
11
  import { applyServerError } from '@/features/forms/server-errors'
11
- import type { SessionAuth } from './auth-client'
12
- import { sessionQuery } from './session'
13
12
 
14
13
  const schema = z.object({
15
14
  name: z.string().min(1).max(80),
16
15
  email: z.email(),
17
- password: z.string().min(8),
16
+ password: z.string().min(8).max(128),
18
17
  })
19
18
 
20
19
  type SignUpValues = z.infer<typeof schema>
21
20
 
22
- export function SignUpForm({ auth, onSignedUp }: { auth: SessionAuth; onSignedUp: () => void }) {
21
+ export function SignUpForm({
22
+ auth,
23
+ onSignedUp,
24
+ }: {
25
+ auth: Pick<AuthClient, 'signUp' | 'getSession'>
26
+ onSignedUp: () => void
27
+ }) {
23
28
  const queryClient = useQueryClient()
29
+ const router = useRouter() // prumo:email
24
30
  const form = useForm<SignUpValues>({
25
31
  resolver: zodResolver(schema),
26
32
  mode: 'onTouched',
@@ -30,13 +36,23 @@ export function SignUpForm({ auth, onSignedUp }: { auth: SessionAuth; onSignedUp
30
36
  const { errors, isSubmitting } = form.formState
31
37
 
32
38
  async function onSubmit(values: SignUpValues) {
33
- const { error } = await auth.signUp.email(values)
39
+ const result = await auth.signUp(values).catch((error: unknown) => {
40
+ applyServerError(error, ['name', 'email', 'password'], form.setError)
41
+ return undefined
42
+ })
34
43
 
35
- if (error !== null) {
36
- applyServerError(ApiError.fromAuthError(error), ['name', 'email', 'password'], form.setError)
44
+ if (result === undefined) {
37
45
  return
38
46
  }
39
47
 
48
+ // prumo:email
49
+ if (result.verificationRequired) {
50
+ router.push({ pathname: '/verify-email', params: { email: values.email } })
51
+ return
52
+ }
53
+
54
+ // prumo:end-email
55
+
40
56
  await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
41
57
  onSignedUp()
42
58
  }
@@ -0,0 +1,79 @@
1
+ import { useQueryClient } from '@tanstack/react-query'
2
+ import * as Linking from 'expo-linking'
3
+ import * as WebBrowser from 'expo-web-browser'
4
+ import { useState } from 'react'
5
+ import { Text, View } from 'react-native'
6
+ import { type AuthClient, type SocialProvider, sessionQuery } from '@/api-contract'
7
+ import { Button } from '@/components/ui/button'
8
+
9
+ // Read by hand: the return URL carries one parameter, and parsing it needs nothing from the platform.
10
+ function codeFrom(url: string): string | undefined {
11
+ for (const pair of (url.split('?')[1] ?? '').split('&')) {
12
+ const [key, value] = pair.split('=')
13
+
14
+ if (key === 'code' && value !== undefined && value !== '') {
15
+ return decodeURIComponent(value)
16
+ }
17
+ }
18
+
19
+ return undefined
20
+ }
21
+
22
+ // The system browser signs in at the provider and comes back to this app's scheme with a one-time code; the code,
23
+ // not a token, is what travels in the URL, and the app trades it for its tokens.
24
+ export function SocialButtons({
25
+ auth,
26
+ onSignedIn,
27
+ }: {
28
+ auth: Pick<AuthClient, 'socialSignInUrl' | 'exchangeSocialCode' | 'getSession'>
29
+ onSignedIn: () => void
30
+ }) {
31
+ const queryClient = useQueryClient()
32
+ const [failed, setFailed] = useState(false)
33
+
34
+ async function signInWith(provider: SocialProvider) {
35
+ setFailed(false)
36
+
37
+ const result = await WebBrowser.openAuthSessionAsync(
38
+ auth.socialSignInUrl(provider, 'mobile'),
39
+ Linking.createURL('social'),
40
+ )
41
+
42
+ // Closing the browser is a decision, not a failure.
43
+ if (result.type !== 'success') {
44
+ return
45
+ }
46
+
47
+ const code = codeFrom(result.url)
48
+
49
+ try {
50
+ if (code === undefined) {
51
+ throw new Error('The provider sign-in came back without a code')
52
+ }
53
+
54
+ await auth.exchangeSocialCode(code)
55
+ } catch {
56
+ setFailed(true)
57
+ return
58
+ }
59
+
60
+ await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
61
+ onSignedIn()
62
+ }
63
+
64
+ return (
65
+ <View className="gap-3">
66
+ {failed ? (
67
+ <Text accessibilityRole="alert" className="text-sm text-red-600">
68
+ The sign-in did not complete. Try again.
69
+ </Text>
70
+ ) : null}
71
+ {/* prumo:google */}
72
+ <Button label="Continue with Google" variant="outline" onPress={() => signInWith('google')} />
73
+ {/* prumo:end-google */}
74
+ {/* prumo:apple */}
75
+ <Button label="Continue with Apple" variant="outline" onPress={() => signInWith('apple')} />
76
+ {/* prumo:end-apple */}
77
+ </View>
78
+ )
79
+ }
@@ -0,0 +1,21 @@
1
+ import * as SecureStore from 'expo-secure-store'
2
+ import type { TokenStore, Tokens } from '@/api-contract'
3
+
4
+ const KEY = 'auth.tokens'
5
+
6
+ // The Keychain on iOS and the Keystore on Android: the tokens never sit in plain storage, MMKV included.
7
+ export const secureTokenStore: TokenStore = {
8
+ async read() {
9
+ const value = await SecureStore.getItemAsync(KEY)
10
+
11
+ return value === null ? null : (JSON.parse(value) as Tokens)
12
+ },
13
+
14
+ async write(tokens) {
15
+ if (tokens === null) {
16
+ await SecureStore.deleteItemAsync(KEY)
17
+ } else {
18
+ await SecureStore.setItemAsync(KEY, JSON.stringify(tokens))
19
+ }
20
+ },
21
+ }
@@ -0,0 +1,81 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useQueryClient } from '@tanstack/react-query'
3
+ import { useState } from 'react'
4
+ import { Controller, useForm } from 'react-hook-form'
5
+ import { Text, View } from 'react-native'
6
+ import { z } from 'zod'
7
+ import { type AuthClient, sessionQuery } from '@/api-contract'
8
+ import { Button } from '@/components/ui/button'
9
+ import { Field } from '@/components/ui/field'
10
+ import { FormError } from '@/features/forms/form-error'
11
+ import { applyServerError } from '@/features/forms/server-errors'
12
+
13
+ const schema = z.object({ code: z.string().regex(/^\d{6}$/, 'Enter the 6 digits from the email') })
14
+
15
+ type VerifyEmailValues = z.infer<typeof schema>
16
+
17
+ export function VerifyEmailForm({
18
+ auth,
19
+ email,
20
+ onVerified,
21
+ }: {
22
+ auth: Pick<AuthClient, 'verifyEmail' | 'requestEmailVerification' | 'getSession'>
23
+ email: string
24
+ onVerified: () => void
25
+ }) {
26
+ const queryClient = useQueryClient()
27
+ const [resent, setResent] = useState(false)
28
+ const form = useForm<VerifyEmailValues>({
29
+ resolver: zodResolver(schema),
30
+ mode: 'onTouched',
31
+ defaultValues: { code: '' },
32
+ })
33
+ const { errors, isSubmitting } = form.formState
34
+
35
+ async function onSubmit(values: VerifyEmailValues) {
36
+ try {
37
+ await auth.verifyEmail({ email, code: values.code })
38
+ } catch (error) {
39
+ applyServerError(error, ['code'], form.setError)
40
+ return
41
+ }
42
+
43
+ await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
44
+ onVerified()
45
+ }
46
+
47
+ async function resend() {
48
+ await auth.requestEmailVerification(email)
49
+ setResent(true)
50
+ }
51
+
52
+ return (
53
+ <View className="gap-6">
54
+ <Text className="text-sm text-neutral-500">We sent a 6-digit code to {email}.</Text>
55
+ <FormError errors={errors} />
56
+ <Controller
57
+ control={form.control}
58
+ name="code"
59
+ render={({ field }) => (
60
+ <Field
61
+ label="Code"
62
+ keyboardType="number-pad"
63
+ autoComplete="one-time-code"
64
+ textContentType="oneTimeCode"
65
+ value={field.value}
66
+ onChangeText={field.onChange}
67
+ onBlur={field.onBlur}
68
+ error={errors.code?.message}
69
+ />
70
+ )}
71
+ />
72
+ <Button label="Confirm email" disabled={isSubmitting} onPress={form.handleSubmit(onSubmit)} />
73
+ <Button label="Send a new code" variant="outline" onPress={resend} />
74
+ {resent ? (
75
+ <Text accessibilityRole="text" className="text-sm text-neutral-500">
76
+ A new code is on its way. The previous one no longer works.
77
+ </Text>
78
+ ) : null}
79
+ </View>
80
+ )
81
+ }
@@ -1,6 +1,5 @@
1
1
  import { createContext, type ReactNode, useContext } from 'react'
2
- import type { ApiClient } from '@/api-contract'
3
- import type { AuthClient } from '@/features/auth/auth-client'
2
+ import type { ApiClient, AuthClient } from '@/api-contract'
4
3
 
5
4
  type Clients = { api: ApiClient; auth: AuthClient }
6
5
 
@@ -1,26 +1,28 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
- import { createAuthClient } from 'better-auth/react'
3
+ import { memoryTokenStore } from '@test/support/memory-token-store'
4
4
  import { renderRouter, screen } from 'expo-router/testing-library'
5
5
  import type { ReactNode } from 'react'
6
6
  import { Text } from 'react-native'
7
- import { createClient } from '@/api-contract'
7
+ import { createAuthClient, createClient } from '@/api-contract'
8
8
  import AuthenticatedLayout from '@/app/(app)/_layout'
9
- import type { AuthClient } from '@/features/auth/auth-client'
10
9
  import { ClientsProvider } from '@/features/clients/clients-context'
11
10
 
12
- const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
11
+ const user = { id: 'user-1', email: 'ana@example.com' }
12
+
13
+ const TOKENS = { accessToken: 'access-1', refreshToken: 'refresh-1', expiresIn: 900 }
13
14
 
14
15
  async function renderAt(getSession: () => Response | Promise<Response>) {
15
- const transport = fakeTransport({ 'GET /api/auth/get-session': getSession })
16
- // jest-expo leaves the app manifest empty, and the Expo plugin needs it to build an origin; the layout depends only on
17
- // what getSession returns, which the plugin does not change.
16
+ const transport = fakeTransport({
17
+ 'GET /api/auth/session': getSession,
18
+ 'POST /api/auth/refresh': () => json(401, {}),
19
+ })
18
20
  const auth = createAuthClient({
19
- baseURL: API_URL,
20
- basePath: '/api/auth',
21
- fetchOptions: { customFetchImpl: transport },
22
- }) as unknown as AuthClient
23
- const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: transport }) }
21
+ baseUrl: API_URL,
22
+ fetch: transport,
23
+ tokens: memoryTokenStore(TOKENS),
24
+ })
25
+ const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: auth.fetch }) }
24
26
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
25
27
 
26
28
  function Providers({ children }: { children: ReactNode }) {
@@ -46,7 +48,7 @@ describe('AuthenticatedLayout', () => {
46
48
  it('waits for a session still on its way instead of sending a signed-in visitor to sign in', async () => {
47
49
  await renderAt(async () => {
48
50
  await new Promise((resolve) => setTimeout(resolve, 50))
49
- return json(200, { session: { id: 'session-1', userId: user.id }, user })
51
+ return json(200, { user })
50
52
  })
51
53
 
52
54
  expect(await screen.findByText('Your profile')).toBeOnTheScreen()
@@ -54,7 +56,7 @@ describe('AuthenticatedLayout', () => {
54
56
  })
55
57
 
56
58
  it('sends a visitor without a session to sign in', async () => {
57
- await renderAt(() => json(200, null))
59
+ await renderAt(() => json(401, {}))
58
60
 
59
61
  expect(await screen.findByText('Sign in')).toBeOnTheScreen()
60
62
  })