@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,8 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
import { Route as rootRouteImport } from './routes/__root'
|
|
12
12
|
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
|
13
|
+
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
|
14
|
+
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
|
13
15
|
import { Route as SignInRouteImport } from './routes/sign-in'
|
|
14
16
|
import { Route as SignUpRouteImport } from './routes/sign-up'
|
|
17
|
+
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
|
|
15
18
|
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
|
16
19
|
import { Route as AuthenticatedConsentRouteImport } from './routes/_authenticated/consent'
|
|
17
20
|
|
|
@@ -19,6 +22,16 @@ const AuthenticatedRoute = AuthenticatedRouteImport.update({
|
|
|
19
22
|
id: '/_authenticated',
|
|
20
23
|
getParentRoute: () => rootRouteImport,
|
|
21
24
|
} as any)
|
|
25
|
+
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
|
26
|
+
id: '/forgot-password',
|
|
27
|
+
path: '/forgot-password',
|
|
28
|
+
getParentRoute: () => rootRouteImport,
|
|
29
|
+
} as any)
|
|
30
|
+
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
|
31
|
+
id: '/reset-password',
|
|
32
|
+
path: '/reset-password',
|
|
33
|
+
getParentRoute: () => rootRouteImport,
|
|
34
|
+
} as any)
|
|
22
35
|
const SignInRoute = SignInRouteImport.update({
|
|
23
36
|
id: '/sign-in',
|
|
24
37
|
path: '/sign-in',
|
|
@@ -29,6 +42,11 @@ const SignUpRoute = SignUpRouteImport.update({
|
|
|
29
42
|
path: '/sign-up',
|
|
30
43
|
getParentRoute: () => rootRouteImport,
|
|
31
44
|
} as any)
|
|
45
|
+
const VerifyEmailRoute = VerifyEmailRouteImport.update({
|
|
46
|
+
id: '/verify-email',
|
|
47
|
+
path: '/verify-email',
|
|
48
|
+
getParentRoute: () => rootRouteImport,
|
|
49
|
+
} as any)
|
|
32
50
|
const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
|
|
33
51
|
id: '/',
|
|
34
52
|
path: '/',
|
|
@@ -42,42 +60,71 @@ const AuthenticatedConsentRoute = AuthenticatedConsentRouteImport.update({
|
|
|
42
60
|
|
|
43
61
|
export interface FileRoutesByFullPath {
|
|
44
62
|
'/': typeof AuthenticatedIndexRoute
|
|
63
|
+
'/forgot-password': typeof ForgotPasswordRoute
|
|
64
|
+
'/reset-password': typeof ResetPasswordRoute
|
|
45
65
|
'/sign-in': typeof SignInRoute
|
|
46
66
|
'/sign-up': typeof SignUpRoute
|
|
67
|
+
'/verify-email': typeof VerifyEmailRoute
|
|
47
68
|
'/consent': typeof AuthenticatedConsentRoute
|
|
48
69
|
}
|
|
49
70
|
export interface FileRoutesByTo {
|
|
71
|
+
'/forgot-password': typeof ForgotPasswordRoute
|
|
72
|
+
'/reset-password': typeof ResetPasswordRoute
|
|
50
73
|
'/sign-in': typeof SignInRoute
|
|
51
74
|
'/sign-up': typeof SignUpRoute
|
|
75
|
+
'/verify-email': typeof VerifyEmailRoute
|
|
52
76
|
'/consent': typeof AuthenticatedConsentRoute
|
|
53
77
|
'/': typeof AuthenticatedIndexRoute
|
|
54
78
|
}
|
|
55
79
|
export interface FileRoutesById {
|
|
56
80
|
__root__: typeof rootRouteImport
|
|
57
81
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
|
82
|
+
'/forgot-password': typeof ForgotPasswordRoute
|
|
83
|
+
'/reset-password': typeof ResetPasswordRoute
|
|
58
84
|
'/sign-in': typeof SignInRoute
|
|
59
85
|
'/sign-up': typeof SignUpRoute
|
|
86
|
+
'/verify-email': typeof VerifyEmailRoute
|
|
60
87
|
'/_authenticated/consent': typeof AuthenticatedConsentRoute
|
|
61
88
|
'/_authenticated/': typeof AuthenticatedIndexRoute
|
|
62
89
|
}
|
|
63
90
|
export interface FileRouteTypes {
|
|
64
91
|
fileRoutesByFullPath: FileRoutesByFullPath
|
|
65
|
-
fullPaths:
|
|
92
|
+
fullPaths:
|
|
93
|
+
| '/'
|
|
94
|
+
| '/forgot-password'
|
|
95
|
+
| '/reset-password'
|
|
96
|
+
| '/sign-in'
|
|
97
|
+
| '/sign-up'
|
|
98
|
+
| '/verify-email'
|
|
99
|
+
| '/consent'
|
|
66
100
|
fileRoutesByTo: FileRoutesByTo
|
|
67
|
-
to:
|
|
101
|
+
to:
|
|
102
|
+
| '/forgot-password'
|
|
103
|
+
| '/reset-password'
|
|
104
|
+
| '/sign-in'
|
|
105
|
+
| '/sign-up'
|
|
106
|
+
| '/verify-email'
|
|
107
|
+
| '/consent'
|
|
108
|
+
| '/'
|
|
68
109
|
id:
|
|
69
110
|
| '__root__'
|
|
70
111
|
| '/_authenticated'
|
|
112
|
+
| '/forgot-password'
|
|
113
|
+
| '/reset-password'
|
|
71
114
|
| '/sign-in'
|
|
72
115
|
| '/sign-up'
|
|
116
|
+
| '/verify-email'
|
|
73
117
|
| '/_authenticated/consent'
|
|
74
118
|
| '/_authenticated/'
|
|
75
119
|
fileRoutesById: FileRoutesById
|
|
76
120
|
}
|
|
77
121
|
export interface RootRouteChildren {
|
|
78
122
|
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
|
123
|
+
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
|
124
|
+
ResetPasswordRoute: typeof ResetPasswordRoute
|
|
79
125
|
SignInRoute: typeof SignInRoute
|
|
80
126
|
SignUpRoute: typeof SignUpRoute
|
|
127
|
+
VerifyEmailRoute: typeof VerifyEmailRoute
|
|
81
128
|
}
|
|
82
129
|
|
|
83
130
|
declare module '@tanstack/react-router' {
|
|
@@ -89,6 +136,20 @@ declare module '@tanstack/react-router' {
|
|
|
89
136
|
preLoaderRoute: typeof AuthenticatedRouteImport
|
|
90
137
|
parentRoute: typeof rootRouteImport
|
|
91
138
|
}
|
|
139
|
+
'/forgot-password': {
|
|
140
|
+
id: '/forgot-password'
|
|
141
|
+
path: '/forgot-password'
|
|
142
|
+
fullPath: '/forgot-password'
|
|
143
|
+
preLoaderRoute: typeof ForgotPasswordRouteImport
|
|
144
|
+
parentRoute: typeof rootRouteImport
|
|
145
|
+
}
|
|
146
|
+
'/reset-password': {
|
|
147
|
+
id: '/reset-password'
|
|
148
|
+
path: '/reset-password'
|
|
149
|
+
fullPath: '/reset-password'
|
|
150
|
+
preLoaderRoute: typeof ResetPasswordRouteImport
|
|
151
|
+
parentRoute: typeof rootRouteImport
|
|
152
|
+
}
|
|
92
153
|
'/sign-in': {
|
|
93
154
|
id: '/sign-in'
|
|
94
155
|
path: '/sign-in'
|
|
@@ -103,6 +164,13 @@ declare module '@tanstack/react-router' {
|
|
|
103
164
|
preLoaderRoute: typeof SignUpRouteImport
|
|
104
165
|
parentRoute: typeof rootRouteImport
|
|
105
166
|
}
|
|
167
|
+
'/verify-email': {
|
|
168
|
+
id: '/verify-email'
|
|
169
|
+
path: '/verify-email'
|
|
170
|
+
fullPath: '/verify-email'
|
|
171
|
+
preLoaderRoute: typeof VerifyEmailRouteImport
|
|
172
|
+
parentRoute: typeof rootRouteImport
|
|
173
|
+
}
|
|
106
174
|
'/_authenticated/': {
|
|
107
175
|
id: '/_authenticated/'
|
|
108
176
|
path: '/'
|
|
@@ -136,8 +204,11 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
|
|
136
204
|
|
|
137
205
|
const rootRouteChildren: RootRouteChildren = {
|
|
138
206
|
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
|
207
|
+
ForgotPasswordRoute: ForgotPasswordRoute,
|
|
208
|
+
ResetPasswordRoute: ResetPasswordRoute,
|
|
139
209
|
SignInRoute: SignInRoute,
|
|
140
210
|
SignUpRoute: SignUpRoute,
|
|
211
|
+
VerifyEmailRoute: VerifyEmailRoute,
|
|
141
212
|
}
|
|
142
213
|
export const routeTree = rootRouteImport
|
|
143
214
|
._addFileChildren(rootRouteChildren)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { ForgotPasswordForm } from '@/features/auth/forgot-password-form'
|
|
3
|
+
|
|
4
|
+
export const Route = createFileRoute('/forgot-password')({
|
|
5
|
+
component: ForgotPasswordPage,
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
function ForgotPasswordPage() {
|
|
9
|
+
const { auth } = Route.useRouteContext()
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<h1 className="text-2xl font-semibold">Reset your password</h1>
|
|
14
|
+
<ForgotPasswordForm auth={auth} />
|
|
15
|
+
</>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { ResetPasswordForm } from '@/features/auth/reset-password-form'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/reset-password')({
|
|
6
|
+
validateSearch: z.object({ email: z.email() }),
|
|
7
|
+
component: ResetPasswordPage,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
function ResetPasswordPage() {
|
|
11
|
+
const { auth } = Route.useRouteContext()
|
|
12
|
+
const { email } = Route.useSearch()
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<h1 className="text-2xl font-semibold">Choose a new password</h1>
|
|
17
|
+
<ResetPasswordForm auth={auth} email={email} />
|
|
18
|
+
</>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import { createFileRoute } from '@tanstack/react-router'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { SignInForm } from '@/features/auth/sign-in-form'
|
|
4
|
+
import { SocialButtons } from '@/features/auth/social-buttons' // prumo:social
|
|
4
5
|
|
|
5
6
|
export const Route = createFileRoute('/sign-in')({
|
|
6
|
-
validateSearch: z.object({
|
|
7
|
+
validateSearch: z.object({
|
|
8
|
+
redirect: z.string().optional(),
|
|
9
|
+
error: z.string().optional(), // prumo:social
|
|
10
|
+
}),
|
|
7
11
|
component: SignInPage,
|
|
8
12
|
})
|
|
9
13
|
|
|
10
14
|
function SignInPage() {
|
|
11
15
|
const { auth } = Route.useRouteContext()
|
|
12
|
-
const
|
|
16
|
+
const search = Route.useSearch()
|
|
13
17
|
|
|
14
18
|
return (
|
|
15
19
|
<>
|
|
16
20
|
<h1 className="text-2xl font-semibold">Sign in</h1>
|
|
17
|
-
|
|
21
|
+
{/* prumo:social */}
|
|
22
|
+
<SocialButtons auth={auth} returnTo={search.redirect} error={search.error} />
|
|
23
|
+
{/* prumo:end-social */}
|
|
24
|
+
<SignInForm auth={auth} redirect={search.redirect} />
|
|
18
25
|
</>
|
|
19
26
|
)
|
|
20
27
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createFileRoute } from '@tanstack/react-router'
|
|
2
2
|
import { SignUpForm } from '@/features/auth/sign-up-form'
|
|
3
|
+
import { SocialButtons } from '@/features/auth/social-buttons' // prumo:social
|
|
3
4
|
|
|
4
5
|
export const Route = createFileRoute('/sign-up')({
|
|
5
6
|
component: SignUpPage,
|
|
@@ -11,6 +12,9 @@ function SignUpPage() {
|
|
|
11
12
|
return (
|
|
12
13
|
<>
|
|
13
14
|
<h1 className="text-2xl font-semibold">Create an account</h1>
|
|
15
|
+
{/* prumo:social */}
|
|
16
|
+
<SocialButtons auth={auth} returnTo={undefined} error={undefined} />
|
|
17
|
+
{/* prumo:end-social */}
|
|
14
18
|
<SignUpForm auth={auth} />
|
|
15
19
|
</>
|
|
16
20
|
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { VerifyEmailForm } from '@/features/auth/verify-email-form'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/verify-email')({
|
|
6
|
+
validateSearch: z.object({ email: z.email() }),
|
|
7
|
+
component: VerifyEmailPage,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
function VerifyEmailPage() {
|
|
11
|
+
const { auth } = Route.useRouteContext()
|
|
12
|
+
const { email } = Route.useSearch()
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<h1 className="text-2xl font-semibold">Confirm your email</h1>
|
|
17
|
+
<VerifyEmailForm auth={auth} email={email} />
|
|
18
|
+
</>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { fakeTransport, json } from '@test/support/fake-transport'
|
|
2
|
+
import { renderApp } from '@test/support/render-app'
|
|
3
|
+
import { screen } from '@testing-library/react'
|
|
4
|
+
import userEvent from '@testing-library/user-event'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import type { Profile } from '@/api-contract'
|
|
7
|
+
|
|
8
|
+
const user = { id: 'user-1', email: 'ana@example.com' }
|
|
9
|
+
|
|
10
|
+
const profile: Profile = {
|
|
11
|
+
id: 'profile-1',
|
|
12
|
+
userId: user.id,
|
|
13
|
+
displayName: 'Ana',
|
|
14
|
+
locale: 'en',
|
|
15
|
+
timezone: 'UTC',
|
|
16
|
+
createdAt: '2026-09-14T12:57:24.057Z',
|
|
17
|
+
updatedAt: '2026-09-14T12:57:24.057Z',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const empty = (status: number) => new Response(null, { status })
|
|
21
|
+
|
|
22
|
+
const problem = (status: number, body: Record<string, unknown>) =>
|
|
23
|
+
json(status, { status, title: 'Error', ...body }, 'application/problem+json')
|
|
24
|
+
|
|
25
|
+
// A visitor whose session appears once `signedIn` flips, as cookies would.
|
|
26
|
+
function app(routes: Parameters<typeof fakeTransport>[0]) {
|
|
27
|
+
const state = { signedIn: false }
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
state,
|
|
31
|
+
transport: fakeTransport({
|
|
32
|
+
'GET /api/auth/session': () => (state.signedIn ? json(200, { user }) : json(401, {})),
|
|
33
|
+
'POST /api/auth/refresh': () => json(401, {}),
|
|
34
|
+
'GET /api/v1/users/me': () => json(200, profile),
|
|
35
|
+
...routes,
|
|
36
|
+
}),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('email verification on the web', () => {
|
|
41
|
+
it('takes a new account to the code, and into the app once it is entered', async () => {
|
|
42
|
+
const visitor = userEvent.setup()
|
|
43
|
+
const { state, transport } = app({
|
|
44
|
+
'POST /api/auth/sign-up': () => json(201, { verificationRequired: true }),
|
|
45
|
+
'POST /api/auth/email/verify': () => {
|
|
46
|
+
state.signedIn = true
|
|
47
|
+
return empty(204)
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
renderApp('/sign-up', transport)
|
|
52
|
+
|
|
53
|
+
await visitor.type(await screen.findByLabelText('Name'), 'Ana')
|
|
54
|
+
await visitor.type(screen.getByLabelText('Email'), user.email)
|
|
55
|
+
await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
|
|
56
|
+
await visitor.click(screen.getByRole('button', { name: 'Create account' }))
|
|
57
|
+
|
|
58
|
+
expect(await screen.findByRole('heading', { name: 'Confirm your email' })).toBeInTheDocument()
|
|
59
|
+
|
|
60
|
+
await visitor.type(screen.getByLabelText('Code'), '123456')
|
|
61
|
+
await visitor.click(screen.getByRole('button', { name: 'Confirm email' }))
|
|
62
|
+
|
|
63
|
+
expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('sends an unconfirmed account that signs in to the code page', async () => {
|
|
67
|
+
const visitor = userEvent.setup()
|
|
68
|
+
const { transport } = app({
|
|
69
|
+
'POST /api/auth/sign-in': () => problem(403, { code: 'email_not_verified' }),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
renderApp('/sign-in', transport)
|
|
73
|
+
|
|
74
|
+
await visitor.type(await screen.findByLabelText('Email'), user.email)
|
|
75
|
+
await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
|
|
76
|
+
await visitor.click(screen.getByRole('button', { name: 'Sign in' }))
|
|
77
|
+
|
|
78
|
+
expect(await screen.findByRole('heading', { name: 'Confirm your email' })).toBeInTheDocument()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('shows a wrong code on the code field', async () => {
|
|
82
|
+
const visitor = userEvent.setup()
|
|
83
|
+
const { transport } = app({
|
|
84
|
+
'POST /api/auth/email/verify': () =>
|
|
85
|
+
problem(422, { code: 'invalid_code', detail: 'The code is wrong or has expired' }),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
renderApp(`/verify-email?email=${user.email}`, transport)
|
|
89
|
+
|
|
90
|
+
await visitor.type(await screen.findByLabelText('Code'), '000000')
|
|
91
|
+
await visitor.click(screen.getByRole('button', { name: 'Confirm email' }))
|
|
92
|
+
|
|
93
|
+
expect(await screen.findByText(/The code is wrong or has expired/)).toBeInTheDocument()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('password reset on the web', () => {
|
|
98
|
+
it('goes from forgot to reset to the app', async () => {
|
|
99
|
+
const visitor = userEvent.setup()
|
|
100
|
+
const { state, transport } = app({
|
|
101
|
+
'POST /api/auth/password/forgot': () => empty(204),
|
|
102
|
+
'POST /api/auth/password/reset': () => {
|
|
103
|
+
state.signedIn = true
|
|
104
|
+
return empty(204)
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
renderApp('/sign-in', transport)
|
|
109
|
+
|
|
110
|
+
await visitor.click(await screen.findByRole('link', { name: 'Forgot your password?' }))
|
|
111
|
+
await screen.findByRole('heading', { name: 'Reset your password' })
|
|
112
|
+
await visitor.type(screen.getByLabelText('Email'), user.email)
|
|
113
|
+
await visitor.click(screen.getByRole('button', { name: 'Send a reset code' }))
|
|
114
|
+
await screen.findByRole('heading', { name: 'Choose a new password' })
|
|
115
|
+
await visitor.type(screen.getByLabelText('Code'), '123456')
|
|
116
|
+
await visitor.type(screen.getByLabelText('New password'), 'another-good-password')
|
|
117
|
+
await visitor.click(screen.getByRole('button', { name: 'Set the new password' }))
|
|
118
|
+
|
|
119
|
+
expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
|
|
2
|
+
import { renderApp } from '@test/support/render-app'
|
|
3
|
+
import { screen } from '@testing-library/react'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const signedOut = fakeTransport({
|
|
7
|
+
'GET /api/auth/session': () => json(401, {}),
|
|
8
|
+
'POST /api/auth/refresh': () => json(401, {}),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('SocialButtons', () => {
|
|
12
|
+
it('send the browser to the API, which returns it to the page it was turned away from', async () => {
|
|
13
|
+
renderApp('/sign-in?redirect=%2Forders', signedOut)
|
|
14
|
+
|
|
15
|
+
for (const name of [
|
|
16
|
+
'Continue with Google', // prumo:google
|
|
17
|
+
'Continue with Apple', // prumo:apple
|
|
18
|
+
]) {
|
|
19
|
+
const link = await screen.findByRole('link', { name })
|
|
20
|
+
const href = new URL(link.getAttribute('href') ?? '')
|
|
21
|
+
|
|
22
|
+
expect(`${href.origin}${href.pathname}`).toMatch(
|
|
23
|
+
new RegExp(`^${API_URL}/api/auth/social/(google|apple)$`),
|
|
24
|
+
)
|
|
25
|
+
expect(href.searchParams.get('client')).toBe('web')
|
|
26
|
+
expect(href.searchParams.get('returnTo')).toBe('/orders')
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('says why a provider sign-in came back without a session', async () => {
|
|
31
|
+
renderApp('/sign-in?error=provider_email_unverified', signedOut)
|
|
32
|
+
|
|
33
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('not verified with the provider')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -15,6 +15,10 @@ const view = {
|
|
|
15
15
|
clientHost: 'claude.example.com',
|
|
16
16
|
redirectHost: 'claude.example.com',
|
|
17
17
|
redirectsToThisDevice: false,
|
|
18
|
+
scopes: [
|
|
19
|
+
{ scope: 'profile:read', description: 'Read your profile' },
|
|
20
|
+
{ scope: 'profile:write', description: 'Change your display name, language and timezone' },
|
|
21
|
+
],
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
function renderForm(routes: Parameters<typeof fakeTransport>[0]) {
|
|
@@ -44,6 +48,9 @@ describe('ConsentForm', () => {
|
|
|
44
48
|
|
|
45
49
|
expect(await screen.findByText('Claude')).toBeInTheDocument()
|
|
46
50
|
expect(screen.getByText(/claude\.example\.com\)/)).toBeInTheDocument()
|
|
51
|
+
expect(screen.getByRole('list', { name: 'What it will be able to do' })).toHaveTextContent(
|
|
52
|
+
'Read your profile',
|
|
53
|
+
)
|
|
47
54
|
|
|
48
55
|
await visitor.click(screen.getByRole('button', { name: 'Allow' }))
|
|
49
56
|
|
package/dist/mcp.js
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
2
|
-
import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { setJsonc } from "./jsonc.js";
|
|
5
|
-
// The api and web templates ship with MCP. A project that answered no receives them without it: these files are
|
|
6
|
-
// deleted, these dependencies removed, and every marked line or block cut. Every other type has nothing to cut.
|
|
7
|
-
// The API's MCP is its server and the OAuth authorization server that issues its tokens, with the table that
|
|
8
|
-
// holds an authorization in a migration of its own, so a project without MCP never creates it.
|
|
9
|
-
export const FILES = {
|
|
10
|
-
api: [
|
|
11
|
-
'src/infra/mcp',
|
|
12
|
-
'src/domain/oauth',
|
|
13
|
-
'src/infra/oauth',
|
|
14
|
-
'src/infra/di/oauth.di.ts',
|
|
15
|
-
'src/infra/http/fetch-bridge.ts',
|
|
16
|
-
'src/infra/http/controllers/mcp.controller.ts',
|
|
17
|
-
'src/infra/http/controllers/oauth.controller.ts',
|
|
18
|
-
'src/infra/database/mikroorm/entities/authorization.schema.ts',
|
|
19
|
-
'src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts',
|
|
20
|
-
'migrations/Migration20261002210631_create_authorization.ts',
|
|
21
|
-
'test/domain/oauth',
|
|
22
|
-
'test/infra/oauth',
|
|
23
|
-
'test/infra/http/controllers/mcp.controller.spec.ts',
|
|
24
|
-
],
|
|
25
|
-
web: ['src/features/oauth', 'src/routes/_authenticated/consent.tsx', 'test/features/oauth'],
|
|
26
|
-
};
|
|
27
|
-
const DEPENDENCIES = {
|
|
28
|
-
api: ['@modelcontextprotocol/client', '@modelcontextprotocol/server'],
|
|
29
|
-
web: [],
|
|
30
|
-
};
|
|
31
|
-
const BLOCK_START = /^\s*\/\/ prumo:mcp$/;
|
|
32
|
-
const BLOCK_END = /^\s*\/\/ prumo:end-mcp$/;
|
|
33
|
-
// A marked import keeps its marker as a trailing comment, which the import sorter carries along.
|
|
34
|
-
const MARKED_LINE = /\s*\/\/ prumo:mcp$/;
|
|
35
|
-
export function cutMcp(text, keep, file = 'a file') {
|
|
36
|
-
const kept = [];
|
|
37
|
-
let inBlock = false;
|
|
38
|
-
for (const line of text.split('\n')) {
|
|
39
|
-
if (BLOCK_START.test(line)) {
|
|
40
|
-
if (inBlock) {
|
|
41
|
-
throw new Error(`${file} opens a prumo:mcp block inside another`);
|
|
42
|
-
}
|
|
43
|
-
inBlock = true;
|
|
44
|
-
}
|
|
45
|
-
else if (BLOCK_END.test(line)) {
|
|
46
|
-
if (!inBlock) {
|
|
47
|
-
throw new Error(`${file} closes a prumo:mcp block it never opened`);
|
|
48
|
-
}
|
|
49
|
-
inBlock = false;
|
|
50
|
-
}
|
|
51
|
-
else if (MARKED_LINE.test(line)) {
|
|
52
|
-
if (keep) {
|
|
53
|
-
kept.push(line.replace(MARKED_LINE, ''));
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
else if (keep || !inBlock) {
|
|
57
|
-
kept.push(line);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
if (inBlock) {
|
|
61
|
-
throw new Error(`${file} leaves a prumo:mcp block open`);
|
|
62
|
-
}
|
|
63
|
-
return kept.join('\n');
|
|
64
|
-
}
|
|
65
|
-
async function sourceFiles(directory) {
|
|
66
|
-
if (!existsSync(directory)) {
|
|
67
|
-
return [];
|
|
68
|
-
}
|
|
69
|
-
const entries = await readdir(directory, { withFileTypes: true, recursive: true });
|
|
70
|
-
return entries
|
|
71
|
-
.filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name))
|
|
72
|
-
.map((entry) => join(entry.parentPath, entry.name));
|
|
73
|
-
}
|
|
74
|
-
export async function applyMcp(app, type, enabled) {
|
|
75
|
-
const files = FILES[type];
|
|
76
|
-
const dependencies = DEPENDENCIES[type];
|
|
77
|
-
if (files === undefined || dependencies === undefined) {
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
if (!enabled) {
|
|
81
|
-
for (const file of files) {
|
|
82
|
-
await rm(join(app, file), { recursive: true, force: true });
|
|
83
|
-
}
|
|
84
|
-
const manifest = join(app, 'package.json');
|
|
85
|
-
let text = await readFile(manifest, 'utf8');
|
|
86
|
-
for (const section of ['dependencies', 'devDependencies']) {
|
|
87
|
-
for (const dependency of dependencies) {
|
|
88
|
-
text = setJsonc(text, [section, dependency], undefined);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
await writeFile(manifest, text);
|
|
92
|
-
}
|
|
93
|
-
const sources = [
|
|
94
|
-
...(await sourceFiles(join(app, 'src'))),
|
|
95
|
-
...(await sourceFiles(join(app, 'test'))),
|
|
96
|
-
];
|
|
97
|
-
for (const source of sources) {
|
|
98
|
-
const text = await readFile(source, 'utf8');
|
|
99
|
-
const cut = cutMcp(text, enabled, source);
|
|
100
|
-
if (!enabled && dependencies.some((dependency) => cut.includes(`'${dependency}`))) {
|
|
101
|
-
throw new Error(`${source} still imports an MCP package outside a prumo:mcp marker`);
|
|
102
|
-
}
|
|
103
|
-
if (cut !== text) {
|
|
104
|
-
await writeFile(source, cut);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|