diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx index 458aa8cb..26d17c9c 100644 --- a/.storybook/decorators.tsx +++ b/.storybook/decorators.tsx @@ -5,7 +5,7 @@ * Import and use these decorators in your stories. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { AppContext } from '@/components/app/app.hooks'; import { AFConfigContext } from '@/components/main/app.hooks'; @@ -16,12 +16,80 @@ import { mockAFConfigValue, mockAFConfigValueMinimal, mockAppContextValue } from */ declare global { interface Window { - __STORYBOOK_MOCK_HOSTNAME__?: string; + __APP_CONFIG__?: { + APPFLOWY_BASE_URL?: string; + APPFLOWY_GOTRUE_BASE_URL?: string; + APPFLOWY_WS_BASE_URL?: string; + }; } } -export const mockHostname = (hostname: string) => { - window.__STORYBOOK_MOCK_HOSTNAME__ = hostname; +type CleanupFn = () => void; + +const normalizeHostnameToBaseUrl = (hostname: string): string => { + const trimmed = hostname.trim(); + + if (!trimmed) { + return ''; + } + + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + + return `https://${trimmed}`; +}; + +export const mockHostname = (hostname: string): CleanupFn => { + if (typeof window === 'undefined') { + return () => {}; + } + + const previousConfig = window.__APP_CONFIG__ ? { ...window.__APP_CONFIG__ } : undefined; + const formattedHostname = hostname?.trim(); + + if (!formattedHostname) { + delete window.__APP_CONFIG__; + return () => { + if (previousConfig) { + window.__APP_CONFIG__ = previousConfig; + } + }; + } + + const baseUrl = normalizeHostnameToBaseUrl(formattedHostname); + + window.__APP_CONFIG__ = { + ...(window.__APP_CONFIG__ ?? {}), + APPFLOWY_BASE_URL: baseUrl, + }; + + return () => { + if (previousConfig) { + window.__APP_CONFIG__ = previousConfig; + } else { + delete window.__APP_CONFIG__; + } + }; +}; + +export const useHostnameMock = (hostname: string) => { + const cleanupRef = useRef(null); + const appliedHostnameRef = useRef(); + + if (appliedHostnameRef.current !== hostname) { + cleanupRef.current?.(); + cleanupRef.current = mockHostname(hostname); + appliedHostnameRef.current = hostname; + } + + useEffect(() => { + return () => { + cleanupRef.current?.(); + cleanupRef.current = null; + appliedHostnameRef.current = undefined; + }; + }, []); }; /** @@ -89,17 +157,7 @@ export const withHostnameMocking = () => { return (Story: React.ComponentType, context: { args: { hostname?: string } }) => { const hostname = context.args.hostname || 'beta.appflowy.cloud'; - // Set mock hostname synchronously before render - mockHostname(hostname); - - useEffect(() => { - // Update if hostname changes - mockHostname(hostname); - // Cleanup - return () => { - delete (window as any).__STORYBOOK_MOCK_HOSTNAME__; - }; - }, [hostname]); + useHostnameMock(hostname); return ; }; @@ -123,15 +181,7 @@ export const withHostnameAndContexts = (options?: { return (Story: React.ComponentType, context: { args: { hostname?: string } }) => { const hostname = context.args.hostname || 'beta.appflowy.cloud'; - // Set mock hostname synchronously before render - mockHostname(hostname); - - useEffect(() => { - mockHostname(hostname); - return () => { - delete (window as any).__STORYBOOK_MOCK_HOSTNAME__; - }; - }, [hostname]); + useHostnameMock(hostname); const afConfigValue = minimalAFConfig ? mockAFConfigValueMinimal : mockAFConfigValue; diff --git a/cypress/e2e/app/upgrade-plan.cy.ts b/cypress/e2e/app/upgrade-plan.cy.ts new file mode 100644 index 00000000..cff960b5 --- /dev/null +++ b/cypress/e2e/app/upgrade-plan.cy.ts @@ -0,0 +1,54 @@ +import { AuthTestUtils } from '../../support/auth-utils'; +import { SidebarSelectors, WorkspaceSelectors } from '../../support/selectors'; +import { generateRandomEmail } from '../../support/test-config'; +import enTranslations from '../../../src/@types/translations/en.json'; + +const UPGRADE_MENU_LABEL = enTranslations.subscribe?.changePlan ?? 'Upgrade to Pro Plan'; + +describe('Workspace Upgrade Entry', () => { + let testEmail: string; + + beforeEach(() => { + testEmail = generateRandomEmail(); + + cy.on('uncaught:exception', (err: Error) => { + if ( + err.message.includes('No workspace or service found') || + err.message.includes('View not found') || + err.message.includes('WebSocket') || + err.message.includes('connection') || + err.message.includes('Failed to load models') || + err.message.includes('Minified React error') || + err.message.includes('ResizeObserver loop') || + err.message.includes('Non-Error promise rejection') + ) { + return false; + } + return true; + }); + }); + + it('shows Upgrade to Pro Plan for workspace owners', function () { + cy.visit('/login', { failOnStatusCode: false }); + cy.wait(2000); + + const authUtils = new AuthTestUtils(); + authUtils.signInWithTestUrl(testEmail).then(() => { + cy.url().should('include', '/app'); + + SidebarSelectors.pageHeader().should('be.visible', { timeout: 30000 }); + + WorkspaceSelectors.dropdownTrigger().should('be.visible', { timeout: 30000 }).click(); + + WorkspaceSelectors.dropdownContent() + .should('be.visible', { timeout: 10000 }) + .within(() => { + // Prove the workspace menu actually opened by checking additional menu items + cy.contains('Create workspace').should('be.visible'); + cy.contains(UPGRADE_MENU_LABEL).should('be.visible'); + }); + + cy.screenshot('workspace-upgrade-menu'); + }); + }); +}); diff --git a/src/components/billing/UpgradePlan.stories.tsx b/src/components/billing/UpgradePlan.stories.tsx index 5db1efc0..7b083ca7 100644 --- a/src/components/billing/UpgradePlan.stories.tsx +++ b/src/components/billing/UpgradePlan.stories.tsx @@ -1,10 +1,10 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { AppContext } from '@/components/app/app.hooks'; import { AFConfigContext } from '@/components/main/app.hooks'; import { hostnameArgType, openArgType } from '../../../.storybook/argTypes'; -import { mockHostname } from '../../../.storybook/decorators'; +import { useHostnameMock } from '../../../.storybook/decorators'; import { mockAFConfigValue, mockAppContextValue } from '../../../.storybook/mocks'; import UpgradePlan from './UpgradePlan'; @@ -23,17 +23,7 @@ const meta = { const hostname = context.args.hostname || 'beta.appflowy.cloud'; const [open, setOpen] = useState(context.args.open ?? false); - // Set mock hostname synchronously before render - mockHostname(hostname); - - useEffect(() => { - // Update if hostname changes - mockHostname(hostname); - // Cleanup - return () => { - delete window.__STORYBOOK_MOCK_HOSTNAME__; - }; - }, [hostname]); + useHostnameMock(hostname); return ( @@ -96,4 +86,3 @@ export const SelfHosted: Story = { }, }, }; - diff --git a/src/utils/subscription.ts b/src/utils/subscription.ts index 832dad19..67ce8477 100644 --- a/src/utils/subscription.ts +++ b/src/utils/subscription.ts @@ -1,21 +1,50 @@ /** - * Check if the current host is an official AppFlowy host - * Official hosts are beta.appflowy.cloud, test.appflowy.cloud, and localhost (for development) - * Self-hosted instances are not official hosts + * Check if the current host is an official AppFlowy host by looking at the backend base URL. + * Official hosts are beta.appflowy.cloud and test.appflowy.cloud. + * Include localhost:8000 to cover the default dev backend when APPFLOWY_BASE_URL isn't updated. + * Self-hosted instances are not official hosts. */ -export function isOfficialHost(): boolean { - if (typeof window === 'undefined') return false; +import { getConfigValue } from '@/utils/runtime-config'; - // Support Storybook mocking via global variable - const hostname = (window as Window & { __STORYBOOK_MOCK_HOSTNAME__?: string }).__STORYBOOK_MOCK_HOSTNAME__ || window.location.hostname; +const OFFICIAL_HOSTNAMES = new Set(['beta.appflowy.cloud', 'test.appflowy.cloud', 'localhost:8000']); - return ( - hostname === 'beta.appflowy.cloud' || - hostname === 'test.appflowy.cloud' - // hostname === 'localhost' || - // hostname === '127.0.0.1' || - // hostname.startsWith('localhost:') || - // hostname.startsWith('127.0.0.1:') - ); +function getBaseUrlHostname(): string | null { + const baseUrl = getConfigValue('APPFLOWY_BASE_URL', '').trim(); + + if (!baseUrl) return null; + + try { + return new URL(baseUrl).hostname; + } catch (primaryError) { + // Allow hostnames without a protocol, e.g. "beta.appflowy.cloud" + try { + return new URL(`https://${baseUrl}`).hostname; + } catch (secondaryError) { + console.warn('Invalid APPFLOWY_BASE_URL provided:', secondaryError); + return null; + } + } } +function isOfficialHostname(hostname: string | undefined | null): boolean { + if (!hostname) return false; + return OFFICIAL_HOSTNAMES.has(hostname); +} + +function resolveHostname(): string | null { + const baseUrlHostname = getBaseUrlHostname(); + + if (baseUrlHostname) { + return baseUrlHostname; + } + + if (typeof window === 'undefined') { + return null; + } + + return window.location.hostname; +} + +export function isOfficialHost(): boolean { + return isOfficialHostname(resolveHostname()); +}