@open-mercato/documents 0.10.1-develop.8024.1.02dbc16b49 → 0.10.1-develop.8026.1.a65d0b4a3f
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/dist/modules/documents/api/[id]/comments/access-check/route.js +13 -6
- package/dist/modules/documents/api/[id]/comments/access-check/route.js.map +2 -2
- package/dist/modules/documents/backend/documents/[id]/useDocumentComments.js +14 -6
- package/dist/modules/documents/backend/documents/[id]/useDocumentComments.js.map +2 -2
- package/dist/modules/documents/lib/permissions.js +9 -4
- package/dist/modules/documents/lib/permissions.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/documents/api/[id]/comments/access-check/route.ts +13 -6
- package/src/modules/documents/backend/documents/[id]/useDocumentComments.ts +17 -7
- package/src/modules/documents/lib/permissions.ts +13 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { assertTier,
|
|
3
|
+
import { assertTier, hasUserDocumentViewPermission, resolveUserDocumentTier } from "../../../../lib/permissions.js";
|
|
4
4
|
import {
|
|
5
5
|
handleDocumentsRouteError,
|
|
6
6
|
readBody,
|
|
@@ -13,6 +13,7 @@ const accessCheckSchema = z.object({
|
|
|
13
13
|
});
|
|
14
14
|
const accessCheckResponseSchema = z.object({
|
|
15
15
|
withoutAccess: z.array(z.string().uuid()),
|
|
16
|
+
shareableUserIds: z.array(z.string().uuid()).optional(),
|
|
16
17
|
withoutAccessUsers: z.array(
|
|
17
18
|
z.object({
|
|
18
19
|
userId: z.string().uuid(),
|
|
@@ -38,22 +39,28 @@ async function POST(request, context) {
|
|
|
38
39
|
const input = accessCheckSchema.parse(await readBody(request));
|
|
39
40
|
const uniqueUserIds = Array.from(new Set(input.userIds.map((userId) => userId.toLowerCase())));
|
|
40
41
|
const withoutAccess = [];
|
|
42
|
+
const shareableUserIds = [];
|
|
43
|
+
const scope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId };
|
|
41
44
|
for (const userId of uniqueUserIds) {
|
|
42
|
-
const
|
|
45
|
+
const canView = await hasUserDocumentViewPermission(scope, userId, ctx.container);
|
|
46
|
+
const tier = canView ? await resolveUserDocumentTier(
|
|
43
47
|
ctx.em,
|
|
44
48
|
documentId,
|
|
45
|
-
|
|
49
|
+
scope,
|
|
46
50
|
userId,
|
|
47
51
|
ctx.container
|
|
48
|
-
);
|
|
49
|
-
if (!tier)
|
|
52
|
+
) : null;
|
|
53
|
+
if (!tier) {
|
|
54
|
+
withoutAccess.push(userId);
|
|
55
|
+
if (canView) shareableUserIds.push(userId);
|
|
56
|
+
}
|
|
50
57
|
}
|
|
51
58
|
const withoutAccessUsers = withoutAccess.map((userId) => ({
|
|
52
59
|
userId,
|
|
53
60
|
label: null,
|
|
54
61
|
secondary: null
|
|
55
62
|
}));
|
|
56
|
-
return NextResponse.json({ withoutAccess, withoutAccessUsers });
|
|
63
|
+
return NextResponse.json({ withoutAccess, withoutAccessUsers, shareableUserIds });
|
|
57
64
|
} catch (error) {
|
|
58
65
|
return handleDocumentsRouteError(error, "documents.comments.accessCheck");
|
|
59
66
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/documents/api/%5Bid%5D/comments/access-check/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { assertTier,
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,YAAY
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { assertTier, hasUserDocumentViewPermission, resolveUserDocumentTier } from '../../../../lib/permissions'\nimport {\n handleDocumentsRouteError,\n readBody,\n resolveDocumentsContext,\n routeErrorSchema,\n withDocumentsContextErrors,\n} from '../../../_shared'\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\nconst accessCheckSchema = z.object({\n userIds: z.array(z.string().uuid()).max(50),\n})\n\nconst accessCheckResponseSchema = z.object({\n withoutAccess: z.array(z.string().uuid()),\n shareableUserIds: z.array(z.string().uuid()).optional(),\n withoutAccessUsers: z.array(\n z.object({\n userId: z.string().uuid(),\n // Keep the published nullable-string contract for generated clients;\n // this route deliberately returns null values to avoid identity lookup.\n label: z.string().nullable(),\n secondary: z.string().nullable(),\n }),\n ),\n})\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['documents.view'] },\n}\n\nasync function resolveId(context: RouteContext): Promise<string> {\n const params = await context.params\n return params.id\n}\n\nexport async function POST(request: Request, context: RouteContext): Promise<Response> {\n try {\n const documentId = await resolveId(context)\n const ctx = await resolveDocumentsContext(request, ['documents.view'])\n await assertTier(ctx.em, documentId, ctx.auth, 'commenter')\n const input = accessCheckSchema.parse(await readBody(request))\n const uniqueUserIds = Array.from(new Set(input.userIds.map((userId) => userId.toLowerCase())))\n const withoutAccess: string[] = []\n const shareableUserIds: string[] = []\n const scope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId }\n\n for (const userId of uniqueUserIds) {\n const canView = await hasUserDocumentViewPermission(scope, userId, ctx.container)\n const tier = canView ? await resolveUserDocumentTier(\n ctx.em,\n documentId,\n scope,\n userId,\n ctx.container,\n ) : null\n if (!tier) {\n withoutAccess.push(userId)\n if (canView) shareableUserIds.push(userId)\n }\n }\n // Keep the historical response shape for clients, but never turn this\n // authorization probe into a user-directory/PII lookup. The caller\n // already supplied these IDs and owns their local mention labels.\n const withoutAccessUsers = withoutAccess.map((userId) => ({\n userId,\n label: null,\n secondary: null,\n }))\n\n return NextResponse.json({ withoutAccess, withoutAccessUsers, shareableUserIds })\n } catch (error) {\n return handleDocumentsRouteError(error, 'documents.comments.accessCheck')\n }\n}\n\nexport const openApi: OpenApiRouteDoc = withDocumentsContextErrors({\n tag: 'Documents',\n summary: 'Check mentioned user document access',\n pathParams: z.object({ id: z.string().uuid() }),\n methods: {\n POST: {\n summary: 'Check which mentioned users lack access',\n requestBody: { contentType: 'application/json', schema: accessCheckSchema },\n responses: [{ status: 200, description: 'Mention access check result', schema: accessCheckResponseSchema }],\n errors: [\n { status: 400, description: 'Validation failed', schema: routeErrorSchema },\n { status: 401, description: 'Unauthorized', schema: routeErrorSchema },\n { status: 403, description: 'Forbidden', schema: routeErrorSchema },\n { status: 413, description: 'Request body exceeds the safe resource bound', schema: routeErrorSchema },\n ],\n },\n },\n})\n\nexport default { POST }\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -134,20 +134,25 @@ function useDocumentComments({ documentId, editor, canComment, canShare }) {
|
|
|
134
134
|
);
|
|
135
135
|
if (!isCurrentSubmission()) return { cancelled: true };
|
|
136
136
|
if (!call.ok) throw new Error(t("documents.comments.error.save"));
|
|
137
|
-
const withoutAccess = readWithoutAccess(call.result);
|
|
137
|
+
const withoutAccess = readWithoutAccess(call.result).filter((user) => userIds.includes(user.userId));
|
|
138
138
|
if (withoutAccess.length === 0) return { cancelled: false };
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
const response = readRecord(call.result);
|
|
140
|
+
const shareableUserIds = new Set(
|
|
141
|
+
Array.isArray(response?.shareableUserIds) ? response.shareableUserIds.filter((value) => typeof value === "string").map((value) => value.toLowerCase()) : []
|
|
142
|
+
);
|
|
143
|
+
const shareableUsers = withoutAccess.filter((user) => shareableUserIds.has(user.userId));
|
|
144
|
+
if (!canShare || shareableUsers.length === 0) {
|
|
145
|
+
return { cancelled: false, grantAccessTo: [], hasUnnotifiedMentions: true };
|
|
142
146
|
}
|
|
143
147
|
const pendingMentionNames = new Map(
|
|
144
148
|
activePendingMentions.map((mention) => [mention.userId.toLowerCase(), mention.name])
|
|
145
149
|
);
|
|
146
|
-
const decision = await requestGrantAccess(
|
|
150
|
+
const decision = await requestGrantAccess(shareableUsers.map((user) => pendingMentionNames.get(user.userId) ?? user.label ?? labelFor(user.userId)));
|
|
147
151
|
if (decision === "cancel" || !isCurrentSubmission()) return { cancelled: true };
|
|
148
152
|
return {
|
|
149
153
|
cancelled: false,
|
|
150
|
-
grantAccessTo: decision === "share" ?
|
|
154
|
+
grantAccessTo: decision === "share" ? shareableUsers.map((user) => user.userId) : [],
|
|
155
|
+
hasUnnotifiedMentions: decision === "skip" || shareableUsers.length < withoutAccess.length
|
|
151
156
|
};
|
|
152
157
|
}, [activePendingMentions, canShare, documentId, labelFor, requestGrantAccess, t]);
|
|
153
158
|
const submit = React.useCallback(async () => {
|
|
@@ -182,6 +187,9 @@ function useDocumentComments({ documentId, editor, canComment, canShare }) {
|
|
|
182
187
|
mutationPayload: { body: trimmedBody, parentCommentId, mentions }
|
|
183
188
|
});
|
|
184
189
|
if (!isCurrentSubmission()) return;
|
|
190
|
+
if (grantResolution.hasUnnotifiedMentions) {
|
|
191
|
+
flash(t("documents.comments.grant.noAccessInfo"), "info");
|
|
192
|
+
}
|
|
185
193
|
resetComposer();
|
|
186
194
|
await reload();
|
|
187
195
|
} catch (error) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/documents/backend/documents/%5Bid%5D/useDocumentComments.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { Editor } from '@tiptap/core'\nimport { apiCall, apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n DOCUMENTS_COMMENT_LIST_PAGE_SIZE,\n DOCUMENTS_MAX_COMMENTS_PER_DOCUMENT,\n} from '../../../lib/historyPolicy'\nimport { readNumber, readRecord } from '../documentUi'\nimport type { CommentAnchor } from './CommentAnchorNavigation'\nimport {\n bodyContainsPendingMention,\n findCommentById,\n readCommentItems,\n readUserLabels,\n readWithoutAccess,\n type DocumentComment,\n type PendingMention,\n type UserLabels,\n} from './commentTypes'\n\ntype CommentsState =\n | { status: 'loading' }\n | { status: 'error'; message: string }\n | { status: 'ready'; comments: DocumentComment[]; userLabels: UserLabels }\n\ntype UseDocumentCommentsInput = {\n documentId: string\n editor: Editor | null\n canComment: boolean\n canShare: boolean\n}\n\ntype GrantAccessDecision = 'share' | 'skip' | 'cancel'\n\ntype GrantAccessResolution =\n | { cancelled: true }\n | { cancelled: false; grantAccessTo?: string[] }\n\nexport function useDocumentComments({ documentId, editor, canComment, canShare }: UseDocumentCommentsInput) {\n const t = useT()\n const [state, setState] = React.useState<CommentsState>({ status: 'loading' })\n const [body, setBody] = React.useState('')\n const [pendingMentions, setPendingMentions] = React.useState<PendingMention[]>([])\n const [parentCommentId, setParentCommentId] = React.useState<string | null>(null)\n const [pendingAnchor, setPendingAnchor] = React.useState<CommentAnchor | null>(null)\n const [isSubmitting, setIsSubmitting] = React.useState(false)\n const [resolvingCommentId, setResolvingCommentId] = React.useState<string | null>(null)\n const [grantAccessNames, setGrantAccessNames] = React.useState<string[] | null>(null)\n const grantResolver = React.useRef<((decision: GrantAccessDecision) => void) | null>(null)\n const submissionContextSequence = React.useRef(0)\n const reloadSequence = React.useRef(0)\n const activeReload = React.useRef<AbortController | null>(null)\n const activeDocumentId = React.useRef<string | null>(null)\n const mutationContextId = `documents-comments:${documentId}`\n const { runMutation, retryLastMutation } = useGuardedMutation<{\n formId: string\n resourceKind: string\n resourceId: string\n retryLastMutation: () => Promise<boolean>\n }>({ contextId: mutationContextId, blockedMessage: t('ui.forms.flash.saveBlocked') })\n const activePendingMentions = React.useMemo(\n () => pendingMentions.filter((mention) => bodyContainsPendingMention(body, mention)),\n [body, pendingMentions],\n )\n\n const reload = React.useCallback(async () => {\n const reloadId = ++reloadSequence.current\n const controller = new AbortController()\n activeReload.current?.abort()\n activeReload.current = controller\n const isCurrent = () => reloadSequence.current === reloadId && !controller.signal.aborted\n const documentChanged = activeDocumentId.current !== documentId\n activeDocumentId.current = documentId\n setState((current) => !documentChanged && current.status === 'ready' ? current : { status: 'loading' })\n try {\n const maxPages = Math.ceil(DOCUMENTS_MAX_COMMENTS_PER_DOCUMENT / DOCUMENTS_COMMENT_LIST_PAGE_SIZE)\n const pathForPage = (page: number) => (\n `/api/documents/${encodeURIComponent(documentId)}/comments?page=${page}&pageSize=${DOCUMENTS_COMMENT_LIST_PAGE_SIZE}`\n )\n const firstCall = await apiCall<unknown>(pathForPage(1), { signal: controller.signal })\n if (!isCurrent()) return\n if (!firstCall.ok) {\n setState({ status: 'error', message: t('documents.comments.error.load') })\n return\n }\n\n const firstRoot = readRecord(firstCall.result)\n const totalPages = Math.min(\n maxPages,\n Math.max(1, firstRoot ? readNumber(firstRoot, 'totalPages', 'total_pages') ?? 1 : 1),\n )\n const remainingCalls = totalPages > 1\n ? await Promise.all(Array.from({ length: totalPages - 1 }, (_, index) => (\n apiCall<unknown>(pathForPage(index + 2), { signal: controller.signal })\n )))\n : []\n if (!isCurrent()) return\n if (remainingCalls.some((call) => !call.ok)) {\n setState({ status: 'error', message: t('documents.comments.error.load') })\n return\n }\n\n const calls = [firstCall, ...remainingCalls]\n const comments = [...calls].reverse().flatMap((call) => readCommentItems(call.result))\n const userLabels = calls.reduce<UserLabels>((labels, call) => ({\n ...labels,\n ...readUserLabels(call.result, t('documents.users.unknown')),\n }), {})\n setState({ status: 'ready', comments, userLabels })\n } catch (error) {\n if (isCurrent()) {\n setState({ status: 'error', message: error instanceof Error ? error.message : t('documents.comments.error.load') })\n }\n } finally {\n if (activeReload.current === controller) activeReload.current = null\n }\n }, [documentId, t])\n\n React.useEffect(() => {\n void reload()\n return () => {\n reloadSequence.current += 1\n activeReload.current?.abort()\n activeReload.current = null\n }\n }, [reload])\n\n const cancelPendingGrantAccess = React.useCallback(() => {\n const resolve = grantResolver.current\n grantResolver.current = null\n resolve?.('cancel')\n }, [])\n\n React.useEffect(() => {\n setGrantAccessNames(null)\n setIsSubmitting(false)\n return () => {\n submissionContextSequence.current += 1\n cancelPendingGrantAccess()\n }\n }, [cancelPendingGrantAccess, documentId])\n\n const labelFor = React.useCallback((userId: string) => {\n const labels = state.status === 'ready' ? state.userLabels : {}\n return labels[userId.toLowerCase()]?.label ?? t('documents.users.unknown')\n }, [state, t])\n\n const resetComposer = React.useCallback(() => {\n setBody('')\n setPendingMentions([])\n setParentCommentId(null)\n setPendingAnchor(null)\n }, [])\n\n const chooseGrantAccess = React.useCallback((share: boolean) => {\n const resolve = grantResolver.current\n grantResolver.current = null\n setGrantAccessNames(null)\n resolve?.(share ? 'share' : 'skip')\n }, [])\n\n const requestGrantAccess = React.useCallback((names: string[]) => new Promise<GrantAccessDecision>((resolve) => {\n cancelPendingGrantAccess()\n grantResolver.current = resolve\n setGrantAccessNames(names)\n }), [cancelPendingGrantAccess])\n\n const resolveGrantAccessTo = React.useCallback(async (\n isCurrentSubmission: () => boolean,\n ): Promise<GrantAccessResolution> => {\n const userIds = Array.from(new Set(activePendingMentions.map((mention) => mention.userId.toLowerCase())))\n if (userIds.length === 0) return { cancelled: false }\n // Intentionally outside useGuardedMutation: access-check is read-shaped\n // (a POST only to carry the user-id list in the body) and mutates nothing.\n const call = await apiCall<unknown>(\n `/api/documents/${encodeURIComponent(documentId)}/comments/access-check`,\n { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userIds }) },\n )\n if (!isCurrentSubmission()) return { cancelled: true }\n if (!call.ok) throw new Error(t('documents.comments.error.save'))\n const withoutAccess = readWithoutAccess(call.result)\n if (withoutAccess.length === 0) return { cancelled: false }\n if (!canShare) {\n flash(t('documents.comments.grant.noAccessInfo'), 'info')\n return { cancelled: false, grantAccessTo: [] }\n }\n const pendingMentionNames = new Map(\n activePendingMentions.map((mention) => [mention.userId.toLowerCase(), mention.name]),\n )\n const decision = await requestGrantAccess(withoutAccess.map((user) => (\n pendingMentionNames.get(user.userId) ?? user.label ?? labelFor(user.userId)\n )))\n if (decision === 'cancel' || !isCurrentSubmission()) return { cancelled: true }\n return {\n cancelled: false,\n grantAccessTo: decision === 'share' ? withoutAccess.map((user) => user.userId) : [],\n }\n }, [activePendingMentions, canShare, documentId, labelFor, requestGrantAccess, t])\n\n const submit = React.useCallback(async () => {\n const trimmedBody = body.trim()\n if (!trimmedBody || !canComment) return\n const submissionSequence = submissionContextSequence.current\n const isCurrentSubmission = () => submissionContextSequence.current === submissionSequence\n setIsSubmitting(true)\n try {\n const grantResolution = await resolveGrantAccessTo(isCurrentSubmission)\n if (grantResolution.cancelled || !isCurrentSubmission()) return\n const anchor = pendingAnchor ?? (editor\n ? (await import('./CommentAnchorNavigation')).captureCommentAnchor(editor)\n : null)\n if (!isCurrentSubmission()) return\n const mentions = activePendingMentions.map(({ userId }) => ({ userId }))\n await runMutation({\n operation: () => apiCallOrThrow(\n `/api/documents/${encodeURIComponent(documentId)}/comments`,\n {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n body: trimmedBody,\n anchor,\n parentCommentId,\n mentions,\n grantAccessTo: grantResolution.grantAccessTo,\n }),\n },\n { errorMessage: t('documents.comments.error.save') },\n ),\n context: { formId: mutationContextId, resourceKind: 'documents.document_comment', resourceId: documentId, retryLastMutation },\n mutationPayload: { body: trimmedBody, parentCommentId, mentions },\n })\n if (!isCurrentSubmission()) return\n resetComposer()\n await reload()\n } catch (error) {\n if (isCurrentSubmission()) {\n flash(error instanceof Error ? error.message : t('documents.comments.error.save'), 'error')\n }\n } finally {\n if (isCurrentSubmission()) setIsSubmitting(false)\n }\n }, [activePendingMentions, body, canComment, documentId, editor, mutationContextId, parentCommentId, pendingAnchor, reload, resetComposer, resolveGrantAccessTo, retryLastMutation, runMutation, t])\n\n const resolveComment = React.useCallback(async (comment: DocumentComment) => {\n if (!comment.canResolve) return\n setResolvingCommentId(comment.id)\n try {\n await runMutation({\n operation: () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(comment.updatedAt),\n () => apiCallOrThrow(\n `/api/documents/${encodeURIComponent(documentId)}/comments`,\n { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: comment.id, resolved: comment.resolvedAt === null }) },\n { errorMessage: t('documents.comments.error.save') },\n ),\n ),\n context: { formId: mutationContextId, resourceKind: 'documents.document_comment', resourceId: comment.id, retryLastMutation },\n mutationPayload: { id: comment.id, resolved: comment.resolvedAt === null },\n })\n await reload()\n } catch (error) {\n if (!surfaceRecordConflict(error, t, { onRefresh: () => { void reload() } })) {\n flash(error instanceof Error ? error.message : t('documents.comments.error.save'), 'error')\n }\n } finally {\n setResolvingCommentId(null)\n }\n }, [documentId, mutationContextId, reload, retryLastMutation, runMutation, t])\n\n const comments = state.status === 'ready' ? state.comments : []\n const parentComment = parentCommentId ? findCommentById(comments, parentCommentId) : null\n return {\n state, comments, body, setBody, pendingMentions, setPendingMentions, parentCommentId,\n pendingAnchor, setPendingAnchor, isSubmitting, resolvingCommentId, grantAccessNames,\n chooseGrantAccess, labelFor, resetComposer, reload, submit, resolveComment,\n startReply: (comment: DocumentComment) => { setPendingAnchor(null); setParentCommentId(comment.id) },\n replyToName: parentComment ? labelFor(parentComment.authorUserId) : null,\n }\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { Editor } from '@tiptap/core'\nimport { apiCall, apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n DOCUMENTS_COMMENT_LIST_PAGE_SIZE,\n DOCUMENTS_MAX_COMMENTS_PER_DOCUMENT,\n} from '../../../lib/historyPolicy'\nimport { readNumber, readRecord } from '../documentUi'\nimport type { CommentAnchor } from './CommentAnchorNavigation'\nimport {\n bodyContainsPendingMention,\n findCommentById,\n readCommentItems,\n readUserLabels,\n readWithoutAccess,\n type DocumentComment,\n type PendingMention,\n type UserLabels,\n} from './commentTypes'\n\ntype CommentsState =\n | { status: 'loading' }\n | { status: 'error'; message: string }\n | { status: 'ready'; comments: DocumentComment[]; userLabels: UserLabels }\n\ntype UseDocumentCommentsInput = {\n documentId: string\n editor: Editor | null\n canComment: boolean\n canShare: boolean\n}\n\ntype GrantAccessDecision = 'share' | 'skip' | 'cancel'\n\ntype GrantAccessResolution =\n | { cancelled: true }\n | { cancelled: false; grantAccessTo?: string[]; hasUnnotifiedMentions?: boolean }\n\nexport function useDocumentComments({ documentId, editor, canComment, canShare }: UseDocumentCommentsInput) {\n const t = useT()\n const [state, setState] = React.useState<CommentsState>({ status: 'loading' })\n const [body, setBody] = React.useState('')\n const [pendingMentions, setPendingMentions] = React.useState<PendingMention[]>([])\n const [parentCommentId, setParentCommentId] = React.useState<string | null>(null)\n const [pendingAnchor, setPendingAnchor] = React.useState<CommentAnchor | null>(null)\n const [isSubmitting, setIsSubmitting] = React.useState(false)\n const [resolvingCommentId, setResolvingCommentId] = React.useState<string | null>(null)\n const [grantAccessNames, setGrantAccessNames] = React.useState<string[] | null>(null)\n const grantResolver = React.useRef<((decision: GrantAccessDecision) => void) | null>(null)\n const submissionContextSequence = React.useRef(0)\n const reloadSequence = React.useRef(0)\n const activeReload = React.useRef<AbortController | null>(null)\n const activeDocumentId = React.useRef<string | null>(null)\n const mutationContextId = `documents-comments:${documentId}`\n const { runMutation, retryLastMutation } = useGuardedMutation<{\n formId: string\n resourceKind: string\n resourceId: string\n retryLastMutation: () => Promise<boolean>\n }>({ contextId: mutationContextId, blockedMessage: t('ui.forms.flash.saveBlocked') })\n const activePendingMentions = React.useMemo(\n () => pendingMentions.filter((mention) => bodyContainsPendingMention(body, mention)),\n [body, pendingMentions],\n )\n\n const reload = React.useCallback(async () => {\n const reloadId = ++reloadSequence.current\n const controller = new AbortController()\n activeReload.current?.abort()\n activeReload.current = controller\n const isCurrent = () => reloadSequence.current === reloadId && !controller.signal.aborted\n const documentChanged = activeDocumentId.current !== documentId\n activeDocumentId.current = documentId\n setState((current) => !documentChanged && current.status === 'ready' ? current : { status: 'loading' })\n try {\n const maxPages = Math.ceil(DOCUMENTS_MAX_COMMENTS_PER_DOCUMENT / DOCUMENTS_COMMENT_LIST_PAGE_SIZE)\n const pathForPage = (page: number) => (\n `/api/documents/${encodeURIComponent(documentId)}/comments?page=${page}&pageSize=${DOCUMENTS_COMMENT_LIST_PAGE_SIZE}`\n )\n const firstCall = await apiCall<unknown>(pathForPage(1), { signal: controller.signal })\n if (!isCurrent()) return\n if (!firstCall.ok) {\n setState({ status: 'error', message: t('documents.comments.error.load') })\n return\n }\n\n const firstRoot = readRecord(firstCall.result)\n const totalPages = Math.min(\n maxPages,\n Math.max(1, firstRoot ? readNumber(firstRoot, 'totalPages', 'total_pages') ?? 1 : 1),\n )\n const remainingCalls = totalPages > 1\n ? await Promise.all(Array.from({ length: totalPages - 1 }, (_, index) => (\n apiCall<unknown>(pathForPage(index + 2), { signal: controller.signal })\n )))\n : []\n if (!isCurrent()) return\n if (remainingCalls.some((call) => !call.ok)) {\n setState({ status: 'error', message: t('documents.comments.error.load') })\n return\n }\n\n const calls = [firstCall, ...remainingCalls]\n const comments = [...calls].reverse().flatMap((call) => readCommentItems(call.result))\n const userLabels = calls.reduce<UserLabels>((labels, call) => ({\n ...labels,\n ...readUserLabels(call.result, t('documents.users.unknown')),\n }), {})\n setState({ status: 'ready', comments, userLabels })\n } catch (error) {\n if (isCurrent()) {\n setState({ status: 'error', message: error instanceof Error ? error.message : t('documents.comments.error.load') })\n }\n } finally {\n if (activeReload.current === controller) activeReload.current = null\n }\n }, [documentId, t])\n\n React.useEffect(() => {\n void reload()\n return () => {\n reloadSequence.current += 1\n activeReload.current?.abort()\n activeReload.current = null\n }\n }, [reload])\n\n const cancelPendingGrantAccess = React.useCallback(() => {\n const resolve = grantResolver.current\n grantResolver.current = null\n resolve?.('cancel')\n }, [])\n\n React.useEffect(() => {\n setGrantAccessNames(null)\n setIsSubmitting(false)\n return () => {\n submissionContextSequence.current += 1\n cancelPendingGrantAccess()\n }\n }, [cancelPendingGrantAccess, documentId])\n\n const labelFor = React.useCallback((userId: string) => {\n const labels = state.status === 'ready' ? state.userLabels : {}\n return labels[userId.toLowerCase()]?.label ?? t('documents.users.unknown')\n }, [state, t])\n\n const resetComposer = React.useCallback(() => {\n setBody('')\n setPendingMentions([])\n setParentCommentId(null)\n setPendingAnchor(null)\n }, [])\n\n const chooseGrantAccess = React.useCallback((share: boolean) => {\n const resolve = grantResolver.current\n grantResolver.current = null\n setGrantAccessNames(null)\n resolve?.(share ? 'share' : 'skip')\n }, [])\n\n const requestGrantAccess = React.useCallback((names: string[]) => new Promise<GrantAccessDecision>((resolve) => {\n cancelPendingGrantAccess()\n grantResolver.current = resolve\n setGrantAccessNames(names)\n }), [cancelPendingGrantAccess])\n\n const resolveGrantAccessTo = React.useCallback(async (\n isCurrentSubmission: () => boolean,\n ): Promise<GrantAccessResolution> => {\n const userIds = Array.from(new Set(activePendingMentions.map((mention) => mention.userId.toLowerCase())))\n if (userIds.length === 0) return { cancelled: false }\n // Intentionally outside useGuardedMutation: access-check is read-shaped\n // (a POST only to carry the user-id list in the body) and mutates nothing.\n const call = await apiCall<unknown>(\n `/api/documents/${encodeURIComponent(documentId)}/comments/access-check`,\n { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userIds }) },\n )\n if (!isCurrentSubmission()) return { cancelled: true }\n if (!call.ok) throw new Error(t('documents.comments.error.save'))\n const withoutAccess = readWithoutAccess(call.result).filter((user) => userIds.includes(user.userId))\n if (withoutAccess.length === 0) return { cancelled: false }\n const response = readRecord(call.result)\n const shareableUserIds = new Set(\n Array.isArray(response?.shareableUserIds)\n ? response.shareableUserIds.filter((value): value is string => typeof value === 'string').map((value) => value.toLowerCase())\n : [],\n )\n const shareableUsers = withoutAccess.filter((user) => shareableUserIds.has(user.userId))\n if (!canShare || shareableUsers.length === 0) {\n return { cancelled: false, grantAccessTo: [], hasUnnotifiedMentions: true }\n }\n const pendingMentionNames = new Map(\n activePendingMentions.map((mention) => [mention.userId.toLowerCase(), mention.name]),\n )\n const decision = await requestGrantAccess(shareableUsers.map((user) => (\n pendingMentionNames.get(user.userId) ?? user.label ?? labelFor(user.userId)\n )))\n if (decision === 'cancel' || !isCurrentSubmission()) return { cancelled: true }\n return {\n cancelled: false,\n grantAccessTo: decision === 'share' ? shareableUsers.map((user) => user.userId) : [],\n hasUnnotifiedMentions: decision === 'skip' || shareableUsers.length < withoutAccess.length,\n }\n }, [activePendingMentions, canShare, documentId, labelFor, requestGrantAccess, t])\n\n const submit = React.useCallback(async () => {\n const trimmedBody = body.trim()\n if (!trimmedBody || !canComment) return\n const submissionSequence = submissionContextSequence.current\n const isCurrentSubmission = () => submissionContextSequence.current === submissionSequence\n setIsSubmitting(true)\n try {\n const grantResolution = await resolveGrantAccessTo(isCurrentSubmission)\n if (grantResolution.cancelled || !isCurrentSubmission()) return\n const anchor = pendingAnchor ?? (editor\n ? (await import('./CommentAnchorNavigation')).captureCommentAnchor(editor)\n : null)\n if (!isCurrentSubmission()) return\n const mentions = activePendingMentions.map(({ userId }) => ({ userId }))\n await runMutation({\n operation: () => apiCallOrThrow(\n `/api/documents/${encodeURIComponent(documentId)}/comments`,\n {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n body: trimmedBody,\n anchor,\n parentCommentId,\n mentions,\n grantAccessTo: grantResolution.grantAccessTo,\n }),\n },\n { errorMessage: t('documents.comments.error.save') },\n ),\n context: { formId: mutationContextId, resourceKind: 'documents.document_comment', resourceId: documentId, retryLastMutation },\n mutationPayload: { body: trimmedBody, parentCommentId, mentions },\n })\n if (!isCurrentSubmission()) return\n if (grantResolution.hasUnnotifiedMentions) {\n flash(t('documents.comments.grant.noAccessInfo'), 'info')\n }\n resetComposer()\n await reload()\n } catch (error) {\n if (isCurrentSubmission()) {\n flash(error instanceof Error ? error.message : t('documents.comments.error.save'), 'error')\n }\n } finally {\n if (isCurrentSubmission()) setIsSubmitting(false)\n }\n }, [activePendingMentions, body, canComment, documentId, editor, mutationContextId, parentCommentId, pendingAnchor, reload, resetComposer, resolveGrantAccessTo, retryLastMutation, runMutation, t])\n\n const resolveComment = React.useCallback(async (comment: DocumentComment) => {\n if (!comment.canResolve) return\n setResolvingCommentId(comment.id)\n try {\n await runMutation({\n operation: () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(comment.updatedAt),\n () => apiCallOrThrow(\n `/api/documents/${encodeURIComponent(documentId)}/comments`,\n { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: comment.id, resolved: comment.resolvedAt === null }) },\n { errorMessage: t('documents.comments.error.save') },\n ),\n ),\n context: { formId: mutationContextId, resourceKind: 'documents.document_comment', resourceId: comment.id, retryLastMutation },\n mutationPayload: { id: comment.id, resolved: comment.resolvedAt === null },\n })\n await reload()\n } catch (error) {\n if (!surfaceRecordConflict(error, t, { onRefresh: () => { void reload() } })) {\n flash(error instanceof Error ? error.message : t('documents.comments.error.save'), 'error')\n }\n } finally {\n setResolvingCommentId(null)\n }\n }, [documentId, mutationContextId, reload, retryLastMutation, runMutation, t])\n\n const comments = state.status === 'ready' ? state.comments : []\n const parentComment = parentCommentId ? findCommentById(comments, parentCommentId) : null\n return {\n state, comments, body, setBody, pendingMentions, setPendingMentions, parentCommentId,\n pendingAnchor, setPendingAnchor, isSubmitting, resolvingCommentId, grantAccessNames,\n chooseGrantAccess, labelFor, resetComposer, reload, submit, resolveComment,\n startReply: (comment: DocumentComment) => { setPendingAnchor(null); setParentCommentId(comment.id) },\n replyToName: parentComment ? labelFor(parentComment.authorUserId) : null,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["comments"]
|
|
7
7
|
}
|
|
@@ -129,11 +129,15 @@ async function loadScopedDocument(em, documentId, scope) {
|
|
|
129
129
|
);
|
|
130
130
|
}
|
|
131
131
|
async function resolveLoadedDocumentUserAccess(em, document, scope, userId, container) {
|
|
132
|
+
if (!await hasUserDocumentViewPermission(scope, userId, container)) return null;
|
|
133
|
+
return resolveLoadedDocumentUserTier(em, document, scope, userId, container);
|
|
134
|
+
}
|
|
135
|
+
async function hasUserDocumentViewPermission(scope, userId, container) {
|
|
132
136
|
const rbacService = resolveDocumentsRbacService(container);
|
|
133
|
-
if (!rbacService) return
|
|
137
|
+
if (!rbacService) return false;
|
|
134
138
|
if (!await rbacService.userHasAllFeatures(userId, ["documents.view"], scope)) {
|
|
135
139
|
const organizationScopeService = resolveOrganizationScopeService(container);
|
|
136
|
-
if (!organizationScopeService) return
|
|
140
|
+
if (!organizationScopeService) return false;
|
|
137
141
|
const authorization = await organizationScopeService.resolveFresh({
|
|
138
142
|
auth: { sub: userId, userId, tenantId: scope.tenantId, orgId: scope.organizationId, isSuperAdmin: false },
|
|
139
143
|
selectedId: scope.organizationId,
|
|
@@ -143,9 +147,9 @@ async function resolveLoadedDocumentUserAccess(em, document, scope, userId, cont
|
|
|
143
147
|
grantedFeatures: authorization.acl.features,
|
|
144
148
|
unrestricted: authorization.acl.isSuperAdmin,
|
|
145
149
|
scopeAllowed: authorization.scope.tenantId === scope.tenantId && !authorization.scope.selectionRejected && hasResolvedDocumentsOrganizationAccess(authorization.acl, scope.organizationId, authorization.scope)
|
|
146
|
-
})) return
|
|
150
|
+
})) return false;
|
|
147
151
|
}
|
|
148
|
-
return
|
|
152
|
+
return true;
|
|
149
153
|
}
|
|
150
154
|
async function resolveLoadedDocumentUserTier(em, document, scope, userId, container) {
|
|
151
155
|
if (document.ownerUserId === userId) return "owner";
|
|
@@ -238,6 +242,7 @@ export {
|
|
|
238
242
|
TIER_RANK,
|
|
239
243
|
assertTier,
|
|
240
244
|
hasTier,
|
|
245
|
+
hasUserDocumentViewPermission,
|
|
241
246
|
loadScopedDocument,
|
|
242
247
|
resolveActiveSubjectRoleIds,
|
|
243
248
|
resolveActiveUserRoleIds,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/documents/lib/permissions.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\nimport { forbidden } from '@open-mercato/shared/lib/crud/errors'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { Document, DocumentShare } from '../data/entities'\nimport { hasResolvedDocumentsOrganizationAccess } from './organizationAccess'\nimport {\n resolveApiKeyPrincipalService,\n resolveAuthPrincipalService,\n resolveDocumentsRbacService,\n resolveOrganizationScopeService,\n type DocumentsServiceContainer,\n} from './platformServices'\n\nexport type DocumentTier = 'owner' | 'editor' | 'commenter' | 'viewer'\n\nexport const TIER_RANK: Record<DocumentTier, number> = {\n owner: 3,\n editor: 2,\n commenter: 1,\n viewer: 0,\n}\n\ntype DocumentsPermissionContext = NonNullable<AuthContext> & {\n features?: string[]\n roleIds?: string[]\n resolvedRoleIds?: string[]\n organizationId?: string | null\n}\n\nfunction resolveUserId(ctx: AuthContext): string | null {\n if (!ctx) return null\n if (typeof ctx.userId === 'string' && ctx.userId.trim().length > 0) return ctx.userId\n if (typeof ctx.sub === 'string' && ctx.sub.trim().length > 0 && !ctx.sub.startsWith('api_key:')) return ctx.sub\n return null\n}\n\nfunction resolveOrganizationId(ctx: DocumentsPermissionContext): string | null {\n if (typeof ctx.organizationId === 'string' && ctx.organizationId.trim().length > 0) {\n return ctx.organizationId\n }\n if (typeof ctx.orgId === 'string' && ctx.orgId.trim().length > 0) return ctx.orgId\n return null\n}\n\nfunction normalizeStrings(values: unknown): string[] {\n if (!Array.isArray(values)) return []\n return Array.from(\n new Set(\n values\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ),\n )\n}\n\nfunction hasDocumentsManage(ctx: DocumentsPermissionContext): boolean {\n if (ctx.isSuperAdmin === true) return true\n const features = normalizeStrings(ctx.features)\n return hasAllFeatures(['documents.manage'], features)\n}\n\nexport async function resolveActiveUserRoleIds(\n container: DocumentsServiceContainer | null | undefined,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n): Promise<string[]> {\n const service = resolveAuthPrincipalService(container)\n if (!service) return []\n return normalizeStrings(await service.resolveActiveUserRoleIds(userId, scope))\n}\n\nexport async function resolveActiveSubjectRoleIds(\n container: DocumentsServiceContainer | null | undefined,\n scope: { tenantId: string; organizationId: string },\n subject: string,\n): Promise<string[]> {\n if (!subject.startsWith('api_key:')) {\n return resolveActiveUserRoleIds(container, scope, subject)\n }\n\n const apiKeyId = subject.slice('api_key:'.length).trim()\n if (!apiKeyId) return []\n const apiKeyService = resolveApiKeyPrincipalService(container)\n const authService = resolveAuthPrincipalService(container)\n if (!apiKeyService || !authService) return []\n const assigned = normalizeStrings(await apiKeyService.resolveAssignedRoleIds(apiKeyId, scope))\n if (assigned.length === 0) return []\n return normalizeStrings(await authService.filterActiveRoleIds(assigned, scope))\n}\n\nfunction maxShareTier(shares: DocumentShare[]): DocumentTier | null {\n let best: DocumentTier | null = null\n for (const share of shares) {\n const permission = share.permission\n if (permission !== 'viewer' && permission !== 'commenter' && permission !== 'editor') continue\n if (!best || TIER_RANK[permission] > TIER_RANK[best]) best = permission\n }\n return best\n}\n\nexport async function resolvePermission(\n em: EntityManager,\n documentId: string,\n ctx: AuthContext,\n): Promise<DocumentTier | null> {\n if (!ctx || !ctx.tenantId) return null\n const permissionCtx = ctx as DocumentsPermissionContext\n const organizationId = resolveOrganizationId(permissionCtx)\n if (!organizationId) return null\n\n const document = await findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: ctx.tenantId,\n organizationId,\n deletedAt: null,\n },\n undefined,\n { tenantId: ctx.tenantId, organizationId },\n )\n if (!document) return null\n\n const userId = resolveUserId(ctx)\n if ((userId && document.ownerUserId === userId) || hasDocumentsManage(permissionCtx)) {\n return 'owner'\n }\n if (!userId) return null\n\n // Never trust role ids/names carried by the request token. Role shares are\n // resolved from active UserRole links to active roles in the current tenant.\n // Only consume the server-projected role set. Raw token roleIds/roles are\n // authentication hints and can outlive an assignment revocation.\n const roleIds = normalizeStrings(permissionCtx.resolvedRoleIds)\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId,\n tenantId: ctx.tenantId,\n organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n { tenantId: ctx.tenantId, organizationId },\n )\n\n return maxShareTier(shares)\n}\n\nexport async function loadScopedDocument(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<Document | null> {\n return findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n}\n\n/**\n * Resolve one user's tier against an already scoped document. Callers that\n * evaluate many users for the same document load it once and reuse it here\n * instead of re-reading the identical row per user.\n */\nexport async function resolveLoadedDocumentUserAccess(\n em: EntityManager,\n document: Document,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const rbacService = resolveDocumentsRbacService(container)\n if (!rbacService) return null\n if (!await rbacService.userHasAllFeatures(userId, ['documents.view'], scope)) {\n const organizationScopeService = resolveOrganizationScopeService(container)\n if (!organizationScopeService) return null\n const authorization = await organizationScopeService.resolveFresh({\n auth: { sub: userId, userId, tenantId: scope.tenantId, orgId: scope.organizationId, isSuperAdmin: false },\n selectedId: scope.organizationId,\n tenantId: scope.tenantId,\n })\n if (!authorizeFeatures(['documents.view'], {\n grantedFeatures: authorization.acl.features,\n unrestricted: authorization.acl.isSuperAdmin,\n scopeAllowed: authorization.scope.tenantId === scope.tenantId\n && !authorization.scope.selectionRejected\n && hasResolvedDocumentsOrganizationAccess(authorization.acl, scope.organizationId, authorization.scope),\n })) return null\n }\n return resolveLoadedDocumentUserTier(em, document, scope, userId, container)\n}\n\nasync function resolveLoadedDocumentUserTier(\n em: EntityManager,\n document: Document,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n if (document.ownerUserId === userId) return 'owner'\n if (await resolveDocumentsRbacService(container)?.userHasAllFeatures(\n userId,\n ['documents.manage'],\n scope,\n )) return 'owner'\n\n const roleIds = await resolveActiveUserRoleIds(container, scope, userId)\n\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId: document.id,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n scope,\n )\n\n return maxShareTier(shares)\n}\n\nexport async function resolveUserDocumentTier(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await loadScopedDocument(em, documentId, scope)\n if (!document) return null\n return resolveLoadedDocumentUserTier(em, document, scope, userId, container)\n}\n\nexport async function resolveUserAccess(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await loadScopedDocument(em, documentId, scope)\n if (!document) return null\n return resolveLoadedDocumentUserAccess(em, document, scope, userId, container)\n}\n\nexport async function resolveSubjectAccess(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n identity: { subject: string; userId: string },\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!document) return null\n if (document.ownerUserId === identity.userId) return 'owner'\n\n const roleIds = await resolveActiveSubjectRoleIds(container, scope, identity.subject)\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: identity.userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n scope,\n )\n return maxShareTier(shares)\n}\n\nexport function hasTier(tier: DocumentTier | null, required: DocumentTier): boolean {\n if (!tier) return false\n return TIER_RANK[tier] >= TIER_RANK[required]\n}\n\nexport async function assertTier(\n em: EntityManager,\n documentId: string,\n ctx: AuthContext,\n required: DocumentTier,\n): Promise<DocumentTier> {\n const tier = await resolvePermission(em, documentId, ctx)\n if (!tier || !hasTier(tier, required)) {\n throw forbidden('Forbidden')\n }\n return tier\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\nimport { forbidden } from '@open-mercato/shared/lib/crud/errors'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { Document, DocumentShare } from '../data/entities'\nimport { hasResolvedDocumentsOrganizationAccess } from './organizationAccess'\nimport {\n resolveApiKeyPrincipalService,\n resolveAuthPrincipalService,\n resolveDocumentsRbacService,\n resolveOrganizationScopeService,\n type DocumentsServiceContainer,\n} from './platformServices'\n\nexport type DocumentTier = 'owner' | 'editor' | 'commenter' | 'viewer'\n\nexport const TIER_RANK: Record<DocumentTier, number> = {\n owner: 3,\n editor: 2,\n commenter: 1,\n viewer: 0,\n}\n\ntype DocumentsPermissionContext = NonNullable<AuthContext> & {\n features?: string[]\n roleIds?: string[]\n resolvedRoleIds?: string[]\n organizationId?: string | null\n}\n\nfunction resolveUserId(ctx: AuthContext): string | null {\n if (!ctx) return null\n if (typeof ctx.userId === 'string' && ctx.userId.trim().length > 0) return ctx.userId\n if (typeof ctx.sub === 'string' && ctx.sub.trim().length > 0 && !ctx.sub.startsWith('api_key:')) return ctx.sub\n return null\n}\n\nfunction resolveOrganizationId(ctx: DocumentsPermissionContext): string | null {\n if (typeof ctx.organizationId === 'string' && ctx.organizationId.trim().length > 0) {\n return ctx.organizationId\n }\n if (typeof ctx.orgId === 'string' && ctx.orgId.trim().length > 0) return ctx.orgId\n return null\n}\n\nfunction normalizeStrings(values: unknown): string[] {\n if (!Array.isArray(values)) return []\n return Array.from(\n new Set(\n values\n .map((value) => (typeof value === 'string' ? value.trim() : ''))\n .filter((value) => value.length > 0),\n ),\n )\n}\n\nfunction hasDocumentsManage(ctx: DocumentsPermissionContext): boolean {\n if (ctx.isSuperAdmin === true) return true\n const features = normalizeStrings(ctx.features)\n return hasAllFeatures(['documents.manage'], features)\n}\n\nexport async function resolveActiveUserRoleIds(\n container: DocumentsServiceContainer | null | undefined,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n): Promise<string[]> {\n const service = resolveAuthPrincipalService(container)\n if (!service) return []\n return normalizeStrings(await service.resolveActiveUserRoleIds(userId, scope))\n}\n\nexport async function resolveActiveSubjectRoleIds(\n container: DocumentsServiceContainer | null | undefined,\n scope: { tenantId: string; organizationId: string },\n subject: string,\n): Promise<string[]> {\n if (!subject.startsWith('api_key:')) {\n return resolveActiveUserRoleIds(container, scope, subject)\n }\n\n const apiKeyId = subject.slice('api_key:'.length).trim()\n if (!apiKeyId) return []\n const apiKeyService = resolveApiKeyPrincipalService(container)\n const authService = resolveAuthPrincipalService(container)\n if (!apiKeyService || !authService) return []\n const assigned = normalizeStrings(await apiKeyService.resolveAssignedRoleIds(apiKeyId, scope))\n if (assigned.length === 0) return []\n return normalizeStrings(await authService.filterActiveRoleIds(assigned, scope))\n}\n\nfunction maxShareTier(shares: DocumentShare[]): DocumentTier | null {\n let best: DocumentTier | null = null\n for (const share of shares) {\n const permission = share.permission\n if (permission !== 'viewer' && permission !== 'commenter' && permission !== 'editor') continue\n if (!best || TIER_RANK[permission] > TIER_RANK[best]) best = permission\n }\n return best\n}\n\nexport async function resolvePermission(\n em: EntityManager,\n documentId: string,\n ctx: AuthContext,\n): Promise<DocumentTier | null> {\n if (!ctx || !ctx.tenantId) return null\n const permissionCtx = ctx as DocumentsPermissionContext\n const organizationId = resolveOrganizationId(permissionCtx)\n if (!organizationId) return null\n\n const document = await findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: ctx.tenantId,\n organizationId,\n deletedAt: null,\n },\n undefined,\n { tenantId: ctx.tenantId, organizationId },\n )\n if (!document) return null\n\n const userId = resolveUserId(ctx)\n if ((userId && document.ownerUserId === userId) || hasDocumentsManage(permissionCtx)) {\n return 'owner'\n }\n if (!userId) return null\n\n // Never trust role ids/names carried by the request token. Role shares are\n // resolved from active UserRole links to active roles in the current tenant.\n // Only consume the server-projected role set. Raw token roleIds/roles are\n // authentication hints and can outlive an assignment revocation.\n const roleIds = normalizeStrings(permissionCtx.resolvedRoleIds)\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId,\n tenantId: ctx.tenantId,\n organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n { tenantId: ctx.tenantId, organizationId },\n )\n\n return maxShareTier(shares)\n}\n\nexport async function loadScopedDocument(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<Document | null> {\n return findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n}\n\n/**\n * Resolve one user's tier against an already scoped document. Callers that\n * evaluate many users for the same document load it once and reuse it here\n * instead of re-reading the identical row per user.\n */\nexport async function resolveLoadedDocumentUserAccess(\n em: EntityManager,\n document: Document,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n if (!await hasUserDocumentViewPermission(scope, userId, container)) return null\n return resolveLoadedDocumentUserTier(em, document, scope, userId, container)\n}\n\nexport async function hasUserDocumentViewPermission(\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<boolean> {\n const rbacService = resolveDocumentsRbacService(container)\n if (!rbacService) return false\n if (!await rbacService.userHasAllFeatures(userId, ['documents.view'], scope)) {\n const organizationScopeService = resolveOrganizationScopeService(container)\n if (!organizationScopeService) return false\n const authorization = await organizationScopeService.resolveFresh({\n auth: { sub: userId, userId, tenantId: scope.tenantId, orgId: scope.organizationId, isSuperAdmin: false },\n selectedId: scope.organizationId,\n tenantId: scope.tenantId,\n })\n if (!authorizeFeatures(['documents.view'], {\n grantedFeatures: authorization.acl.features,\n unrestricted: authorization.acl.isSuperAdmin,\n scopeAllowed: authorization.scope.tenantId === scope.tenantId\n && !authorization.scope.selectionRejected\n && hasResolvedDocumentsOrganizationAccess(authorization.acl, scope.organizationId, authorization.scope),\n })) return false\n }\n return true\n}\n\nasync function resolveLoadedDocumentUserTier(\n em: EntityManager,\n document: Document,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n if (document.ownerUserId === userId) return 'owner'\n if (await resolveDocumentsRbacService(container)?.userHasAllFeatures(\n userId,\n ['documents.manage'],\n scope,\n )) return 'owner'\n\n const roleIds = await resolveActiveUserRoleIds(container, scope, userId)\n\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId: document.id,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n scope,\n )\n\n return maxShareTier(shares)\n}\n\nexport async function resolveUserDocumentTier(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await loadScopedDocument(em, documentId, scope)\n if (!document) return null\n return resolveLoadedDocumentUserTier(em, document, scope, userId, container)\n}\n\nexport async function resolveUserAccess(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n userId: string,\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await loadScopedDocument(em, documentId, scope)\n if (!document) return null\n return resolveLoadedDocumentUserAccess(em, document, scope, userId, container)\n}\n\nexport async function resolveSubjectAccess(\n em: EntityManager,\n documentId: string,\n scope: { tenantId: string; organizationId: string },\n identity: { subject: string; userId: string },\n container?: DocumentsServiceContainer | null,\n): Promise<DocumentTier | null> {\n const document = await findOneWithDecryption(\n em,\n Document,\n {\n id: documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n if (!document) return null\n if (document.ownerUserId === identity.userId) return 'owner'\n\n const roleIds = await resolveActiveSubjectRoleIds(container, scope, identity.subject)\n const principals: Array<FilterQuery<DocumentShare>> = [\n { principalType: 'user', principalId: identity.userId },\n ]\n if (roleIds.length > 0) {\n principals.push({ principalType: 'role', principalId: { $in: roleIds } } as FilterQuery<DocumentShare>)\n }\n\n const shares = await findWithDecryption(\n em,\n DocumentShare,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n $or: principals,\n } as FilterQuery<DocumentShare>,\n undefined,\n scope,\n )\n return maxShareTier(shares)\n}\n\nexport function hasTier(tier: DocumentTier | null, required: DocumentTier): boolean {\n if (!tier) return false\n return TIER_RANK[tier] >= TIER_RANK[required]\n}\n\nexport async function assertTier(\n em: EntityManager,\n documentId: string,\n ctx: AuthContext,\n required: DocumentTier,\n): Promise<DocumentTier> {\n const tier = await resolvePermission(em, documentId, ctx)\n if (!tier || !hasTier(tier, required)) {\n throw forbidden('Forbidden')\n }\n return tier\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/documents",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.8026.1.a65d0b4a3f",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
"@hocuspocus/server": "^4.4.0",
|
|
76
76
|
"@hocuspocus/transformer": "^4.3.0",
|
|
77
77
|
"@mikro-orm/core": "^7.1.14",
|
|
78
|
-
"@open-mercato/events": "0.10.1-develop.
|
|
79
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
78
|
+
"@open-mercato/events": "0.10.1-develop.8026.1.a65d0b4a3f",
|
|
79
|
+
"@open-mercato/ui": "0.10.1-develop.8026.1.a65d0b4a3f",
|
|
80
80
|
"@tiptap/core": "3.32.0",
|
|
81
81
|
"@tiptap/extension-bubble-menu": "3.32.0",
|
|
82
82
|
"@tiptap/extension-collaboration": "3.32.0",
|
|
@@ -112,14 +112,14 @@
|
|
|
112
112
|
},
|
|
113
113
|
"peerDependencies": {
|
|
114
114
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
115
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
115
|
+
"@open-mercato/shared": "0.10.1-develop.8026.1.a65d0b4a3f",
|
|
116
116
|
"react": "^19.0.0",
|
|
117
117
|
"react-dom": "^19.0.0",
|
|
118
118
|
"react-is": ">=18.0.0"
|
|
119
119
|
},
|
|
120
120
|
"devDependencies": {
|
|
121
|
-
"@open-mercato/core": "0.10.1-develop.
|
|
122
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
121
|
+
"@open-mercato/core": "0.10.1-develop.8026.1.a65d0b4a3f",
|
|
122
|
+
"@open-mercato/shared": "0.10.1-develop.8026.1.a65d0b4a3f",
|
|
123
123
|
"@types/jest": "^30.0.0",
|
|
124
124
|
"@types/pg": "^8.23.1",
|
|
125
125
|
"@types/react": "^19.2.18",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
|
-
import { assertTier,
|
|
4
|
+
import { assertTier, hasUserDocumentViewPermission, resolveUserDocumentTier } from '../../../../lib/permissions'
|
|
5
5
|
import {
|
|
6
6
|
handleDocumentsRouteError,
|
|
7
7
|
readBody,
|
|
@@ -20,6 +20,7 @@ const accessCheckSchema = z.object({
|
|
|
20
20
|
|
|
21
21
|
const accessCheckResponseSchema = z.object({
|
|
22
22
|
withoutAccess: z.array(z.string().uuid()),
|
|
23
|
+
shareableUserIds: z.array(z.string().uuid()).optional(),
|
|
23
24
|
withoutAccessUsers: z.array(
|
|
24
25
|
z.object({
|
|
25
26
|
userId: z.string().uuid(),
|
|
@@ -48,16 +49,22 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
|
|
|
48
49
|
const input = accessCheckSchema.parse(await readBody(request))
|
|
49
50
|
const uniqueUserIds = Array.from(new Set(input.userIds.map((userId) => userId.toLowerCase())))
|
|
50
51
|
const withoutAccess: string[] = []
|
|
52
|
+
const shareableUserIds: string[] = []
|
|
53
|
+
const scope = { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
|
|
51
54
|
|
|
52
55
|
for (const userId of uniqueUserIds) {
|
|
53
|
-
const
|
|
56
|
+
const canView = await hasUserDocumentViewPermission(scope, userId, ctx.container)
|
|
57
|
+
const tier = canView ? await resolveUserDocumentTier(
|
|
54
58
|
ctx.em,
|
|
55
59
|
documentId,
|
|
56
|
-
|
|
60
|
+
scope,
|
|
57
61
|
userId,
|
|
58
62
|
ctx.container,
|
|
59
|
-
)
|
|
60
|
-
if (!tier)
|
|
63
|
+
) : null
|
|
64
|
+
if (!tier) {
|
|
65
|
+
withoutAccess.push(userId)
|
|
66
|
+
if (canView) shareableUserIds.push(userId)
|
|
67
|
+
}
|
|
61
68
|
}
|
|
62
69
|
// Keep the historical response shape for clients, but never turn this
|
|
63
70
|
// authorization probe into a user-directory/PII lookup. The caller
|
|
@@ -68,7 +75,7 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
|
|
|
68
75
|
secondary: null,
|
|
69
76
|
}))
|
|
70
77
|
|
|
71
|
-
return NextResponse.json({ withoutAccess, withoutAccessUsers })
|
|
78
|
+
return NextResponse.json({ withoutAccess, withoutAccessUsers, shareableUserIds })
|
|
72
79
|
} catch (error) {
|
|
73
80
|
return handleDocumentsRouteError(error, 'documents.comments.accessCheck')
|
|
74
81
|
}
|
|
@@ -41,7 +41,7 @@ type GrantAccessDecision = 'share' | 'skip' | 'cancel'
|
|
|
41
41
|
|
|
42
42
|
type GrantAccessResolution =
|
|
43
43
|
| { cancelled: true }
|
|
44
|
-
| { cancelled: false; grantAccessTo?: string[] }
|
|
44
|
+
| { cancelled: false; grantAccessTo?: string[]; hasUnnotifiedMentions?: boolean }
|
|
45
45
|
|
|
46
46
|
export function useDocumentComments({ documentId, editor, canComment, canShare }: UseDocumentCommentsInput) {
|
|
47
47
|
const t = useT()
|
|
@@ -185,22 +185,29 @@ export function useDocumentComments({ documentId, editor, canComment, canShare }
|
|
|
185
185
|
)
|
|
186
186
|
if (!isCurrentSubmission()) return { cancelled: true }
|
|
187
187
|
if (!call.ok) throw new Error(t('documents.comments.error.save'))
|
|
188
|
-
const withoutAccess = readWithoutAccess(call.result)
|
|
188
|
+
const withoutAccess = readWithoutAccess(call.result).filter((user) => userIds.includes(user.userId))
|
|
189
189
|
if (withoutAccess.length === 0) return { cancelled: false }
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
const response = readRecord(call.result)
|
|
191
|
+
const shareableUserIds = new Set(
|
|
192
|
+
Array.isArray(response?.shareableUserIds)
|
|
193
|
+
? response.shareableUserIds.filter((value): value is string => typeof value === 'string').map((value) => value.toLowerCase())
|
|
194
|
+
: [],
|
|
195
|
+
)
|
|
196
|
+
const shareableUsers = withoutAccess.filter((user) => shareableUserIds.has(user.userId))
|
|
197
|
+
if (!canShare || shareableUsers.length === 0) {
|
|
198
|
+
return { cancelled: false, grantAccessTo: [], hasUnnotifiedMentions: true }
|
|
193
199
|
}
|
|
194
200
|
const pendingMentionNames = new Map(
|
|
195
201
|
activePendingMentions.map((mention) => [mention.userId.toLowerCase(), mention.name]),
|
|
196
202
|
)
|
|
197
|
-
const decision = await requestGrantAccess(
|
|
203
|
+
const decision = await requestGrantAccess(shareableUsers.map((user) => (
|
|
198
204
|
pendingMentionNames.get(user.userId) ?? user.label ?? labelFor(user.userId)
|
|
199
205
|
)))
|
|
200
206
|
if (decision === 'cancel' || !isCurrentSubmission()) return { cancelled: true }
|
|
201
207
|
return {
|
|
202
208
|
cancelled: false,
|
|
203
|
-
grantAccessTo: decision === 'share' ?
|
|
209
|
+
grantAccessTo: decision === 'share' ? shareableUsers.map((user) => user.userId) : [],
|
|
210
|
+
hasUnnotifiedMentions: decision === 'skip' || shareableUsers.length < withoutAccess.length,
|
|
204
211
|
}
|
|
205
212
|
}, [activePendingMentions, canShare, documentId, labelFor, requestGrantAccess, t])
|
|
206
213
|
|
|
@@ -237,6 +244,9 @@ export function useDocumentComments({ documentId, editor, canComment, canShare }
|
|
|
237
244
|
mutationPayload: { body: trimmedBody, parentCommentId, mentions },
|
|
238
245
|
})
|
|
239
246
|
if (!isCurrentSubmission()) return
|
|
247
|
+
if (grantResolution.hasUnnotifiedMentions) {
|
|
248
|
+
flash(t('documents.comments.grant.noAccessInfo'), 'info')
|
|
249
|
+
}
|
|
240
250
|
resetComposer()
|
|
241
251
|
await reload()
|
|
242
252
|
} catch (error) {
|
|
@@ -192,11 +192,20 @@ export async function resolveLoadedDocumentUserAccess(
|
|
|
192
192
|
userId: string,
|
|
193
193
|
container?: DocumentsServiceContainer | null,
|
|
194
194
|
): Promise<DocumentTier | null> {
|
|
195
|
+
if (!await hasUserDocumentViewPermission(scope, userId, container)) return null
|
|
196
|
+
return resolveLoadedDocumentUserTier(em, document, scope, userId, container)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function hasUserDocumentViewPermission(
|
|
200
|
+
scope: { tenantId: string; organizationId: string },
|
|
201
|
+
userId: string,
|
|
202
|
+
container?: DocumentsServiceContainer | null,
|
|
203
|
+
): Promise<boolean> {
|
|
195
204
|
const rbacService = resolveDocumentsRbacService(container)
|
|
196
|
-
if (!rbacService) return
|
|
205
|
+
if (!rbacService) return false
|
|
197
206
|
if (!await rbacService.userHasAllFeatures(userId, ['documents.view'], scope)) {
|
|
198
207
|
const organizationScopeService = resolveOrganizationScopeService(container)
|
|
199
|
-
if (!organizationScopeService) return
|
|
208
|
+
if (!organizationScopeService) return false
|
|
200
209
|
const authorization = await organizationScopeService.resolveFresh({
|
|
201
210
|
auth: { sub: userId, userId, tenantId: scope.tenantId, orgId: scope.organizationId, isSuperAdmin: false },
|
|
202
211
|
selectedId: scope.organizationId,
|
|
@@ -208,9 +217,9 @@ export async function resolveLoadedDocumentUserAccess(
|
|
|
208
217
|
scopeAllowed: authorization.scope.tenantId === scope.tenantId
|
|
209
218
|
&& !authorization.scope.selectionRejected
|
|
210
219
|
&& hasResolvedDocumentsOrganizationAccess(authorization.acl, scope.organizationId, authorization.scope),
|
|
211
|
-
})) return
|
|
220
|
+
})) return false
|
|
212
221
|
}
|
|
213
|
-
return
|
|
222
|
+
return true
|
|
214
223
|
}
|
|
215
224
|
|
|
216
225
|
async function resolveLoadedDocumentUserTier(
|