@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
@@ -10,8 +10,11 @@
10
10
 
11
11
  import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
13
+ import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
14
+ import { Route as ResetPasswordRouteImport } from './routes/reset-password'
13
15
  import { Route as SignInRouteImport } from './routes/sign-in'
14
16
  import { Route as SignUpRouteImport } from './routes/sign-up'
17
+ import { Route as VerifyEmailRouteImport } from './routes/verify-email'
15
18
  import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
16
19
  import { Route as AuthenticatedConsentRouteImport } from './routes/_authenticated/consent'
17
20
 
@@ -19,6 +22,16 @@ const AuthenticatedRoute = AuthenticatedRouteImport.update({
19
22
  id: '/_authenticated',
20
23
  getParentRoute: () => rootRouteImport,
21
24
  } as any)
25
+ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
26
+ id: '/forgot-password',
27
+ path: '/forgot-password',
28
+ getParentRoute: () => rootRouteImport,
29
+ } as any)
30
+ const ResetPasswordRoute = ResetPasswordRouteImport.update({
31
+ id: '/reset-password',
32
+ path: '/reset-password',
33
+ getParentRoute: () => rootRouteImport,
34
+ } as any)
22
35
  const SignInRoute = SignInRouteImport.update({
23
36
  id: '/sign-in',
24
37
  path: '/sign-in',
@@ -29,6 +42,11 @@ const SignUpRoute = SignUpRouteImport.update({
29
42
  path: '/sign-up',
30
43
  getParentRoute: () => rootRouteImport,
31
44
  } as any)
45
+ const VerifyEmailRoute = VerifyEmailRouteImport.update({
46
+ id: '/verify-email',
47
+ path: '/verify-email',
48
+ getParentRoute: () => rootRouteImport,
49
+ } as any)
32
50
  const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
33
51
  id: '/',
34
52
  path: '/',
@@ -42,42 +60,71 @@ const AuthenticatedConsentRoute = AuthenticatedConsentRouteImport.update({
42
60
 
43
61
  export interface FileRoutesByFullPath {
44
62
  '/': typeof AuthenticatedIndexRoute
63
+ '/forgot-password': typeof ForgotPasswordRoute
64
+ '/reset-password': typeof ResetPasswordRoute
45
65
  '/sign-in': typeof SignInRoute
46
66
  '/sign-up': typeof SignUpRoute
67
+ '/verify-email': typeof VerifyEmailRoute
47
68
  '/consent': typeof AuthenticatedConsentRoute
48
69
  }
49
70
  export interface FileRoutesByTo {
71
+ '/forgot-password': typeof ForgotPasswordRoute
72
+ '/reset-password': typeof ResetPasswordRoute
50
73
  '/sign-in': typeof SignInRoute
51
74
  '/sign-up': typeof SignUpRoute
75
+ '/verify-email': typeof VerifyEmailRoute
52
76
  '/consent': typeof AuthenticatedConsentRoute
53
77
  '/': typeof AuthenticatedIndexRoute
54
78
  }
55
79
  export interface FileRoutesById {
56
80
  __root__: typeof rootRouteImport
57
81
  '/_authenticated': typeof AuthenticatedRouteWithChildren
82
+ '/forgot-password': typeof ForgotPasswordRoute
83
+ '/reset-password': typeof ResetPasswordRoute
58
84
  '/sign-in': typeof SignInRoute
59
85
  '/sign-up': typeof SignUpRoute
86
+ '/verify-email': typeof VerifyEmailRoute
60
87
  '/_authenticated/consent': typeof AuthenticatedConsentRoute
61
88
  '/_authenticated/': typeof AuthenticatedIndexRoute
62
89
  }
63
90
  export interface FileRouteTypes {
64
91
  fileRoutesByFullPath: FileRoutesByFullPath
65
- fullPaths: '/' | '/sign-in' | '/sign-up' | '/consent'
92
+ fullPaths:
93
+ | '/'
94
+ | '/forgot-password'
95
+ | '/reset-password'
96
+ | '/sign-in'
97
+ | '/sign-up'
98
+ | '/verify-email'
99
+ | '/consent'
66
100
  fileRoutesByTo: FileRoutesByTo
67
- to: '/sign-in' | '/sign-up' | '/consent' | '/'
101
+ to:
102
+ | '/forgot-password'
103
+ | '/reset-password'
104
+ | '/sign-in'
105
+ | '/sign-up'
106
+ | '/verify-email'
107
+ | '/consent'
108
+ | '/'
68
109
  id:
69
110
  | '__root__'
70
111
  | '/_authenticated'
112
+ | '/forgot-password'
113
+ | '/reset-password'
71
114
  | '/sign-in'
72
115
  | '/sign-up'
116
+ | '/verify-email'
73
117
  | '/_authenticated/consent'
74
118
  | '/_authenticated/'
75
119
  fileRoutesById: FileRoutesById
76
120
  }
77
121
  export interface RootRouteChildren {
78
122
  AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
123
+ ForgotPasswordRoute: typeof ForgotPasswordRoute
124
+ ResetPasswordRoute: typeof ResetPasswordRoute
79
125
  SignInRoute: typeof SignInRoute
80
126
  SignUpRoute: typeof SignUpRoute
127
+ VerifyEmailRoute: typeof VerifyEmailRoute
81
128
  }
82
129
 
83
130
  declare module '@tanstack/react-router' {
@@ -89,6 +136,20 @@ declare module '@tanstack/react-router' {
89
136
  preLoaderRoute: typeof AuthenticatedRouteImport
90
137
  parentRoute: typeof rootRouteImport
91
138
  }
139
+ '/forgot-password': {
140
+ id: '/forgot-password'
141
+ path: '/forgot-password'
142
+ fullPath: '/forgot-password'
143
+ preLoaderRoute: typeof ForgotPasswordRouteImport
144
+ parentRoute: typeof rootRouteImport
145
+ }
146
+ '/reset-password': {
147
+ id: '/reset-password'
148
+ path: '/reset-password'
149
+ fullPath: '/reset-password'
150
+ preLoaderRoute: typeof ResetPasswordRouteImport
151
+ parentRoute: typeof rootRouteImport
152
+ }
92
153
  '/sign-in': {
93
154
  id: '/sign-in'
94
155
  path: '/sign-in'
@@ -103,6 +164,13 @@ declare module '@tanstack/react-router' {
103
164
  preLoaderRoute: typeof SignUpRouteImport
104
165
  parentRoute: typeof rootRouteImport
105
166
  }
167
+ '/verify-email': {
168
+ id: '/verify-email'
169
+ path: '/verify-email'
170
+ fullPath: '/verify-email'
171
+ preLoaderRoute: typeof VerifyEmailRouteImport
172
+ parentRoute: typeof rootRouteImport
173
+ }
106
174
  '/_authenticated/': {
107
175
  id: '/_authenticated/'
108
176
  path: '/'
@@ -136,8 +204,11 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
136
204
 
137
205
  const rootRouteChildren: RootRouteChildren = {
138
206
  AuthenticatedRoute: AuthenticatedRouteWithChildren,
207
+ ForgotPasswordRoute: ForgotPasswordRoute,
208
+ ResetPasswordRoute: ResetPasswordRoute,
139
209
  SignInRoute: SignInRoute,
140
210
  SignUpRoute: SignUpRoute,
211
+ VerifyEmailRoute: VerifyEmailRoute,
141
212
  }
142
213
  export const routeTree = rootRouteImport
143
214
  ._addFileChildren(rootRouteChildren)
@@ -1,7 +1,6 @@
1
1
  import type { QueryClient } from '@tanstack/react-query'
2
2
  import { createRouter, type RouterHistory } from '@tanstack/react-router'
3
- import type { ApiClient } from '@/api-contract'
4
- import type { AuthClient } from '@/features/auth/auth-client'
3
+ import type { ApiClient, AuthClient } from '@/api-contract'
5
4
  import { routeTree } from './routeTree.gen'
6
5
 
7
6
  export type RouterContext = {
@@ -2,20 +2,21 @@ import { createFileRoute } from '@tanstack/react-router'
2
2
  import { z } from 'zod'
3
3
  import { ConsentForm } from '@/features/oauth/consent-form'
4
4
 
5
- // Loose, because every parameter Better Auth signed has to stay in the URL for the consent request.
5
+ // The API sends the browser here with the id of the authorization an MCP client asked for. Signing in first, when
6
+ // needed, comes back to this same address.
6
7
  export const Route = createFileRoute('/_authenticated/consent')({
7
- validateSearch: z.looseObject({ client_id: z.string(), scope: z.string().default('') }),
8
+ validateSearch: z.object({ request: z.uuid() }),
8
9
  component: ConsentPage,
9
10
  })
10
11
 
11
12
  function ConsentPage() {
12
13
  const { auth } = Route.useRouteContext()
13
- const { client_id, scope } = Route.useSearch()
14
+ const { request } = Route.useSearch()
14
15
 
15
16
  return (
16
17
  <>
17
18
  <h1 className="text-2xl font-semibold">Allow access</h1>
18
- <ConsentForm auth={auth} clientId={client_id} scopes={scope.split(' ').filter(Boolean)} />
19
+ <ConsentForm auth={auth} requestId={request} leave={(url) => window.location.assign(url)} />
19
20
  </>
20
21
  )
21
22
  }
@@ -1,5 +1,5 @@
1
1
  import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
2
- import { sessionQuery } from '@/features/auth/session'
2
+ import { sessionQuery } from '@/api-contract'
3
3
 
4
4
  export const Route = createFileRoute('/_authenticated')({
5
5
  beforeLoad: async ({ context, location }) => {
@@ -0,0 +1,17 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { ForgotPasswordForm } from '@/features/auth/forgot-password-form'
3
+
4
+ export const Route = createFileRoute('/forgot-password')({
5
+ component: ForgotPasswordPage,
6
+ })
7
+
8
+ function ForgotPasswordPage() {
9
+ const { auth } = Route.useRouteContext()
10
+
11
+ return (
12
+ <>
13
+ <h1 className="text-2xl font-semibold">Reset your password</h1>
14
+ <ForgotPasswordForm auth={auth} />
15
+ </>
16
+ )
17
+ }
@@ -0,0 +1,20 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { z } from 'zod'
3
+ import { ResetPasswordForm } from '@/features/auth/reset-password-form'
4
+
5
+ export const Route = createFileRoute('/reset-password')({
6
+ validateSearch: z.object({ email: z.email() }),
7
+ component: ResetPasswordPage,
8
+ })
9
+
10
+ function ResetPasswordPage() {
11
+ const { auth } = Route.useRouteContext()
12
+ const { email } = Route.useSearch()
13
+
14
+ return (
15
+ <>
16
+ <h1 className="text-2xl font-semibold">Choose a new password</h1>
17
+ <ResetPasswordForm auth={auth} email={email} />
18
+ </>
19
+ )
20
+ }
@@ -1,20 +1,27 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
2
  import { z } from 'zod'
3
3
  import { SignInForm } from '@/features/auth/sign-in-form'
4
+ import { SocialButtons } from '@/features/auth/social-buttons' // prumo:social
4
5
 
5
6
  export const Route = createFileRoute('/sign-in')({
6
- validateSearch: z.object({ redirect: z.string().optional() }),
7
+ validateSearch: z.object({
8
+ redirect: z.string().optional(),
9
+ error: z.string().optional(), // prumo:social
10
+ }),
7
11
  component: SignInPage,
8
12
  })
9
13
 
10
14
  function SignInPage() {
11
15
  const { auth } = Route.useRouteContext()
12
- const { redirect } = Route.useSearch()
16
+ const search = Route.useSearch()
13
17
 
14
18
  return (
15
19
  <>
16
20
  <h1 className="text-2xl font-semibold">Sign in</h1>
17
- <SignInForm auth={auth} redirect={redirect} />
21
+ {/* prumo:social */}
22
+ <SocialButtons auth={auth} returnTo={search.redirect} error={search.error} />
23
+ {/* prumo:end-social */}
24
+ <SignInForm auth={auth} redirect={search.redirect} />
18
25
  </>
19
26
  )
20
27
  }
@@ -1,5 +1,6 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
2
  import { SignUpForm } from '@/features/auth/sign-up-form'
3
+ import { SocialButtons } from '@/features/auth/social-buttons' // prumo:social
3
4
 
4
5
  export const Route = createFileRoute('/sign-up')({
5
6
  component: SignUpPage,
@@ -11,6 +12,9 @@ function SignUpPage() {
11
12
  return (
12
13
  <>
13
14
  <h1 className="text-2xl font-semibold">Create an account</h1>
15
+ {/* prumo:social */}
16
+ <SocialButtons auth={auth} returnTo={undefined} error={undefined} />
17
+ {/* prumo:end-social */}
14
18
  <SignUpForm auth={auth} />
15
19
  </>
16
20
  )
@@ -0,0 +1,20 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { z } from 'zod'
3
+ import { VerifyEmailForm } from '@/features/auth/verify-email-form'
4
+
5
+ export const Route = createFileRoute('/verify-email')({
6
+ validateSearch: z.object({ email: z.email() }),
7
+ component: VerifyEmailPage,
8
+ })
9
+
10
+ function VerifyEmailPage() {
11
+ const { auth } = Route.useRouteContext()
12
+ const { email } = Route.useSearch()
13
+
14
+ return (
15
+ <>
16
+ <h1 className="text-2xl font-semibold">Confirm your email</h1>
17
+ <VerifyEmailForm auth={auth} email={email} />
18
+ </>
19
+ )
20
+ }
@@ -0,0 +1,155 @@
1
+ import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createAuthClient, type TokenStore, type Tokens } from '@/api-contract'
4
+
5
+ const TOKENS: Tokens = { accessToken: 'access-1', refreshToken: 'refresh-1', expiresIn: 900 }
6
+
7
+ function memoryStore(initial: Tokens | null = null): TokenStore & { current: Tokens | null } {
8
+ return {
9
+ current: initial,
10
+ async read() {
11
+ return this.current
12
+ },
13
+ async write(tokens) {
14
+ this.current = tokens
15
+ },
16
+ }
17
+ }
18
+
19
+ describe('createAuthClient on the web', () => {
20
+ it('refreshes once on a 401, repeats the request, and asks nothing of the body', async () => {
21
+ const calls: string[] = []
22
+ let valid = false
23
+ const auth = createAuthClient({
24
+ baseUrl: API_URL,
25
+ fetch: fakeTransport({
26
+ 'GET /api/v1/things': () => {
27
+ calls.push('things')
28
+ return valid ? json(200, []) : json(401, {})
29
+ },
30
+ 'POST /api/auth/refresh': (request) => {
31
+ calls.push(`refresh:${request.headers.get('x-auth-transport')}`)
32
+ valid = true
33
+ return new Response(null, { status: 204 })
34
+ },
35
+ }),
36
+ })
37
+
38
+ const response = await auth.fetch(`${API_URL}/api/v1/things`)
39
+
40
+ expect(response.status).toBe(200)
41
+ expect(calls).toEqual(['things', 'refresh:null', 'things'])
42
+ })
43
+
44
+ it('makes one refresh for every request refused at once', async () => {
45
+ let refreshes = 0
46
+ let valid = false
47
+ const auth = createAuthClient({
48
+ baseUrl: API_URL,
49
+ fetch: fakeTransport({
50
+ 'GET /api/v1/things': () => (valid ? json(200, []) : json(401, {})),
51
+ 'POST /api/auth/refresh': async () => {
52
+ refreshes += 1
53
+ await new Promise((resolve) => setTimeout(resolve, 10))
54
+ valid = true
55
+ return new Response(null, { status: 204 })
56
+ },
57
+ }),
58
+ })
59
+
60
+ const responses = await Promise.all([1, 2, 3].map(() => auth.fetch(`${API_URL}/api/v1/things`)))
61
+
62
+ expect(responses.map((response) => response.status)).toEqual([200, 200, 200])
63
+ expect(refreshes).toBe(1)
64
+ })
65
+
66
+ it('repeats the request when another tab rotated the token first', async () => {
67
+ let valid = false
68
+ const auth = createAuthClient({
69
+ baseUrl: API_URL,
70
+ fetch: fakeTransport({
71
+ 'GET /api/v1/things': () => {
72
+ const response = valid ? json(200, []) : json(401, {})
73
+ valid = true
74
+ return response
75
+ },
76
+ 'POST /api/auth/refresh': () => json(409, {}),
77
+ }),
78
+ })
79
+
80
+ expect((await auth.fetch(`${API_URL}/api/v1/things`)).status).toBe(200)
81
+ })
82
+
83
+ it('answers a visitor without a session with null', async () => {
84
+ const auth = createAuthClient({
85
+ baseUrl: API_URL,
86
+ fetch: fakeTransport({
87
+ 'GET /api/auth/session': () => json(401, {}),
88
+ 'POST /api/auth/refresh': () => json(401, {}),
89
+ }),
90
+ })
91
+
92
+ expect(await auth.getSession()).toBeNull()
93
+ })
94
+ })
95
+
96
+ describe('createAuthClient for a native app', () => {
97
+ it('keeps the tokens it signs in with, and sends them as a bearer', async () => {
98
+ const tokens = memoryStore()
99
+ let authorization: string | null = null
100
+ const auth = createAuthClient({
101
+ baseUrl: API_URL,
102
+ tokens,
103
+ fetch: fakeTransport({
104
+ 'POST /api/auth/sign-in': (request) =>
105
+ request.headers.get('x-auth-transport') === 'bearer' ? json(200, TOKENS) : json(400, {}),
106
+ 'GET /api/auth/session': (request) => {
107
+ authorization = request.headers.get('authorization')
108
+ return json(200, { user: { id: 'user-1', email: 'ana@example.com' } })
109
+ },
110
+ }),
111
+ })
112
+
113
+ await auth.signIn({ email: 'ana@example.com', password: 'correct-horse-battery' })
114
+ await auth.getSession()
115
+
116
+ expect(tokens.current).toEqual(TOKENS)
117
+ expect(authorization).toBe('Bearer access-1')
118
+ })
119
+
120
+ it('refreshes with the stored token, and forgets both when that fails', async () => {
121
+ const tokens = memoryStore(TOKENS)
122
+ let sent: unknown
123
+ const auth = createAuthClient({
124
+ baseUrl: API_URL,
125
+ tokens,
126
+ fetch: fakeTransport({
127
+ 'GET /api/auth/session': () => json(401, {}),
128
+ 'POST /api/auth/refresh': async (request) => {
129
+ sent = await request.json()
130
+ return json(401, {})
131
+ },
132
+ }),
133
+ })
134
+
135
+ expect(await auth.getSession()).toBeNull()
136
+ expect(sent).toEqual({ refreshToken: 'refresh-1' })
137
+ expect(tokens.current).toBeNull()
138
+ })
139
+
140
+ it('forgets the tokens on sign-out even when the request never arrives', async () => {
141
+ const tokens = memoryStore(TOKENS)
142
+ const auth = createAuthClient({
143
+ baseUrl: API_URL,
144
+ tokens,
145
+ fetch: fakeTransport({
146
+ 'POST /api/auth/sign-out': () => {
147
+ throw new TypeError('Network request failed')
148
+ },
149
+ }),
150
+ })
151
+
152
+ await expect(auth.signOut()).rejects.toThrow('Network request failed')
153
+ expect(tokens.current).toBeNull()
154
+ })
155
+ })
@@ -5,9 +5,17 @@ import userEvent from '@testing-library/user-event'
5
5
  import { describe, expect, it } from 'vitest'
6
6
  import type { Profile } from '@/api-contract'
7
7
 
8
- const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
8
+ const user = { id: 'user-1', email: 'ana@example.com' }
9
9
 
10
- const session = { session: { id: 'session-1', userId: user.id }, user }
10
+ const session = { user }
11
+
12
+ const empty = (status: number) => new Response(null, { status })
13
+
14
+ // What the API answers a visitor without cookies: no session, and nothing to refresh it with.
15
+ const signedOut = {
16
+ 'GET /api/auth/session': () => json(401, {}),
17
+ 'POST /api/auth/refresh': () => json(401, {}),
18
+ }
11
19
 
12
20
  const profile: Profile = {
13
21
  id: 'profile-1',
@@ -21,7 +29,7 @@ const profile: Profile = {
21
29
 
22
30
  describe('App', () => {
23
31
  it('sends a visitor without a session to sign in', async () => {
24
- renderApp('/', fakeTransport({ 'GET /api/auth/get-session': () => json(200, null) }))
32
+ renderApp('/', fakeTransport(signedOut))
25
33
 
26
34
  expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
27
35
  })
@@ -35,10 +43,11 @@ describe('App', () => {
35
43
  renderApp(
36
44
  '/',
37
45
  fakeTransport({
38
- 'GET /api/auth/get-session': () => json(200, signedIn ? session : null),
39
- 'POST /api/auth/sign-in/email': () => {
46
+ ...signedOut,
47
+ 'GET /api/auth/session': () => (signedIn ? json(200, session) : json(401, {})),
48
+ 'POST /api/auth/sign-in': () => {
40
49
  signedIn = true
41
- return json(200, { redirect: false, token: 'token-1', user })
50
+ return empty(204)
42
51
  },
43
52
  'GET /api/v1/users/me': () => json(200, profile),
44
53
  }),
@@ -59,17 +68,18 @@ describe('App', () => {
59
68
  renderApp(
60
69
  '/',
61
70
  fakeTransport({
62
- 'GET /api/auth/get-session': () => json(200, registered ? session : null),
63
- 'POST /api/auth/sign-up/email': () => {
71
+ ...signedOut,
72
+ 'GET /api/auth/session': () => (registered ? json(200, session) : json(401, {})),
73
+ 'POST /api/auth/sign-up': () => {
64
74
  registered = true
65
- return json(200, { token: 'token-1', user })
75
+ return empty(201)
66
76
  },
67
77
  'GET /api/v1/users/me': () => json(200, profile),
68
78
  }),
69
79
  )
70
80
 
71
81
  await visitor.click(await screen.findByRole('link', { name: 'Sign up' }))
72
- await visitor.type(await screen.findByLabelText('Name'), user.name)
82
+ await visitor.type(await screen.findByLabelText('Name'), 'Ana')
73
83
  await visitor.type(screen.getByLabelText('Email'), user.email)
74
84
  await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
75
85
  await visitor.click(screen.getByRole('button', { name: 'Create account' }))
@@ -82,7 +92,7 @@ describe('App', () => {
82
92
  const people = {
83
93
  ana: { user, profile },
84
94
  bruno: {
85
- user: { id: 'user-2', email: 'bruno@example.com', name: 'Bruno' },
95
+ user: { id: 'user-2', email: 'bruno@example.com' },
86
96
  profile: { ...profile, id: 'profile-2', userId: 'user-2', displayName: 'Bruno' },
87
97
  },
88
98
  }
@@ -91,20 +101,16 @@ describe('App', () => {
91
101
  renderApp(
92
102
  '/',
93
103
  fakeTransport({
94
- 'GET /api/auth/get-session': () =>
95
- json(
96
- 200,
97
- current === null
98
- ? null
99
- : { session: { id: `session-${current}` }, user: people[current].user },
100
- ),
104
+ ...signedOut,
105
+ 'GET /api/auth/session': () =>
106
+ current === null ? json(401, {}) : json(200, { user: people[current].user }),
101
107
  'POST /api/auth/sign-out': () => {
102
108
  current = null
103
- return json(200, { success: true })
109
+ return empty(204)
104
110
  },
105
- 'POST /api/auth/sign-in/email': () => {
111
+ 'POST /api/auth/sign-in': () => {
106
112
  current = 'bruno'
107
- return json(200, { redirect: false, token: 'token-2', user: people.bruno.user })
113
+ return empty(204)
108
114
  },
109
115
  'GET /api/v1/users/me': () => json(200, current === null ? null : people[current].profile),
110
116
  }),
@@ -121,13 +127,32 @@ describe('App', () => {
121
127
  expect(screen.getByLabelText('Display name')).toHaveValue('Bruno')
122
128
  })
123
129
 
130
+ // Fifteen minutes in, the access cookie has expired: the next request is refused once, refreshed, and repeated.
131
+ it('keeps a visitor signed in across an expired access token', async () => {
132
+ let refreshed = false
133
+
134
+ renderApp(
135
+ '/',
136
+ fakeTransport({
137
+ 'GET /api/auth/session': () => (refreshed ? json(200, session) : json(401, {})),
138
+ 'POST /api/auth/refresh': () => {
139
+ refreshed = true
140
+ return empty(204)
141
+ },
142
+ 'GET /api/v1/users/me': () => json(200, profile),
143
+ }),
144
+ )
145
+
146
+ expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
147
+ })
148
+
124
149
  it('signs a visitor out of this tab even when the sign-out request never arrives', async () => {
125
150
  const visitor = userEvent.setup()
126
151
 
127
152
  renderApp(
128
153
  '/',
129
154
  fakeTransport({
130
- 'GET /api/auth/get-session': () => json(200, session),
155
+ 'GET /api/auth/session': () => json(200, session),
131
156
  'GET /api/v1/users/me': () => json(200, profile),
132
157
  'POST /api/auth/sign-out': () => {
133
158
  throw new TypeError('Failed to fetch')