@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.
- package/.prumo-templates/api/auth.md +51 -60
- package/.prumo-templates/api/config.md +2 -2
- package/.prumo-templates/api/routes.md +6 -4
- package/.prumo-templates/api/structure.md +4 -1
- package/.prumo-templates/client/data.md +16 -18
- package/.prumo-templates/client/forms.md +2 -2
- package/.prumo-templates/core/testing.md +4 -4
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/email/codes.md +62 -0
- package/.prumo-templates/email/mailer.md +47 -0
- package/.prumo-templates/mcp/server.md +55 -14
- package/.prumo-templates/mobile/routing.md +1 -1
- package/.prumo-templates/mobile/storage.md +7 -6
- package/.prumo-templates/monorepo/layout.md +4 -4
- package/.prumo-templates/multi-tenancy/client.md +6 -6
- package/.prumo-templates/multi-tenancy/isolation.md +17 -10
- package/.prumo-templates/social/sign-in.md +77 -0
- package/README.md +8 -6
- package/dist/asker.js +13 -0
- package/dist/clean.js +2 -2
- package/dist/cli.js +16 -2
- package/dist/commands.js +7 -0
- package/dist/compose.js +3 -11
- package/dist/context.js +4 -0
- package/dist/features.js +230 -0
- package/dist/generate.js +20 -7
- package/dist/questions.js +64 -1
- package/package.json +1 -1
- package/templates/api/.env.example +21 -5
- package/templates/api/README.md +4 -5
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
- 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 +7 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
- package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
- package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -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 +78 -0
- package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
- package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
- package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -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/errors/superseded-token.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
- 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/ports/opaque-tokens.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -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 +12 -0
- package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
- package/templates/api/src/domain/auth/repositories/user.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/find-user.use-case.ts +20 -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 +94 -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 +38 -0
- package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
- 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-decision.dto.ts +7 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
- package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
- package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
- package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
- package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
- package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
- package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
- package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
- package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
- package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
- package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
- package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
- package/templates/api/src/infra/config/env.ts +17 -3
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -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/profile.schema.ts +1 -1
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -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 +19 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -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 +25 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
- package/templates/api/src/infra/di/auth.di.ts +21 -0
- package/templates/api/src/infra/di/email.di.ts +23 -0
- package/templates/api/src/infra/di/index.ts +8 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -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 +15 -1
- package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
- package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
- package/templates/api/src/infra/http/fastify.d.ts +2 -3
- package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
- package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
- package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
- package/templates/api/src/infra/http/server.ts +1 -4
- 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/mcp.server.ts +10 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
- package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
- package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -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 +66 -0
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
- 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 +53 -0
- package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
- package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -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 +168 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
- package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
- package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
- package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fake-oidc-server.ts +96 -0
- package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- 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/fake-opaque-tokens.ts +14 -0
- package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
- package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -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 +27 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/fakes/social-fakes.ts +112 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/api/test/support/test-app.ts +65 -0
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +0 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +4 -11
- package/templates/mobile/src/api-contract/auth.ts +221 -0
- package/templates/mobile/src/api-contract/index.ts +14 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- 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/app/_layout.tsx +5 -5
- 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 +21 -7
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
- package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/package.json +0 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +4 -11
- package/templates/web/src/api-contract/auth.ts +221 -0
- package/templates/web/src/api-contract/index.ts +14 -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 +24 -14
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
- 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 +72 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/routeTree.gen.ts +73 -2
- package/templates/web/src/router.tsx +1 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
- package/templates/web/src/routes/_authenticated.tsx +1 -1
- 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/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
- package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/dist/mcp.js +0 -100
- package/templates/api/docker/init/01-auth-schema.sql +0 -1
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
- package/templates/api/src/infra/auth/auth.cli.ts +0 -5
- package/templates/api/src/infra/auth/auth.factory.ts +0 -77
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
- package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
- package/templates/mobile/src/features/auth/auth-client.ts +0 -27
- package/templates/mobile/src/features/auth/session.ts +0 -17
- package/templates/web/src/features/auth/auth-client.ts +0 -17
- 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 {
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
applyServerError(
|
|
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
|
-
//
|
|
15
|
-
//
|
|
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 {
|
|
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
|
|
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 (
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
12
|
-
|
|
29
|
+
requestId,
|
|
30
|
+
leave,
|
|
13
31
|
}: {
|
|
14
|
-
auth:
|
|
15
|
-
|
|
16
|
-
|
|
32
|
+
auth: ConsentAuth
|
|
33
|
+
requestId: string
|
|
34
|
+
leave: (url: string) => void
|
|
17
35
|
}) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
if (view.isPending) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
34
55
|
|
|
35
|
-
|
|
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>{
|
|
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
|
-
{
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
{
|
|
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)}>
|
|
57
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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')
|