@open-mercato/core 0.10.1-develop.7836.1.0ef0f453c6 → 0.10.1-develop.7841.1.af87ac614d
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/customer_accounts/api/admin/roles.js +25 -2
- package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users.js +24 -7
- package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +77 -15
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +12 -1
- package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
- package/dist/modules/customer_accounts/data/validators.js +2 -1
- package/dist/modules/customer_accounts/data/validators.js.map +2 -2
- package/dist/modules/customer_accounts/lib/adminOrganizationScope.js +46 -0
- package/dist/modules/customer_accounts/lib/adminOrganizationScope.js.map +7 -0
- package/dist/modules/customers/api/customer-dictionary-entries/route.js +86 -0
- package/dist/modules/customers/api/customer-dictionary-entries/route.js.map +7 -0
- package/dist/modules/customers/api/dictionaries/[kind]/route.js +29 -7
- package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
- package/dist/modules/customers/cli.js +28 -110
- package/dist/modules/customers/cli.js.map +3 -3
- package/dist/modules/customers/components/DictionarySettings.js +1 -1
- package/dist/modules/customers/components/DictionarySettings.js.map +1 -1
- package/dist/modules/customers/components/detail/ManageTagsDialog.js +1 -1
- package/dist/modules/customers/components/detail/ManageTagsDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/TaskDialog.js +14 -1
- package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/TaskForm.js +1 -16
- package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
- package/dist/modules/customers/components/detail/TasksSection.js +38 -10
- package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
- package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js +12 -5
- package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js.map +2 -2
- package/dist/modules/customers/components/detail/hooks/useInteractions.js +3 -13
- package/dist/modules/customers/components/detail/hooks/useInteractions.js.map +2 -2
- package/dist/modules/customers/lib/dictionaryDefaults.js +134 -0
- package/dist/modules/customers/lib/dictionaryDefaults.js.map +7 -0
- package/dist/modules/customers/lib/dictionaryLabels.js +59 -0
- package/dist/modules/customers/lib/dictionaryLabels.js.map +7 -0
- package/dist/modules/customers/lib/interactionCompatibility.js +1 -0
- package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
- package/dist/modules/customers/translations.js +9 -0
- package/dist/modules/customers/translations.js.map +7 -0
- package/dist/modules/eudr/index.js +13 -0
- package/dist/modules/eudr/index.js.map +2 -2
- package/dist/modules/staff/api/timesheets/access-requests/route.js +11 -5
- package/dist/modules/staff/api/timesheets/access-requests/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-projects/route.js +19 -12
- package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +21 -12
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +14 -9
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +31 -10
- package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js +64 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js +13 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js.map +7 -0
- package/dist/modules/sync_excel/index.js +11 -1
- package/dist/modules/sync_excel/index.js.map +2 -2
- package/dist/modules/translations/di.js +2 -0
- package/dist/modules/translations/di.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customer_accounts/api/admin/roles.ts +26 -2
- package/src/modules/customer_accounts/api/admin/users.ts +26 -7
- package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +96 -13
- package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +9 -1
- package/src/modules/customer_accounts/data/validators.ts +1 -0
- package/src/modules/customer_accounts/i18n/de.json +10 -0
- package/src/modules/customer_accounts/i18n/en.json +10 -0
- package/src/modules/customer_accounts/i18n/es.json +10 -0
- package/src/modules/customer_accounts/i18n/ko.json +10 -0
- package/src/modules/customer_accounts/i18n/pl.json +10 -0
- package/src/modules/customer_accounts/lib/adminOrganizationScope.ts +71 -0
- package/src/modules/customers/api/customer-dictionary-entries/route.ts +82 -0
- package/src/modules/customers/api/dictionaries/[kind]/route.ts +29 -6
- package/src/modules/customers/cli.ts +23 -124
- package/src/modules/customers/components/DictionarySettings.tsx +1 -1
- package/src/modules/customers/components/detail/ManageTagsDialog.tsx +1 -1
- package/src/modules/customers/components/detail/TaskDialog.tsx +15 -1
- package/src/modules/customers/components/detail/TaskForm.tsx +1 -17
- package/src/modules/customers/components/detail/TasksSection.tsx +44 -14
- package/src/modules/customers/components/detail/hooks/useCustomerDictionary.ts +14 -8
- package/src/modules/customers/components/detail/hooks/useInteractions.ts +3 -4
- package/src/modules/customers/i18n/de.json +81 -0
- package/src/modules/customers/i18n/en.json +81 -0
- package/src/modules/customers/i18n/es.json +81 -0
- package/src/modules/customers/i18n/ko.json +81 -0
- package/src/modules/customers/i18n/pl.json +81 -0
- package/src/modules/customers/lib/dictionaryDefaults.ts +137 -0
- package/src/modules/customers/lib/dictionaryLabels.ts +73 -0
- package/src/modules/customers/lib/interactionCompatibility.ts +1 -0
- package/src/modules/customers/translations.ts +5 -0
- package/src/modules/eudr/index.ts +13 -0
- package/src/modules/staff/api/timesheets/access-requests/route.ts +13 -14
- package/src/modules/staff/api/timesheets/time-projects/route.ts +22 -13
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +25 -14
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +14 -13
- package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +30 -8
- package/src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts +102 -0
- package/src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts +15 -0
- package/src/modules/sync_excel/index.ts +10 -0
- package/src/modules/translations/di.ts +2 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import type { Locale } from '@open-mercato/shared/lib/i18n/config'
|
|
3
|
+
import { loadDictionary } from '@open-mercato/shared/lib/i18n/server'
|
|
4
|
+
import { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate'
|
|
5
|
+
import { getTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'
|
|
6
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
7
|
+
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
8
|
+
import { CUSTOMER_DICTIONARY_DEFAULTS } from './dictionaryDefaults'
|
|
9
|
+
|
|
10
|
+
const logger = createLogger('customers').child({ component: 'dictionary-labels' })
|
|
11
|
+
|
|
12
|
+
type DictionaryLabelEntry = {
|
|
13
|
+
id: string
|
|
14
|
+
value: string
|
|
15
|
+
label: string
|
|
16
|
+
organizationId: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function customerDictionaryLabelKey(kind: string, value: string): string {
|
|
20
|
+
const token = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
|
|
21
|
+
return `customers.dictionaries.defaults.${kind}.${token}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function localizeCustomerDictionaryEntries<T extends DictionaryLabelEntry>(
|
|
25
|
+
entries: T[],
|
|
26
|
+
options: { kind: string; locale: string; tenantId: string; container: AwilixContainer },
|
|
27
|
+
): Promise<T[]> {
|
|
28
|
+
if (!entries.length) return []
|
|
29
|
+
const dictionaryLocale = options.locale.toLowerCase().split('-')[0] as Locale
|
|
30
|
+
const translate = createFallbackTranslator(await loadDictionary(dictionaryLocale))
|
|
31
|
+
const defaults = Object.prototype.hasOwnProperty.call(CUSTOMER_DICTIONARY_DEFAULTS, options.kind)
|
|
32
|
+
? CUSTOMER_DICTIONARY_DEFAULTS[options.kind]
|
|
33
|
+
: []
|
|
34
|
+
const localized = entries.map((entry) => {
|
|
35
|
+
const seeded = defaults.find((candidate) => candidate.value === entry.value && candidate.label === entry.label)
|
|
36
|
+
const renewalQuarter = options.kind === 'renewal_quarter' ? /^(\d{4})_q([1-4])$/.exec(entry.value) : null
|
|
37
|
+
const label = renewalQuarter && entry.label === `Q${renewalQuarter[2]} ${renewalQuarter[1]}`
|
|
38
|
+
? translate('customers.dictionaries.defaults.renewal_quarter.label', entry.label, {
|
|
39
|
+
year: renewalQuarter[1], quarter: renewalQuarter[2],
|
|
40
|
+
})
|
|
41
|
+
: seeded ? translate(customerDictionaryLabelKey(options.kind, seeded.value), entry.label) : entry.label
|
|
42
|
+
return {
|
|
43
|
+
...entry,
|
|
44
|
+
label,
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
const { overlay } = getTranslationOverlayPlugin()
|
|
48
|
+
if (!overlay) return localized
|
|
49
|
+
|
|
50
|
+
const organizationIds = new Set(localized.map((entry) => entry.organizationId))
|
|
51
|
+
const labelsById = new Map<string, string>()
|
|
52
|
+
for (const organizationId of organizationIds) {
|
|
53
|
+
const items = localized.filter((entry) => entry.organizationId === organizationId)
|
|
54
|
+
try {
|
|
55
|
+
const translated = await overlay(items.map(({ id, label }) => ({ id, label })), {
|
|
56
|
+
entityType: 'customers:customer_dictionary_entry',
|
|
57
|
+
locale: options.locale,
|
|
58
|
+
tenantId: options.tenantId,
|
|
59
|
+
organizationId,
|
|
60
|
+
container: options.container,
|
|
61
|
+
})
|
|
62
|
+
for (const item of translated) {
|
|
63
|
+
if (typeof item.id === 'string' && typeof item.label === 'string' && item.label.trim()) {
|
|
64
|
+
labelsById.set(item.id, item.label)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
getTelemetryRuntime()?.reportError(error, { module: 'customers', code: 'customers.dictionary_translation_failed' })
|
|
69
|
+
logger.warn('Translation overlay failed', { error })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return localized.map((entry) => ({ ...entry, label: labelsById.get(entry.id) ?? entry.label }))
|
|
73
|
+
}
|
|
@@ -63,6 +63,7 @@ export function mapInteractionRecordToTodoSummary(interaction: InteractionRecord
|
|
|
63
63
|
todoId: interaction.id,
|
|
64
64
|
todoSource: CUSTOMER_INTERACTION_TASK_SOURCE,
|
|
65
65
|
createdAt: interaction.createdAt,
|
|
66
|
+
updatedAt: interaction.updatedAt,
|
|
66
67
|
title: interaction.title ?? null,
|
|
67
68
|
isDone: interaction.status === 'done',
|
|
68
69
|
status: interaction.status,
|
|
@@ -7,6 +7,19 @@ export const metadata: ModuleInfo = {
|
|
|
7
7
|
description: 'EU Deforestation Regulation compliance: product commodity mappings, supplier origin evidence, due diligence statements.',
|
|
8
8
|
author: 'Open Mercato Team',
|
|
9
9
|
license: 'MIT',
|
|
10
|
+
/**
|
|
11
|
+
* `catalog` is a HARD dependency, and saying so is the fix.
|
|
12
|
+
*
|
|
13
|
+
* `data/enrichers.ts` reads `E.catalog.catalog_product` at module scope, so with `catalog` disabled
|
|
14
|
+
* `E.catalog` is undefined and the read throws while the enricher registry is being imported — which takes
|
|
15
|
+
* `/api/auth/login` with it. An installation shaped as a CRM rather than a shop could not reach its own login
|
|
16
|
+
* screen, and the error named a property rather than a module.
|
|
17
|
+
*
|
|
18
|
+
* Declared rather than defended with optional chaining. This module exists to map PRODUCTS to deforestation
|
|
19
|
+
* compliance evidence, so without a catalogue it has nothing to work on at all — the honest answer is that it
|
|
20
|
+
* cannot be enabled, which the generator now says before anybody starts the app rather than after.
|
|
21
|
+
*/
|
|
22
|
+
requires: ['catalog'],
|
|
10
23
|
ejectable: true,
|
|
11
24
|
}
|
|
12
25
|
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
5
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
6
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
6
7
|
import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
7
8
|
import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'
|
|
8
9
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
@@ -74,7 +75,8 @@ async function resolveAccessRequestContext(req: Request): Promise<AccessRequestC
|
|
|
74
75
|
if (!requesterUserId) {
|
|
75
76
|
throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
79
|
+
if (!scope.tenantId || !scope.selectedId || scope.selectionRejected === true) {
|
|
78
80
|
throw new CrudHttpError(400, {
|
|
79
81
|
error: translate('staff.errors.missingScope', 'Missing tenant or organization scope.'),
|
|
80
82
|
})
|
|
@@ -82,8 +84,8 @@ async function resolveAccessRequestContext(req: Request): Promise<AccessRequestC
|
|
|
82
84
|
return {
|
|
83
85
|
container,
|
|
84
86
|
em: (container.resolve('em') as EntityManager).fork(),
|
|
85
|
-
tenantId:
|
|
86
|
-
organizationId:
|
|
87
|
+
tenantId: scope.tenantId,
|
|
88
|
+
organizationId: scope.selectedId,
|
|
87
89
|
requesterUserId,
|
|
88
90
|
requesterEmail: typeof auth.email === 'string' && auth.email.trim().length > 0 ? auth.email : null,
|
|
89
91
|
translate,
|
|
@@ -155,17 +157,14 @@ async function resolveRequesterName(context: AccessRequestContext): Promise<stri
|
|
|
155
157
|
async function POST(req: Request) {
|
|
156
158
|
try {
|
|
157
159
|
const context = await resolveAccessRequestContext(req)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
organizationId: context.organizationId,
|
|
167
|
-
})
|
|
168
|
-
).grantedFeatures
|
|
160
|
+
const featureAccess = await resolveFeatureAccess(context.container, context.requesterUserId, [VIEW_FEATURE], {
|
|
161
|
+
tenantId: context.tenantId,
|
|
162
|
+
organizationId: context.organizationId,
|
|
163
|
+
})
|
|
164
|
+
if (!featureAccess.allowed) {
|
|
165
|
+
throw new CrudHttpError(403, { error: context.translate('staff.errors.forbidden', 'Forbidden') })
|
|
166
|
+
}
|
|
167
|
+
const grantedFeatures = featureAccess.grantedFeatures
|
|
169
168
|
|
|
170
169
|
const interceptors = await runTimesheetInterceptors({
|
|
171
170
|
request: req,
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { makeCrudRoute, type CrudCtx } from '@open-mercato/shared/lib/crud/factory'
|
|
5
5
|
import { staffTimeProjectCrudEvents } from '../../../lib/crud'
|
|
6
|
+
import { PROJECT_SCOPE_TENANT_HEADER, PROJECT_SCOPE_ORGANIZATION_HEADER, type ResolvedProjectAccessScope } from '../../../lib/time-tracking-ui/projectAccessScopeHeaders'
|
|
6
7
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
7
8
|
import { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'
|
|
8
9
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
@@ -500,24 +501,28 @@ async function buildAccessDenialResponse(): Promise<Response> {
|
|
|
500
501
|
* `reason` discriminator carries only "you are not a member of this project",
|
|
501
502
|
* never the project or customer it is refusing to describe.
|
|
502
503
|
*/
|
|
503
|
-
async function resolveProjectAccessDenial(req: Request): Promise<Response | null> {
|
|
504
|
+
async function resolveProjectAccessDenial(req: Request): Promise<{ denial: Response | null; scope: ResolvedProjectAccessScope | null }> {
|
|
505
|
+
const unresolved = { denial: null, scope: null }
|
|
504
506
|
const url = new URL(req.url)
|
|
505
507
|
const rawIds = url.searchParams.get('ids') ?? url.searchParams.get('id')
|
|
506
|
-
if (!isIdsParamProvided(rawIds)) return
|
|
508
|
+
if (!isIdsParamProvided(rawIds)) return unresolved
|
|
507
509
|
const requestedIds = parseIdsParam(rawIds)
|
|
508
510
|
// Malformed ids match nothing on their own; the factory answers an empty list.
|
|
509
|
-
if (requestedIds.length === 0) return
|
|
511
|
+
if (requestedIds.length === 0) return unresolved
|
|
510
512
|
|
|
511
513
|
try {
|
|
512
514
|
const container = await createRequestContainer()
|
|
513
515
|
const auth = await getAuthFromRequest(req)
|
|
514
516
|
// Unauthenticated and unscoped requests are the factory's to reject.
|
|
515
|
-
if (!auth) return
|
|
517
|
+
if (!auth) return unresolved
|
|
516
518
|
const orgScope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
517
519
|
const tenantId = orgScope?.tenantId ?? auth.tenantId ?? null
|
|
518
520
|
const organizationId = resolveSingleOrganizationIdOrDeny(orgScope, auth) ?? null
|
|
519
|
-
if (!tenantId || !organizationId) return
|
|
521
|
+
if (!tenantId || !organizationId) return unresolved
|
|
520
522
|
|
|
523
|
+
const scope = orgScope?.tenantId && orgScope.selectedId && orgScope.selectionRejected !== true
|
|
524
|
+
? { tenantId: orgScope.tenantId, organizationId: orgScope.selectedId }
|
|
525
|
+
: null
|
|
521
526
|
const access = await resolveRequestProjectAccess(req, () =>
|
|
522
527
|
loadProjectAccess(
|
|
523
528
|
container,
|
|
@@ -526,20 +531,24 @@ async function resolveProjectAccessDenial(req: Request): Promise<Response | null
|
|
|
526
531
|
organizationId,
|
|
527
532
|
),
|
|
528
533
|
)
|
|
529
|
-
if (access.canManageAll) return null
|
|
530
|
-
if (requestedIds.some((id) => access.projectIds.includes(id))) return null
|
|
531
|
-
return await buildAccessDenialResponse()
|
|
534
|
+
if (access.canManageAll) return { denial: null, scope }
|
|
535
|
+
if (requestedIds.some((id) => access.projectIds.includes(id))) return { denial: null, scope }
|
|
536
|
+
return { denial: await buildAccessDenialResponse(), scope }
|
|
532
537
|
} catch (err) {
|
|
533
538
|
// Fail closed: an access decision that could not be made is a denial.
|
|
534
539
|
logger.error('staff.timesheets.time-projects denial probe failed', { err })
|
|
535
|
-
return await buildAccessDenialResponse()
|
|
540
|
+
return { denial: await buildAccessDenialResponse(), scope: null }
|
|
536
541
|
}
|
|
537
542
|
}
|
|
538
543
|
|
|
539
544
|
export async function GET(req: Request): Promise<Response> {
|
|
540
|
-
const denial = await resolveProjectAccessDenial(req)
|
|
541
|
-
|
|
542
|
-
|
|
545
|
+
const { denial, scope } = await resolveProjectAccessDenial(req)
|
|
546
|
+
const response = denial ?? await crud.GET(req)
|
|
547
|
+
if (scope) {
|
|
548
|
+
response.headers.set(PROJECT_SCOPE_TENANT_HEADER, scope.tenantId)
|
|
549
|
+
response.headers.set(PROJECT_SCOPE_ORGANIZATION_HEADER, scope.organizationId)
|
|
550
|
+
}
|
|
551
|
+
return response
|
|
543
552
|
}
|
|
544
553
|
|
|
545
554
|
export const POST = crud.POST
|
|
@@ -611,7 +620,7 @@ export const openApi = {
|
|
|
611
620
|
? {
|
|
612
621
|
GET: {
|
|
613
622
|
...baseOpenApi.methods.GET,
|
|
614
|
-
description: `${baseOpenApi.methods.GET.description ?? ''} Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),
|
|
623
|
+
description: `${baseOpenApi.methods.GET.description ?? ''} Project-specific reads include optional x-om-project-scope-tenant-id and x-om-project-scope-organization-id response headers when the server resolves a scope. These identify the request scope for client hydration; a missing header provides no scope identity. Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),
|
|
615
624
|
responses: [
|
|
616
625
|
...(baseOpenApi.methods.GET.responses ?? []),
|
|
617
626
|
{
|
|
@@ -15,6 +15,8 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
|
15
15
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
16
16
|
import { DEFAULT_BUDGET_WARN_AT_PERCENT, type ProjectBudgetKind } from '../../../../../../lib/time-tracking-ui/ProjectFormSections'
|
|
17
17
|
import { NoProjectAccess } from '../../../../../../lib/time-tracking-ui/NoProjectAccess'
|
|
18
|
+
import { useProjectAccessScope } from '../../../../../../lib/time-tracking-ui/projectAccessScope'
|
|
19
|
+
import { readResolvedProjectAccessScope, type ResolvedProjectAccessScope } from '../../../../../../lib/time-tracking-ui/projectAccessScopeHeaders'
|
|
18
20
|
import {
|
|
19
21
|
buildProjectPayload,
|
|
20
22
|
createProjectFormFields,
|
|
@@ -36,7 +38,7 @@ const NO_PROJECT_ACCESS_REASON = 'no_project_access'
|
|
|
36
38
|
|
|
37
39
|
/** Thrown by the loader so the caller can branch on the guard state. */
|
|
38
40
|
class ProjectAccessDeniedError extends Error {
|
|
39
|
-
constructor() {
|
|
41
|
+
constructor(readonly resolvedScope: ResolvedProjectAccessScope | null) {
|
|
40
42
|
super('[internal] no project access')
|
|
41
43
|
this.name = 'ProjectAccessDeniedError'
|
|
42
44
|
}
|
|
@@ -106,25 +108,28 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
|
|
|
106
108
|
const t = useT()
|
|
107
109
|
const router = useRouter()
|
|
108
110
|
const scopeVersion = useOrganizationScopeVersion()
|
|
111
|
+
const { scopeKey: projectAccessScopeKey, resolvedScope, resolveScope } = useProjectAccessScope(projectId)
|
|
109
112
|
|
|
110
113
|
const [initialValues, setInitialValues] = React.useState<ProjectFormValues | null>(null)
|
|
111
114
|
const [loading, setLoading] = React.useState(true)
|
|
112
115
|
const [error, setError] = React.useState<string | null>(null)
|
|
113
116
|
const [isNotFound, setIsNotFound] = React.useState(false)
|
|
114
|
-
const [
|
|
117
|
+
const [accessDeniedScopeKey, setAccessDeniedScopeKey] = React.useState<string | null>(null)
|
|
118
|
+
const [loadedScopeKey, setLoadedScopeKey] = React.useState<string | null>(null)
|
|
119
|
+
const accessDenied = accessDeniedScopeKey === projectAccessScopeKey
|
|
115
120
|
|
|
116
|
-
const fetchProject = React.useCallback(async (): Promise<ProjectRecord | null> => {
|
|
121
|
+
const fetchProject = React.useCallback(async (): Promise<{ record: ProjectRecord | null; scope: ResolvedProjectAccessScope | null }> => {
|
|
117
122
|
const call = await apiCall<{ items?: ProjectRecord[]; reason?: unknown }>(
|
|
118
123
|
`/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`,
|
|
119
124
|
)
|
|
120
125
|
if (call.status === 404 && call.result?.reason === NO_PROJECT_ACCESS_REASON) {
|
|
121
|
-
throw new ProjectAccessDeniedError()
|
|
126
|
+
throw new ProjectAccessDeniedError(readResolvedProjectAccessScope(call.response))
|
|
122
127
|
}
|
|
123
128
|
if (!call.ok) {
|
|
124
129
|
throw new Error(t('staff.timesheets.projects.errors.load', 'Failed to load project.'))
|
|
125
130
|
}
|
|
126
131
|
const record = Array.isArray(call.result?.items) ? call.result.items[0] : null
|
|
127
|
-
return record ?? null
|
|
132
|
+
return { record: record ?? null, scope: readResolvedProjectAccessScope(call.response) }
|
|
128
133
|
}, [projectId, t])
|
|
129
134
|
|
|
130
135
|
/**
|
|
@@ -137,7 +142,7 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
|
|
|
137
142
|
const handleCurrencyChanged = React.useCallback(() => {
|
|
138
143
|
if (!projectId) return
|
|
139
144
|
void fetchProject()
|
|
140
|
-
.then((record) => { if (record) setInitialValues(toValues(record)) })
|
|
145
|
+
.then(({ record }) => { if (record) setInitialValues(toValues(record)) })
|
|
141
146
|
.catch(() => {
|
|
142
147
|
// Best effort: the optimistic-lock conflict bar remains the backstop.
|
|
143
148
|
})
|
|
@@ -159,25 +164,30 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
|
|
|
159
164
|
// it was loaded for. The decision looks at the load this one replaces,
|
|
160
165
|
// settled or not: a version-0 load cancelled by a quick second switch must
|
|
161
166
|
// not let that switch through.
|
|
162
|
-
const lastLoadRef = React.useRef<{ projectId: string; scopeVersion: number } | null>(null)
|
|
167
|
+
const lastLoadRef = React.useRef<{ projectId: string; scopeVersion: number; scopeKey: string } | null>(null)
|
|
163
168
|
React.useEffect(() => {
|
|
164
169
|
if (!projectId) return
|
|
165
170
|
let cancelled = false
|
|
166
171
|
const replaced = lastLoadRef.current
|
|
167
|
-
lastLoadRef.current = { projectId, scopeVersion }
|
|
168
|
-
const keepScreen = replaced !== null
|
|
172
|
+
lastLoadRef.current = { projectId, scopeVersion, scopeKey: projectAccessScopeKey }
|
|
173
|
+
const keepScreen = replaced !== null
|
|
174
|
+
&& replaced.projectId === projectId
|
|
175
|
+
&& replaced.scopeVersion === 0
|
|
176
|
+
&& replaced.scopeKey === projectAccessScopeKey
|
|
169
177
|
async function load() {
|
|
170
178
|
if (!keepScreen) setLoading(true)
|
|
171
179
|
const settle = (outcome: { values?: ProjectFormValues; accessDenied?: boolean; isNotFound?: boolean; error?: string }) => {
|
|
172
180
|
if (cancelled) return
|
|
173
|
-
|
|
181
|
+
setAccessDeniedScopeKey(outcome.accessDenied === true ? projectAccessScopeKey : null)
|
|
174
182
|
setIsNotFound(outcome.isNotFound === true)
|
|
175
183
|
setError(outcome.error ?? null)
|
|
176
184
|
if (outcome.values) setInitialValues(outcome.values)
|
|
185
|
+
setLoadedScopeKey(projectAccessScopeKey)
|
|
177
186
|
setLoading(false)
|
|
178
187
|
}
|
|
179
188
|
try {
|
|
180
|
-
const record = await fetchProject()
|
|
189
|
+
const { record, scope } = await fetchProject()
|
|
190
|
+
if (!cancelled) resolveScope(scope)
|
|
181
191
|
if (!record) {
|
|
182
192
|
settle({ isNotFound: true })
|
|
183
193
|
return
|
|
@@ -185,6 +195,7 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
|
|
|
185
195
|
settle({ values: toValues(record) })
|
|
186
196
|
} catch (err) {
|
|
187
197
|
if (err instanceof ProjectAccessDeniedError) {
|
|
198
|
+
if (!cancelled) resolveScope(err.resolvedScope)
|
|
188
199
|
settle({ accessDenied: true })
|
|
189
200
|
return
|
|
190
201
|
}
|
|
@@ -197,16 +208,16 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
|
|
|
197
208
|
}
|
|
198
209
|
load()
|
|
199
210
|
return () => { cancelled = true }
|
|
200
|
-
}, [projectId, fetchProject, scopeVersion, t])
|
|
211
|
+
}, [projectId, fetchProject, scopeVersion, t, projectAccessScopeKey, resolveScope])
|
|
201
212
|
|
|
202
|
-
if (loading) {
|
|
213
|
+
if (!accessDenied && (loading || loadedScopeKey !== projectAccessScopeKey)) {
|
|
203
214
|
return <Page><PageBody><LoadingMessage label={t('staff.timesheets.projects.loading', 'Loading project...')} /></PageBody></Page>
|
|
204
215
|
}
|
|
205
216
|
|
|
206
217
|
// Screen 17 — the caller may not open this project. Same guard state the
|
|
207
218
|
// detail page renders; it names neither the customer nor the project.
|
|
208
219
|
if (accessDenied) {
|
|
209
|
-
return <NoProjectAccess timeProjectId={projectId} />
|
|
220
|
+
return <NoProjectAccess timeProjectId={projectId} resolvedScope={resolvedScope} />
|
|
210
221
|
}
|
|
211
222
|
|
|
212
223
|
if (isNotFound) {
|
|
@@ -33,6 +33,8 @@ import { formatCurrency } from '@open-mercato/ui/utils/format'
|
|
|
33
33
|
import { KpiCard, Sparkline } from '@open-mercato/ui/backend/charts'
|
|
34
34
|
import { ProjectTeamDrawer } from '../../../../../lib/time-tracking-ui/ProjectTeamDrawer'
|
|
35
35
|
import { NoProjectAccess } from '../../../../../lib/time-tracking-ui/NoProjectAccess'
|
|
36
|
+
import { useProjectAccessScope } from '../../../../../lib/time-tracking-ui/projectAccessScope'
|
|
37
|
+
import { readResolvedProjectAccessScope } from '../../../../../lib/time-tracking-ui/projectAccessScopeHeaders'
|
|
36
38
|
import { ProjectBudgetCell } from '../../../../../lib/timesheets-projects-ui/ProjectBudgetCell'
|
|
37
39
|
import { computeBudgetBurn } from '../../../../../lib/timesheets-projects/budgetBurn'
|
|
38
40
|
import { resolveProjectColorHex } from '../../../../../lib/timesheets-ui/colors'
|
|
@@ -177,6 +179,7 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
|
|
|
177
179
|
const translateRef = React.useRef(t)
|
|
178
180
|
React.useEffect(() => { translateRef.current = t }, [t])
|
|
179
181
|
const scopeVersion = useOrganizationScopeVersion()
|
|
182
|
+
const { scopeKey: projectAccessScopeKey, resolvedScope, resolveScope } = useProjectAccessScope(projectId)
|
|
180
183
|
const { confirm, ConfirmDialogElement } = useConfirmDialog()
|
|
181
184
|
const router = useRouter()
|
|
182
185
|
const pathname = usePathname()
|
|
@@ -186,13 +189,9 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
|
|
|
186
189
|
const [loading, setLoading] = React.useState(true)
|
|
187
190
|
const [error, setError] = React.useState<string | null>(null)
|
|
188
191
|
const [isNotFound, setIsNotFound] = React.useState(false)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
const accessDenied = accessDeniedProjectId !== null && accessDeniedProjectId === projectId
|
|
193
|
-
// The id the last finished load was for. Until it matches the route, the page
|
|
194
|
-
// is still loading — never the previous project's error or empty state.
|
|
195
|
-
const [settledProjectId, setSettledProjectId] = React.useState<string | null>(null)
|
|
192
|
+
const [accessDeniedScopeKey, setAccessDeniedScopeKey] = React.useState<string | null>(null)
|
|
193
|
+
const [loadedScopeKey, setLoadedScopeKey] = React.useState<string | null>(null)
|
|
194
|
+
const accessDenied = accessDeniedScopeKey === projectAccessScopeKey
|
|
196
195
|
|
|
197
196
|
const [employees, setEmployees] = React.useState<EmployeeAssignment[]>([])
|
|
198
197
|
const [employeesLoading, setEmployeesLoading] = React.useState(false)
|
|
@@ -407,13 +406,14 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
|
|
|
407
406
|
const call = await apiCall<ProjectResponse>(
|
|
408
407
|
`/api/staff/timesheets/time-projects?${queryParams.toString()}`,
|
|
409
408
|
)
|
|
409
|
+
if (!cancelled) resolveScope(readResolvedProjectAccessScope(call.response))
|
|
410
410
|
// Screen 17: the route answers 404 with this discriminator for a project
|
|
411
411
|
// the caller is not a member of, without naming it.
|
|
412
412
|
if (call.status === 404 && readDenialReason(call.result) === NO_PROJECT_ACCESS_REASON) {
|
|
413
|
-
if (!cancelled)
|
|
413
|
+
if (!cancelled) setAccessDeniedScopeKey(projectAccessScopeKey)
|
|
414
414
|
return
|
|
415
415
|
}
|
|
416
|
-
if (!cancelled)
|
|
416
|
+
if (!cancelled) setAccessDeniedScopeKey(null)
|
|
417
417
|
if (!call.ok) {
|
|
418
418
|
throw new Error(translateRef.current('staff.timesheets.projects.errors.load', 'Failed to load project.'))
|
|
419
419
|
}
|
|
@@ -426,18 +426,19 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
|
|
|
426
426
|
if (!cancelled) setProject(record)
|
|
427
427
|
} catch (loadError) {
|
|
428
428
|
if (!cancelled) {
|
|
429
|
+
setAccessDeniedScopeKey(null)
|
|
429
430
|
setError(loadError instanceof Error ? loadError.message : translateRef.current('staff.timesheets.projects.errors.load', 'Failed to load project.'))
|
|
430
431
|
}
|
|
431
432
|
} finally {
|
|
432
433
|
if (!cancelled) {
|
|
434
|
+
setLoadedScopeKey(projectAccessScopeKey)
|
|
433
435
|
setLoading(false)
|
|
434
|
-
setSettledProjectId(projectId ?? null)
|
|
435
436
|
}
|
|
436
437
|
}
|
|
437
438
|
}
|
|
438
439
|
loadProject()
|
|
439
440
|
return () => { cancelled = true }
|
|
440
|
-
}, [projectId, scopeVersion])
|
|
441
|
+
}, [projectId, scopeVersion, projectAccessScopeKey, resolveScope])
|
|
441
442
|
|
|
442
443
|
// --- Load employees with name resolution ---
|
|
443
444
|
const loadEmployees = React.useCallback(async () => {
|
|
@@ -709,10 +710,10 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
|
|
|
709
710
|
// resolving late) keeps the guard mounted instead of resetting a request the
|
|
710
711
|
// user has just sent.
|
|
711
712
|
if (accessDenied) {
|
|
712
|
-
return <NoProjectAccess timeProjectId={projectId} />
|
|
713
|
+
return <NoProjectAccess timeProjectId={projectId} resolvedScope={resolvedScope} />
|
|
713
714
|
}
|
|
714
715
|
|
|
715
|
-
if (loading ||
|
|
716
|
+
if (loading || loadedScopeKey !== projectAccessScopeKey) {
|
|
716
717
|
return (
|
|
717
718
|
<Page>
|
|
718
719
|
<PageBody>
|
|
@@ -10,6 +10,8 @@ import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
|
10
10
|
import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
11
11
|
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
12
12
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
13
|
+
import { useProjectAccessScope } from './projectAccessScope'
|
|
14
|
+
import type { ResolvedProjectAccessScope } from './projectAccessScopeHeaders'
|
|
13
15
|
|
|
14
16
|
export const MY_WORK_HREF = '/backend/staff/time-tracking'
|
|
15
17
|
export const ACCESS_REQUEST_ENDPOINT = '/api/staff/timesheets/access-requests'
|
|
@@ -22,36 +24,52 @@ export type AccessRequestButtonProps = {
|
|
|
22
24
|
* project exists to a user who has no right to it.
|
|
23
25
|
*/
|
|
24
26
|
timeProjectId?: string | null
|
|
27
|
+
resolvedScope?: ResolvedProjectAccessScope | null
|
|
25
28
|
label: string
|
|
26
29
|
sentLabel: string
|
|
27
30
|
variant?: 'default' | 'outline'
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
export function AccessRequestButton({
|
|
33
|
+
export function AccessRequestButton(props: AccessRequestButtonProps) {
|
|
34
|
+
const { scopeKey, isCurrentScope } = useProjectAccessScope(props.timeProjectId, props.resolvedScope)
|
|
35
|
+
return <ScopedAccessRequestButton key={scopeKey} {...props} isCurrentScope={isCurrentScope} />
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ScopedAccessRequestButton({
|
|
31
39
|
timeProjectId,
|
|
32
40
|
label,
|
|
33
41
|
sentLabel,
|
|
34
42
|
variant = 'outline',
|
|
35
|
-
|
|
43
|
+
isCurrentScope,
|
|
44
|
+
}: AccessRequestButtonProps & { isCurrentScope: () => boolean }) {
|
|
36
45
|
const t = useT()
|
|
37
46
|
const [submitting, setSubmitting] = React.useState(false)
|
|
38
47
|
const [sent, setSent] = React.useState(false)
|
|
48
|
+
const mounted = React.useRef(true)
|
|
49
|
+
React.useEffect(() => {
|
|
50
|
+
mounted.current = true
|
|
51
|
+
return () => { mounted.current = false }
|
|
52
|
+
}, [])
|
|
39
53
|
const { runMutation, retryLastMutation } = useGuardedMutation<Record<string, unknown>>({
|
|
40
54
|
contextId: ACCESS_REQUEST_CONTEXT_ID,
|
|
41
55
|
blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
42
56
|
})
|
|
43
57
|
|
|
44
58
|
const requestAccess = React.useCallback(async () => {
|
|
59
|
+
const isCurrent = () => mounted.current && isCurrentScope()
|
|
60
|
+
if (!isCurrent()) return
|
|
45
61
|
const payload = timeProjectId ? { timeProjectId } : {}
|
|
46
62
|
setSubmitting(true)
|
|
47
63
|
try {
|
|
48
64
|
await runMutation({
|
|
49
|
-
operation: () =>
|
|
50
|
-
|
|
65
|
+
operation: () => {
|
|
66
|
+
if (!isCurrent()) throw new Error('[internal] Access request scope changed')
|
|
67
|
+
return apiCallOrThrow(ACCESS_REQUEST_ENDPOINT, {
|
|
51
68
|
method: 'POST',
|
|
52
69
|
headers: { 'content-type': 'application/json' },
|
|
53
70
|
body: JSON.stringify(payload),
|
|
54
|
-
})
|
|
71
|
+
})
|
|
72
|
+
},
|
|
55
73
|
context: {
|
|
56
74
|
formId: ACCESS_REQUEST_CONTEXT_ID,
|
|
57
75
|
resourceKind: 'staff.timesheets.access_request',
|
|
@@ -59,6 +77,7 @@ export function AccessRequestButton({
|
|
|
59
77
|
},
|
|
60
78
|
mutationPayload: payload,
|
|
61
79
|
})
|
|
80
|
+
if (!isCurrent()) return
|
|
62
81
|
setSent(true)
|
|
63
82
|
flash(
|
|
64
83
|
t(
|
|
@@ -68,14 +87,15 @@ export function AccessRequestButton({
|
|
|
68
87
|
'success',
|
|
69
88
|
)
|
|
70
89
|
} catch {
|
|
90
|
+
if (!isCurrent()) return
|
|
71
91
|
flash(
|
|
72
92
|
t('staff.time_tracking.access.request.error', 'Could not send the access request.'),
|
|
73
93
|
'error',
|
|
74
94
|
)
|
|
75
95
|
} finally {
|
|
76
|
-
setSubmitting(false)
|
|
96
|
+
if (isCurrent()) setSubmitting(false)
|
|
77
97
|
}
|
|
78
|
-
}, [retryLastMutation, runMutation, t, timeProjectId])
|
|
98
|
+
}, [isCurrentScope, retryLastMutation, runMutation, t, timeProjectId])
|
|
79
99
|
|
|
80
100
|
return (
|
|
81
101
|
<Button type="button" variant={variant} onClick={requestAccess} disabled={submitting || sent}>
|
|
@@ -87,6 +107,7 @@ export function AccessRequestButton({
|
|
|
87
107
|
|
|
88
108
|
export type NoProjectAccessProps = {
|
|
89
109
|
timeProjectId?: string | null
|
|
110
|
+
resolvedScope?: ResolvedProjectAccessScope | null
|
|
90
111
|
/** Where the "Back to My work" action returns to. */
|
|
91
112
|
myWorkHref?: string
|
|
92
113
|
}
|
|
@@ -97,7 +118,7 @@ export type NoProjectAccessProps = {
|
|
|
97
118
|
* access" and "access was revoked" (note 2); navigation stays untouched because
|
|
98
119
|
* the caller still has other projects (note 3).
|
|
99
120
|
*/
|
|
100
|
-
export function NoProjectAccess({ timeProjectId, myWorkHref = MY_WORK_HREF }: NoProjectAccessProps) {
|
|
121
|
+
export function NoProjectAccess({ timeProjectId, resolvedScope, myWorkHref = MY_WORK_HREF }: NoProjectAccessProps) {
|
|
101
122
|
const t = useT()
|
|
102
123
|
return (
|
|
103
124
|
<Page>
|
|
@@ -121,6 +142,7 @@ export function NoProjectAccess({ timeProjectId, myWorkHref = MY_WORK_HREF }: No
|
|
|
121
142
|
</Button>
|
|
122
143
|
<AccessRequestButton
|
|
123
144
|
timeProjectId={timeProjectId}
|
|
145
|
+
resolvedScope={resolvedScope}
|
|
124
146
|
label={t('staff.time_tracking.access.denied.request', 'Request access')}
|
|
125
147
|
sentLabel={t('staff.time_tracking.access.request.sent', 'Request sent')}
|
|
126
148
|
/>
|