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