@stjoseph/prumo 0.3.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 (148) hide show
  1. package/.prumo-templates/api/auth.md +3 -2
  2. package/.prumo-templates/core/testing.md +4 -3
  3. package/.prumo-templates/email/codes.md +62 -0
  4. package/.prumo-templates/email/mailer.md +47 -0
  5. package/.prumo-templates/mcp/server.md +19 -5
  6. package/.prumo-templates/social/sign-in.md +77 -0
  7. package/README.md +6 -4
  8. package/dist/asker.js +13 -0
  9. package/dist/cli.js +16 -2
  10. package/dist/commands.js +7 -0
  11. package/dist/compose.js +3 -3
  12. package/dist/context.js +4 -0
  13. package/dist/features.js +230 -0
  14. package/dist/generate.js +17 -3
  15. package/dist/questions.js +64 -1
  16. package/package.json +1 -1
  17. package/templates/api/.env.example +18 -0
  18. package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
  19. package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
  20. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  21. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  22. package/templates/api/package.json +1 -0
  23. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  24. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  25. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  26. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  27. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  28. package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
  29. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  30. package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
  31. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  32. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  33. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  34. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  35. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  36. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
  37. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  38. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  39. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  40. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  41. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  42. package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
  43. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  44. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  45. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  46. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  47. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  48. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
  49. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  50. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  51. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  52. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
  53. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
  54. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
  55. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  56. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  57. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
  58. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
  59. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
  60. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
  61. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
  62. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  63. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
  64. package/templates/api/src/infra/config/env.ts +18 -0
  65. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
  66. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  67. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  68. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
  69. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  70. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
  71. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
  72. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  73. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  74. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
  75. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  76. package/templates/api/src/infra/di/email.di.ts +23 -0
  77. package/templates/api/src/infra/di/index.ts +4 -0
  78. package/templates/api/src/infra/di/social.di.ts +17 -0
  79. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  80. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  81. package/templates/api/src/infra/http/app.ts +4 -0
  82. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  83. package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
  84. package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
  85. package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
  86. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  87. package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
  88. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  89. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  90. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  91. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  92. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  93. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
  94. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
  95. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
  96. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  97. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
  98. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  99. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  100. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
  101. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
  102. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  103. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
  104. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  105. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  106. package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
  107. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  108. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  109. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  110. package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
  111. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  112. package/templates/api/test/support/test-app.ts +65 -0
  113. package/templates/mobile/src/api-contract/api-error.ts +4 -0
  114. package/templates/mobile/src/api-contract/auth.ts +63 -3
  115. package/templates/mobile/src/api-contract/index.ts +4 -0
  116. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  117. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  118. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  119. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  120. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  121. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  122. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  123. package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
  124. package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
  125. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  126. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  127. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  128. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  129. package/templates/web/src/api-contract/api-error.ts +4 -0
  130. package/templates/web/src/api-contract/auth.ts +63 -3
  131. package/templates/web/src/api-contract/index.ts +4 -0
  132. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  133. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  134. package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
  135. package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
  136. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  137. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  138. package/templates/web/src/features/oauth/consent-form.tsx +7 -1
  139. package/templates/web/src/routeTree.gen.ts +73 -2
  140. package/templates/web/src/routes/forgot-password.tsx +17 -0
  141. package/templates/web/src/routes/reset-password.tsx +20 -0
  142. package/templates/web/src/routes/sign-in.tsx +10 -3
  143. package/templates/web/src/routes/sign-up.tsx +4 -0
  144. package/templates/web/src/routes/verify-email.tsx +20 -0
  145. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  146. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  147. package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
  148. package/dist/mcp.js +0 -107
