From f4afbb7c6892de6d11e2a174cd012ff4f6e5d0f2 Mon Sep 17 00:00:00 2001 From: Jimmfly Date: Fri, 16 May 2025 11:25:23 +0800 Subject: [PATCH] refactor(core): use Route component --- .../apps/electron-renderer/src/app/app.tsx | 8 +- packages/frontend/apps/web/src/app.tsx | 8 +- packages/frontend/core/package.json | 1 + .../components/hooks/use-navigate-helper.ts | 46 ++- .../core/src/desktop/pages/auth/auth.tsx | 27 +- .../src/desktop/pages/auth/magic-link.tsx | 44 +- .../src/desktop/pages/auth/oauth-callback.tsx | 49 +-- .../src/desktop/pages/auth/oauth-login.tsx | 50 +-- .../src/desktop/pages/onboarding/index.tsx | 16 - .../core/src/desktop/pages/redirect/index.tsx | 51 +-- .../core/src/desktop/router-loader.tsx | 219 ++++++++++ packages/frontend/core/src/desktop/router.tsx | 380 ++++++++++-------- packages/frontend/core/src/mobile/router.tsx | 27 +- packages/frontend/routes/routes.json | 64 +++ packages/frontend/routes/src/routes.ts | 190 ++++++++- yarn.lock | 1 + 16 files changed, 744 insertions(+), 437 deletions(-) create mode 100644 packages/frontend/core/src/desktop/router-loader.tsx diff --git a/packages/frontend/apps/electron-renderer/src/app/app.tsx b/packages/frontend/apps/electron-renderer/src/app/app.tsx index 0e69a0385a..58ae553b8b 100644 --- a/packages/frontend/apps/electron-renderer/src/app/app.tsx +++ b/packages/frontend/apps/electron-renderer/src/app/app.tsx @@ -1,12 +1,12 @@ import { AffineContext } from '@affine/core/components/context'; import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls'; -import { router } from '@affine/core/desktop/router'; +import { Router } from '@affine/core/desktop/router'; import { I18nProvider } from '@affine/core/modules/i18n'; import createEmotionCache from '@affine/core/utils/create-emotion-cache'; import { CacheProvider } from '@emotion/react'; import { FrameworkRoot, getCurrentStore } from '@toeverything/infra'; import { Suspense } from 'react'; -import { RouterProvider } from 'react-router/dom'; +import { BrowserRouter } from 'react-router'; import { setupEffects } from './effects'; import { DesktopThemeSync } from './theme-sync'; @@ -41,7 +41,9 @@ export function App() { - + + + {environment.isWindows && (
diff --git a/packages/frontend/apps/web/src/app.tsx b/packages/frontend/apps/web/src/app.tsx index 03099ad0bc..df20acbcfd 100644 --- a/packages/frontend/apps/web/src/app.tsx +++ b/packages/frontend/apps/web/src/app.tsx @@ -1,5 +1,5 @@ import { AffineContext } from '@affine/core/components/context'; -import { router } from '@affine/core/desktop/router'; +import { Router } from '@affine/core/desktop/router'; import { configureCommonModules } from '@affine/core/modules'; import { I18nProvider } from '@affine/core/modules/i18n'; import { LifecycleService } from '@affine/core/modules/lifecycle'; @@ -17,7 +17,7 @@ import { CacheProvider } from '@emotion/react'; import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra'; import { OpClient } from '@toeverything/infra/op'; import { Suspense } from 'react'; -import { RouterProvider } from 'react-router/dom'; +import { BrowserRouter } from 'react-router'; const cache = createEmotionCache(); @@ -85,7 +85,9 @@ export function App() { - + + + diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 1c0c9ab677..03e23db068 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -16,6 +16,7 @@ "@affine/graphql": "workspace:*", "@affine/i18n": "workspace:*", "@affine/nbstore": "workspace:*", + "@affine/routes": "workspace:*", "@affine/templates": "workspace:*", "@affine/track": "workspace:*", "@blocksuite/affine": "workspace:*", diff --git a/packages/frontend/core/src/components/hooks/use-navigate-helper.ts b/packages/frontend/core/src/components/hooks/use-navigate-helper.ts index b74823de45..a8340e92f6 100644 --- a/packages/frontend/core/src/components/hooks/use-navigate-helper.ts +++ b/packages/frontend/core/src/components/hooks/use-navigate-helper.ts @@ -3,6 +3,7 @@ import type { SettingTab } from '@affine/core/modules/dialogs/constant'; import { toDocSearchParams } from '@affine/core/modules/navigation'; import { getOpenUrlInDesktopAppLink } from '@affine/core/modules/open-in-app'; import { UserFriendlyError } from '@affine/error'; +import { FACTORIES } from '@affine/routes'; import type { DocMode } from '@blocksuite/affine/model'; import { nanoid } from 'nanoid'; import { createContext, useCallback, useContext, useMemo } from 'react'; @@ -52,7 +53,7 @@ export function useNavigateHelper() { pageId: string, logic: RouteLogic = RouteLogic.PUSH ) => { - return navigate(`/workspace/${workspaceId}/${pageId}`, { + return navigate(FACTORIES.workspace.doc({ workspaceId, docId: pageId }), { replace: logic === RouteLogic.REPLACE, }); }, @@ -74,15 +75,18 @@ export function useNavigateHelper() { refreshKey: nanoid(), }); const query = search?.size ? `?${search.toString()}` : ''; - return navigate(`/workspace/${workspaceId}/${pageId}${query}`, { - replace: logic === RouteLogic.REPLACE, - }); + return navigate( + FACTORIES.workspace.doc({ workspaceId, docId: pageId }) + query, + { + replace: logic === RouteLogic.REPLACE, + } + ); }, [navigate] ); const jumpToCollections = useCallback( (workspaceId: string, logic: RouteLogic = RouteLogic.PUSH) => { - return navigate(`/workspace/${workspaceId}/collection`, { + return navigate(FACTORIES.workspace.collections({ workspaceId }), { replace: logic === RouteLogic.REPLACE, }); }, @@ -90,7 +94,7 @@ export function useNavigateHelper() { ); const jumpToTags = useCallback( (workspaceId: string, logic: RouteLogic = RouteLogic.PUSH) => { - return navigate(`/workspace/${workspaceId}/tag`, { + return navigate(FACTORIES.workspace.tags({ workspaceId }), { replace: logic === RouteLogic.REPLACE, }); }, @@ -102,7 +106,7 @@ export function useNavigateHelper() { tagId: string, logic: RouteLogic = RouteLogic.PUSH ) => { - return navigate(`/workspace/${workspaceId}/tag/${tagId}`, { + return navigate(FACTORIES.workspace.tags.tag({ workspaceId, tagId }), { replace: logic === RouteLogic.REPLACE, }); }, @@ -114,16 +118,22 @@ export function useNavigateHelper() { collectionId: string, logic: RouteLogic = RouteLogic.PUSH ) => { - return navigate(`/workspace/${workspaceId}/collection/${collectionId}`, { - replace: logic === RouteLogic.REPLACE, - }); + return navigate( + FACTORIES.workspace.collections.collection({ + workspaceId, + collectionId, + }), + { + replace: logic === RouteLogic.REPLACE, + } + ); }, [navigate] ); const jumpToAll = useCallback( (workspaceId: string, logic?: RouteLogic) => { - return navigate(`/workspace/${workspaceId}/all`, { + return navigate(FACTORIES.workspace.all({ workspaceId }), { replace: logic === RouteLogic.REPLACE, }); }, @@ -144,7 +154,7 @@ export function useNavigateHelper() { const jumpTo404 = useCallback( (logic: RouteLogic = RouteLogic.PUSH) => { - return navigate('/404', { + return navigate(FACTORIES.notFound(), { replace: logic === RouteLogic.REPLACE, }); }, @@ -152,7 +162,7 @@ export function useNavigateHelper() { ); const jumpToExpired = useCallback( (logic: RouteLogic = RouteLogic.PUSH) => { - return navigate('/expired', { + return navigate(FACTORIES.expired(), { replace: logic === RouteLogic.REPLACE, }); }, @@ -176,7 +186,7 @@ export function useNavigateHelper() { } return navigate( - '/sign-in' + + FACTORIES.signIn() + (searchParams.toString() ? '?' + searchParams.toString() : ''), { replace: logic === RouteLogic.REPLACE, @@ -196,7 +206,7 @@ export function useNavigateHelper() { } const encodedUrl = encodeURIComponent(deeplink); - return navigate(`/open-app/url?url=${encodedUrl}`); + return navigate(FACTORIES.openApp({ action: `url?url=${encodedUrl}` })); }, [navigate] ); @@ -204,7 +214,8 @@ export function useNavigateHelper() { const jumpToImportTemplate = useCallback( (name: string, snapshotUrl: string) => { return navigate( - `/template/import?name=${encodeURIComponent(name)}&snapshotUrl=${encodeURIComponent(snapshotUrl)}` + FACTORIES.template.import() + + `?name=${encodeURIComponent(name)}&snapshotUrl=${encodeURIComponent(snapshotUrl)}` ); }, [navigate] @@ -221,7 +232,8 @@ export function useNavigateHelper() { searchParams.set('tab', tab); } return navigate( - `/workspace/${workspaceId}/settings?${searchParams.toString()}`, + FACTORIES.workspace.settings({ workspaceId }) + + (searchParams.toString() ? `?${searchParams.toString()}` : ''), { replace: logic === RouteLogic.REPLACE, } diff --git a/packages/frontend/core/src/desktop/pages/auth/auth.tsx b/packages/frontend/core/src/desktop/pages/auth/auth.tsx index 3647b42cf5..ab7cf3325f 100644 --- a/packages/frontend/core/src/desktop/pages/auth/auth.tsx +++ b/packages/frontend/core/src/desktop/pages/auth/auth.tsx @@ -14,9 +14,7 @@ import { import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; import { useCallback } from 'react'; -import type { LoaderFunction } from 'react-router'; -import { redirect, useParams, useSearchParams } from 'react-router'; -import { z } from 'zod'; +import { useParams, useSearchParams } from 'react-router'; import { useMutation } from '../../../components/hooks/use-mutation'; import { @@ -28,18 +26,6 @@ import { AppContainer } from '../../components/app-container'; import { ConfirmChangeEmail } from './confirm-change-email'; import { ConfirmVerifiedEmail } from './email-verified-email'; -const authTypeSchema = z.enum([ - 'onboarding', - 'setPassword', - 'signIn', - 'changePassword', - 'signUp', - 'changeEmail', - 'confirm-change-email', - 'subscription-redirect', - 'verify-email', -]); - export const Component = () => { const authService = useService(AuthService); const account = useLiveData(authService.session.account$); @@ -159,14 +145,3 @@ export const Component = () => { } return null; }; - -export const loader: LoaderFunction = async args => { - if (!args.params.authType) { - return redirect('/404'); - } - if (!authTypeSchema.safeParse(args.params.authType).success) { - return redirect('/404'); - } - - return null; -}; diff --git a/packages/frontend/core/src/desktop/pages/auth/magic-link.tsx b/packages/frontend/core/src/desktop/pages/auth/magic-link.tsx index 3070a62dad..1d12940b3a 100644 --- a/packages/frontend/core/src/desktop/pages/auth/magic-link.tsx +++ b/packages/frontend/core/src/desktop/pages/auth/magic-link.tsx @@ -1,57 +1,19 @@ import { useAsyncNavigate } from '@affine/core/utils/use-async-navigate'; import { useService } from '@toeverything/infra'; import { useEffect, useRef } from 'react'; -import { type LoaderFunction, redirect, useLoaderData } from 'react-router'; +import { useLoaderData } from 'react-router'; import { AuthService } from '../../../modules/cloud'; -import { supportedClient } from './common'; - -interface LoaderData { +export interface MagicLinkLoaderData { token: string; email: string; redirectUri: string | null; } -export const loader: LoaderFunction = ({ request }) => { - const url = new URL(request.url); - const params = url.searchParams; - const client = params.get('client'); - const email = params.get('email'); - const token = params.get('token'); - const redirectUri = params.get('redirect_uri'); - - if (!email || !token) { - return redirect('/sign-in?error=Invalid magic link'); - } - - const payload: LoaderData = { - email, - token, - redirectUri, - }; - - if (!client || client === 'web') { - return payload; - } - - const clientCheckResult = supportedClient.safeParse(client); - if (!clientCheckResult.success) { - return redirect('/sign-in?error=Invalid callback parameters'); - } - - const authParams = new URLSearchParams(); - authParams.set('method', 'magic-link'); - authParams.set('payload', JSON.stringify(payload)); - - return redirect( - `/open-app/url?url=${encodeURIComponent(`${client}://authentication?${authParams.toString()}`)}` - ); -}; - export const Component = () => { // TODO(@eyhn): loading ui const auth = useService(AuthService); - const data = useLoaderData() as LoaderData; + const data = useLoaderData() as MagicLinkLoaderData; const nav = useAsyncNavigate(); diff --git a/packages/frontend/core/src/desktop/pages/auth/oauth-callback.tsx b/packages/frontend/core/src/desktop/pages/auth/oauth-callback.tsx index ee3ac9f9b2..1d89b7b195 100644 --- a/packages/frontend/core/src/desktop/pages/auth/oauth-callback.tsx +++ b/packages/frontend/core/src/desktop/pages/auth/oauth-callback.tsx @@ -1,62 +1,19 @@ import { useAsyncNavigate } from '@affine/core/utils/use-async-navigate'; import { useService } from '@toeverything/infra'; import { useEffect, useRef } from 'react'; -import { type LoaderFunction, redirect, useLoaderData } from 'react-router'; +import { useLoaderData } from 'react-router'; import { AuthService } from '../../../modules/cloud'; -import { supportedClient } from './common'; -interface LoaderData { +export interface OAuthCallbackLoaderData { state: string; code: string; provider: string; } -export const loader: LoaderFunction = async ({ request }) => { - const url = new URL(request.url); - const queries = url.searchParams; - const code = queries.get('code'); - let stateStr = queries.get('state') ?? '{}'; - - if (!code || !stateStr) { - return redirect('/sign-in?error=Invalid oauth callback parameters'); - } - - try { - const { state, client, provider } = JSON.parse(stateStr); - stateStr = state; - - const payload: LoaderData = { - state, - code, - provider, - }; - - if (!client || client === 'web') { - return payload; - } - - const clientCheckResult = supportedClient.safeParse(client); - if (!clientCheckResult.success) { - return redirect('/sign-in?error=Invalid oauth callback parameters'); - } - - const authParams = new URLSearchParams(); - authParams.set('method', 'oauth'); - authParams.set('payload', JSON.stringify(payload)); - authParams.set('server', location.origin); - - return redirect( - `/open-app/url?url=${encodeURIComponent(`${client}://authentication?${authParams.toString()}`)}` - ); - } catch { - return redirect('/sign-in?error=Invalid oauth callback parameters'); - } -}; - export const Component = () => { const auth = useService(AuthService); - const data = useLoaderData() as LoaderData; + const data = useLoaderData() as OAuthCallbackLoaderData; // loader data from useLoaderData is not reactive, so that we can safely // assume the effect below is only triggered once diff --git a/packages/frontend/core/src/desktop/pages/auth/oauth-login.tsx b/packages/frontend/core/src/desktop/pages/auth/oauth-login.tsx index b34ae2f6a4..ffe3a10e22 100644 --- a/packages/frontend/core/src/desktop/pages/auth/oauth-login.tsx +++ b/packages/frontend/core/src/desktop/pages/auth/oauth-login.tsx @@ -1,61 +1,19 @@ import { AuthService } from '@affine/core/modules/cloud'; import { useAsyncNavigate } from '@affine/core/utils'; -import { OAuthProviderType } from '@affine/graphql'; +import type { OAuthProviderType } from '@affine/graphql'; import { useService } from '@toeverything/infra'; import { useEffect } from 'react'; -import { type LoaderFunction, redirect, useLoaderData } from 'react-router'; -import { z } from 'zod'; +import { useLoaderData } from 'react-router'; -import { supportedClient } from './common'; - -const supportedProvider = z.nativeEnum(OAuthProviderType); - -const oauthParameters = z.object({ - provider: supportedProvider, - client: supportedClient, - redirectUri: z.string().optional().nullable(), -}); - -interface LoaderData { +interface OAuthLoginLoaderData { provider: OAuthProviderType; client: string; redirectUri?: string; } -export const loader: LoaderFunction = async ({ request }) => { - const url = new URL(request.url); - const searchParams = url.searchParams; - const provider = searchParams.get('provider'); - const client = searchParams.get('client') ?? 'web'; - const redirectUri = searchParams.get('redirect_uri'); - - // sign out first, web only - if (client === 'web') { - await fetch('/api/auth/sign-out'); - } - - const paramsParseResult = oauthParameters.safeParse({ - provider, - client, - redirectUri, - }); - - if (paramsParseResult.success) { - return { - provider, - client, - redirectUri, - }; - } - - return redirect( - `/sign-in?error=${encodeURIComponent(`Invalid oauth parameters`)}` - ); -}; - export const Component = () => { const auth = useService(AuthService); - const data = useLoaderData() as LoaderData; + const data = useLoaderData() as OAuthLoginLoaderData; const nav = useAsyncNavigate(); diff --git a/packages/frontend/core/src/desktop/pages/onboarding/index.tsx b/packages/frontend/core/src/desktop/pages/onboarding/index.tsx index 810655a088..fdce7fa7ce 100644 --- a/packages/frontend/core/src/desktop/pages/onboarding/index.tsx +++ b/packages/frontend/core/src/desktop/pages/onboarding/index.tsx @@ -1,24 +1,8 @@ import { DesktopApiService } from '@affine/core/modules/desktop-api'; import { useServiceOptional } from '@toeverything/infra'; import { useCallback } from 'react'; -import { redirect } from 'react-router'; import { Onboarding } from '../../../components/affine/onboarding/onboarding'; -import { appConfigStorage } from '../../../components/hooks/use-app-config-storage'; - -/** - * /onboarding page - * - * only for electron - */ -export const loader = () => { - if (!BUILD_CONFIG.isElectron && !appConfigStorage.get('onBoarding')) { - // onboarding is off, redirect to index - return redirect('/'); - } - - return null; -}; export const Component = () => { const desktopApi = useServiceOptional(DesktopApiService); diff --git a/packages/frontend/core/src/desktop/pages/redirect/index.tsx b/packages/frontend/core/src/desktop/pages/redirect/index.tsx index 6e9cc60200..569bbd02ab 100644 --- a/packages/frontend/core/src/desktop/pages/redirect/index.tsx +++ b/packages/frontend/core/src/desktop/pages/redirect/index.tsx @@ -1,53 +1,4 @@ -import { DebugLogger } from '@affine/debug'; -import { type LoaderFunction, Navigate, useLoaderData } from 'react-router'; - -const trustedDomain = [ - 'google.com', - 'stripe.com', - 'github.com', - 'twitter.com', - 'discord.gg', - 'youtube.com', - 't.me', - 'reddit.com', - 'affine.pro', -]; - -const logger = new DebugLogger('redirect_proxy'); - -/** - * /redirect-proxy page - * - * only for web - */ -export const loader: LoaderFunction = async ({ request }) => { - const url = new URL(request.url); - const searchParams = url.searchParams; - const redirectUri = searchParams.get('redirect_uri'); - - if (!redirectUri) { - return { allow: false }; - } - - try { - const target = new URL(redirectUri); - - if ( - target.hostname === window.location.hostname || - trustedDomain.some(domain => - new RegExp(`.?${domain}$`).test(target.hostname) - ) - ) { - location.href = redirectUri; - return { allow: true }; - } - } catch (e) { - logger.error('Failed to parse redirect uri', e); - return { allow: false }; - } - - return { allow: true }; -}; +import { Navigate, useLoaderData } from 'react-router'; export const Component = () => { const { allow } = useLoaderData() as { allow: boolean }; diff --git a/packages/frontend/core/src/desktop/router-loader.tsx b/packages/frontend/core/src/desktop/router-loader.tsx new file mode 100644 index 0000000000..bd7570b617 --- /dev/null +++ b/packages/frontend/core/src/desktop/router-loader.tsx @@ -0,0 +1,219 @@ +import { DebugLogger } from '@affine/debug'; +import { OAuthProviderType } from '@affine/graphql'; +import { FACTORIES } from '@affine/routes'; +import type { LoaderFunction } from 'react-router'; +import { redirect } from 'react-router'; +import { z } from 'zod'; + +import { appConfigStorage } from '../components/hooks/use-app-config-storage'; +import { supportedClient } from './pages/auth/common'; +import type { MagicLinkLoaderData } from './pages/auth/magic-link'; +import type { OAuthCallbackLoaderData } from './pages/auth/oauth-callback'; + +const trustedDomain = [ + 'google.com', + 'stripe.com', + 'github.com', + 'twitter.com', + 'discord.gg', + 'youtube.com', + 't.me', + 'reddit.com', + 'affine.pro', +]; + +const authTypeSchema = z.enum([ + 'onboarding', + 'setPassword', + 'signIn', + 'changePassword', + 'signUp', + 'changeEmail', + 'confirm-change-email', + 'subscription-redirect', + 'verify-email', +]); + +const supportedProvider = z.nativeEnum(OAuthProviderType); + +const oauthParameters = z.object({ + provider: supportedProvider, + client: supportedClient, + redirectUri: z.string().optional().nullable(), +}); + +const redirectLogger = new DebugLogger('redirect_proxy'); + +/** + * /onboarding page + * + * only for electron + */ +export const onboardingLoader = async () => { + if (!BUILD_CONFIG.isElectron && !appConfigStorage.get('onBoarding')) { + // onboarding is off, redirect to index + return redirect('/'); + } + + return null; +}; + +/** + * /redirect-proxy page + * + * only for web + */ +export const redirectLoader: LoaderFunction = async ({ request }) => { + const url = new URL(request.url); + const searchParams = url.searchParams; + const redirectUri = searchParams.get('redirect_uri'); + + if (!redirectUri) { + return { allow: false }; + } + + try { + const target = new URL(redirectUri); + + if ( + target.hostname === window.location.hostname || + trustedDomain.some(domain => + new RegExp(`.?${domain}$`).test(target.hostname) + ) + ) { + location.href = redirectUri; + return { allow: true }; + } + } catch (e) { + redirectLogger.error('Failed to parse redirect uri', e); + return { allow: false }; + } + + return { allow: true }; +}; + +export const authLoader: LoaderFunction = async args => { + if (!args.params.authType) { + return redirect(FACTORIES.notFound()); + } + if (!authTypeSchema.safeParse(args.params.authType).success) { + return redirect(FACTORIES.notFound()); + } + + return null; +}; + +export const magicLinkLoader: LoaderFunction = ({ request }) => { + const url = new URL(request.url); + const params = url.searchParams; + const client = params.get('client'); + const email = params.get('email'); + const token = params.get('token'); + const redirectUri = params.get('redirect_uri'); + + if (!email || !token) { + return redirect(FACTORIES.signIn() + '?error=Invalid magic link'); + } + + const payload: MagicLinkLoaderData = { + email, + token, + redirectUri, + }; + + if (!client || client === 'web') { + return payload; + } + + const clientCheckResult = supportedClient.safeParse(client); + if (!clientCheckResult.success) { + return redirect(FACTORIES.signIn() + '?error=Invalid callback parameters'); + } + + const authParams = new URLSearchParams(); + authParams.set('method', 'magic-link'); + authParams.set('payload', JSON.stringify(payload)); + + return redirect( + `${FACTORIES.openApp({ action: 'url' })}?url=${encodeURIComponent(`${client}://authentication?${authParams.toString()}`)}` + ); +}; + +export const oauthLoginLoader: LoaderFunction = async ({ request }) => { + const url = new URL(request.url); + const searchParams = url.searchParams; + const provider = searchParams.get('provider'); + const client = searchParams.get('client') ?? 'web'; + const redirectUri = searchParams.get('redirect_uri'); + + // sign out first, web only + if (client === 'web') { + await fetch('/api/auth/sign-out'); + } + + const paramsParseResult = oauthParameters.safeParse({ + provider, + client, + redirectUri, + }); + + if (paramsParseResult.success) { + return { + provider, + client, + redirectUri, + }; + } + + return redirect( + `${FACTORIES.signIn()}?error=${encodeURIComponent(`Invalid oauth parameters`)}` + ); +}; + +export const oauthCallbackLoader: LoaderFunction = async ({ request }) => { + const url = new URL(request.url); + const queries = url.searchParams; + const code = queries.get('code'); + let stateStr = queries.get('state') ?? '{}'; + + if (!code || !stateStr) { + return redirect( + `${FACTORIES.signIn()}?error=${encodeURIComponent(`Invalid oauth callback parameters`)}` + ); + } + + try { + const { state, client, provider } = JSON.parse(stateStr); + stateStr = state; + + const payload: OAuthCallbackLoaderData = { + state, + code, + provider, + }; + + if (!client || client === 'web') { + return payload; + } + + const clientCheckResult = supportedClient.safeParse(client); + if (!clientCheckResult.success) { + return redirect( + `${FACTORIES.signIn()}?error=${encodeURIComponent(`Invalid oauth callback parameters`)}` + ); + } + + const authParams = new URLSearchParams(); + authParams.set('method', 'oauth'); + authParams.set('payload', JSON.stringify(payload)); + authParams.set('server', location.origin); + + return redirect( + `${FACTORIES.openApp({ action: 'url' })}?url=${encodeURIComponent(`${client}://authentication?${authParams.toString()}`)}` + ); + } catch { + return redirect( + `${FACTORIES.signIn()}?error=${encodeURIComponent(`Invalid oauth callback parameters`)}` + ); + } +}; diff --git a/packages/frontend/core/src/desktop/router.tsx b/packages/frontend/core/src/desktop/router.tsx index 9a8ca6b80a..443a5161ff 100644 --- a/packages/frontend/core/src/desktop/router.tsx +++ b/packages/frontend/core/src/desktop/router.tsx @@ -1,16 +1,26 @@ -import { wrapCreateBrowserRouterV7 } from '@sentry/react'; +import { FACTORIES, lazy, RELATIVE_ROUTES } from '@affine/routes'; +import { withSentryReactRouterV7Routing } from '@sentry/react'; import { useEffect, useState } from 'react'; -import type { RouteObject } from 'react-router'; +import type { Params } from 'react-router'; import { - createBrowserRouter as reactRouterCreateBrowserRouter, redirect, + Route, + Routes as ReactRouterRoutes, useNavigate, } from 'react-router'; import { AffineErrorComponent } from '../components/affine/affine-error-boundary/affine-error-fallback'; import { NavigateContext } from '../components/hooks/use-navigate-helper'; +import { AppContainer } from './components/app-container'; import { RootWrapper } from './pages/root'; - +import { + authLoader, + magicLinkLoader, + oauthCallbackLoader, + oauthLoginLoader, + onboardingLoader, + redirectLoader, +} from './router-loader'; export function RootRouter() { const navigate = useNavigate(); const [ready, setReady] = useState(false); @@ -27,176 +37,194 @@ export function RootRouter() { ) ); } - -export const topLevelRoutes = [ - { - element: , - errorElement: , - children: [ - { - path: '/', - lazy: async () => await import('./pages/index'), - }, - { - path: '/workspace/:workspaceId/*', - lazy: async () => await import('./pages/workspace/index'), - }, - { - path: '/share/:workspaceId/:pageId', - loader: ({ params }) => { - return redirect(`/workspace/${params.workspaceId}/${params.pageId}`); - }, - }, - { - path: '/404', - lazy: async () => await import('./pages/404'), - }, - { - path: '/expired', - lazy: async () => await import('./pages/expired'), - }, - { - path: '/invite/:inviteId', - lazy: async () => await import('./pages/invite'), - }, - { - path: '/upgrade-success', - lazy: async () => await import('./pages/upgrade-success'), - }, - { - path: '/upgrade-success/team', - lazy: async () => await import('./pages/upgrade-success/team'), - }, - { - path: '/upgrade-success/self-hosted-team', - lazy: async () => - await import('./pages/upgrade-success/self-host-team'), - }, - { - path: '/ai-upgrade-success', - lazy: async () => await import('./pages/ai-upgrade-success'), - }, - { - path: '/onboarding', - lazy: async () => await import('./pages/onboarding'), - }, - { - path: '/redirect-proxy', - lazy: async () => await import('./pages/redirect'), - }, - { - path: '/subscribe', - lazy: async () => await import('./pages/subscribe'), - }, - { - path: '/upgrade-to-team', - lazy: async () => await import('./pages/upgrade-to-team'), - }, - { - path: '/try-cloud', - loader: () => { - return redirect( - `/sign-in?redirect_uri=${encodeURIComponent('/?initCloud=true')}` - ); - }, - }, - { - path: '/theme-editor', - lazy: async () => await import('./pages/theme-editor'), - }, - { - path: '/clipper/import', - lazy: async () => await import('./pages/import-clipper'), - }, - { - path: '/template/import', - lazy: async () => await import('./pages/import-template'), - }, - { - path: '/template/preview', - loader: ({ request }) => { - const url = new URL(request.url); - const workspaceId = url.searchParams.get('workspaceId'); - const docId = url.searchParams.get('docId'); - const templateName = url.searchParams.get('name'); - const templateMode = url.searchParams.get('mode'); - const snapshotUrl = url.searchParams.get('snapshotUrl'); - - return redirect( - `/workspace/${workspaceId}/${docId}?${new URLSearchParams({ - isTemplate: 'true', - templateName: templateName ?? '', - snapshotUrl: snapshotUrl ?? '', - mode: templateMode ?? 'page', - }).toString()}` - ); - }, - }, - { - path: '/auth/:authType', - lazy: async () => - await import(/* webpackChunkName: "auth" */ './pages/auth/auth'), - }, - { - path: '/sign-In', - lazy: async () => - await import(/* webpackChunkName: "auth" */ './pages/auth/sign-in'), - }, - { - path: '/magic-link', - lazy: async () => - await import( - /* webpackChunkName: "auth" */ './pages/auth/magic-link' - ), - }, - { - path: '/oauth/login', - lazy: async () => - await import( - /* webpackChunkName: "auth" */ './pages/auth/oauth-login' - ), - }, - { - path: '/oauth/callback', - lazy: async () => - await import( - /* webpackChunkName: "auth" */ './pages/auth/oauth-callback' - ), - }, - // deprecated, keep for old client compatibility - // TODO(@forehalo): remove - { - path: '/desktop-signin', - lazy: async () => - await import( - /* webpackChunkName: "auth" */ './pages/auth/oauth-login' - ), - }, - // deprecated, keep for old client compatibility - // use '/sign-in' - // TODO(@forehalo): remove - { - path: '/signIn', - lazy: async () => - await import(/* webpackChunkName: "auth" */ './pages/auth/sign-in'), - }, - { - path: '/open-app/:action', - lazy: async () => await import('./pages/open-app'), - }, - { - path: '*', - lazy: async () => await import('./pages/404'), - }, - ], - }, -] satisfies [RouteObject, ...RouteObject[]]; - -const createBrowserRouter = wrapCreateBrowserRouterV7( - reactRouterCreateBrowserRouter +export const Index = lazy(async () => await import('./pages/index')); +export const Workspace = lazy( + async () => await import('./pages/workspace/index') ); -export const router = ( - window.SENTRY_RELEASE ? createBrowserRouter : reactRouterCreateBrowserRouter -)(topLevelRoutes, { - basename: environment.subPath, -}); +export const NotFound = lazy(async () => await import('./pages/404')); +export const Expired = lazy(async () => await import('./pages/expired')); +export const Invite = lazy(async () => await import('./pages/invite')); +export const UpgradeSuccess = lazy( + async () => await import('./pages/upgrade-success') +); +export const UpgradeSuccessTeam = lazy( + async () => await import('./pages/upgrade-success/team') +); +export const UpgradeSuccessSelfHostedTeam = lazy( + async () => await import('./pages/upgrade-success/self-host-team') +); +export const AIUpgradeSuccess = lazy( + async () => await import('./pages/ai-upgrade-success') +); +export const Subscribe = lazy(async () => await import('./pages/subscribe')); +export const UpgradeToTeam = lazy( + async () => await import('./pages/upgrade-to-team') +); +export const ThemeEditor = lazy( + async () => await import('./pages/theme-editor') +); +export const ImportClipper = lazy( + async () => await import('./pages/import-clipper') +); +export const ImportTemplate = lazy( + async () => await import('./pages/import-template') +); +export const OpenApp = lazy(async () => await import('./pages/open-app')); +export const Onboarding = lazy(async () => await import('./pages/onboarding')); +export const Redirect = lazy(async () => await import('./pages/redirect')); +export const Auth = lazy( + async () => await import(/* webpackChunkName: "auth" */ './pages/auth/auth') +); +export const SignIn = lazy( + async () => + await import(/* webpackChunkName: "auth" */ './pages/auth/sign-in') +); +export const MagicLink = lazy( + async () => + await import(/* webpackChunkName: "auth" */ './pages/auth/magic-link') +); +export const OAuthLogin = lazy( + async () => + await import(/* webpackChunkName: "auth" */ './pages/auth/oauth-login') +); +export const OAuthCallback = lazy( + async () => + await import(/* webpackChunkName: "auth" */ './pages/auth/oauth-callback') +); + +// Define routes using JSX syntax for better type checking +export const routes = ( + } + errorElement={} + hydrateFallbackElement={} + > + + } /> + } /> + }) => { + return redirect( + FACTORIES.workspace.doc({ + workspaceId: params.workspaceId ?? '', + docId: params.pageId ?? '', + }) + ); + }} + /> + } /> + } /> + } /> + + } /> + } + /> + } + /> + + } + /> + } + loader={onboardingLoader} + /> + } + loader={redirectLoader} + /> + } /> + } /> + { + return redirect( + FACTORIES.signIn() + + `?redirect_uri=${encodeURIComponent('/?initCloud=true')}` + ); + }} + /> + } /> + } /> + }> + } + /> + { + const url = new URL(request.url); + const workspaceId = url.searchParams.get('workspaceId'); + const docId = url.searchParams.get('docId'); + const templateName = url.searchParams.get('name'); + const templateMode = url.searchParams.get('mode'); + const snapshotUrl = url.searchParams.get('snapshotUrl'); + + return redirect( + FACTORIES.workspace.doc({ + workspaceId: workspaceId ?? '', + docId: docId ?? '', + }) + + `?${new URLSearchParams({ + isTemplate: 'true', + templateName: templateName ?? '', + snapshotUrl: snapshotUrl ?? '', + mode: templateMode ?? 'page', + }).toString()}` + ); + }} + /> + + + } + loader={authLoader} + /> + } /> + } + loader={magicLinkLoader} + /> + }> + } + loader={oauthLoginLoader} + /> + } + loader={oauthCallbackLoader} + /> + + } /> + {/* deprecated, keep for old client compatibility */} + {/* TODO(@forehalo): remove */} + } /> + {/* deprecated, keep for old client compatibility */} + {/* use '/sign-in' */} + {/* TODO(@forehalo): remove */} + } /> + } /> + + +); + +// Apply Sentry wrapper to ReactRouterRoutes if needed +const Routes = window.SENTRY_RELEASE + ? withSentryReactRouterV7Routing(ReactRouterRoutes) + : ReactRouterRoutes; + +// Export Router component - will be wrapped by BrowserRouter in app.tsx +export const Router = () => {routes}; diff --git a/packages/frontend/core/src/mobile/router.tsx b/packages/frontend/core/src/mobile/router.tsx index de098e00e7..d695dc150b 100644 --- a/packages/frontend/core/src/mobile/router.tsx +++ b/packages/frontend/core/src/mobile/router.tsx @@ -1,4 +1,5 @@ import { NavigateContext } from '@affine/core/components/hooks/use-navigate-helper'; +import { ROUTES } from '@affine/routes'; import { wrapCreateBrowserRouterV7 } from '@sentry/react'; import { useEffect, useState } from 'react'; import type { RouteObject } from 'react-router'; @@ -34,58 +35,60 @@ export const topLevelRoutes = [ hydrateFallbackElement: , children: [ { - path: '/', + path: ROUTES.index, lazy: async () => await import('./pages/index'), }, { - path: '/workspace/:workspaceId/*', + path: `${ROUTES.workspace.index}/*`, lazy: async () => await import('./pages/workspace/index'), }, { - path: '/share/:workspaceId/:pageId', + path: ROUTES.share, loader: async ({ params }) => { - return redirect(`/workspace/${params.workspaceId}/${params.pageId}`); + return redirect( + `/workspaces/${params.workspaceId}/docs/${params.pageId}` + ); }, }, { - path: '/404', + path: ROUTES.notFound, lazy: async () => await import('./pages/404'), }, { - path: '/auth/:authType', + path: ROUTES.auth, lazy: async () => await import('./pages/auth'), }, { - path: '/sign-in', + path: ROUTES.signIn, lazy: async () => await import('./pages/sign-in'), }, { - path: '/magic-link', + path: ROUTES.magicLink, lazy: async () => await import( /* webpackChunkName: "auth" */ '@affine/core/desktop/pages/auth/magic-link' ), }, { - path: '/oauth/login', + path: ROUTES.oauth.login, lazy: async () => await import( /* webpackChunkName: "auth" */ '@affine/core/desktop/pages/auth/oauth-login' ), }, { - path: '/oauth/callback', + path: ROUTES.oauth.callback, lazy: async () => await import( /* webpackChunkName: "auth" */ '@affine/core/desktop/pages/auth/oauth-callback' ), }, { - path: '/redirect-proxy', + path: ROUTES.redirect, lazy: async () => await import('@affine/core/desktop/pages/redirect'), }, { - path: '/open-app/:action', + path: ROUTES.openApp, lazy: async () => await import('@affine/core/desktop/pages/open-app'), }, { diff --git a/packages/frontend/routes/routes.json b/packages/frontend/routes/routes.json index 8c58ee25b8..b993bafb59 100644 --- a/packages/frontend/routes/routes.json +++ b/packages/frontend/routes/routes.json @@ -2,6 +2,70 @@ "$schema": "./schema.json", "route": "/", "children": { + "workspace": { + "route": "workspaces/:workspaceId", + "children": { + "all": "all", + "trash": "trash", + "doc": { + "route": "docs/:docId", + "children": { + "attachment": "attachment/:attachmentId" + } + }, + "journals": "journals", + "collections": { + "route": "collections", + "children": { + "collection": ":collectionId" + } + }, + "tags": { + "route": "tags", + "children": { + "tag": ":tagId" + } + }, + "settings": "settings" + } + }, + "share": "share/:workspaceId/:pageId", + "expired": "expired", + "invite": "invite/:inviteId", + "payment": "payment/:plan/success", + "onboarding": "onboarding", + "redirect": "redirect", + "subscribe": "subscribe", + "upgradeToTeam": "upgrade-to-team", + "upgradeSuccess": { + "route": "upgrade-success", + "children": { + "team": "team", + "selfHostTeam": "self-host-team" + } + }, + "aiUpgradeSuccess": "ai-upgrade-success", + "tryCloud": "try-cloud", + "themeEditor": "theme-editor", + "template": { + "route": "template", + "children": { + "import": "import", + "preview": "preview" + } + }, + "auth": "auth/:authType", + "signIn": "sign-in", + "magicLink": "magic-link", + "oauth": { + "route": "oauth", + "children": { + "login": "login", + "callback": "callback" + } + }, + "openApp": "open-app/:action", + "notFound": "404", "admin": { "route": "admin", "children": { diff --git a/packages/frontend/routes/src/routes.ts b/packages/frontend/routes/src/routes.ts index 8a0e586e99..c021368535 100644 --- a/packages/frontend/routes/src/routes.ts +++ b/packages/frontend/routes/src/routes.ts @@ -1,5 +1,29 @@ // #region Path Parameter Types export interface RouteParamsTypes { + workspace: { + index: { workspaceId: string }; + all: { workspaceId: string }; + trash: { workspaceId: string }; + doc: { + index: { workspaceId: string; docId: string }; + attachment: { workspaceId: string; docId: string; attachmentId: string }; + }; + journals: { workspaceId: string }; + collections: { + index: { workspaceId: string }; + collection: { workspaceId: string; collectionId: string }; + }; + tags: { + index: { workspaceId: string }; + tag: { workspaceId: string; tagId: string }; + }; + settings: { workspaceId: string }; + }; + share: { workspaceId: string; pageId: string }; + invite: { inviteId: string }; + payment: { plan: string }; + auth: { authType: string }; + openApp: { action: string }; admin: { settings: { module: { module: string } } }; } // #endregion @@ -7,6 +31,57 @@ export interface RouteParamsTypes { // #region Absolute Paths export const ROUTES = { index: '/', + workspace: { + index: '/workspaces/:workspaceId', + all: '/workspaces/:workspaceId/all', + trash: '/workspaces/:workspaceId/trash', + doc: { + index: '/workspaces/:workspaceId/docs/:docId', + attachment: + '/workspaces/:workspaceId/docs/:docId/attachment/:attachmentId', + }, + journals: '/workspaces/:workspaceId/journals', + collections: { + index: '/workspaces/:workspaceId/collections', + collection: '/workspaces/:workspaceId/collections/:collectionId', + }, + tags: { + index: '/workspaces/:workspaceId/tags', + tag: '/workspaces/:workspaceId/tags/:tagId', + }, + settings: '/workspaces/:workspaceId/settings', + }, + share: '/share/:workspaceId/:pageId', + expired: '/expired', + invite: '/invite/:inviteId', + payment: '/payment/:plan/success', + onboarding: '/onboarding', + redirect: '/redirect', + subscribe: '/subscribe', + upgradeToTeam: '/upgrade-to-team', + upgradeSuccess: { + index: '/upgrade-success', + team: '/upgrade-success/team', + selfHostTeam: '/upgrade-success/self-host-team', + }, + aiUpgradeSuccess: '/ai-upgrade-success', + tryCloud: '/try-cloud', + themeEditor: '/theme-editor', + template: { + index: '/template', + import: '/template/import', + preview: '/template/preview', + }, + auth: '/auth/:authType', + signIn: '/sign-in', + magicLink: '/magic-link', + oauth: { + index: '/oauth', + login: '/oauth/login', + callback: '/oauth/callback', + }, + openApp: '/open-app/:action', + notFound: '/404', admin: { index: '/admin', auth: '/admin/auth', @@ -23,6 +98,39 @@ export const ROUTES = { // #region Relative Paths export const RELATIVE_ROUTES = { index: '/', + workspace: { + index: 'workspaces/:workspaceId', + all: 'all', + trash: 'trash', + doc: { index: 'docs/:docId', attachment: 'attachment/:attachmentId' }, + journals: 'journals', + collections: { index: 'collections', collection: ':collectionId' }, + tags: { index: 'tags', tag: ':tagId' }, + settings: 'settings', + }, + share: 'share/:workspaceId/:pageId', + expired: 'expired', + invite: 'invite/:inviteId', + payment: 'payment/:plan/success', + onboarding: 'onboarding', + redirect: 'redirect', + subscribe: 'subscribe', + upgradeToTeam: 'upgrade-to-team', + upgradeSuccess: { + index: 'upgrade-success', + team: 'team', + selfHostTeam: 'self-host-team', + }, + aiUpgradeSuccess: 'ai-upgrade-success', + tryCloud: 'try-cloud', + themeEditor: 'theme-editor', + template: { index: 'template', import: 'import', preview: 'preview' }, + auth: 'auth/:authType', + signIn: 'sign-in', + magicLink: 'magic-link', + oauth: { index: 'oauth', login: 'login', callback: 'callback' }, + openApp: 'open-app/:action', + notFound: '404', admin: { index: 'admin', auth: 'auth', @@ -38,6 +146,63 @@ export const RELATIVE_ROUTES = { // #region Path Factories const home = () => '/'; +const workspace = (params: { workspaceId: string }) => + `/workspaces/${params.workspaceId}`; +workspace.all = (params: { workspaceId: string }) => + `/workspaces/${params.workspaceId}/all`; +workspace.trash = (params: { workspaceId: string }) => + `/workspaces/${params.workspaceId}/trash`; +const workspace_doc = (params: { workspaceId: string; docId: string }) => + `/workspaces/${params.workspaceId}/docs/${params.docId}`; +workspace_doc.attachment = (params: { + workspaceId: string; + docId: string; + attachmentId: string; +}) => + `/workspaces/${params.workspaceId}/docs/${params.docId}/attachment/${params.attachmentId}`; +workspace.doc = workspace_doc; +workspace.journals = (params: { workspaceId: string }) => + `/workspaces/${params.workspaceId}/journals`; +const workspace_collections = (params: { workspaceId: string }) => + `/workspaces/${params.workspaceId}/collections`; +workspace_collections.collection = (params: { + workspaceId: string; + collectionId: string; +}) => `/workspaces/${params.workspaceId}/collections/${params.collectionId}`; +workspace.collections = workspace_collections; +const workspace_tags = (params: { workspaceId: string }) => + `/workspaces/${params.workspaceId}/tags`; +workspace_tags.tag = (params: { workspaceId: string; tagId: string }) => + `/workspaces/${params.workspaceId}/tags/${params.tagId}`; +workspace.tags = workspace_tags; +workspace.settings = (params: { workspaceId: string }) => + `/workspaces/${params.workspaceId}/settings`; +const share = (params: { workspaceId: string; pageId: string }) => + `/share/${params.workspaceId}/${params.pageId}`; +const expired = () => '/expired'; +const invite = (params: { inviteId: string }) => `/invite/${params.inviteId}`; +const payment = (params: { plan: string }) => `/payment/${params.plan}/success`; +const onboarding = () => '/onboarding'; +const redirect = () => '/redirect'; +const subscribe = () => '/subscribe'; +const upgradeToTeam = () => '/upgrade-to-team'; +const upgradeSuccess = () => '/upgrade-success'; +upgradeSuccess.team = () => '/upgrade-success/team'; +upgradeSuccess.selfHostTeam = () => '/upgrade-success/self-host-team'; +const aiUpgradeSuccess = () => '/ai-upgrade-success'; +const tryCloud = () => '/try-cloud'; +const themeEditor = () => '/theme-editor'; +const template = () => '/template'; +template.import = () => '/template/import'; +template.preview = () => '/template/preview'; +const auth = (params: { authType: string }) => `/auth/${params.authType}`; +const signIn = () => '/sign-in'; +const magicLink = () => '/magic-link'; +const oauth = () => '/oauth'; +oauth.login = () => '/oauth/login'; +oauth.callback = () => '/oauth/callback'; +const openApp = (params: { action: string }) => `/open-app/${params.action}`; +const notFound = () => '/404'; const admin = () => '/admin'; admin.auth = () => '/admin/auth'; admin.setup = () => '/admin/setup'; @@ -49,5 +214,28 @@ admin_settings.module = (params: { module: string }) => admin.settings = admin_settings; admin.about = () => '/admin/about'; admin.notFound = () => '/admin/404'; -export const FACTORIES = { admin, home }; +export const FACTORIES = { + workspace, + share, + expired, + invite, + payment, + onboarding, + redirect, + subscribe, + upgradeToTeam, + upgradeSuccess, + aiUpgradeSuccess, + tryCloud, + themeEditor, + template, + auth, + signIn, + magicLink, + oauth, + openApp, + notFound, + admin, + home, +}; // #endregion diff --git a/yarn.lock b/yarn.lock index 9b387794be..3caf8701b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -400,6 +400,7 @@ __metadata: "@affine/graphql": "workspace:*" "@affine/i18n": "workspace:*" "@affine/nbstore": "workspace:*" + "@affine/routes": "workspace:*" "@affine/templates": "workspace:*" "@affine/track": "workspace:*" "@blocksuite/affine": "workspace:*"