feat(core): add doc grant feature to share menu (#9672)

This commit is contained in:
JimmFly
2025-02-07 13:05:58 +00:00
parent 459972fe6c
commit 5ae5fd88f1
57 changed files with 3188 additions and 697 deletions

View File

@@ -1,6 +1,6 @@
import { DebugLogger } from '@affine/debug';
import { Unreachable } from '@affine/env/constant';
import { type OperatorFunction, Subject } from 'rxjs';
import { type OperatorFunction, Subject, type Subscription } from 'rxjs';
const logger = new DebugLogger('effect');
@@ -9,6 +9,8 @@ export type Effect<T> = (T | undefined extends T // hack to detect if T is unkno
: (value: T) => void) & {
// unsubscribe effect, all ongoing effects will be cancelled.
unsubscribe: () => void;
// reset internal state, all ongoing effects will be cancelled.
reset: () => void;
};
/**
@@ -89,36 +91,44 @@ export function effect(...args: any[]) {
}
}
// eslint-disable-next-line prefer-spread
const subscription = subject$.pipe.apply(subject$, args as any).subscribe({
next(value) {
const error = new EffectError('should not emit value', value);
// make a uncaught exception
setTimeout(() => {
throw error;
}, 0);
},
complete() {
const error = new EffectError('effect unexpected complete');
// make a uncaught exception
setTimeout(() => {
throw error;
}, 0);
},
error(error) {
const effectError = new EffectError('effect uncaught error', error);
// make a uncaught exception
setTimeout(() => {
throw effectError;
}, 0);
},
});
let subscription: Subscription | null = null;
function subscribe() {
subscription = subject$.pipe.apply(subject$, args as any).subscribe({
next(value) {
const error = new EffectError('should not emit value', value);
// make a uncaught exception
setTimeout(() => {
throw error;
}, 0);
},
complete() {
const error = new EffectError('effect unexpected complete');
// make a uncaught exception
setTimeout(() => {
throw error;
}, 0);
},
error(error) {
const effectError = new EffectError('effect uncaught error', error);
// make a uncaught exception
setTimeout(() => {
throw effectError;
}, 0);
},
});
}
subscribe();
const fn = (value: unknown) => {
subject$.next(value);
};
fn.unsubscribe = () => subscription.unsubscribe();
fn.unsubscribe = () => subscription?.unsubscribe();
fn.reset = () => {
subscription?.unsubscribe();
subscribe();
};
return fn as never;
}

View File

@@ -85,7 +85,6 @@ export const menuItem = style({
'&.checked, &.selected': {
vars: {
[iconColor]: cssVar('primaryColor'),
[labelColor]: cssVar('primaryColor'),
},
},
},

View File

@@ -1,271 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const headerStyle = style({
display: 'flex',
alignItems: 'center',
fontSize: cssVar('fontSm'),
fontWeight: 600,
lineHeight: '22px',
padding: '0 4px',
gap: '4px',
});
export const content = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const menuStyle = style({
width: '390px',
height: 'auto',
padding: '12px',
});
export const menuTriggerStyle = style({
width: '150px',
padding: '4px 10px',
justifyContent: 'space-between',
});
export const publicItemRowStyle = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
export const DoneIconStyle = style({
color: cssVarV2('button/primary'),
fontSize: cssVar('fontH5'),
marginLeft: '8px',
});
export const exportItemStyle = style({
padding: '4px',
transition: 'all 0.3s',
gap: '0px',
});
globalStyle(`${exportItemStyle} > div:first-child`, {
alignItems: 'center',
});
globalStyle(`${exportItemStyle} svg`, {
width: '16px',
height: '16px',
});
export const copyLinkContainerStyle = style({
padding: '4px',
display: 'flex',
alignItems: 'center',
width: '100%',
position: 'relative',
selectors: {
'&.secondary': {
padding: 0,
marginTop: '12px',
},
},
});
export const copyLinkButtonStyle = style({
flex: 1,
padding: '4px 12px',
paddingRight: '6px',
borderRight: 'none',
borderTopRightRadius: '0',
borderBottomRightRadius: '0',
color: 'transparent',
position: 'initial',
selectors: {
'&.dark': {
backgroundColor: cssVarV2('layer/pureBlack'),
},
'&.dark::hover': {
backgroundColor: cssVarV2('layer/pureBlack'),
},
},
});
export const copyLinkLabelContainerStyle = style({
width: '100%',
borderRight: 'none',
borderTopRightRadius: '0',
borderBottomRightRadius: '0',
position: 'relative',
});
export const copyLinkLabelStyle = style({
position: 'absolute',
textAlign: 'end',
top: '50%',
left: '50%',
transform: 'translateX(-50%) translateY(-50%)',
lineHeight: '20px',
color: cssVarV2('text/pureWhite'),
selectors: {
'&.secondary': {
color: cssVarV2('text/primary'),
},
},
});
export const copyLinkShortcutStyle = style({
position: 'absolute',
textAlign: 'end',
top: '50%',
right: '52px',
transform: 'translateY(-50%)',
opacity: 0.5,
lineHeight: '20px',
color: cssVarV2('text/pureWhite'),
selectors: {
'&.secondary': {
color: cssVarV2('text/secondary'),
},
},
});
export const copyLinkTriggerStyle = style({
padding: '4px 12px 4px 8px',
borderLeft: 'none',
borderTopLeftRadius: '0',
borderBottomLeftRadius: '0',
':hover': {
backgroundColor: cssVarV2('button/primary'),
color: cssVarV2('button/pureWhiteText'),
},
'::after': {
content: '""',
position: 'absolute',
left: '0',
top: '0',
height: '100%',
width: '1px',
backgroundColor: cssVarV2('button/innerBlackBorder'),
},
selectors: {
'&.secondary': {
backgroundColor: cssVarV2('button/secondary'),
color: cssVarV2('text/secondary'),
},
'&.secondary:hover': {
backgroundColor: cssVarV2('button/secondary'),
color: cssVarV2('text/secondary'),
},
},
});
globalStyle(`${copyLinkTriggerStyle} svg`, {
color: cssVarV2('button/pureWhiteText'),
transform: 'translateX(2px)',
});
globalStyle(`${copyLinkTriggerStyle}.secondary svg`, {
color: cssVarV2('text/secondary'),
transform: 'translateX(2px)',
});
export const copyLinkMenuItemStyle = style({
padding: '4px',
transition: 'all 0.3s',
});
export const descriptionStyle = style({
wordWrap: 'break-word',
fontSize: cssVar('fontXs'),
lineHeight: '20px',
color: cssVarV2('text/secondary'),
textAlign: 'left',
padding: '0 6px',
});
export const containerStyle = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
gap: '8px',
});
export const indicatorContainerStyle = style({
position: 'relative',
});
export const titleContainerStyle = style({
display: 'flex',
alignItems: 'center',
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
fontWeight: 400,
padding: '8px 4px 0 4px',
});
export const columnContainerStyle = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
gap: '8px',
});
export const rowContainerStyle = style({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: '4px',
});
export const exportContainerStyle = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const labelStyle = style({
fontSize: cssVar('fontSm'),
fontWeight: 500,
});
export const disableSharePage = style({
color: cssVarV2('button/error'),
});
export const localSharePage = style({
padding: '12px 8px',
display: 'flex',
alignItems: 'center',
borderRadius: '8px',
backgroundColor: cssVarV2('layer/background/secondary'),
minHeight: '84px',
position: 'relative',
});
export const cloudSvgContainer = style({
width: '146px',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
position: 'absolute',
bottom: '0',
right: '0',
});
export const shareLinkStyle = style({
padding: '4px',
fontSize: cssVar('fontXs'),
fontWeight: 500,
lineHeight: '20px',
transform: 'translateX(-4px)',
gap: '4px',
});
globalStyle(`${shareLinkStyle} > span`, {
color: cssVarV2('text/link'),
});
globalStyle(`${shareLinkStyle} > div > svg`, {
color: cssVarV2('text/link'),
});
export const buttonContainer = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
fontWeight: 500,
});
export const button = style({
padding: '6px 8px',
height: 32,
});
export const shortcutStyle = style({
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
fontWeight: 400,
});
export const openWorkspaceSettingsStyle = style({
color: cssVarV2('text/link'),
fontSize: cssVar('fontXs'),
fontWeight: 500,
display: 'flex',
gap: '8px',
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%',
padding: '4px',
cursor: 'pointer',
});
globalStyle(`${openWorkspaceSettingsStyle} svg`, {
color: cssVarV2('text/link'),
});

View File

@@ -1,124 +0,0 @@
import { Tabs, Tooltip } from '@affine/component';
import { Button } from '@affine/component/ui/button';
import { Menu } from '@affine/component/ui/menu';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import type { WorkspaceMetadata } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import type { Store } from '@blocksuite/affine/store';
import { LockIcon, PublishIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { forwardRef, type PropsWithChildren, type Ref, useEffect } from 'react';
import * as styles from './index.css';
import { ShareExport } from './share-export';
import { SharePage } from './share-page';
export interface ShareMenuProps extends PropsWithChildren {
workspaceMetadata: WorkspaceMetadata;
currentPage: Store;
onEnableAffineCloud: () => void;
onOpenShareModal?: (open: boolean) => void;
}
export const ShareMenuContent = (props: ShareMenuProps) => {
const t = useI18n();
return (
<div className={styles.containerStyle}>
<Tabs.Root defaultValue="share">
<Tabs.List>
<Tabs.Trigger value="share">
{t['com.affine.share-menu.shareButton']()}
</Tabs.Trigger>
<Tabs.Trigger value="export">{t['Export']()}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="share">
<SharePage {...props} />
</Tabs.Content>
<Tabs.Content value="export">
<ShareExport />
</Tabs.Content>
</Tabs.Root>
</div>
);
};
const DefaultShareButton = forwardRef(function DefaultShareButton(
_,
ref: Ref<HTMLButtonElement>
) {
const t = useI18n();
const shareInfoService = useService(ShareInfoService);
const shared = useLiveData(shareInfoService.shareInfo.isShared$);
useEffect(() => {
shareInfoService.shareInfo.revalidate();
}, [shareInfoService]);
return (
<Tooltip
content={
shared
? t['com.affine.share-menu.option.link.readonly.description']()
: t['com.affine.share-menu.option.link.no-access.description']()
}
>
<Button ref={ref} className={styles.button}>
<div className={styles.buttonContainer}>
{shared ? <PublishIcon fontSize={16} /> : <LockIcon fontSize={16} />}
{t['com.affine.share-menu.shareButton']()}
</div>
</Button>
</Tooltip>
);
});
const LocalShareMenu = (props: ShareMenuProps) => {
return (
<Menu
items={<ShareMenuContent {...props} />}
contentOptions={{
className: styles.menuStyle,
['data-testid' as string]: 'local-share-menu',
align: 'end',
}}
rootOptions={{
modal: false,
onOpenChange: props.onOpenShareModal,
}}
>
<div data-testid="local-share-menu-button">
{props.children || <DefaultShareButton />}
</div>
</Menu>
);
};
const CloudShareMenu = (props: ShareMenuProps) => {
return (
<Menu
items={<ShareMenuContent {...props} />}
contentOptions={{
className: styles.menuStyle,
['data-testid' as string]: 'cloud-share-menu',
align: 'end',
}}
rootOptions={{
modal: false,
onOpenChange: props.onOpenShareModal,
}}
>
<div data-testid="cloud-share-menu-button">
{props.children || <DefaultShareButton />}
</div>
</Menu>
);
};
export const ShareMenu = (props: ShareMenuProps) => {
const { workspaceMetadata } = props;
if (workspaceMetadata.flavour === 'local') {
return <LocalShareMenu {...props} />;
}
return <CloudShareMenu {...props} />;
};

View File

@@ -1,253 +0,0 @@
import { notify, Skeleton } from '@affine/component';
import { Button } from '@affine/component/ui/button';
import { Menu, MenuItem, MenuTrigger } from '@affine/component/ui/menu';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { ServerService } from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { PublicDocMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
CollaborationIcon,
DoneIcon,
LockIcon,
SingleSelectCheckSolidIcon,
ViewIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { Suspense, useCallback, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { CloudSvg } from '../cloud-svg';
import { CopyLinkButton } from './copy-link-button';
import * as styles from './index.css';
import type { ShareMenuProps } from './share-menu';
export const LocalSharePage = (props: ShareMenuProps) => {
const t = useI18n();
const {
workspaceMetadata: { id: workspaceId },
} = props;
return (
<>
<div className={styles.localSharePage}>
<div className={styles.columnContainerStyle} style={{ gap: '12px' }}>
<div
className={styles.descriptionStyle}
style={{ maxWidth: '230px' }}
>
{t['com.affine.share-menu.EnableCloudDescription']()}
</div>
<div>
<Button
onClick={props.onEnableAffineCloud}
variant="primary"
data-testid="share-menu-enable-affine-cloud-button"
>
{t['Enable AFFiNE Cloud']()}
</Button>
</div>
</div>
<div className={styles.cloudSvgContainer}>
<CloudSvg />
</div>
</div>
<CopyLinkButton workspaceId={workspaceId} secondary />
</>
);
};
export const AFFiNESharePage = (props: ShareMenuProps) => {
const t = useI18n();
const {
workspaceMetadata: { id: workspaceId },
} = props;
const shareInfoService = useService(ShareInfoService);
const serverService = useService(ServerService);
useEffect(() => {
shareInfoService.shareInfo.revalidate();
}, [shareInfoService]);
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
const sharedMode = useLiveData(shareInfoService.shareInfo.sharedMode$);
const baseUrl = serverService.server.baseUrl;
const isLoading =
isSharedPage === null || sharedMode === null || baseUrl === null;
const permissionService = useService(WorkspacePermissionService);
const isOwner = useLiveData(permissionService.permission.isOwner$);
const workspaceDialogService = useService(WorkspaceDialogService);
const onOpenWorkspaceSettings = useCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'workspace:preference',
});
}, [workspaceDialogService]);
const onClickAnyoneReadOnlyShare = useAsyncCallback(async () => {
if (isSharedPage) {
return;
}
try {
// TODO(@JimmFly): remove mode when we have a better way to handle it
await shareInfoService.shareInfo.enableShare(PublicDocMode.Page);
track.$.sharePanel.$.createShareLink();
notify.success({
title:
t[
'com.affine.share-menu.create-public-link.notification.success.title'
](),
message:
t[
'com.affine.share-menu.create-public-link.notification.success.message'
](),
style: 'normal',
icon: <SingleSelectCheckSolidIcon color={cssVar('primaryColor')} />,
});
} catch (err) {
notify.error({
title:
t[
'com.affine.share-menu.confirm-modify-mode.notification.fail.title'
](),
message:
t[
'com.affine.share-menu.confirm-modify-mode.notification.fail.message'
](),
});
console.error(err);
}
}, [isSharedPage, shareInfoService.shareInfo, t]);
const onDisablePublic = useAsyncCallback(async () => {
try {
await shareInfoService.shareInfo.disableShare();
notify.error({
title:
t[
'com.affine.share-menu.disable-publish-link.notification.success.title'
](),
message:
t[
'com.affine.share-menu.disable-publish-link.notification.success.message'
](),
});
} catch (err) {
notify.error({
title:
t[
'com.affine.share-menu.disable-publish-link.notification.fail.title'
](),
message:
t[
'com.affine.share-menu.disable-publish-link.notification.fail.message'
](),
});
console.log(err);
}
}, [shareInfoService, t]);
if (isLoading) {
// TODO(@eyhn): loading and error UI
return (
<>
<Skeleton height={100} />
<Skeleton height={40} />
</>
);
}
return (
<div className={styles.content}>
<div className={styles.titleContainerStyle}>
{isSharedPage
? t['com.affine.share-menu.option.link.readonly.description']()
: t['com.affine.share-menu.option.link.no-access.description']()}
</div>
<div className={styles.columnContainerStyle}>
<div className={styles.rowContainerStyle}>
<div className={styles.labelStyle}>
{t['com.affine.share-menu.option.link.label']()}
</div>
<Menu
contentOptions={{
align: 'end',
}}
items={
<>
<MenuItem prefixIcon={<LockIcon />} onSelect={onDisablePublic}>
<div className={styles.publicItemRowStyle}>
<div>
{t['com.affine.share-menu.option.link.no-access']()}
</div>
{!isSharedPage && (
<DoneIcon className={styles.DoneIconStyle} />
)}
</div>
</MenuItem>
<MenuItem
prefixIcon={<ViewIcon />}
onSelect={onClickAnyoneReadOnlyShare}
data-testid="share-link-menu-enable-share"
>
<div className={styles.publicItemRowStyle}>
<div>
{t['com.affine.share-menu.option.link.readonly']()}
</div>
{isSharedPage && (
<DoneIcon className={styles.DoneIconStyle} />
)}
</div>
</MenuItem>
</>
}
>
<MenuTrigger
className={styles.menuTriggerStyle}
data-testid="share-link-menu-trigger"
>
{isSharedPage
? t['com.affine.share-menu.option.link.readonly']()
: t['com.affine.share-menu.option.link.no-access']()}
</MenuTrigger>
</Menu>
</div>
<div className={styles.rowContainerStyle}>
<div className={styles.labelStyle}>
{t['com.affine.share-menu.option.permission.label']()}
</div>
<Button className={styles.menuTriggerStyle} disabled>
{t['com.affine.share-menu.option.permission.can-edit']()}
</Button>
</div>
</div>
{isOwner && (
<div
className={styles.openWorkspaceSettingsStyle}
onClick={onOpenWorkspaceSettings}
>
<CollaborationIcon fontSize={16} />
{t['com.affine.share-menu.navigate.workspace']()}
</div>
)}
<CopyLinkButton workspaceId={workspaceId} />
</div>
);
};
export const SharePage = (props: ShareMenuProps) => {
if (props.workspaceMetadata.flavour === 'local') {
return <LocalSharePage {...props} />;
} else {
return (
// TODO(@eyhn): refactor this part
<ErrorBoundary fallback={null}>
<Suspense>
<AFFiNESharePage {...props} />
</Suspense>
</ErrorBoundary>
);
}
};

View File

@@ -6,7 +6,6 @@ import {
MenuSub,
} from '@affine/component/ui/menu';
import { PageHistoryModal } from '@affine/core/components/affine/page-history-modal';
import { ShareMenuContent } from '@affine/core/components/affine/share-page-modal/share-menu';
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
import { useExportPage } from '@affine/core/components/hooks/affine/use-export-page';
@@ -17,6 +16,7 @@ import { useDetailPageHeaderResponsive } from '@affine/core/desktop/pages/worksp
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { EditorService } from '@affine/core/modules/editor';
import { OpenInAppService } from '@affine/core/modules/open-in-app/services';
import { ShareMenuContent } from '@affine/core/modules/share-menu';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { WorkspaceService } from '@affine/core/modules/workspace';

View File

@@ -1,6 +1,5 @@
import { Avatar, ConfirmModal, Input, notify, Switch } from '@affine/component';
import type { ConfirmModalProps } from '@affine/component/ui/modal';
import { CloudSvg } from '@affine/core/components/affine/share-page-modal/cloud-svg';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService, ServersService } from '@affine/core/modules/cloud';
import {
@@ -9,6 +8,7 @@ import {
GlobalDialogService,
} from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { CloudSvg } from '@affine/core/modules/share-menu';
import { WorkspacesService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';

View File

@@ -5,7 +5,6 @@ import {
observeResize,
useDraggable,
} from '@affine/component';
import { SharePageButton } from '@affine/core/components/affine/share-page-modal';
import { FavoriteButton } from '@affine/core/components/blocksuite/block-suite-header/favorite';
import { InfoButton } from '@affine/core/components/blocksuite/block-suite-header/info';
import { JournalWeekDatePicker } from '@affine/core/components/blocksuite/block-suite-header/journal/date-picker';
@@ -20,6 +19,7 @@ import { DocService } from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { EditorService } from '@affine/core/modules/editor';
import { JournalService } from '@affine/core/modules/journal';
import { SharePageButton } from '@affine/core/modules/share-menu';
import { TemplateDocService } from '@affine/core/modules/template-doc';
import { ViewIcon, ViewTitle } from '@affine/core/modules/workbench';
import type { Workspace } from '@affine/core/modules/workspace';

View File

@@ -1,7 +1,7 @@
import { IconButton, MobileMenu } from '@affine/component';
import { SharePage } from '@affine/core/components/affine/share-page-modal/share-menu/share-page';
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
import { DocService } from '@affine/core/modules/doc';
import { ShareMenuContent } from '@affine/core/modules/share-menu';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { ShareiOsIcon } from '@blocksuite/icons/rc';
import { useServices } from '@toeverything/infra';
@@ -25,7 +25,7 @@ export const PageHeaderShareButton = () => {
<MobileMenu
items={
<div className={styles.content}>
<SharePage
<ShareMenuContent
workspaceMetadata={workspace.meta}
currentPage={doc}
onEnableAffineCloud={() =>

View File

@@ -1,10 +1,16 @@
export type { Member } from './entities/members';
export {
DocGrantedUsersService,
type GrantedUser,
} from './services/doc-granted-users';
export { MemberSearchService } from './services/member-search';
export { WorkspaceMembersService } from './services/members';
export { WorkspacePermissionService } from './services/permission';
import { type Framework } from '@toeverything/infra';
import { WorkspaceServerService } from '../cloud';
import { DocScope, DocService } from '../doc';
import {
WorkspaceScope,
WorkspaceService,
@@ -12,8 +18,12 @@ import {
} from '../workspace';
import { WorkspaceMembers } from './entities/members';
import { WorkspacePermission } from './entities/permission';
import { DocGrantedUsersService } from './services/doc-granted-users';
import { MemberSearchService } from './services/member-search';
import { WorkspaceMembersService } from './services/members';
import { WorkspacePermissionService } from './services/permission';
import { DocGrantedUsersStore } from './stores/doc-granted-users';
import { MemberSearchStore } from './stores/member-search';
import { WorkspaceMembersStore } from './stores/members';
import { WorkspacePermissionStore } from './stores/permission';
@@ -29,5 +39,17 @@ export function configurePermissionsModule(framework: Framework) {
.entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore])
.service(WorkspaceMembersService, [WorkspaceMembersStore, WorkspaceService])
.store(WorkspaceMembersStore, [WorkspaceServerService])
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService]);
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService])
.service(MemberSearchService, [MemberSearchStore, WorkspaceService])
.store(MemberSearchStore, [WorkspaceServerService]);
framework
.scope(WorkspaceScope)
.scope(DocScope)
.service(DocGrantedUsersService, [
DocGrantedUsersStore,
WorkspaceService,
DocService,
])
.store(DocGrantedUsersStore, [WorkspaceServerService]);
}

View File

@@ -0,0 +1,142 @@
import type { DocRole, GetPageGrantedUsersListQuery } from '@affine/graphql';
import {
backoffRetry,
catchErrorInto,
effect,
fromPromise,
LiveData,
onComplete,
onStart,
Service,
} from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { DocService } from '../../doc';
import type { WorkspaceService } from '../../workspace';
import type { DocGrantedUsersStore } from '../stores/doc-granted-users';
export type GrantedUser =
GetPageGrantedUsersListQuery['workspace']['doc']['grantedUsersList']['edges'][number]['node'];
export class DocGrantedUsersService extends Service {
constructor(
private readonly store: DocGrantedUsersStore,
private readonly workspaceService: WorkspaceService,
private readonly docService: DocService
) {
super();
}
readonly PAGE_SIZE = 8;
nextCursor$ = new LiveData<string | undefined>(undefined);
hasMore$ = new LiveData(true);
grantedUserCount$ = new LiveData(0);
grantedUsers$ = new LiveData<GrantedUser[]>([]);
isLoading$ = new LiveData(false);
error$ = new LiveData<any>(null);
readonly loadMore = effect(
exhaustMap(() => {
if (!this.hasMore$.value) {
return EMPTY;
}
return fromPromise(async signal => {
return await this.store.fetchDocGrantedUsersList(
this.workspaceService.workspace.id,
this.docService.doc.id,
{
first: this.PAGE_SIZE,
after: this.nextCursor$.value,
},
signal
);
}).pipe(
mergeMap(({ edges, pageInfo, totalCount }) => {
this.grantedUsers$.next([
...this.grantedUsers$.value,
...edges.map(edge => edge.node),
]);
this.grantedUserCount$.next(totalCount);
this.hasMore$.next(pageInfo.hasNextPage);
this.nextCursor$.next(pageInfo.endCursor ?? undefined);
return EMPTY;
}),
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$),
onStart(() => {
this.isLoading$.setValue(true);
}),
onComplete(() => this.isLoading$.setValue(false))
);
})
);
reset() {
this.grantedUsers$.setValue([]);
this.grantedUserCount$.setValue(0);
this.hasMore$.setValue(true);
this.nextCursor$.setValue(undefined);
this.isLoading$.setValue(false);
this.error$.setValue(null);
this.loadMore.reset();
}
async grantUsersRole(userIds: string[], role: DocRole) {
await this.store.grantDocUserRoles({
docId: this.docService.doc.id,
workspaceId: this.workspaceService.workspace.id,
userIds,
role,
});
this.grantedUsers$.next(
this.grantedUsers$.value.map(user => {
if (userIds.includes(user.user.id)) {
return { ...user, role };
}
return user;
})
);
}
async revokeUsersRole(userId: string) {
await this.store.revokeDocUserRoles(
this.workspaceService.workspace.id,
this.docService.doc.id,
userId
);
this.grantedUsers$.next(
this.grantedUsers$.value.filter(user => user.user.id !== userId)
);
}
async updateUserRole(userId: string, role: DocRole) {
await this.store.updateDocUserRole(
this.workspaceService.workspace.id,
this.docService.doc.id,
userId,
role
);
this.grantedUsers$.next(
this.grantedUsers$.value.map(user => {
if (user.user.id === userId) {
return { ...user, role };
}
return user;
})
);
}
override dispose(): void {
this.loadMore.unsubscribe();
}
}

View File

@@ -0,0 +1,85 @@
import {
backoffRetry,
catchErrorInto,
effect,
fromPromise,
LiveData,
onComplete,
onStart,
Service,
} from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceService } from '../../workspace';
import type { Member } from '../entities/members';
import type { MemberSearchStore } from '../stores/member-search';
export class MemberSearchService extends Service {
constructor(
private readonly store: MemberSearchStore,
private readonly workspaceService: WorkspaceService
) {
super();
}
readonly PAGE_SIZE = 8;
readonly searchText$ = new LiveData<string>('');
readonly isLoading$ = new LiveData(false);
readonly error$ = new LiveData<any>(null);
readonly result$ = new LiveData<Member[]>([]);
readonly hasMore$ = new LiveData(true);
readonly loadMore = effect(
exhaustMap(() => {
if (!this.hasMore$.value) {
return EMPTY;
}
return fromPromise(async signal => {
return this.store.getMembersByEmailOrName(
this.workspaceService.workspace.id,
this.searchText$.value || undefined,
this.result$.value.length,
this.PAGE_SIZE,
signal
);
}).pipe(
mergeMap(data => {
this.result$.setValue([...this.result$.value, ...data.members]);
this.hasMore$.setValue(data.members.length === this.PAGE_SIZE);
return EMPTY;
}),
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$),
onStart(() => {
this.isLoading$.setValue(true);
}),
onComplete(() => this.isLoading$.setValue(false))
);
})
);
reset() {
this.result$.setValue([]);
this.hasMore$.setValue(true);
this.searchText$.setValue('');
this.error$.setValue(null);
this.loadMore.reset();
}
search(searchText?: string) {
if (this.searchText$.value === searchText) {
return;
}
this.reset();
this.searchText$.setValue(searchText ?? '');
this.loadMore();
}
}

View File

@@ -0,0 +1,95 @@
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
import {
type DocRole,
getPageGrantedUsersListQuery,
type GrantDocUserRolesInput,
grantDocUserRolesMutation,
type PaginationInput,
revokeDocUserRolesMutation,
updateDocUserRoleMutation,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
export class DocGrantedUsersStore extends Store {
constructor(private readonly workspaceServerService: WorkspaceServerService) {
super();
}
async fetchDocGrantedUsersList(
workspaceId: string,
docId: string,
pagination: PaginationInput,
signal?: AbortSignal
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const res = await this.workspaceServerService.server.gql({
query: getPageGrantedUsersListQuery,
variables: {
workspaceId,
docId,
pagination,
},
context: { signal },
});
return res.workspace.doc.grantedUsersList;
}
async grantDocUserRoles(input: GrantDocUserRolesInput) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const res = await this.workspaceServerService.server.gql({
query: grantDocUserRolesMutation,
variables: {
input,
},
});
return res.grantDocUserRoles;
}
async revokeDocUserRoles(workspaceId: string, docId: string, userId: string) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const res = await this.workspaceServerService.server.gql({
query: revokeDocUserRolesMutation,
variables: {
input: {
workspaceId,
docId,
userId,
},
},
});
return res.revokeDocUserRoles;
}
async updateDocUserRole(
workspaceId: string,
docId: string,
userId: string,
role: DocRole
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const res = await this.workspaceServerService.server.gql({
query: updateDocUserRoleMutation,
variables: {
input: {
workspaceId,
docId,
userId,
role,
},
},
});
return res.updateDocUserRole;
}
}

View File

@@ -0,0 +1,36 @@
import { getMembersByWorkspaceIdQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { WorkspaceServerService } from '../../cloud';
export class MemberSearchStore extends Store {
constructor(private readonly workspaceServerService: WorkspaceServerService) {
super();
}
async getMembersByEmailOrName(
workspaceId: string,
query?: string,
skip?: number,
take?: number,
signal?: AbortSignal
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const data = await this.workspaceServerService.server.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId,
skip,
take,
query,
},
context: {
signal,
},
});
return data.workspace;
}
}

View File

@@ -0,0 +1 @@
export { CloudSvg, ShareMenuContent, SharePageButton } from './view';

View File

@@ -5,6 +5,8 @@ import type { Store } from '@blocksuite/affine/store';
import { useCallback } from 'react';
import { ShareMenu } from './share-menu';
export { CloudSvg } from './cloud-svg';
export { ShareMenuContent } from './share-menu';
type SharePageModalProps = {
workspace: Workspace;

View File

@@ -0,0 +1,111 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const copyLinkContainerStyle = style({
padding: '4px',
display: 'flex',
alignItems: 'center',
width: '100%',
position: 'relative',
selectors: {
'&.secondary': {
padding: 0,
marginTop: '12px',
},
},
});
export const copyLinkButtonStyle = style({
flex: 1,
padding: '4px 12px',
paddingRight: '6px',
borderRight: 'none',
borderTopRightRadius: '0',
borderBottomRightRadius: '0',
color: 'transparent',
position: 'initial',
selectors: {
'&.dark': {
backgroundColor: cssVarV2('layer/pureBlack'),
},
'&.dark::hover': {
backgroundColor: cssVarV2('layer/pureBlack'),
},
},
});
export const copyLinkLabelContainerStyle = style({
width: '100%',
borderRight: 'none',
borderTopRightRadius: '0',
borderBottomRightRadius: '0',
position: 'relative',
});
export const copyLinkLabelStyle = style({
position: 'absolute',
textAlign: 'end',
top: '50%',
left: '50%',
transform: 'translateX(-50%) translateY(-50%)',
lineHeight: '20px',
color: cssVarV2('text/pureWhite'),
selectors: {
'&.secondary': {
color: cssVarV2('text/primary'),
},
},
});
export const copyLinkShortcutStyle = style({
position: 'absolute',
textAlign: 'end',
top: '50%',
right: '52px',
transform: 'translateY(-50%)',
opacity: 0.5,
lineHeight: '20px',
color: cssVarV2('text/pureWhite'),
selectors: {
'&.secondary': {
color: cssVarV2('text/secondary'),
},
},
});
export const copyLinkTriggerStyle = style({
padding: '4px 12px 4px 8px',
borderLeft: 'none',
borderTopLeftRadius: '0',
borderBottomLeftRadius: '0',
':hover': {
backgroundColor: cssVarV2('button/primary'),
color: cssVarV2('button/pureWhiteText'),
},
'::after': {
content: '""',
position: 'absolute',
left: '0',
top: '0',
height: '100%',
width: '1px',
backgroundColor: cssVarV2('button/innerBlackBorder'),
},
selectors: {
'&.secondary': {
backgroundColor: cssVarV2('button/secondary'),
color: cssVarV2('text/secondary'),
},
'&.secondary:hover': {
backgroundColor: cssVarV2('button/secondary'),
color: cssVarV2('text/secondary'),
},
},
});
globalStyle(`${copyLinkTriggerStyle} svg`, {
color: cssVarV2('button/pureWhiteText'),
transform: 'translateX(2px)',
});
globalStyle(`${copyLinkTriggerStyle}.secondary svg`, {
color: cssVarV2('text/secondary'),
transform: 'translateX(2px)',
});
export const copyLinkMenuItemStyle = style({
padding: '4px',
transition: 'all 0.3s',
});

View File

@@ -11,7 +11,7 @@ import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import * as styles from './index.css';
import * as styles from './copy-link-button.css';
export const CopyLinkButton = ({
workspaceId,

View File

@@ -0,0 +1,2 @@
export * from './members-permission';
export * from './public-page-button';

View File

@@ -0,0 +1,112 @@
import { Menu, MenuItem, MenuTrigger } from '@affine/component';
import { DocRole } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useCallback, useMemo, useState } from 'react';
import { PlanTag } from '../plan-tag';
import * as styles from './styles.css';
const getRoleName = (role: DocRole, t: ReturnType<typeof useI18n>) => {
switch (role) {
case DocRole.Manager:
return t['com.affine.share-menu.option.permission.can-manage']();
case DocRole.Editor:
return t['com.affine.share-menu.option.permission.can-edit']();
case DocRole.Reader:
return t['com.affine.share-menu.option.permission.can-read']();
default:
return '';
}
};
// TODO(@JimmFly): impl the real permission
export const MembersPermission = ({
openPaywallModal,
hittingPaywall,
}: {
hittingPaywall: boolean;
openPaywallModal?: () => void;
}) => {
const t = useI18n();
const [docRole, setDocRole] = useState<DocRole>(DocRole.Manager);
const currentRoleName = useMemo(() => getRoleName(docRole, t), [docRole, t]);
const changePermission = useCallback((newPermission: DocRole) => {
setDocRole(newPermission);
}, []);
const selectManage = useCallback(() => {
changePermission(DocRole.Manager);
}, [changePermission]);
const selectEdit = useCallback(() => {
if (hittingPaywall) {
openPaywallModal?.();
return;
}
changePermission(DocRole.Editor);
}, [changePermission, hittingPaywall, openPaywallModal]);
const selectRead = useCallback(() => {
if (hittingPaywall) {
openPaywallModal?.();
return;
}
changePermission(DocRole.Reader);
}, [changePermission, hittingPaywall, openPaywallModal]);
return (
<div className={styles.rowContainerStyle}>
<div className={styles.labelStyle}>
{t['com.affine.share-menu.option.permission.label']()}
</div>
<Menu
contentOptions={{
align: 'end',
}}
items={
<>
<MenuItem
onSelect={selectManage}
selected={docRole === DocRole.Manager}
>
<div className={styles.publicItemRowStyle}>
{t['com.affine.share-menu.option.permission.can-manage']()}
</div>
</MenuItem>
<MenuItem
onSelect={selectEdit}
selected={docRole === DocRole.Editor}
>
<div className={styles.publicItemRowStyle}>
<div className={styles.tagContainerStyle}>
{t['com.affine.share-menu.option.permission.can-edit']()}
<PlanTag />
</div>
</div>
</MenuItem>
<MenuItem
onSelect={selectRead}
selected={docRole === DocRole.Reader}
>
<div className={styles.publicItemRowStyle}>
<div className={styles.tagContainerStyle}>
{t['com.affine.share-menu.option.permission.can-read']()}
<PlanTag />
</div>
</div>
</MenuItem>
</>
}
>
<MenuTrigger
className={styles.menuTriggerStyle}
variant="plain"
contentStyle={{
width: '100%',
}}
>
{currentRoleName}
</MenuTrigger>
</Menu>
</div>
);
};

View File

@@ -0,0 +1,139 @@
import { Menu, MenuItem, MenuTrigger, notify } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { PublicDocMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import {
LockIcon,
SingleSelectCheckSolidIcon,
ViewIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useEffect } from 'react';
import * as styles from './styles.css';
export const PublicDoc = () => {
const t = useI18n();
const shareInfoService = useService(ShareInfoService);
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
useEffect(() => {
shareInfoService.shareInfo.revalidate();
}, [shareInfoService]);
const onDisablePublic = useAsyncCallback(async () => {
try {
await shareInfoService.shareInfo.disableShare();
notify.error({
title:
t[
'com.affine.share-menu.disable-publish-link.notification.success.title'
](),
message:
t[
'com.affine.share-menu.disable-publish-link.notification.success.message'
](),
});
} catch (err) {
notify.error({
title:
t[
'com.affine.share-menu.disable-publish-link.notification.fail.title'
](),
message:
t[
'com.affine.share-menu.disable-publish-link.notification.fail.message'
](),
});
console.log(err);
}
}, [shareInfoService, t]);
const onClickAnyoneReadOnlyShare = useAsyncCallback(async () => {
if (isSharedPage) {
return;
}
try {
// TODO(@JimmFly): remove mode when we have a better way to handle it
await shareInfoService.shareInfo.enableShare(PublicDocMode.Page);
track.$.sharePanel.$.createShareLink();
notify.success({
title:
t[
'com.affine.share-menu.create-public-link.notification.success.title'
](),
message:
t[
'com.affine.share-menu.create-public-link.notification.success.message'
](),
style: 'normal',
icon: <SingleSelectCheckSolidIcon color={cssVar('primaryColor')} />,
});
} catch (err) {
notify.error({
title:
t[
'com.affine.share-menu.confirm-modify-mode.notification.fail.title'
](),
message:
t[
'com.affine.share-menu.confirm-modify-mode.notification.fail.message'
](),
});
console.error(err);
}
}, [isSharedPage, shareInfoService.shareInfo, t]);
return (
<div className={styles.rowContainerStyle}>
<div className={styles.labelStyle}>
{t['com.affine.share-menu.option.link.label']()}
</div>
<Menu
contentOptions={{
align: 'end',
}}
items={
<>
<MenuItem
prefixIcon={<LockIcon />}
onSelect={onDisablePublic}
selected={!isSharedPage}
>
<div className={styles.publicItemRowStyle}>
<div>{t['com.affine.share-menu.option.link.no-access']()}</div>
</div>
</MenuItem>
<MenuItem
prefixIcon={<ViewIcon />}
onSelect={onClickAnyoneReadOnlyShare}
data-testid="share-link-menu-enable-share"
selected={!!isSharedPage}
>
<div className={styles.publicItemRowStyle}>
<div>{t['com.affine.share-menu.option.link.readonly']()}</div>
</div>
</MenuItem>
</>
}
>
<MenuTrigger
className={styles.menuTriggerStyle}
data-testid="share-link-menu-trigger"
variant="plain"
contentStyle={{
width: '100%',
}}
>
{isSharedPage
? t['com.affine.share-menu.option.link.readonly']()
: t['com.affine.share-menu.option.link.no-access']()}
</MenuTrigger>
</Menu>
</div>
);
};

View File

@@ -0,0 +1,38 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const menuTriggerStyle = style({
padding: '4px 10px',
borderRadius: '4px',
justifyContent: 'space-between',
display: 'flex',
fontSize: cssVar('fontSm'),
fontWeight: 400,
});
export const rowContainerStyle = style({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: '4px',
});
export const exportContainerStyle = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const labelStyle = style({
fontSize: cssVar('fontSm'),
fontWeight: 500,
});
export const publicItemRowStyle = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
export const tagContainerStyle = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
});

View File

@@ -0,0 +1,135 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const headerStyle = style({
display: 'flex',
alignItems: 'center',
fontSize: cssVar('fontSm'),
fontWeight: 600,
lineHeight: '22px',
padding: '0 4px',
gap: '4px',
});
export const content = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const menuStyle = style({
width: '390px',
minHeight: '310px',
maxHeight: '562px',
padding: '12px',
});
export const localMenuStyle = style({
width: '390px',
padding: '12px',
});
export const menuTriggerStyle = style({
width: '150px',
padding: '4px 10px',
justifyContent: 'space-between',
});
export const exportItemStyle = style({
padding: '4px',
transition: 'all 0.3s',
gap: '0px',
});
globalStyle(`${exportItemStyle} > div:first-child`, {
alignItems: 'center',
});
globalStyle(`${exportItemStyle} svg`, {
width: '16px',
height: '16px',
});
export const descriptionStyle = style({
wordWrap: 'break-word',
fontSize: cssVar('fontXs'),
lineHeight: '20px',
color: cssVarV2('text/secondary'),
textAlign: 'left',
padding: '0 6px',
});
export const containerStyle = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
gap: '8px',
});
export const indicatorContainerStyle = style({
position: 'relative',
});
export const columnContainerStyle = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
gap: '8px',
});
export const exportContainerStyle = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const labelStyle = style({
fontSize: cssVar('fontSm'),
fontWeight: 500,
});
export const disableSharePage = style({
color: cssVarV2('button/error'),
});
export const localSharePage = style({
padding: '12px 8px',
display: 'flex',
alignItems: 'center',
borderRadius: '8px',
backgroundColor: cssVarV2('layer/background/secondary'),
minHeight: '84px',
position: 'relative',
});
export const cloudSvgContainer = style({
width: '146px',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
position: 'absolute',
bottom: '0',
right: '0',
});
export const shareLinkStyle = style({
padding: '4px',
fontSize: cssVar('fontXs'),
fontWeight: 500,
lineHeight: '20px',
transform: 'translateX(-4px)',
gap: '4px',
});
globalStyle(`${shareLinkStyle} > span`, {
color: cssVarV2('text/link'),
});
globalStyle(`${shareLinkStyle} > div > svg`, {
color: cssVarV2('text/link'),
});
export const buttonContainer = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
fontWeight: 500,
});
export const button = style({
padding: '6px 8px',
height: 32,
});
export const shortcutStyle = style({
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
fontWeight: 400,
});
export const generalAccessStyle = style({
padding: '4px',
fontSize: cssVar('fontSm'),
color: cssVarV2('text/secondary'),
fontWeight: 500,
});

View File

@@ -0,0 +1,21 @@
import { Input } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { SearchIcon } from '@blocksuite/icons/rc';
import * as styles from './styles.css';
export const InviteInput = ({ onFocus }: { onFocus: () => void }) => {
const t = useI18n();
return (
<Input
preFix={<SearchIcon fontSize={20} />}
className={styles.inputStyle}
onFocus={onFocus}
inputStyle={{
paddingLeft: '0',
}}
placeholder={t['com.affine.share-menu.invite-editor.placeholder']()}
/>
);
};

View File

@@ -0,0 +1,148 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const containerStyle = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
gap: '8px',
height: '100%',
flex: 1,
overflow: 'hidden',
});
export const headerStyle = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
borderBottom: `1px solid ${cssVarV2('tab/divider/divider')}`,
cursor: 'pointer',
gap: '4px',
padding: '4px 4px 6px',
color: cssVarV2('text/secondary'),
});
export const iconStyle = style({
fontSize: '20px',
color: cssVarV2('icon/primary'),
});
export const memberListStyle = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
overflowY: 'auto',
flex: 1,
});
export const footerStyle = style({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
borderTop: `1px solid ${cssVarV2('tab/divider/divider')}`,
paddingTop: '8px',
});
export const manageMemberStyle = style({
color: cssVarV2('text/link'),
cursor: 'pointer',
fontSize: cssVar('fontSm'),
fontWeight: 500,
padding: '5px 4px',
});
export const searchInput = style({
flexGrow: 1,
border: 'none',
outline: 'none',
fontSize: '14px',
fontFamily: 'inherit',
color: 'inherit',
backgroundColor: 'transparent',
'::placeholder': {
color: cssVarV2('text/placeholder'),
},
});
export const InputContainer = style({
display: 'flex',
gap: '4px',
borderRadius: '4px',
padding: '4px',
flexWrap: 'wrap',
width: '100%',
border: `1px solid ${cssVarV2('input/border/default')}`,
selectors: {
'&.focus': {
borderColor: cssVarV2('input/border/active'),
},
},
});
export const inlineMembersContainer = style({
display: 'flex',
flexWrap: 'wrap',
width: '100%',
flex: 1,
gap: '4px',
maxHeight: '60px',
overflowY: 'auto',
});
export const roleSelectorContainer = style({
flexShrink: 0,
});
export const menuTriggerStyle = style({
padding: '1px 2px',
gap: '4px',
height: '22px',
borderRadius: '2px',
justifyContent: 'space-between',
display: 'flex',
fontSize: cssVar('fontXs'),
fontWeight: 400,
});
export const buttonsContainer = style({
display: 'flex',
flexDirection: 'row',
gap: '12px',
flexShrink: 0,
});
export const button = style({
padding: '4px 12px',
borderRadius: '4px',
fontSize: cssVar('fontSm'),
fontWeight: 500,
display: 'flex',
alignItems: 'center',
});
export const sentEmail = style({
display: 'flex',
flexDirection: 'row',
gap: '8px',
alignItems: 'center',
fontSize: cssVar('fontSm'),
cursor: 'pointer',
});
export const checkbox = style({
fontSize: 20,
color: cssVarV2('icon/primary'),
});
export const noFound = style({
fontSize: cssVar('fontSm'),
color: cssVarV2('text/secondary'),
});
export const scrollbar = style({
width: 6,
});
export const planTagContainer = style({
display: 'flex',
gap: '8px',
alignItems: 'center',
});

View File

@@ -0,0 +1,398 @@
import {
Button,
Checkbox,
Loading,
Menu,
MenuItem,
MenuTrigger,
notify,
RowInput,
} from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import {
DocGrantedUsersService,
type Member,
MemberSearchService,
} from '@affine/core/modules/permissions';
import {
DocRole,
UserFriendlyError,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { ArrowLeftBigIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
type CompositionEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import { PlanTag } from '../plan-tag';
import { Scroller } from '../scroller';
import * as styles from './invite-member-editor.css';
import { MemberItem } from './member-item';
import { SelectedMemberItem } from './selected-member-item';
const getRoleName = (role: DocRole, t: ReturnType<typeof useI18n>) => {
switch (role) {
case DocRole.Manager:
return t['com.affine.share-menu.option.permission.can-manage']();
case DocRole.Editor:
return t['com.affine.share-menu.option.permission.can-edit']();
case DocRole.Reader:
return t['com.affine.share-menu.option.permission.can-read']();
default:
return '';
}
};
export const InviteMemberEditor = ({
openPaywallModal,
hittingPaywall,
onClickCancel,
}: {
hittingPaywall: boolean;
openPaywallModal: () => void;
onClickCancel: () => void;
}) => {
const t = useI18n();
const [selectedMembers, setSelectedMembers] = useState<Member[]>([]);
const docGrantedUsersService = useService(DocGrantedUsersService);
const [inviteDocRoleType, setInviteDocRoleType] = useState<DocRole>(
DocRole.Manager
);
const memberSearchService = useService(MemberSearchService);
const searchText = useLiveData(memberSearchService.searchText$);
useEffect(() => {
// reset the search text when the component is mounted
memberSearchService.reset();
memberSearchService.loadMore();
}, [memberSearchService]);
const inputRef = useRef<HTMLInputElement>(null);
const [focused, setFocused] = useState(false);
const [composing, setComposing] = useState(false);
const handleValueChange = useCallback(
(value: string) => {
if (!composing) {
memberSearchService.search(value);
}
},
[composing, memberSearchService]
);
const [shouldSendEmail, setShouldSendEmail] = useState(false);
const workspaceDialogService = useService(WorkspaceDialogService);
const onInvite = useAsyncCallback(async () => {
const selectedMemberIds = selectedMembers.map(member => member.id);
try {
await docGrantedUsersService.grantUsersRole(
selectedMemberIds,
inviteDocRoleType
);
notify.success({
title: 'Invite successful',
});
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({
title: t[`error.${err.name}`](err.data),
});
}
}, [docGrantedUsersService, inviteDocRoleType, selectedMembers, t]);
const handleCompositionStart: CompositionEventHandler<HTMLInputElement> =
useCallback(() => {
setComposing(true);
}, []);
const handleCompositionEnd: CompositionEventHandler<HTMLInputElement> =
useCallback(
e => {
setComposing(false);
memberSearchService.search(e.currentTarget.value);
},
[memberSearchService]
);
const onCheckboxChange = useCallback(() => {
setShouldSendEmail(prev => !prev);
}, []);
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, []);
const onFocus = useCallback(() => {
setFocused(true);
}, []);
const onBlur = useCallback(() => {
setFocused(false);
}, []);
const handleRemoved = useCallback(
(memberId: string) => {
setSelectedMembers(prev => prev.filter(member => member.id !== memberId));
focusInput();
},
[focusInput]
);
const switchToMemberManagementTab = useCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'workspace:preference',
});
}, [workspaceDialogService]);
const handleClickMember = useCallback((member: Member) => {
setSelectedMembers(prev => {
if (prev.some(m => m.id === member.id)) {
// if the member is already in the list, just return
return prev;
}
return [...prev, member];
});
}, []);
const handleRoleChange = useCallback((role: DocRole) => {
setInviteDocRoleType(role);
}, []);
return (
<div className={styles.containerStyle}>
<div className={styles.headerStyle} onClick={onClickCancel}>
<ArrowLeftBigIcon className={styles.iconStyle} />
{t['com.affine.share-menu.invite-editor.header']()}
</div>
<div className={styles.memberListStyle}>
<div
className={clsx(styles.InputContainer, {
focus: focused,
})}
>
<div className={styles.inlineMembersContainer}>
{selectedMembers.map((member, idx) => {
if (!member) {
return null;
}
const onRemoved = () => handleRemoved(member.id);
return (
<SelectedMemberItem
key={member.id}
idx={idx}
onRemoved={onRemoved}
member={member}
/>
);
})}
<RowInput
ref={inputRef}
value={searchText}
onChange={handleValueChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onFocus={onFocus}
onBlur={onBlur}
autoFocus
className={styles.searchInput}
placeholder={t[
'com.affine.share-menu.invite-editor.placeholder'
]()}
/>
</div>
{!selectedMembers.length ? null : (
<RoleSelector
openPaywallModal={openPaywallModal}
hittingPaywall={hittingPaywall}
inviteDocRoleType={inviteDocRoleType}
onRoleChange={handleRoleChange}
/>
)}
</div>
<div className={styles.sentEmail} onClick={onCheckboxChange}>
<Checkbox
className={styles.checkbox}
checked={shouldSendEmail}
disabled // not supported yet
/>
{t['com.affine.share-menu.invite-editor.sent-email']()}
</div>
<Result onClickMember={handleClickMember} />
</div>
<div className={styles.footerStyle}>
<span
className={styles.manageMemberStyle}
onClick={switchToMemberManagementTab}
>
{t['com.affine.share-menu.invite-editor.manage-members']()}
</span>
<div className={styles.buttonsContainer}>
<Button className={styles.button} onClick={onClickCancel}>
{t['Cancel']()}
</Button>
<Button
className={styles.button}
variant="primary"
disabled={!selectedMembers.length}
onClick={onInvite}
>
{t['com.affine.share-menu.invite-editor.invite']()}
</Button>
</div>
</div>
</div>
);
};
const Result = ({
onClickMember,
}: {
onClickMember: (member: Member) => void;
}) => {
const memberSearchService = useService(MemberSearchService);
const result = useLiveData(memberSearchService.result$);
const isLoading = useLiveData(memberSearchService.isLoading$);
const activeMembers = useMemo(() => {
return result.filter(
member => member.status === WorkspaceMemberStatus.Accepted
);
}, [result]);
const itemContentRenderer = useCallback(
(_index: number, data: Member) => {
const handleSelect = () => {
onClickMember(data);
};
return (
<div onClick={handleSelect}>
<MemberItem member={data} />
</div>
);
},
[onClickMember]
);
const t = useI18n();
const loadMore = useCallback(() => {
memberSearchService.loadMore();
}, [memberSearchService]);
if (!activeMembers || activeMembers.length === 0) {
if (isLoading) {
return <Loading />;
}
return (
<div className={styles.noFound}>
{t['com.affine.share-menu.invite-editor.no-found']()}
</div>
);
}
return (
<Virtuoso
components={{
Scroller,
}}
data={activeMembers}
itemContent={itemContentRenderer}
endReached={loadMore}
/>
);
};
const RoleSelector = ({
openPaywallModal,
hittingPaywall,
inviteDocRoleType,
onRoleChange,
}: {
openPaywallModal: () => void;
inviteDocRoleType: DocRole;
onRoleChange: (role: DocRole) => void;
hittingPaywall: boolean;
}) => {
const t = useI18n();
const currentRoleName = useMemo(
() => getRoleName(inviteDocRoleType, t),
[inviteDocRoleType, t]
);
const changeToAdmin = useCallback(
() => onRoleChange(DocRole.Manager),
[onRoleChange]
);
const changeToWrite = useCallback(() => {
if (hittingPaywall) {
openPaywallModal();
return;
}
onRoleChange(DocRole.Editor);
}, [hittingPaywall, onRoleChange, openPaywallModal]);
const changeToRead = useCallback(() => {
if (hittingPaywall) {
openPaywallModal();
return;
}
onRoleChange(DocRole.Reader);
}, [hittingPaywall, onRoleChange, openPaywallModal]);
return (
<div className={styles.roleSelectorContainer}>
<Menu
contentOptions={{
align: 'end',
}}
items={
<>
<MenuItem
onSelect={changeToAdmin}
selected={inviteDocRoleType === DocRole.Manager}
>
{t['com.affine.share-menu.option.permission.can-manage']()}
</MenuItem>
<MenuItem
onSelect={changeToWrite}
selected={inviteDocRoleType === DocRole.Editor}
>
<div className={styles.planTagContainer}>
{t['com.affine.share-menu.option.permission.can-edit']()}
<PlanTag />
</div>
</MenuItem>
<MenuItem
onSelect={changeToRead}
selected={inviteDocRoleType === DocRole.Reader}
>
<div className={styles.planTagContainer}>
{t['com.affine.share-menu.option.permission.can-read']()}
<PlanTag />
</div>
</MenuItem>
</>
}
>
<MenuTrigger
className={styles.menuTriggerStyle}
variant="plain"
contentStyle={{
width: '100%',
}}
>
{currentRoleName}
</MenuTrigger>
</Menu>
</div>
);
};

View File

@@ -0,0 +1,68 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const memberItemStyle = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px',
width: '100%',
gap: '12px',
cursor: 'pointer',
selectors: {
'&:hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
borderRadius: '4px',
},
},
});
export const memberContainerStyle = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '12px',
flex: 1,
overflow: 'hidden',
width: '100%',
});
export const memberInfoStyle = style({
display: 'flex',
flexDirection: 'column',
width: '100%',
overflow: 'hidden',
});
export const memberNameStyle = style({
color: cssVarV2('text/primary'),
fontSize: cssVar('fontSm'),
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const memberEmailStyle = style({
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontXs'),
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const memberRoleStyle = style({
color: cssVarV2('text/primary'),
fontSize: cssVar('fontSm'),
flexShrink: 0,
selectors: {
'&.disable': {
color: cssVarV2('text/disable'),
},
},
});
export const tooltipContentStyle = style({
wordBreak: 'break-word',
});

View File

@@ -0,0 +1,39 @@
import { Avatar, Tooltip } from '@affine/component';
import type { Member } from '@affine/core/modules/permissions';
import * as styles from './member-item.css';
export const MemberItem = ({ member }: { member: Member }) => {
return (
<div className={styles.memberItemStyle}>
<div className={styles.memberContainerStyle}>
<Avatar
key={member.id}
url={member.avatarUrl || ''}
name={member.name || ''}
size={36}
/>
<div className={styles.memberInfoStyle}>
<Tooltip
content={member.name}
rootOptions={{ delayDuration: 1000 }}
options={{
className: styles.tooltipContentStyle,
}}
>
<div className={styles.memberNameStyle}>{member.name}</div>
</Tooltip>
<Tooltip
content={member.email}
rootOptions={{ delayDuration: 1000 }}
options={{
className: styles.tooltipContentStyle,
}}
>
<div className={styles.memberEmailStyle}>{member.email}</div>
</Tooltip>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,54 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const member = style({
height: '22px',
display: 'flex',
minWidth: 0,
alignItems: 'center',
justifyContent: 'space-between',
':last-child': {
minWidth: 'max-content',
},
});
export const memberInnerWrapper = style({
fontSize: 'inherit',
borderRadius: '2px',
columnGap: '4px',
borderWidth: '1px',
borderStyle: 'solid',
background: cssVar('backgroundPrimaryColor'),
maxWidth: '128px',
height: '100%',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '1px 2px',
color: cssVarV2('text/primary'),
borderColor: cssVarV2('layer/insideBorder/blackBorder'),
});
export const label = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
userSelect: 'none',
});
export const remove = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 14,
height: 14,
borderRadius: '2px',
flexShrink: 0,
cursor: 'pointer',
':hover': {
background: 'var(--affine-hover-color)',
},
});

View File

@@ -0,0 +1,45 @@
import type { Member } from '@affine/core/modules/permissions';
import { CloseIcon } from '@blocksuite/icons/rc';
import { type MouseEventHandler, useCallback } from 'react';
import * as styles from './selected-member-item.css';
export interface TagItemProps {
member: Member;
idx?: number;
onRemoved?: () => void;
style?: React.CSSProperties;
}
export const SelectedMemberItem = ({
member,
idx,
onRemoved,
style,
}: TagItemProps) => {
const handleRemove: MouseEventHandler<HTMLDivElement> = useCallback(
e => {
e.stopPropagation();
onRemoved?.();
},
[onRemoved]
);
return (
<div
className={styles.member}
data-idx={idx}
style={{
...style,
}}
>
<div className={styles.memberInnerWrapper}>
<div className={styles.label}>{member.name}</div>
{onRemoved ? (
<div className={styles.remove} onClick={handleRemove}>
<CloseIcon />
</div>
) : null}
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
import { style } from '@vanilla-extract/css';
export const inputStyle = style({
marginTop: '6px',
padding: '4px',
gap: '4px',
});

View File

@@ -0,0 +1,138 @@
import { Avatar, Skeleton, Tooltip } from '@affine/component';
import { DocGrantedUsersService } from '@affine/core/modules/permissions';
import { DocRole } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import clsx from 'clsx';
import { useEffect, useMemo } from 'react';
import * as styles from './styles.css';
export { MemberManagement } from './member-management';
export const MembersRow = ({ onClick }: { onClick: () => void }) => {
const t = useI18n();
const docGrantedUsersService = useService(DocGrantedUsersService);
const grantedUserList = useLiveData(docGrantedUsersService.grantedUsers$);
const grantedUserCount = useLiveData(
docGrantedUsersService.grantedUserCount$
);
const loading = useLiveData(docGrantedUsersService.isLoading$);
const docOwner = useLiveData(
docGrantedUsersService.grantedUsers$.map(users =>
users.find(user => user.role === DocRole.Owner)
)
);
const topThreeMembers = useMemo(
() =>
grantedUserList
?.slice(0, Math.min(3, grantedUserList.length))
.map(grantedUser => ({
name: grantedUser.user.name,
avatarUrl: grantedUser.user.avatarUrl,
id: grantedUser.user.id,
})),
[grantedUserList]
);
const description = useMemo(() => {
if (!grantedUserCount || !topThreeMembers || topThreeMembers.length <= 1) {
return '';
}
switch (grantedUserCount) {
case 2:
return t['com.affine.share-menu.member-management.member-count-2']({
member1: topThreeMembers[0].name,
member2: topThreeMembers[1].name,
});
case 3:
return t['com.affine.share-menu.member-management.member-count-3']({
member1: topThreeMembers[0].name,
member2: topThreeMembers[1].name,
member3: topThreeMembers[2].name,
});
default:
return t['com.affine.share-menu.member-management.member-count-more']({
member1: topThreeMembers[0].name,
member2: topThreeMembers[1].name,
memberCount: (grantedUserCount - 2).toString(),
});
}
}, [grantedUserCount, t, topThreeMembers]);
useEffect(() => {
docGrantedUsersService.reset();
docGrantedUsersService.loadMore();
}, [docGrantedUsersService]);
if (
grantedUserCount &&
topThreeMembers &&
topThreeMembers.length > 1 &&
grantedUserCount > 1
) {
return (
<Tooltip content={description}>
<div
className={clsx(styles.rowContainerStyle, 'clickable')}
onClick={onClick}
>
<div className={styles.memberContainerStyle}>
<div className={styles.avatarsContainerStyle}>
{topThreeMembers.map((member, index) => (
<Avatar
key={member.id}
url={member.avatarUrl || ''}
name={member.name || ''}
size={24}
style={{
marginLeft: index === 0 ? 0 : -8,
border: `1px solid ${cssVarV2('layer/white')}`,
}}
/>
))}
</div>
<span className={styles.descriptionStyle}>{description}</span>
</div>
<div className={styles.IconButtonStyle}>
<ArrowRightSmallIcon />
</div>
</div>
</Tooltip>
);
}
if (!docOwner && loading) {
// is loading
return (
<div className={styles.rowContainerStyle}>
<Skeleton />
</div>
);
}
// TODO(@JimmFly): handle the case when there is only one member
return (
<div
className={clsx(styles.rowContainerStyle, 'clickable')}
onClick={onClick}
>
<div className={styles.memberContainerStyle}>
<Avatar
url={docOwner?.user.avatarUrl || ''}
name={docOwner?.user.name}
size={24}
/>
<span>{docOwner?.user.name}</span>
</div>
<div className={styles.OwnerStyle}>{t['Owner']()}</div>
<div className={styles.IconButtonStyle}>
<ArrowRightSmallIcon />
</div>
</div>
);
};

View File

@@ -0,0 +1,80 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const memberItemStyle = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px',
width: '100%',
gap: '12px',
});
export const memberContainerStyle = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '12px',
flex: 1,
overflow: 'hidden',
width: '100%',
});
export const memberInfoStyle = style({
display: 'flex',
flexDirection: 'column',
width: '100%',
overflow: 'hidden',
});
export const memberNameStyle = style({
color: cssVarV2('text/primary'),
fontSize: cssVar('fontSm'),
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const memberEmailStyle = style({
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontXs'),
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const memberRoleStyle = style({
color: cssVarV2('text/primary'),
fontSize: cssVar('fontSm'),
flexShrink: 0,
selectors: {
'&.disable': {
color: cssVarV2('text/disable'),
},
},
});
export const tooltipContentStyle = style({
wordBreak: 'break-word',
});
export const menuTriggerStyle = style({
padding: '4px',
paddingRight: '0',
borderRadius: '4px',
gap: '4px',
display: 'flex',
fontSize: cssVar('fontSm'),
fontWeight: 400,
});
export const remove = style({
color: cssVarV2('status/error'),
});
export const planTagContainer = style({
display: 'flex',
gap: '8px',
alignItems: 'center',
});

View File

@@ -0,0 +1,236 @@
import {
Avatar,
Menu,
MenuItem,
MenuSeparator,
MenuTrigger,
notify,
Tooltip,
} from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import {
DocGrantedUsersService,
type GrantedUser,
} from '@affine/core/modules/permissions';
import { DocRole, UserFriendlyError } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { PlanTag } from '../plan-tag';
import * as styles from './member-item.css';
export const MemberItem = ({
openPaywallModal,
hittingPaywall,
grantedUser,
}: {
grantedUser: GrantedUser;
hittingPaywall: boolean;
openPaywallModal: () => void;
}) => {
const user = grantedUser.user;
const role = useMemo(() => {
switch (grantedUser.role) {
case DocRole.Owner:
return 'Owner';
case DocRole.Manager:
return 'Can manage';
case DocRole.Editor:
return 'Can edit';
case DocRole.Reader:
return 'Can read';
default:
return '';
}
}, [grantedUser.role]);
return (
<div className={styles.memberItemStyle}>
<div className={styles.memberContainerStyle}>
<Avatar
key={user.id}
url={user.avatarUrl || ''}
name={user.name}
size={36}
/>
<div className={styles.memberInfoStyle}>
<Tooltip
content={user.name}
rootOptions={{ delayDuration: 1000 }}
options={{
className: styles.tooltipContentStyle,
}}
>
<div className={styles.memberNameStyle}>{user.name}</div>
</Tooltip>
<Tooltip
content={user.email}
rootOptions={{ delayDuration: 1000 }}
options={{
className: styles.tooltipContentStyle,
}}
>
<div className={styles.memberEmailStyle}>{user.email}</div>
</Tooltip>
</div>
</div>
{/* TODO(@eyhn): add guard here */}
<Menu
items={
<Options
userId={user.id}
memberRole={grantedUser.role}
hittingPaywall={hittingPaywall}
openPaywallModal={openPaywallModal}
/>
}
contentOptions={{
align: 'start',
}}
>
<MenuTrigger
variant="plain"
className={styles.menuTriggerStyle}
contentStyle={{
width: '100%',
}}
>
{role}
</MenuTrigger>
</Menu>
</div>
);
};
const Options = ({
openPaywallModal,
hittingPaywall,
memberRole,
userId,
}: {
userId: string;
memberRole: DocRole;
hittingPaywall: boolean;
openPaywallModal: () => void;
}) => {
const t = useI18n();
const docGrantedUsersService = useService(DocGrantedUsersService);
const changeToManager = useAsyncCallback(async () => {
try {
await docGrantedUsersService.updateUserRole(userId, DocRole.Manager);
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({
title: t[`error.${err.name}`](err.data),
});
}
}, [docGrantedUsersService, userId, t]);
const changeToEditor = useAsyncCallback(async () => {
if (hittingPaywall) {
openPaywallModal();
return;
}
try {
await docGrantedUsersService.updateUserRole(userId, DocRole.Editor);
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({
title: t[`error.${err.name}`](err.data),
});
}
}, [docGrantedUsersService, hittingPaywall, openPaywallModal, userId, t]);
const changeToReader = useAsyncCallback(async () => {
if (hittingPaywall) {
openPaywallModal();
return;
}
try {
await docGrantedUsersService.updateUserRole(userId, DocRole.Reader);
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({
title: t[`error.${err.name}`](err.data),
});
}
}, [docGrantedUsersService, hittingPaywall, openPaywallModal, userId, t]);
const changeToOwner = useAsyncCallback(async () => {
try {
await docGrantedUsersService.updateUserRole(userId, DocRole.Owner);
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({
title: t[`error.${err.name}`](err.data),
});
}
}, [docGrantedUsersService, userId, t]);
const removeMember = useAsyncCallback(async () => {
try {
await docGrantedUsersService.revokeUsersRole(userId);
} catch (error) {
const err = UserFriendlyError.fromAnyError(error);
notify.error({
title: t[`error.${err.name}`](err.data),
});
}
}, [docGrantedUsersService, userId, t]);
const operationButtonInfo = useMemo(() => {
return [
{
label: t['com.affine.share-menu.option.permission.can-manage'](),
onClick: changeToManager,
role: DocRole.Manager,
show: true, // TODO(@eyhn): add guard here
},
{
label: t['com.affine.share-menu.option.permission.can-edit'](),
onClick: changeToEditor,
role: DocRole.Editor,
show: true, // TODO(@eyhn): add guard here
showPlanTag: true,
},
{
label: t['com.affine.share-menu.option.permission.can-read'](),
onClick: changeToReader,
role: DocRole.Reader,
show: true, // TODO(@eyhn): add guard here
showPlanTag: true,
},
];
}, [changeToEditor, changeToManager, changeToReader, t]);
return (
<>
{operationButtonInfo.map(item =>
item.show ? (
<MenuItem
key={item.label}
onSelect={item.onClick}
selected={memberRole === item.role}
>
<div className={styles.planTagContainer}>
{item.label} {item.showPlanTag ? <PlanTag /> : null}
</div>
</MenuItem>
) : null
)}
{/* TODO(@eyhn): add guard here */}
<MenuItem onSelect={changeToOwner}>
{t['com.affine.share-menu.member-management.set-as-owner']()}
</MenuItem>
{/* TODO(@eyhn): add guard here */}
<MenuSeparator />
<MenuItem onSelect={removeMember} type="danger" className={styles.remove}>
{t['com.affine.share-menu.member-management.remove']()}
</MenuItem>
</>
);
};

