@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
@@ -0,0 +1,82 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useQueryClient } from '@tanstack/react-query'
3
+ import { useNavigate } from '@tanstack/react-router'
4
+ import { useForm } from 'react-hook-form'
5
+ import { z } from 'zod'
6
+ import { type AuthClient, sessionQuery } from '@/api-contract'
7
+ import { Button } from '@/components/ui/button'
8
+ import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
9
+ import { Input } from '@/components/ui/input'
10
+ import { FormError } from '@/features/forms/form-error'
11
+ import { applyServerError } from '@/features/forms/server-errors'
12
+
13
+ const schema = z.object({
14
+ code: z.string().regex(/^\d{6}$/, 'Enter the 6 digits from the email'),
15
+ password: z.string().min(8).max(128),
16
+ })
17
+
18
+ type ResetPasswordValues = z.infer<typeof schema>
19
+
20
+ export function ResetPasswordForm({
21
+ auth,
22
+ email,
23
+ }: {
24
+ auth: Pick<AuthClient, 'resetPassword' | 'getSession'>
25
+ email: string
26
+ }) {
27
+ const queryClient = useQueryClient()
28
+ const navigate = useNavigate()
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.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
46
+ await navigate({ to: '/' })
47
+ }
48
+
49
+ return (
50
+ <form onSubmit={form.handleSubmit(onSubmit)} noValidate className="flex flex-col gap-6">
51
+ <p className="text-sm text-muted-foreground">
52
+ If <strong>{email}</strong> has an account, a 6-digit code is on its way to it.
53
+ </p>
54
+ <FormError errors={errors} />
55
+ <FieldGroup>
56
+ <Field data-invalid={errors.code !== undefined}>
57
+ <FieldLabel htmlFor="code">Code</FieldLabel>
58
+ <Input
59
+ id="code"
60
+ inputMode="numeric"
61
+ autoComplete="one-time-code"
62
+ {...form.register('code')}
63
+ />
64
+ <FieldError errors={[errors.code]} />
65
+ </Field>
66
+ <Field data-invalid={errors.password !== undefined}>
67
+ <FieldLabel htmlFor="password">New password</FieldLabel>
68
+ <Input
69
+ id="password"
70
+ type="password"
71
+ autoComplete="new-password"
72
+ {...form.register('password')}
73
+ />
74
+ <FieldError errors={[errors.password]} />
75
+ </Field>
76
+ </FieldGroup>
77
+ <Button type="submit" disabled={isSubmitting}>
78
+ Set the new password
79
+ </Button>
80
+ </form>
81
+ )
82
+ }
@@ -3,14 +3,12 @@ import { useQueryClient } from '@tanstack/react-query'
3
3
  import { Link, useNavigate } from '@tanstack/react-router'
4
4
  import { useForm } from 'react-hook-form'
5
5
  import { z } from 'zod'
6
- import { ApiError } from '@/api-contract'
6
+ import { type AuthClient, sessionQuery } from '@/api-contract'
7
7
  import { Button } from '@/components/ui/button'
8
8
  import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
9
9
  import { Input } from '@/components/ui/input'
10
10
  import { FormError } from '@/features/forms/form-error'
11
11
  import { applyServerError } from '@/features/forms/server-errors'
12
- import type { AuthClient } from './auth-client'
13
- import { sessionQuery } from './session'
14
12
 
