@open-mercato/core 0.10.1-develop.7691.1.d8af406541 → 0.10.1-develop.7699.1.5236fabda0
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/autologin.js +3 -1
- package/dist/modules/auth/api/autologin.js.map +2 -2
- package/dist/modules/auth/api/login.js +4 -1
- package/dist/modules/auth/api/login.js.map +2 -2
- package/dist/modules/auth/lib/sessionIntegrity.js +2 -0
- package/dist/modules/auth/lib/sessionIntegrity.js.map +2 -2
- package/dist/modules/auth/lib/tenantEligibility.js +16 -0
- package/dist/modules/auth/lib/tenantEligibility.js.map +7 -0
- package/dist/modules/auth/services/authService.js +5 -0
- package/dist/modules/auth/services/authService.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.js +5 -5
- package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.js +5 -5
- package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/domain-mappings.js +36 -14
- package/dist/modules/customer_accounts/api/admin/domain-mappings.js.map +2 -2
- package/dist/modules/customer_accounts/api/login.js +2 -1
- package/dist/modules/customer_accounts/api/login.js.map +2 -2
- package/dist/modules/customer_accounts/lib/customerAuth.js +2 -0
- package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
- package/dist/modules/customer_accounts/lib/tenantEligibility.js +16 -0
- package/dist/modules/customer_accounts/lib/tenantEligibility.js.map +7 -0
- package/dist/modules/customer_accounts/services/customerInvitationService.js +2 -0
- package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerSessionService.js +2 -0
- package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerTokenService.js +2 -0
- package/dist/modules/customer_accounts/services/customerTokenService.js.map +2 -2
- package/dist/modules/customer_accounts/services/domainMappingService.js +5 -2
- package/dist/modules/customer_accounts/services/domainMappingService.js.map +2 -2
- package/dist/modules/customers/components/linking/LinkEntityDialog.js +10 -2
- package/dist/modules/customers/components/linking/LinkEntityDialog.js.map +2 -2
- package/dist/modules/sales/backend/sales/documents/[id]/page.js +17 -3
- package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/autologin.ts +3 -1
- package/src/modules/auth/api/login.ts +4 -1
- package/src/modules/auth/lib/sessionIntegrity.ts +2 -0
- package/src/modules/auth/lib/tenantEligibility.ts +17 -0
- package/src/modules/auth/services/authService.ts +5 -0
- package/src/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.ts +5 -5
- package/src/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.ts +5 -5
- package/src/modules/customer_accounts/api/admin/domain-mappings.ts +38 -16
- package/src/modules/customer_accounts/api/login.ts +3 -1
- package/src/modules/customer_accounts/lib/customerAuth.ts +2 -0
- package/src/modules/customer_accounts/lib/tenantEligibility.ts +14 -0
- package/src/modules/customer_accounts/services/customerInvitationService.ts +2 -0
- package/src/modules/customer_accounts/services/customerSessionService.ts +2 -0
- package/src/modules/customer_accounts/services/customerTokenService.ts +2 -0
- package/src/modules/customer_accounts/services/domainMappingService.ts +3 -0
- package/src/modules/customers/components/linking/LinkEntityDialog.tsx +10 -2
- package/src/modules/sales/backend/sales/documents/[id]/page.tsx +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7699.1.5236fabda0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -265,16 +265,16 @@
|
|
|
265
265
|
"zod": "^4.4.3"
|
|
266
266
|
},
|
|
267
267
|
"peerDependencies": {
|
|
268
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
269
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
268
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7699.1.5236fabda0",
|
|
269
|
+
"@open-mercato/shared": "0.10.1-develop.7699.1.5236fabda0",
|
|
270
|
+
"@open-mercato/ui": "0.10.1-develop.7699.1.5236fabda0",
|
|
271
271
|
"react": "^19.0.0",
|
|
272
272
|
"react-dom": "^19.0.0"
|
|
273
273
|
},
|
|
274
274
|
"devDependencies": {
|
|
275
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
276
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
275
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7699.1.5236fabda0",
|
|
276
|
+
"@open-mercato/shared": "0.10.1-develop.7699.1.5236fabda0",
|
|
277
|
+
"@open-mercato/ui": "0.10.1-develop.7699.1.5236fabda0",
|
|
278
278
|
"@testing-library/dom": "^10.4.1",
|
|
279
279
|
"@testing-library/jest-dom": "^7.0.1",
|
|
280
280
|
"@testing-library/react": "^16.3.3",
|
|
@@ -8,6 +8,7 @@ import { buildSafeRedirectResponse, resolveTrustedRedirectBase } from '@open-mer
|
|
|
8
8
|
import { sanitizeRedirectPath } from '@open-mercato/core/modules/auth/lib/safeRedirect'
|
|
9
9
|
import { resolveAutoLoginCredentials } from '@open-mercato/core/modules/auth/lib/autologin'
|
|
10
10
|
import { emitAuthEvent } from '@open-mercato/core/modules/auth/events'
|
|
11
|
+
import { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'
|
|
11
12
|
|
|
12
13
|
export const metadata = { requireAuth: false }
|
|
13
14
|
|
|
@@ -47,7 +48,8 @@ export async function GET(req: Request) {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
const ok = await auth.verifyPassword(user, credentials.password)
|
|
50
|
-
|
|
51
|
+
const tenantActive = user ? await isStaffTenantActive(container.resolve('em'), user.tenantId) : false
|
|
52
|
+
if (!user || !ok || !tenantActive) {
|
|
51
53
|
// Misconfigured demo credentials — never loop; drop to the manual form.
|
|
52
54
|
logger.warn('OM_AUTOLOGIN credentials did not resolve to a single valid user; falling back to login')
|
|
53
55
|
return buildSafeRedirectResponse(req, '/login')
|
|
@@ -16,6 +16,7 @@ import { runCustomRouteAfterInterceptors } from '@open-mercato/shared/lib/crud/c
|
|
|
16
16
|
import { sanitizeRedirectPath } from '@open-mercato/core/modules/auth/lib/safeRedirect'
|
|
17
17
|
import { getAppBaseUrl, isCrossSiteBrowserRequest } from '@open-mercato/shared/lib/url'
|
|
18
18
|
import { handleAuthRouteError } from '@open-mercato/core/modules/auth/api/routeError'
|
|
19
|
+
import { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'
|
|
19
20
|
|
|
20
21
|
const loginRateLimitConfig = readEndpointRateLimitConfig('LOGIN', {
|
|
21
22
|
points: 5, duration: 60, blockDuration: 60, keyPrefix: 'login',
|
|
@@ -146,13 +147,15 @@ async function handleLoginRequest(req: Request) {
|
|
|
146
147
|
// wrong-password, and multi-tenant cases return an identical 401 with
|
|
147
148
|
// identical latency.
|
|
148
149
|
const ok = await auth.verifyPassword(user, parsed.data.password)
|
|
149
|
-
|
|
150
|
+
const tenantActive = user ? await isStaffTenantActive(container.resolve('em'), user.tenantId) : false
|
|
151
|
+
if (!user || !ok || user.isConfirmed === false || !tenantActive) {
|
|
150
152
|
// The 401 body stays identical for every branch so the response never reveals
|
|
151
153
|
// which one fired. `reason` goes to the audit stream instead, where separating a
|
|
152
154
|
// deactivated account from a mistyped password is the whole point — otherwise
|
|
153
155
|
// repeated attempts against a disabled account look like ordinary fat-fingering.
|
|
154
156
|
let reason: string
|
|
155
157
|
if (user && user.isConfirmed === false) reason = 'account_deactivated'
|
|
158
|
+
else if (user && !tenantActive) reason = 'tenant_unavailable'
|
|
156
159
|
else if (user?.passwordHash) reason = 'invalid_password'
|
|
157
160
|
else reason = 'invalid_credentials'
|
|
158
161
|
void emitAuthEvent('auth.login.failed', { email: parsed.data.email, reason }).catch(() => undefined)
|
|
@@ -2,6 +2,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
2
2
|
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
3
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
4
|
import { Role, RoleAcl, Session, User, UserAcl, UserRole } from '@open-mercato/core/modules/auth/data/entities'
|
|
5
|
+
import { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'
|
|
5
6
|
|
|
6
7
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
7
8
|
const INVALID_SCOPE = Symbol('invalid-scope')
|
|
@@ -106,6 +107,7 @@ export async function resolveCanonicalStaffAuthContext(
|
|
|
106
107
|
) {
|
|
107
108
|
return null
|
|
108
109
|
}
|
|
110
|
+
if (!(await isStaffTenantActive(em, currentTenantId))) return null
|
|
109
111
|
|
|
110
112
|
// Role links and the per-user super-admin flag are likewise independent, so they
|
|
111
113
|
// run concurrently. The role-level super-admin lookup depends on the resolved
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { Tenant } from '@open-mercato/core/modules/directory/data/entities'
|
|
3
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
|
|
5
|
+
export async function isStaffTenantActive(
|
|
6
|
+
em: EntityManager,
|
|
7
|
+
tenantId: string | null | undefined,
|
|
8
|
+
): Promise<boolean> {
|
|
9
|
+
if (tenantId == null) return true
|
|
10
|
+
const tenant = await findOneWithDecryption(
|
|
11
|
+
em,
|
|
12
|
+
Tenant,
|
|
13
|
+
{ id: tenantId, isActive: true, deletedAt: null },
|
|
14
|
+
{ fields: ['id'], refresh: true },
|
|
15
|
+
)
|
|
16
|
+
return tenant !== null
|
|
17
|
+
}
|
|
@@ -4,6 +4,7 @@ import { User, Role, UserRole, Session, PasswordReset } from '@open-mercato/core
|
|
|
4
4
|
import { emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/emailHash'
|
|
5
5
|
import { generateAuthToken, hashAuthToken } from '@open-mercato/core/modules/auth/lib/tokenHash'
|
|
6
6
|
import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
7
|
+
import { isStaffTenantActive } from '@open-mercato/core/modules/auth/lib/tenantEligibility'
|
|
7
8
|
|
|
8
9
|
// A fixed, valid bcrypt hash (cost 10) of a throwaway value no real password
|
|
9
10
|
// can match. verifyPassword compares against it whenever the user is missing or
|
|
@@ -95,6 +96,9 @@ export class AuthService {
|
|
|
95
96
|
|
|
96
97
|
|
|
97
98
|
async createSession(user: User, expiresAt: Date): Promise<{ session: Session; token: string }> {
|
|
99
|
+
if (!(await isStaffTenantActive(this.em, user.tenantId))) {
|
|
100
|
+
throw new Error('[internal] Tenant is not available for authentication')
|
|
101
|
+
}
|
|
98
102
|
const rawToken = generateAuthToken()
|
|
99
103
|
const tokenHash = hashAuthToken(rawToken)
|
|
100
104
|
const sess = this.em.create(Session as any, { user, token: tokenHash, expiresAt, createdAt: new Date() } as any)
|
|
@@ -129,6 +133,7 @@ export class AuthService {
|
|
|
129
133
|
if (!sess || sess.expiresAt <= now) return null
|
|
130
134
|
const user = await findOneWithDecryption(this.em, User, { id: sess.user.id, deletedAt: null })
|
|
131
135
|
if (!user) return null
|
|
136
|
+
if (!(await isStaffTenantActive(this.em, user.tenantId))) return null
|
|
132
137
|
const roles = await this.getUserRoles(user, user.tenantId ?? null)
|
|
133
138
|
return { user, roles, session: sess }
|
|
134
139
|
}
|
|
@@ -22,16 +22,16 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
|
|
|
22
22
|
|
|
23
23
|
const container = await createRequestContainer()
|
|
24
24
|
const rbac = container.resolve('rbacService') as RbacService
|
|
25
|
-
const allowed = await rbac.userHasAllFeatures(auth.sub, [FEATURE], {
|
|
26
|
-
tenantId: auth.tenantId,
|
|
27
|
-
organizationId: auth.orgId,
|
|
28
|
-
})
|
|
29
|
-
if (!allowed) return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
|
|
30
25
|
|
|
31
26
|
const { id } = await ctx.params
|
|
32
27
|
const service = container.resolve('domainMappingService') as DomainMappingService
|
|
33
28
|
const record = await service.findById(id, { tenantId: auth.tenantId })
|
|
34
29
|
if (!record) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
|
|
30
|
+
const allowed = await rbac.userHasAllFeatures(auth.sub, [FEATURE], {
|
|
31
|
+
tenantId: auth.tenantId,
|
|
32
|
+
organizationId: record.organizationId,
|
|
33
|
+
})
|
|
34
|
+
if (!allowed) return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
|
|
35
35
|
|
|
36
36
|
const guardResult = await validateCrudMutationGuard(container, {
|
|
37
37
|
tenantId: auth.tenantId,
|
|
@@ -22,16 +22,16 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
|
|
|
22
22
|
|
|
23
23
|
const container = await createRequestContainer()
|
|
24
24
|
const rbac = container.resolve('rbacService') as RbacService
|
|
25
|
-
const allowed = await rbac.userHasAllFeatures(auth.sub, [FEATURE], {
|
|
26
|
-
tenantId: auth.tenantId,
|
|
27
|
-
organizationId: auth.orgId,
|
|
28
|
-
})
|
|
29
|
-
if (!allowed) return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
|
|
30
25
|
|
|
31
26
|
const { id } = await ctx.params
|
|
32
27
|
const service = container.resolve('domainMappingService') as DomainMappingService
|
|
33
28
|
const record = await service.findById(id, { tenantId: auth.tenantId })
|
|
34
29
|
if (!record) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
|
|
30
|
+
const allowed = await rbac.userHasAllFeatures(auth.sub, [FEATURE], {
|
|
31
|
+
tenantId: auth.tenantId,
|
|
32
|
+
organizationId: record.organizationId,
|
|
33
|
+
})
|
|
34
|
+
if (!allowed) return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
|
|
35
35
|
|
|
36
36
|
const guardResult = await validateCrudMutationGuard(container, {
|
|
37
37
|
tenantId: auth.tenantId,
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { z } from 'zod'
|
|
3
|
-
import { UniqueConstraintViolationException } from '@mikro-orm/core'
|
|
3
|
+
import { UniqueConstraintViolationException, type FilterQuery } from '@mikro-orm/core'
|
|
4
4
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
5
5
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
6
6
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
7
7
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
8
8
|
import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
9
9
|
import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
|
|
10
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
10
11
|
import {
|
|
11
12
|
validateCrudMutationGuard,
|
|
12
13
|
runCrudMutationGuardAfterSuccess,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
type ResolveResult,
|
|
19
20
|
} from '@open-mercato/core/modules/customer_accounts/services/domainMappingService'
|
|
20
21
|
import { DomainMapping } from '@open-mercato/core/modules/customer_accounts/data/entities'
|
|
22
|
+
import { Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
21
23
|
|
|
22
24
|
const FEATURE = 'customer_accounts.domain.manage'
|
|
23
25
|
|
|
@@ -59,7 +61,7 @@ function serializeRecord(record: DomainMapping) {
|
|
|
59
61
|
|
|
60
62
|
export async function GET(req: Request) {
|
|
61
63
|
const auth = await getAuthFromRequest(req)
|
|
62
|
-
if (!auth) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
|
64
|
+
if (!auth || !auth.tenantId) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
|
63
65
|
|
|
64
66
|
const container = await createRequestContainer()
|
|
65
67
|
const rbac = container.resolve('rbacService') as RbacService
|
|
@@ -71,11 +73,31 @@ export async function GET(req: Request) {
|
|
|
71
73
|
|
|
72
74
|
const url = new URL(req.url)
|
|
73
75
|
const orgFilter = url.searchParams.get('organizationId')
|
|
76
|
+
if (orgFilter && !await rbac.userHasAllFeatures(auth.sub, [FEATURE], {
|
|
77
|
+
tenantId: auth.tenantId,
|
|
78
|
+
organizationId: orgFilter,
|
|
79
|
+
})) {
|
|
80
|
+
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
|
|
81
|
+
}
|
|
74
82
|
|
|
75
83
|
const em = container.resolve('em') as EntityManager
|
|
76
|
-
const where:
|
|
77
|
-
if (orgFilter)
|
|
78
|
-
|
|
84
|
+
const where: FilterQuery<DomainMapping> = { tenantId: auth.tenantId }
|
|
85
|
+
if (orgFilter) {
|
|
86
|
+
where.organizationId = orgFilter
|
|
87
|
+
} else {
|
|
88
|
+
const featureAccess = await rbac.resolveFeatureOrganizationAccess(auth.sub, [FEATURE], { tenantId: auth.tenantId })
|
|
89
|
+
if (!featureAccess.unrestricted) {
|
|
90
|
+
const organizations = await findWithDecryption(em, Organization, {
|
|
91
|
+
tenant: auth.tenantId,
|
|
92
|
+
deletedAt: null,
|
|
93
|
+
}, { fields: ['id', 'ancestorIds'] }, { tenantId: auth.tenantId })
|
|
94
|
+
where.organizationId = { $in: featureAccess.filterOrganizationIds(organizations) }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const records = await findWithDecryption(em, DomainMapping, where, { orderBy: { createdAt: 'desc' } }, {
|
|
98
|
+
tenantId: auth.tenantId,
|
|
99
|
+
organizationId: orgFilter,
|
|
100
|
+
})
|
|
79
101
|
|
|
80
102
|
return NextResponse.json({
|
|
81
103
|
ok: true,
|
|
@@ -93,11 +115,6 @@ export async function POST(req: Request) {
|
|
|
93
115
|
|
|
94
116
|
const container = await createRequestContainer()
|
|
95
117
|
const rbac = container.resolve('rbacService') as RbacService
|
|
96
|
-
const allowed = await rbac.userHasAllFeatures(auth.sub, [FEATURE], {
|
|
97
|
-
tenantId: auth.tenantId,
|
|
98
|
-
organizationId: auth.orgId,
|
|
99
|
-
})
|
|
100
|
-
if (!allowed) return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
|
|
101
118
|
|
|
102
119
|
const body = await readJsonSafe(req, {})
|
|
103
120
|
const parsed = registerDomainSchema.safeParse(body)
|
|
@@ -107,6 +124,11 @@ export async function POST(req: Request) {
|
|
|
107
124
|
{ status: 400 },
|
|
108
125
|
)
|
|
109
126
|
}
|
|
127
|
+
const allowed = await rbac.userHasAllFeatures(auth.sub, [FEATURE], {
|
|
128
|
+
tenantId: auth.tenantId,
|
|
129
|
+
organizationId: parsed.data.organizationId,
|
|
130
|
+
})
|
|
131
|
+
if (!allowed) return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
|
|
110
132
|
|
|
111
133
|
const guardResult = await validateCrudMutationGuard(container, {
|
|
112
134
|
tenantId: auth.tenantId,
|
|
@@ -176,11 +198,6 @@ export async function DELETE(req: Request) {
|
|
|
176
198
|
|
|
177
199
|
const container = await createRequestContainer()
|
|
178
200
|
const rbac = container.resolve('rbacService') as RbacService
|
|
179
|
-
const allowed = await rbac.userHasAllFeatures(auth.sub, [FEATURE], {
|
|
180
|
-
tenantId: auth.tenantId,
|
|
181
|
-
organizationId: auth.orgId,
|
|
182
|
-
})
|
|
183
|
-
if (!allowed) return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
|
|
184
201
|
|
|
185
202
|
const url = new URL(req.url)
|
|
186
203
|
const id = url.searchParams.get('id')
|
|
@@ -189,6 +206,11 @@ export async function DELETE(req: Request) {
|
|
|
189
206
|
const service = container.resolve('domainMappingService') as DomainMappingService
|
|
190
207
|
const existing = await service.findById(id, { tenantId: auth.tenantId })
|
|
191
208
|
if (!existing) return NextResponse.json({ ok: false, error: 'Not found' }, { status: 404 })
|
|
209
|
+
const allowed = await rbac.userHasAllFeatures(auth.sub, [FEATURE], {
|
|
210
|
+
tenantId: auth.tenantId,
|
|
211
|
+
organizationId: existing.organizationId,
|
|
212
|
+
})
|
|
213
|
+
if (!allowed) return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
|
|
192
214
|
|
|
193
215
|
const guardResult = await validateCrudMutationGuard(container, {
|
|
194
216
|
tenantId: auth.tenantId,
|
|
@@ -248,7 +270,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
248
270
|
methods: {
|
|
249
271
|
GET: {
|
|
250
272
|
summary: 'List domain mappings',
|
|
251
|
-
description: 'Returns
|
|
273
|
+
description: 'Returns custom-domain mappings for authorized organizations in the current tenant, optionally filtered by organization.',
|
|
252
274
|
responses: [
|
|
253
275
|
{
|
|
254
276
|
status: 200,
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
} from '@open-mercato/core/modules/customer_accounts/lib/resolveTenantContext'
|
|
24
24
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
25
25
|
import { isCrossSiteBrowserRequest } from '@open-mercato/shared/lib/url'
|
|
26
|
+
import { isCustomerTenantActive } from '@open-mercato/core/modules/customer_accounts/lib/tenantEligibility'
|
|
27
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
26
28
|
|
|
27
29
|
export const metadata: { path?: string; requireAuth?: boolean } = { requireAuth: false }
|
|
28
30
|
|
|
@@ -86,7 +88,7 @@ export async function POST(req: Request) {
|
|
|
86
88
|
// password-less, inactive, locked, wrong-password, and unverified accounts all run a
|
|
87
89
|
// bcrypt comparison and return the same generic 401 — lockout/deactivation guidance is
|
|
88
90
|
// conveyed out-of-band (e.g. email), never in the synchronous response.
|
|
89
|
-
if (!user || !user.passwordHash) {
|
|
91
|
+
if (!user || !user.passwordHash || !(await isCustomerTenantActive(container.resolve('em') as EntityManager, tenantId))) {
|
|
90
92
|
await bcryptCompare(password, TIMING_EQUALIZATION_HASH)
|
|
91
93
|
void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'invalid_credentials', tenantId }).catch(reportLoginEventError)
|
|
92
94
|
return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
|
|
@@ -3,6 +3,7 @@ import { verifyAudienceJwt, verifyJwt } from '@open-mercato/shared/lib/auth/jwt'
|
|
|
3
3
|
import type { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'
|
|
4
4
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
5
|
import { CUSTOMER_JWT_AUDIENCE } from '@open-mercato/core/modules/customer_accounts/services/customerSessionService'
|
|
6
|
+
import { isCustomerTenantActive } from './tenantEligibility'
|
|
6
7
|
|
|
7
8
|
export interface CustomerAuthContext {
|
|
8
9
|
sub: string
|
|
@@ -103,6 +104,7 @@ export async function validateUserState(
|
|
|
103
104
|
if (user.sessionsRevokedAt && typeof iat === 'number' && iat * 1000 < user.sessionsRevokedAt.getTime()) {
|
|
104
105
|
return { valid: false }
|
|
105
106
|
}
|
|
107
|
+
if (!(await isCustomerTenantActive(em, tenantId))) return { valid: false }
|
|
106
108
|
|
|
107
109
|
const { CustomerRbacService } = await import(
|
|
108
110
|
'@open-mercato/core/modules/customer_accounts/services/customerRbacService'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { Tenant } from '@open-mercato/core/modules/directory/data/entities'
|
|
3
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
|
|
5
|
+
export async function isCustomerTenantActive(em: EntityManager, tenantId: string): Promise<boolean> {
|
|
6
|
+
if (!tenantId) return false
|
|
7
|
+
const tenant = await findOneWithDecryption(
|
|
8
|
+
em,
|
|
9
|
+
Tenant,
|
|
10
|
+
{ id: tenantId, isActive: true, deletedAt: null },
|
|
11
|
+
{ refresh: true },
|
|
12
|
+
)
|
|
13
|
+
return Boolean(tenant)
|
|
14
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { generateSecureToken, hashToken } from '@open-mercato/core/modules/customer_accounts/lib/tokenGenerator'
|
|
11
11
|
import { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
|
|
12
12
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
13
|
+
import { isCustomerTenantActive } from '@open-mercato/core/modules/customer_accounts/lib/tenantEligibility'
|
|
13
14
|
|
|
14
15
|
const BCRYPT_COST = 10
|
|
15
16
|
const INVITATION_TTL_MS = 72 * 60 * 60 * 1000 // 72 hours
|
|
@@ -207,6 +208,7 @@ export class CustomerInvitationService {
|
|
|
207
208
|
): Promise<{ user: CustomerUser; invitation: CustomerUserInvitation } | null> {
|
|
208
209
|
const invitation = await this.findByToken(token)
|
|
209
210
|
if (!invitation) return null
|
|
211
|
+
if (!(await isCustomerTenantActive(this.em, invitation.tenantId))) return null
|
|
210
212
|
|
|
211
213
|
const emailHash = hashForLookup(invitation.email)
|
|
212
214
|
|
|
@@ -3,6 +3,7 @@ import { CustomerUser, CustomerUserSession } from '@open-mercato/core/modules/cu
|
|
|
3
3
|
import { generateSecureToken, hashToken } from '@open-mercato/core/modules/customer_accounts/lib/tokenGenerator'
|
|
4
4
|
import { signAudienceJwt } from '@open-mercato/shared/lib/auth/jwt'
|
|
5
5
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
6
|
+
import { isCustomerTenantActive } from '@open-mercato/core/modules/customer_accounts/lib/tenantEligibility'
|
|
6
7
|
|
|
7
8
|
export const CUSTOMER_JWT_AUDIENCE = 'customer'
|
|
8
9
|
const CUSTOMER_JWT_TTL_SECONDS = 60 * 60 * 8
|
|
@@ -90,6 +91,7 @@ export class CustomerSessionService {
|
|
|
90
91
|
if (!session) return null
|
|
91
92
|
const user = session.user as CustomerUser
|
|
92
93
|
if (!user || user.deletedAt || !user.isActive) return null
|
|
94
|
+
if (!(await isCustomerTenantActive(this.em, user.tenantId))) return null
|
|
93
95
|
|
|
94
96
|
await this.em.nativeUpdate(CustomerUserSession, { id: session.id }, { lastUsedAt: new Date() })
|
|
95
97
|
const jwt = this.signCustomerJwt(user, resolvedFeatures, session.id)
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
CustomerUserPasswordReset,
|
|
7
7
|
} from '@open-mercato/core/modules/customer_accounts/data/entities'
|
|
8
8
|
import { generateSecureToken, hashToken } from '@open-mercato/core/modules/customer_accounts/lib/tokenGenerator'
|
|
9
|
+
import { isCustomerTenantActive } from '@open-mercato/core/modules/customer_accounts/lib/tenantEligibility'
|
|
9
10
|
|
|
10
11
|
function getKysely(em: EntityManager): Kysely<any> {
|
|
11
12
|
return (em as unknown as { getKysely: () => Kysely<any> }).getKysely()
|
|
@@ -74,6 +75,7 @@ export class CustomerTokenService {
|
|
|
74
75
|
|
|
75
76
|
const user = record.user as CustomerUser
|
|
76
77
|
if (tenantId && user?.tenantId !== tenantId) return null
|
|
78
|
+
if (purpose === 'magic_link' && !(await isCustomerTenantActive(this.em, user?.tenantId))) return null
|
|
77
79
|
|
|
78
80
|
const db = getKysely(this.em)
|
|
79
81
|
const updateResult = await db
|
|
@@ -287,6 +287,7 @@ export class DomainMappingService {
|
|
|
287
287
|
replacesDomain = await this.em.findOne(DomainMapping, {
|
|
288
288
|
id: input.replacesDomainId,
|
|
289
289
|
tenantId: input.tenantId,
|
|
290
|
+
organizationId: input.organizationId,
|
|
290
291
|
} as never)
|
|
291
292
|
}
|
|
292
293
|
|
|
@@ -383,6 +384,8 @@ export class DomainMappingService {
|
|
|
383
384
|
if (entity.replacesDomain) {
|
|
384
385
|
const replaced = await this.em.findOne(DomainMapping, {
|
|
385
386
|
id: (entity.replacesDomain as unknown as { id: string }).id,
|
|
387
|
+
tenantId: entity.tenantId,
|
|
388
|
+
organizationId: entity.organizationId,
|
|
386
389
|
} as never)
|
|
387
390
|
if (replaced) {
|
|
388
391
|
const replacedHostname = replaced.hostname
|
|
@@ -403,11 +403,19 @@ export function LinkEntityDialog<TDetails = unknown, TLinkSettings = Record<stri
|
|
|
403
403
|
|
|
404
404
|
const handleRowClick = React.useCallback(
|
|
405
405
|
(option: LinkEntityOption) => {
|
|
406
|
-
setFocusedId(option.id)
|
|
407
406
|
const isSelected = draftSet.has(option.id)
|
|
408
407
|
toggleDraftId(option.id, !isSelected)
|
|
408
|
+
if (!isSelected) {
|
|
409
|
+
setFocusedId(option.id)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
setFocusedId((current) => {
|
|
413
|
+
if (current !== option.id) return current
|
|
414
|
+
const remaining = draftIds.filter((id) => id !== option.id)
|
|
415
|
+
return remaining.length > 0 ? remaining[0] : null
|
|
416
|
+
})
|
|
409
417
|
},
|
|
410
|
-
[draftSet, toggleDraftId],
|
|
418
|
+
[draftIds, draftSet, toggleDraftId],
|
|
411
419
|
)
|
|
412
420
|
|
|
413
421
|
const handleAddNewCreated = React.useCallback(
|
|
@@ -904,6 +904,7 @@ type DocumentRecord = {
|
|
|
904
904
|
metadata?: Record<string, unknown> | null
|
|
905
905
|
customFields?: Record<string, unknown> | null
|
|
906
906
|
tags?: TagOption[] | null
|
|
907
|
+
validUntil?: string | null
|
|
907
908
|
}
|
|
908
909
|
|
|
909
910
|
type DocumentUpdateResult = {
|
|
@@ -972,6 +973,18 @@ const resolveLineItemCount = (record: unknown): number | null => {
|
|
|
972
973
|
return Number.isFinite(current) ? current : null
|
|
973
974
|
}
|
|
974
975
|
|
|
976
|
+
const DEFAULT_QUOTE_VALID_FOR_DAYS = 14
|
|
977
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
|
978
|
+
|
|
979
|
+
export function resolveDefaultValidForDays(validUntil: string | null | undefined): number {
|
|
980
|
+
if (!validUntil) return DEFAULT_QUOTE_VALID_FOR_DAYS
|
|
981
|
+
const target = new Date(validUntil)
|
|
982
|
+
if (Number.isNaN(target.getTime())) return DEFAULT_QUOTE_VALID_FOR_DAYS
|
|
983
|
+
const daysRemaining = Math.ceil((target.getTime() - Date.now()) / MS_PER_DAY)
|
|
984
|
+
if (daysRemaining < 1) return DEFAULT_QUOTE_VALID_FOR_DAYS
|
|
985
|
+
return Math.min(daysRemaining, 365)
|
|
986
|
+
}
|
|
987
|
+
|
|
975
988
|
async function fetchDocument(id: string, kind: 'order' | 'quote', errorMessage: string): Promise<DocumentRecord | null> {
|
|
976
989
|
const params = new URLSearchParams({ id, page: '1', pageSize: '1' })
|
|
977
990
|
const payload = await readApiResultOrThrow<{ items?: DocumentRecord[] }>(
|
|
@@ -1926,7 +1939,7 @@ export default function SalesDocumentDetailPage({
|
|
|
1926
1939
|
const [deleting, setDeleting] = React.useState(false)
|
|
1927
1940
|
const [sending, setSending] = React.useState(false)
|
|
1928
1941
|
const [sendOpen, setSendOpen] = React.useState(false)
|
|
1929
|
-
const [validForDays, setValidForDays] = React.useState(
|
|
1942
|
+
const [validForDays, setValidForDays] = React.useState(DEFAULT_QUOTE_VALID_FOR_DAYS)
|
|
1930
1943
|
const [numberEditing, setNumberEditing] = React.useState(false)
|
|
1931
1944
|
const [canEditNumber, setCanEditNumber] = React.useState(false)
|
|
1932
1945
|
const [currencyError, setCurrencyError] = React.useState<string | null>(null)
|
|
@@ -4688,7 +4701,7 @@ export default function SalesDocumentDetailPage({
|
|
|
4688
4701
|
) : undefined}
|
|
4689
4702
|
menuActions={kind === 'quote' ? ([
|
|
4690
4703
|
{ id: 'convert', label: t('sales.documents.detail.convertToOrder', 'Convert to order'), icon: ArrowRightLeft, onSelect: () => void handleConvert(), disabled: converting, loading: converting },
|
|
4691
|
-
{ id: 'send', label: t('sales.quotes.send.action', 'Send to customer'), icon: Send, onSelect: () => setSendOpen(true), disabled: !contactEmail || sending, loading: sending },
|
|
4704
|
+
{ id: 'send', label: t('sales.quotes.send.action', 'Send to customer'), icon: Send, onSelect: () => { setValidForDays(resolveDefaultValidForDays(record.validUntil)); setSendOpen(true) }, disabled: !contactEmail || sending, loading: sending },
|
|
4692
4705
|
] satisfies ActionItem[]) : undefined}
|
|
4693
4706
|
onDelete={() => void handleDelete()}
|
|
4694
4707
|
isDeleting={deleting}
|