@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,112 @@
|
|
|
1
|
+
import { authFakes } from '@test/support/fakes/auth-fakes'
|
|
2
|
+
import { FakeOpaqueTokens } from '@test/support/fakes/fake-opaque-tokens'
|
|
3
|
+
import { ImmediateTransactionManager } from '@test/support/fakes/immediate-transaction-manager'
|
|
4
|
+
import type { Identity } from '@/domain/auth/entities/identity.entity'
|
|
5
|
+
import type { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
|
|
6
|
+
import type {
|
|
7
|
+
IdentityProviders,
|
|
8
|
+
ProviderCallback,
|
|
9
|
+
ProviderName,
|
|
10
|
+
ProviderProfile,
|
|
11
|
+
ProviderRedirect,
|
|
12
|
+
} from '@/domain/auth/ports/identity-providers.port'
|
|
13
|
+
import type { IdentityRepository } from '@/domain/auth/repositories/identity.repository'
|
|
14
|
+
import type { SocialSignInRepository } from '@/domain/auth/repositories/social-sign-in.repository'
|
|
15
|
+
import { CompleteSocialSignInUseCase } from '@/domain/auth/use-cases/complete-social-sign-in.use-case'
|
|
16
|
+
import { ExchangeSocialCodeUseCase } from '@/domain/auth/use-cases/exchange-social-code.use-case'
|
|
17
|
+
import { LinkIdentityUseCase } from '@/domain/auth/use-cases/link-identity.use-case'
|
|
18
|
+
import { StartSessionUseCase } from '@/domain/auth/use-cases/start-session.use-case'
|
|
19
|
+
import { StartSocialSignInUseCase } from '@/domain/auth/use-cases/start-social-sign-in.use-case'
|
|
20
|
+
import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
|
|
21
|
+
import { FakeAccessTokens } from './fake-access-tokens'
|
|
22
|
+
|
|
23
|
+
export class InMemoryIdentityRepository implements IdentityRepository {
|
|
24
|
+
readonly rows: Identity[] = []
|
|
25
|
+
|
|
26
|
+
async findBySubject(provider: ProviderName, subject: string): Promise<Identity | null> {
|
|
27
|
+
return this.rows.find((row) => row.provider === provider && row.subject === subject) ?? null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async save(identity: Identity): Promise<void> {
|
|
31
|
+
identity.id ??= crypto.randomUUID()
|
|
32
|
+
this.rows.push(identity)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class InMemorySocialSignInRepository implements SocialSignInRepository {
|
|
37
|
+
readonly rows = new Map<string, SocialSignIn>()
|
|
38
|
+
|
|
39
|
+
async lockById(id: string): Promise<SocialSignIn | null> {
|
|
40
|
+
return this.rows.get(id) ?? null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async save(signIn: SocialSignIn): Promise<void> {
|
|
44
|
+
signIn.id ??= crypto.randomUUID()
|
|
45
|
+
this.rows.set(signIn.id, signIn)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A provider that answers what the test tells it to, and remembers what it was asked.
|
|
50
|
+
export class FakeIdentityProviders implements IdentityProviders {
|
|
51
|
+
next: ProviderProfile = {
|
|
52
|
+
subject: 'google-sub-1',
|
|
53
|
+
email: 'ana@example.com',
|
|
54
|
+
emailVerified: true,
|
|
55
|
+
name: 'Ana',
|
|
56
|
+
}
|
|
57
|
+
lastCallback: ProviderCallback | undefined
|
|
58
|
+
|
|
59
|
+
constructor(private readonly configured: ProviderName[] = ['google', 'apple']) {}
|
|
60
|
+
|
|
61
|
+
isConfigured(provider: ProviderName): boolean {
|
|
62
|
+
return this.configured.includes(provider)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async redirect(provider: ProviderName, state: string): Promise<ProviderRedirect> {
|
|
66
|
+
return {
|
|
67
|
+
url: `https://${provider}.test/authorize?state=${state}`,
|
|
68
|
+
codeVerifier: 'verifier',
|
|
69
|
+
nonce: 'nonce',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async profile(_provider: ProviderName, callback: ProviderCallback): Promise<ProviderProfile> {
|
|
74
|
+
this.lastCallback = callback
|
|
75
|
+
return this.next
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function socialFakes() {
|
|
80
|
+
const auth = authFakes()
|
|
81
|
+
const identities = new InMemoryIdentityRepository()
|
|
82
|
+
const signIns = new InMemorySocialSignInRepository()
|
|
83
|
+
const providers = new FakeIdentityProviders()
|
|
84
|
+
const opaque = new FakeOpaqueTokens()
|
|
85
|
+
const transactions = new ImmediateTransactionManager()
|
|
86
|
+
const startSession = new StartSessionUseCase(auth.sessions, new FakeAccessTokens(), opaque)
|
|
87
|
+
const linkIdentity = new LinkIdentityUseCase(
|
|
88
|
+
auth.users,
|
|
89
|
+
identities,
|
|
90
|
+
auth.sessions,
|
|
91
|
+
transactions,
|
|
92
|
+
new CreateProfileUseCase(auth.profiles),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
...auth,
|
|
97
|
+
identities,
|
|
98
|
+
signIns,
|
|
99
|
+
providers,
|
|
100
|
+
linkIdentity,
|
|
101
|
+
start: new StartSocialSignInUseCase(providers, signIns, opaque),
|
|
102
|
+
complete: new CompleteSocialSignInUseCase(
|
|
103
|
+
providers,
|
|
104
|
+
signIns,
|
|
105
|
+
opaque,
|
|
106
|
+
transactions,
|
|
107
|
+
linkIdentity,
|
|
108
|
+
startSession,
|
|
109
|
+
),
|
|
110
|
+
exchange: new ExchangeSocialCodeUseCase(signIns, opaque, transactions, startSession),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
// tsyringe reads decorator metadata as each class is loaded, so this must come before any other import.
|
|
2
2
|
import 'reflect-metadata'
|
|
3
3
|
import { MikroORM } from '@mikro-orm/postgresql'
|
|
4
|
-
import {
|
|
5
|
-
import { Client, type Pool } from 'pg'
|
|
4
|
+
import { Client } from 'pg'
|
|
6
5
|
import { afterAll, beforeAll, beforeEach, inject } from 'vitest'
|
|
7
|
-
import { createAuth } from '@/infra/auth/auth.factory'
|
|
8
6
|
import type { Env } from '@/infra/config/env'
|
|
9
7
|
import { createOrmConfig } from '@/infra/database/mikroorm/mikro-orm.factory'
|
|
10
8
|
|
|
@@ -25,9 +23,8 @@ export function testEnv(): Env {
|
|
|
25
23
|
NODE_ENV: 'test',
|
|
26
24
|
PORT: 3000,
|
|
27
25
|
DATABASE_URL: databaseUrl,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
BETTER_AUTH_URL: 'http://localhost:3000',
|
|
26
|
+
JWT_SECRET: 'test-secret-that-is-at-least-32-characters',
|
|
27
|
+
API_URL: 'http://localhost:3000',
|
|
31
28
|
WEB_ORIGIN: 'http://localhost:5173',
|
|
32
29
|
LOG_LEVEL: 'error',
|
|
33
30
|
}
|
|
@@ -44,41 +41,22 @@ async function createWorkerDatabase(baseUrl: string, name: string): Promise<stri
|
|
|
44
41
|
const url = new URL(baseUrl)
|
|
45
42
|
url.pathname = `/${name}`
|
|
46
43
|
|
|
47
|
-
const worker = new Client({ connectionString: url.toString() })
|
|
48
|
-
|
|
49
|
-
await worker.connect()
|
|
50
|
-
await worker.query('CREATE SCHEMA IF NOT EXISTS auth')
|
|
51
|
-
await worker.end()
|
|
52
|
-
|
|
53
44
|
return url.toString()
|
|
54
45
|
}
|
|
55
46
|
|
|
56
|
-
async function migrateAuth(): Promise<void> {
|
|
57
|
-
const auth = createAuth(testEnv())
|
|
58
|
-
const { runMigrations } = await getMigrations(auth.options)
|
|
59
|
-
|
|
60
|
-
await runMigrations()
|
|
61
|
-
await (auth.options.database as Pool).end()
|
|
62
|
-
}
|
|
63
|
-
|
|
64
47
|
beforeAll(async () => {
|
|
65
48
|
const name = `test_${process.env.VITEST_WORKER_ID ?? '1'}`
|
|
66
49
|
|
|
67
50
|
databaseUrl = await createWorkerDatabase(inject('postgresUrl'), name)
|
|
68
|
-
await migrateAuth()
|
|
69
51
|
|
|
70
52
|
orm = await MikroORM.init(createOrmConfig(testEnv()))
|
|
71
53
|
|
|
72
54
|
await orm.migrator.up()
|
|
73
55
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
.filter(
|
|
77
|
-
(meta) => meta.tableName !== undefined && meta.pivotTable !== true && meta.schema !== 'auth',
|
|
78
|
-
)
|
|
56
|
+
// getAll() is a Map: Object.values() on it is empty, and nothing would be truncated.
|
|
57
|
+
tables = [...orm.getMetadata().getAll().values()]
|
|
58
|
+
.filter((meta) => meta.tableName !== undefined && meta.pivotTable !== true)
|
|
79
59
|
.map((meta) => `"${meta.tableName}"`)
|
|
80
|
-
|
|
81
|
-
tables = [...own, 'auth."user"', 'auth.session', 'auth.account', 'auth.verification']
|
|
82
60
|
})
|
|
83
61
|
|
|
84
62
|
beforeEach(async () => {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { FakeMailer } from '@test/support/fakes/fake-mailer' // prumo:email
|
|
2
|
+
import { testEnv, testOrm } from '@test/support/setup'
|
|
3
|
+
import type { DependencyContainer } from 'tsyringe'
|
|
4
|
+
import { MAILER } from '@/domain/auth/ports/mailer.port' // prumo:email
|
|
5
|
+
import type { Env } from '@/infra/config/env'
|
|
6
|
+
import { createContainer } from '@/infra/di'
|
|
7
|
+
import { buildApp } from '@/infra/http/app'
|
|
8
|
+
|
|
9
|
+
export const ORIGIN = 'http://localhost:5173'
|
|
10
|
+
export const PASSWORD = 'correct-horse-battery'
|
|
11
|
+
|
|
12
|
+
// The application as a test meets it: the real container over the test database, with a mailer whose inbox the
|
|
13
|
+
// test can read.
|
|
14
|
+
export async function testApp({
|
|
15
|
+
env = testEnv(),
|
|
16
|
+
configure,
|
|
17
|
+
}: {
|
|
18
|
+
env?: Env
|
|
19
|
+
configure?: (container: DependencyContainer) => void
|
|
20
|
+
} = {}) {
|
|
21
|
+
const container = createContainer({ env, orm: testOrm() })
|
|
22
|
+
// prumo:email
|
|
23
|
+
const mailer = new FakeMailer()
|
|
24
|
+
|
|
25
|
+
container.register(MAILER, { useValue: mailer })
|
|
26
|
+
// prumo:end-email
|
|
27
|
+
configure?.(container)
|
|
28
|
+
|
|
29
|
+
const app = await buildApp(container)
|
|
30
|
+
|
|
31
|
+
// Signs up through the real routes and, where the email must be confirmed first, confirms it. Returns the
|
|
32
|
+
// response that delivered the session: cookies for the web, tokens in the body for a bearer client.
|
|
33
|
+
async function signUp({ bearer = false, email = `${crypto.randomUUID()}@example.com` } = {}) {
|
|
34
|
+
const headers = bearer ? { 'x-auth-transport': 'bearer' } : { origin: ORIGIN }
|
|
35
|
+
let response = await app.inject({
|
|
36
|
+
method: 'POST',
|
|
37
|
+
url: '/api/auth/sign-up',
|
|
38
|
+
headers,
|
|
39
|
+
payload: { name: 'Ana', email, password: PASSWORD },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// prumo:email
|
|
43
|
+
if (response.body.includes('verificationRequired')) {
|
|
44
|
+
response = await app.inject({
|
|
45
|
+
method: 'POST',
|
|
46
|
+
url: '/api/auth/email/verify',
|
|
47
|
+
headers,
|
|
48
|
+
payload: { email, code: mailer.lastCode(email) },
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// prumo:end-email
|
|
53
|
+
return { response, email }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
app,
|
|
58
|
+
signUp,
|
|
59
|
+
mailer, // prumo:email
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function cookieHeader(response: { cookies: { name: string; value: string }[] }): string {
|
|
64
|
+
return response.cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')
|
|
65
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Requirements
|
|
4
4
|
|
|
5
|
-
Node 22.18 or later, pnpm 10.26 or later, and the API running
|
|
5
|
+
Node 22.18 or later, pnpm 10.26 or later, and the API running. A simulator or a device with Expo Go
|
|
6
6
|
to open the app.
|
|
7
7
|
|
|
8
8
|
## Running it
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
const preset = require('jest-expo/jest-preset')
|
|
2
|
-
|
|
3
|
-
const [nodeModules, ...rest] = preset.transformIgnorePatterns
|
|
4
|
-
const babel = preset.transform['\\.[jt]sx?$']
|
|
5
|
-
|
|
6
|
-
if (!nodeModules.includes('(?!(') || babel === undefined) {
|
|
7
|
-
throw new Error(
|
|
8
|
-
'jest-expo changed its transform settings; re-check the Better Auth entries below',
|
|
9
|
-
)
|
|
10
|
-
}
|
|
11
|
-
|
|
12
1
|
module.exports = {
|
|
13
2
|
preset: 'jest-expo',
|
|
14
3
|
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', '^@test/(.*)$': '<rootDir>/test/$1' },
|
|
@@ -16,10 +5,4 @@ module.exports = {
|
|
|
16
5
|
// workspace the contract is a package to transform too, and Jest's 5s default was crossed in CI while the same
|
|
17
6
|
// suite passed alone. The budget is the machine's slowness, not the test's.
|
|
18
7
|
testTimeout: 30_000,
|
|
19
|
-
// Better Auth ships ESM only, partly as .mjs, so it joins the packages jest-expo transforms, with the same Babel.
|
|
20
|
-
transform: { '\\.mjs$': babel },
|
|
21
|
-
transformIgnorePatterns: [
|
|
22
|
-
nodeModules.replace('(?!(', '(?!(better-auth|@better-auth|nanostores|'),
|
|
23
|
-
...rest,
|
|
24
|
-
],
|
|
25
8
|
}
|
|
@@ -15,18 +15,14 @@
|
|
|
15
15
|
"prepare": "node .githooks/install.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@better-auth/core": "1.7.7",
|
|
19
|
-
"@better-auth/expo": "1.7.7",
|
|
20
18
|
"@hookform/resolvers": "5.9.1",
|
|
21
19
|
"@tanstack/query-sync-storage-persister": "5.102.8",
|
|
22
20
|
"@tanstack/react-query": "5.102.8",
|
|
23
21
|
"@tanstack/react-query-persist-client": "5.102.8",
|
|
24
|
-
"better-auth": "1.7.7",
|
|
25
22
|
"class-variance-authority": "0.7.1",
|
|
26
23
|
"expo": "57.0.22",
|
|
27
24
|
"expo-constants": "57.0.18",
|
|
28
25
|
"expo-linking": "57.0.10",
|
|
29
|
-
"expo-network": "57.0.2",
|
|
30
26
|
"expo-router": "57.0.21",
|
|
31
27
|
"expo-secure-store": "57.0.4",
|
|
32
28
|
"expo-splash-screen": "57.0.9",
|
|
@@ -25,7 +25,7 @@ const APPS = {
|
|
|
25
25
|
// A port that moves takes these with it: the key to write, in which app, from which app's port.
|
|
26
26
|
const FOLLOWERS = {
|
|
27
27
|
api: [
|
|
28
|
-
{ app: 'api', key: '
|
|
28
|
+
{ app: 'api', key: 'API_URL' },
|
|
29
29
|
{ app: 'web', key: 'VITE_API_URL' },
|
|
30
30
|
{ app: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
|
|
31
31
|
],
|
|
@@ -5,14 +5,8 @@ type ProblemDocument = {
|
|
|
5
5
|
status?: number
|
|
6
6
|
detail?: string
|
|
7
7
|
requestId?: string
|
|
8
|
-
errors?: FieldErrors
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
type AuthClientError = {
|
|
12
|
-
status: number
|
|
13
|
-
statusText: string
|
|
14
|
-
message?: string
|
|
15
8
|
code?: string
|
|
9
|
+
errors?: FieldErrors
|
|
16
10
|
}
|
|
17
11
|
|
|
18
12
|
export class ApiError extends Error {
|
|
@@ -22,6 +16,8 @@ export class ApiError extends Error {
|
|
|
22
16
|
readonly detail: string | undefined,
|
|
23
17
|
readonly requestId: string | undefined,
|
|
24
18
|
readonly errors: FieldErrors,
|
|
19
|
+
// A stable name for a refusal the client acts on, such as `email_not_verified`.
|
|
20
|
+
readonly code?: string,
|
|
25
21
|
) {
|
|
26
22
|
super(detail ?? title)
|
|
27
23
|
}
|
|
@@ -42,10 +38,7 @@ export class ApiError extends Error {
|
|
|
42
38
|
problem.detail,
|
|
43
39
|
problem.requestId ?? requestId,
|
|
44
40
|
problem.errors ?? {},
|
|
41
|
+
problem.code,
|
|
45
42
|
)
|
|
46
43
|
}
|
|
47
|
-
|
|
48
|
-
static fromAuthError(error: AuthClientError): ApiError {
|
|
49
|
-
return new ApiError(error.status, error.code ?? error.statusText, error.message, undefined, {})
|
|
50
|
-
}
|
|
51
44
|
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { queryOptions } from '@tanstack/react-query'
|
|
2
|
+
import { ApiError } from './api-error'
|
|
3
|
+
import type { Transport } from './client'
|
|
4
|
+
|
|
5
|
+
export type Session = { user: { id: string; email: string } }
|
|
6
|
+
|
|
7
|
+
export type Tokens = { accessToken: string; refreshToken: string; expiresIn: number }
|
|
8
|
+
|
|
9
|
+
export type SignUpRequest = { name: string; email: string; password: string }
|
|
10
|
+
|
|
11
|
+
export type SignInRequest = { email: string; password: string }
|
|
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
|
+
|
|
26
|
+
// Where a native client keeps its tokens. The web passes none: its tokens live in cookies no script can read.
|
|
27
|
+
export type TokenStore = {
|
|
28
|
+
read(): Promise<Tokens | null>
|
|
29
|
+
write(tokens: Tokens | null): Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type AuthClient = ReturnType<typeof createAuthClient>
|
|
33
|
+
|
|
34
|
+
export function createAuthClient({
|
|
35
|
+
baseUrl,
|
|
36
|
+
fetch,
|
|
37
|
+
tokens,
|
|
38
|
+
}: {
|
|
39
|
+
baseUrl: string
|
|
40
|
+
fetch: Transport
|
|
41
|
+
tokens?: TokenStore
|
|
42
|
+
}) {
|
|
43
|
+
const url = (path: string) => `${baseUrl}/api/auth${path}`
|
|
44
|
+
|
|
45
|
+
function send(path: string, body?: unknown): Promise<Response> {
|
|
46
|
+
const headers = new Headers()
|
|
47
|
+
|
|
48
|
+
// Asking for the tokens in the body is what makes the API leave cookies out.
|
|
49
|
+
if (tokens !== undefined) {
|
|
50
|
+
headers.set('x-auth-transport', 'bearer')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (body !== undefined) {
|
|
54
|
+
headers.set('content-type', 'application/json')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return fetch(url(path), {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers,
|
|
60
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function begin(path: string, body: unknown): Promise<SignUpResult> {
|
|
65
|
+
const response = await send(path, body)
|
|
66
|
+
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw await ApiError.fromResponse(response)
|
|
69
|
+
}
|
|
70
|
+
|
|
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 }
|
|
81
|
+
}
|
|
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
|
+
|
|
94
|
+
let refreshing: Promise<boolean> | undefined
|
|
95
|
+
|
|
96
|
+
// Every request that meets a 401 at once waits on the same refresh: a second refresh would present a token the
|
|
97
|
+
// first has already rotated.
|
|
98
|
+
function refresh(): Promise<boolean> {
|
|
99
|
+
refreshing ??= (async () => {
|
|
100
|
+
try {
|
|
101
|
+
// The web has no store and sends its cookie; a native client with nothing stored has nothing to send.
|
|
102
|
+
const stored = tokens === undefined ? undefined : await tokens.read()
|
|
103
|
+
|
|
104
|
+
if (stored === null) {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const response = await send(
|
|
109
|
+
'/refresh',
|
|
110
|
+
stored === undefined ? undefined : { refreshToken: stored.refreshToken },
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
// Another tab rotated the token a moment ago, and the browser already holds the new one.
|
|
114
|
+
if (response.status === 409) {
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
await tokens?.write(null)
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await tokens?.write((await response.json()) as Tokens)
|
|
124
|
+
return true
|
|
125
|
+
} finally {
|
|
126
|
+
refreshing = undefined
|
|
127
|
+
}
|
|
128
|
+
})()
|
|
129
|
+
|
|
130
|
+
return refreshing
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Every request to the API goes through here: with a bearer when there is one, and refreshed once on a 401.
|
|
134
|
+
const authorized: Transport = async (input, init) => {
|
|
135
|
+
const attempt = async () => {
|
|
136
|
+
const headers = new Headers(init?.headers)
|
|
137
|
+
const stored = await tokens?.read()
|
|
138
|
+
|
|
139
|
+
if (stored !== null && stored !== undefined) {
|
|
140
|
+
headers.set('authorization', `Bearer ${stored.accessToken}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return fetch(input, { ...init, headers })
|
|
144
|
+
}
|
|
145
|
+
const response = await attempt()
|
|
146
|
+
|
|
147
|
+
if (response.status !== 401 || !(await refresh())) {
|
|
148
|
+
return response
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return attempt()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
baseUrl,
|
|
156
|
+
fetch: authorized,
|
|
157
|
+
refresh,
|
|
158
|
+
|
|
159
|
+
signUp: (request: SignUpRequest) => begin('/sign-up', request),
|
|
160
|
+
|
|
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
|
|
189
|
+
|
|
190
|
+
// The device forgets the tokens whether or not the request arrives: signing out locally is what was asked.
|
|
191
|
+
async signOut(): Promise<void> {
|
|
192
|
+
try {
|
|
193
|
+
const response = await authorized(url('/sign-out'), { method: 'POST' })
|
|
194
|
+
|
|
195
|
+
if (!response.ok && response.status !== 401) {
|
|
196
|
+
throw await ApiError.fromResponse(response)
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
await tokens?.write(null)
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
// A visitor without a session is not an error: the answer is null.
|
|
204
|
+
async getSession(): Promise<Session | null> {
|
|
205
|
+
const response = await authorized(url('/session'))
|
|
206
|
+
|
|
207
|
+
if (response.status === 401) {
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
throw await ApiError.fromResponse(response)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return (await response.json()) as Session
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export const sessionQuery = (auth: Pick<AuthClient, 'getSession'>) =>
|
|
221
|
+
queryOptions({ queryKey: ['session'], queryFn: () => auth.getSession() })
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
export { ApiError, type FieldErrors } from './api-error'
|
|
2
|
+
export {
|
|
3
|
+
type AuthClient,
|
|
4
|
+
createAuthClient,
|
|
5
|
+
type ResetPasswordRequest, // prumo:email
|
|
6
|
+
type Session,
|
|
7
|
+
type SignInRequest,
|
|
8
|
+
type SignUpRequest,
|
|
9
|
+
type SignUpResult,
|
|
10
|
+
type SocialProvider, // prumo:social
|
|
11
|
+
sessionQuery,
|
|
12
|
+
type TokenStore,
|
|
13
|
+
type Tokens,
|
|
14
|
+
type VerifyEmailRequest, // prumo:email
|
|
15
|
+
} from './auth'
|
|
2
16
|
export { type ApiClient, createClient, type Transport } from './client'
|
|
3
17
|
export {
|
|
4
18
|
LOCALES,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useQuery } from '@tanstack/react-query'
|
|
2
2
|
import { Redirect, Stack, usePathname } from 'expo-router'
|
|
3
|
+
import { sessionQuery } from '@/api-contract'
|
|
3
4
|
import { rememberIntendedRoute } from '@/features/auth/intended-route'
|
|
4
|
-
import { sessionQuery } from '@/features/auth/session'
|
|
5
5
|
import { useClients } from '@/features/clients/clients-context'
|
|
6
6
|
|
|
7
7
|
export default function AuthenticatedLayout() {
|
|
@@ -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
|
}
|