fix: detect host (#169)

* fix: detect host

* chore: add test

* chore: lint
This commit is contained in:
Nathan.fooo
2025-11-25 19:25:55 +08:00
committed by GitHub
parent e8c951bd18
commit 613e60f5bd
4 changed files with 175 additions and 53 deletions

View File

@@ -5,7 +5,7 @@
* Import and use these decorators in your stories. * 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 { AppContext } from '@/components/app/app.hooks';
import { AFConfigContext } from '@/components/main/app.hooks'; import { AFConfigContext } from '@/components/main/app.hooks';
@@ -16,12 +16,80 @@ import { mockAFConfigValue, mockAFConfigValueMinimal, mockAppContextValue } from
*/ */
declare global { declare global {
interface Window { 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) => { type CleanupFn = () => void;
window.__STORYBOOK_MOCK_HOSTNAME__ = hostname;
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<CleanupFn | null>(null);
const appliedHostnameRef = useRef<string>();
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 } }) => { return (Story: React.ComponentType, context: { args: { hostname?: string } }) => {
const hostname = context.args.hostname || 'beta.appflowy.cloud'; const hostname = context.args.hostname || 'beta.appflowy.cloud';
// Set mock hostname synchronously before render useHostnameMock(hostname);
mockHostname(hostname);
useEffect(() => {
// Update if hostname changes
mockHostname(hostname);
// Cleanup
return () => {
delete (window as any).__STORYBOOK_MOCK_HOSTNAME__;
};
}, [hostname]);
return <Story />; return <Story />;
}; };
@@ -123,15 +181,7 @@ export const withHostnameAndContexts = (options?: {
return (Story: React.ComponentType, context: { args: { hostname?: string } }) => { return (Story: React.ComponentType, context: { args: { hostname?: string } }) => {
const hostname = context.args.hostname || 'beta.appflowy.cloud'; const hostname = context.args.hostname || 'beta.appflowy.cloud';
// Set mock hostname synchronously before render useHostnameMock(hostname);
mockHostname(hostname);
useEffect(() => {
mockHostname(hostname);
return () => {
delete (window as any).__STORYBOOK_MOCK_HOSTNAME__;
};
}, [hostname]);
const afConfigValue = minimalAFConfig ? mockAFConfigValueMinimal : mockAFConfigValue; const afConfigValue = minimalAFConfig ? mockAFConfigValueMinimal : mockAFConfigValue;

View File

@@ -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');
});
});
});

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { AppContext } from '@/components/app/app.hooks'; import { AppContext } from '@/components/app/app.hooks';
import { AFConfigContext } from '@/components/main/app.hooks'; import { AFConfigContext } from '@/components/main/app.hooks';
import { hostnameArgType, openArgType } from '../../../.storybook/argTypes'; import { hostnameArgType, openArgType } from '../../../.storybook/argTypes';
import { mockHostname } from '../../../.storybook/decorators'; import { useHostnameMock } from '../../../.storybook/decorators';
import { mockAFConfigValue, mockAppContextValue } from '../../../.storybook/mocks'; import { mockAFConfigValue, mockAppContextValue } from '../../../.storybook/mocks';
import UpgradePlan from './UpgradePlan'; import UpgradePlan from './UpgradePlan';
@@ -23,17 +23,7 @@ const meta = {
const hostname = context.args.hostname || 'beta.appflowy.cloud'; const hostname = context.args.hostname || 'beta.appflowy.cloud';
const [open, setOpen] = useState(context.args.open ?? false); const [open, setOpen] = useState(context.args.open ?? false);
// Set mock hostname synchronously before render useHostnameMock(hostname);
mockHostname(hostname);
useEffect(() => {
// Update if hostname changes
mockHostname(hostname);
// Cleanup
return () => {
delete window.__STORYBOOK_MOCK_HOSTNAME__;
};
}, [hostname]);
return ( return (
<AFConfigContext.Provider value={mockAFConfigValue}> <AFConfigContext.Provider value={mockAFConfigValue}>
@@ -96,4 +86,3 @@ export const SelfHosted: Story = {
}, },
}, },
}; };

View File

@@ -1,21 +1,50 @@
/** /**
* Check if the current host is an official AppFlowy host * Check if the current host is an official AppFlowy host by looking at the backend base URL.
* Official hosts are beta.appflowy.cloud, test.appflowy.cloud, and localhost (for development) * Official hosts are beta.appflowy.cloud and test.appflowy.cloud.
* Self-hosted instances are not official hosts * 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 { import { getConfigValue } from '@/utils/runtime-config';
if (typeof window === 'undefined') return false;
// Support Storybook mocking via global variable const OFFICIAL_HOSTNAMES = new Set(['beta.appflowy.cloud', 'test.appflowy.cloud', 'localhost:8000']);
const hostname = (window as Window & { __STORYBOOK_MOCK_HOSTNAME__?: string }).__STORYBOOK_MOCK_HOSTNAME__ || window.location.hostname;
return ( function getBaseUrlHostname(): string | null {
hostname === 'beta.appflowy.cloud' || const baseUrl = getConfigValue('APPFLOWY_BASE_URL', '').trim();
hostname === 'test.appflowy.cloud'
// hostname === 'localhost' || if (!baseUrl) return null;
// hostname === '127.0.0.1' ||
// hostname.startsWith('localhost:') || try {
// hostname.startsWith('127.0.0.1:') 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());
}