diff --git a/playwright/bdd/features/page/full-access-share-management.feature b/playwright/bdd/features/page/full-access-share-management.feature new file mode 100644 index 000000000..0462264b3 --- /dev/null +++ b/playwright/bdd/features/page/full-access-share-management.feature @@ -0,0 +1,86 @@ +@full-access-share-management +Feature: FullAccess private page share panel controls + The fa0522 FullAccess share-management fixture already exists in the local AppFlowy Cloud database. + These scenarios verify the web share panel controls for owner, member, and guest users on private pages. + + Background: + Given the seeded fa0522 full access share-management fixture exists + + # Expected result: the private-page owner can manage shares and grant Full access. + Scenario: Owner sees share-management controls on a private page + Given I sign in as full access seeded "owner" + When I open the full access seeded "owner control private page" + And I open the share panel + Then the full access share panel shows seeded "owner" with "Full access" + And the share panel general access is "Restricted" + And the full access share panel can prepare an invite + And the full access invite access selector offers "Full access" + + # Expected result: a workspace member with explicit FullAccess can manage sharing on the private page. + Scenario: FullAccess member sees share-management controls on a private page + Given I sign in as full access seeded "full access member" + When I open the full access seeded "member full access private page" + And I open the share panel + Then the full access share panel shows seeded "owner" with "Full access" + And the full access share panel shows seeded "full access member" with "Full access" + And the share panel general access is "Restricted" + And the full access share panel can prepare an invite + And the full access invite access selector offers "Full access" + + # Expected result: a workspace member with edit access can open the share panel but cannot invite or grant access. + Scenario: Edit member sees read-only share-management controls on a private page + Given I sign in as full access seeded "edit member" + When I open the full access seeded "member edit private page" + And I open the share panel + Then the full access share panel shows seeded "owner" with "Full access" + And the full access share panel shows seeded "edit member" with "Can edit" + And the full access share panel invite controls are read-only + And the full access seeded "edit member" access menu only allows removing self + + # Expected result: a workspace guest with explicit FullAccess can manage sharing on the private page. + Scenario: FullAccess guest sees share-management controls on a private page + Given I sign in as full access seeded "full access guest" + When I open the full access seeded "guest full access private page" + And I open the share panel + Then the full access share panel shows seeded "owner" with "Full access" + And the full access share panel shows seeded "full access guest" with "Full access" + And the share panel general access is "Restricted" + And the full access share panel can prepare an invite + And the full access invite access selector offers "Full access" + + # Expected result: guests without FullAccess can open explicitly shared private pages but cannot manage sharing. + Scenario Outline: Non-FullAccess guests see read-only share-management controls + Given I sign in as full access seeded "" + When I open the full access seeded "" + And I open the share panel + Then the full access share panel shows seeded "owner" with "Full access" + And the full access share panel shows seeded "" with "" + And the full access share panel invite controls are read-only + And the full access seeded "" access menu only allows removing self + + Examples: + | account | page | access | + | edit guest | guest edit private page | Can edit | + | read guest | guest read only private page | Can view | + + # Expected result: a guest with Can view can open the private page but cannot change its title. + Scenario: Read guest cannot edit a private page title + Given I sign in as full access seeded "read guest" + When I open the full access seeded "guest read only private page" + Then the full access seeded page title is visible + And the full access page title cannot be edited to "fa0522 Read Guest Rename Probe" + + # Expected result: a guest with Can edit can change the private page title. + Scenario: Edit guest can edit a private page title + Given I sign in as full access seeded "edit guest" + When I open the full access seeded "guest edit private page" + Then the full access seeded page title is visible + And the full access page title is editable + When I rename the full access page title to "fa0522 Edit Guest Rename Probe" + Then the full access page title is "fa0522 Edit Guest Rename Probe" + + # Expected result: a workspace guest without an explicit page share cannot open the owner's private page. + Scenario: Unshared guest cannot open a private page + Given I sign in as full access seeded "no share guest" + When I open the full access seeded "owner control private page" + Then the full access seeded "owner control private page" is not opened diff --git a/playwright/bdd/features/page/seeded-role-matrix.feature b/playwright/bdd/features/page/seeded-role-matrix.feature new file mode 100644 index 000000000..b31d35914 --- /dev/null +++ b/playwright/bdd/features/page/seeded-role-matrix.feature @@ -0,0 +1,111 @@ +@seeded-role-matrix +Feature: Seeded role matrix private page permissions + The rm0521 role-matrix fixture already exists in the local AppFlowy Cloud database. + These scenarios verify the web UI behavior for owner, member, guests, and nonmember accounts. + + Background: + Given the seeded rm0521 role matrix fixture exists + + # Expected result: a private page shared to a guest only lists the owner and that guest. + # Workspace co-owners, members, other guests, and nonmembers must not appear as inherited full-access users. + Scenario: Owner private page share panel only lists explicit guest access + Given I sign in as seeded "owner" + When I open the seeded "owner guest read private page" + And I open the share panel + Then the share panel shows seeded "owner" with "Full access" + And the share panel shows seeded "guest reader" with "Can view" + And the share panel does not show seeded "co-owner" + And the share panel does not show seeded "member" + And the share panel does not show seeded "guest writer" + And the share panel does not show seeded "guest no share" + And the share panel does not show seeded "nonmember" + And the share panel general access is "Restricted" + + # Expected result: a private page shared to a workspace member lists that member with edit access, + # without leaking other workspace members or guests into the people-with-access list. + Scenario: Owner private page share panel lists explicit member access + Given I sign in as seeded "owner" + When I open the seeded "owner member write private page" + And I open the share panel + Then the share panel shows seeded "owner" with "Full access" + And the share panel shows seeded "member" with "Can edit" + And the share panel does not show seeded "co-owner" + And the share panel does not show seeded "guest reader" + And the share panel does not show seeded "guest writer" + And the share panel does not show seeded "guest no share" + And the share panel does not show seeded "nonmember" + And the share panel general access is "Restricted" + + # Expected result: a read-only guest can open the explicitly shared private page, + # sees restricted general access, and cannot edit the page title. + Scenario: Guest reader can open the shared private page but cannot edit the title + Given I sign in as seeded "guest reader" + When I open the seeded "owner guest read private page" + Then the seeded page title is visible + And the page title is read-only + When I open the share panel + Then the share panel shows seeded "guest reader" with "Can view" + And the share panel general access is "Restricted" + + # Expected result: a write guest can open and rename the explicitly shared private page. + Scenario: Guest writer can open and rename the shared private page + Given I sign in as seeded "guest writer" + When I open the seeded "owner guest write private page" + Then the seeded page title is visible + And the page title is editable + When I rename the page title to "rm0521 Writer BDD Rename Probe Private Page" + Then the page title is "rm0521 Writer BDD Rename Probe Private Page" + + # Expected result: a workspace co-owner does not inherit access to another user's unshared private page. + Scenario: Co-owner cannot open the owner's unshared private page + Given I sign in as seeded "co-owner" + When I open the seeded "owner unshared private page" + Then the no access page is shown + + # Expected result: a normal workspace member does not inherit access to another user's unshared private page. + Scenario: Member cannot open the owner's unshared private page + Given I sign in as seeded "member" + When I open the seeded "owner unshared private page" + Then the no access page is shown + + # Expected result: a workspace member can open and edit a private page explicitly shared to them. + Scenario: Member can open the owner private page explicitly shared to them + Given I sign in as seeded "member" + When I open the seeded "owner member write private page" + Then the seeded page title is visible + And the page title is editable + + # Expected result: a workspace member can open a page in a public space while it is public. + # After the owner changes that space to Private in the web UI, the same member loses access to + # the page and sees the no-access screen instead of the private-space content. + Scenario: Member loses access when a public space becomes private + Given I sign in as seeded "owner" + And I create a temporary public space page in the seeded workspace + When I open the temporary seeded page + Then the temporary seeded page title is visible + When I sign in as seeded "member" + And I open the temporary seeded page + Then the temporary seeded page title is visible + When I sign in as seeded "owner" + And I change the temporary seeded space permission to "Private" + And I sign in as seeded "member" + And I open the temporary seeded page + Then the no access page is shown + And the temporary seeded space is hidden from the sidebar + And the temporary seeded page editor is not visible + + # Expected result: a guest with no explicit share cannot open workspace pages or another guest's shared private page. + Scenario: Guest with no page share cannot open seeded pages + Given I sign in as seeded "guest no share" + When I open the seeded "public page" + Then the no access page is shown + And the seeded page title is not visible + When I open the seeded "owner guest read private page" + Then the no access page is shown + + # Expected result: a user outside the workspace cannot open the seeded workspace public page. + Scenario: Nonmember cannot open the workspace public page + Given I sign in as seeded "nonmember" + When I open the seeded "public page" + Then the no access page is shown + And the seeded page title is not visible diff --git a/playwright/bdd/steps/full-access-share-management.steps.ts b/playwright/bdd/steps/full-access-share-management.steps.ts new file mode 100644 index 000000000..2fae41404 --- /dev/null +++ b/playwright/bdd/steps/full-access-share-management.steps.ts @@ -0,0 +1,329 @@ +import { APIRequestContext, expect, Page } from '@playwright/test'; +import { createBdd } from 'playwright-bdd'; + +import { signInWithPasswordViaUi } from '../../support/auth-flow-helpers'; +import { PageSelectors, ShareSelectors, SidebarSelectors } from '../../support/selectors'; +import { setupPageErrorHandling, TestConfig } from '../../support/test-config'; + +const { Given, When, Then, Before, After } = createBdd(); + +const PASSWORD = 'AppFlowy!@123'; +const WORKSPACE_ID = '2b64f8c8-22d2-4e35-8deb-8a7e85bba4d4'; +const INVITE_PROBE_EMAIL = 'fa0522-out@appflowy.local'; +const modKey = process.platform === 'darwin' ? 'Meta' : 'Control'; + +const FULL_ACCESS_ACCOUNTS = { + owner: 'fa0522-own@appflowy.local', + 'full access member': 'fa0522-fm@appflowy.local', + 'edit member': 'fa0522-em@appflowy.local', + 'target member': 'fa0522-tm@appflowy.local', + 'full access guest': 'fa0522-fg@appflowy.local', + 'edit guest': 'fa0522-eg@appflowy.local', + 'read guest': 'fa0522-rg@appflowy.local', + 'no share guest': 'fa0522-ng@appflowy.local', + nonmember: 'fa0522-out@appflowy.local', +} as const; + +const FULL_ACCESS_PAGES = { + 'owner control private page': { + viewId: 'f8d677a2-cb1f-4a93-9ca1-5449b791a5e4', + title: 'fa0522 Owner Control Private Page', + }, + 'member full access private page': { + viewId: '93f3783e-1777-4718-a467-d11a19824968', + title: 'fa0522 Member FullAccess Manage Private Page', + }, + 'member edit private page': { + viewId: '446b9a2d-293e-4e06-8379-75fafdf2e9a4', + title: 'fa0522 Member Edit Only Private Page', + }, + 'guest full access private page': { + viewId: '45ed683e-8ed1-4872-8b28-dc6a61937485', + title: 'fa0522 Guest FullAccess Manage Private Page', + }, + 'guest edit private page': { + viewId: 'f93e1946-32d3-49be-a5f8-2801309a5d33', + title: 'fa0522 Guest Edit Only Private Page', + }, + 'guest read only private page': { + viewId: '43230138-2cda-4d96-af0f-2fa5522f054c', + title: 'fa0522 Guest Read Only Private Page', + }, +} as const; + +type FullAccessAccountAlias = keyof typeof FULL_ACCESS_ACCOUNTS; +type FullAccessPageAlias = keyof typeof FULL_ACCESS_PAGES; +type FullAccessPage = (typeof FULL_ACCESS_PAGES)[FullAccessPageAlias]; + +type ScenarioState = { + currentPage?: FullAccessPage; + pagesToRestore: FullAccessPage[]; +}; + +const stateByPage = new WeakMap(); + +Before(async ({ page }) => { + setupPageErrorHandling(page); + await page.setViewportSize({ width: 1440, height: 900 }); + stateByPage.set(page, { pagesToRestore: [] }); +}); + +After(async ({ page, request }) => { + const state = getState(page); + + for (const seededPage of state.pagesToRestore) { + await restoreFullAccessSeededPageTitle(request, page, seededPage); + } +}); + +Given('the seeded fa0522 full access share-management fixture exists', async () => { + // This suite intentionally reuses the local fixture documented in + // AppFlowy-Cloud-Premium/backup/README.md instead of creating accounts. +}); + +Given('I sign in as full access seeded {string}', async ({ page }, accountAliasValue: string) => { + await signInWithPasswordViaUi(page, accountEmail(accountAliasValue), PASSWORD, 2000); + await expect(page).toHaveURL(/\/app/, { timeout: 30000 }); + await expect(SidebarSelectors.pageHeader(page)).toBeVisible({ timeout: 30000 }); +}); + +When('I open the full access seeded {string}', async ({ page }, pageAliasValue: string) => { + const seededPage = pageDefinition(pageAliasValue); + + getState(page).currentPage = seededPage; + await page.goto(`/app/${WORKSPACE_ID}/${seededPage.viewId}`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1500); +}); + +Then( + 'the full access share panel shows seeded {string} with {string}', + async ({ page }, accountAliasValue: string, accessText: string) => { + const email = accountEmail(accountAliasValue); + const row = sharePersonRow(page, email); + + await expect(row).toBeVisible({ timeout: 15000 }); + await expect(row.getByText(email, { exact: true }).first()).toBeVisible(); + await expect(row.getByText(accessText, { exact: true }).first()).toBeVisible(); + } +); + +Then('the full access share panel can prepare an invite', async ({ page }) => { + const input = inviteInput(page); + + await expect(input).toBeVisible({ timeout: 15000 }); + await expect.poll(async () => input.evaluate((element) => (element as HTMLInputElement).readOnly)).toBe(false); + + await input.fill(INVITE_PROBE_EMAIL); + await input.press('Enter'); + await expect(ShareSelectors.inviteButton(page)).toBeEnabled({ timeout: 15000 }); +}); + +Then('the full access invite access selector offers {string}', async ({ page }, accessText: string) => { + const inviteAccessSelector = ShareSelectors.emailTagInput(page) + .getByRole('button', { name: /Can view|Can edit|Full access/ }) + .first(); + + await expect(inviteAccessSelector).toBeVisible({ timeout: 15000 }); + await inviteAccessSelector.click({ force: true }); + await expect(page.getByText(accessText, { exact: true }).last()).toBeVisible({ timeout: 15000 }); + + if (accessText === 'Full access') { + await expect(page.getByText('Can edit and share with others', { exact: true })).toBeVisible({ timeout: 15000 }); + } +}); + +Then('the full access share panel invite controls are read-only', async ({ page }) => { + const input = inviteInput(page); + + await expect(input).toBeVisible({ timeout: 15000 }); + await expect.poll(async () => input.evaluate((element) => (element as HTMLInputElement).readOnly)).toBe(true); + await expect(ShareSelectors.inviteButton(page)).toBeDisabled(); + await expect( + ShareSelectors.emailTagInput(page).getByRole('button', { name: /Can view|Can edit|Full access/ }) + ).toHaveCount(0); +}); + +Then('the full access seeded page title is visible', async ({ page }) => { + const seededPage = requireCurrentPage(getState(page)); + + await expect(page.getByText(seededPage.title, { exact: true }).first()).toBeVisible({ timeout: 30000 }); +}); + +Then('the full access page title is editable', async ({ page }) => { + await expect(PageSelectors.titleInput(page).first()).toBeVisible({ timeout: 15000 }); +}); + +Then('the full access page title cannot be edited to {string}', async ({ page }, blockedTitle: string) => { + const seededPage = requireCurrentPage(getState(page)); + const originalTitle = page.getByText(seededPage.title, { exact: true }).first(); + const titleInput = PageSelectors.titleInput(page).first(); + + await expect(originalTitle).toBeVisible({ timeout: 30000 }); + + if ((await titleInput.count()) > 0 && (await titleInput.isVisible().catch(() => false))) { + await titleInput.click({ force: true }); + await page.keyboard.press(`${modKey}+A`); + await page.keyboard.type(blockedTitle, { delay: 20 }); + await page.keyboard.press('Enter'); + } else { + await originalTitle.click({ force: true }); + await page.keyboard.press(`${modKey}+A`); + await page.keyboard.type(blockedTitle, { delay: 20 }); + await page.keyboard.press('Enter'); + } + + await page.waitForTimeout(1500); + await expect(page.getByText(blockedTitle, { exact: true })).toHaveCount(0); + await expect(page.getByText(seededPage.title, { exact: true }).first()).toBeVisible({ timeout: 15000 }); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1500); + await expect(page.getByText(seededPage.title, { exact: true }).first()).toBeVisible({ timeout: 30000 }); + await expect(page.getByText(blockedTitle, { exact: true })).toHaveCount(0); +}); + +When('I rename the full access page title to {string}', async ({ page }, newTitle: string) => { + const state = getState(page); + const seededPage = requireCurrentPage(state); + const titleInput = PageSelectors.titleInput(page).first(); + + await expect(titleInput).toBeVisible({ timeout: 15000 }); + state.pagesToRestore.push(seededPage); + + await titleInput.click({ force: true }); + await page.keyboard.press(`${modKey}+A`); + await page.keyboard.type(newTitle, { delay: 20 }); + await page.keyboard.press('Enter'); + await expect(titleInput).toHaveText(newTitle, { timeout: 15000 }); + await page.waitForTimeout(1500); +}); + +Then('the full access page title is {string}', async ({ page }, expectedTitle: string) => { + const editableTitle = PageSelectors.titleInput(page).first(); + + if (await editableTitle.isVisible().catch(() => false)) { + await expect(editableTitle).toHaveText(expectedTitle, { timeout: 15000 }); + return; + } + + await expect(page.getByText(expectedTitle, { exact: true }).first()).toBeVisible({ timeout: 15000 }); +}); + +Then( + 'the full access seeded {string} access menu only allows removing self', + async ({ page }, accountAliasValue: string) => { + const row = sharePersonRow(page, accountEmail(accountAliasValue)); + const accessButton = row.getByRole('button', { name: /Can view|Can edit|Can comment/ }).first(); + + await expect(accessButton).toBeVisible({ timeout: 15000 }); + await accessButton.click({ force: true }); + + const menu = page.locator('[role="menu"]').last(); + + await expect(menu).toBeVisible({ timeout: 15000 }); + await expect(menu.getByText('Remove access', { exact: true })).toBeVisible(); + await expect(menu.getByText('Full access', { exact: true })).toHaveCount(0); + await expect(menu.getByText('Can edit', { exact: true })).toHaveCount(0); + await expect(menu.getByText('Can view', { exact: true })).toHaveCount(0); + } +); + +Then('the full access seeded {string} is not opened', async ({ page }, pageAliasValue: string) => { + const seededPage = pageDefinition(pageAliasValue); + + await expect(page.getByText(seededPage.title, { exact: true })).toHaveCount(0); + await expect(ShareSelectors.shareButton(page)).toHaveCount(0); +}); + +function accountEmail(aliasValue: string): string { + const alias = aliasValue as FullAccessAccountAlias; + const email = FULL_ACCESS_ACCOUNTS[alias]; + + if (!email) { + throw new Error(`Unknown full access seeded account alias: ${aliasValue}`); + } + + return email; +} + +function pageDefinition(aliasValue: string): (typeof FULL_ACCESS_PAGES)[FullAccessPageAlias] { + const alias = aliasValue as FullAccessPageAlias; + const seededPage = FULL_ACCESS_PAGES[alias]; + + if (!seededPage) { + throw new Error(`Unknown full access seeded page alias: ${aliasValue}`); + } + + return seededPage; +} + +function getState(page: Page): ScenarioState { + const state = stateByPage.get(page); + + if (!state) { + throw new Error('FullAccess share-management scenario state has not been initialized'); + } + + return state; +} + +function requireCurrentPage(state: ScenarioState): FullAccessPage { + if (!state.currentPage) { + throw new Error('No full access seeded page is currently open for this scenario'); + } + + return state.currentPage; +} + +function inviteInput(page: Page) { + return ShareSelectors.emailTagInput(page).locator('input[type="text"]'); +} + +function sharePersonRow(page: Page, email: string) { + return ShareSelectors.sharePopover(page).locator('.group').filter({ hasText: email }).first(); +} + +async function restoreFullAccessSeededPageTitle(request: APIRequestContext, page: Page, seededPage: FullAccessPage) { + const token = await getAuthToken(page); + + if (!token) { + throw new Error(`Cannot restore ${seededPage.viewId}: no auth token in browser storage`); + } + + const response = await request.post( + `${TestConfig.apiUrl}/api/workspace/${WORKSPACE_ID}/page-view/${seededPage.viewId}/update-name`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { name: seededPage.title }, + failOnStatusCode: false, + } + ); + const body = (await response.json().catch(() => null)) as { code?: number; message?: string } | null; + + if (!response.ok() || body?.code !== 0) { + throw new Error( + `Failed to restore full access seeded page ${seededPage.viewId}: HTTP ${response.status()} ${JSON.stringify(body)}` + ); + } +} + +async function getAuthToken(page: Page): Promise { + return page.evaluate(() => { + const directToken = localStorage.getItem('af_auth_token'); + + if (directToken) return directToken; + + const rawToken = localStorage.getItem('token'); + + if (!rawToken) return ''; + + try { + return (JSON.parse(rawToken) as { access_token?: string }).access_token || ''; + } catch { + return ''; + } + }); +} diff --git a/playwright/bdd/steps/seeded-role-matrix.steps.ts b/playwright/bdd/steps/seeded-role-matrix.steps.ts new file mode 100644 index 000000000..1ea032ac9 --- /dev/null +++ b/playwright/bdd/steps/seeded-role-matrix.steps.ts @@ -0,0 +1,466 @@ +import { APIRequestContext, expect, Page } from '@playwright/test'; +import { createBdd } from 'playwright-bdd'; + +import { signInWithPasswordViaUi } from '../../support/auth-flow-helpers'; +import { + ModalSelectors, + PageSelectors, + ShareSelectors, + SidebarSelectors, + SpaceSelectors, +} from '../../support/selectors'; +import { setupPageErrorHandling, TestConfig } from '../../support/test-config'; + +const { Given, When, Then, Before, After } = createBdd(); + +const PASSWORD = 'AppFlowy!@123'; +const WORKSPACE_ID = 'cd3c4886-8da8-468f-b633-f7e257ef288d'; +const SPACE_PERMISSION_PUBLIC = 0; +const VIEW_LAYOUT_DOCUMENT = 0; +const modKey = process.platform === 'darwin' ? 'Meta' : 'Control'; + +const SEEDED_ACCOUNTS = { + owner: 'rm0521-own@appflowy.local', + 'co-owner': 'rm0521-co@appflowy.local', + member: 'rm0521-mem@appflowy.local', + 'guest reader': 'rm0521-r@appflowy.local', + 'guest writer': 'rm0521-w@appflowy.local', + 'guest no share': 'rm0521-gst@appflowy.local', + nonmember: 'rm0521-out@appflowy.local', +} as const; + +const SEEDED_PAGES = { + 'public page': { + viewId: 'a85edd27-22fe-4a3a-8857-65efa48ba17a', + title: 'rm0521 Public Page', + }, + 'owner unshared private page': { + viewId: 'e667c364-2151-4e8d-8c2e-a289e0fe8fc1', + title: 'rm0521 Owner Unshared Private Page', + }, + 'owner member write private page': { + viewId: '50cc9de9-df1a-4bdd-8695-f0afd5bb5f9c', + title: 'rm0521 Owner Shared To Member Write Private Page', + }, + 'owner guest read private page': { + viewId: 'e86a25be-4e4d-4a9e-9080-fe32015502d7', + title: 'rm0521 Owner Shared To Guest Read Private Page', + }, + 'owner guest write private page': { + viewId: '92c09a6c-0134-4c56-bc6c-8ad85680f7f5', + title: 'rm0521 Owner Shared To Guest Write Private Page', + }, +} as const; + +type SeededAccountAlias = keyof typeof SEEDED_ACCOUNTS; +type SeededPageAlias = keyof typeof SEEDED_PAGES; +type SeededPage = (typeof SEEDED_PAGES)[SeededPageAlias]; + +type ScenarioState = { + currentPage?: SeededPage; + pagesToRestore: SeededPage[]; + temporarySpacePage?: TemporarySpacePage; +}; + +const stateByPage = new WeakMap(); + +type TemporarySpacePage = { + ownerToken: string; + spaceId: string; + spaceName: string; + pageId: string; + pageTitle: string; +}; + +type ApiResponse = { + code?: number; + message?: string; + data?: T; +}; + +Before(async ({ page }) => { + setupPageErrorHandling(page); + await page.setViewportSize({ width: 1440, height: 900 }); + stateByPage.set(page, { pagesToRestore: [] }); +}); + +After(async ({ page, request }) => { + const state = getState(page); + + for (const seededPage of state.pagesToRestore) { + await restoreSeededPageTitle(request, page, seededPage); + } + + if (state.temporarySpacePage) { + await cleanupTemporarySpacePage(request, state.temporarySpacePage); + } +}); + +Given('the seeded rm0521 role matrix fixture exists', async () => { + // This BDD suite intentionally reuses the local fixture documented in + // AppFlowy-Cloud-Premium/backup/README.md instead of creating accounts. +}); + +Given('I sign in as seeded {string}', async ({ page }, accountAliasValue: string) => { + const email = accountEmail(accountAliasValue); + + await resetBrowserSession(page); + await signInWithPasswordViaUi(page, email, PASSWORD, 2000); + await expect(page).toHaveURL(/\/app/, { timeout: 30000 }); + await expect(SidebarSelectors.pageHeader(page)).toBeVisible({ timeout: 30000 }); +}); + +Given('I create a temporary public space page in the seeded workspace', async ({ page, request }) => { + const token = await getAuthToken(page); + + if (!token) { + throw new Error('Cannot create temporary public space: no owner auth token in browser storage'); + } + + const runId = Date.now().toString(36); + const spaceName = `rm0521 BDD Public To Private ${runId}`; + const pageTitle = `rm0521 BDD Public To Private Page ${runId}`; + const space = await postApi<{ view_id: string }>(request, token, `/api/workspace/${WORKSPACE_ID}/space`, { + name: spaceName, + space_icon: 'icon', + space_icon_color: '#000000', + space_permission: SPACE_PERMISSION_PUBLIC, + }); + const pageResponse = await postApi<{ view_id: string }>(request, token, `/api/workspace/${WORKSPACE_ID}/page-view`, { + parent_view_id: space.view_id, + layout: VIEW_LAYOUT_DOCUMENT, + name: pageTitle, + }); + + getState(page).temporarySpacePage = { + ownerToken: token, + spaceId: space.view_id, + spaceName, + pageId: pageResponse.view_id, + pageTitle, + }; + + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(SidebarSelectors.pageHeader(page)).toBeVisible({ timeout: 30000 }); + await expect(SpaceSelectors.itemByName(page, spaceName)).toBeVisible({ timeout: 30000 }); +}); + +When('I open the seeded {string}', async ({ page }, pageAliasValue: string) => { + const seededPage = pageDefinition(pageAliasValue); + const state = getState(page); + + state.currentPage = seededPage; + await openWorkspacePage(page, seededPage.viewId); +}); + +When('I open the temporary seeded page', async ({ page }) => { + const temporaryPage = requireTemporarySpacePage(getState(page)); + + await openWorkspacePage(page, temporaryPage.pageId); +}); + +When('I change the temporary seeded space permission to {string}', async ({ page }, permission: string) => { + const temporaryPage = requireTemporarySpacePage(getState(page)); + + if (permission !== 'Private') { + throw new Error(`Unsupported temporary space permission: ${permission}`); + } + + const spaceItem = SpaceSelectors.itemByName(page, temporaryPage.spaceName); + + await expect(spaceItem).toBeVisible({ timeout: 30000 }); + await spaceItem.getByTestId('inline-more-actions').click({ force: true }); + await page.getByTestId('space-action-manage').click(); + + const dialog = page.getByRole('dialog').filter({ hasText: 'Manage Space' }).last(); + + await expect(dialog).toBeVisible({ timeout: 15000 }); + await dialog.getByRole('button', { name: /Public/ }).click(); + await page + .getByRole('button', { name: /Private/ }) + .last() + .click(); + await ModalSelectors.okButton(page).click(); + await expect(dialog).toHaveCount(0, { timeout: 15000 }); + await page.waitForTimeout(1500); +}); + +When('I open the share panel', async ({ page }) => { + await expect(ShareSelectors.shareButton(page)).toBeVisible({ timeout: 30000 }); + await ShareSelectors.shareButton(page).evaluate((element: HTMLElement) => element.click()); + await expect(ShareSelectors.sharePopover(page)).toBeVisible({ timeout: 15000 }); + await expect(ShareSelectors.sharePopover(page).getByText('People with access', { exact: true })).toBeVisible({ + timeout: 15000, + }); +}); + +When('I rename the page title to {string}', async ({ page }, newTitle: string) => { + const state = getState(page); + const seededPage = requireCurrentPage(state); + const titleInput = PageSelectors.titleInput(page).first(); + + await expect(titleInput).toBeVisible({ timeout: 15000 }); + state.pagesToRestore.push(seededPage); + + await titleInput.click({ force: true }); + await page.keyboard.press(`${modKey}+A`); + await page.keyboard.type(newTitle, { delay: 20 }); + await page.keyboard.press('Enter'); + await expect(titleInput).toHaveText(newTitle, { timeout: 15000 }); + await page.waitForTimeout(1500); +}); + +Then('the seeded page title is visible', async ({ page }) => { + const seededPage = requireCurrentPage(getState(page)); + + await expect(page.getByText(seededPage.title, { exact: true }).first()).toBeVisible({ timeout: 30000 }); +}); + +Then('the seeded page title is not visible', async ({ page }) => { + const seededPage = requireCurrentPage(getState(page)); + + await expect(page.getByText(seededPage.title, { exact: true })).toHaveCount(0); +}); + +Then('the temporary seeded page title is visible', async ({ page }) => { + const temporaryPage = requireTemporarySpacePage(getState(page)); + + await expect(page.getByText(temporaryPage.pageTitle, { exact: true }).first()).toBeVisible({ timeout: 30000 }); +}); + +Then('the temporary seeded space is hidden from the sidebar', async ({ page }) => { + const temporaryPage = requireTemporarySpacePage(getState(page)); + + await expect(SpaceSelectors.itemByName(page, temporaryPage.spaceName)).toHaveCount(0); +}); + +Then('the temporary seeded page editor is not visible', async ({ page }) => { + await expect(PageSelectors.titleInput(page)).toHaveCount(0); + await expect(ShareSelectors.shareButton(page)).toHaveCount(0); +}); + +Then('the page title is {string}', async ({ page }, expectedTitle: string) => { + const editableTitle = PageSelectors.titleInput(page).first(); + + if (await editableTitle.isVisible().catch(() => false)) { + await expect(editableTitle).toHaveText(expectedTitle, { timeout: 15000 }); + return; + } + + await expect(page.getByText(expectedTitle, { exact: true }).first()).toBeVisible({ timeout: 15000 }); +}); + +Then('the page title is read-only', async ({ page }) => { + await expect(PageSelectors.titleInput(page)).toHaveCount(0); +}); + +Then('the page title is editable', async ({ page }) => { + await expect(PageSelectors.titleInput(page).first()).toBeVisible({ timeout: 15000 }); +}); + +Then( + 'the share panel shows seeded {string} with {string}', + async ({ page }, accountAliasValue: string, accessText: string) => { + const email = accountEmail(accountAliasValue); + const row = sharePersonRow(page, email); + + await expect(row).toBeVisible({ timeout: 15000 }); + await expect(row.getByText(email, { exact: true }).first()).toBeVisible(); + await expect(row.getByText(accessText, { exact: true }).first()).toBeVisible(); + } +); + +Then('the share panel does not show seeded {string}', async ({ page }, accountAliasValue: string) => { + const email = accountEmail(accountAliasValue); + const popover = ShareSelectors.sharePopover(page); + + await expect(popover.getByText(email, { exact: true })).toHaveCount(0); +}); + +Then('the share panel general access is {string}', async ({ page }, accessText: string) => { + const popover = ShareSelectors.sharePopover(page); + + await expect(popover.getByText('General access', { exact: true })).toBeVisible(); + await expect(popover.getByText(accessText, { exact: true })).toBeVisible(); +}); + +Then('the no access page is shown', async ({ page }) => { + await expect(page.getByText('No access to this page', { exact: true }).first()).toBeVisible({ timeout: 30000 }); + await expect(page.getByRole('button', { name: 'Request access' }).first()).toBeVisible({ timeout: 15000 }); +}); + +function getState(page: Page): ScenarioState { + const state = stateByPage.get(page); + + if (!state) { + throw new Error('Seeded role matrix scenario state has not been initialized'); + } + + return state; +} + +function accountEmail(aliasValue: string): string { + const alias = aliasValue as SeededAccountAlias; + const email = SEEDED_ACCOUNTS[alias]; + + if (!email) { + throw new Error(`Unknown seeded account alias: ${aliasValue}`); + } + + return email; +} + +function pageDefinition(aliasValue: string): SeededPage { + const alias = aliasValue as SeededPageAlias; + const seededPage = SEEDED_PAGES[alias]; + + if (!seededPage) { + throw new Error(`Unknown seeded page alias: ${aliasValue}`); + } + + return seededPage; +} + +function requireCurrentPage(state: ScenarioState): SeededPage { + if (!state.currentPage) { + throw new Error('No seeded page is currently open for this scenario'); + } + + return state.currentPage; +} + +function requireTemporarySpacePage(state: ScenarioState): TemporarySpacePage { + if (!state.temporarySpacePage) { + throw new Error('No temporary seeded space page has been created for this scenario'); + } + + return state.temporarySpacePage; +} + +function sharePersonRow(page: Page, email: string) { + return ShareSelectors.sharePopover(page).locator('.group').filter({ hasText: email }).first(); +} + +async function restoreSeededPageTitle(request: APIRequestContext, page: Page, seededPage: SeededPage) { + const token = await getAuthToken(page); + + if (!token) { + throw new Error(`Cannot restore ${seededPage.viewId}: no auth token in browser storage`); + } + + const response = await request.post( + `${TestConfig.apiUrl}/api/workspace/${WORKSPACE_ID}/page-view/${seededPage.viewId}/update-name`, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: { name: seededPage.title }, + failOnStatusCode: false, + } + ); + const body = (await response.json().catch(() => null)) as { code?: number; message?: string } | null; + + if (!response.ok() || body?.code !== 0) { + throw new Error( + `Failed to restore seeded page ${seededPage.viewId}: HTTP ${response.status()} ${JSON.stringify(body)}` + ); + } +} + +async function cleanupTemporarySpacePage(request: APIRequestContext, temporaryPage: TemporarySpacePage) { + const response = await request.post( + `${TestConfig.apiUrl}/api/workspace/${WORKSPACE_ID}/page-view/${temporaryPage.spaceId}/move-to-trash`, + { + headers: { + Authorization: `Bearer ${temporaryPage.ownerToken}`, + 'Content-Type': 'application/json', + }, + failOnStatusCode: false, + } + ); + + if (!response.ok()) { + console.warn( + `Failed to move temporary space ${ + temporaryPage.spaceId + } to trash: HTTP ${response.status()} ${await response.text()}` + ); + } +} + +async function postApi( + request: APIRequestContext, + token: string, + path: string, + data: Record +): Promise { + const response = await request.post(`${TestConfig.apiUrl}${path}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data, + failOnStatusCode: false, + }); + const body = (await response.json().catch(() => null)) as ApiResponse | null; + + if (!response.ok() || body?.code !== 0 || !body.data) { + throw new Error(`API request failed for ${path}: HTTP ${response.status()} ${JSON.stringify(body)}`); + } + + return body.data; +} + +async function openWorkspacePage(page: Page, viewId: string) { + await page.goto(`/app/${WORKSPACE_ID}/${viewId}`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1500); +} + +async function resetBrowserSession(page: Page) { + await page.goto('/', { waitUntil: 'domcontentloaded' }).catch(() => undefined); + await page.evaluate(async () => { + localStorage.clear(); + sessionStorage.clear(); + + const indexedDatabase = indexedDB as IDBFactory & { databases?: () => Promise> }; + + if (!indexedDatabase.databases) return; + + const databases = await indexedDatabase.databases(); + await Promise.all( + databases + .map((database) => database.name) + .filter((name): name is string => Boolean(name)) + .map( + (name) => + new Promise((resolve) => { + const request = indexedDB.deleteDatabase(name); + + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + request.onblocked = () => resolve(); + }) + ) + ); + }); + await page.context().clearCookies(); +} + +async function getAuthToken(page: Page): Promise { + return page.evaluate(() => { + const directToken = localStorage.getItem('af_auth_token'); + + if (directToken) return directToken; + + const rawToken = localStorage.getItem('token'); + + if (!rawToken) return ''; + + try { + return (JSON.parse(rawToken) as { access_token?: string }).access_token || ''; + } catch { + return ''; + } + }); +} diff --git a/playwright/e2e/embeded/database/database-block-duplicate-in-document.spec.ts b/playwright/e2e/embeded/database/database-block-duplicate-in-document.spec.ts index df7429d2a..897b3cbd1 100644 --- a/playwright/e2e/embeded/database/database-block-duplicate-in-document.spec.ts +++ b/playwright/e2e/embeded/database/database-block-duplicate-in-document.spec.ts @@ -22,7 +22,11 @@ import { expandSpaceByName, insertLinkedDatabaseViaSlash, } from '../../../support/page-utils'; -import { insertInlineGridViaSlash } from '../../../support/duplicate-test-helpers'; +import { + editFirstGridCell, + firstGridCellText, + insertInlineGridViaSlash, +} from '../../../support/duplicate-test-helpers'; const spaceName = 'General'; const sourceDatabaseName = 'Block Database'; @@ -159,21 +163,6 @@ async function createStandaloneGridDatabase(page: Page, name: string): Promise { - const firstCell = gridBlock.locator('[data-testid^="grid-cell-"]').first(); - await expect(firstCell).toBeVisible({ timeout: 15000 }); - await firstCell.click({ force: true }); - await page.waitForTimeout(500); - await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A'); - await page.keyboard.type(text); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); -} - -async function firstCellText(gridBlock: Locator): Promise { - return (await gridBlock.locator('[data-testid^="grid-cell-"]').first().innerText()).trim(); -} - async function hoverDatabaseBlock(page: Page, gridBlock: Locator): Promise { await gridBlock.scrollIntoViewIfNeeded(); await expect @@ -238,8 +227,8 @@ test.describe('Embedded Database Block Duplication', () => { await ensurePageExpandedByViewId(page, docViewId); await expect(databaseBlocks(editor)).toHaveCount(1, { timeout: 30000 }); - await focusAndReplaceCellText(page, databaseBlocks(editor).nth(0), 'inline original'); - await expect(await firstCellText(databaseBlocks(editor).nth(0))).toContain('inline original'); + await editFirstGridCell(page, databaseBlocks(editor).nth(0), 'inline original'); + await expect(await firstGridCellText(databaseBlocks(editor).nth(0))).toContain('inline original'); await duplicateDatabaseBlockAt(page, editor, 0); await expect(databaseBlocks(editor)).toHaveCount(2, { timeout: 30000 }); @@ -254,8 +243,8 @@ test.describe('Embedded Database Block Duplication', () => { .first() ).toBeVisible({ timeout: 30000 }); - await focusAndReplaceCellText(page, databaseBlocks(editor).nth(1), 'inline dup edit'); - await expect(await firstCellText(databaseBlocks(editor).nth(0))).toContain('inline original'); + await editFirstGridCell(page, databaseBlocks(editor).nth(1), 'inline dup edit'); + await expect(await firstGridCellText(databaseBlocks(editor).nth(0))).toContain('inline original'); }); test('duplicates linked database blocks as shared views of the same database', async ({ page, request }) => { @@ -267,7 +256,7 @@ test.describe('Embedded Database Block Duplication', () => { await createStandaloneGridDatabase(page, sourceDatabaseName); await expect(DatabaseGridSelectors.grid(page)).toBeVisible({ timeout: 30000 }); - await focusAndReplaceCellText(page, DatabaseGridSelectors.grid(page), 'linked shared'); + await editFirstGridCell(page, DatabaseGridSelectors.grid(page), 'linked shared'); const docViewId = await createDocumentPageAndNavigate(page); await page.waitForTimeout(1000); @@ -287,7 +276,7 @@ test.describe('Embedded Database Block Duplication', () => { await ensurePageExpandedByViewId(page, docViewId); await expect(directChildPageItems(page, docViewId)).toHaveCount(2, { timeout: 30000 }); - await focusAndReplaceCellText(page, databaseBlocks(editor).nth(1), 'linked duplicate edit'); - await expect(await firstCellText(databaseBlocks(editor).nth(0))).toContain('linked duplicate edit'); + await editFirstGridCell(page, databaseBlocks(editor).nth(1), 'linked duplicate edit'); + await expect(await firstGridCellText(databaseBlocks(editor).nth(0))).toContain('linked duplicate edit'); }); }); diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index dee73b485..67f1eec3f 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -203,6 +203,7 @@ "removeAccess": "Remove access", "canViewDescription": "Can’t make changes", "canEditDescription": "Can make any changes", + "fullAccessDescription": "Can edit and share with others", "turnIntoMember": "Turn into member", "notInvitedToPage": "Not invited to page", "keepTypingEmail": "Keep typing an email to invite", @@ -223,6 +224,7 @@ "upgradeText": " to the Pro plan to invite guests to this page", "pendingTooltip": "Invitation not yet accepted", "onlyFullAccess": "Only user with full access can invite others", + "readOnlyPublishTooltip": "You don't have permission to publish this page. Contact the page owner for access.", "onlyProCanInvite": "Please upgrade to the Pro plan to invite others", "upgradeConfirmTitle": "Upgrade to invite guest editors", "upgradeConfirmDescription": "Upgrade to Pro to collaborate with non-members on this page" @@ -3661,6 +3663,8 @@ "haveBeenInvited": "You've been invited to Join this workspace with the contact information below.", "invalid": "The invitation code has expired", "invalidMessage": "Please ask your collaborators to resend the invite.", + "memberLimitTitle": "Workspace member limit reached", + "memberLimitDescription": "{{workspaceName}} has reached the Free plan member limit. Ask the workspace owner to upgrade their plan or remove a member, then try joining again.", "joining": "Joining...", "hasJoined": "You've joined to\n" }, diff --git a/src/@types/translations/zh-CN.json b/src/@types/translations/zh-CN.json index 612868178..c90cb5d6b 100644 --- a/src/@types/translations/zh-CN.json +++ b/src/@types/translations/zh-CN.json @@ -157,6 +157,7 @@ "removeAccess": "移除访问权限", "canViewDescription": "无法进行更改", "canEditDescription": "可以进行任何更改", + "fullAccessDescription": "可以编辑并与他人共享", "turnIntoMember": "转为成员", "notInvitedToPage": "未邀请到页面", "keepTypingEmail": "继续输入邮箱以邀请", @@ -1971,6 +1972,12 @@ "removeRelatedTemplate": "移除相关模板", "label": "模板" }, + "landingPage": { + "inviteCode": { + "memberLimitTitle": "工作区成员数量已达上限", + "memberLimitDescription": "{{workspaceName}} 已达到免费计划的成员数量上限。请联系工作区所有者升级计划或移除成员,然后再尝试加入。" + } + }, "time": { "justNow": "刚刚", "seconds": { diff --git a/src/components/_shared/landing-page/ErrorPage.tsx b/src/components/_shared/landing-page/ErrorPage.tsx index 34a83b39b..5af20496a 100644 --- a/src/components/_shared/landing-page/ErrorPage.tsx +++ b/src/components/_shared/landing-page/ErrorPage.tsx @@ -1,30 +1,29 @@ -import { useCallback, useState } from 'react'; +import { ReactNode, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { ReactComponent as ErrorLogo } from '@/assets/icons/warning_logo.svg'; +import { getLandingPageErrorContent, LandingPageError } from '@/components/_shared/landing-page/errorContent'; import LandingPage from '@/components/_shared/landing-page/LandingPage'; import { Progress } from '@/components/ui/progress'; interface ErrorPageProps { onRetry?: () => Promise; - error?: { - code?: number; - message?: string; - }; + error?: LandingPageError; + title?: ReactNode; + description?: ReactNode; } -export function ErrorPage({ onRetry, error }: ErrorPageProps) { +export function ErrorPage({ onRetry, error, title, description }: ErrorPageProps) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); + const errorContent = useMemo(() => getLandingPageErrorContent(error, t), [error, t]); const handleCopyError = useCallback(async () => { if (!error) return; - const errorText = error.code - ? `Error: ${error.message}\nCode: ${error.code}` - : `Error: ${error.message}`; + const errorText = error.code ? `Error: ${error.message}\nCode: ${error.code}` : `Error: ${error.message}`; try { await navigator.clipboard.writeText(errorText); @@ -38,20 +37,15 @@ export function ErrorPage({ onRetry, error }: ErrorPageProps) { return ( -
- {t('landingPage.error.descriptionShort', 'This might be due to a network issue or a temporary server error. Please check your internet connection or try again later.')} -
+
{description ?? errorContent.description}
{t('landingPage.error.contactSupport', 'If the problem persists, ')} {error?.message && ( <> - + {t('landingPage.error.copyError', 'copy error')} {' and '} diff --git a/src/components/_shared/landing-page/errorContent.ts b/src/components/_shared/landing-page/errorContent.ts new file mode 100644 index 000000000..7932ac634 --- /dev/null +++ b/src/components/_shared/landing-page/errorContent.ts @@ -0,0 +1,189 @@ +import { TFunction } from 'i18next'; + +import { ERROR_CODE } from '@/application/constants'; +import { determineErrorType, ErrorType } from '@/application/utils/error-utils'; + +export interface LandingPageError { + code?: number; + message?: string; +} + +export interface LandingPageErrorContent { + title: string; + description: string; +} + +const GENERIC_ERROR_MESSAGES = new Set(['Request failed', 'Unknown error occurred']); + +function getServerMessage(error?: LandingPageError) { + const message = error?.message?.trim(); + + if (!message || GENERIC_ERROR_MESSAGES.has(message)) return undefined; + + return message; +} + +export function getLandingPageErrorContent(error: LandingPageError | undefined, t: TFunction): LandingPageErrorContent { + const serverMessage = getServerMessage(error); + const content = (titleKey: string, fallbackTitle: string, descriptionKey: string, fallbackDescription: string) => ({ + title: t(titleKey, fallbackTitle), + description: serverMessage || t(descriptionKey, fallbackDescription), + }); + + if (!error) { + return content( + 'landingPage.error.title', + 'Something went wrong', + 'landingPage.error.descriptionShort', + 'This might be due to a network issue or a temporary server error. Please check your internet connection or try again later.' + ); + } + + switch (error.code) { + case ERROR_CODE.WORKSPACE_MEMBER_LIMIT_EXCEEDED: + return content( + 'landingPage.inviteCode.memberLimitTitle', + 'Workspace member limit reached', + 'landingPage.error.workspaceMemberLimitDescription', + 'This workspace has reached the Free plan member limit. Ask the workspace owner to upgrade their plan or remove a member, then try again.' + ); + case ERROR_CODE.FREE_PLAN_GUEST_LIMIT_EXCEEDED: + case ERROR_CODE.PAID_PLAN_GUEST_LIMIT_EXCEEDED: + return content( + 'landingPage.error.guestLimitTitle', + 'Workspace guest limit reached', + 'landingPage.error.guestLimitDescription', + 'This workspace has reached its guest limit. Ask the workspace owner to upgrade their plan or remove a guest, then try again.' + ); + case ERROR_CODE.WORKSPACE_LIMIT_EXCEEDED: + return content( + 'landingPage.error.workspaceLimitTitle', + 'Workspace limit reached', + 'landingPage.error.workspaceLimitDescription', + 'This account has reached the workspace limit. Remove an unused workspace or upgrade the plan, then try again.' + ); + case ERROR_CODE.PAYLOAD_TOO_LARGE: + case ERROR_CODE.SINGLE_UPLOAD_LIMIT_EXCEEDED: + return content( + 'landingPage.error.uploadLimitTitle', + 'Upload limit reached', + 'landingPage.error.uploadLimitDescription', + 'The uploaded file is larger than the allowed limit. Choose a smaller file and try again.' + ); + case ERROR_CODE.FILE_STORAGE_LIMIT_EXCEEDED: + case ERROR_CODE.STORAGE_SPACE_NOT_ENOUGH: + return content( + 'landingPage.error.storageLimitTitle', + 'Storage limit reached', + 'landingPage.error.storageLimitDescription', + 'This workspace does not have enough storage available. Free up storage or upgrade the plan, then try again.' + ); + case ERROR_CODE.FEATURE_NOT_AVAILABLE: + return content( + 'landingPage.error.featureUnavailableTitle', + 'Feature not available', + 'landingPage.error.featureUnavailableDescription', + 'This feature is not available for the current workspace or plan.' + ); + case ERROR_CODE.ACCESS_REQUEST_ALREADY_APPROVED: + case ERROR_CODE.ACCESS_REQUEST_ALREADY_DENIED: + return content( + 'landingPage.error.requestAlreadyHandledTitle', + 'Request already handled', + 'landingPage.error.requestAlreadyHandledDescription', + 'This access request has already been handled. Refresh the page to see the latest state.' + ); + default: + break; + } + + const appError = determineErrorType(error); + + switch (appError.type) { + case ErrorType.PageNotFound: + return content( + 'landingPage.pageNotFound.title', + 'Page not found', + 'landingPage.pageNotFound.description', + "This page doesn't exist or has been deleted. It may have been moved or removed by the owner." + ); + case ErrorType.Unauthorized: + return content( + 'landingPage.unauthorized.title', + 'Sign in required', + 'landingPage.unauthorized.description', + 'You need to sign in to access this page. Please sign in with your AppFlowy account.' + ); + case ErrorType.Forbidden: + return content( + 'landingPage.forbidden.title', + 'Access denied', + 'landingPage.forbidden.description', + "You don't have permission to view this page. Contact the page owner to request access." + ); + case ErrorType.ServerError: + return content( + 'landingPage.serverError.title', + 'Server error', + 'landingPage.serverError.description', + 'Our servers are experiencing issues. Please try again in a few moments.' + ); + case ErrorType.NetworkError: + return content( + 'landingPage.networkError.title', + 'Connection error', + 'landingPage.networkError.description', + 'Unable to connect to AppFlowy. Please check your internet connection and try again.' + ); + case ErrorType.InvalidLink: + return content( + 'landingPage.invalidLink.title', + 'Invalid link', + 'landingPage.invalidLink.description', + 'This link is invalid or has expired. Please request a new invitation link from the sender.' + ); + case ErrorType.AlreadyJoined: + return content( + 'landingPage.alreadyJoined.title', + 'Already a member', + 'landingPage.alreadyJoined.description', + "You're already a member of this workspace." + ); + case ErrorType.NotInvitee: + return content( + 'landingPage.notInvitee.title', + 'Access denied', + 'landingPage.notInvitee.description', + "This invitation wasn't sent to your account. Please contact the sender for a new invitation." + ); + case ErrorType.Gone: + return content( + 'landingPage.gone.title', + 'Resource deleted', + 'landingPage.gone.description', + 'This resource has been permanently deleted and is no longer available.' + ); + case ErrorType.Timeout: + return content( + 'landingPage.timeout.title', + 'Request timeout', + 'landingPage.timeout.description', + 'The request took too long to complete. Please check your connection and try again.' + ); + case ErrorType.RateLimited: + return content( + 'landingPage.rateLimited.title', + 'Too many requests', + 'landingPage.rateLimited.description', + "You've made too many requests. Please wait a moment and try again." + ); + case ErrorType.Unknown: + default: + return content( + 'landingPage.unknown.title', + 'Something went wrong', + 'landingPage.unknown.description', + 'An unexpected error occurred. Please try again or contact support if the problem persists.' + ); + } +} diff --git a/src/components/app/landing-pages/ApproveConversion.tsx b/src/components/app/landing-pages/ApproveConversion.tsx index 03c2c2a57..d0fb4f04a 100644 --- a/src/components/app/landing-pages/ApproveConversion.tsx +++ b/src/components/app/landing-pages/ApproveConversion.tsx @@ -7,6 +7,7 @@ import { Workspace } from '@/application/types'; import { ReactComponent as SuccessLogo } from '@/assets/icons/success_logo.svg'; import { WorkspaceService } from '@/application/services/domains'; import { ErrorPage } from '@/components/_shared/landing-page/ErrorPage'; +import { LandingPageError } from '@/components/_shared/landing-page/errorContent'; import { InvalidLink } from '@/components/_shared/landing-page/InvalidLink'; import LandingPage from '@/components/_shared/landing-page/LandingPage'; import { NotInvitationAccount } from '@/components/_shared/landing-page/NotInvitationAccount'; @@ -33,13 +34,23 @@ export function ApproveConversion() { const [notInvitee, setNotInvitee] = useState(false); const [isError, setIsError] = useState(false); + const [error, setError] = useState(); const [isAlreadyMember, setIsAlreadyMember] = useState(false); const loadConversion = useCallback(async () => { setLoading(true); + setError(undefined); + if (!workspaceId || !code) { + setError({ + message: t( + 'landingPage.error.invalidInvitationUrl', + 'This invitation link is missing required information. Please ask the sender for a new link.' + ), + }); setIsError(true); + setLoading(false); return; } @@ -62,6 +73,7 @@ export function ApproveConversion() { setGuestName(info.guest_name); + setIsError(false); setIsAlreadyMember(info.guest_is_already_a_member); // eslint-disable-next-line } catch (e: any) { @@ -73,12 +85,13 @@ export function ApproveConversion() { } else if (e.code === ERROR_CODE.NOT_INVITEE_OF_INVITATION) { setNotInvitee(true); } else { + setError(e); setIsError(true); } } finally { setLoading(false); } - }, [workspaceId, code]); + }, [workspaceId, code, t]); const AvatarLogo = useCallback( (props: HTMLAttributes) => { @@ -94,13 +107,23 @@ export function ApproveConversion() { const handleApprove = useCallback(async () => { setLoading(true); + setError(undefined); + if (!workspaceId || !code) { + setError({ + message: t( + 'landingPage.error.invalidInvitationUrl', + 'This invitation link is missing required information. Please ask the sender for a new link.' + ), + }); setIsError(true); + setLoading(false); return; } try { await WorkspaceService.approveTurnGuestToMember(workspaceId, code); + setIsError(false); setIsAlreadyMember(true); // eslint-disable-next-line } catch (e: any) { @@ -109,11 +132,12 @@ export function ApproveConversion() { return; } + setError(e); setIsError(true); } finally { setLoading(false); } - }, [workspaceId, code]); + }, [workspaceId, code, t]); useEffect(() => { void loadConversion(); @@ -152,7 +176,7 @@ export function ApproveConversion() { } if (isError) { - return ; + return ; } return ( diff --git a/src/components/app/landing-pages/ApproveRequestPage.tsx b/src/components/app/landing-pages/ApproveRequestPage.tsx index 60649290a..a9b427711 100644 --- a/src/components/app/landing-pages/ApproveRequestPage.tsx +++ b/src/components/app/landing-pages/ApproveRequestPage.tsx @@ -14,6 +14,7 @@ import { ReactComponent as SuccessLogo } from '@/assets/icons/success_logo.svg'; import { ReactComponent as WarningIcon } from '@/assets/icons/warning.svg'; import { AccessService, BillingService } from '@/application/services/domains'; import { ErrorPage } from '@/components/_shared/landing-page/ErrorPage'; +import { LandingPageError } from '@/components/_shared/landing-page/errorContent'; import LandingPage from '@/components/_shared/landing-page/LandingPage'; import { NotInvitationAccount } from '@/components/_shared/landing-page/NotInvitationAccount'; import { NormalModal } from '@/components/_shared/modal'; @@ -33,13 +34,16 @@ function ApproveRequestPage() { const [alreadyProModalOpen, setAlreadyProModalOpen] = useState(false); const [hasSend, setHasSend] = useState(false); const [isError, setIsError] = useState(false); + const [error, setError] = useState(); const [notInvitee, setNotInvitee] = useState(false); const loadRequestInfo = useCallback(async () => { if (!requestId) return; try { + setError(undefined); const requestInfo = await AccessService.getRequestAccessInfo(requestId); + setIsError(false); setRequestInfo(requestInfo); if (requestInfo.status === RequestAccessInfoStatus.Accepted) { @@ -66,10 +70,12 @@ function ApproveRequestPage() { } if (e.code === ERROR_CODE.INVALID_LINK) { + setError(e); setIsError(true); return; } + setError(e); setIsError(true); } }, [requestId]); @@ -77,7 +83,9 @@ function ApproveRequestPage() { const handleApprove = useCallback(async () => { if (!requestId) return; try { + setError(undefined); await AccessService.approveRequestAccess(requestId); + setIsError(false); toast.success(t('approveAccess.approveSuccess')); void loadRequestInfo(); @@ -89,6 +97,7 @@ function ApproveRequestPage() { setUpgradeModalOpen(true); } else { toast.error(e.message); + setError(e); setIsError(true); } @@ -100,6 +109,7 @@ function ApproveRequestPage() { return; } + setError(e); setIsError(true); } }, [requestId, t, loadRequestInfo]); @@ -154,7 +164,7 @@ function ApproveRequestPage() { }, [handleApprove]); if (isError) { - return ; + return ; } if (notInvitee) { diff --git a/src/components/app/landing-pages/AsGuest.tsx b/src/components/app/landing-pages/AsGuest.tsx index 0cfd0ae21..a2a539cc0 100644 --- a/src/components/app/landing-pages/AsGuest.tsx +++ b/src/components/app/landing-pages/AsGuest.tsx @@ -7,6 +7,7 @@ import { Workspace } from '@/application/types'; import { ReactComponent as SuccessLogo } from '@/assets/icons/success_logo.svg'; import { WorkspaceService } from '@/application/services/domains'; import { ErrorPage } from '@/components/_shared/landing-page/ErrorPage'; +import { LandingPageError } from '@/components/_shared/landing-page/errorContent'; import { InvalidLink } from '@/components/_shared/landing-page/InvalidLink'; import LandingPage from '@/components/_shared/landing-page/LandingPage'; import { NotInvitationAccount } from '@/components/_shared/landing-page/NotInvitationAccount'; @@ -29,6 +30,7 @@ export function AsGuest() { const [notInvitee, setNotInvitee] = useState(false); const [isError, setIsError] = useState(false); + const [error, setError] = useState(); const isAuthenticated = useIsAuthenticatedOptional(); const url = useMemo(() => window.location.href, []); @@ -42,8 +44,17 @@ export function AsGuest() { const loadInvitation = useCallback(async () => { setLoading(true); + setError(undefined); + if (!workspaceId || !code) { + setError({ + message: t( + 'landingPage.error.invalidInvitationUrl', + 'This invitation link is missing required information. Please ask the sender for a new link.' + ), + }); setIsError(true); + setLoading(false); return; } @@ -65,10 +76,12 @@ export function AsGuest() { }); if (info.is_existing_member) { + setIsError(false); return; } await WorkspaceService.acceptGuestInvitation(workspaceId, code); + setIsError(false); // eslint-disable-next-line } catch (e: any) { @@ -80,12 +93,13 @@ export function AsGuest() { } else if (e.code === ERROR_CODE.NOT_INVITEE_OF_INVITATION) { setNotInvitee(true); } else { + setError(e); setIsError(true); } } finally { setLoading(false); } - }, [code, workspaceId]); + }, [code, t, workspaceId]); useEffect(() => { if (isAuthenticated) { @@ -102,7 +116,7 @@ export function AsGuest() { } if (isError) { - return ; + return ; } return ( diff --git a/src/components/app/landing-pages/InviteCode.tsx b/src/components/app/landing-pages/InviteCode.tsx index 0226b483a..ee1904359 100644 --- a/src/components/app/landing-pages/InviteCode.tsx +++ b/src/components/app/landing-pages/InviteCode.tsx @@ -23,6 +23,7 @@ function InviteCode() { const [invalidMessage, setInvalidMessage] = useState(); const [workspace, setWorkspace] = useState(); const [isError, setIsError] = useState(false); + const [error, setError] = useState<{ code?: number; message?: string }>(); const loadWorkspaceInfo = useCallback(async () => { if (!params.code) { @@ -31,6 +32,7 @@ function InviteCode() { } try { + setError(undefined); const info = await WorkspaceService.getInfoByInvitationCode(params.code); setWorkspace({ @@ -52,6 +54,7 @@ function InviteCode() { setInvalidMessage(e.message); setIsInValid(true); } else { + setError(e); setIsError(true); } } finally { @@ -70,9 +73,12 @@ function InviteCode() { } setLoading(true); + setError(undefined); + try { await WorkspaceService.joinByInvitationCode(params.code); + setIsError(false); window.open(`/app/${workspace?.id}`, '_self'); setHasJoined(true); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -81,6 +87,7 @@ function InviteCode() { setInvalidMessage(e.message); setIsInValid(true); } else { + setError(e); setIsError(true); } } finally { @@ -105,7 +112,22 @@ function InviteCode() { } if (isError) { - return ; + const isMemberLimitError = error?.code === ERROR_CODE.WORKSPACE_MEMBER_LIMIT_EXCEEDED; + + return ( + + ); } if (hasJoined) { diff --git a/src/components/app/share/AccessLevelDropdown.tsx b/src/components/app/share/AccessLevelDropdown.tsx index 2d7ef12e2..3c4d97cc4 100644 --- a/src/components/app/share/AccessLevelDropdown.tsx +++ b/src/components/app/share/AccessLevelDropdown.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { AccessLevel, IPeopleWithAccessType } from '@/application/types'; import { ReactComponent as ArrowDownIcon } from '@/assets/icons/alt_arrow_down.svg'; +import { ReactComponent as CrownIcon } from '@/assets/icons/crown.svg'; import { ReactComponent as EditIcon } from '@/assets/icons/edit.svg'; import { ReactComponent as ViewIcon } from '@/assets/icons/show.svg'; import { notify } from '@/components/_shared/notify'; @@ -89,7 +90,7 @@ export function AccessLevelDropdown({ ); }, [loading, isYou, handleRemoveAccess, t]); - if (person.access_level === AccessLevel.FullAccess) { + if (person.access_level === AccessLevel.FullAccess && !canModify) { return (
{getAccessLevelText(person.access_level)} @@ -161,6 +162,32 @@ export function AccessLevelDropdown({ {!loading && person.access_level === AccessLevel.ReadAndWrite && } {loading === 'edit' && } + { + e.preventDefault(); + setLoading('full'); + try { + await onAccessLevelChange(person.email, AccessLevel.FullAccess); + setOpen(false); + notify.success(t('shareAction.changeAccessSuccess', { email: person.email })); + } catch (error) { + notify.error(t('shareAction.changeAccessError')); + } finally { + setLoading(null); + } + }} + > +
+ +
+
{t('shareAction.fullAccess')}
+
{t('shareAction.fullAccessDescription')}
+
+
+ {!loading && person.access_level === AccessLevel.FullAccess && } + {loading === 'full' && } +
{renderRemoveAccess()} diff --git a/src/components/app/share/GeneralAccess.tsx b/src/components/app/share/GeneralAccess.tsx index 5315ef1af..45f0df558 100644 --- a/src/components/app/share/GeneralAccess.tsx +++ b/src/components/app/share/GeneralAccess.tsx @@ -1,23 +1,18 @@ -import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as LockIcon } from '@/assets/icons/lock.svg'; -import { findView } from '@/components/_shared/outline/utils'; -import { useAppOutline, useUserWorkspaceInfo } from '@/components/app/app.hooks'; +import { useUserWorkspaceInfo } from '@/components/app/app.hooks'; +import { ShareSectionType } from '@/components/app/share/shareSectionType'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Label } from '@/components/ui/label'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -export function GeneralAccess({ viewId }: { viewId: string }) { +export function GeneralAccess({ sectionType }: { sectionType: ShareSectionType }) { const { t } = useTranslation(); const userWorkspaceInfo = useUserWorkspaceInfo(); const selectedWorkspace = userWorkspaceInfo?.selectedWorkspace; - const outline = useAppOutline(); - - const isPrivateView = useMemo(() => { - return findView(outline || [], viewId)?.is_private; - }, [outline, viewId]); + const isRestricted = sectionType !== ShareSectionType.Public; if (!selectedWorkspace) { return null; @@ -32,7 +27,7 @@ export function GeneralAccess({ viewId }: { viewId: string }) {
- {isPrivateView ? ( + {isRestricted ? ( - {isPrivateView ? ( + {isRestricted ? ( <>
{t('shareAction.restricted')}
{t('shareAction.restrictedDescription')}
@@ -76,7 +71,7 @@ export function GeneralAccess({ viewId }: { viewId: string }) { )}
- {!isPrivateView && ( + {!isRestricted && (
{t('shareAction.fullAccess')}
)}
diff --git a/src/components/app/share/InviteGuest.tsx b/src/components/app/share/InviteGuest.tsx index 32f61d15a..82a9e92e5 100644 --- a/src/components/app/share/InviteGuest.tsx +++ b/src/components/app/share/InviteGuest.tsx @@ -13,6 +13,7 @@ import { SubscriptionPlan, } from '@/application/types'; import { ReactComponent as ArrowDownIcon } from '@/assets/icons/alt_arrow_down.svg'; +import { ReactComponent as CrownIcon } from '@/assets/icons/crown.svg'; import { ReactComponent as EditIcon } from '@/assets/icons/edit.svg'; import { ReactComponent as ViewIcon } from '@/assets/icons/show.svg'; import { notify } from '@/components/_shared/notify'; @@ -309,6 +310,8 @@ export function InviteGuest({ const getAccessLevelText = useCallback( (accessLevel: AccessLevel) => { switch (accessLevel) { + case AccessLevel.FullAccess: + return t('shareAction.fullAccess'); case AccessLevel.ReadAndWrite: return t('shareAction.canEdit'); case AccessLevel.ReadOnly: @@ -377,6 +380,20 @@ export function InviteGuest({
{selectedAccessLevel === AccessLevel.ReadAndWrite && }
+
e.preventDefault()} + className={cn(dropdownMenuItemVariants({ variant: 'default' }))} + onClick={() => handleAccessLevelSelect(AccessLevel.FullAccess)} + > +
+ +
+
{t('shareAction.fullAccess')}
+
{t('shareAction.fullAccessDescription')}
+
+
+ {selectedAccessLevel === AccessLevel.FullAccess && } +
); @@ -613,7 +630,7 @@ export function InviteGuest({ onMouseDown={(e) => e.preventDefault()} onClick={handleSendInvites} loading={inviteLoading} - disabled={emailTags.length === 0 || isLoading} + disabled={canNotInvite || emailTags.length === 0 || isLoading} > {inviteLoading && } {t('shareAction.invite')} diff --git a/src/components/app/share/PeopleWithAccess.tsx b/src/components/app/share/PeopleWithAccess.tsx index 1e1eb19a5..b2974c5fb 100644 --- a/src/components/app/share/PeopleWithAccess.tsx +++ b/src/components/app/share/PeopleWithAccess.tsx @@ -17,9 +17,10 @@ interface PeopleWithAccessProps { people: IPeopleWithAccessType[]; isLoading: boolean; onPeopleChange: () => Promise; + hasFullAccess: boolean; } -export function PeopleWithAccess({ viewId, people, onPeopleChange, isLoading }: PeopleWithAccessProps) { +export function PeopleWithAccess({ viewId, people, onPeopleChange, isLoading, hasFullAccess }: PeopleWithAccessProps) { const { t } = useTranslation(); const currentUser = useCurrentUser(); @@ -91,10 +92,6 @@ export function PeopleWithAccess({ viewId, people, onPeopleChange, isLoading }: [onPeopleChange, currentWorkspaceId] ); - // Check if current user has full access (can modify others) - const currentUserHasFullAccess = - people.find((p) => p.email === currentUser?.email)?.access_level === AccessLevel.FullAccess; - // Check if current user is owner const currentUserIsOwner = people.find((p) => p.email === currentUser?.email)?.role === Role.Owner; @@ -113,7 +110,7 @@ export function PeopleWithAccess({ viewId, people, onPeopleChange, isLoading }: key={person.email} person={person} isYou={isYou} - currentUserHasFullAccess={currentUserHasFullAccess} + currentUserHasFullAccess={hasFullAccess} currentUserIsOwner={currentUserIsOwner} onAccessLevelChange={handleAccessLevelChange} onRemoveAccess={handleRemoveAccess} diff --git a/src/components/app/share/PublishPanel.tsx b/src/components/app/share/PublishPanel.tsx index 919810421..d07b48248 100644 --- a/src/components/app/share/PublishPanel.tsx +++ b/src/components/app/share/PublishPanel.tsx @@ -1,8 +1,8 @@ -import { Button, CircularProgress, Divider, Typography } from '@mui/material'; +import { Button, CircularProgress, Divider, Tooltip, Typography } from '@mui/material'; import React, { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { ViewLayout } from '@/application/types'; +import { AccessLevel, ViewLayout } from '@/application/types'; import { ReactComponent as CheckboxCheckSvg } from '@/assets/icons/check_filled.svg'; import { ReactComponent as PublishIcon } from '@/assets/icons/earth.svg'; import { ReactComponent as CheckboxUncheckSvg } from '@/assets/icons/uncheck.svg'; @@ -18,11 +18,15 @@ function PublishPanel({ opened, onClose, onOpenPublishManage, + currentUserAccessLevel, + shareDetailsLoading, }: { viewId: string; onClose: () => void; opened: boolean; onOpenPublishManage?: () => void; + currentUserAccessLevel?: AccessLevel; + shareDetailsLoading?: boolean; }) { const { t } = useTranslation(); const { publish, unpublish } = usePublishing(); @@ -36,8 +40,7 @@ function PublishPanel({ isOwner, isPublisher, updatePublishConfig, - } = - useLoadPublishInfo(viewId); + } = useLoadPublishInfo(viewId); const [unpublishLoading, setUnpublishLoading] = React.useState(false); const [publishLoading, setPublishLoading] = React.useState(false); // Track publish/unpublish actions locally so the panel updates immediately, @@ -217,6 +220,24 @@ function PublishPanel({ const renderUnpublished = useCallback(() => { if (!view) return null; const list = [view, ...view.children]; + const isReadOnlyUser = currentUserAccessLevel === AccessLevel.ReadOnly; + const publishDisabled = isReadOnlyUser || shareDetailsLoading || publishLoading; + const publishButton = ( + + ); return (
@@ -271,21 +292,15 @@ function PublishPanel({
)} - + {publishButton} + ); - }, [handlePublish, isDatabase, publishLoading, t, view, visibleViewId]); + }, [currentUserAccessLevel, handlePublish, isDatabase, publishLoading, shareDetailsLoading, t, view, visibleViewId]); return (
diff --git a/src/components/app/share/RequestAccessContent.tsx b/src/components/app/share/RequestAccessContent.tsx index 5375020f6..6df7a0037 100644 --- a/src/components/app/share/RequestAccessContent.tsx +++ b/src/components/app/share/RequestAccessContent.tsx @@ -8,6 +8,7 @@ import { ReactComponent as SuccessLogo } from '@/assets/icons/success_logo.svg'; import { useAppViewId, useCurrentWorkspaceId } from '@/components/app/app.hooks'; import { RequestAccessError } from '@/components/app/hooks/useWorkspaceData'; import { AccessService } from '@/application/services/domains'; +import { getLandingPageErrorContent, LandingPageError } from '@/components/_shared/landing-page/errorContent'; import { useCurrentUser } from '@/components/main/app.hooks'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; @@ -20,7 +21,11 @@ interface RequestAccessContentProps { error?: RequestAccessError; } -export function RequestAccessContent({ viewId: propViewId, workspaceId: propWorkspaceId, error: _error }: RequestAccessContentProps) { +export function RequestAccessContent({ + viewId: propViewId, + workspaceId: propWorkspaceId, + error: _error, +}: RequestAccessContentProps) { const { t } = useTranslation(); const currentWorkspaceId = useCurrentWorkspaceId(); const appViewId = useAppViewId(); @@ -29,6 +34,7 @@ export function RequestAccessContent({ viewId: propViewId, workspaceId: propWork const [hasSend, setHasSend] = useState(false); const [loading, setLoading] = useState(false); const [isError, setIsError] = useState(false); + const [error, setError] = useState(); const currentUser = useCurrentUser(); // Use props if provided, otherwise fall back to hooks @@ -38,11 +44,18 @@ export function RequestAccessContent({ viewId: propViewId, workspaceId: propWork const handleSendRequest = async () => { try { if (!workspaceId || !viewId) { + setError({ + message: t( + 'landingPage.error.missingAccessRequestContext', + 'This access request is missing required page information. Please reopen the shared page and try again.' + ), + }); setIsError(true); return; } setLoading(true); + setError(undefined); await AccessService.sendRequestAccess(workspaceId, viewId); toast.success(t('landingPage.noAccess.requestAccessSuccess')); @@ -53,6 +66,7 @@ export function RequestAccessContent({ viewId: propViewId, workspaceId: propWork toast.error(t('requestAccess.repeatRequestError')); } else { toast.error(e.message); + setError(e); setIsError(true); } } finally { @@ -62,6 +76,7 @@ export function RequestAccessContent({ viewId: propViewId, workspaceId: propWork const handleRetry = () => { setIsError(false); + setError(undefined); void handleSendRequest(); }; @@ -108,15 +123,17 @@ export function RequestAccessContent({ viewId: propViewId, workspaceId: propWork } if (isError) { + const errorContent = getLandingPageErrorContent(error, t); + return (
- {t('landingPage.error.title')} + {errorContent.title}
- {t('landingPage.error.description')} + {errorContent.description}
-
diff --git a/src/components/app/share/ShareTabs.tsx b/src/components/app/share/ShareTabs.tsx index 95446aa3e..3c42750b5 100644 --- a/src/components/app/share/ShareTabs.tsx +++ b/src/components/app/share/ShareTabs.tsx @@ -7,6 +7,7 @@ import { useAppView } from '@/components/app/app.hooks'; import PublishPanel from '@/components/app/share/PublishPanel'; import SharePanel from '@/components/app/share/SharePanel'; import TemplatePanel from '@/components/app/share/TemplatePanel'; +import { useShareAccessDetails } from '@/components/app/share/useShareAccessDetails'; import { useCurrentUser } from '@/components/main/app.hooks'; import { Separator } from '@/components/ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -36,6 +37,8 @@ function ShareTabs({ const view = useAppView(viewId); const [value, setValue] = React.useState(TabKey.SHARE); const currentUser = useCurrentUser(); + const { people, isLoadingPeople, loadPeople, currentUserAccessLevel, hasFullAccess, sectionType } = + useShareAccessDetails(viewId, opened); const options = useMemo(() => { return [ @@ -62,19 +65,17 @@ function ShareTabs({ icon: , Panel: TemplatePanel, }, - ].filter(Boolean) as Array< - { - value: TabKey; - label: string; - icon?: React.JSX.Element; - Panel: React.FC<{ - viewId: string; - onClose: () => void; - opened: boolean; - onOpenPublishManage?: () => void; - }>; - } - >; + ].filter(Boolean) as Array<{ + value: TabKey; + label: string; + icon?: React.JSX.Element; + Panel: React.FC<{ + viewId: string; + onClose: () => void; + opened: boolean; + onOpenPublishManage?: () => void; + }>; + }>; }, [currentUser?.email, t, view?.is_published]); useEffect(() => { @@ -103,12 +104,32 @@ function ShareTabs({ {options.map((option) => ( - + {option.value === TabKey.SHARE ? ( + + ) : option.value === TabKey.PUBLISH ? ( + + ) : ( + + )} ))} diff --git a/src/components/app/share/__tests__/AccessLevelDropdown.test.tsx b/src/components/app/share/__tests__/AccessLevelDropdown.test.tsx new file mode 100644 index 000000000..43ffcb8c7 --- /dev/null +++ b/src/components/app/share/__tests__/AccessLevelDropdown.test.tsx @@ -0,0 +1,114 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; + +import { AccessLevel, IPeopleWithAccessType, Role } from '@/application/types'; + +import { AccessLevelDropdown } from '../AccessLevelDropdown'; + +import type { ComponentProps, ReactNode } from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'shareAction.canEdit': 'Can edit', + 'shareAction.canEditDescription': 'Can make any changes', + 'shareAction.canView': 'Can view', + 'shareAction.canViewDescription': "Can't make changes", + 'shareAction.fullAccess': 'Full access', + 'shareAction.fullAccessDescription': 'Can edit and share with others', + 'shareAction.readAndWrite': 'Can edit', + 'shareAction.readOnly': 'Can view', + 'shareAction.removeAccess': 'Remove access', + }; + + return translations[key] ?? key; + }, + }), +})); + +jest.mock('@/components/_shared/notify', () => ({ + notify: { + error: jest.fn(), + success: jest.fn(), + }, +})); + +jest.mock('@/components/app/share/RemoveAccessConfirmDialog', () => ({ + RemoveAccessConfirmDialog: ({ open }: { open: boolean }) => (open ?
remove access dialog
: null), +})); + +jest.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + disabled, + onSelect, + }: { + children: ReactNode; + disabled?: boolean; + onSelect?: (event: { preventDefault: () => void }) => void; + }) => ( + + ), + DropdownMenuItemTick: () => , + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +const createPerson = (overrides: Partial = {}): IPeopleWithAccessType => ({ + access_level: AccessLevel.FullAccess, + avatar_url: '', + email: 'collaborator@appflowy.local', + name: 'Collaborator', + pending_invitation: false, + role: Role.Member, + ...overrides, +}); + +function renderAccessLevelDropdown(overrides: Partial> = {}) { + const props: ComponentProps = { + canModify: true, + currentUserHasFullAccess: true, + isYou: false, + onAccessLevelChange: async () => undefined, + onRemoveAccess: async () => undefined, + person: createPerson(), + ...overrides, + }; + + return render(); +} + +describe('AccessLevelDropdown', () => { + it('keeps full-access collaborators editable for users who can modify access', () => { + renderAccessLevelDropdown(); + + const trigger = screen.getByRole('button', { name: 'Full access' }); + + expect(trigger.disabled).toBe(false); + expect(screen.getByText('Can view')).toBeTruthy(); + expect(screen.getByText('Can edit')).toBeTruthy(); + expect(screen.getAllByText('Full access')).toHaveLength(2); + expect(screen.getByText('Remove access')).toBeTruthy(); + }); + + it('keeps non-modifiable full-access rows as static labels', () => { + renderAccessLevelDropdown({ + canModify: false, + currentUserHasFullAccess: false, + }); + + expect(screen.queryByRole('button', { name: 'Full access' })).toBeNull(); + expect(screen.getByText('Full access')).toBeTruthy(); + expect(screen.queryByText('Remove access')).toBeNull(); + }); +}); diff --git a/src/components/app/share/shareSectionType.ts b/src/components/app/share/shareSectionType.ts new file mode 100644 index 000000000..9f8c20816 --- /dev/null +++ b/src/components/app/share/shareSectionType.ts @@ -0,0 +1,48 @@ +import { AccessLevel, IPeopleWithAccessType, Role, View } from '@/application/types'; +import { findView, findViewInShareWithMe } from '@/components/_shared/outline/utils'; + +export enum ShareSectionType { + Public = 'public', + Shared = 'shared', + Private = 'private', + Unknown = 'unknown', +} + +export function resolveShareSectionType({ + outline, + viewId, + sharedPeople, + workspaceMemberCount, +}: { + outline: View[]; + viewId: string; + sharedPeople: IPeopleWithAccessType[]; + workspaceMemberCount?: number; +}): ShareSectionType { + if (findViewInShareWithMe(outline, viewId)) { + return ShareSectionType.Shared; + } + + const view = findView(outline, viewId); + const hasKnownWorkspaceMemberCount = workspaceMemberCount !== undefined && workspaceMemberCount > 0; + const hasWorkspaceWideAccess = + hasKnownWorkspaceMemberCount && + sharedPeople.filter( + (person) => + !person.pending_invitation && person.access_level === AccessLevel.FullAccess && person.role !== Role.Guest + ).length >= workspaceMemberCount; + + if (view) { + if (!view.is_private && (hasWorkspaceWideAccess || !hasKnownWorkspaceMemberCount)) { + return ShareSectionType.Public; + } + + return sharedPeople.length > 1 ? ShareSectionType.Shared : ShareSectionType.Private; + } + + if (sharedPeople.length > 1) { + return ShareSectionType.Shared; + } + + return ShareSectionType.Unknown; +} diff --git a/src/components/app/share/useShareAccessDetails.test.ts b/src/components/app/share/useShareAccessDetails.test.ts new file mode 100644 index 000000000..63dd328eb --- /dev/null +++ b/src/components/app/share/useShareAccessDetails.test.ts @@ -0,0 +1,88 @@ +import { AccessLevel, IPeopleWithAccessType, Role, View, ViewLayout } from '@/application/types'; +import { resolveShareSectionType, ShareSectionType } from '@/components/app/share/shareSectionType'; + +const createView = (overrides: Partial = {}): View => ({ + view_id: 'view-1', + name: 'View', + icon: null, + layout: ViewLayout.Document, + extra: null, + children: [], + is_published: false, + is_private: false, + ...overrides, +}); + +const createPerson = (email: string, overrides: Partial = {}): IPeopleWithAccessType => ({ + email, + name: email, + access_level: AccessLevel.FullAccess, + role: Role.Member, + avatar_url: '', + pending_invitation: false, + ...overrides, +}); + +describe('resolveShareSectionType', () => { + it('treats public outline views as public even when multiple people have access', () => { + expect( + resolveShareSectionType({ + outline: [createView({ is_private: false })], + viewId: 'view-1', + sharedPeople: [createPerson('owner@appflowy.io'), createPerson('member@appflowy.io')], + workspaceMemberCount: 2, + }) + ).toBe(ShareSectionType.Public); + }); + + it('does not trust a public outline flag when access details are not workspace-wide', () => { + expect( + resolveShareSectionType({ + outline: [createView({ is_private: false })], + viewId: 'view-1', + sharedPeople: [createPerson('owner@appflowy.io'), createPerson('guest@example.com', { role: Role.Guest })], + workspaceMemberCount: 3, + }) + ).toBe(ShareSectionType.Shared); + }); + + it('treats private views with multiple shared users as shared', () => { + expect( + resolveShareSectionType({ + outline: [createView({ is_private: true })], + viewId: 'view-1', + sharedPeople: [createPerson('owner@appflowy.io'), createPerson('guest@example.com')], + }) + ).toBe(ShareSectionType.Shared); + }); + + it('treats private views with only one user as private', () => { + expect( + resolveShareSectionType({ + outline: [createView({ is_private: true })], + viewId: 'view-1', + sharedPeople: [createPerson('owner@appflowy.io')], + }) + ).toBe(ShareSectionType.Private); + }); + + it('prioritizes the Share with me space over the view private flag', () => { + const sharedView = createView({ is_private: false, view_id: 'shared-view' }); + const shareWithMeSpace = createView({ + view_id: 'share-with-me', + extra: { + is_space: true, + is_hidden_space: true, + }, + children: [sharedView], + }); + + expect( + resolveShareSectionType({ + outline: [shareWithMeSpace], + viewId: 'shared-view', + sharedPeople: [createPerson('owner@appflowy.io')], + }) + ).toBe(ShareSectionType.Shared); + }); +}); diff --git a/src/components/app/share/useShareAccessDetails.ts b/src/components/app/share/useShareAccessDetails.ts new file mode 100644 index 000000000..a67cf05e6 --- /dev/null +++ b/src/components/app/share/useShareAccessDetails.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { AccessLevel, IPeopleWithAccessType } from '@/application/types'; +import { findAncestors, findView } from '@/components/_shared/outline/utils'; +import { useAppOutline, useCurrentWorkspaceId, useUserWorkspaceInfo } from '@/components/app/app.hooks'; +import { AccessService } from '@/application/services/domains'; +import { resolveShareSectionType, ShareSectionType } from '@/components/app/share/shareSectionType'; +import { useCurrentUser } from '@/components/main/app.hooks'; + +export function useShareAccessDetails(viewId: string, opened: boolean) { + const currentUser = useCurrentUser(); + const currentUserEmail = currentUser?.email; + const currentWorkspaceId = useCurrentWorkspaceId(); + const userWorkspaceInfo = useUserWorkspaceInfo(); + const outline = useAppOutline(); + const [people, setPeople] = useState([]); + const [isLoadingPeople, setIsLoadingPeople] = useState(false); + const [hasLoadedPeople, setHasLoadedPeople] = useState(false); + const [loadedPeopleViewId, setLoadedPeopleViewId] = useState(null); + const loadPeopleRequestSeq = useRef(0); + + const loadPeople = useCallback( + async (signal?: AbortSignal) => { + if (!currentWorkspaceId || !viewId || !currentUserEmail) { + return; + } + + const ancestorViewIds = findAncestors(outline || [], viewId)?.map((item) => item.view_id) || []; + const requestSeq = ++loadPeopleRequestSeq.current; + + setIsLoadingPeople(true); + setHasLoadedPeople(false); + try { + const detail = await AccessService.getShareDetail(currentWorkspaceId, viewId, ancestorViewIds, signal); + + if (signal?.aborted || requestSeq !== loadPeopleRequestSeq.current) return; + setPeople(detail.shared_with); + setHasLoadedPeople(true); + setLoadedPeopleViewId(viewId); + } catch (error) { + if (signal?.aborted || requestSeq !== loadPeopleRequestSeq.current) return; + console.error(error); + setPeople([]); + setHasLoadedPeople(false); + setLoadedPeopleViewId(null); + } finally { + if (!signal?.aborted && requestSeq === loadPeopleRequestSeq.current) { + setIsLoadingPeople(false); + } + } + }, + [currentUserEmail, currentWorkspaceId, viewId, outline] + ); + + useEffect(() => { + if (!opened) return; + + const controller = new AbortController(); + + void loadPeople(controller.signal); + return () => controller.abort(); + }, [loadPeople, opened]); + + const outlineView = useMemo(() => findView(outline || [], viewId), [outline, viewId]); + const peopleForCurrentView = useMemo( + () => (loadedPeopleViewId === viewId ? people : []), + [loadedPeopleViewId, people, viewId] + ); + const currentUserAccessLevel = useMemo(() => { + return ( + peopleForCurrentView.find((person) => person.email === currentUserEmail)?.access_level ?? outlineView?.access_level + ); + }, [currentUserEmail, outlineView?.access_level, peopleForCurrentView]); + const sectionType = useMemo(() => { + if (!hasLoadedPeople || loadedPeopleViewId !== viewId) { + return ShareSectionType.Unknown; + } + + return resolveShareSectionType({ + outline: outline || [], + viewId, + sharedPeople: peopleForCurrentView, + workspaceMemberCount: userWorkspaceInfo?.selectedWorkspace?.memberCount, + }); + }, [ + hasLoadedPeople, + loadedPeopleViewId, + outline, + peopleForCurrentView, + userWorkspaceInfo?.selectedWorkspace?.memberCount, + viewId, + ]); + + return { + people: peopleForCurrentView, + isLoadingPeople, + loadPeople, + currentUserAccessLevel, + hasFullAccess: currentUserAccessLevel === AccessLevel.FullAccess, + sectionType, + }; +}