From b743cc87bbdd017a598c55df3ec05fbe5d5df61c Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 2 Dec 2025 17:27:25 +0800 Subject: [PATCH 1/3] chore: display no access error --- .../app/components/AppContextConsumer.tsx | 7 ++--- src/components/app/hooks/useWorkspaceData.ts | 26 +++++++++++++++---- .../app/landing-pages/RequestAccess.tsx | 9 +++++-- .../app/layers/AppBusinessLayer.tsx | 4 +-- .../app/share/RequestAccessContent.tsx | 4 ++- 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/components/app/components/AppContextConsumer.tsx b/src/components/app/components/AppContextConsumer.tsx index 0b6d2e9bb..ef5d3fd24 100644 --- a/src/components/app/components/AppContextConsumer.tsx +++ b/src/components/app/components/AppContextConsumer.tsx @@ -5,6 +5,7 @@ import { Awareness } from 'y-protocols/awareness'; import { AIChatProvider } from '@/components/ai-chat/AIChatProvider'; import { AppOverlayProvider } from '@/components/app/app-overlay/AppOverlayProvider'; import { AppContext, useAppViewId, useCurrentWorkspaceId } from '@/components/app/app.hooks'; +import { RequestAccessError } from '@/components/app/hooks/useWorkspaceData'; import RequestAccess from '@/components/app/landing-pages/RequestAccess'; import { useCurrentUser } from '@/components/main/app.hooks'; @@ -14,7 +15,7 @@ const ViewModal = React.lazy(() => import('@/components/app/ViewModal')); interface AppContextConsumerProps { children: React.ReactNode; - requestAccessOpened: boolean; + requestAccessError: RequestAccessError | null; openModalViewId?: string; setOpenModalViewId: (id: string | undefined) => void; awarenessMap: Record; @@ -23,7 +24,7 @@ interface AppContextConsumerProps { // Component that consumes all internal contexts and provides the unified AppContext // This maintains the original AppContext API while using the new layered architecture internally export const AppContextConsumer: React.FC = memo( - ({ children, requestAccessOpened, openModalViewId, setOpenModalViewId, awarenessMap }) => { + ({ children, requestAccessError, openModalViewId, setOpenModalViewId, awarenessMap }) => { // Merge all layer data into the complete AppContextType const allContextData = useAllContextData(awarenessMap); @@ -31,7 +32,7 @@ export const AppContextConsumer: React.FC = memo( - {requestAccessOpened ? : children} + {requestAccessError ? : children} { (); const [trashList, setTrashList] = useState(); const [workspaceDatabases, setWorkspaceDatabases] = useState(undefined); - const [requestAccessOpened, setRequestAccessOpened] = useState(false); + const [requestAccessError, setRequestAccessError] = useState(null); const mentionableUsersRef = useRef([]); @@ -110,8 +117,17 @@ export function useWorkspaceData() { // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { console.error('App outline not found'); - if (USER_NO_ACCESS_CODE.includes(e.code)) { - setRequestAccessOpened(true); + if (e.code === USER_UNAUTHORIZED_CODE) { + invalidToken(); + navigate('/login'); + return; + } + + if (e.code === USER_NO_ACCESS_CODE) { + setRequestAccessError({ + code: e.code, + message: e.message, + }); return; } } @@ -320,7 +336,7 @@ export function useWorkspaceData() { recentViews, trashList, workspaceDatabases, - requestAccessOpened, + requestAccessError, loadOutline, loadFavoriteViews, loadRecentViews, diff --git a/src/components/app/landing-pages/RequestAccess.tsx b/src/components/app/landing-pages/RequestAccess.tsx index 610400775..bed8ec313 100644 --- a/src/components/app/landing-pages/RequestAccess.tsx +++ b/src/components/app/landing-pages/RequestAccess.tsx @@ -1,7 +1,12 @@ import { ReactComponent as AppFlowyLogo } from '@/assets/icons/appflowy.svg'; +import { RequestAccessError } from '@/components/app/hooks/useWorkspaceData'; import { RequestAccessContent } from '@/components/app/share/RequestAccessContent'; -function RequestAccess() { +interface RequestAccessProps { + error?: RequestAccessError; +} + +function RequestAccess({ error }: RequestAccessProps) { return (
@@ -15,7 +20,7 @@ function RequestAccess() {
- +
); diff --git a/src/components/app/layers/AppBusinessLayer.tsx b/src/components/app/layers/AppBusinessLayer.tsx index d167b533c..14ec29aee 100644 --- a/src/components/app/layers/AppBusinessLayer.tsx +++ b/src/components/app/layers/AppBusinessLayer.tsx @@ -47,7 +47,7 @@ export const AppBusinessLayer: React.FC = ({ children }) recentViews, trashList, workspaceDatabases, - requestAccessOpened, + requestAccessError, loadOutline, loadFavoriteViews, loadRecentViews, @@ -295,7 +295,7 @@ export const AppBusinessLayer: React.FC = ({ children }) return ( Date: Tue, 2 Dec 2025 19:30:39 +0800 Subject: [PATCH 2/3] chore: add storybook --- .../landing-page/ErrorPage.stories.tsx | 72 +++++++++++++++++++ .../_shared/landing-page/ErrorPage.tsx | 71 +++++++++++++----- 2 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 src/components/_shared/landing-page/ErrorPage.stories.tsx diff --git a/src/components/_shared/landing-page/ErrorPage.stories.tsx b/src/components/_shared/landing-page/ErrorPage.stories.tsx new file mode 100644 index 000000000..186e583ec --- /dev/null +++ b/src/components/_shared/landing-page/ErrorPage.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { withContextsMinimal } from '../../../../.storybook/decorators'; + +import { ErrorPage } from './ErrorPage'; + +const meta = { + title: 'Landing Pages/ErrorPage', + component: ErrorPage, + parameters: { + layout: 'fullscreen', + }, + decorators: [withContextsMinimal], + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithRetry: Story = { + args: { + onRetry: async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + }, + }, +}; + +export const WithErrorDetails: Story = { + args: { + error: { + code: 1012, + message: 'You do not have permission to access this workspace.', + }, + onRetry: async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + }, + }, +}; + +export const WithNetworkError: Story = { + args: { + error: { + code: -1, + message: 'Network error. Please check your connection.', + }, + onRetry: async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + }, + }, +}; + +export const WithLongErrorMessage: Story = { + args: { + error: { + code: 500, + message: 'Internal server error: The request could not be processed due to an unexpected condition. Please try again later or contact support if the problem persists. Request ID: abc123-def456-ghi789', + }, + onRetry: async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + }, + }, +}; + +export const WithErrorMessageOnly: Story = { + args: { + error: { + message: 'Something unexpected happened.', + }, + }, +}; diff --git a/src/components/_shared/landing-page/ErrorPage.tsx b/src/components/_shared/landing-page/ErrorPage.tsx index e19b994f6..34a83b39b 100644 --- a/src/components/_shared/landing-page/ErrorPage.tsx +++ b/src/components/_shared/landing-page/ErrorPage.tsx @@ -1,33 +1,72 @@ -import { useState } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; import { ReactComponent as ErrorLogo } from '@/assets/icons/warning_logo.svg'; import LandingPage from '@/components/_shared/landing-page/LandingPage'; import { Progress } from '@/components/ui/progress'; -export function ErrorPage({ onRetry }: { onRetry?: () => Promise }) { +interface ErrorPageProps { + onRetry?: () => Promise; + error?: { + code?: number; + message?: string; + }; +} + +export function ErrorPage({ onRetry, error }: ErrorPageProps) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); + const handleCopyError = useCallback(async () => { + if (!error) return; + + const errorText = error.code + ? `Error: ${error.message}\nCode: ${error.code}` + : `Error: ${error.message}`; + + try { + await navigator.clipboard.writeText(errorText); + toast.success('Error details copied to clipboard', { duration: 3000 }); + } catch (e) { + console.error('Failed to copy:', e); + toast.error('Failed to copy error details', { duration: 3000 }); + } + }, [error]); + return ( window.open('mailto:support@appflowy.io', '_blank')} - className='cursor-pointer text-text-action' - > - support@appflowy.io - - ), - }} - /> + <> +
+ {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.')} +
+
+ {t('landingPage.error.contactSupport', 'If the problem persists, ')} + {error?.message && ( + <> + + {t('landingPage.error.copyError', 'copy error')} + + {' and '} + + )} + {t('landingPage.error.contact', 'contact ')} + window.open('mailto:support@appflowy.io', '_blank')} + className='cursor-pointer text-text-action hover:underline' + > + support@appflowy.io + + . +
+ } primaryAction={ onRetry From a0676afecac53866a078709646bdfe12261c5fcd Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 2 Dec 2025 20:06:52 +0800 Subject: [PATCH 3/3] chore: fix test --- cypress/e2e/page/publish-page.cy.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/page/publish-page.cy.ts b/cypress/e2e/page/publish-page.cy.ts index 51bcb0e33..548174a6d 100644 --- a/cypress/e2e/page/publish-page.cy.ts +++ b/cypress/e2e/page/publish-page.cy.ts @@ -21,6 +21,8 @@ describe('Publish Page Test', () => { if (err.message.includes('No workspace or service found') || err.message.includes('createThemeNoVars_default is not a function') || err.message.includes('View not found') || + err.message.includes('Record not found') || + err.message.includes('Request failed') || err.message.includes('Failed to execute \'writeText\' on \'Clipboard\': Document is not focused') || err.name === 'NotAllowedError') { return false; @@ -671,7 +673,9 @@ describe('Publish Page Test', () => { cy.on('uncaught:exception', (err: Error) => { if (err.message.includes('No workspace or service found') || err.message.includes('createThemeNoVars_default is not a function') || - err.message.includes('View not found')) { + err.message.includes('View not found') || + err.message.includes('Record not found') || + err.message.includes('Request failed')) { return false; } return true;