View File

@@ -0,0 +1,60 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const containerStyle = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
gap: '8px',
height: '100%',
flex: 1,
overflowY: 'hidden',
});
export const headerStyle = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
borderBottom: `1px solid ${cssVarV2('tab/divider/divider')}`,
cursor: 'pointer',
gap: '4px',
padding: '4px 4px 6px',
color: cssVarV2('text/secondary'),
});
export const iconStyle = style({
fontSize: '20px',
color: cssVarV2('icon/primary'),
});
export const footerStyle = style({
display: 'flex',
flexDirection: 'row',
borderTop: `1px solid ${cssVarV2('tab/divider/divider')}`,
paddingTop: '8px',
});
export const addCollaboratorsStyle = style({
color: cssVarV2('text/link'),
cursor: 'pointer',
fontSize: cssVar('fontSm'),
fontWeight: 500,
padding: '5px 4px',
});
export const memberListStyle = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
paddingTop: '6px',
maxHeight: '455px',
});
export const scrollableRootStyle = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
});
export const scrollbar = style({
width: 6,
});

View File

@@ -0,0 +1,112 @@
import { Skeleton } from '@affine/component';
import {
DocGrantedUsersService,
type GrantedUser,
} from '@affine/core/modules/permissions';
import { useI18n } from '@affine/i18n';
import { ArrowLeftBigIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Scroller } from '../scroller';
import { MemberItem } from './member-item';
import * as styles from './member-management.css';
export const MemberManagement = ({
openPaywallModal,
hittingPaywall,
onClickBack,
onClickInvite,
}: {
hittingPaywall: boolean;
openPaywallModal: () => void;
onClickBack: () => void;
onClickInvite: () => void;
}) => {
const docGrantedUsersService = useService(DocGrantedUsersService);
const grantedUserList = useLiveData(docGrantedUsersService.grantedUsers$);
const grantedUserCount = useLiveData(
docGrantedUsersService.grantedUserCount$
);
const t = useI18n();
useEffect(() => {
// reset the list when mounted
docGrantedUsersService.reset();
docGrantedUsersService.loadMore();
}, [docGrantedUsersService]);
const loadMore = useCallback(() => {
docGrantedUsersService.loadMore();
}, [docGrantedUsersService]);
return (
<div className={styles.containerStyle}>
<div className={styles.headerStyle} onClick={onClickBack}>
<ArrowLeftBigIcon className={styles.iconStyle} />
{t['com.affine.share-menu.member-management.header']({
memberCount: grantedUserCount?.toString() || '??',
})}
</div>
{grantedUserList ? (
<MemberList
openPaywallModal={openPaywallModal}
hittingPaywall={hittingPaywall}
grantedUserList={grantedUserList}
grantedUserCount={grantedUserCount}
loadMore={loadMore}
/>
) : (
<Skeleton className={styles.scrollableRootStyle} />
)}
{/* TODO(@eyhn): add guard here */}
<div className={styles.footerStyle}>
<span className={styles.addCollaboratorsStyle} onClick={onClickInvite}>
{t['com.affine.share-menu.member-management.add-collaborators']()}
</span>
</div>
</div>
);
};
const MemberList = ({
openPaywallModal,
hittingPaywall,
grantedUserList,
grantedUserCount,
loadMore,
}: {
hittingPaywall: boolean;
grantedUserList: GrantedUser[];
grantedUserCount?: number;
openPaywallModal: () => void;
loadMore: () => void;
}) => {
const itemContentRenderer = useCallback(
(_index: number, data: GrantedUser) => {
return (
<MemberItem
key={data.user.id}
grantedUser={data}
openPaywallModal={openPaywallModal}
hittingPaywall={hittingPaywall}
/>
);
},
[hittingPaywall, openPaywallModal]
);
return (
<Virtuoso
components={{
Scroller,
}}
data={grantedUserList}
itemContent={itemContentRenderer}
totalCount={grantedUserCount}
endReached={loadMore}
/>
);
};