@@ -0,0 +1,86 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
+ import { memoryTokenStore } from '@test/support/memory-token-store'
4
+ import { userEvent } from '@testing-library/react-native'
5
+ import { renderRouter, screen } from 'expo-router/testing-library'
6
+ import type { ReactNode } from 'react'
7
+ import { Text } from 'react-native'
8
+ import { createAuthClient, createClient } from '@/api-contract'
9
+ import ForgotPasswordScreen from '@/app/(auth)/forgot-password'
10
+ import ResetPasswordScreen from '@/app/(auth)/reset-password'
11
+ import SignUpScreen from '@/app/(auth)/sign-up'
12
+ import VerifyEmailScreen from '@/app/(auth)/verify-email'
13
+ import { ClientsProvider } from '@/features/clients/clients-context'
14
+
15
+ const TOKENS = { accessToken: 'access-1', refreshToken: 'refresh-1', expiresIn: 900 }
16
+
17
+ async function renderAt(initialUrl: string, routes: Parameters<typeof fakeTransport>[0]) {
18
+ const transport = fakeTransport(routes)
19
+ const auth = createAuthClient({ baseUrl: API_URL, fetch: transport, tokens: memoryTokenStore() })
20
+ const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: auth.fetch }) }
21
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
22
+
23
+ function Providers({ children }: { children: ReactNode }) {
24
+ return (
25
+ <QueryClientProvider client={queryClient}>
26
+ <ClientsProvider clients={clients}>{children}</ClientsProvider>
27
+ </QueryClientProvider>
28
+ )
29
+ }
30
+
31
+ await renderRouter(
32
+ {
33
+ index: () => <Text>Your profile</Text>,
34
+ '(auth)/sign-up': SignUpScreen,
35
+ '(auth)/verify-email': VerifyEmailScreen,
36
+ '(auth)/forgot-password': ForgotPasswordScreen,
37
+ '(auth)/reset-password': ResetPasswordScreen,
38
+ },
39
+ { initialUrl, wrapper: Providers },
40
+ )
41
+ }
42
+
43
+ describe('email verification on mobile', () => {
44
+ it('takes a new account to the code, and in once it is entered', async () => {
45
+ const user = userEvent.setup()
46
+
47
+ await renderAt('/sign-up', {
48
+ 'POST /api/auth/sign-up': () => json(201, { verificationRequired: true }),
49
+ 'POST /api/auth/email/verify': () => json(200, TOKENS),
50
+ })
51
+
52
+ await user.type(screen.getByLabelText('Name'), 'Ana')
53
+ await user.type(screen.getByLabelText('Email'), 'ana@example.com')
54
+ await user.type(screen.getByLabelText('Password'), 'correct-horse-battery')
55
+ await user.press(screen.getByRole('button', { name: 'Create account' }))
56
+
57
+ expect(await screen.findByText('Confirm your email')).toBeOnTheScreen()
58
+
59
+ await user.type(screen.getByLabelText('Code'), '123456')
60
+ await user.press(screen.getByRole('button', { name: 'Confirm email' }))
61
+
62
+ expect(await screen.findByText('Your profile')).toBeOnTheScreen()
63
+ })
64
+ })
65
+
66
+ describe('password reset on mobile', () => {
67
+ it('goes from forgot to reset and in', async () => {
68
+ const user = userEvent.setup()
69
+
70
+ await renderAt('/forgot-password', {
71
+ 'POST /api/auth/password/forgot': () => new Response(null, { status: 204 }),
72
+ 'POST /api/auth/password/reset': () => json(200, TOKENS),
73
+ })
74
+
75
+ await user.type(screen.getByLabelText('Email'), 'ana@example.com')
76
+ await user.press(screen.getByRole('button', { name: 'Send a reset code' }))
77
+
78
+ expect(await screen.findByText('Choose a new password')).toBeOnTheScreen()
79
+
80
+ await user.type(screen.getByLabelText('Code'), '123456')
81
+ await user.type(screen.getByLabelText('New password'), 'another-good-password')
82
+ await user.press(screen.getByRole('button', { name: 'Set the new password' }))
83
+
84
+ expect(await screen.findByText('Your profile')).toBeOnTheScreen()
85
+ })
86
+ })
@@ -0,0 +1,77 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
+ import { memoryTokenStore } from '@test/support/memory-token-store'
4
+ import { render, screen, userEvent, waitFor } from '@testing-library/react-native'
5
+ import * as WebBrowser from 'expo-web-browser'
6
+ import { createAuthClient } from '@/api-contract'
7
+ import { SocialButtons } from '@/features/auth/social-buttons'
8
+
9
+ jest.mock('expo-web-browser', () => ({ openAuthSessionAsync: jest.fn() }))
10
+ // jest-expo leaves the app manifest empty, so expo-linking cannot know the app's scheme; this one stands in for it.
11
+ jest.mock('expo-linking', () => ({
12
+ ...jest.requireActual('expo-linking'),
13
+ createURL: (path: string) => `acme://${path}`,
14
+ }))
15
+
16
+ // Whichever provider the project kept: each button runs the same trip.
17
+ const firstProvider = () => screen.getAllByRole('button', { name: /^Continue with / })[0] as never
18
+
19
+ const TOKENS = { accessToken: 'access-1', refreshToken: 'refresh-1', expiresIn: 900 }
20
+
21
+ async function renderButtons(exchange: () => Response) {
22
+ const tokens = memoryTokenStore()
23
+ const onSignedIn = jest.fn()
24
+ const auth = createAuthClient({
25
+ baseUrl: API_URL,
26
+ tokens,
27
+ fetch: fakeTransport({ 'POST /api/auth/social/exchange': exchange }),
28
+ })
29
+
30
+ await render(
31
+ <QueryClientProvider client={new QueryClient()}>
32
+ <SocialButtons auth={auth} onSignedIn={onSignedIn} />
33
+ </QueryClientProvider>,
34
+ )
35
+
36
+ return { tokens, onSignedIn }
37
+ }
38
+
39
+ describe('SocialButtons', () => {
40
+ beforeEach(() => jest.mocked(WebBrowser.openAuthSessionAsync).mockReset())
41
+
42
+ it('opens the provider through the API and trades the code it comes back with', async () => {
43
+ jest
44
+ .mocked(WebBrowser.openAuthSessionAsync)
45
+ .mockResolvedValue({ type: 'success', url: 'acme://social?code=flow.secret' })
46
+ const { tokens, onSignedIn } = await renderButtons(() => json(200, TOKENS))
47
+
48
+ await userEvent.setup().press(firstProvider())
49
+
50
+ await waitFor(() => expect(onSignedIn).toHaveBeenCalled())
51
+ expect(jest.mocked(WebBrowser.openAuthSessionAsync).mock.calls[0]?.[0]).toMatch(
52
+ new RegExp(`^${API_URL}/api/auth/social/\\w+\\?client=mobile&returnTo=%2F$`),
53
+ )
54
+ expect(await tokens.read()).toEqual(TOKENS)
55
+ })
56
+
57
+ it('does nothing when the browser is closed, and says so when the code is refused', async () => {
58
+ jest
59
+ .mocked(WebBrowser.openAuthSessionAsync)
60
+ .mockResolvedValueOnce({ type: 'cancel' } as Awaited<
61
+ ReturnType<typeof WebBrowser.openAuthSessionAsync>
62
+ >)
63
+ const { onSignedIn } = await renderButtons(() => json(401, {}))
64
+ const user = userEvent.setup()
65
+
66
+ await user.press(firstProvider())
67
+ expect(screen.queryByRole('alert')).toBeNull()
68
+
69
+ jest
70
+ .mocked(WebBrowser.openAuthSessionAsync)
71
+ .mockResolvedValueOnce({ type: 'success', url: 'acme://social?code=used' })
72
+ await user.press(firstProvider())
73
+
74
+ expect(await screen.findByRole('alert')).toHaveTextContent(/did not complete/)
75
+ expect(onSignedIn).not.toHaveBeenCalled()
76
+ })
77
+ })
@@ -5,6 +5,7 @@ type ProblemDocument = {
5
5
  status?: number
6
6
  detail?: string
7
7
  requestId?: string
8
+ code?: string
8
9
  errors?: FieldErrors
9
10
  }
