mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
feat(core): add allowGuestDemoWorkspace flag to force login (#12779)
https://github.com/user-attachments/assets/41a659c9-6def-4492-be8e-5910eb148d6f This PR enforces login‑first access (#8716) by disabling or enabling the guest demo workspace via Admin Server Client Page and redirecting unauthenticated users straight to `/sign‑in`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a configuration option to control whether guest users can create demo workspaces. * Updated server and client interfaces, GraphQL schema, and queries to support the new guest demo workspace flag. * **Bug Fixes** * Improved sign-out behavior to redirect users appropriately based on guest demo workspace permissions. * Enhanced navigation flow to handle guest demo workspace access and user authentication state. * **Tests** * Added tests to verify sign-out logic when guest demo workspaces are enabled or disabled. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: liuyi <forehalo@gmail.com> Co-authored-by: fengmk2 <fengmk2@gmail.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { AuthService, SubscriptionService } from '../../../modules/cloud';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const UserInfo = () => {
|
||||
@@ -51,9 +52,11 @@ export const PublishPageUserAvatar = () => {
|
||||
const user = useLiveData(authService.session.account$);
|
||||
const t = useI18n();
|
||||
|
||||
const navigateHelper = useNavigateHelper();
|
||||
const handleSignOut = useAsyncCallback(async () => {
|
||||
await authService.signOut();
|
||||
}, [authService]);
|
||||
navigateHelper.jumpToSignIn();
|
||||
}, [authService, navigateHelper]);
|
||||
|
||||
const menuItem = useMemo(() => {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/* eslint-disable rxjs/finnish */
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
// mocks
|
||||
const signOutFn = vi.fn();
|
||||
const jumpToIndex = vi.fn();
|
||||
const jumpToSignIn = vi.fn();
|
||||
let allowGuestDemo: boolean | undefined = true;
|
||||
|
||||
vi.mock('@affine/core/modules/cloud', () => ({
|
||||
AuthService: class {},
|
||||
DefaultServerService: class {},
|
||||
}));
|
||||
|
||||
vi.mock('@toeverything/infra', () => {
|
||||
return {
|
||||
useService: () => ({ signOut: signOutFn }),
|
||||
useServices: () => ({
|
||||
defaultServerService: {
|
||||
server: {
|
||||
config$: {
|
||||
value: {
|
||||
get allowGuestDemoWorkspace() {
|
||||
return allowGuestDemo;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@affine/component', () => {
|
||||
return {
|
||||
useConfirmModal: () => ({
|
||||
openConfirmModal: ({ onConfirm }: { onConfirm?: () => unknown }) => {
|
||||
return Promise.resolve(onConfirm?.());
|
||||
},
|
||||
}),
|
||||
notify: { error: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@affine/i18n', () => ({
|
||||
useI18n: () => new Proxy({}, { get: () => () => '' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../use-navigate-helper', () => ({
|
||||
useNavigateHelper: () => ({ jumpToIndex, jumpToSignIn }),
|
||||
}));
|
||||
|
||||
import { useSignOut } from '../use-sign-out';
|
||||
|
||||
describe('useSignOut', () => {
|
||||
beforeEach(() => {
|
||||
signOutFn.mockClear();
|
||||
jumpToIndex.mockClear();
|
||||
jumpToSignIn.mockClear();
|
||||
});
|
||||
|
||||
test('redirects to index when guest demo allowed', async () => {
|
||||
allowGuestDemo = true;
|
||||
const { result } = renderHook(() => useSignOut());
|
||||
result.current();
|
||||
await waitFor(() => expect(signOutFn).toHaveBeenCalled());
|
||||
expect(jumpToIndex).toHaveBeenCalled();
|
||||
expect(jumpToSignIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('redirects to index when guest demo config not provided', async () => {
|
||||
allowGuestDemo = undefined;
|
||||
const { result } = renderHook(() => useSignOut());
|
||||
result.current();
|
||||
await waitFor(() => expect(signOutFn).toHaveBeenCalled());
|
||||
expect(jumpToIndex).toHaveBeenCalled();
|
||||
expect(jumpToSignIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('redirects to sign in when guest demo disabled', async () => {
|
||||
allowGuestDemo = false;
|
||||
const { result } = renderHook(() => useSignOut());
|
||||
result.current();
|
||||
await waitFor(() => expect(signOutFn).toHaveBeenCalled());
|
||||
expect(jumpToSignIn).toHaveBeenCalled();
|
||||
expect(jumpToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,10 @@ import {
|
||||
notify,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { AuthService } from '@affine/core/modules/cloud';
|
||||
import { AuthService, DefaultServerService } from '@affine/core/modules/cloud';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useService, useServices } from '@toeverything/infra';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useNavigateHelper } from '../use-navigate-helper';
|
||||
@@ -25,21 +25,29 @@ export const useSignOut = ({
|
||||
}: ConfirmModalProps = {}) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { jumpToIndex } = useNavigateHelper();
|
||||
const { jumpToSignIn, jumpToIndex } = useNavigateHelper();
|
||||
|
||||
const authService = useService(AuthService);
|
||||
const { defaultServerService } = useServices({ DefaultServerService });
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
onConfirm?.()?.catch(console.error);
|
||||
try {
|
||||
await authService.signOut();
|
||||
jumpToIndex();
|
||||
if (
|
||||
defaultServerService.server.config$.value.allowGuestDemoWorkspace !==
|
||||
false
|
||||
) {
|
||||
jumpToIndex();
|
||||
} else {
|
||||
jumpToSignIn();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = UserFriendlyError.fromAny(err);
|
||||
notify.error(error);
|
||||
}
|
||||
}, [authService, jumpToIndex, onConfirm]);
|
||||
}, [authService, jumpToIndex, jumpToSignIn, defaultServerService, onConfirm]);
|
||||
|
||||
const getDefaultText = useCallback(
|
||||
(key: SignOutConfirmModalI18NKeys) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MenuItem } from '@affine/component/ui/menu';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { DefaultServerService } from '@affine/core/modules/cloud';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ImportIcon, PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
@@ -14,10 +14,11 @@ export const AddWorkspace = ({
|
||||
onNewWorkspace?: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const featureFlagService = useService(FeatureFlagService);
|
||||
const enableLocalWorkspace = useLiveData(
|
||||
featureFlagService.flags.enable_local_workspace.$
|
||||
const defaultServerService = useService(DefaultServerService);
|
||||
const allowGuestDemo = useLiveData(
|
||||
defaultServerService.server.config$.selector(c => c.allowGuestDemoWorkspace)
|
||||
);
|
||||
const guestDemoEnabled = allowGuestDemo !== false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -44,7 +45,7 @@ export const AddWorkspace = ({
|
||||
className={styles.ItemContainer}
|
||||
>
|
||||
<div className={styles.ItemText}>
|
||||
{enableLocalWorkspace
|
||||
{guestDemoEnabled
|
||||
? t['com.affine.workspaceList.addWorkspace.create']()
|
||||
: t['com.affine.workspaceList.addWorkspace.create-cloud']()}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ScrollableContainer } from '@affine/component';
|
||||
import { MenuItem } from '@affine/component/ui/menu';
|
||||
import { AuthService } from '@affine/core/modules/cloud';
|
||||
import { AuthService, DefaultServerService } from '@affine/core/modules/cloud';
|
||||
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { type WorkspaceMetadata } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
@@ -66,7 +65,7 @@ export const UserWithWorkspaceList = ({
|
||||
}: UserWithWorkspaceListProps) => {
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
const session = useLiveData(useService(AuthService).session.session$);
|
||||
const featureFlagService = useService(FeatureFlagService);
|
||||
const defaultServerService = useService(DefaultServerService);
|
||||
|
||||
const isAuthenticated = session.status === 'authenticated';
|
||||
|
||||
@@ -77,7 +76,8 @@ export const UserWithWorkspaceList = ({
|
||||
const onNewWorkspace = useCallback(() => {
|
||||
if (
|
||||
!isAuthenticated &&
|
||||
!featureFlagService.flags.enable_local_workspace.value
|
||||
defaultServerService.server.config$.value.allowGuestDemoWorkspace ===
|
||||
false
|
||||
) {
|
||||
return openSignInModal();
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export const UserWithWorkspaceList = ({
|
||||
onEventEnd?.();
|
||||
}, [
|
||||
globalDialogService,
|
||||
featureFlagService,
|
||||
defaultServerService,
|
||||
isAuthenticated,
|
||||
onCreatedWorkspace,
|
||||
onEventEnd,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IconButton, Menu, MenuItem } from '@affine/component';
|
||||
import { Divider } from '@affine/component/ui/divider';
|
||||
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
|
||||
import { useSignOut } from '@affine/core/components/hooks/affine/use-sign-out';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
|
||||
import type { AuthAccountInfo, Server } from '@affine/core/modules/cloud';
|
||||
@@ -161,9 +162,7 @@ const CloudWorkSpaceList = ({
|
||||
workspaces,
|
||||
]);
|
||||
|
||||
const handleSignOut = useAsyncCallback(async () => {
|
||||
await authService.signOut();
|
||||
}, [authService]);
|
||||
const handleSignOut = useSignOut();
|
||||
|
||||
const handleSignIn = useAsyncCallback(async () => {
|
||||
globalDialogService.open('sign-in', {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DefaultServerService } from '@affine/core/modules/cloud';
|
||||
import { DesktopApiService } from '@affine/core/modules/desktop-api';
|
||||
import { WorkspacesService } from '@affine/core/modules/workspace';
|
||||
import {
|
||||
@@ -46,16 +47,23 @@ export const Component = ({
|
||||
const [navigating, setNavigating] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const authService = useService(AuthService);
|
||||
const defaultServerService = useService(DefaultServerService);
|
||||
|
||||
const loggedIn = useLiveData(
|
||||
authService.session.status$.map(s => s === 'authenticated')
|
||||
);
|
||||
const allowGuestDemo =
|
||||
useLiveData(
|
||||
defaultServerService.server.config$.selector(
|
||||
c => c.allowGuestDemoWorkspace
|
||||
)
|
||||
) ?? true;
|
||||
|
||||
const workspacesService = useService(WorkspacesService);
|
||||
const list = useLiveData(workspacesService.list.workspaces$);
|
||||
const listIsLoading = useLiveData(workspacesService.list.isRevalidating$);
|
||||
|
||||
const { openPage, jumpToPage } = useNavigateHelper();
|
||||
const { openPage, jumpToPage, jumpToSignIn } = useNavigateHelper();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const createOnceRef = useRef(false);
|
||||
@@ -84,6 +92,12 @@ export const Component = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowGuestDemo && !loggedIn) {
|
||||
localStorage.removeItem('last_workspace_id');
|
||||
jumpToSignIn();
|
||||
return;
|
||||
}
|
||||
|
||||
// check is user logged in && has cloud workspace
|
||||
if (searchParams.get('initCloud') === 'true') {
|
||||
if (loggedIn) {
|
||||
@@ -111,10 +125,12 @@ export const Component = ({
|
||||
openPage(openWorkspace.id, defaultIndexRoute, RouteLogic.REPLACE);
|
||||
}
|
||||
}, [
|
||||
allowGuestDemo,
|
||||
createCloudWorkspace,
|
||||
list,
|
||||
openPage,
|
||||
searchParams,
|
||||
jumpToSignIn,
|
||||
listIsLoading,
|
||||
loggedIn,
|
||||
navigating,
|
||||
@@ -128,7 +144,9 @@ export const Component = ({
|
||||
}, [desktopApi]);
|
||||
|
||||
useEffect(() => {
|
||||
setCreating(true);
|
||||
if (listIsLoading || list.length > 0) {
|
||||
return;
|
||||
}
|
||||
createFirstAppData(workspacesService)
|
||||
.then(createdWorkspace => {
|
||||
if (createdWorkspace) {
|
||||
@@ -148,7 +166,15 @@ export const Component = ({
|
||||
.finally(() => {
|
||||
setCreating(false);
|
||||
});
|
||||
}, [jumpToPage, openPage, workspacesService]);
|
||||
}, [
|
||||
jumpToPage,
|
||||
jumpToSignIn,
|
||||
openPage,
|
||||
workspacesService,
|
||||
loggedIn,
|
||||
listIsLoading,
|
||||
list,
|
||||
]);
|
||||
|
||||
if (navigating || creating) {
|
||||
return fallback ?? <AppContainer fallback />;
|
||||
|
||||
@@ -83,7 +83,8 @@ const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
|
||||
|
||||
const onSignOut = useAsyncCallback(async () => {
|
||||
await authService.signOut();
|
||||
}, [authService]);
|
||||
navigateHelper.jumpToSignIn();
|
||||
}, [authService, navigateHelper]);
|
||||
|
||||
if ((loading && !requestToJoinLoading) || inviteId !== targetInviteId) {
|
||||
return null;
|
||||
|
||||
@@ -228,7 +228,8 @@ const CloudWorkSpaceList = ({
|
||||
|
||||
const handleSignOut = useAsyncCallback(async () => {
|
||||
await authService.signOut();
|
||||
}, [authService]);
|
||||
navigateHelper.jumpToSignIn();
|
||||
}, [authService, navigateHelper]);
|
||||
|
||||
const handleSignIn = useAsyncCallback(async () => {
|
||||
globalDialogService.open('sign-in', {
|
||||
|
||||
@@ -26,6 +26,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
||||
maxLength: 32,
|
||||
},
|
||||
},
|
||||
allowGuestDemoWorkspace: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -56,6 +57,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
||||
maxLength: 32,
|
||||
},
|
||||
},
|
||||
allowGuestDemoWorkspace: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -88,6 +90,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
||||
maxLength: 32,
|
||||
},
|
||||
},
|
||||
allowGuestDemoWorkspace: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -120,6 +123,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
||||
maxLength: 32,
|
||||
},
|
||||
},
|
||||
allowGuestDemoWorkspace: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -148,6 +152,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
||||
maxLength: 32,
|
||||
},
|
||||
},
|
||||
allowGuestDemoWorkspace: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -178,6 +183,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
||||
maxLength: 32,
|
||||
},
|
||||
},
|
||||
allowGuestDemoWorkspace: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -82,6 +82,7 @@ export class Server extends Entity<{
|
||||
credentialsRequirement: config.credentialsRequirement,
|
||||
features: config.features,
|
||||
oauthProviders: config.oauthProviders,
|
||||
allowGuestDemoWorkspace: config.allowGuestDemoWorkspace,
|
||||
serverName: config.name,
|
||||
type: config.type,
|
||||
version: config.version,
|
||||
|
||||
@@ -82,6 +82,7 @@ export class ServersService extends Service {
|
||||
credentialsRequirement: config.credentialsRequirement,
|
||||
features: config.features,
|
||||
oauthProviders: config.oauthProviders,
|
||||
allowGuestDemoWorkspace: config.allowGuestDemoWorkspace,
|
||||
serverName: config.name,
|
||||
type: config.type,
|
||||
initialized: config.initialized,
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ServerMetadata {
|
||||
export interface ServerConfig {
|
||||
serverName: string;
|
||||
features: ServerFeature[];
|
||||
allowGuestDemoWorkspace: boolean;
|
||||
oauthProviders: OAuthProviderType[];
|
||||
type: ServerDeploymentType;
|
||||
initialized?: boolean;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { FlagInfo } from './types';
|
||||
|
||||
// const isNotStableBuild = BUILD_CONFIG.appBuildType !== 'stable';
|
||||
const isDesktopEnvironment = BUILD_CONFIG.isElectron;
|
||||
const isCanaryBuild = BUILD_CONFIG.appBuildType === 'canary';
|
||||
const isMobile = BUILD_CONFIG.isMobileEdition;
|
||||
|
||||
@@ -149,15 +148,6 @@ export const AFFINE_FLAGS = {
|
||||
configurable: isCanaryBuild && !isMobile,
|
||||
defaultState: isCanaryBuild,
|
||||
},
|
||||
enable_local_workspace: {
|
||||
category: 'affine',
|
||||
displayName:
|
||||
'com.affine.settings.workspace.experimental-features.enable-local-workspace.name',
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-local-workspace.description',
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: isDesktopEnvironment || isCanaryBuild,
|
||||
},
|
||||
enable_advanced_block_visibility: {
|
||||
category: 'blocksuite',
|
||||
bsFlag: 'enable_advanced_block_visibility',
|
||||
|
||||
Reference in New Issue
Block a user