View File

@@ -0,0 +1,77 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const menuTriggerStyle = style({
width: '150px',
padding: '4px 10px',
justifyContent: 'space-between',
});
export const rowContainerStyle = style({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: '4px',
selectors: {
'&.clickable:hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
cursor: 'pointer',
borderRadius: '4px',
},
},
});
export const memberContainerStyle = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '8px',
fontSize: cssVar('fontSm'),
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
overflow: 'hidden',
});
export const descriptionStyle = style({
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
width: '100%',
});
export const IconButtonStyle = style({
flexShrink: 0,
marginLeft: '8px',
fontSize: '20px',
display: 'flex',
alignItems: 'center',
color: cssVarV2('icon/primary'),
});
export const OwnerStyle = style({
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontSm'),
});
export const avatarsContainerStyle = style({
display: 'flex',
flexDirection: 'row',
});
export const openWorkspaceSettingsStyle = style({
color: cssVarV2('text/link'),
fontSize: cssVar('fontXs'),
fontWeight: 500,
display: 'flex',
gap: '8px',
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%',
padding: '4px',
cursor: 'pointer',
});
globalStyle(`${openWorkspaceSettingsStyle} svg`, {
color: cssVarV2('text/link'),
});

View File

@@ -0,0 +1,15 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const containerStyle = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: cssVar('fontXs'),
fontWeight: 500,
lineHeight: '20px',
padding: '0 4px',
color: cssVarV2('button/pureWhiteText'),
backgroundColor: cssVarV2('button/primary'),
borderRadius: '4px',
});

