mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-11-30 03:18:02 +08:00
fix: detect host (#169)
* fix: detect host * chore: add test * chore: lint
This commit is contained in:
@@ -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<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 } }) => {
|
||||
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 <Story />;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
54
cypress/e2e/app/upgrade-plan.cy.ts
Normal file
54
cypress/e2e/app/upgrade-plan.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<AFConfigContext.Provider value={mockAFConfigValue}>
|
||||
@@ -96,4 +86,3 @@ export const SelfHosted: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user