diff --git a/.github/workflows/web_ci.yaml b/.github/workflows/web_ci.yaml index c656e760..e1d07a8e 100644 --- a/.github/workflows/web_ci.yaml +++ b/.github/workflows/web_ci.yaml @@ -6,7 +6,7 @@ on: - "develop" - "release/*" env: - NODE_VERSION: "18.16.0" + NODE_VERSION: "20.19.0" PNPM_VERSION: "10.9.0" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -50,6 +50,10 @@ jobs: run: | pnpm run lint + - name: Build Storybook + run: | + pnpm run build-storybook + - name: build and analyze run: | pnpm run analyze >> analyze-size.txt diff --git a/.github/workflows/web_coverage.yaml b/.github/workflows/web_coverage.yaml index 637f0f75..e3d8e360 100644 --- a/.github/workflows/web_coverage.yaml +++ b/.github/workflows/web_coverage.yaml @@ -7,7 +7,7 @@ on: - "develop" - "release/*" env: - NODE_VERSION: "18.16.0" + NODE_VERSION: "20.19.0" PNPM_VERSION: "10.9.0" COVERAGE: "true" CYPRESS_CACHE_FOLDER: ${{ github.workspace }}/.cypress_cache diff --git a/.storybook/GUIDE.md b/.storybook/GUIDE.md new file mode 100644 index 00000000..97f6d089 --- /dev/null +++ b/.storybook/GUIDE.md @@ -0,0 +1,764 @@ +# Storybook Guide for AppFlowy Web + +This guide covers how to write Storybook stories for AppFlowy Web components, including common patterns, solutions to frequent issues, and best practices. + +## Table of Contents + +1. [Setup and Configuration](#setup-and-configuration) +2. [Writing Stories](#writing-stories) +3. [Shared Utilities](#shared-utilities) +4. [Common Patterns](#common-patterns) +5. [Mocking and Context Providers](#mocking-and-context-providers) +6. [Hostname Mocking for Different Scenarios](#hostname-mocking-for-different-scenarios) +7. [CSS and Styling](#css-and-styling) +8. [Common Issues and Solutions](#common-issues-and-solutions) +9. [Examples](#examples) + +## Setup and Configuration + +### Prerequisites + +- Node.js v20.6.0 or higher (required for Storybook) +- All dependencies installed via `pnpm install` + +### Running Storybook + +```bash +pnpm run storybook +``` + +This starts Storybook on `http://localhost:6006` (or next available port). + +### Building Storybook + +```bash +pnpm run build-storybook +``` + +## Writing Stories + +### Basic Story Structure + +A Storybook story file should follow this structure: + +```typescript +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import YourComponent from './YourComponent'; + +const meta = { + title: 'Category/ComponentName', + component: YourComponent, + parameters: { + layout: 'padded', // or 'centered', 'fullscreen' + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + // Component props + }, +}; +``` + +### Story Categories + +Organize stories by feature area: +- `Share/` - Sharing and collaboration features +- `Billing/` - Subscription and billing components +- `Publish/` - Publishing and site management +- `Editor/` - Editor components and features +- `Error Pages/` - Error and not found pages + +## Shared Utilities + +**IMPORTANT:** To avoid code duplication, always use the shared utilities located in `.storybook/` instead of creating your own mocks, decorators, or argTypes. + +### Available Utilities + +#### 1. Shared Mocks (`.storybook/mocks.ts`) + +Pre-configured mock context values to use in your stories: + +```typescript +import { mockAFConfigValue, mockAFConfigValueMinimal, mockAppContextValue } from '../../../.storybook/mocks'; + +// mockAFConfigValue - Full mock with service.getSubscriptionLink +// mockAFConfigValueMinimal - Minimal mock without service (use when service not needed) +// mockAppContextValue - Mock for AppContext with workspace info +``` + +**When to use each:** +- `mockAFConfigValue`: Components that need `service.getSubscriptionLink` (e.g., billing components) +- `mockAFConfigValueMinimal`: Components that only need auth, no service functionality +- `mockAppContextValue`: Components that need workspace information + +#### 2. Shared Decorators (`.storybook/decorators.tsx`) + +Pre-built decorator functions to wrap your components: + +```typescript +import { + withContexts, // AFConfig + AppContext + withContextsMinimal, // AFConfig (minimal) + AppContext + withAFConfig, // Just AFConfig + withAFConfigMinimal, // Just AFConfig (minimal) + withAppContext, // Just AppContext + withHostnameMocking, // Hostname mocking only + withHostnameAndContexts,// Hostname + both contexts + withContainer, // Padded container with max-width + withPadding, // Simple padding wrapper +} from '../../../.storybook/decorators'; +``` + +**Common decorator patterns:** + +```typescript +// For components needing both contexts +decorators: [withContextsMinimal] + +// For hostname-aware components with contexts +decorators: [ + withHostnameAndContexts({ maxWidth: '600px', minimalAFConfig: true }) +] + +// For components needing hostname only (no contexts) +decorators: [ + withHostnameMocking(), + withContainer({ maxWidth: '600px' }) +] +``` + +#### 3. Shared ArgTypes (`.storybook/argTypes.ts`) + +Pre-configured argTypes for common controls: + +```typescript +import { + hostnameArgType, // hostname control + subscriptionPlanArgType, // activeSubscriptionPlan control + activePlanArgType, // activePlan control (alias) + isOwnerArgType, // isOwner boolean control + openArgType, // open boolean control (modals) + hostnameAndSubscriptionArgTypes, // Combined hostname + subscription + ownershipArgTypes, // Combined owner + subscription +} from '../../../.storybook/argTypes'; + +// Usage +argTypes: { + ...hostnameArgType, + ...subscriptionPlanArgType, +} +// or +argTypes: hostnameAndSubscriptionArgTypes, +``` + +### Import Path Patterns + +The import path depends on your file's depth from the `.storybook/` directory: + +```typescript +// From src/components/error/*.stories.tsx (3 levels deep) +import { withContextsMinimal } from '../../../.storybook/decorators'; + +// From src/components/app/share/*.stories.tsx (4 levels deep) +import { withHostnameAndContexts } from '../../../../.storybook/decorators'; + +// From src/components/editor/components/toolbar/selection-toolbar/actions/*.stories.tsx (8 levels deep) +import { hostnameAndSubscriptionArgTypes } from '../../../../../../../.storybook/argTypes'; +``` + +**Tip:** Count the number of `../` by counting how many directories you need to go up to reach `src/`, then add one more to reach the project root where `.storybook/` is located. + +## Common Patterns + +### 1. Component with Context Dependencies + +**Use shared decorators instead of creating your own!** + +If your component uses React Context (like `AppContext`, `AFConfigContext`), use the pre-built decorators: + +```typescript +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { withContextsMinimal } from '../../../.storybook/decorators'; +import YourComponent from './YourComponent'; + +const meta = { + title: 'Category/YourComponent', + component: YourComponent, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + decorators: [withContextsMinimal], +} satisfies Meta; + +export default meta; +type Story = StoryObj; +``` + +**Choose the right decorator:** +- `withContextsMinimal` - Most common, for components needing auth and workspace context +- `withContexts` - When component needs `service.getSubscriptionLink` +- `withAFConfigMinimal` or `withAppContext` - When only one context is needed + +### 2. Router-Dependent Components + +**IMPORTANT**: Do NOT add `BrowserRouter` in your story decorators. The `.storybook/preview.tsx` already provides a global `BrowserRouter` for all stories. Adding another will cause a "Cannot render Router inside another Router" error. + +```typescript +// ✅ CORRECT - No BrowserRouter needed +const meta = { + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +// ❌ WRONG - Don't add BrowserRouter +const meta = { + decorators: [ + (Story) => ( + // ❌ This will cause an error! + + + ), + ], +}; +``` + +## Mocking and Context Providers + +### Required Contexts + +Many AppFlowy components require these contexts: + +1. **AFConfigContext** - Authentication and service configuration +2. **AppContext** - Workspace and app state +3. **I18nextProvider** - Already provided globally in preview.tsx +4. **BrowserRouter** - Already provided globally in preview.tsx + +### Using Shared Mock Contexts + +**DO NOT create new mock contexts!** Use the pre-configured ones from `.storybook/mocks.ts`: + +```typescript +import { + mockAFConfigValue, // Full mock with service + mockAFConfigValueMinimal, // Minimal mock without service + mockAppContextValue // App context with workspace info +} from '../../../.storybook/mocks'; +``` + +These mocks are already configured with all required properties and sensible defaults. If you need custom behavior, you can extend them: + +```typescript +import { mockAppContextValue } from '../../../.storybook/mocks'; + +// Custom mock extending the base +const customMock = { + ...mockAppContextValue, + currentWorkspaceId: 'custom-workspace-id', +}; +``` + +**When to use each mock:** +- `mockAFConfigValueMinimal` - Most components (no service needed) +- `mockAFConfigValue` - Billing/subscription components that need `service.getSubscriptionLink` +- `mockAppContextValue` - Components needing workspace/user information + +## Hostname Mocking for Different Scenarios + +Many components behave differently based on whether they're running on official AppFlowy hosts (`beta.appflowy.cloud`, `test.appflowy.cloud`) or self-hosted instances. + +### How It Works + +The `isOfficialHost()` function in `src/utils/subscription.ts` checks `window.location.hostname`. For Storybook, we mock this using a global variable. + +### Using Shared Hostname Decorators + +**Use the pre-built decorators instead of writing your own!** + +#### Option 1: Hostname with Contexts (Most Common) + +For components that need both hostname mocking and context providers: + +```typescript +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SubscriptionPlan } from '@/application/types'; +import { hostnameAndSubscriptionArgTypes } from '../../../.storybook/argTypes'; +import { withHostnameAndContexts } from '../../../.storybook/decorators'; +import YourComponent from './YourComponent'; + +const meta = { + title: 'Category/YourComponent', + component: YourComponent, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + decorators: [ + withHostnameAndContexts({ maxWidth: '600px', minimalAFConfig: true }), + ], + argTypes: hostnameAndSubscriptionArgTypes, +} satisfies Meta; +``` + +#### Option 2: Hostname Only (No Contexts) + +For components that check hostname but don't need context providers: + +```typescript +import { hostnameArgType } from '../../../.storybook/argTypes'; +import { withHostnameMocking, withContainer } from '../../../.storybook/decorators'; + +const meta = { + title: 'Category/YourComponent', + component: YourComponent, + decorators: [ + withHostnameMocking(), + withContainer({ maxWidth: '600px' }), + ], + argTypes: hostnameArgType, +} satisfies Meta; +``` + +### Story Examples for Different Hosts + +```typescript +export const OfficialHost: Story = { + args: { + hostname: 'beta.appflowy.cloud', + // ... other props + }, + parameters: { + docs: { + description: { + story: 'Behavior on official AppFlowy host (beta.appflowy.cloud)', + }, + }, + }, +}; + +export const SelfHosted: Story = { + args: { + hostname: 'self-hosted.example.com', + // ... other props + }, + parameters: { + docs: { + description: { + story: 'Behavior on self-hosted instance - Pro features enabled by default', + }, + }, + }, +}; + +export const TestHost: Story = { + args: { + hostname: 'test.appflowy.cloud', + // ... other props + }, +}; +``` + +### Custom Decorator (Advanced) + +If you need custom behavior (like modal state management), you can still use the shared `mockHostname` function and argTypes: + +```typescript +import { useEffect, useState } from 'react'; +import { mockHostname } from '../../../.storybook/decorators'; +import { hostnameArgType } from '../../../.storybook/argTypes'; + +const meta = { + decorators: [ + (Story, context) => { + const hostname = context.args.hostname || 'beta.appflowy.cloud'; + mockHostname(hostname); + + useEffect(() => { + mockHostname(hostname); + return () => delete (window as any).__STORYBOOK_MOCK_HOSTNAME__; + }, [hostname]); + + // Your custom logic here... + return ; + }, + ], + argTypes: hostnameArgType, +}; +``` + +## CSS and Styling + +### CSS Import Order + +The `.storybook/preview.tsx` imports styles in the correct order: + +```typescript +import '@/styles/global.css'; // Imports tailwind.css +import '@/styles/app.scss'; // Additional app styles +``` + +**Do not** import CSS files in individual story files. All styles are loaded globally. + +### Tailwind Configuration + +Tailwind is configured to use `#body` as the important selector. The preview decorator wraps all stories in a `div` with `id="body"`, so Tailwind classes will work correctly. + +### Dark Mode + +Dark mode is automatically handled in the preview decorator. The `data-dark-mode` attribute is set on `document.documentElement` based on: +1. `localStorage.getItem('dark-mode')` +2. System preference (`prefers-color-scheme: dark`) + +## Common Issues and Solutions + +### Issue 1: "Cannot render Router inside another Router" + +**Problem**: You added `BrowserRouter` in your story decorator. + +**Solution**: Remove `BrowserRouter` from your story. It's already provided globally in `.storybook/preview.tsx`. + +```typescript +// ❌ Wrong + + + + +// ✅ Correct + +``` + +### Issue 2: "useUserWorkspaceInfo must be used within an AppProvider" + +**Problem**: Component uses `useUserWorkspaceInfo()` or other AppContext hooks but no `AppContext.Provider` is provided. + +**Solution**: Wrap your story in `AppContext.Provider` with mock values: + +```typescript +import { AppContext } from '@/components/app/app.hooks'; + +const mockAppContextValue = { + userWorkspaceInfo: { + selectedWorkspace: { + id: 'storybook-workspace-id', + owner: { uid: 'storybook-uid' }, + }, + workspaces: [], + }, + // ... other required properties +}; + +const meta = { + decorators: [ + (Story) => ( + + + + ), + ], +}; +``` + +### Issue 3: "Cannot redefine property: hostname" + +**Problem**: Trying to mock `window.location.hostname` directly using `Object.defineProperty`. + +**Solution**: Use the global variable approach instead: + +```typescript +// ❌ Wrong - window.location.hostname is not configurable +Object.defineProperty(window.location, 'hostname', { + value: hostname, +}); + +// ✅ Correct - Use global variable +window.__STORYBOOK_MOCK_HOSTNAME__ = hostname; +``` + +### Issue 4: Styles Not Loading + +**Problem**: CSS/Tailwind styles not appearing in Storybook. + +**Solutions**: +1. Ensure Storybook is restarted after configuration changes +2. Check that CSS files are imported in `.storybook/preview.tsx` +3. Verify `postcss.config.cjs` exists and includes Tailwind +4. Check browser console for CSS loading errors +5. Ensure the `#body` element exists (it's added in preview.tsx) + +### Issue 5: Hostname Mocking Not Working + +**Problem**: `isOfficialHost()` returns wrong value in stories. + +**Solutions**: +1. Set `mockHostname()` synchronously before render, not just in `useEffect` +2. Ensure `window.__STORYBOOK_MOCK_HOSTNAME__` is set before component mounts +3. Check that the cleanup function deletes the variable properly + +## Examples + +### Example 1: Component with Hostname and Context (Recommended Pattern) + +Most subscription/billing/sharing components follow this pattern: + +```typescript +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SubscriptionPlan } from '@/application/types'; +import { hostnameAndSubscriptionArgTypes } from '../../../../.storybook/argTypes'; +import { withHostnameAndContexts } from '../../../../.storybook/decorators'; +import { UpgradeBanner } from './UpgradeBanner'; + +const meta = { + title: 'Share/UpgradeBanner', + component: UpgradeBanner, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + decorators: [ + withHostnameAndContexts({ maxWidth: '600px', minimalAFConfig: true }), + ], + argTypes: hostnameAndSubscriptionArgTypes, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const OfficialHostFreePlan: Story = { + args: { + activeSubscriptionPlan: SubscriptionPlan.Free, + hostname: 'beta.appflowy.cloud', + }, + parameters: { + docs: { + description: { + story: 'Shows upgrade banner on official host when user has Free plan', + }, + }, + }, +}; + +export const SelfHostedFreePlan: Story = { + args: { + activeSubscriptionPlan: SubscriptionPlan.Free, + hostname: 'self-hosted.example.com', + }, + parameters: { + docs: { + description: { + story: 'No banner on self-hosted - Pro features enabled by default', + }, + }, + }, +}; +``` + +### Example 2: Error Page Component (Context Only, No Hostname) + +```typescript +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ErrorType } from '@/application/utils/error-utils'; +import { withContextsMinimal } from '../../../.storybook/decorators'; +import RecordNotFound from './RecordNotFound'; + +const meta = { + title: 'Error Pages/RecordNotFound', + component: RecordNotFound, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], + decorators: [withContextsMinimal], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const PageNotFound: Story = { + args: { + error: { + type: ErrorType.PageNotFound, + message: 'Page or resource not found', + statusCode: 404, + }, + }, +}; +``` + +### Example 3: Simple Component (No Context, No Hostname) + +```typescript +import type { Meta, StoryObj } from '@storybook/react-vite'; +import SimpleComponent from './SimpleComponent'; + +const meta = { + title: 'Category/SimpleComponent', + component: SimpleComponent, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + text: 'Hello Storybook', + }, +}; +``` + +### Example 4: Custom Decorator with Shared Utilities + +When you need custom behavior (like managing modal state), use shared mocks and argTypes: + +```typescript +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React, { useEffect, 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 { mockAFConfigValue, mockAppContextValue } from '../../../.storybook/mocks'; +import UpgradePlan from './UpgradePlan'; + +const meta = { + title: 'Billing/UpgradePlan', + component: UpgradePlan, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + decorators: [ + (Story, context) => { + const hostname = context.args.hostname || 'beta.appflowy.cloud'; + const [open, setOpen] = useState(context.args.open ?? false); + + mockHostname(hostname); + + useEffect(() => { + mockHostname(hostname); + return () => delete (window as any).__STORYBOOK_MOCK_HOSTNAME__; + }, [hostname]); + + return ( + + +
+ + setOpen(false) }} /> +
+
+
+ ); + }, + ], + argTypes: { + ...openArgType, + ...hostnameArgType, + }, +} satisfies Meta; +``` + +## Best Practices + +1. **ALWAYS use shared utilities**: Never create your own mocks, decorators, or argTypes when shared ones exist in `.storybook/` +2. **Use the right decorator for your needs**: + - `withContextsMinimal` - Most common (auth + workspace context, no service) + - `withHostnameAndContexts()` - For hostname-aware subscription components + - `withHostnameMocking()` - For hostname-only components (no contexts) +3. **Don't duplicate Router**: Never add `BrowserRouter` in stories (already in preview.tsx) +4. **Import shared utilities with correct relative paths**: Count `../` levels from your file to project root +5. **Use descriptive story names**: Make it clear what scenario the story demonstrates +6. **Add documentation**: Use `parameters.docs.description.story` to explain the story +7. **Test different scenarios**: Create stories for official hosts, self-hosted, different plans, etc. +8. **Use TypeScript**: Leverage `satisfies Meta` for type safety +9. **Follow existing patterns**: Look at existing `.stories.tsx` files for reference +10. **Keep stories focused**: Each story should demonstrate one specific scenario or state + +## Quick Reference + +### Decision Tree: Which Utilities Do I Need? + +``` +Does my component check hostname (isOfficialHost)? +├─ YES: Does it need context providers? +│ ├─ YES: Use withHostnameAndContexts() +│ └─ NO: Use withHostnameMocking() + withContainer() +└─ NO: Does it need context providers? + ├─ YES: Does it need service.getSubscriptionLink? + │ ├─ YES: Use withContexts + │ └─ NO: Use withContextsMinimal + └─ NO: No decorators needed (or just layout decorators) +``` + +### Quick Import Cheatsheet + +```typescript +// Decorators +import { + withContextsMinimal, // ← Most common + withHostnameAndContexts, // ← For hostname-aware components + withHostnameMocking, // ← Hostname only + withContainer, // ← Layout helper +} from '../../../.storybook/decorators'; + +// ArgTypes +import { + hostnameAndSubscriptionArgTypes, // ← Most common combo + hostnameArgType, + subscriptionPlanArgType, +} from '../../../.storybook/argTypes'; + +// Mocks (only if you need custom decorator) +import { + mockAFConfigValueMinimal, // ← Most common + mockAppContextValue, +} from '../../../.storybook/mocks'; +``` + +### Common Patterns at a Glance + +| Component Type | Decorators | ArgTypes | Example | +|---|---|---|---| +| Error pages | `withContextsMinimal` | None | RecordNotFound | +| Subscription UI | `withHostnameAndContexts({ ... })` | `hostnameAndSubscriptionArgTypes` | UpgradeBanner | +| Billing modals | Custom (using shared mocks) | `hostnameArgType + openArgType` | UpgradePlan | +| Settings pages | `withHostnameMocking() + withContainer()` | `hostnameArgType + activePlanArgType` | HomePageSetting | +| Simple components | None | None | SimpleButton | + +## Additional Resources + +- [Storybook Documentation](https://storybook.js.org/docs) +- [Storybook React-Vite Framework](https://storybook.js.org/docs/react/get-started/install) +- [Tailwind CSS Documentation](https://tailwindcss.com/docs) +- **Shared Utilities**: `.storybook/mocks.ts`, `.storybook/decorators.tsx`, `.storybook/argTypes.ts` +- **Example Stories**: All files in `src/**/*.stories.tsx` + +## Troubleshooting + +If you encounter issues not covered here: + +1. Check the browser console for errors +2. **Verify you're using shared utilities** from `.storybook/` instead of creating your own +3. Verify all required contexts are provided (use appropriate decorator) +4. Check import paths - count `../` levels correctly +5. Ensure CSS files are imported in preview.tsx +6. Restart Storybook after configuration changes +7. Check that Node.js version is v20.6.0 or higher +8. Clear Storybook cache: `rm -rf node_modules/.cache/storybook` + +For more help, refer to existing story files in the codebase for examples. + diff --git a/.storybook/argTypes.ts b/.storybook/argTypes.ts new file mode 100644 index 00000000..9df9845a --- /dev/null +++ b/.storybook/argTypes.ts @@ -0,0 +1,106 @@ +/** + * Shared argTypes for Storybook stories + * + * This file contains common argTypes definitions to avoid duplication across story files. + * Import and spread these into your story's argTypes. + */ + +import { SubscriptionPlan } from '@/application/types'; + +/** + * ArgType for hostname control + * Use this for stories that need to test different hosting scenarios + */ +export const hostnameArgType = { + hostname: { + control: 'text', + description: 'Mock hostname to simulate different hosting scenarios (e.g., "beta.appflowy.cloud" for official host, "self-hosted.example.com" for self-hosted)', + table: { + category: 'Testing', + defaultValue: { summary: 'beta.appflowy.cloud' }, + }, + }, +}; + +/** + * ArgType for active subscription plan + * Use this for stories that need to test different subscription states + */ +export const subscriptionPlanArgType = { + activeSubscriptionPlan: { + control: 'select', + options: [SubscriptionPlan.Free, SubscriptionPlan.Pro, null], + description: 'Current subscription plan of the user', + table: { + category: 'Subscription', + type: { summary: 'SubscriptionPlan | null' }, + defaultValue: { summary: 'null' }, + }, + }, +}; + +/** + * ArgType for active plan (shorter name, same as above) + * Alias for subscriptionPlanArgType + */ +export const activePlanArgType = { + activePlan: { + control: 'select', + options: [SubscriptionPlan.Free, SubscriptionPlan.Pro, null], + description: 'Current subscription plan of the user', + table: { + category: 'Subscription', + type: { summary: 'SubscriptionPlan | null' }, + defaultValue: { summary: 'null' }, + }, + }, +}; + +/** + * ArgType for isOwner flag + * Use this for stories that test owner vs non-owner behavior + */ +export const isOwnerArgType = { + isOwner: { + control: 'boolean', + description: 'Whether the current user is the workspace owner', + table: { + category: 'User', + type: { summary: 'boolean' }, + defaultValue: { summary: 'true' }, + }, + }, +}; + +/** + * ArgType for modal open state + * Use this for stories with modal/dialog components + */ +export const openArgType = { + open: { + control: 'boolean', + description: 'Whether the modal/dialog is open', + table: { + category: 'State', + type: { summary: 'boolean' }, + defaultValue: { summary: 'false' }, + }, + }, +}; + +/** + * Combined argTypes for hostname-aware subscription components + * Common pattern for components that check both hostname and subscription + */ +export const hostnameAndSubscriptionArgTypes = { + ...hostnameArgType, + ...subscriptionPlanArgType, +}; + +/** + * Combined argTypes for ownership-aware components + */ +export const ownershipArgTypes = { + ...isOwnerArgType, + ...subscriptionPlanArgType, +}; diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx new file mode 100644 index 00000000..458aa8cb --- /dev/null +++ b/.storybook/decorators.tsx @@ -0,0 +1,174 @@ +/** + * Shared decorators for Storybook stories + * + * This file contains reusable decorator functions to avoid duplication across story files. + * Import and use these decorators in your stories. + */ + +import React, { useEffect } from 'react'; + +import { AppContext } from '@/components/app/app.hooks'; +import { AFConfigContext } from '@/components/main/app.hooks'; +import { mockAFConfigValue, mockAFConfigValueMinimal, mockAppContextValue } from './mocks'; + +/** + * Hostname mocking utilities + */ +declare global { + interface Window { + __STORYBOOK_MOCK_HOSTNAME__?: string; + } +} + +export const mockHostname = (hostname: string) => { + window.__STORYBOOK_MOCK_HOSTNAME__ = hostname; +}; + +/** + * Decorator that provides AFConfigContext with mock values + * Use this for components that need authentication context + */ +export const withAFConfig = (Story: React.ComponentType) => ( + + + +); + +/** + * Decorator that provides AFConfigContext with minimal mock values (no service) + * Use this for components that need auth but not service functionality + */ +export const withAFConfigMinimal = (Story: React.ComponentType) => ( + + + +); + +/** + * Decorator that provides AppContext with mock values + * Use this for components that need workspace and app state + */ +export const withAppContext = (Story: React.ComponentType) => ( + + + +); + +/** + * Decorator that provides both AFConfig and AppContext + * Use this for components that need both contexts + */ +export const withContexts = (Story: React.ComponentType) => ( + + + + + +); + +/** + * Decorator that provides both AFConfig (minimal) and AppContext + * Use this for components that need both contexts but not service + */ +export const withContextsMinimal = (Story: React.ComponentType) => ( + + + + + +); + +/** + * Higher-order decorator for hostname mocking + * Use this for components that behave differently based on hostname (official vs self-hosted) + * + * @example + * decorators: [withHostnameMocking()] + */ +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]); + + return ; + }; +}; + +/** + * Combined decorator: hostname mocking + both contexts + * Common pattern for components that need contexts and hostname mocking + * + * @param options.padding - Add padding around the story (default: '20px') + * @param options.maxWidth - Maximum width of the story container + * @param options.minimalAFConfig - Use minimal AFConfig (no service) + */ +export const withHostnameAndContexts = (options?: { + padding?: string; + maxWidth?: string; + minimalAFConfig?: boolean; +}) => { + const { padding = '20px', maxWidth, minimalAFConfig = false } = 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]); + + const afConfigValue = minimalAFConfig ? mockAFConfigValueMinimal : mockAFConfigValue; + + return ( + + +
+ +
+
+
+ ); + }; +}; + +/** + * Decorator that adds a padded container + * Use this for consistent spacing around components + */ +export const withPadding = (padding = '20px') => { + return (Story: React.ComponentType) => ( +
+ +
+ ); +}; + +/** + * Decorator that adds a padded container with max width + * Use this for components that should be constrained + */ +export const withContainer = (options?: { padding?: string; maxWidth?: string }) => { + const { padding = '20px', maxWidth = '600px' } = options || {}; + + return (Story: React.ComponentType) => ( +
+ +
+ ); +}; diff --git a/.storybook/main.ts b/.storybook/main.ts index d7bada55..cceefa74 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -30,10 +30,6 @@ const config: StorybookConfig = { shouldRemoveUndefinedFromOptional: true, }, }, - // Exclude plugin files and other non-component files from react-docgen - features: { - buildStoriesJson: true, - }, async viteFinal(config) { if (config.resolve) { const existingAlias = Array.isArray(config.resolve.alias) @@ -52,8 +48,38 @@ const config: StorybookConfig = { ]; } + // Ensure CSS processing is enabled // PostCSS config is automatically picked up from postcss.config.cjs - // No need to configure it explicitly, but ensure CSS processing is enabled + // Vite/Storybook will automatically process CSS/SCSS files + if (!config.css) { + config.css = {}; + } + + config.css.modules = config.css.modules || {}; + + // Ensure Material-UI is properly optimized for Storybook + if (!config.optimizeDeps) { + config.optimizeDeps = {}; + } + + config.optimizeDeps.include = [ + ...(config.optimizeDeps.include || []), + '@mui/material/styles', + '@mui/material/styles/createTheme', + '@emotion/react', + '@emotion/styled', + ]; + + // Ensure proper module resolution for MUI v6 + if (!config.resolve) { + config.resolve = {}; + } + if (!config.resolve.dedupe) { + config.resolve.dedupe = []; + } + if (!config.resolve.dedupe.includes('@mui/material')) { + config.resolve.dedupe.push('@mui/material'); + } return config; }, diff --git a/.storybook/mocks.ts b/.storybook/mocks.ts new file mode 100644 index 00000000..22203724 --- /dev/null +++ b/.storybook/mocks.ts @@ -0,0 +1,117 @@ +/** + * Shared mock values for Storybook stories + * + * This file contains common mock context values to avoid duplication across story files. + * Import and use these mocks in your stories instead of creating new ones. + */ + +import { SubscriptionInterval, SubscriptionPlan } from '@/application/types'; + +/** + * Mock AFConfig context value + * Used by components that need authentication and service configuration + */ +export const mockAFConfigValue = { + service: { + getSubscriptionLink: async () => 'https://example.com/subscribe', + }, + isAuthenticated: true, + currentUser: { + email: 'storybook@example.com', + name: 'Storybook User', + uid: 'storybook-uid', + avatar: null, + uuid: 'storybook-uuid', + latestWorkspaceId: 'storybook-workspace-id', + }, + updateCurrentUser: async () => { + // Mock implementation + }, + openLoginModal: () => { + // Mock implementation + }, +}; + +/** + * Minimal mock AFConfig without service + * Use this for components that don't need service functionality + */ +export const mockAFConfigValueMinimal = { + service: undefined, + isAuthenticated: true, + currentUser: { + email: 'storybook@example.com', + name: 'Storybook User', + uid: 'storybook-uid', + avatar: null, + uuid: 'storybook-uuid', + latestWorkspaceId: 'storybook-workspace-id', + }, + updateCurrentUser: async () => { + // Mock implementation + }, + openLoginModal: () => { + // Mock implementation + }, +}; + +/** + * Mock App context value + * Used by components that need workspace and app state + */ +export const mockAppContextValue = { + userWorkspaceInfo: { + selectedWorkspace: { + id: 'storybook-workspace-id', + name: 'Storybook Workspace', + owner: { + uid: 'storybook-uid', + }, + }, + workspaces: [ + { + id: 'storybook-workspace-id', + name: 'Storybook Workspace', + owner: { + uid: 'storybook-uid', + }, + }, + ], + }, + currentWorkspaceId: 'storybook-workspace-id', + outline: [], + rendered: true, + toView: async () => {}, + loadViewMeta: async () => { + throw new Error('Not implemented in story'); + }, + loadView: async () => { + throw new Error('Not implemented in story'); + }, + createRowDoc: async () => { + throw new Error('Not implemented in story'); + }, + appendBreadcrumb: () => {}, + onRendered: () => {}, + updatePage: async () => {}, + addPage: async () => 'test-page-id', + deletePage: async () => {}, + openPageModal: () => {}, + loadViews: async () => [], + setWordCount: () => {}, + uploadFile: async () => { + throw new Error('Not implemented in story'); + }, + eventEmitter: undefined, + awarenessMap: {}, + getSubscriptions: async () => { + return [ + { + plan: SubscriptionPlan.Free, + currency: 'USD', + recurring_interval: SubscriptionInterval.Month, + price_cents: 0, + }, + ]; + }, +}; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index a7fa5b82..748cd138 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -6,8 +6,9 @@ import { BrowserRouter } from 'react-router-dom'; import { AFConfigContext } from '@/components/main/app.hooks'; import '@/i18n/config'; import { i18nInstance } from '@/i18n/config'; -import '@/styles/app.scss'; +// Import styles - order matters: global.css imports tailwind.css import '@/styles/global.css'; +import '@/styles/app.scss'; // Set dark mode attribute early, before React renders if (typeof window !== 'undefined') { diff --git a/cypress/e2e/auth/oauth-login.cy.ts b/cypress/e2e/auth/oauth-login.cy.ts new file mode 100644 index 00000000..4597f2f8 --- /dev/null +++ b/cypress/e2e/auth/oauth-login.cy.ts @@ -0,0 +1,489 @@ +import { v4 as uuidv4 } from 'uuid'; + +/** + * OAuth Login Flow Tests + * + * These tests verify the complete OAuth (Google) login flow and ensure + * the redirect loop fix is working correctly: + * + * 1. New User OAuth Login: Verifies new users complete OAuth flow and redirect to /app without loops + * 2. Existing User OAuth Login: Verifies existing users complete OAuth flow and redirect correctly + * 3. Redirect Loop Prevention: Tests that the fix prevents redirect loops after OAuth callback + * 4. Token Persistence: Verifies token is saved and state syncs correctly after page reload + * + * Key Features Tested: + * - OAuth callback handling with hash parameters + * - Token extraction and saving to localStorage + * - State synchronization after page reload + * - Redirect loop prevention (the fix we implemented) + * - Context initialization timing + */ +describe('OAuth Login Flow', () => { + const baseUrl = Cypress.config('baseUrl') || 'http://localhost:3000'; + const gotrueUrl = Cypress.env('APPFLOWY_GOTRUE_BASE_URL') || 'http://localhost/gotrue'; + const apiUrl = Cypress.env('APPFLOWY_BASE_URL') || 'http://localhost'; + + beforeEach(() => { + // Handle uncaught exceptions + cy.on('uncaught:exception', (err) => { + if ( + err.message.includes('Minified React error') || + err.message.includes('View not found') || + err.message.includes('No workspace or service found') || + err.message.includes('Cannot read properties of undefined') + ) { + return false; + } + return true; + }); + cy.viewport(1280, 720); + + // Clear localStorage before each test + cy.window().then((win) => { + win.localStorage.clear(); + }); + }); + + describe('Google OAuth Login - New User', () => { + it('should complete OAuth login for new user without redirect loop', () => { + const testEmail = `oauth-test-${uuidv4()}@appflowy.io`; + const mockAccessToken = 'mock-oauth-access-token-' + uuidv4(); + const mockRefreshToken = 'mock-oauth-refresh-token-' + uuidv4(); + const mockUserId = uuidv4(); + const mockWorkspaceId = uuidv4(); + + cy.log(`[TEST START] Testing OAuth login for new user: ${testEmail}`); + + // Mock the verifyToken endpoint - new user + cy.intercept('GET', `${apiUrl}/api/user/verify/${mockAccessToken}`, { + statusCode: 200, + body: { + code: 0, + data: { + is_new: true, + }, + message: 'User verified successfully', + }, + }).as('verifyUser'); + + // Mock the refreshToken endpoint + cy.intercept('POST', `${gotrueUrl}/token?grant_type=refresh_token`, { + statusCode: 200, + body: { + access_token: mockAccessToken, + refresh_token: mockRefreshToken, + expires_at: Math.floor(Date.now() / 1000) + 3600, + user: { + id: mockUserId, + email: testEmail, + email_confirmed_at: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + }, + }).as('refreshToken'); + + // Mock getUserWorkspaceInfo endpoint + cy.intercept('GET', `${apiUrl}/api/user/workspace`, { + statusCode: 200, + body: { + code: 0, + data: { + user_profile: { uuid: mockUserId }, + visiting_workspace: { + workspace_id: mockWorkspaceId, + workspace_name: 'My Workspace', + icon: '', + created_at: Date.now().toString(), + database_storage_id: '', + owner_uid: 1, + owner_name: 'Test User', + member_count: 1, + }, + workspaces: [ + { + workspace_id: mockWorkspaceId, + workspace_name: 'My Workspace', + icon: '', + created_at: Date.now().toString(), + database_storage_id: '', + owner_uid: 1, + owner_name: 'Test User', + member_count: 1, + }, + ], + }, + message: 'Success', + }, + }).as('getUserWorkspaceInfo'); + + // Mock getCurrentUser endpoint + cy.intercept('GET', `${apiUrl}/api/user/profile*`, { + statusCode: 200, + body: { + code: 0, + data: { + uid: 1, + uuid: mockUserId, + email: testEmail, + name: 'Test User', + metadata: {}, + encryption_sign: null, + latest_workspace_id: mockWorkspaceId, + updated_at: Date.now(), + }, + message: 'Success', + }, + }).as('getCurrentUser'); + + // Step 1: Simulate OAuth callback by visiting /auth/callback with hash params + // This simulates what happens after Google redirects back + cy.log('[STEP 1] Simulating OAuth callback with hash parameters'); + const callbackUrl = `${baseUrl}/auth/callback#access_token=${mockAccessToken}&expires_at=${Math.floor(Date.now() / 1000) + 3600 + }&expires_in=7200&provider_refresh_token=google_refresh_token&provider_token=google_provider_token&refresh_token=${mockRefreshToken}&token_type=bearer`; + + cy.visit(callbackUrl, { failOnStatusCode: false }); + cy.wait(2000); + + // Step 2: Wait for verifyToken API call (new users redirect immediately, so we might already be on /app) + cy.log('[STEP 2] Waiting for verifyToken API call'); + cy.wait('@verifyUser', { timeout: 10000 }).then((interception) => { + expect(interception.response?.statusCode).to.equal(200); + if (interception.response?.body) { + cy.log(`[API] Verify user response: ${JSON.stringify(interception.response.body)}`); + expect(interception.response.body.data.is_new).to.equal(true); + } + }); + + // Step 3: Wait for refreshToken API call + cy.log('[STEP 3] Waiting for refreshToken API call'); + cy.wait('@refreshToken', { timeout: 10000 }).then((interception) => { + expect(interception.response?.statusCode).to.equal(200); + if (interception.response?.body) { + cy.log(`[API] Refresh token response: ${JSON.stringify(interception.response.body)}`); + } + }); + + // Step 4: Wait for redirect to /app (new users redirect immediately via window.location.replace) + cy.log('[STEP 4] Waiting for redirect to /app'); + cy.url({ timeout: 15000 }).should('include', '/app'); + + // Step 5: Verify token is saved to localStorage + cy.log('[STEP 5] Verifying token is saved to localStorage'); + cy.window().then((win) => { + const token = win.localStorage.getItem('token'); + expect(token).to.exist; + const tokenData = JSON.parse(token || '{}'); + expect(tokenData.access_token).to.equal(mockAccessToken); + expect(tokenData.refresh_token).to.equal(mockRefreshToken); + }); + + // Step 6: Verify we're NOT redirected back to login (redirect loop prevention) + cy.log('[STEP 6] Verifying no redirect loop - should stay on /app'); + cy.wait(3000); // Wait to ensure no redirect happens + cy.url().should('include', '/app'); + cy.url().should('not.include', '/login'); + + // Step 7: Verify workspace info is loaded + cy.log('[STEP 7] Waiting for workspace info to load'); + cy.wait('@getUserWorkspaceInfo', { timeout: 10000 }); + + // Step 8: Verify user is authenticated and app is loaded + cy.log('[STEP 8] Verifying app is fully loaded'); + cy.window().then((win) => { + const token = win.localStorage.getItem('token'); + expect(token).to.exist; + // Verify token is still there after page reload + const tokenData = JSON.parse(token || '{}'); + expect(tokenData.access_token).to.exist; + }); + + cy.log('[STEP 9] OAuth login test for new user completed successfully - no redirect loop detected'); + }); + }); + + describe('Google OAuth Login - Existing User', () => { + it('should complete OAuth login for existing user without redirect loop', () => { + const testEmail = `oauth-existing-${uuidv4()}@appflowy.io`; + const mockAccessToken = 'mock-oauth-access-token-existing-' + uuidv4(); + const mockRefreshToken = 'mock-oauth-refresh-token-existing-' + uuidv4(); + const mockUserId = uuidv4(); + const mockWorkspaceId = uuidv4(); + + cy.log(`[TEST START] Testing OAuth login for existing user: ${testEmail}`); + + // Mock the verifyToken endpoint - existing user + cy.intercept('GET', `${apiUrl}/api/user/verify/${mockAccessToken}`, { + statusCode: 200, + body: { + code: 0, + data: { + is_new: false, + }, + message: 'User verified successfully', + }, + }).as('verifyUser'); + + // Mock the refreshToken endpoint + cy.intercept('POST', `${gotrueUrl}/token?grant_type=refresh_token`, { + statusCode: 200, + body: { + access_token: mockAccessToken, + refresh_token: mockRefreshToken, + expires_at: Math.floor(Date.now() / 1000) + 3600, + user: { + id: mockUserId, + email: testEmail, + email_confirmed_at: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + }, + }).as('refreshToken'); + + // Mock getUserWorkspaceInfo endpoint + cy.intercept('GET', `${apiUrl}/api/user/workspace`, { + statusCode: 200, + body: { + code: 0, + data: { + user_profile: { uuid: mockUserId }, + visiting_workspace: { + workspace_id: mockWorkspaceId, + workspace_name: 'My Workspace', + icon: '', + created_at: Date.now().toString(), + database_storage_id: '', + owner_uid: 1, + owner_name: 'Test User', + member_count: 1, + }, + workspaces: [ + { + workspace_id: mockWorkspaceId, + workspace_name: 'My Workspace', + icon: '', + created_at: Date.now().toString(), + database_storage_id: '', + owner_uid: 1, + owner_name: 'Test User', + member_count: 1, + }, + ], + }, + message: 'Success', + }, + }).as('getUserWorkspaceInfo'); + + // Mock getCurrentUser endpoint + cy.intercept('GET', `${apiUrl}/api/user/profile*`, { + statusCode: 200, + body: { + code: 0, + data: { + uid: 1, + uuid: mockUserId, + email: testEmail, + name: 'Test User', + metadata: {}, + encryption_sign: null, + latest_workspace_id: mockWorkspaceId, + updated_at: Date.now(), + }, + message: 'Success', + }, + }).as('getCurrentUser'); + + // Step 1: Set redirectTo in localStorage (existing users use afterAuth logic) + cy.log('[STEP 1] Setting redirectTo in localStorage'); + cy.window().then((win) => { + win.localStorage.setItem('redirectTo', encodeURIComponent(`${baseUrl}/app`)); + }); + + // Step 2: Simulate OAuth callback + cy.log('[STEP 2] Simulating OAuth callback with hash parameters'); + const callbackUrl = `${baseUrl}/auth/callback#access_token=${mockAccessToken}&expires_at=${Math.floor(Date.now() / 1000) + 3600 + }&expires_in=7200&provider_refresh_token=google_refresh_token&provider_token=google_provider_token&refresh_token=${mockRefreshToken}&token_type=bearer`; + + cy.visit(callbackUrl, { failOnStatusCode: false }); + cy.wait(2000); + + // Step 3: Wait for verifyToken API call + cy.log('[STEP 3] Waiting for verifyToken API call'); + cy.wait('@verifyUser', { timeout: 10000 }).then((interception) => { + cy.log(`[API] Verify user response: ${JSON.stringify(interception.response?.body)}`); + expect(interception.response?.statusCode).to.equal(200); + expect(interception.response?.body.data.is_new).to.equal(false); + }); + + // Step 4: Wait for refreshToken API call + cy.log('[STEP 4] Waiting for refreshToken API call'); + cy.wait('@refreshToken', { timeout: 10000 }); + + // Step 5: Verify token is saved + cy.log('[STEP 5] Verifying token is saved to localStorage'); + cy.window().then((win) => { + const token = win.localStorage.getItem('token'); + expect(token).to.exist; + }); + + // Step 6: Wait for redirect to /app (existing users use afterAuth) + cy.log('[STEP 6] Waiting for redirect to /app via afterAuth'); + cy.url({ timeout: 15000 }).should('include', '/app'); + + // Step 7: Verify we're NOT redirected back to login (redirect loop prevention) + cy.log('[STEP 7] Verifying no redirect loop - should stay on /app'); + cy.wait(5000); // Wait longer to ensure no redirect happens + cy.url().should('include', '/app'); + cy.url().should('not.include', '/login'); + cy.url().should('not.include', 'force=true'); // Should not redirect to /login?force=true + + // Step 8: Verify redirectTo is cleared + cy.log('[STEP 8] Verifying redirectTo is cleared'); + cy.window().then((win) => { + const redirectTo = win.localStorage.getItem('redirectTo'); + expect(redirectTo).to.be.null; + }); + + // Step 9: Verify workspace info is loaded + cy.log('[STEP 9] Waiting for workspace info to load'); + cy.wait('@getUserWorkspaceInfo', { timeout: 10000 }); + + cy.log('[STEP 10] OAuth login test for existing user completed successfully - no redirect loop detected'); + }); + }); + + describe('Redirect Loop Prevention', () => { + it('should prevent redirect loop when token exists but context is not ready', () => { + const mockAccessToken = 'mock-token-' + uuidv4(); + const mockRefreshToken = 'mock-refresh-' + uuidv4(); + const mockUserId = uuidv4(); + const mockWorkspaceId = uuidv4(); + + cy.log('[TEST START] Testing redirect loop prevention'); + + // Mock all required endpoints + cy.intercept('GET', `${apiUrl}/api/user/verify/${mockAccessToken}`, { + statusCode: 200, + body: { + code: 0, + data: { is_new: false }, + message: 'Success', + }, + }).as('verifyUser'); + + cy.intercept('POST', `${gotrueUrl}/token?grant_type=refresh_token`, { + statusCode: 200, + body: { + access_token: mockAccessToken, + refresh_token: mockRefreshToken, + expires_at: Math.floor(Date.now() / 1000) + 3600, + user: { + id: mockUserId, + email: 'test@example.com', + email_confirmed_at: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + }, + }).as('refreshToken'); + + cy.intercept('GET', `${apiUrl}/api/user/workspace`, { + statusCode: 200, + body: { + code: 0, + data: { + user_profile: { uuid: mockUserId }, + visiting_workspace: { + workspace_id: mockWorkspaceId, + workspace_name: 'My Workspace', + icon: '', + created_at: Date.now().toString(), + database_storage_id: '', + owner_uid: 1, + owner_name: 'Test User', + member_count: 1, + }, + workspaces: [ + { + workspace_id: mockWorkspaceId, + workspace_name: 'My Workspace', + icon: '', + created_at: Date.now().toString(), + database_storage_id: '', + owner_uid: 1, + owner_name: 'Test User', + member_count: 1, + }, + ], + }, + message: 'Success', + }, + }).as('getUserWorkspaceInfo'); + + cy.intercept('GET', `${apiUrl}/api/user/profile*`, { + statusCode: 200, + body: { + code: 0, + data: { + uid: 1, + uuid: mockUserId, + email: 'test@example.com', + name: 'Test User', + metadata: {}, + encryption_sign: null, + latest_workspace_id: mockWorkspaceId, + updated_at: Date.now(), + }, + message: 'Success', + }, + }).as('getCurrentUser'); + + // Step 1: Simulate OAuth callback + cy.log('[STEP 1] Simulating OAuth callback'); + const callbackUrl = `${baseUrl}/auth/callback#access_token=${mockAccessToken}&refresh_token=${mockRefreshToken}&expires_at=${Math.floor(Date.now() / 1000) + 3600 + }&token_type=bearer`; + + cy.visit(callbackUrl, { failOnStatusCode: false }); + cy.wait(2000); + + // Step 2: Wait for API calls + cy.wait('@verifyUser'); + cy.wait('@refreshToken'); + + // Step 3: Verify token is saved + cy.log('[STEP 2] Verifying token is saved'); + cy.window().then((win) => { + const token = win.localStorage.getItem('token'); + expect(token).to.exist; + }); + + // Step 4: Wait for redirect to /app + cy.log('[STEP 3] Waiting for redirect to /app'); + cy.url({ timeout: 15000 }).should('include', '/app'); + + // Step 5: Critical test - verify NO redirect loop + // This is the main test for the fix we implemented + cy.log('[STEP 4] Verifying NO redirect loop occurs'); + cy.wait(5000); // Wait to ensure no redirect happens + + // Should stay on /app, not redirect to /login + cy.url().should('include', '/app'); + cy.url().should('not.include', '/login'); + + // Should not have force=true parameter + cy.url().should('not.include', 'force=true'); + + // Token should still be in localStorage + cy.window().then((win) => { + const token = win.localStorage.getItem('token'); + expect(token).to.exist; + const tokenData = JSON.parse(token || '{}'); + expect(tokenData.access_token).to.equal(mockAccessToken); + }); + + cy.log('[STEP 5] Redirect loop prevention test passed - token persisted and no redirect occurred'); + }); + }); +}); + diff --git a/package.json b/package.json index f000ae5c..7b5d2b2f 100644 --- a/package.json +++ b/package.json @@ -278,7 +278,7 @@ "vite-plugin-total-bundle-size": "^1.0.7" }, "engines": { - "node": ">=18.0.0", + "node": ">=20.0.0", "npm": ">=8.0.0", "pnpm": "^10.9.0" }, diff --git a/src/components/app/hooks/useSubscriptionPlan.ts b/src/components/app/hooks/useSubscriptionPlan.ts new file mode 100644 index 00000000..2903e7fb --- /dev/null +++ b/src/components/app/hooks/useSubscriptionPlan.ts @@ -0,0 +1,69 @@ +import { Subscription, SubscriptionPlan } from '@/application/types'; +import { isOfficialHost } from '@/utils/subscription'; +import { useCallback, useEffect, useState } from 'react'; + +/** + * Hook to manage subscription plan loading and Pro feature detection + * Only loads subscription for official hosts (self-hosted instances have Pro features enabled by default) + * + * @param getSubscriptions - Function to fetch subscriptions (can be undefined) + * @returns Object containing activeSubscriptionPlan and isPro flag + */ +export function useSubscriptionPlan( + getSubscriptions?: () => Promise +): { + activeSubscriptionPlan: SubscriptionPlan | null; + isPro: boolean; +} { + const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); + // Pro features are enabled by default on self-hosted instances + const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro || !isOfficialHost(); + + const loadSubscription = useCallback(async () => { + try { + if (!getSubscriptions) { + setActiveSubscriptionPlan(SubscriptionPlan.Free); + return; + } + + const subscriptions = await getSubscriptions(); + + if (!subscriptions || subscriptions.length === 0) { + setActiveSubscriptionPlan(SubscriptionPlan.Free); + return; + } + + const subscription = subscriptions[0]; + + setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); + } catch (e: unknown) { + // Silently handle expected errors (API not initialized, no response data, etc.) + // These are normal scenarios when the service isn't available or there's no subscription data + const error = e as { code?: number; message?: string }; + const isExpectedError = + error?.code === -1 && + (error?.message === 'No response data received' || + error?.message === 'No response received from server' || + error?.message === 'API service not initialized'); + + if (!isExpectedError) { + console.error(e); + } + + setActiveSubscriptionPlan(SubscriptionPlan.Free); + } + }, [getSubscriptions]); + + useEffect(() => { + // Only load subscription for official host (self-hosted instances have Pro features enabled by default) + if (isOfficialHost()) { + void loadSubscription(); + } + }, [loadSubscription]); + + return { + activeSubscriptionPlan, + isPro, + }; +} + diff --git a/src/components/app/landing-pages/ApproveRequestPage.tsx b/src/components/app/landing-pages/ApproveRequestPage.tsx index 091f3cda..17e81fb9 100644 --- a/src/components/app/landing-pages/ApproveRequestPage.tsx +++ b/src/components/app/landing-pages/ApproveRequestPage.tsx @@ -19,6 +19,7 @@ import { NormalModal } from '@/components/_shared/modal'; import { useService } from '@/components/main/app.hooks'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { cn } from '@/lib/utils'; +import { isOfficialHost } from '@/utils/subscription'; const GuestLimitExceededCode = 1070; const REPEAT_REQUEST_CODE = 1122; @@ -53,7 +54,7 @@ function ApproveRequestPage() { const plans = await service.getActiveSubscription(requestInfo.workspace.id); setCurrentPlans(plans); - if (plans.length === 0) { + if (plans.length === 0 && isOfficialHost()) { setUpgradeModalOpen(true); } // eslint-disable-next-line @@ -83,7 +84,10 @@ function ApproveRequestPage() { // eslint-disable-next-line } catch (e: any) { if (e.code === GuestLimitExceededCode) { - setUpgradeModalOpen(true); + if (isOfficialHost()) { + setUpgradeModalOpen(true); + } + return; } @@ -107,6 +111,12 @@ function ApproveRequestPage() { return; } + // This should not be called on self-hosted instances, but adding check as safety + if (!isOfficialHost()) { + // Self-hosted instances have Pro features enabled by default + return; + } + const plan = SubscriptionPlan.Pro; try { diff --git a/src/components/app/layers/AppAuthLayer.tsx b/src/components/app/layers/AppAuthLayer.tsx index 3e8049b6..9cb18ccf 100644 --- a/src/components/app/layers/AppAuthLayer.tsx +++ b/src/components/app/layers/AppAuthLayer.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; -import { invalidToken } from '@/application/session/token'; +import { invalidToken, isTokenValid } from '@/application/session/token'; import { UserWorkspaceInfo } from '@/application/types'; import { AFConfigContext, useService } from '@/components/main/app.hooks'; @@ -15,7 +15,9 @@ interface AppAuthLayerProps { // Handles user authentication, workspace info, and service setup // Does not depend on workspace ID - establishes basic authentication context export const AppAuthLayer: React.FC = ({ children }) => { - const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated; + const context = useContext(AFConfigContext); + const isAuthenticated = context?.isAuthenticated; + const location = useLocation(); const service = useService(); const navigate = useNavigate(); const params = useParams(); @@ -68,16 +70,48 @@ export const AppAuthLayer: React.FC = ({ children }) => { ); // If the user is not authenticated, log out the user + // But check localStorage token first to avoid redirect loops after login + // This handles the race condition where token exists but React state hasn't synced yet useEffect(() => { - if (!isAuthenticated) { - logout(); + // Don't check if we're already on login/auth pages + if (location.pathname === '/login' || location.pathname.startsWith('/auth/callback')) { + return; } - }, [isAuthenticated, logout]); + + // Wait a bit for context to be ready (in case it's still initializing) + // This prevents false negatives when context hasn't loaded yet + const timeoutId = setTimeout(() => { + // Check token on mount and whenever isAuthenticated changes + const hasToken = isTokenValid(); + + // Only redirect if both conditions are true: + // 1. Context exists and says not authenticated (don't redirect if context is undefined/not ready) + // 2. No token exists in localStorage + // This prevents redirect loops when token exists but state hasn't synced + if (context && !isAuthenticated && !hasToken) { + logout(); + } + // If token exists but isAuthenticated is false/undefined, wait for state to sync + // The state will sync via: + // - Initial state in AppConfig (isTokenValid() on mount) + // - SESSION_VALID event listener + // - Storage event listener (for cross-tab updates) + // - Sync effect in AppConfig that checks token on mount + }, 50); // Small delay to allow context to initialize + + return () => clearTimeout(timeoutId); + }, [isAuthenticated, location.pathname, logout, context]); // Load user workspace info on mount useEffect(() => { - void loadUserWorkspaceInfo(); - }, [loadUserWorkspaceInfo]); + if (!isAuthenticated) { + return; + } + + void loadUserWorkspaceInfo().catch((e) => { + console.error('[AppAuthLayer] Failed to load workspace info:', e); + }); + }, [loadUserWorkspaceInfo, isAuthenticated]); // Context value for authentication layer const authContextValue: AuthInternalContextType = useMemo( diff --git a/src/components/app/publish-manage/HomePageSetting.stories.tsx b/src/components/app/publish-manage/HomePageSetting.stories.tsx new file mode 100644 index 00000000..0c7f8461 --- /dev/null +++ b/src/components/app/publish-manage/HomePageSetting.stories.tsx @@ -0,0 +1,165 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { SubscriptionPlan, View } from '@/application/types'; +import { activePlanArgType, hostnameArgType, isOwnerArgType } from '../../../../.storybook/argTypes'; +import { withHostnameMocking, withContainer } from '../../../../.storybook/decorators'; +import HomePageSetting from './HomePageSetting'; + +const mockView: View = { + view_id: 'test-view-id', + name: 'Test Page', + icon: { ty: 0, value: '📄' }, + layout: 0, + created_at: Date.now(), + modified_at: Date.now(), + created_by: 'test-user', + parent_view_id: '', + parent_id: '', + data: {}, +}; + +const mockPublishViews: View[] = [ + mockView, + { + ...mockView, + view_id: 'test-view-id-2', + name: 'Another Page', + }, +]; + +const meta = { + title: 'Publish/HomePageSetting', + component: HomePageSetting, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + decorators: [ + withHostnameMocking(), + withContainer({ maxWidth: '600px' }), + ], + argTypes: { + ...activePlanArgType, + ...isOwnerArgType, + ...hostnameArgType, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const OfficialHostFreePlan: Story = { + args: { + activePlan: SubscriptionPlan.Free, + isOwner: true, + hostname: 'beta.appflowy.cloud', + homePage: undefined, + publishViews: mockPublishViews, + onRemoveHomePage: async () => { + // Mock implementation + }, + onUpdateHomePage: async () => { + // Mock implementation + }, + }, + parameters: { + docs: { + description: { + story: 'Shows upgrade button on official host when user has Free plan and wants to set homepage', + }, + }, + }, +}; + +export const OfficialHostProPlan: Story = { + args: { + activePlan: SubscriptionPlan.Pro, + isOwner: true, + hostname: 'beta.appflowy.cloud', + homePage: mockView, + publishViews: mockPublishViews, + onRemoveHomePage: async () => { + // Mock implementation + }, + onUpdateHomePage: async () => { + // Mock implementation + }, + }, + parameters: { + docs: { + description: { + story: 'Shows homepage selector on official host when user has Pro plan', + }, + }, + }, +}; + +export const SelfHostedFreePlan: Story = { + args: { + activePlan: SubscriptionPlan.Free, + isOwner: true, + hostname: 'self-hosted.example.com', + homePage: undefined, + publishViews: mockPublishViews, + onRemoveHomePage: async () => { + // Mock implementation + }, + onUpdateHomePage: async () => { + // Mock implementation + }, + }, + parameters: { + docs: { + description: { + story: 'On self-hosted instances, homepage feature is available even with Free plan (Pro features enabled by default). No upgrade button shown.', + }, + }, + }, +}; + +export const SelfHostedProPlan: Story = { + args: { + activePlan: SubscriptionPlan.Pro, + isOwner: true, + hostname: 'self-hosted.example.com', + homePage: mockView, + publishViews: mockPublishViews, + onRemoveHomePage: async () => { + // Mock implementation + }, + onUpdateHomePage: async () => { + // Mock implementation + }, + }, + parameters: { + docs: { + description: { + story: 'On self-hosted instances, homepage feature works the same as Pro plan (Pro features enabled by default)', + }, + }, + }, +}; + +export const NotOwner: Story = { + args: { + activePlan: SubscriptionPlan.Free, + isOwner: false, + hostname: 'beta.appflowy.cloud', + homePage: mockView, + publishViews: mockPublishViews, + onRemoveHomePage: async () => { + // Mock implementation + }, + onUpdateHomePage: async () => { + // Mock implementation + }, + }, + parameters: { + docs: { + description: { + story: 'Non-owner users can view but not modify homepage settings', + }, + }, + }, +}; + diff --git a/src/components/app/publish-manage/HomePageSetting.tsx b/src/components/app/publish-manage/HomePageSetting.tsx index ee3477fb..7f5eb627 100644 --- a/src/components/app/publish-manage/HomePageSetting.tsx +++ b/src/components/app/publish-manage/HomePageSetting.tsx @@ -4,6 +4,7 @@ import { ReactComponent as SearchIcon } from '@/assets/icons/search.svg'; import { ReactComponent as UpgradeIcon } from '@/assets/icons/upgrade.svg'; import { Popover } from '@/components/_shared/popover'; import PageIcon from '@/components/_shared/view-icon/PageIcon'; +import { isOfficialHost } from '@/utils/subscription'; import { Button, CircularProgress, IconButton, OutlinedInput, Tooltip } from '@mui/material'; import React, { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -48,6 +49,11 @@ function HomePageSetting({ }, [setSearch, isOwner]); if (activePlan && activePlan !== SubscriptionPlan.Pro) { + // Only show upgrade button on official hosts (self-hosted instances have Pro features enabled by default) + if (!isOfficialHost()) { + return null; + } + return ( + setOpen(false), onOpen: () => setOpen(true) }} /> + + + + ); + }, + ], + argTypes: { + ...openArgType, + ...hostnameArgType, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const OfficialHost: Story = { + args: { + open: true, + hostname: 'beta.appflowy.cloud', + }, + parameters: { + docs: { + description: { + story: 'Shows both Free and Pro plans on official host (beta.appflowy.cloud). Users can upgrade to Pro plan.', + }, + }, + }, +}; + +export const SelfHosted: Story = { + args: { + open: true, + hostname: 'self-hosted.example.com', + }, + parameters: { + docs: { + description: { + story: 'On self-hosted instances, Pro plan is hidden. Pro features are enabled by default without subscription.', + }, + }, + }, +}; + diff --git a/src/components/billing/UpgradePlan.tsx b/src/components/billing/UpgradePlan.tsx index 310259dc..8a6508ac 100644 --- a/src/components/billing/UpgradePlan.tsx +++ b/src/components/billing/UpgradePlan.tsx @@ -1,10 +1,11 @@ import { Subscription, SubscriptionInterval, SubscriptionPlan } from '@/application/types'; -import { useAppHandlers, useCurrentWorkspaceId } from '@/components/app/app.hooks'; -import CancelSubscribe from '@/components/billing/CancelSubscribe'; -import { useService } from '@/components/main/app.hooks'; import { NormalModal } from '@/components/_shared/modal'; import { notify } from '@/components/_shared/notify'; import { ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; +import { useAppHandlers, useCurrentWorkspaceId } from '@/components/app/app.hooks'; +import CancelSubscribe from '@/components/billing/CancelSubscribe'; +import { useService } from '@/components/main/app.hooks'; +import { isOfficialHost } from '@/utils/subscription'; import { Button } from '@mui/material'; import React, { useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -68,6 +69,12 @@ function UpgradePlan({ open, onClose, onOpen }: { open: boolean; onClose: () => const handleUpgrade = useCallback(async () => { if (!service || !currentWorkspaceId) return; + + if (!isOfficialHost()) { + // Self-hosted instances have Pro features enabled by default + return; + } + const plan = SubscriptionPlan.Pro; try { @@ -87,7 +94,7 @@ function UpgradePlan({ open, onClose, onOpen }: { open: boolean; onClose: () => }, [open, loadSubscription]); const plans = useMemo(() => { - return [ + const allPlans = [ { key: SubscriptionPlan.Free, name: t('subscribe.free'), @@ -124,6 +131,13 @@ function UpgradePlan({ open, onClose, onOpen }: { open: boolean; onClose: () => ], }, ]; + + // Filter out Pro plan if not on official host (self-hosted instances don't need subscription) + if (!isOfficialHost()) { + return allPlans.filter((plan) => plan.key !== SubscriptionPlan.Pro); + } + + return allPlans; }, [t, interval]); return ( diff --git a/src/components/database/components/property/select/OptionMenu.tsx b/src/components/database/components/property/select/OptionMenu.tsx index 5f99df46..ff630ad8 100644 --- a/src/components/database/components/property/select/OptionMenu.tsx +++ b/src/components/database/components/property/select/OptionMenu.tsx @@ -1,20 +1,20 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SelectOption, SelectOptionColor, useDatabaseContext } from '@/application/database-yjs'; import { useDeleteSelectOption, useUpdateSelectOption } from '@/application/database-yjs/dispatch'; -import { SubscriptionPlan } from '@/application/types'; import { ReactComponent as DeleteIcon } from '@/assets/icons/delete.svg'; import { ColorTile } from '@/components/_shared/color-picker'; +import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; import { SelectOptionColorMap } from '@/components/database/components/cell/cell.const'; import { DropdownMenu, + DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, - DropdownMenuContent, } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -36,30 +36,7 @@ function OptionMenu({ const onDelete = useDeleteSelectOption(fieldId); const onUpdate = useUpdateSelectOption(fieldId); - const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro; - - const loadSubscription = useCallback(async () => { - try { - const subscriptions = await getSubscriptions?.(); - - if (!subscriptions || subscriptions.length === 0) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - return; - } - - const subscription = subscriptions[0]; - - setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); - } catch (e) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - console.error(e); - } - }, [getSubscriptions]); - - useEffect(() => { - void loadSubscription(); - }, [loadSubscription]); + const { isPro } = useSubscriptionPlan(getSubscriptions); const colors = useMemo(() => { const baseColors = [ @@ -280,10 +257,10 @@ function OptionMenu({ className='mx-1.5 mb-1.5' {...(editing ? { - onPointerMove: (e) => e.preventDefault(), - onPointerEnter: (e) => e.preventDefault(), - onPointerLeave: (e) => e.preventDefault(), - } + onPointerMove: (e) => e.preventDefault(), + onPointerEnter: (e) => e.preventDefault(), + onPointerLeave: (e) => e.preventDefault(), + } : undefined)} > diff --git a/src/components/editor/components/toolbar/block-controls/CalloutTextColor.tsx b/src/components/editor/components/toolbar/block-controls/CalloutTextColor.tsx index c053c9f3..bd23aef7 100644 --- a/src/components/editor/components/toolbar/block-controls/CalloutTextColor.tsx +++ b/src/components/editor/components/toolbar/block-controls/CalloutTextColor.tsx @@ -4,10 +4,10 @@ import { useSlateStatic } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; -import { SubscriptionPlan } from '@/application/types'; import { ReactComponent as ChevronRightIcon } from '@/assets/icons/alt_arrow_right.svg'; import { ColorTile } from '@/components/_shared/color-picker'; import { Origins, Popover } from '@/components/_shared/popover'; +import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; import { CalloutNode } from '@/components/editor/editor.type'; import { useEditorContext } from '@/components/editor/EditorContext'; import { Button } from '@/components/ui/button'; @@ -45,30 +45,7 @@ function CalloutTextColor({ node, onSelectColor }: { node: CalloutNode; onSelect const [originalColor, setOriginalColor] = useState(node.data?.textColor || ''); const selectedColor = originalColor || ColorEnum.BlockTextColor10; - const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro; - - const loadSubscription = useCallback(async () => { - try { - const subscriptions = await getSubscriptions?.(); - - if (!subscriptions || subscriptions.length === 0) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - return; - } - - const subscription = subscriptions[0]; - - setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); - } catch (e) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - console.error(e); - } - }, [getSubscriptions]); - - useEffect(() => { - void loadSubscription(); - }, [loadSubscription]); + const { isPro } = useSubscriptionPlan(getSubscriptions); const builtinColors = useMemo(() => { const proPalette = [ diff --git a/src/components/editor/components/toolbar/block-controls/Color.tsx b/src/components/editor/components/toolbar/block-controls/Color.tsx index ebfcf349..05fcd0aa 100644 --- a/src/components/editor/components/toolbar/block-controls/Color.tsx +++ b/src/components/editor/components/toolbar/block-controls/Color.tsx @@ -13,6 +13,7 @@ import { useEditorContext } from '@/components/editor/EditorContext'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { ColorEnum, renderColor } from '@/utils/color'; +import { isOfficialHost } from '@/utils/subscription'; const origins: Origins = { anchorOrigin: { @@ -37,7 +38,8 @@ function Color({ node, onSelectColor }: { node: BlockNode; onSelectColor: () => const selectedColor = originalColor || (hasNonTransparentBg ? ColorEnum.Tint10 : ''); const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro; + // Pro features are enabled by default on self-hosted instances + const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro || !isOfficialHost(); const loadSubscription = useCallback(async () => { try { diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx index b497632c..4c519f46 100644 --- a/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx +++ b/src/components/editor/components/toolbar/selection-toolbar/actions/BgColor.tsx @@ -5,10 +5,10 @@ import { useSlateStatic } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; import { EditorMarkFormat } from '@/application/slate-yjs/types'; -import { SubscriptionPlan } from '@/application/types'; import { ReactComponent as ColorSvg } from '@/assets/icons/text_highlight.svg'; import { ColorTile } from '@/components/_shared/color-picker'; import { CustomColorPicker } from '@/components/_shared/color-picker/CustomColorPicker'; +import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; import { useSelectionToolbarContext } from '@/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks'; import { useEditorContext } from '@/components/editor/EditorContext'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -43,28 +43,9 @@ function BgColor({ const recentColorToSave = useRef(null); const initialColor = useRef(null); - const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro; + const { isPro } = useSubscriptionPlan(getSubscriptions); const maxCustomColors = isPro ? 9 : 4; - const loadSubscription = useCallback(async () => { - try { - const subscriptions = await getSubscriptions?.(); - - if (!subscriptions || subscriptions.length === 0) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - return; - } - - const subscription = subscriptions[0]; - - setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); - } catch (e) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - console.error(e); - } - }, [getSubscriptions]); - const isCustomColor = useCallback((color: string) => { return color.startsWith('#') || color.startsWith('0x'); }, []); @@ -101,10 +82,6 @@ function BgColor({ } }, [isOpen, visible]); - useEffect(() => { - void loadSubscription(); - }, [loadSubscription]); - const getRawColorValue = useCallback( (color: string) => { if (isCustomColor(color)) { @@ -187,109 +164,109 @@ function BgColor({ const builtinColors = useMemo(() => { return isPro ? [ - { - label: t('colors.default'), - color: '', - }, - { - label: t('colors.mauve'), - color: 'bg-color-14', - }, - { - label: t('colors.lavender'), - color: 'bg-color-15', - }, - { - label: t('colors.lilac'), - color: 'bg-color-16', - }, - { - label: t('colors.mallow'), - color: 'bg-color-17', - }, - { - label: t('colors.camellia'), - color: 'bg-color-18', - }, - { - label: t('colors.rose'), - color: 'bg-color-1', - }, - { - label: t('colors.papaya'), - color: 'bg-color-2', - }, - { - label: t('colors.mango'), - color: 'bg-color-4', - }, - { - label: t('colors.lemon'), - color: 'bg-color-5', - }, - { - label: t('colors.olive'), - color: 'bg-color-6', - }, - { - label: t('colors.grass'), - color: 'bg-color-8', - }, - { - label: t('colors.jade'), - color: 'bg-color-10', - }, - { - label: t('colors.azure'), - color: 'bg-color-12', - }, - { - label: t('colors.iron'), - color: 'bg-color-20', - }, - ] + { + label: t('colors.default'), + color: '', + }, + { + label: t('colors.mauve'), + color: 'bg-color-14', + }, + { + label: t('colors.lavender'), + color: 'bg-color-15', + }, + { + label: t('colors.lilac'), + color: 'bg-color-16', + }, + { + label: t('colors.mallow'), + color: 'bg-color-17', + }, + { + label: t('colors.camellia'), + color: 'bg-color-18', + }, + { + label: t('colors.rose'), + color: 'bg-color-1', + }, + { + label: t('colors.papaya'), + color: 'bg-color-2', + }, + { + label: t('colors.mango'), + color: 'bg-color-4', + }, + { + label: t('colors.lemon'), + color: 'bg-color-5', + }, + { + label: t('colors.olive'), + color: 'bg-color-6', + }, + { + label: t('colors.grass'), + color: 'bg-color-8', + }, + { + label: t('colors.jade'), + color: 'bg-color-10', + }, + { + label: t('colors.azure'), + color: 'bg-color-12', + }, + { + label: t('colors.iron'), + color: 'bg-color-20', + }, + ] : [ - { - label: t('colors.default'), - color: '', - }, - { - label: t('colors.mauve'), - color: 'bg-color-14', - }, - { - label: t('colors.lilac'), - color: 'bg-color-16', - }, - { - label: t('colors.camellia'), - color: 'bg-color-18', - }, - { - label: t('colors.papaya'), - color: 'bg-color-2', - }, - { - label: t('colors.mango'), - color: 'bg-color-4', - }, - { - label: t('colors.olive'), - color: 'bg-color-6', - }, - { - label: t('colors.grass'), - color: 'bg-color-8', - }, - { - label: t('colors.jade'), - color: 'bg-color-10', - }, - { - label: t('colors.azure'), - color: 'bg-color-12', - }, - ]; + { + label: t('colors.default'), + color: '', + }, + { + label: t('colors.mauve'), + color: 'bg-color-14', + }, + { + label: t('colors.lilac'), + color: 'bg-color-16', + }, + { + label: t('colors.camellia'), + color: 'bg-color-18', + }, + { + label: t('colors.papaya'), + color: 'bg-color-2', + }, + { + label: t('colors.mango'), + color: 'bg-color-4', + }, + { + label: t('colors.olive'), + color: 'bg-color-6', + }, + { + label: t('colors.grass'), + color: 'bg-color-8', + }, + { + label: t('colors.jade'), + color: 'bg-color-10', + }, + { + label: t('colors.azure'), + color: 'bg-color-12', + }, + ]; }, [isPro, t]); const handleOpen = useCallback(() => { diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.stories.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.stories.tsx new file mode 100644 index 00000000..7a53929a --- /dev/null +++ b/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.stories.tsx @@ -0,0 +1,144 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { SubscriptionPlan } from '@/application/types'; +import { hostnameAndSubscriptionArgTypes } from '../../../../../../../.storybook/argTypes'; + +// Component to demonstrate Pro feature availability +// Note: This story uses a local isOfficialHost function, so it doesn't need to mock window.location.hostname +const ProFeatureDemo = ({ hostname, activeSubscriptionPlan }: { hostname: string; activeSubscriptionPlan: SubscriptionPlan | null }) => { + + // Simulate the logic from TextColor component + const isOfficialHost = () => { + return hostname === 'beta.appflowy.cloud' || hostname === 'test.appflowy.cloud'; + }; + + const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro || !isOfficialHost(); + const maxCustomColors = isPro ? 9 : 4; + + return ( +
+

