Skip to content

Commit a5ba5b2

Browse files
appflowyclaude
andauthored
fix: enforce inherited read-only access for pages in a view-only shared space (#386)
* fix: enforce inherited read-only access for pages in a view-only shared space Pages inside a private space shared with View-only access carried no explicit access_level of their own, so getViewReadOnlyStatus treated them as editable. Resolve the effective access level by walking the ancestor chain inside the hidden "Shared with me" space and inheriting from the nearest ancestor that declares an access_level (an explicit child re-share still overrides the inherited space level). - Add findSharedAccessLevel() to resolve inherited shared access - Consolidate getViewReadOnlyStatus traversal: findView already resolves views inside the shared space, so drop the redundant findViewInShareWithMe walk and its duplicate space lookup - Cover the inheritance logic with unit tests and assert the read-only editor gate end-to-end in the public-to-private BDD scenario Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: show "in Trash" for embedded database whose container is trashed When an embedded database's container page is moved to trash, the block showed "permanently deleted" instead of "in Trash". The block's viewId is a child of the container, and once the container is trashed ViewService.get(viewId) returns 404, so viewMeta becomes null and the parent id is lost. The trash list only contains the container id (never the embedded child view id), so the trash match failed and the block was misclassified as permanently deleted. Cache the parent id (keyed by viewId) while the view is still reachable and fall back to it in the trash check when viewMeta is null. Fixes the consistently-failing database-sidebar-deletion Scenario 1 and Scenario 6. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 110e381 commit a5ba5b2

6 files changed

Lines changed: 230 additions & 19 deletions

File tree

playwright/bdd/features/page/public-to-private-move-access.feature

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Feature: Public page moved under private page access
3232
When I sign in as seeded public-to-private "member 1"
3333
And I open the temporary public-to-private movable page
3434
Then the temporary public-to-private movable page title is visible
35+
And the temporary public-to-private movable page editor is read-only
36+
And typing in the temporary public-to-private movable page editor has no effect
3537
When I sign in as seeded public-to-private "member 2"
3638
And I open the temporary public-to-private movable page
3739
Then the public-to-private no access page is shown

playwright/bdd/steps/public-to-private-move-access.steps.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { APIRequestContext, expect, Page } from '@playwright/test';
22
import { createBdd } from 'playwright-bdd';
33

44
import { signInWithPasswordViaUi } from '../../support/auth-flow-helpers';
5-
import { PageSelectors, ShareSelectors, SidebarSelectors } from '../../support/selectors';
5+
import { EditorSelectors, PageSelectors, ShareSelectors, SidebarSelectors } from '../../support/selectors';
66
import { setupPageErrorHandling, TestConfig } from '../../support/test-config';
77

88
const { Given, When, Then, Before, After } = createBdd();
@@ -73,7 +73,7 @@ After(async ({ page, request }) => {
7373

7474
Given('the seeded ptp0527 public-to-private fixture exists', async () => {
7575
// Created by AppFlowy-Cloud-Premium:
76-
// PUBLIC_TO_PRIVATE_WEB_PREFIX=ptp0527 cargo test --test public_to_private_access_seed ...
76+
// cargo test --test public_to_private_access_seed seed_public_to_private_move_web_suite -- --ignored
7777
});
7878

7979
Given('I sign in as seeded public-to-private {string}', async ({ page, request }, accountAliasValue: string) => {
@@ -235,6 +235,34 @@ Then('the temporary public-to-private movable page title is visible', async ({ p
235235
await expect(page.getByText(movablePage.pageTitle, { exact: true }).first()).toBeVisible({ timeout: 30000 });
236236
});
237237

238+
Then('the temporary public-to-private movable page editor is read-only', async ({ page }) => {
239+
// A View-only member viewing a page inside a private space must not be able
240+
// to edit it. Slate renders the editor with contenteditable="false" when the
241+
// page resolves to read-only, so assert the inherited access is enforced.
242+
const editor = EditorSelectors.firstEditor(page);
243+
244+
await expect(editor).toBeVisible({ timeout: 30000 });
245+
await expect(editor).toHaveAttribute('contenteditable', 'false');
246+
});
247+
248+
Then('typing in the temporary public-to-private movable page editor has no effect', async ({ page }) => {
249+
// Prove the read-only gate actually rejects edits: focus the editor, type a
250+
// sentinel, and assert it never lands in the document content.
251+
const sentinel = 'PTP_READONLY_EDIT_ATTEMPT';
252+
const editor = EditorSelectors.firstEditor(page);
253+
254+
await expect(editor).toBeVisible({ timeout: 30000 });
255+
256+
const before = (await editor.textContent()) ?? '';
257+
258+
await editor.click({ position: { x: 40, y: 20 } }).catch(() => undefined);
259+
await page.keyboard.type(sentinel);
260+
await page.waitForTimeout(500);
261+
262+
await expect(editor).not.toContainText(sentinel);
263+
await expect(editor).toHaveText(before);
264+
});
265+
238266
Then('the public-to-private share panel only shows {string}', async ({ page }, aliasesValue: string) => {
239267
const expectedEmails = parseAccountAliases(aliasesValue).map(accountEmail);
240268
const popover = ShareSelectors.sharePopover(page);

src/components/_shared/outline/utils.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { View, ViewLayout } from '@/application/types';
1+
import { AccessLevel, View, ViewLayout } from '@/application/types';
22

33
export function filterViews (views: View[], keyword: string): View[] {
44
const filterAndFlatten = (views: View[]): View[] => {
@@ -217,4 +217,41 @@ export function findViewInShareWithMe (views: View[], targetViewId: string): Vie
217217
}
218218

219219
return findView(shareWithMeSpace.children, targetViewId);
220-
}
220+
}
221+
222+
/**
223+
* Resolve the effective access level for a view shared with the current user.
224+
*
225+
* A shared private space carries its `access_level` on the space node; child
226+
* pages inside it have no explicit level of their own. Walk the ancestor chain
227+
* inside the hidden "Shared with me" space and return the nearest ancestor that
228+
* declares an `access_level` (the target view itself first), so an explicit
229+
* child re-share overrides the inherited space access.
230+
*
231+
* Returns `undefined` when the view is not part of the "Shared with me" space
232+
* (e.g. a view the user owns), in which case no shared access restriction
233+
* applies.
234+
*/
235+
export function findSharedAccessLevel (views: View[], targetViewId: string): AccessLevel | undefined {
236+
const shareWithMeSpace = findShareWithMeSpace(views);
237+
238+
if (!shareWithMeSpace?.children) {
239+
return undefined;
240+
}
241+
242+
const path = findAncestors(shareWithMeSpace.children, targetViewId);
243+
244+
if (!path) {
245+
return undefined;
246+
}
247+
248+
for (let i = path.length - 1; i >= 0; i--) {
249+
const accessLevel = path[i].access_level;
250+
251+
if (accessLevel !== undefined) {
252+
return accessLevel;
253+
}
254+
}
255+
256+
return undefined;
257+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { AccessLevel, View, ViewExtra, ViewLayout } from '@/application/types';
2+
import { getPlatform } from '@/utils/platform';
3+
4+
import { getViewReadOnlyStatus } from '../useViewOperations';
5+
6+
// useViewOperations pulls in service/loader modules at import time; stub the
7+
// heavy ones so the pure getViewReadOnlyStatus function can be tested in isolation.
8+
jest.mock('@/application/services/domains', () => ({
9+
CollabService: {},
10+
ViewService: {},
11+
WorkspaceService: {},
12+
}));
13+
jest.mock('@/application/view-loader', () => ({ openView: jest.fn() }));
14+
jest.mock('@/utils/platform', () => ({ getPlatform: jest.fn(() => ({ isMobile: false })) }));
15+
16+
const mockGetPlatform = getPlatform as jest.MockedFunction<typeof getPlatform>;
17+
18+
function createView(overrides: Partial<View>): View {
19+
return {
20+
view_id: 'view-id',
21+
name: 'View',
22+
icon: null,
23+
layout: ViewLayout.Document,
24+
extra: null,
25+
children: [],
26+
is_published: false,
27+
is_private: false,
28+
...overrides,
29+
};
30+
}
31+
32+
/**
33+
* Build the hidden "Shared with me" space holding a shared private space that
34+
* itself contains a child page.
35+
*
36+
* This mirrors the user-reported scenario: an owner creates a private space,
37+
* invites a member with View-only (ReadOnly) access, and the member opens a
38+
* page *inside* that space. The server reports the access level on the shared
39+
* space node; the child page does not carry its own `access_level`.
40+
*/
41+
function shareWithMePrivateSpaceOutline(spaceAccessLevel: AccessLevel, childPage: View): View[] {
42+
return [
43+
createView({
44+
view_id: 'share-with-me-space',
45+
extra: { is_space: true, is_hidden_space: true } as ViewExtra,
46+
children: [
47+
createView({
48+
view_id: 'shared-private-space',
49+
is_private: true,
50+
extra: { is_space: true } as ViewExtra,
51+
access_level: spaceAccessLevel,
52+
children: [childPage],
53+
}),
54+
],
55+
}),
56+
];
57+
}
58+
59+
describe('getViewReadOnlyStatus (private space inherited access)', () => {
60+
beforeEach(() => {
61+
mockGetPlatform.mockReturnValue({ isMobile: false } as ReturnType<typeof getPlatform>);
62+
});
63+
64+
it('treats the shared private space root as read-only for a View-only member', () => {
65+
// Sanity check: the shared node itself carries the access level, so this
66+
// already works today and pins the boundary of the inheritance gap below.
67+
const outline = shareWithMePrivateSpaceOutline(
68+
AccessLevel.ReadOnly,
69+
createView({ view_id: 'child-page' })
70+
);
71+
72+
expect(getViewReadOnlyStatus('shared-private-space', outline)).toBe(true);
73+
});
74+
75+
it('treats a child page of a View-only private space as read-only', () => {
76+
// User-reported bug: a member invited to a private space with View-only
77+
// access can still edit pages *inside* the space. The child page inherits
78+
// the space's ReadOnly access and must therefore be read-only, even though
79+
// the page itself has no explicit `access_level`.
80+
const outline = shareWithMePrivateSpaceOutline(
81+
AccessLevel.ReadOnly,
82+
createView({ view_id: 'child-page' })
83+
);
84+
85+
expect(getViewReadOnlyStatus('child-page', outline)).toBe(true);
86+
});
87+
88+
it('treats a nested page of a View-only private space as read-only', () => {
89+
// Deeper nesting must still inherit the space's restricted access.
90+
const outline = shareWithMePrivateSpaceOutline(
91+
AccessLevel.ReadOnly,
92+
createView({
93+
view_id: 'child-page',
94+
children: [createView({ view_id: 'grandchild-page' })],
95+
})
96+
);
97+
98+
expect(getViewReadOnlyStatus('grandchild-page', outline)).toBe(true);
99+
});
100+
101+
it('keeps a child page editable when the private space grants write access', () => {
102+
// Counter-case: a member shared into the private space with write access
103+
// must be able to edit pages inside it.
104+
const outline = shareWithMePrivateSpaceOutline(
105+
AccessLevel.ReadAndWrite,
106+
createView({ view_id: 'child-page' })
107+
);
108+
109+
expect(getViewReadOnlyStatus('child-page', outline)).toBe(false);
110+
});
111+
112+
it('lets an explicit child access level override the inherited space access', () => {
113+
// A child re-shared with write access inside an otherwise View-only space
114+
// should follow its own (more permissive) access level.
115+
const outline = shareWithMePrivateSpaceOutline(
116+
AccessLevel.ReadOnly,
117+
createView({ view_id: 'child-page', access_level: AccessLevel.ReadAndWrite })
118+
);
119+
120+
expect(getViewReadOnlyStatus('child-page', outline)).toBe(false);
121+
});
122+
});

src/components/app/hooks/useViewOperations.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from '@/application/types';
1717
import { openView } from '@/application/view-loader';
1818
import { getDatabaseIdFromExtra, getFirstChildView, isDatabaseContainer, isDatabaseLayout } from '@/application/view-utils';
19-
import { findView, findViewInShareWithMe } from '@/components/_shared/outline/utils';
19+
import { findSharedAccessLevel, findView } from '@/components/_shared/outline/utils';
2020
import { CollabDocResetPayload } from '@/components/ws/sync/types';
2121
import { Log } from '@/utils/log';
2222
import { getPlatform } from '@/utils/platform';
@@ -47,20 +47,24 @@ export function getViewReadOnlyStatus(viewId: string, outline?: View[], fallback
4747

4848
if (!outline) return false;
4949

50-
// Check if view exists in shareWithMe
51-
const shareWithMeView = findViewInShareWithMe(outline, viewId);
52-
53-
// A locked page is read-only for everyone until it is unlocked.
54-
const view = findView(outline, viewId) ?? shareWithMeView;
50+
// A locked page is read-only for everyone until it is unlocked. The outline
51+
// includes the hidden "Shared with me" space, so findView also resolves views
52+
// shared with the current user.
53+
const view = findView(outline, viewId);
5554

5655
if (view?.is_locked) return true;
5756

58-
if (shareWithMeView?.access_level !== undefined) {
59-
// If found in shareWithMe, check access level
60-
return shareWithMeView.access_level <= AccessLevel.ReadAndComment;
57+
// Resolve the effective shared access level, inheriting from the nearest
58+
// ancestor inside the "Shared with me" space. This makes pages inside a
59+
// View-only private space read-only even though the page itself carries no
60+
// explicit access level.
61+
const sharedAccessLevel = findSharedAccessLevel(outline, viewId);
62+
63+
if (sharedAccessLevel !== undefined) {
64+
return sharedAccessLevel <= AccessLevel.ReadAndComment;
6165
}
6266

63-
// If not found in shareWithMe, default is false (editable)
67+
// If not part of the shared-with-me space, default is false (editable)
6468
return false;
6569
}
6670

src/components/editor/components/blocks/database/DatabaseBlock.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ export const DatabaseBlock = memo(
7979

8080
notFoundRef.current = notFound;
8181

82+
// Remember the database container (parent) id while the view is still reachable.
83+
// Once the container is moved to trash, ViewService.get(viewId) returns 404 and
84+
// viewMeta becomes null, so the response can no longer tell us the parent id. We
85+
// still need it to recognize the deletion as "in trash" rather than "permanently
86+
// deleted": the trash list only contains the container id, never the embedded
87+
// child view id. Keyed by viewId so a stale parent is ignored if the block swaps views.
88+
const lastKnownParentRef = useRef<{ viewId: string; parentId: string } | null>(null);
89+
8290
useEffect(() => {
8391
const eventEmitter = context.eventEmitter;
8492

@@ -112,13 +120,23 @@ export const DatabaseBlock = memo(
112120
return;
113121
}
114122

115-
// Build the set of IDs to check: the view itself, its parent (database container),
116-
// and the document page. When a database container is trashed, only the container
117-
// ID appears in trash — not its child view IDs.
123+
// Cache the parent id whenever the view is reachable, so we can still resolve
124+
// the container after it is trashed (at which point viewMeta is null).
125+
if (viewMeta?.parent_view_id) {
126+
lastKnownParentRef.current = { viewId, parentId: viewMeta.parent_view_id };
127+
}
128+
129+
// Build the set of IDs to check: the view itself and its parent (database
130+
// container). When a database container is trashed, only the container ID
131+
// appears in trash — not its child view IDs — so fall back to the last known
132+
// parent id when the trashed view no longer reports its metadata.
133+
const cachedParentId =
134+
lastKnownParentRef.current?.viewId === viewId ? lastKnownParentRef.current.parentId : null;
135+
const parentId = viewMeta?.parent_view_id ?? cachedParentId;
118136
const idsToCheck = new Set<string>([viewId]);
119137

120-
if (viewMeta?.parent_view_id) {
121-
idsToCheck.add(viewMeta.parent_view_id);
138+
if (parentId) {
139+
idsToCheck.add(parentId);
122140
}
123141

124142
const isInTrash = trashItems?.some((item) => idsToCheck.has(item.view_id));

0 commit comments

Comments
 (0)