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:
Richard Lora
2025-06-29 10:17:18 -04:00
committed by GitHub
parent a4680d236d
commit 82b3c0d264
25 changed files with 209 additions and 34 deletions

View File

@@ -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 (

View File

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

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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,

View File

@@ -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', {

View File

@@ -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 />;

View File

@@ -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;

View File

@@ -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', {

View File

@@ -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,
},
},
]

View File

@@ -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,

View File

@@ -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,

View File

@@ -14,6 +14,7 @@ export interface ServerMetadata {
export interface ServerConfig {
serverName: string;
features: ServerFeature[];
allowGuestDemoWorkspace: boolean;
oauthProviders: OAuthProviderType[];
type: ServerDeploymentType;
initialized?: boolean;

View File

@@ -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',