15
13
  const schema = z.object({
16
14
  email: z.email(),
@@ -19,7 +17,13 @@ const schema = z.object({
19
17
 
20
18
  type SignInValues = z.infer<typeof schema>
21
19
 
22
- export function SignInForm({ auth, redirect }: { auth: AuthClient; redirect: string | undefined }) {
20
+ export function SignInForm({
21
+ auth,
22
+ redirect,
23
+ }: {
24
+ auth: Pick<AuthClient, 'signIn' | 'getSession'>
25
+ redirect: string | undefined
26
+ }) {
23
27
  const queryClient = useQueryClient()
24
28
  const navigate = useNavigate()
25
29
  const form = useForm<SignInValues>({
@@ -31,20 +35,21 @@ export function SignInForm({ auth, redirect }: { auth: AuthClient; redirect: str
31
35
  const { errors, isSubmitting } = form.formState
32
36
 
33
37
  async function onSubmit(values: SignInValues) {
34
- const result = 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 page offers a new code.
43
+ if ((error as { code?: string }).code === 'email_not_verified') {
44
+ await navigate({ to: '/verify-email', search: { email: values.email } })
45
+ return
46
+ }
35
47
 
36
- if (result.error !== null) {
37
- applyServerError(ApiError.fromAuthError(result.error), ['email', 'password'], form.setError)
48
+ // prumo:end-email
49
+ applyServerError(error, ['email', 'password'], form.setError)
38
50
  return
39
51
  }
40
52
 
41
- // prumo:mcp
42
- // Signing in during an MCP client's authorization: Better Auth has already sent the browser on.
43
- if (result.data.redirect) {
44
- return
45
- }
46
-
47
- // prumo:end-mcp
48
53
  // Invalidating is not enough: nothing observes the session, and the protected route's ensureQueryData
49
54
  // would keep returning the cached null. Refetching replaces it before the route reads it.
50
55
  await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
@@ -77,6 +82,11 @@ export function SignInForm({ auth, redirect }: { auth: AuthClient; redirect: str
77
82
  <p className="text-sm text-muted-foreground">
78
83
  No account? <Link to="/sign-up">Sign up</Link>
79
84
  </p>
85
+ {/* prumo:email */}
86
+ <p className="text-sm text-muted-foreground">
87
+ <Link to="/forgot-password">Forgot your password?</Link>
88
+ </p>
89
+ {/* prumo:end-email */}
80
90
  </form>
81
91
  )
82
92
  }
@@ -1,9 +1,9 @@
1
1
  import { useQueryClient } from '@tanstack/react-query'
2
2
  import { useNavigate } from '@tanstack/react-router'
3
+ import type { AuthClient } from '@/api-contract'
3
4
  import { Button } from '@/components/ui/button'
4
- import type { AuthClient } from './auth-client'
5
5
 
6
- export function SignOutButton({ auth }: { auth: AuthClient }) {
6
+ export function SignOutButton({ auth }: { auth: Pick<AuthClient, 'signOut'> }) {
7
7
  const queryClient = useQueryClient()
8
8
  const navigate = useNavigate()
9
9
 
@@ -11,8 +11,8 @@ export function SignOutButton({ auth }: { auth: AuthClient }) {
11
11
  try {
12
12
  await auth.signOut()
13
13
  } catch {
14
- // Better Auth throws when the request never arrives. The cache is cleared regardless, because data left behind
15
- // for the next person on this tab is the worse error; the cookie survives until the server's session expires.
14
+ // The request may never arrive. The cache is cleared regardless, because data left behind for the next person
15
+ // on this tab is the worse error; the cookies then survive until the access token expires and the refresh fails.
16
16
  }
17
17
 
18
18
  // The whole cache, not only the session: a query about "me" belongs to whoever was signed in.
@@ -3,24 +3,22 @@ import { useQueryClient } from '@tanstack/react-query'
3
3
  import { Link, useNavigate } from '@tanstack/react-router'
4
4
  import { useForm } from 'react-hook-form'
5
5
  import { z } from 'zod'
6
- import { ApiError } from '@/api-contract'
6
+ import { type AuthClient, sessionQuery } from '@/api-contract'
7
7
  import { Button } from '@/components/ui/button'
8
8
  import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
9
9
  import { Input } from '@/components/ui/input'
10
10
  import { FormError } from '@/features/forms/form-error'
11
11
  import { applyServerError } from '@/features/forms/server-errors'
12
- import type { AuthClient } from './auth-client'
13
- import { sessionQuery } from './session'
14
12
 
15
13
  const schema = z.object({
16
14
  name: z.string().min(1).max(80),
17
15
  email: z.email(),
18
- password: z.string().min(8),
16
+ password: z.string().min(8).max(128),
19
17
  })
20
18
 
21
19
  type SignUpValues = z.infer<typeof schema>
22
20
 
23
- export function SignUpForm({ auth }: { auth: AuthClient }) {
21
+ export function SignUpForm({ auth }: { auth: Pick<AuthClient, 'signUp' | 'getSession'> }) {
24
22
  const queryClient = useQueryClient()
25
23
  const navigate = useNavigate()
26
24
  const form = useForm<SignUpValues>({
@@ -32,13 +30,23 @@ export function SignUpForm({ auth }: { auth: AuthClient }) {
32
30
  const { errors, isSubmitting } = form.formState
33
31
 
34
32
  async function onSubmit(values: SignUpValues) {
35
- const { error } = await auth.signUp.email(values)
33
+ const result = await auth.signUp(values).catch((error: unknown) => {
34
+ applyServerError(error, ['name', 'email', 'password'], form.setError)
35
+ return undefined
36
+ })
36
37
 
37
- if (error !== null) {
38
- applyServerError(ApiError.fromAuthError(error), ['name', 'email', 'password'], form.setError)
38
+ if (result === undefined) {
39
39
  return
40
40
  }
41
41
 
42
+ // prumo:email
43
+ if (result.verificationRequired) {
44
+ await navigate({ to: '/verify-email', search: { email: values.email } })
45
+ return
46
+ }
47
+
48
+ // prumo:end-email
49
+
42
50
  // Invalidating is not enough: nothing observes the session, and the protected route's ensureQueryData
43
51
  // would keep returning the cached null. Refetching replaces it before the route reads it.
44
52
  await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
@@ -0,0 +1,45 @@
1
+ import type { AuthClient } from '@/api-contract'
2
+ import { buttonVariants } from '@/components/ui/button'
3
+
4
+ // What the API sends back to sign-in when a provider sign-in does not complete.
5
+ const FAILURES: Record<string, string> = {
6
+ provider_email_unverified: 'That account’s email is not verified with the provider.',
7
+ provider_not_configured: 'That sign-in is not set up yet.',
8
+ }
9
+
10
+ // Plain links: the browser leaves for the provider and comes back signed in, with no script holding a token.
11
+ export function SocialButtons({
12
+ auth,
13
+ returnTo,
14
+ error,
15
+ }: {
16
+ auth: Pick<AuthClient, 'socialSignInUrl'>
17
+ returnTo: string | undefined
18
+ error: string | undefined
19
+ }) {
20
+ return (
21
+ <div className="flex flex-col gap-3">
22
+ {error !== undefined && (
23
+ <p role="alert" className="text-sm text-destructive">
24
+ {FAILURES[error] ?? 'The sign-in did not complete. Try again.'}
25
+ </p>
26
+ )}
27
+ {/* prumo:google */}
28
+ <a
29
+ href={auth.socialSignInUrl('google', 'web', returnTo)}
30
+ className={buttonVariants({ variant: 'outline' })}
31
+ >
32
+ Continue with Google
33
+ </a>
34
+ {/* prumo:end-google */}
35
+ {/* prumo:apple */}
36
+ <a
37
+ href={auth.socialSignInUrl('apple', 'web', returnTo)}
38
+ className={buttonVariants({ variant: 'outline' })}
39
+ >
40
+ Continue with Apple
41
+ </a>
42
+ {/* prumo:end-apple */}
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,84 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useQueryClient } from '@tanstack/react-query'
3
+ import { useNavigate } from '@tanstack/react-router'
4
+ import { useState } from 'react'
5
+ import { useForm } from 'react-hook-form'
6
+ import { z } from 'zod'
7
+ import { type AuthClient, sessionQuery } from '@/api-contract'
8
+ import { Button } from '@/components/ui/button'
9
+ import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
10
+ import { Input } from '@/components/ui/input'
11
+ import { FormError } from '@/features/forms/form-error'
12
+ import { applyServerError } from '@/features/forms/server-errors'
13
+
14
+ const schema = z.object({ code: z.string().regex(/^\d{6}$/, 'Enter the 6 digits from the email') })
15
+
16
+ type VerifyEmailValues = z.infer<typeof schema>
17
+
18
+ export function VerifyEmailForm({
19
+ auth,
20
+ email,
21
+ }: {
22
+ auth: Pick<AuthClient, 'verifyEmail' | 'requestEmailVerification' | 'getSession'>
23
+ email: string
24
+ }) {
25
+ const queryClient = useQueryClient()
26
+ const navigate = useNavigate()
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
+ // A confirmed email signs the user in; refetch so the protected routes see the session.
44
+ await queryClient.refetchQueries({ queryKey: sessionQuery(auth).queryKey })
45
+ await navigate({ to: '/' })
46
+ }
47
+
48
+ async function resend() {
49
+ await auth.requestEmailVerification(email)
50
+ setResent(true)
51
+ }
52
+
53
+ return (
54
+ <form onSubmit={form.handleSubmit(onSubmit)} noValidate className="flex flex-col gap-6">
55
+ <p className="text-sm text-muted-foreground">
56
+ We sent a 6-digit code to <strong>{email}</strong>.
57
+ </p>
58
+ <FormError errors={errors} />
59
+ <FieldGroup>
60
+ <Field data-invalid={errors.code !== undefined}>
61
+ <FieldLabel htmlFor="code">Code</FieldLabel>
62
+ <Input
63
+ id="code"
64
+ inputMode="numeric"
65
+ autoComplete="one-time-code"
66
+ {...form.register('code')}
67
+ />
68
+ <FieldError errors={[errors.code]} />
69
+ </Field>
70
+ </FieldGroup>
71
+ <Button type="submit" disabled={isSubmitting}>
72
+ Confirm email
73
+ </Button>
74
+ <Button type="button" variant="outline" onClick={resend}>
75
+ Send a new code
76
+ </Button>
77
+ {resent && (
78
+ <p role="status" className="text-sm text-muted-foreground">
79
+ A new code is on its way. The previous one no longer works.
80
+ </p>
81
+ )}
82
+ </form>
83
+ )
84
+ }
@@ -1,60 +1,97 @@
1
- import { useQuery } from '@tanstack/react-query'
2
- import { useState } from 'react'
1
+ import { useMutation, useQuery } from '@tanstack/react-query'
2
+ import { ApiError, type AuthClient } from '@/api-contract'
3
3
  import { Button } from '@/components/ui/button'
4
- import type { AuthClient } from '@/features/auth/auth-client'
5
4
 
6
- type PublicClient = { client_name?: string }
5
+ type AuthorizationView = {
6
+ clientId: string
7
+ clientName: string
8
+ clientHost: string
9
+ redirectHost: string
10
+ redirectsToThisDevice: boolean
11
+ scopes: { scope: string; description: string }[]
12
+ }
13
+
14
+ type ConsentAuth = Pick<AuthClient, 'baseUrl' | 'fetch'>
15
+
16
+ async function call<T>(auth: ConsentAuth, path: string, init?: RequestInit): Promise<T> {
17
+ const response = await auth.fetch(`${auth.baseUrl}/api/oauth${path}`, init)
18
+
19
+ if (!response.ok) {
20
+ throw await ApiError.fromResponse(response)
21
+ }
22
+
23
+ return (await response.json()) as T
24
+ }
7
25
 
8
- // Better Auth checks the signed query on submit; what is shown here comes from the same URL.
26
+ // What the user agrees to is shown by host, not only by name: a name is whatever the client chose to write.
9
27
  export function ConsentForm({
10
28
  auth,
11
- clientId,
12
- scopes,
29
+ requestId,
30
+ leave,
13
31
  }: {
14
- auth: AuthClient
15
- clientId: string
16
- scopes: string[]
32
+ auth: ConsentAuth
33
+ requestId: string
34
+ leave: (url: string) => void
17
35
  }) {
18
- const [failed, setFailed] = useState(false)
19
- const client = useQuery({
20
- queryKey: ['oauth-client', clientId],
21
- queryFn: async () => {
22
- const { data } = await auth.$fetch<PublicClient>('/oauth2/public-client', {
23
- query: { client_id: clientId },
24
- })
25
-
26
- return data
27
- },
36
+ const view = useQuery({
37
+ queryKey: ['oauth-authorization', requestId],
38
+ queryFn: () => call<AuthorizationView>(auth, `/authorizations/${requestId}`),
39
+ retry: false,
40
+ })
41
+ const answer = useMutation({
42
+ mutationFn: (accept: boolean) =>
43
+ call<{ redirectTo: string }>(auth, `/authorizations/${requestId}/decision`, {
44
+ method: 'POST',
45
+ headers: { 'content-type': 'application/json' },
46
+ body: JSON.stringify({ accept }),
47
+ }),
48
+ // The answer goes back to the client either way, as the code or as access_denied.
49
+ onSuccess: ({ redirectTo }) => leave(redirectTo),
28
50
  })
29
- const name = client.data?.client_name ?? clientId
30
51
 
31
- async function answer(accept: boolean) {
32
- // On success Better Auth sends the browser back to the client, so nothing follows here.
33
- const { error } = await auth.$fetch('/oauth2/consent', { method: 'POST', body: { accept } })
52
+ if (view.isPending) {
53
+ return null
54
+ }
34
55
 
35
- setFailed(error !== null)
56
+ if (view.isError) {
57
+ return (
58
+ <p role="alert" className="text-sm text-destructive">
59
+ This request has expired or was already answered. Start again from the application.
60
+ </p>
61
+ )
36
62
  }
37
63
 
64
+ const { clientName, clientHost, redirectHost, redirectsToThisDevice, scopes } = view.data
65
+
38
66
  return (
39
67
  <div className="flex flex-col gap-6">
40
68
  <p>
41
- <strong>{name}</strong> wants to act on your behalf.
69
+ <strong>{clientName}</strong> ({clientHost}) wants to act on your behalf.
70
+ </p>
71
+ <ul aria-label="What it will be able to do" className="list-disc pl-6 text-sm">
72
+ {scopes.map(({ scope, description }) => (
73
+ <li key={scope}>{description}</li>
74
+ ))}
75
+ </ul>
76
+ <p className="text-sm text-muted-foreground">
77
+ If you allow it, you will be sent to <strong>{redirectHost}</strong>.
42
78
  </p>
43
- {scopes.length > 0 && (
44
- <ul className="list-disc pl-6 text-sm text-muted-foreground">
45
- {scopes.map((scope) => (
46
- <li key={scope}>{scope}</li>
47
- ))}
48
- </ul>
79
+ {redirectsToThisDevice && (
80
+ <p role="note" className="text-sm text-destructive">
81
+ It returns to a program on this device. Allow it only if you started this from a program
82
+ you trust.
83
+ </p>
49
84
  )}
50
- {failed && (
85
+ {answer.isError && (
51
86
  <p role="alert" className="text-sm text-destructive">
52
87
  The authorization could not be completed. Start again from the application.
53
88
  </p>
54
89
  )}
55
90
  <div className="flex gap-3">
56
- <Button onClick={() => answer(true)}>Allow</Button>
57
- <Button variant="outline" onClick={() => answer(false)}>
91
+ <Button onClick={() => answer.mutate(true)} disabled={answer.isPending}>
92
+ Allow
93
+ </Button>
94
+ <Button variant="outline" onClick={() => answer.mutate(false)} disabled={answer.isPending}>
58
95
  Deny
59
96
  </Button>
60
97
  </div>
@@ -2,17 +2,19 @@ import '@/index.css'
2
2
  import { QueryClient } from '@tanstack/react-query'
3
3
  import { StrictMode } from 'react'
4
4
  import { createRoot } from 'react-dom/client'
5
- import { createClient, type Transport } from '@/api-contract'
6
- import { createAuth } from '@/features/auth/auth-client'
5
+ import { createAuthClient, createClient, type Transport } from '@/api-contract'
7
6
  import { App } from './app'
8
7
  import { config } from './config'
9
8
 
10
9
  const transport: Transport = (input, init) => fetch(input, { ...init, credentials: 'include' })
11
10
 
11
+ const auth = createAuthClient({ baseUrl: config.VITE_API_URL, fetch: transport })
12
+
12
13
  const context = {
13
14
  queryClient: new QueryClient(),
14
- api: createClient({ baseUrl: config.VITE_API_URL, fetch: transport }),
15
- auth: createAuth({ baseUrl: config.VITE_API_URL, fetch: transport }),
15
+ // Through the auth client, so an expired access token is refreshed and the request repeated.
16
+ api: createClient({ baseUrl: config.VITE_API_URL, fetch: auth.fetch }),
17
+ auth,
16
18
  }
17
19
 
18
20
  const root = document.getElementById('root')