View File

@@ -0,0 +1,5 @@
import { containerStyle } from './plan-tag.css';
export const PlanTag = () => {
return <div className={containerStyle}>Pro</div>;
};

View File

@@ -0,0 +1,5 @@
import { style } from '@vanilla-extract/css';
export const result = style({
minHeight: '164px',
maxHeight: '342px',
});

View File

@@ -0,0 +1,20 @@
import { Scrollable } from '@affine/component';
import { forwardRef, type HTMLAttributes, type PropsWithChildren } from 'react';
import * as styles from './scroller.css';
export const Scroller = forwardRef<
HTMLDivElement,
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
>(({ children, ...props }, ref) => {
return (
<Scrollable.Root>
<Scrollable.Viewport {...props} className={styles.result} ref={ref}>
{children}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
);
});
Scroller.displayName = 'Scroller';

View File

@@ -0,0 +1,275 @@
import { Tabs, Tooltip, useConfirmModal } from '@affine/component';
import { Button } from '@affine/component/ui/button';
import { Menu } from '@affine/component/ui/menu';
import {
ServerService,
WorkspaceSubscriptionService,
} from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import type { WorkspaceMetadata } from '@affine/core/modules/workspace';
import { ServerDeploymentType, SubscriptionPlan } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import type { Store } from '@blocksuite/affine/store';
import { LockIcon, PublishIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import {
forwardRef,
type PropsWithChildren,
type Ref,
useCallback,
useEffect,
useState,
} from 'react';
import * as styles from './index.css';
import { InviteMemberEditor } from './invite-member-editor/invite-member-editor';
import { MemberManagement } from './member-management';
import { ShareExport } from './share-export';
import { SharePage } from './share-page';
export interface ShareMenuProps extends PropsWithChildren {
workspaceMetadata: WorkspaceMetadata;
currentPage: Store;
onEnableAffineCloud: () => void;
onOpenShareModal?: (open: boolean) => void;
openPaywallModal?: () => void;
hittingPaywall?: boolean;
}
export enum ShareMenuTab {
Share = 'share',
Export = 'export',
Invite = 'invite',
Members = 'members',
}
export const ShareMenuContent = (props: ShareMenuProps) => {
const t = useI18n();
const [currentTab, setCurrentTab] = useState(ShareMenuTab.Share);
const serverService = useService(ServerService);
const isSelfhosted = useLiveData(
serverService.server.config$.selector(
c => c.type === ServerDeploymentType.Selfhosted
)
);
const workspaceSubscriptionService = useService(WorkspaceSubscriptionService);
const subscription = useLiveData(
workspaceSubscriptionService.subscription.subscription$
);
const hittingPaywall =
(!subscription && !isSelfhosted) ||
(subscription && subscription.plan === SubscriptionPlan.Free);
const permissionService = useService(WorkspacePermissionService);
const isOwner = useLiveData(permissionService.permission.isOwner$);
const workspaceDialogService = useService(WorkspaceDialogService);
const onValueChange = useCallback((value: string) => {
setCurrentTab(value as ShareMenuTab);
}, []);
useEffect(() => {
workspaceSubscriptionService.subscription.revalidate();
}, [workspaceSubscriptionService]);
const { openConfirmModal } = useConfirmModal();
const onConfirm = useCallback(() => {
if (!isOwner) {
return;
}
workspaceDialogService.open('setting', {
activeTab: 'plans',
scrollAnchor: 'cloudPricingPlan',
});
return;
}, [isOwner, workspaceDialogService]);
const openPaywallModal = useCallback(() => {
openConfirmModal({
title:
t[
`com.affine.share-menu.paywall.${isOwner ? 'owner' : 'member'}.title`
](),
description:
t[
`com.affine.share-menu.paywall.${isOwner ? 'owner' : 'member'}.description`
](),
confirmText:
t[
`com.affine.share-menu.paywall.${isOwner ? 'owner' : 'member'}.confirm`
](),
onConfirm: onConfirm,
cancelText: t['Cancel'](),
cancelButtonOptions: {
style: {
visibility: isOwner ? 'visible' : 'hidden',
},
},
confirmButtonOptions: {
variant: isOwner ? 'primary' : 'custom',
},
});
}, [isOwner, onConfirm, openConfirmModal, t]);
if (currentTab === ShareMenuTab.Members) {
return (
<MemberManagement
openPaywallModal={openPaywallModal}
hittingPaywall={!!hittingPaywall}
onClickBack={() => {
setCurrentTab(ShareMenuTab.Share);
}}
onClickInvite={() => {
setCurrentTab(ShareMenuTab.Invite);
}}
/>
);
}
if (currentTab === ShareMenuTab.Invite) {
return (
<InviteMemberEditor
openPaywallModal={openPaywallModal}
hittingPaywall={!!hittingPaywall}
onClickCancel={() => {
setCurrentTab(ShareMenuTab.Share);
}}
/>
);
}
return (
<div className={styles.containerStyle}>
<Tabs.Root
defaultValue={ShareMenuTab.Share}
value={currentTab}
onValueChange={onValueChange}
>
<Tabs.List>
<Tabs.Trigger value={ShareMenuTab.Share}>
{t['com.affine.share-menu.shareButton']()}
</Tabs.Trigger>
<Tabs.Trigger value={ShareMenuTab.Export}>
{t['Export']()}
</Tabs.Trigger>
<Tabs.Trigger value={ShareMenuTab.Invite} style={{ display: 'none' }}>
invite
</Tabs.Trigger>
<Tabs.Trigger
value={ShareMenuTab.Members}
style={{ display: 'none' }}
>
members
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value={ShareMenuTab.Share}>
<SharePage
hittingPaywall={!!hittingPaywall}
openPaywallModal={openPaywallModal}
onClickInvite={() => {
setCurrentTab(ShareMenuTab.Invite);
}}
onClickMembers={() => {
setCurrentTab(ShareMenuTab.Members);
}}
{...props}
/>
</Tabs.Content>
<Tabs.Content value={ShareMenuTab.Export}>
<ShareExport />
</Tabs.Content>
<Tabs.Content value={ShareMenuTab.Invite}>
<div>null</div>
</Tabs.Content>
<Tabs.Content value={ShareMenuTab.Members}>
<div>null</div>
</Tabs.Content>
</Tabs.Root>
</div>
);
};
const DefaultShareButton = forwardRef(function DefaultShareButton(
_,
ref: Ref<HTMLButtonElement>
) {
const t = useI18n();
const shareInfoService = useService(ShareInfoService);
const shared = useLiveData(shareInfoService.shareInfo.isShared$);
useEffect(() => {
shareInfoService.shareInfo.revalidate();
}, [shareInfoService]);
return (
<Tooltip
content={
shared
? t['com.affine.share-menu.option.link.readonly.description']()
: t['com.affine.share-menu.option.link.no-access.description']()
}
>
<Button ref={ref} className={styles.button}>
<div className={styles.buttonContainer}>
{shared ? <PublishIcon fontSize={16} /> : <LockIcon fontSize={16} />}
{t['com.affine.share-menu.shareButton']()}
</div>
</Button>
</Tooltip>
);
});
const LocalShareMenu = (props: ShareMenuProps) => {
return (
<Menu
items={<ShareMenuContent {...props} />}
contentOptions={{
className: styles.localMenuStyle,
['data-testid' as string]: 'local-share-menu',
align: 'end',
}}
rootOptions={{
modal: false,
onOpenChange: props.onOpenShareModal,
}}
>
<div data-testid="local-share-menu-button">
{props.children || <DefaultShareButton />}
</div>
</Menu>
);
};
const CloudShareMenu = (props: ShareMenuProps) => {
return (
<Menu
items={<ShareMenuContent {...props} />}
contentOptions={{
className: styles.menuStyle,
['data-testid' as string]: 'cloud-share-menu',
align: 'end',
}}
rootOptions={{
modal: false,
onOpenChange: props.onOpenShareModal,
}}
>
<div data-testid="cloud-share-menu-button">
{props.children || <DefaultShareButton />}
</div>
</Menu>
);
};
export const ShareMenu = (props: ShareMenuProps) => {
const { workspaceMetadata } = props;
if (workspaceMetadata.flavour === 'local') {
return <LocalShareMenu {...props} />;
}
return <CloudShareMenu {...props} />;
};

View File

@@ -0,0 +1,122 @@
import { Skeleton } from '@affine/component';
import { Button } from '@affine/component/ui/button';
import { ServerService } from '@affine/core/modules/cloud';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { Suspense, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { CloudSvg } from '../cloud-svg';
import { CopyLinkButton } from './copy-link-button';
import { MembersPermission, PublicDoc } from './general-access';
import * as styles from './index.css';
import { InviteInput } from './invite-member-editor';
import { MembersRow } from './member-management';
import type { ShareMenuProps } from './share-menu';
export const LocalSharePage = (props: ShareMenuProps) => {
const t = useI18n();
const {
workspaceMetadata: { id: workspaceId },
} = props;
return (
<>
<div className={styles.localSharePage}>
<div className={styles.columnContainerStyle} style={{ gap: '12px' }}>
<div
className={styles.descriptionStyle}
style={{ maxWidth: '230px' }}
>
{t['com.affine.share-menu.EnableCloudDescription']()}
</div>
<div>
<Button
onClick={props.onEnableAffineCloud}
variant="primary"
data-testid="share-menu-enable-affine-cloud-button"
>
{t['Enable AFFiNE Cloud']()}
</Button>
</div>
</div>
<div className={styles.cloudSvgContainer}>
<CloudSvg />
</div>
</div>
<CopyLinkButton workspaceId={workspaceId} secondary />
</>
);
};
export const AFFiNESharePage = (
props: ShareMenuProps & {
onClickInvite: () => void;
onClickMembers: () => void;
}
) => {
const t = useI18n();
const {
workspaceMetadata: { id: workspaceId },
} = props;
const shareInfoService = useService(ShareInfoService);
const serverService = useService(ServerService);
useEffect(() => {
shareInfoService.shareInfo.revalidate();
}, [shareInfoService]);
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
const sharedMode = useLiveData(shareInfoService.shareInfo.sharedMode$);
const baseUrl = serverService.server.baseUrl;
const isLoading =
isSharedPage === null || sharedMode === null || baseUrl === null;
if (isLoading) {
// TODO(@eyhn): loading and error UI
return (
<>
<Skeleton height={100} />
<Skeleton height={40} />
</>
);
}
return (
<div className={styles.content}>
<div className={styles.columnContainerStyle}>
<InviteInput onFocus={props.onClickInvite} />
<MembersRow onClick={props.onClickMembers} />
<div className={styles.generalAccessStyle}>
{t['com.affine.share-menu.generalAccess']()}
</div>
<MembersPermission
openPaywallModal={props.openPaywallModal}
hittingPaywall={!!props.hittingPaywall}
/>
<PublicDoc />
</div>
<CopyLinkButton workspaceId={workspaceId} />
</div>
);
};
export const SharePage = (
props: ShareMenuProps & {
onClickInvite: () => void;
onClickMembers: () => void;
}
) => {
if (props.workspaceMetadata.flavour === 'local') {
return <LocalSharePage {...props} />;
} else {
return (
// TODO(@eyhn): refactor this part
<ErrorBoundary fallback={null}>
<Suspense>
<AFFiNESharePage {...props} />
</Suspense>
</ErrorBoundary>
);
}
};

View File

@@ -8,7 +8,7 @@ export interface UserFriendlyErrorResponse {
type: string;
name: ErrorNames;
message: string;
args?: any;
data?: any;
stacktrace?: string;
}
@@ -21,7 +21,7 @@ export class UserFriendlyError
readonly type = this.response.type;
override readonly name = this.response.name;
override readonly message = this.response.message;
readonly args = this.response.args;
readonly data = this.response.data;
readonly stacktrace = this.response.stacktrace;
static fromAnyError(response: any) {

View File

@@ -1,7 +1,7 @@
query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) {
query getMembersByWorkspaceId($workspaceId: String!, $skip: Int, $take: Int, $query: String) {
workspace(id: $workspaceId) {
memberCount
members(skip: $skip, take: $take) {
members(skip: $skip, take: $take, query: $query) {
id
name
email

View File

@@ -0,0 +1,24 @@
query getPageGrantedUsersList($pagination: PaginationInput!, $docId: String!, $workspaceId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
grantedUsersList(pagination: $pagination) {
totalCount
pageInfo {
endCursor
hasNextPage
}
edges {
node {
role
user {
id
name
email
avatarUrl
}
}
}
}
}
}
}

View File

@@ -0,0 +1,3 @@
mutation grantDocUserRoles($input: GrantDocUserRolesInput!) {
grantDocUserRoles(input: $input)
}

View File

@@ -525,10 +525,10 @@ export const getMembersByWorkspaceIdQuery = {
definitionName: 'workspace',
containsFile: false,
query: `
query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) {
query getMembersByWorkspaceId($workspaceId: String!, $skip: Int, $take: Int, $query: String) {
workspace(id: $workspaceId) {
memberCount
members(skip: $skip, take: $take) {
members(skip: $skip, take: $take, query: $query) {
id
name
email
@@ -555,6 +555,38 @@ query oauthProviders {
}`,
};
export const getPageGrantedUsersListQuery = {
id: 'getPageGrantedUsersListQuery' as const,
operationName: 'getPageGrantedUsersList',
definitionName: 'workspace',
containsFile: false,
query: `
query getPageGrantedUsersList($pagination: PaginationInput!, $docId: String!, $workspaceId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
grantedUsersList(pagination: $pagination) {
totalCount
pageInfo {
endCursor
hasNextPage
}
edges {
node {
role
user {
id
name
email
avatarUrl
}
}
}
}
}
}
}`,
};
export const getPromptsQuery = {
id: 'getPromptsQuery' as const,
operationName: 'getPrompts',
@@ -830,6 +862,17 @@ query getWorkspaces {
}`,
};
export const grantDocUserRolesMutation = {
id: 'grantDocUserRolesMutation' as const,
operationName: 'grantDocUserRoles',
definitionName: 'grantDocUserRoles',
containsFile: false,
query: `
mutation grantDocUserRoles($input: GrantDocUserRolesInput!) {
grantDocUserRoles(input: $input)
}`,
};
export const listHistoryQuery = {
id: 'listHistoryQuery' as const,
operationName: 'listHistory',
@@ -1019,6 +1062,17 @@ mutation resumeSubscription($plan: SubscriptionPlan = Pro, $workspaceId: String)
}`,
};
export const revokeDocUserRolesMutation = {
id: 'revokeDocUserRolesMutation' as const,
operationName: 'revokeDocUserRoles',
definitionName: 'revokeDocUserRoles',
containsFile: false,
query: `
mutation revokeDocUserRoles($input: RevokeDocUserRoleInput!) {
revokeDocUserRoles(input: $input)
}`,
};
export const revokeMemberPermissionMutation = {
id: 'revokeMemberPermissionMutation' as const,
operationName: 'revokeMemberPermission',
@@ -1196,6 +1250,17 @@ mutation updateCopilotSession($options: UpdateChatSessionInput!) {
}`,
};
export const updateDocUserRoleMutation = {
id: 'updateDocUserRoleMutation' as const,
operationName: 'updateDocUserRole',
definitionName: 'updateDocUserRole',
containsFile: false,
query: `
mutation updateDocUserRole($input: UpdateDocUserRoleInput!) {
updateDocUserRole(input: $input)
}`,
};
export const updatePromptMutation = {
id: 'updatePromptMutation' as const,
operationName: 'updatePrompt',

View File

@@ -0,0 +1,3 @@
mutation revokeDocUserRoles($input: RevokeDocUserRoleInput!) {
revokeDocUserRoles(input: $input)
}

View File

@@ -0,0 +1,3 @@
mutation updateDocUserRole($input: UpdateDocUserRoleInput!) {
updateDocUserRole(input: $input)
}

View File

@@ -348,6 +348,7 @@ export enum ErrorNames {
CANNOT_DELETE_ALL_ADMIN_ACCOUNT = 'CANNOT_DELETE_ALL_ADMIN_ACCOUNT',
CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT',
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION = 'CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION',
CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS = 'CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS',
CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED',
COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN',
COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE',
@@ -925,7 +926,7 @@ export interface MutationRevokeArgs {
}
export interface MutationRevokeDocUserRolesArgs {
input: RevokeDocUserRolesInput;
input: RevokeDocUserRoleInput;
}
export interface MutationRevokeInviteLinkArgs {
@@ -1202,9 +1203,9 @@ export interface RemoveAvatar {
success: Scalars['Boolean']['output'];
}
export interface RevokeDocUserRolesInput {
export interface RevokeDocUserRoleInput {
docId: Scalars['String']['input'];
userIds: Array<Scalars['String']['input']>;
userId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
}
@@ -2160,8 +2161,9 @@ export type GetMemberCountByWorkspaceIdQuery = {
export type GetMembersByWorkspaceIdQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
skip: Scalars['Int']['input'];
take: Scalars['Int']['input'];
skip?: InputMaybe<Scalars['Int']['input']>;
take?: InputMaybe<Scalars['Int']['input']>;
query?: InputMaybe<Scalars['String']['input']>;
}>;
export type GetMembersByWorkspaceIdQuery = {
@@ -2193,6 +2195,45 @@ export type OauthProvidersQuery = {
};
};
export type GetPageGrantedUsersListQueryVariables = Exact<{
pagination: PaginationInput;
docId: Scalars['String']['input'];
workspaceId: Scalars['String']['input'];
}>;
export type GetPageGrantedUsersListQuery = {
__typename?: 'Query';
workspace: {
__typename?: 'WorkspaceType';
doc: {
__typename?: 'DocType';
grantedUsersList: {
__typename?: 'PaginatedGrantedDocUserType';
totalCount: number;
pageInfo: {
__typename?: 'PageInfo';
endCursor: string | null;
hasNextPage: boolean;
};
edges: Array<{
__typename?: 'GrantedDocUserTypeEdge';
node: {
__typename?: 'GrantedDocUserType';
role: DocRole;
user: {
__typename?: 'PublicUserType';
id: string;
name: string;
email: string;
avatarUrl: string | null;
};
};
}>;
};
};
};
};
export type GetPromptsQueryVariables = Exact<{ [key: string]: never }>;
export type GetPromptsQuery = {
@@ -2442,6 +2483,15 @@ export type GetWorkspacesQuery = {
}>;
};
export type GrantDocUserRolesMutationVariables = Exact<{
input: GrantDocUserRolesInput;
}>;
export type GrantDocUserRolesMutation = {
__typename?: 'Mutation';
grantDocUserRoles: boolean;
};
export type ListHistoryQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
pageDocId: Scalars['String']['input'];
@@ -2613,6 +2663,15 @@ export type ResumeSubscriptionMutation = {
};
};
export type RevokeDocUserRolesMutationVariables = Exact<{
input: RevokeDocUserRoleInput;
}>;
export type RevokeDocUserRolesMutation = {
__typename?: 'Mutation';
revokeDocUserRoles: boolean;
};
export type RevokeMemberPermissionMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
userId: Scalars['String']['input'];
@@ -2774,6 +2833,15 @@ export type UpdateCopilotSessionMutation = {
updateCopilotSession: string;
};
export type UpdateDocUserRoleMutationVariables = Exact<{
input: UpdateDocUserRoleInput;
}>;
export type UpdateDocUserRoleMutation = {
__typename?: 'Mutation';
updateDocUserRole: boolean;
};
export type UpdatePromptMutationVariables = Exact<{
name: Scalars['String']['input'];
messages: Array<CopilotPromptMessageInput> | CopilotPromptMessageInput;
@@ -3129,6 +3197,11 @@ export type Queries =
variables: OauthProvidersQueryVariables;
response: OauthProvidersQuery;
}
| {
name: 'getPageGrantedUsersListQuery';
variables: GetPageGrantedUsersListQueryVariables;
response: GetPageGrantedUsersListQuery;
}
| {
name: 'getPromptsQuery';
variables: GetPromptsQueryVariables;
@@ -3371,6 +3444,11 @@ export type Mutations =
variables: GenerateLicenseKeyMutationVariables;
response: GenerateLicenseKeyMutation;
}
| {
name: 'grantDocUserRolesMutation';
variables: GrantDocUserRolesMutationVariables;
response: GrantDocUserRolesMutation;
}
| {
name: 'leaveWorkspaceMutation';
variables: LeaveWorkspaceMutationVariables;
@@ -3396,6 +3474,11 @@ export type Mutations =
variables: ResumeSubscriptionMutationVariables;
response: ResumeSubscriptionMutation;
}
| {
name: 'revokeDocUserRolesMutation';
variables: RevokeDocUserRolesMutationVariables;
response: RevokeDocUserRolesMutation;
}
| {
name: 'revokeMemberPermissionMutation';
variables: RevokeMemberPermissionMutationVariables;
@@ -3451,6 +3534,11 @@ export type Mutations =
variables: UpdateCopilotSessionMutationVariables;
response: UpdateCopilotSessionMutation;
}
| {
name: 'updateDocUserRoleMutation';
variables: UpdateDocUserRoleMutationVariables;
response: UpdateDocUserRoleMutation;
}
| {
name: 'updatePromptMutation';
variables: UpdatePromptMutationVariables;

View File

@@ -5754,6 +5754,10 @@ export function useAFFiNEI18N(): {
* `Share doc`
*/
["com.affine.share-menu.SharePage"](): string;
/**
* `General access`
*/
["com.affine.share-menu.generalAccess"](): string;
/**
* `Share via export`
*/
@@ -5859,9 +5863,17 @@ export function useAFFiNEI18N(): {
*/
["com.affine.share-menu.option.link.readonly.description"](): string;
/**
* `Can Edit`
* `Can manage`
*/
["com.affine.share-menu.option.permission.can-manage"](): string;
/**
* `Can edit`
*/
["com.affine.share-menu.option.permission.can-edit"](): string;
/**
* `Can read`
*/
["com.affine.share-menu.option.permission.can-read"](): string;
/**
* `Members in workspace`
*/
@@ -5882,6 +5894,71 @@ export function useAFFiNEI18N(): {
* `Shared`
*/
["com.affine.share-menu.sharedButton"](): string;
/**
* `{{member1}} and {{member2}} are in this doc`
*/
["com.affine.share-menu.member-management.member-count-2"](options: Readonly<{
member1: string;
member2: string;
}>): string;
/**
* `{{member1}}, {{member2}} and {{member3}} are in this doc`
*/
["com.affine.share-menu.member-management.member-count-3"](options: Readonly<{
member1: string;
member2: string;
member3: string;
}>): string;
/**
* `{{member1}}, {{member2}} and {{memberCount}} others`
*/
["com.affine.share-menu.member-management.member-count-more"](options: Readonly<{
member1: string;
member2: string;
memberCount: string;
}>): string;
/**
* `Remove`
*/
["com.affine.share-menu.member-management.remove"](): string;
/**
* `Set as owner`
*/
["com.affine.share-menu.member-management.set-as-owner"](): string;
/**
* `{{memberCount}} collaborators in the doc`
*/
["com.affine.share-menu.member-management.header"](options: {
readonly memberCount: string;
}): string;
/**
* `Add collaborators`
*/
["com.affine.share-menu.member-management.add-collaborators"](): string;
/**
* `Send invite`
*/
["com.affine.share-menu.invite-editor.header"](): string;
/**
* `Manage members`
*/
["com.affine.share-menu.invite-editor.manage-members"](): string;
/**
* `Invite`
*/
["com.affine.share-menu.invite-editor.invite"](): string;
/**
* `No results found`
*/
["com.affine.share-menu.invite-editor.no-found"](): string;
/**
* `Invite other members`
*/
["com.affine.share-menu.invite-editor.placeholder"](): string;
/**
* `Notify via Email`
*/
["com.affine.share-menu.invite-editor.sent-email"](): string;
/**
* `Built with`
*/

View File

@@ -1439,6 +1439,7 @@
"com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.",
"com.affine.share-menu.ShareMode": "Share mode",
"com.affine.share-menu.SharePage": "Share doc",
"com.affine.share-menu.generalAccess": "General access",
"com.affine.share-menu.ShareViaExport": "Share via export",
"com.affine.share-menu.ShareViaExportDescription": "Download a static copy of your doc to share with others",
"com.affine.share-menu.ShareViaPrintDescription": "Print a paper copy",
@@ -1461,16 +1462,37 @@
"com.affine.share-menu.disable-publish-link.notification.success.title": "Public link disabled",
"com.affine.share-menu.navigate.workspace": "Manage workspace members",
"com.affine.share-menu.option.link.label": "Anyone with the link",
"com.affine.share-menu.option.link.no-access": "No Access",
"com.affine.share-menu.option.link.no-access": "No access",
"com.affine.share-menu.option.link.no-access.description": "Only workspace members can access this link",
"com.affine.share-menu.option.link.readonly": "Read Only",
"com.affine.share-menu.option.link.readonly": "Read only",
"com.affine.share-menu.option.link.readonly.description": "Anyone can access this link",
"com.affine.share-menu.option.permission.can-edit": "Can Edit",
"com.affine.share-menu.option.permission.can-manage": "Can manage",
"com.affine.share-menu.option.permission.can-edit": "Can edit",
"com.affine.share-menu.option.permission.can-read": "Can read",
"com.affine.share-menu.option.permission.label": "Members in workspace",
"com.affine.share-menu.publish-to-web": "Publish to web",
"com.affine.share-menu.share-privately": "Share privately",
"com.affine.share-menu.shareButton": "Share",
"com.affine.share-menu.sharedButton": "Shared",
"com.affine.share-menu.member-management.member-count-2": "{{member1}} and {{member2}} are in this doc",
"com.affine.share-menu.member-management.member-count-3": "{{member1}}, {{member2}} and {{member3}} are in this doc",
"com.affine.share-menu.member-management.member-count-more": "{{member1}}, {{member2}} and {{memberCount}} others",
"com.affine.share-menu.member-management.remove": "Remove",
"com.affine.share-menu.member-management.set-as-owner": "Set as owner",
"com.affine.share-menu.member-management.header": "{{memberCount}} collaborators in the doc",
"com.affine.share-menu.member-management.add-collaborators": "Add collaborators",
"com.affine.share-menu.invite-editor.header": "Send invite",
"com.affine.share-menu.invite-editor.manage-members": "Manage members",
"com.affine.share-menu.invite-editor.invite": "Invite",
"com.affine.share-menu.invite-editor.no-found": "No results found",
"com.affine.share-menu.invite-editor.placeholder": "Invite other members",
"com.affine.share-menu.invite-editor.sent-email": "Notify via Email",
"com.affine.share-menu.paywall.owner.title": "Permission not available in Free plan",
"com.affine.share-menu.paywall.owner.description": "Upgrade to Pro or higher to unlock permission settings for this doc.",
"com.affine.share-menu.paywall.owner.confirm": "Upgrade",
"com.affine.share-menu.paywall.member.title": "Permission requires a workspace upgrade",
"com.affine.share-menu.paywall.member.description": "Ask your workspace owner to upgrade to Pro or higher to enable permissions.",
"com.affine.share-menu.paywall.member.confirm": "Got it",
"com.affine.share-page.footer.built-with": "Built with",
"com.affine.share-page.footer.create-with": "Create with",
"com.affine.share-page.footer.description": "Empower your sharing with AFFiNE Cloud: One-click doc sharing",