10
11
 
@@ -15,6 +16,8 @@ export class ApiError extends Error {
15
16
  readonly detail: string | undefined,
16
17
  readonly requestId: string | undefined,
17
18
  readonly errors: FieldErrors,
19
+ // A stable name for a refusal the client acts on, such as `email_not_verified`.
20
+ readonly code?: string,
18
21
  ) {
19
22
  super(detail ?? title)
20
23
  }
@@ -35,6 +38,7 @@ export class ApiError extends Error {
35
38
  problem.detail,
36
39
  problem.requestId ?? requestId,
37
40
  problem.errors ?? {},
41
+ problem.code,
38
42
  )
39
43
  }
40
44
  }
@@ -10,6 +10,19 @@ export type SignUpRequest = { name: string; email: string; password: string }
10
10
 
11
11
  export type SignInRequest = { email: string; password: string }
12
12
 
13
+ // prumo:email
14
+ export type VerifyEmailRequest = { email: string; code: string }
15
+
16
+ export type ResetPasswordRequest = { email: string; code: string; password: string }
17
+
18
+ // prumo:end-email
19
+ // prumo:social
20
+ export type SocialProvider = 'google' | 'apple'
21
+
22
+ // prumo:end-social
23
+ // An account that must confirm its email first gets no session from signing up, and says so.
24
+ export type SignUpResult = { verificationRequired: boolean }
25
+
13
26
  // Where a native client keeps its tokens. The web passes none: its tokens live in cookies no script can read.
