@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.
- package/.prumo-templates/api/auth.md +3 -2
- package/.prumo-templates/core/testing.md +4 -3
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +19 -5
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +6 -4
- package/dist/asker.js +13 -0
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -3
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +17 -3
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +18 -0
- package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
- package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
- package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
- package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
- package/templates/api/package.json +1 -0
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
- package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
- package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
- package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
- package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
- package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
- package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
- package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
- package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
- package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
- package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
- package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
- package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
- package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
- package/templates/api/src/infra/config/env.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
- package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
- package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +4 -0
- package/templates/api/src/infra/di/social.di.ts +17 -0
- package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
- package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
- package/templates/api/src/infra/http/app.ts +4 -0
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
- package/templates/api/src/infra/http/session-delivery.ts +69 -0
- package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
- package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
- package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
- package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
- package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
- package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
- package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/src/api-contract/api-error.ts +4 -0
- package/templates/mobile/src/api-contract/auth.ts +63 -3
- package/templates/mobile/src/api-contract/index.ts +4 -0
- package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
- package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
- package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
- package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
- package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/web/src/api-contract/api-error.ts +4 -0
- package/templates/web/src/api-contract/auth.ts +63 -3
- package/templates/web/src/api-contract/index.ts +4 -0
- package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
- package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
- package/templates/web/src/features/auth/social-buttons.tsx +45 -0
- package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
- package/templates/web/src/features/oauth/consent-form.tsx +7 -1
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/routes/forgot-password.tsx +17 -0
- package/templates/web/src/routes/reset-password.tsx +20 -0
- package/templates/web/src/routes/sign-in.tsx +10 -3
- package/templates/web/src/routes/sign-up.tsx +4 -0
- package/templates/web/src/routes/verify-email.tsx +20 -0
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
- package/dist/mcp.js +0 -107
|
@@ -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<
|
|
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
|
-
|
|
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) =>
|
|
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,20 @@
|
|
|
1
|
+
import { useRouter } from 'expo-router'
|
|
2
|
+
import { Text } from 'react-native'
|
|
3
|
+
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
4
|
+
import { ForgotPasswordForm } from '@/features/auth/forgot-password-form'
|
|
5
|
+
import { useClients } from '@/features/clients/clients-context'
|
|
6
|
+
|
|
7
|
+
export default function ForgotPasswordScreen() {
|
|
8
|
+
const { auth } = useClients()
|
|
9
|
+
const router = useRouter()
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<SafeAreaView className="flex-1 justify-center gap-6 p-6">
|
|
13
|
+
<Text className="text-2xl font-semibold text-neutral-900">Reset your password</Text>
|
|
14
|
+
<ForgotPasswordForm
|
|
15
|
+
auth={auth}
|
|
16
|
+
onSent={(email) => router.push({ pathname: '/reset-password', params: { email } })}
|
|
17
|
+
/>
|
|
18
|
+
</SafeAreaView>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useLocalSearchParams, useRouter } from 'expo-router'
|
|
2
|
+
import { Text } from 'react-native'
|
|
3
|
+
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
4
|
+
import { ResetPasswordForm } from '@/features/auth/reset-password-form'
|
|
5
|
+
import { useClients } from '@/features/clients/clients-context'
|
|
6
|
+
|
|
7
|
+
export default function ResetPasswordScreen() {
|
|
8
|
+
const { auth } = useClients()
|
|
9
|
+
const router = useRouter()
|
|
10
|
+
const { email } = useLocalSearchParams<{ email: string }>()
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<SafeAreaView className="flex-1 justify-center gap-6 p-6">
|
|
14
|
+
<Text className="text-2xl font-semibold text-neutral-900">Choose a new password</Text>
|
|
15
|
+
<ResetPasswordForm auth={auth} email={email} onReset={() => router.replace('/')} />
|
|
16
|
+
</SafeAreaView>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Text, View } from 'react-native'
|
|
|
3
3
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
4
4
|
import { takeIntendedRoute } from '@/features/auth/intended-route'
|
|
5
5
|
import { SignInForm } from '@/features/auth/sign-in-form'
|
|
6
|
+
import { SocialButtons } from '@/features/auth/social-buttons' // prumo:social
|
|
6
7
|
import { useClients } from '@/features/clients/clients-context'
|
|
7
8
|
|
|
8
9
|
export default function SignInScreen() {
|
|
@@ -12,6 +13,9 @@ export default function SignInScreen() {
|
|
|
12
13
|
return (
|
|
13
14
|
<SafeAreaView className="flex-1 justify-center gap-6 p-6">
|
|
14
15
|
<Text className="text-2xl font-semibold text-neutral-900">Sign in</Text>
|
|
16
|
+
{/* prumo:social */}
|
|
17
|
+
<SocialButtons auth={auth} onSignedIn={() => router.replace(takeIntendedRoute() ?? '/')} />
|
|
18
|
+
{/* prumo:end-social */}
|
|
15
19
|
<SignInForm auth={auth} onSignedIn={() => router.replace(takeIntendedRoute() ?? '/')} />
|
|
16
20
|
<View className="flex-row gap-1">
|
|
17
21
|
<Text className="text-sm text-neutral-500">No account?</Text>
|
|
@@ -19,6 +23,11 @@ export default function SignInScreen() {
|
|
|
19
23
|
Sign up
|
|
20
24
|
</Link>
|
|
21
25
|
</View>
|
|
26
|
+
{/* prumo:email */}
|
|
27
|
+
<Link href="/forgot-password" className="text-sm text-neutral-900">
|
|
28
|
+
Forgot your password?
|
|
29
|
+
</Link>
|
|
30
|
+
{/* prumo:end-email */}
|
|
22
31
|
</SafeAreaView>
|
|
23
32
|
)
|
|
24
33
|
}
|
|
@@ -2,6 +2,7 @@ import { Link, useRouter } from 'expo-router'
|
|
|
2
2
|
import { Text, View } from 'react-native'
|
|
3
3
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
4
4
|
import { SignUpForm } from '@/features/auth/sign-up-form'
|
|
5
|
+
import { SocialButtons } from '@/features/auth/social-buttons' // prumo:social
|
|
5
6
|
import { useClients } from '@/features/clients/clients-context'
|
|
6
7
|
|
|
7
8
|
export default function SignUpScreen() {
|
|
@@ -11,6 +12,9 @@ export default function SignUpScreen() {
|
|
|
11
12
|
return (
|
|
12
13
|
<SafeAreaView className="flex-1 justify-center gap-6 p-6">
|
|
13
14
|
<Text className="text-2xl font-semibold text-neutral-900">Create an account</Text>
|
|
15
|
+
{/* prumo:social */}
|
|
16
|
+
<SocialButtons auth={auth} onSignedIn={() => router.replace('/')} />
|
|
17
|
+
{/* prumo:end-social */}
|
|
14
18
|
<SignUpForm auth={auth} onSignedUp={() => router.replace('/')} />
|
|
15
19
|
<View className="flex-row gap-1">
|
|
16
20
|
<Text className="text-sm text-neutral-500">Already registered?</Text>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useLocalSearchParams, useRouter } from 'expo-router'
|
|
2
|
+
import { Text } from 'react-native'
|
|
3
|
+
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
4
|
+
import { VerifyEmailForm } from '@/features/auth/verify-email-form'
|
|
5
|
+
import { useClients } from '@/features/clients/clients-context'
|
|
6
|
+
|
|
7
|
+
export default function VerifyEmailScreen() {
|
|
8
|
+
const { auth } = useClients()
|
|
9
|
+
const router = useRouter()
|
|
10
|
+
const { email } = useLocalSearchParams<{ email: string }>()
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<SafeAreaView className="flex-1 justify-center gap-6 p-6">
|
|
14
|
+
<Text className="text-2xl font-semibold text-neutral-900">Confirm your email</Text>
|
|
15
|
+
<VerifyEmailForm auth={auth} email={email} onVerified={() => router.replace('/')} />
|
|
16
|
+
</SafeAreaView>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import { Controller, useForm } from 'react-hook-form'
|
|
3
|
+
import { View } from 'react-native'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import type { AuthClient } from '@/api-contract'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Field } from '@/components/ui/field'
|
|
8
|
+
import { FormError } from '@/features/forms/form-error'
|
|
9
|
+
import { applyServerError } from '@/features/forms/server-errors'
|
|
10
|
+
|
|
11
|
+
const schema = z.object({ email: z.email() })
|
|
12
|
+
|
|
13
|
+
type ForgotPasswordValues = z.infer<typeof schema>
|
|
14
|
+
|
|
15
|
+
export function ForgotPasswordForm({
|
|
16
|
+
auth,
|
|
17
|
+
onSent,
|
|
18
|
+
}: {
|
|
19
|
+
auth: Pick<AuthClient, 'requestPasswordReset'>
|
|
20
|
+
onSent: (email: string) => void
|
|
21
|
+
}) {
|
|
22
|
+
const form = useForm<ForgotPasswordValues>({
|
|
23
|
+
resolver: zodResolver(schema),
|
|
24
|
+
mode: 'onTouched',
|
|
25
|
+
defaultValues: { email: '' },
|
|
26
|
+
})
|
|
27
|
+
const { errors, isSubmitting } = form.formState
|
|
28
|
+
|
|
29
|
+
async function onSubmit(values: ForgotPasswordValues) {
|
|
30
|
+
try {
|
|
31
|
+
await auth.requestPasswordReset(values.email)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
applyServerError(error, ['email'], form.setError)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The API answers alike whether the account exists, so the next screen cannot tell either.
|
|
38
|
+
onSent(values.email)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<View className="gap-6">
|
|
43
|
+
<FormError errors={errors} />
|
|
44
|
+
<Controller
|
|
45
|
+
control={form.control}
|
|
46
|
+
name="email"
|
|
47
|
+
render={({ field }) => (
|
|
48
|
+
<Field
|
|
49
|
+
label="Email"
|
|
50
|
+
autoCapitalize="none"
|
|
51
|
+
autoComplete="email"
|
|
52
|
+
keyboardType="email-address"
|
|
53
|
+
value={field.value}
|
|
54
|
+
onChangeText={field.onChange}
|
|
55
|
+
onBlur={field.onBlur}
|
|
56
|
+
error={errors.email?.message}
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
/>
|
|
60
|
+
<Button
|
|
61
|
+
label="Send a reset code"
|
|
62
|
+
disabled={isSubmitting}
|
|
63
|
+
onPress={form.handleSubmit(onSubmit)}
|
|
64
|
+
/>
|
|
65
|
+
</View>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { Controller, useForm } from 'react-hook-form'
|
|
4
|
+
import { Text, View } from 'react-native'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import { type AuthClient, sessionQuery } from '@/api-contract'
|
|
7
|
+
import { Button } from '@/components/ui/button'
|
|
8
|
+
import { Field } from '@/components/ui/field'
|
|
9
|
+
import { FormError } from '@/features/forms/form-error'
|
|
10
|
+
import { applyServerError } from '@/features/forms/server-errors'
|
|
11
|
+
|
|
12
|
+
const schema = z.object({
|
|
13
|
+
code: z.string().regex(/^\d{6}$/, 'Enter the 6 digits from the email'),
|
|
14
|
+
password: z.string().min(8).max(128),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
type ResetPasswordValues = z.infer<typeof schema>
|
|
18
|
+
|
|
19
|
+
export function ResetPasswordForm({
|
|
20
|
+
auth,
|
|
21
|
+
email,
|
|
22
|
+
onReset,
|
|
23
|
+
}: {
|
|
24
|
+
auth: Pick<AuthClient, 'resetPassword' | 'getSession'>
|
|
25
|
+
email: string
|
|
26
|
+
onReset: () => void
|
|
27
|
+
}) {
|
|
28
|
+
const queryClient = useQueryClient()
|
|
29
|
+
const form = useForm<ResetPasswordValues>({
|
|
30
|
+
resolver: zodResolver(schema),
|
|
31
|
+
mode: 'onTouched',
|
|
32
|
+
defaultValues: { code: '', password: '' },
|
|
33
|
+
})
|
|
34
|
+
const { errors, isSubmitting } = form.formState
|
|
35
|
+
|
|
36
|
+
async function onSubmit(values: ResetPasswordValues) {
|
|
37
|
+
try {
|
|
38
|
+
await auth.resetPassword({ email, ...values })
|
|
39
|
+
} catch (error) {
|
|
40
|
+
applyServerError(error, ['code', 'password'], form.setError)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Every other session was ended; this one starts signed in.
|
|
45
|
+
await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
|
|
46
|
+
onReset()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<View className="gap-6">
|
|
51
|
+
<Text className="text-sm text-neutral-500">
|
|
52
|
+
If {email} has an account, a 6-digit code is on its way to it.
|
|
53
|
+
</Text>
|
|
54
|
+
<FormError errors={errors} />
|
|
55
|
+
<Controller
|
|
56
|
+
control={form.control}
|
|
57
|
+
name="code"
|
|
58
|
+
render={({ field }) => (
|
|
59
|
+
<Field
|
|
60
|
+
label="Code"
|
|
61
|
+
keyboardType="number-pad"
|
|
62
|
+
autoComplete="one-time-code"
|
|
63
|
+
textContentType="oneTimeCode"
|
|
64
|
+
value={field.value}
|
|
65
|
+
onChangeText={field.onChange}
|
|
66
|
+
onBlur={field.onBlur}
|
|
67
|
+
error={errors.code?.message}
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
70
|
+
/>
|
|
71
|
+
<Controller
|
|
72
|
+
control={form.control}
|
|
73
|
+
name="password"
|
|
74
|
+
render={({ field }) => (
|
|
75
|
+
<Field
|
|
76
|
+
label="New password"
|
|
77
|
+
secureTextEntry
|
|
78
|
+
autoComplete="new-password"
|
|
79
|
+
value={field.value}
|
|
80
|
+
onChangeText={field.onChange}
|
|
81
|
+
onBlur={field.onBlur}
|
|
82
|
+
error={errors.password?.message}
|
|
83
|
+
/>
|
|
84
|
+
)}
|
|
85
|
+
/>
|
|
86
|
+
<Button
|
|
87
|
+
label="Set the new password"
|
|
88
|
+
disabled={isSubmitting}
|
|
89
|
+
onPress={form.handleSubmit(onSubmit)}
|
|
90
|
+
/>
|
|
91
|
+
</View>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
2
|
import { useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { useRouter } from 'expo-router' // prumo:email
|
|
3
4
|
import { Controller, useForm } from 'react-hook-form'
|
|
4
5
|
import { View } from 'react-native'
|
|
5
6
|
import { z } from 'zod'
|
|
@@ -24,6 +25,7 @@ export function SignInForm({
|
|
|
24
25
|
onSignedIn: () => void
|
|
25
26
|
}) {
|
|
26
27
|
const queryClient = useQueryClient()
|
|
28
|
+
const router = useRouter() // prumo:email
|
|
27
29
|
const form = useForm<SignInValues>({
|
|
28
30
|
resolver: zodResolver(schema),
|
|
29
31
|
mode: 'onTouched',
|
|
@@ -36,6 +38,14 @@ export function SignInForm({
|
|
|
36
38
|
try {
|
|
37
39
|
await auth.signIn(values)
|
|
38
40
|
} catch (error) {
|
|
41
|
+
// prumo:email
|
|
42
|
+
// The password was right and the email is not confirmed yet: the code screen offers a new code.
|
|
43
|
+
if ((error as { code?: string }).code === 'email_not_verified') {
|
|
44
|
+
router.push({ pathname: '/verify-email', params: { email: values.email } })
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// prumo:end-email
|
|
39
49
|
applyServerError(error, ['email', 'password'], form.setError)
|
|
40
50
|
return
|
|
41
51
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
2
|
import { useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { useRouter } from 'expo-router' // prumo:email
|
|
3
4
|
import { Controller, useForm } from 'react-hook-form'
|
|
4
5
|
import { View } from 'react-native'
|
|
5
6
|
import { z } from 'zod'
|
|
@@ -25,6 +26,7 @@ export function SignUpForm({
|
|
|
25
26
|
onSignedUp: () => void
|
|
26
27
|
}) {
|
|
27
28
|
const queryClient = useQueryClient()
|
|
29
|
+
const router = useRouter() // prumo:email
|
|
28
30
|
const form = useForm<SignUpValues>({
|
|
29
31
|
resolver: zodResolver(schema),
|
|
30
32
|
mode: 'onTouched',
|
|
@@ -34,13 +36,23 @@ export function SignUpForm({
|
|
|
34
36
|
const { errors, isSubmitting } = form.formState
|
|
35
37
|
|
|
36
38
|
async function onSubmit(values: SignUpValues) {
|
|
37
|
-
|
|
38
|
-
await auth.signUp(values)
|
|
39
|
-
} catch (error) {
|
|
39
|
+
const result = await auth.signUp(values).catch((error: unknown) => {
|
|
40
40
|
applyServerError(error, ['name', 'email', 'password'], form.setError)
|
|
41
|
+
return undefined
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if (result === undefined) {
|
|
41
45
|
return
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
// prumo:email
|
|
49
|
+
if (result.verificationRequired) {
|
|
50
|
+
router.push({ pathname: '/verify-email', params: { email: values.email } })
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// prumo:end-email
|
|
55
|
+
|
|
44
56
|
await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
|
|
45
57
|
onSignedUp()
|
|
46
58
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import * as Linking from 'expo-linking'
|
|
3
|
+
import * as WebBrowser from 'expo-web-browser'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import { Text, View } from 'react-native'
|
|
6
|
+
import { type AuthClient, type SocialProvider, sessionQuery } from '@/api-contract'
|
|
7
|
+
import { Button } from '@/components/ui/button'
|
|
8
|
+
|
|
9
|
+
// Read by hand: the return URL carries one parameter, and parsing it needs nothing from the platform.
|
|
10
|
+
function codeFrom(url: string): string | undefined {
|
|
11
|
+
for (const pair of (url.split('?')[1] ?? '').split('&')) {
|
|
12
|
+
const [key, value] = pair.split('=')
|
|
13
|
+
|
|
14
|
+
if (key === 'code' && value !== undefined && value !== '') {
|
|
15
|
+
return decodeURIComponent(value)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The system browser signs in at the provider and comes back to this app's scheme with a one-time code; the code,
|
|
23
|
+
// not a token, is what travels in the URL, and the app trades it for its tokens.
|
|
24
|
+
export function SocialButtons({
|
|
25
|
+
auth,
|
|
26
|
+
onSignedIn,
|
|
27
|
+
}: {
|
|
28
|
+
auth: Pick<AuthClient, 'socialSignInUrl' | 'exchangeSocialCode' | 'getSession'>
|
|
29
|
+
onSignedIn: () => void
|
|
30
|
+
}) {
|
|
31
|
+
const queryClient = useQueryClient()
|
|
32
|
+
const [failed, setFailed] = useState(false)
|
|
33
|
+
|
|
34
|
+
async function signInWith(provider: SocialProvider) {
|
|
35
|
+
setFailed(false)
|
|
36
|
+
|
|
37
|
+
const result = await WebBrowser.openAuthSessionAsync(
|
|
38
|
+
auth.socialSignInUrl(provider, 'mobile'),
|
|
39
|
+
Linking.createURL('social'),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// Closing the browser is a decision, not a failure.
|
|
43
|
+
if (result.type !== 'success') {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const code = codeFrom(result.url)
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
if (code === undefined) {
|
|
51
|
+
throw new Error('The provider sign-in came back without a code')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await auth.exchangeSocialCode(code)
|
|
55
|
+
} catch {
|
|
56
|
+
setFailed(true)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
|
|
61
|
+
onSignedIn()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<View className="gap-3">
|
|
66
|
+
{failed ? (
|
|
67
|
+
<Text accessibilityRole="alert" className="text-sm text-red-600">
|
|
68
|
+
The sign-in did not complete. Try again.
|
|
69
|
+
</Text>
|
|
70
|
+
) : null}
|
|
71
|
+
{/* prumo:google */}
|
|
72
|
+
<Button label="Continue with Google" variant="outline" onPress={() => signInWith('google')} />
|
|
73
|
+
{/* prumo:end-google */}
|
|
74
|
+
{/* prumo:apple */}
|
|
75
|
+
<Button label="Continue with Apple" variant="outline" onPress={() => signInWith('apple')} />
|
|
76
|
+
{/* prumo:end-apple */}
|
|
77
|
+
</View>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Controller, useForm } from 'react-hook-form'
|
|
5
|
+
import { Text, View } from 'react-native'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { type AuthClient, sessionQuery } from '@/api-contract'
|
|
8
|
+
import { Button } from '@/components/ui/button'
|
|
9
|
+
import { Field } from '@/components/ui/field'
|
|
10
|
+
import { FormError } from '@/features/forms/form-error'
|
|
11
|
+
import { applyServerError } from '@/features/forms/server-errors'
|
|
12
|
+
|
|
13
|
+
const schema = z.object({ code: z.string().regex(/^\d{6}$/, 'Enter the 6 digits from the email') })
|
|
14
|
+
|
|
15
|
+
type VerifyEmailValues = z.infer<typeof schema>
|
|
16
|
+
|
|
17
|
+
export function VerifyEmailForm({
|
|
18
|
+
auth,
|
|
19
|
+
email,
|
|
20
|
+
onVerified,
|
|
21
|
+
}: {
|
|
22
|
+
auth: Pick<AuthClient, 'verifyEmail' | 'requestEmailVerification' | 'getSession'>
|
|
23
|
+
email: string
|
|
24
|
+
onVerified: () => void
|
|
25
|
+
}) {
|
|
26
|
+
const queryClient = useQueryClient()
|
|
27
|
+
const [resent, setResent] = useState(false)
|
|
28
|
+
const form = useForm<VerifyEmailValues>({
|
|
29
|
+
resolver: zodResolver(schema),
|
|
30
|
+
mode: 'onTouched',
|
|
31
|
+
defaultValues: { code: '' },
|
|
32
|
+
})
|
|
33
|
+
const { errors, isSubmitting } = form.formState
|
|
34
|
+
|
|
35
|
+
async function onSubmit(values: VerifyEmailValues) {
|
|
36
|
+
try {
|
|
37
|
+
await auth.verifyEmail({ email, code: values.code })
|
|
38
|
+
} catch (error) {
|
|
39
|
+
applyServerError(error, ['code'], form.setError)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
|
|
44
|
+
onVerified()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function resend() {
|
|
48
|
+
await auth.requestEmailVerification(email)
|
|
49
|
+
setResent(true)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<View className="gap-6">
|
|
54
|
+
<Text className="text-sm text-neutral-500">We sent a 6-digit code to {email}.</Text>
|
|
55
|
+
<FormError errors={errors} />
|
|
56
|
+
<Controller
|
|
57
|
+
control={form.control}
|
|
58
|
+
name="code"
|
|
59
|
+
render={({ field }) => (
|
|
60
|
+
<Field
|
|
61
|
+
label="Code"
|
|
62
|
+
keyboardType="number-pad"
|
|
63
|
+
autoComplete="one-time-code"
|
|
64
|
+
textContentType="oneTimeCode"
|
|
65
|
+
value={field.value}
|
|
66
|
+
onChangeText={field.onChange}
|
|
67
|
+
onBlur={field.onBlur}
|
|
68
|
+
error={errors.code?.message}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
/>
|
|
72
|
+
<Button label="Confirm email" disabled={isSubmitting} onPress={form.handleSubmit(onSubmit)} />
|
|
73
|
+
<Button label="Send a new code" variant="outline" onPress={resend} />
|
|
74
|
+
{resent ? (
|
|
75
|
+
<Text accessibilityRole="text" className="text-sm text-neutral-500">
|
|
76
|
+
A new code is on its way. The previous one no longer works.
|
|
77
|
+
</Text>
|
|
78
|
+
) : null}
|
|
79
|
+
</View>
|
|
80
|
+
)
|
|
81
|
+
}
|