Text Color Component - Pro Features

+
+ Hostname: {hostname} +
+
+ Active Plan: {activeSubscriptionPlan || 'None'} +
+
+ Is Official Host: {isOfficialHost() ? 'Yes' : 'No (Self-hosted)'} +
+
+ Pro Features Enabled: {isPro ? '✅ Yes' : '❌ No'} +
+
+ Max Custom Colors: {maxCustomColors} +
+ {isPro + ? 'Pro feature: Users can create up to 9 custom colors' + : 'Free plan: Users can create up to 4 custom colors'} +
+
+
+ {!isOfficialHost() && ( +
+ ℹ️ Self-hosted: Pro features are enabled by default. Users get 9 custom colors without a Pro subscription. +
+ )} + {isOfficialHost() && !isPro && ( +
+ ℹ️ Official Host: Users need a Pro subscription to access 9 custom colors. Free plan users get 4 custom colors. +
+ )} + {isOfficialHost() && isPro && ( +
+ ℹ️ Official Host: User has Pro subscription, so they get 9 custom colors. +
+ )} +
+
+ ); +}; + +const meta = { + title: 'Editor/TextColor - Pro Features', + component: ProFeatureDemo, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: hostnameAndSubscriptionArgTypes, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const OfficialHostFreePlan: Story = { + args: { + hostname: 'beta.appflowy.cloud', + activeSubscriptionPlan: SubscriptionPlan.Free, + }, + parameters: { + docs: { + description: { + story: 'On official host with Free plan: Users get 4 custom colors (Free plan limit)', + }, + }, + }, +}; + +export const OfficialHostProPlan: Story = { + args: { + hostname: 'beta.appflowy.cloud', + activeSubscriptionPlan: SubscriptionPlan.Pro, + }, + parameters: { + docs: { + description: { + story: 'On official host with Pro plan: Users get 9 custom colors (Pro feature)', + }, + }, + }, +}; + +export const SelfHostedFreePlan: Story = { + args: { + hostname: 'self-hosted.example.com', + activeSubscriptionPlan: SubscriptionPlan.Free, + }, + parameters: { + docs: { + description: { + story: 'On self-hosted instance with Free plan: Users get 9 custom colors (Pro features enabled by default)', + }, + }, + }, +}; + +export const SelfHostedProPlan: Story = { + args: { + hostname: 'self-hosted.example.com', + activeSubscriptionPlan: SubscriptionPlan.Pro, + }, + parameters: { + docs: { + description: { + story: 'On self-hosted instance: Pro features are always enabled regardless of subscription plan', + }, + }, + }, +}; + +export const TestHostFreePlan: Story = { + args: { + hostname: 'test.appflowy.cloud', + activeSubscriptionPlan: SubscriptionPlan.Free, + }, + parameters: { + docs: { + description: { + story: 'On official test host with Free plan: Users get 4 custom colors (Free plan limit)', + }, + }, + }, +}; + diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx index c3dab47c..53f9424b 100644 --- a/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx +++ b/src/components/editor/components/toolbar/selection-toolbar/actions/TextColor.tsx @@ -5,11 +5,11 @@ import { useSlateStatic } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; import { EditorMarkFormat } from '@/application/slate-yjs/types'; -import { SubscriptionPlan } from '@/application/types'; import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg'; import { ReactComponent as ColorSvg } from '@/assets/icons/text_color.svg'; import { ColorTile } from '@/components/_shared/color-picker'; import { CustomColorPicker } from '@/components/_shared/color-picker/CustomColorPicker'; +import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; import { useSelectionToolbarContext } from '@/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks'; import { useEditorContext } from '@/components/editor/EditorContext'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -43,28 +43,9 @@ function TextColor({ const recentColorToSave = useRef(null); const initialColor = useRef(null); - const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro; + const { isPro } = useSubscriptionPlan(getSubscriptions); const maxCustomColors = isPro ? 9 : 4; - const loadSubscription = useCallback(async () => { - try { - const subscriptions = await getSubscriptions?.(); - - if (!subscriptions || subscriptions.length === 0) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - return; - } - - const subscription = subscriptions[0]; - - setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); - } catch (e) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - console.error(e); - } - }, [getSubscriptions]); - const isCustomColor = useCallback((color: string) => { return color.startsWith('#') || color.startsWith('0x'); }, []); @@ -101,10 +82,6 @@ function TextColor({ } }, [isOpen, visible]); - useEffect(() => { - void loadSubscription(); - }, [loadSubscription]); - const getRawColorValue = useCallback( (color: string) => { if (isCustomColor(color)) { @@ -187,109 +164,109 @@ function TextColor({ const builtinColors = useMemo(() => { return isPro ? [ - { - label: t('colors.default'), - color: '', - }, - { - label: t('colors.mauve'), - color: 'text-color-14', - }, - { - label: t('colors.lavender'), - color: 'text-color-15', - }, - { - label: t('colors.lilac'), - color: 'text-color-16', - }, - { - label: t('colors.mallow'), - color: 'text-color-17', - }, - { - label: t('colors.camellia'), - color: 'text-color-18', - }, - { - label: t('colors.rose'), - color: 'text-color-1', - }, - { - label: t('colors.papaya'), - color: 'text-color-2', - }, - { - label: t('colors.mango'), - color: 'text-color-4', - }, - { - label: t('colors.lemon'), - color: 'text-color-5', - }, - { - label: t('colors.olive'), - color: 'text-color-6', - }, - { - label: t('colors.grass'), - color: 'text-color-8', - }, - { - label: t('colors.jade'), - color: 'text-color-10', - }, - { - label: t('colors.azure'), - color: 'text-color-12', - }, - { - label: t('colors.iron'), - color: 'text-color-20', - }, - ] + { + label: t('colors.default'), + color: '', + }, + { + label: t('colors.mauve'), + color: 'text-color-14', + }, + { + label: t('colors.lavender'), + color: 'text-color-15', + }, + { + label: t('colors.lilac'), + color: 'text-color-16', + }, + { + label: t('colors.mallow'), + color: 'text-color-17', + }, + { + label: t('colors.camellia'), + color: 'text-color-18', + }, + { + label: t('colors.rose'), + color: 'text-color-1', + }, + { + label: t('colors.papaya'), + color: 'text-color-2', + }, + { + label: t('colors.mango'), + color: 'text-color-4', + }, + { + label: t('colors.lemon'), + color: 'text-color-5', + }, + { + label: t('colors.olive'), + color: 'text-color-6', + }, + { + label: t('colors.grass'), + color: 'text-color-8', + }, + { + label: t('colors.jade'), + color: 'text-color-10', + }, + { + label: t('colors.azure'), + color: 'text-color-12', + }, + { + label: t('colors.iron'), + color: 'text-color-20', + }, + ] : [ - { - label: t('colors.default'), - color: '', - }, - { - label: t('colors.mauve'), - color: 'text-color-14', - }, - { - label: t('colors.lilac'), - color: 'text-color-16', - }, - { - label: t('colors.camellia'), - color: 'text-color-18', - }, - { - label: t('colors.papaya'), - color: 'text-color-2', - }, - { - label: t('colors.mango'), - color: 'text-color-4', - }, - { - label: t('colors.olive'), - color: 'text-color-6', - }, - { - label: t('colors.grass'), - color: 'text-color-8', - }, - { - label: t('colors.jade'), - color: 'text-color-10', - }, - { - label: t('colors.azure'), - color: 'text-color-12', - }, - ]; + { + label: t('colors.default'), + color: '', + }, + { + label: t('colors.mauve'), + color: 'text-color-14', + }, + { + label: t('colors.lilac'), + color: 'text-color-16', + }, + { + label: t('colors.camellia'), + color: 'text-color-18', + }, + { + label: t('colors.papaya'), + color: 'text-color-2', + }, + { + label: t('colors.mango'), + color: 'text-color-4', + }, + { + label: t('colors.olive'), + color: 'text-color-6', + }, + { + label: t('colors.grass'), + color: 'text-color-8', + }, + { + label: t('colors.jade'), + color: 'text-color-10', + }, + { + label: t('colors.azure'), + color: 'text-color-12', + }, + ]; }, [isPro, t]); const handleOpen = useCallback(() => { diff --git a/src/components/error/RecordNotFound.stories.tsx b/src/components/error/RecordNotFound.stories.tsx index d782d9d3..a9167357 100644 --- a/src/components/error/RecordNotFound.stories.tsx +++ b/src/components/error/RecordNotFound.stories.tsx @@ -1,66 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import React from 'react'; import { ErrorType } from '@/application/utils/error-utils'; -import { AppContext } from '@/components/app/app.hooks'; -import { AFConfigContext } from '@/components/main/app.hooks'; +import { withContextsMinimal } from '../../../.storybook/decorators'; import RecordNotFound from './RecordNotFound'; -const mockAppContext = { - currentWorkspaceId: 'test-workspace-id', - outline: [], - rendered: true, - // eslint-disable-next-line @typescript-eslint/no-empty-function - toView: async () => {}, - loadViewMeta: async () => { - throw new Error('Not implemented in story'); - }, - loadView: async () => { - throw new Error('Not implemented in story'); - }, - createRowDoc: async () => { - throw new Error('Not implemented in story'); - }, - // eslint-disable-next-line @typescript-eslint/no-empty-function - appendBreadcrumb: () => {}, - // eslint-disable-next-line @typescript-eslint/no-empty-function - onRendered: () => {}, - // eslint-disable-next-line @typescript-eslint/no-empty-function - updatePage: async () => {}, - addPage: async () => 'test-page-id', - // eslint-disable-next-line @typescript-eslint/no-empty-function - deletePage: async () => {}, - // eslint-disable-next-line @typescript-eslint/no-empty-function - openPageModal: () => {}, - loadViews: async () => [], - // eslint-disable-next-line @typescript-eslint/no-empty-function - setWordCount: () => {}, - uploadFile: async () => { - throw new Error('Not implemented in story'); - }, - eventEmitter: undefined, - awarenessMap: {}, -}; - -const mockAFConfigValue = { - service: undefined, - isAuthenticated: true, - currentUser: { - email: 'storybook@example.com', - name: 'Storybook User', - uid: 'storybook-uid', - avatar: null, - uuid: 'storybook-uuid', - latestWorkspaceId: 'storybook-workspace-id', - }, - updateCurrentUser: async () => { - // Mock implementation - }, - openLoginModal: () => { - // Mock implementation - }, -}; - const meta = { title: 'Error Pages/RecordNotFound', component: RecordNotFound, @@ -68,15 +11,7 @@ const meta = { layout: 'fullscreen', }, tags: ['autodocs'], - decorators: [ - (Story: React.ComponentType) => ( - - - - - - ), - ], + decorators: [withContextsMinimal], } satisfies Meta; export default meta; diff --git a/src/components/main/AppConfig.tsx b/src/components/main/AppConfig.tsx index 57772286..000e8cec 100644 --- a/src/components/main/AppConfig.tsx +++ b/src/components/main/AppConfig.tsx @@ -54,7 +54,6 @@ function AppConfig({ children }: { children: React.ReactNode }) { useEffect(() => { return on(EventType.SESSION_VALID, () => { - console.debug('session valid'); setIsAuthenticated(true); }); }, []); @@ -84,9 +83,20 @@ function AppConfig({ children }: { children: React.ReactNode }) { window.removeEventListener('storage', handleStorageChange); }; }, []); + + // Sync authentication state whenever isAuthenticated changes + // This handles cases where token was saved but state wasn't updated (e.g., after page reload) + // This prevents redirect loops after OAuth callback or page reload + useEffect(() => { + const hasToken = isTokenValid(); + + // If token exists but state says not authenticated, sync the state + if (hasToken && !isAuthenticated) { + setIsAuthenticated(true); + } + }, [isAuthenticated]); useEffect(() => { return on(EventType.SESSION_INVALID, () => { - console.debug('session invalid'); setIsAuthenticated(false); }); }, []); @@ -99,36 +109,36 @@ function AppConfig({ children }: { children: React.ReactNode }) { async (detectedTimezone: string) => { if (!isAuthenticated || !service || hasCheckedTimezone) return; - try { - // Get current user profile to check if timezone is already set - const user = await service.getCurrentUser(); - const currentMetadata = user.metadata || {}; + try { + // Get current user profile to check if timezone is already set + const user = await service.getCurrentUser(); + const currentMetadata = user.metadata || {}; - // Check if user has timezone metadata - const existingTimezone = currentMetadata[MetadataKey.Timezone] as UserTimezone | undefined; + // Check if user has timezone metadata + const existingTimezone = currentMetadata[MetadataKey.Timezone] as UserTimezone | undefined; - // Only set timezone if it's not already set (None in Rust = no timezone field or null) - if (!existingTimezone || existingTimezone.timezone === null || existingTimezone.timezone === undefined) { - // Create the UserTimezone struct format matching Rust - const timezoneData = createInitialTimezone(detectedTimezone); + // Only set timezone if it's not already set (None in Rust = no timezone field or null) + if (!existingTimezone || existingTimezone.timezone === null || existingTimezone.timezone === undefined) { + // Create the UserTimezone struct format matching Rust + const timezoneData = createInitialTimezone(detectedTimezone); - const metadata = { - [MetadataKey.Timezone]: timezoneData, - }; + const metadata = { + [MetadataKey.Timezone]: timezoneData, + }; - await service.updateUserProfile(metadata); - console.debug('Initial timezone set in user profile:', timezoneData); - } else { - console.debug('User timezone already set, skipping update:', existingTimezone); - } + await service.updateUserProfile(metadata); + console.debug('Initial timezone set in user profile:', timezoneData); + } else { + console.debug('User timezone already set, skipping update:', existingTimezone); + } - setHasCheckedTimezone(true); - } catch (e) { - console.error('Failed to check/update timezone:', e); - // Still mark as checked to avoid repeated attempts - setHasCheckedTimezone(true); - } - }, [isAuthenticated, service, hasCheckedTimezone]); + setHasCheckedTimezone(true); + } catch (e) { + console.error('Failed to check/update timezone:', e); + // Still mark as checked to avoid repeated attempts + setHasCheckedTimezone(true); + } + }, [isAuthenticated, service, hasCheckedTimezone]); // Detect timezone once on mount const _timezoneInfo = useUserTimezone({ diff --git a/src/components/main/AppTheme.tsx b/src/components/main/AppTheme.tsx index 4fa5a7b9..4029bb37 100644 --- a/src/components/main/AppTheme.tsx +++ b/src/components/main/AppTheme.tsx @@ -1,5 +1,4 @@ -import createTheme from '@mui/material/styles/createTheme'; -import ThemeProvider from '@mui/material/styles/ThemeProvider'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; import React, { useMemo } from 'react'; import { I18nextProvider } from 'react-i18next'; diff --git a/src/components/view-meta/CoverPopover.tsx b/src/components/view-meta/CoverPopover.tsx index 9d4d8958..808830a8 100644 --- a/src/components/view-meta/CoverPopover.tsx +++ b/src/components/view-meta/CoverPopover.tsx @@ -1,10 +1,11 @@ -import { CoverType, SubscriptionPlan, ViewMetaCover } from '@/application/types'; +import { CoverType, ViewMetaCover } from '@/application/types'; +import { EmbedLink, TAB_KEY, TabOption, Unsplash, UploadImage, UploadPopover } from '@/components/_shared/image-upload'; import { useAppHandlers, useAppViewId, useOpenModalViewId } from '@/components/app/app.hooks'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { EmbedLink, Unsplash, UploadPopover, TabOption, TAB_KEY, UploadImage } from '@/components/_shared/image-upload'; +import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; +import { GradientEnum } from '@/utils/color'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import Colors from './CoverColors'; -import { GradientEnum } from '@/utils/color'; function CoverPopover({ coverValue, @@ -25,30 +26,7 @@ function CoverPopover({ const modalViewId = useOpenModalViewId(); const viewId = modalViewId || appViewId; - const [activeSubscriptionPlan, setActiveSubscriptionPlan] = useState(null); - const isPro = activeSubscriptionPlan === SubscriptionPlan.Pro; - - const loadSubscription = useCallback(async () => { - try { - const subscriptions = await getSubscriptions?.(); - - if (!subscriptions || subscriptions.length === 0) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - return; - } - - const subscription = subscriptions[0]; - - setActiveSubscriptionPlan(subscription?.plan || SubscriptionPlan.Free); - } catch (e) { - setActiveSubscriptionPlan(SubscriptionPlan.Free); - console.error(e); - } - }, [getSubscriptions]); - - useEffect(() => { - void loadSubscription(); - }, [loadSubscription]); + const { isPro } = useSubscriptionPlan(getSubscriptions); const tabOptions: TabOption[] = useMemo(() => { return [ diff --git a/src/utils/subscription.ts b/src/utils/subscription.ts new file mode 100644 index 00000000..832dad19 --- /dev/null +++ b/src/utils/subscription.ts @@ -0,0 +1,21 @@ +/** + * 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 + */ +export function isOfficialHost(): boolean { + if (typeof window === 'undefined') return false; + + // Support Storybook mocking via global variable + const hostname = (window as Window & { __STORYBOOK_MOCK_HOSTNAME__?: string }).__STORYBOOK_MOCK_HOSTNAME__ || window.location.hostname; + + 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:') + ); +} + diff --git a/tailwind.config.cjs b/tailwind.config.cjs index aba5f43a..dbfaafdf 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -6,6 +6,7 @@ module.exports = { content: [ './index.html', './src/**/*.{js,ts,jsx,tsx}', + './.storybook/**/*.{js,ts,jsx,tsx}', './node_modules/react-tailwindcss-datepicker/dist/index.esm.js', ], important: '#body', diff --git a/tsconfig.web.json b/tsconfig.web.json index ad6f63d3..8d1687e5 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -9,6 +9,7 @@ "**/*.stories.ts", "**/*.stories.tsx", "**/__tests__/**", + ".storybook/**/*", "cypress/**/*", "dist", "coverage"