14
27
  export type TokenStore = {
15
28
  read(): Promise<Tokens | null>
@@ -48,16 +61,36 @@ export function createAuthClient({
48
61
  })
49
62
  }
50
63
 
51
- async function begin(path: string, body: unknown): Promise<void> {
64
+ async function begin(path: string, body: unknown): Promise<SignUpResult> {
52
65
  const response = await send(path, body)
53
66
 
54
67
  if (!response.ok) {
55
68
  throw await ApiError.fromResponse(response)
56
69
  }
57
70
 
58
- await tokens?.write((await response.json()) as Tokens)
71
+ // The web's answer is empty: its session arrived as cookies.
72
+ const text = await response.text()
73
+ const answer = text === '' ? {} : (JSON.parse(text) as Partial<Tokens & SignUpResult>)
74
+
75
+ if (answer.verificationRequired === true) {
76
+ return { verificationRequired: true }
77
+ }
78
+
79
+ await tokens?.write(answer as Tokens)
80
+ return { verificationRequired: false }
59
81
  }
60
82
 
83
+ // prumo:email
84
+ async function ask(path: string, body: unknown): Promise<void> {
85
+ const response = await send(path, body)
86
+
87
+ if (!response.ok) {
88
+ throw await ApiError.fromResponse(response)
89
+ }
90
+ }
91
+
92
+ // prumo:end-email
93
+
61
94
  let refreshing: Promise<boolean> | undefined
62
95
 
63
96
  // Every request that meets a 401 at once waits on the same refresh: a second refresh would present a token the
@@ -125,7 +158,34 @@ export function createAuthClient({
125
158
 
126
159
  signUp: (request: SignUpRequest) => begin('/sign-up', request),
127
160
 
128
- signIn: (request: SignInRequest) => begin('/sign-in', request),
161
+ signIn: async (request: SignInRequest): Promise<void> => {
162
+ await begin('/sign-in', request)
163
+ },
164
+
165
+ // prumo:email
166
+ // Each answers alike for an unknown email, so a form can say "if the account exists, a code is on its way".
167
+ requestEmailVerification: (email: string) => ask('/email/verification', { email }),
168
+
169
+ verifyEmail: async (request: VerifyEmailRequest): Promise<void> => {
170
+ await begin('/email/verify', request)
171
+ },
172
+
173
+ requestPasswordReset: (email: string) => ask('/password/forgot', { email }),
174
+
175
+ resetPassword: async (request: ResetPasswordRequest): Promise<void> => {
176
+ await begin('/password/reset', request)
177
+ },
178
+ // prumo:end-email
179
+ // prumo:social
180
+ // Where to send the browser to sign in with a provider: the API takes it there and back. The web comes back
181
+ // signed in; a mobile app comes back with a code to exchange.
182
+ socialSignInUrl: (provider: SocialProvider, client: 'web' | 'mobile', returnTo = '/') =>
183
+ `${url(`/social/${provider}`)}?${new URLSearchParams({ client, returnTo })}`,
184
+
185
+ exchangeSocialCode: async (code: string): Promise<void> => {
186
+ await begin('/social/exchange', { code })
187
+ },
188
+ // prumo:end-social
129
189
 
130
190
  // The device forgets the tokens whether or not the request arrives: signing out locally is what was asked.
131
191
  async signOut(): Promise<void> {
@@ -2,12 +2,16 @@ export { ApiError, type FieldErrors } from './api-error'
2
2
  export {
3
3
  type AuthClient,
4
4
  createAuthClient,
5
+ type ResetPasswordRequest, // prumo:email
5
6
  type Session,
6
7
  type SignInRequest,
7
8
  type SignUpRequest,
9
+ type SignUpResult,
10
+ type SocialProvider, // prumo:social
8
11
  sessionQuery,
9
12
  type TokenStore,
10
13
  type Tokens,
14
+ type VerifyEmailRequest, // prumo:email
11
15
  } from './auth'
12
16
  export { type ApiClient, createClient, type Transport } from './client'
13
17
  export {
@@ -0,0 +1,52 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useNavigate } from '@tanstack/react-router'
3
+ import { useForm } from 'react-hook-form'
4
+ import { z } from 'zod'
5
+ import type { AuthClient } from '@/api-contract'
6
+ import { Button } from '@/components/ui/button'
7
+ import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
8
+ import { Input } from '@/components/ui/input'
9
+ import { FormError } from '@/features/forms/form-error'
10
+ import { applyServerError } from '@/features/forms/server-errors'
11
+
12
+ const schema = z.object({ email: z.email() })
13
+
14
+ type ForgotPasswordValues = z.infer<typeof schema>
15
+
16
+ export function ForgotPasswordForm({ auth }: { auth: Pick<AuthClient, 'requestPasswordReset'> }) {
17
+ const navigate = useNavigate()
18
+ const form = useForm<ForgotPasswordValues>({
19
+ resolver: zodResolver(schema),
20
+ mode: 'onTouched',
21
+ defaultValues: { email: '' },
22
+ })
23
+ const { errors, isSubmitting } = form.formState
24
+
25
+ async function onSubmit(values: ForgotPasswordValues) {
26
+ try {
27
+ await auth.requestPasswordReset(values.email)
28
+ } catch (error) {
29
+ applyServerError(error, ['email'], form.setError)
30
+ return
31
+ }
32
+
33
+ // The API answers alike whether the account exists, so the next page cannot tell either.
34
+ await navigate({ to: '/reset-password', search: { email: values.email } })
35
+ }
36
+
37
+ return (
38
+ <form onSubmit={form.handleSubmit(onSubmit)} noValidate className="flex flex-col gap-6">
39
+ <FormError errors={errors} />
40
+ <FieldGroup>
41
+ <Field data-invalid={errors.email !== undefined}>
42
+ <FieldLabel htmlFor="email">Email</FieldLabel>
43
+ <Input id="email" type="email" autoComplete="email" {...form.register('email')} />
44
+ <FieldError errors={[errors.email]} />
45
+ </Field>
46
+ </FieldGroup>
47
+ <Button type="submit" disabled={isSubmitting}>
48
+ Send a reset code
49
+ </Button>
50
+ </form>
51
+ )
52
+ }
@@ -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
+ }
@@ -38,6 +38,14 @@ export function SignInForm({
38
38
  try {
39
39
  await auth.signIn(values)
40
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
+ }
47
+
48
+ // prumo:end-email
41
49
  applyServerError(error, ['email', 'password'], form.setError)
42
50
  return
43
51
  }
@@ -74,6 +82,11 @@ export function SignInForm({
74
82
  <p className="text-sm text-muted-foreground">
75
83
  No account? <Link to="/sign-up">Sign up</Link>
76
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 */}
77
90
  </form>
78
91
  )
79
92
  }
@@ -30,13 +30,23 @@ export function SignUpForm({ auth }: { auth: Pick<AuthClient, 'signUp' | 'getSes
30
30
  const { errors, isSubmitting } = form.formState
31
31
 
32
32
  async function onSubmit(values: SignUpValues) {
33
- try {
34
- await auth.signUp(values)
35
- } catch (error) {
33
+ const result = await auth.signUp(values).catch((error: unknown) => {
36
34
  applyServerError(error, ['name', 'email', 'password'], form.setError)
35
+ return undefined
36
+ })
37
+
38
+ if (result === undefined) {
37
39
  return
38
40
  }
39
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
+
40
50
  // Invalidating is not enough: nothing observes the session, and the protected route's ensureQueryData
41
51
  // would keep returning the cached null. Refetching replaces it before the route reads it.
42
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
+ }
@@ -8,6 +8,7 @@ type AuthorizationView = {
8
8
  clientHost: string
9
9
  redirectHost: string
10
10
  redirectsToThisDevice: boolean
11
+ scopes: { scope: string; description: string }[]
11
12
  }
12
13
 
13
14
  type ConsentAuth = Pick<AuthClient, 'baseUrl' | 'fetch'>
@@ -60,13 +61,18 @@ export function ConsentForm({
60
61
  )
61
62
  }
62
63
 
63
- const { clientName, clientHost, redirectHost, redirectsToThisDevice } = view.data
64
+ const { clientName, clientHost, redirectHost, redirectsToThisDevice, scopes } = view.data
64
65
 
65
66
  return (
66
67
  <div className="flex flex-col gap-6">
67
68
  <p>
68
69
  <strong>{clientName}</strong> ({clientHost}) wants to act on your behalf.
69
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>
70
76
  <p className="text-sm text-muted-foreground">
71
77
  If you allow it, you will be sent to <strong>{redirectHost}</strong>.
72
78
  </p>