mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-11-29 19:08:33 +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 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;
|
||||||
|
|
||||||
|
|||||||
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 { 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 = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user