@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.
Files changed (53) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/autologin.js +3 -1
  3. package/dist/modules/auth/api/autologin.js.map +2 -2
  4. package/dist/modules/auth/api/login.js +4 -1
  5. package/dist/modules/auth/api/login.js.map +2 -2
  6. package/dist/modules/auth/lib/sessionIntegrity.js +2 -0
  7. package/dist/modules/auth/lib/sessionIntegrity.js.map +2 -2
  8. package/dist/modules/auth/lib/tenantEligibility.js +16 -0
  9. package/dist/modules/auth/lib/tenantEligibility.js.map +7 -0
  10. package/dist/modules/auth/services/authService.js +5 -0
  11. package/dist/modules/auth/services/authService.js.map +2 -2
  12. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.js +5 -5
  13. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.js.map +2 -2
  14. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.js +5 -5
  15. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.js.map +2 -2
  16. package/dist/modules/customer_accounts/api/admin/domain-mappings.js +36 -14
  17. package/dist/modules/customer_accounts/api/admin/domain-mappings.js.map +2 -2
  18. package/dist/modules/customer_accounts/api/login.js +2 -1
  19. package/dist/modules/customer_accounts/api/login.js.map +2 -2
  20. package/dist/modules/customer_accounts/lib/customerAuth.js +2 -0
  21. package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
  22. package/dist/modules/customer_accounts/lib/tenantEligibility.js +16 -0
  23. package/dist/modules/customer_accounts/lib/tenantEligibility.js.map +7 -0
  24. package/dist/modules/customer_accounts/services/customerInvitationService.js +2 -0
  25. package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
  26. package/dist/modules/customer_accounts/services/customerSessionService.js +2 -0
  27. package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
  28. package/dist/modules/customer_accounts/services/customerTokenService.js +2 -0
  29. package/dist/modules/customer_accounts/services/customerTokenService.js.map +2 -2
  30. package/dist/modules/customer_accounts/services/domainMappingService.js +5 -2
  31. package/dist/modules/customer_accounts/services/domainMappingService.js.map +2 -2
  32. package/dist/modules/customers/components/linking/LinkEntityDialog.js +10 -2
  33. package/dist/modules/customers/components/linking/LinkEntityDialog.js.map +2 -2
  34. package/dist/modules/sales/backend/sales/documents/[id]/page.js +17 -3
  35. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  36. package/package.json +7 -7
  37. package/src/modules/auth/api/autologin.ts +3 -1
  38. package/src/modules/auth/api/login.ts +4 -1
  39. package/src/modules/auth/lib/sessionIntegrity.ts +2 -0
  40. package/src/modules/auth/lib/tenantEligibility.ts +17 -0
  41. package/src/modules/auth/services/authService.ts +5 -0
  42. package/src/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.ts +5 -5
  43. package/src/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.ts +5 -5
  44. package/src/modules/customer_accounts/api/admin/domain-mappings.ts +38 -16
  45. package/src/modules/customer_accounts/api/login.ts +3 -1
  46. package/src/modules/customer_accounts/lib/customerAuth.ts +2 -0
  47. package/src/modules/customer_accounts/lib/tenantEligibility.ts +14 -0
  48. package/src/modules/customer_accounts/services/customerInvitationService.ts +2 -0
  49. package/src/modules/customer_accounts/services/customerSessionService.ts +2 -0
  50. package/src/modules/customer_accounts/services/customerTokenService.ts +2 -0
  51. package/src/modules/customer_accounts/services/domainMappingService.ts +3 -0
  52. package/src/modules/customers/components/linking/LinkEntityDialog.tsx +10 -2
  53. 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.7691.1.d8af406541",
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.7691.1.d8af406541",
269
- "@open-mercato/shared": "0.10.1-develop.7691.1.d8af406541",
270
- "@open-mercato/ui": "0.10.1-develop.7691.1.d8af406541",
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.7691.1.d8af406541",
276
- "@open-mercato/shared": "0.10.1-develop.7691.1.d8af406541",
277
- "@open-mercato/ui": "0.10.1-develop.7691.1.d8af406541",
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
- if (!user || !ok) {
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
- if (!user || !ok || user.isConfirmed === false) {
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: Record<string, unknown> = { tenantId: auth.tenantId }
77
- if (orgFilter) where.organizationId = orgFilter
78
- const records = await em.find(DomainMapping, where as never, { orderBy: { createdAt: 'desc' } })
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 all custom-domain mappings for the current tenant, optionally filtered by organization.',
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(14)
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}