mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 03:56:23 +08:00
feat(core): impl team workspace (#8920)
AF-1738 AF-1735 AF-1731 AF-1721 AF-1717 AF-1736 AF-1727 AF-1719 AF-1877 UI for team workspaces : - add upgrade to team & successful upgrade page ( `/upgrade-to-team` & `/upgrade-success/team`) - update team plans on pricing page ( settings —> pricing plans ) - update reaching the usage/member limit modal - update invite member modal - update member CRUD options
This commit is contained in:
@@ -224,6 +224,21 @@ export class WorkspaceResolver {
|
||||
return data?.user?.id === user.id;
|
||||
}
|
||||
|
||||
@Query(() => Boolean, {
|
||||
description: 'Get is admin of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async isAdmin(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
return this.permissions.tryCheckWorkspaceIs(
|
||||
workspaceId,
|
||||
user.id,
|
||||
Permission.Admin
|
||||
);
|
||||
}
|
||||
|
||||
@Query(() => [WorkspaceType], {
|
||||
description: 'Get all accessible workspaces for current user',
|
||||
complexity: 2,
|
||||
|
||||
@@ -594,6 +594,9 @@ type Query {
|
||||
"""send workspace invitation"""
|
||||
getInviteInfo(inviteId: String!): InvitationType!
|
||||
|
||||
"""Get is admin of workspace"""
|
||||
isAdmin(workspaceId: String!): Boolean!
|
||||
|
||||
"""Get is owner of workspace"""
|
||||
isOwner(workspaceId: String!): Boolean!
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface WorkspaceProfileInfo {
|
||||
avatar?: string;
|
||||
name?: string;
|
||||
isOwner?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isTeam?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,11 +19,12 @@ export class WorkspaceProfileCacheStore extends Store {
|
||||
}
|
||||
|
||||
const info = data as WorkspaceProfileInfo;
|
||||
|
||||
return {
|
||||
avatar: info.avatar,
|
||||
name: info.name,
|
||||
isOwner: info.isOwner,
|
||||
isAdmin: info.isAdmin,
|
||||
isTeam: info.isTeam,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 256 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
@@ -7,7 +7,8 @@ export const root = style({
|
||||
flexDirection: 'column',
|
||||
fontSize: cssVar('fontBase'),
|
||||
position: 'relative',
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
backgroundSize: 'cover',
|
||||
});
|
||||
export const affineLogo = style({
|
||||
color: 'inherit',
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { Logo1Icon } from '@blocksuite/icons/rc';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { type ReactNode, useCallback } from 'react';
|
||||
|
||||
import dotBgDark from './assets/dot-bg.dark.png';
|
||||
import dotBgLight from './assets/dot-bg.light.png';
|
||||
import { DesktopNavbar } from './desktop-navbar';
|
||||
import * as styles from './index.css';
|
||||
import { MobileNavbar } from './mobile-navbar';
|
||||
@@ -18,8 +21,15 @@ export const AffineOtherPageLayout = ({
|
||||
open(BUILD_CONFIG.downloadUrl, '_blank');
|
||||
}, []);
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
const backgroundImage =
|
||||
resolvedTheme === 'dark' && dotBgDark ? dotBgDark : dotBgLight;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div
|
||||
className={styles.root}
|
||||
style={{ backgroundImage: `url(${backgroundImage})` }}
|
||||
>
|
||||
{BUILD_CONFIG.isElectron ? (
|
||||
<div className={styles.draggableHeader} />
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { Empty } from '../../ui/empty';
|
||||
import { ThemedImg } from '../../ui/themed-img';
|
||||
import { AffineOtherPageLayout } from '../affine-other-page-layout';
|
||||
import { authPageContainer, hideInSmallScreen } from './share.css';
|
||||
import illustrationDark from '../affine-other-page-layout/assets/other-page.dark.png';
|
||||
import illustrationLight from '../affine-other-page-layout/assets/other-page.light.png';
|
||||
import {
|
||||
authPageContainer,
|
||||
hideInSmallScreen,
|
||||
illustration,
|
||||
} from './share.css';
|
||||
|
||||
export const AuthPageContainer: FC<
|
||||
PropsWithChildren<{
|
||||
@@ -20,7 +26,12 @@ export const AuthPageContainer: FC<
|
||||
{children}
|
||||
</div>
|
||||
<div className={hideInSmallScreen}>
|
||||
<Empty />
|
||||
<ThemedImg
|
||||
draggable={false}
|
||||
className={illustration}
|
||||
lightSrc={illustrationLight}
|
||||
darkSrc={illustrationDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,14 +159,33 @@ export const authPageContainer = style({
|
||||
globalStyle(`${authPageContainer} .wrapper`, {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
'@media': {
|
||||
'screen and (max-width: 1024px)': {
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${authPageContainer} .content`, {
|
||||
maxWidth: '700px',
|
||||
maxWidth: '810px',
|
||||
'@media': {
|
||||
'screen and (min-width: 1024px)': {
|
||||
marginLeft: '200px',
|
||||
minWidth: '500px',
|
||||
marginRight: '60px',
|
||||
flexGrow: 1,
|
||||
flexShrink: 0,
|
||||
flexBasis: 0,
|
||||
},
|
||||
'screen and (max-width: 1024px)': {
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
margin: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${authPageContainer} .title`, {
|
||||
fontSize: cssVar('fontTitle'),
|
||||
@@ -203,3 +222,8 @@ export const hideInSmallScreen = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const illustration = style({
|
||||
flexShrink: 0,
|
||||
width: '670px',
|
||||
});
|
||||
|
||||
+23
-18
@@ -4,7 +4,6 @@ import { useI18n } from '@affine/i18n';
|
||||
|
||||
import { Avatar } from '../../ui/avatar';
|
||||
import { Button } from '../../ui/button';
|
||||
import { FlexWrapper } from '../../ui/layout';
|
||||
import * as styles from './styles.css';
|
||||
export const AcceptInvitePage = ({
|
||||
onOpenWorkspace,
|
||||
@@ -18,23 +17,29 @@ export const AcceptInvitePage = ({
|
||||
<AuthPageContainer
|
||||
title={t['Successfully joined!']()}
|
||||
subtitle={
|
||||
<FlexWrapper alignItems="center">
|
||||
<Avatar
|
||||
url={inviteInfo.user.avatarUrl || ''}
|
||||
name={inviteInfo.user.name}
|
||||
size={20}
|
||||
/>
|
||||
<span className={styles.inviteName}>{inviteInfo.user.name}</span>
|
||||
{t['invited you to join']()}
|
||||
<Avatar
|
||||
url={`data:image/png;base64,${inviteInfo.workspace.avatar}`}
|
||||
name={inviteInfo.workspace.name}
|
||||
size={20}
|
||||
style={{ marginLeft: 4 }}
|
||||
colorfulFallback
|
||||
/>
|
||||
<span className={styles.inviteName}>{inviteInfo.workspace.name}</span>
|
||||
</FlexWrapper>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.userWrapper}>
|
||||
<Avatar
|
||||
url={inviteInfo.user.avatarUrl || ''}
|
||||
name={inviteInfo.user.name}
|
||||
size={20}
|
||||
/>
|
||||
<span className={styles.inviteName}>{inviteInfo.user.name}</span>
|
||||
</div>
|
||||
<div>{t['invited you to join']()}</div>
|
||||
<div className={styles.userWrapper}>
|
||||
<Avatar
|
||||
url={`data:image/png;base64,${inviteInfo.workspace.avatar}`}
|
||||
name={inviteInfo.workspace.name}
|
||||
size={20}
|
||||
style={{ marginLeft: 4 }}
|
||||
colorfulFallback
|
||||
/>
|
||||
<span className={styles.inviteName}>
|
||||
{inviteInfo.workspace.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button variant="primary" size="large" onClick={onOpenWorkspace}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './accept-invite-page';
|
||||
export * from './invite-modal';
|
||||
export * from './invite-team-modal';
|
||||
export * from './member-limit-modal';
|
||||
export * from './pagination';
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
|
||||
import Input from '../../../ui/input';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const EmailInvite = ({
|
||||
inviteEmail,
|
||||
setInviteEmail,
|
||||
handleConfirm,
|
||||
importCSV,
|
||||
isMutating,
|
||||
isValidEmail,
|
||||
}: {
|
||||
inviteEmail: string;
|
||||
setInviteEmail: (value: string) => void;
|
||||
handleConfirm: () => void;
|
||||
isMutating: boolean;
|
||||
isValidEmail: boolean;
|
||||
importCSV: React.ReactNode;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<>
|
||||
<div className={styles.modalSubTitle}>
|
||||
{t['com.affine.payment.member.team.invite.email-invite']()}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
inputStyle={{ fontSize: cssVar('fontXs') }}
|
||||
disabled={isMutating}
|
||||
placeholder={t[
|
||||
'com.affine.payment.member.team.invite.email-placeholder'
|
||||
]()}
|
||||
value={inviteEmail}
|
||||
onChange={setInviteEmail}
|
||||
onEnter={handleConfirm}
|
||||
size="large"
|
||||
/>
|
||||
{!isValidEmail ? (
|
||||
<div className={styles.errorHint}>
|
||||
{t['com.affine.auth.sign.email.error']()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>{importCSV}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { emailRegex } from '@affine/component/auth-components';
|
||||
import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ConfirmModal } from '../../../ui/modal';
|
||||
import { notify } from '../../../ui/notification';
|
||||
import { type InviteMethodType, ModalContent } from './modal-content';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export interface InviteTeamMemberModalProps {
|
||||
open: boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
onConfirm: (params: { emails: string[] }) => void;
|
||||
isMutating: boolean;
|
||||
copyTextToClipboard: (text: string) => Promise<boolean>;
|
||||
onGenerateInviteLink: (
|
||||
expireTime: WorkspaceInviteLinkExpireTime
|
||||
) => Promise<string>;
|
||||
onRevokeInviteLink: () => Promise<boolean>;
|
||||
importCSV: React.ReactNode;
|
||||
}
|
||||
|
||||
const parseEmailString = (emailString: string): string[] => {
|
||||
return emailString
|
||||
.split(',')
|
||||
.map(email => email.trim())
|
||||
.filter(email => email.length > 0);
|
||||
};
|
||||
|
||||
export const InviteTeamMemberModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
onConfirm,
|
||||
isMutating,
|
||||
copyTextToClipboard,
|
||||
onGenerateInviteLink,
|
||||
onRevokeInviteLink,
|
||||
importCSV,
|
||||
}: InviteTeamMemberModalProps) => {
|
||||
const t = useI18n();
|
||||
const [inviteEmails, setInviteEmails] = useState('');
|
||||
const [isValidEmail, setIsValidEmail] = useState(true);
|
||||
const [inviteMethod, setInviteMethod] = useState<InviteMethodType>('email');
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (inviteMethod === 'link') {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const inviteEmailsArray = parseEmailString(inviteEmails);
|
||||
const invalidEmail = inviteEmailsArray.find(
|
||||
email => !emailRegex.test(email)
|
||||
);
|
||||
if (invalidEmail) {
|
||||
setIsValidEmail(false);
|
||||
return;
|
||||
}
|
||||
setIsValidEmail(true);
|
||||
|
||||
onConfirm({
|
||||
emails: inviteEmailsArray,
|
||||
});
|
||||
notify.success({
|
||||
title: t['com.affine.payment.member.team.invite.notify.title'](),
|
||||
message: t['com.affine.payment.member.team.invite.notify.message'](),
|
||||
});
|
||||
}, [inviteEmails, inviteMethod, onConfirm, setOpen, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setInviteEmails('');
|
||||
setIsValidEmail(true);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
width={480}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t['com.affine.payment.member.team.invite.title']()}
|
||||
cancelText={t['com.affine.inviteModal.button.cancel']()}
|
||||
contentOptions={{
|
||||
['data-testid' as string]: 'invite-modal',
|
||||
style: {
|
||||
padding: '20px 24px',
|
||||
},
|
||||
}}
|
||||
confirmText={
|
||||
inviteMethod === 'email'
|
||||
? t['com.affine.payment.member.team.invite.send-invites']()
|
||||
: t['com.affine.payment.member.team.invite.done']()
|
||||
}
|
||||
confirmButtonOptions={{
|
||||
loading: isMutating,
|
||||
variant: 'primary',
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
childrenContentClassName={styles.contentStyle}
|
||||
>
|
||||
<ModalContent
|
||||
inviteEmail={inviteEmails}
|
||||
setInviteEmail={setInviteEmails}
|
||||
handleConfirm={handleConfirm}
|
||||
isMutating={isMutating}
|
||||
isValidEmail={isValidEmail}
|
||||
inviteMethod={inviteMethod}
|
||||
importCSV={importCSV}
|
||||
onInviteMethodChange={setInviteMethod}
|
||||
copyTextToClipboard={copyTextToClipboard}
|
||||
onGenerateInviteLink={onGenerateInviteLink}
|
||||
onRevokeInviteLink={onRevokeInviteLink}
|
||||
/>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import { WorkspaceInviteLinkExpireTime } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { CloseIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Button, IconButton } from '../../../ui/button';
|
||||
import Input from '../../../ui/input';
|
||||
import { Menu, MenuItem, MenuTrigger } from '../../../ui/menu';
|
||||
import { notify } from '../../../ui/notification';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const getMenuItems = (t: ReturnType<typeof useI18n>) => [
|
||||
{
|
||||
label: t['com.affine.payment.member.team.invite.expiration-date']({
|
||||
number: '1',
|
||||
}),
|
||||
value: WorkspaceInviteLinkExpireTime.OneDay,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.invite.expiration-date']({
|
||||
number: '3',
|
||||
}),
|
||||
value: WorkspaceInviteLinkExpireTime.ThreeDays,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.invite.expiration-date']({
|
||||
number: '7',
|
||||
}),
|
||||
value: WorkspaceInviteLinkExpireTime.OneWeek,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.invite.expiration-date']({
|
||||
number: '30',
|
||||
}),
|
||||
value: WorkspaceInviteLinkExpireTime.OneMonth,
|
||||
},
|
||||
];
|
||||
|
||||
export const LinkInvite = ({
|
||||
copyTextToClipboard,
|
||||
generateInvitationLink,
|
||||
revokeInvitationLink,
|
||||
}: {
|
||||
generateInvitationLink: (
|
||||
expireTime: WorkspaceInviteLinkExpireTime
|
||||
) => Promise<string>;
|
||||
revokeInvitationLink: () => Promise<boolean>;
|
||||
copyTextToClipboard: (text: string) => Promise<boolean>;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [selectedValue, setSelectedValue] = useState(
|
||||
WorkspaceInviteLinkExpireTime.OneWeek
|
||||
);
|
||||
const [invitationLink, setInvitationLink] = useState('');
|
||||
const menuItems = getMenuItems(t);
|
||||
const items = useMemo(() => {
|
||||
return menuItems.map(item => (
|
||||
<MenuItem key={item.value} onSelect={() => setSelectedValue(item.value)}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
));
|
||||
}, [menuItems]);
|
||||
|
||||
const currentSelectedLabel = useMemo(
|
||||
() => menuItems.find(item => item.value === selectedValue)?.label,
|
||||
[menuItems, selectedValue]
|
||||
);
|
||||
|
||||
const onGenerate = useCallback(() => {
|
||||
generateInvitationLink(selectedValue)
|
||||
.then(link => {
|
||||
setInvitationLink(link);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to generate invitation link: ', err);
|
||||
notify.error({
|
||||
title: 'Failed to generate invitation link',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
}, [generateInvitationLink, selectedValue]);
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
copyTextToClipboard(invitationLink)
|
||||
.then(() =>
|
||||
notify.success({
|
||||
title: t['Copied link to clipboard'](),
|
||||
})
|
||||
)
|
||||
.catch(err => {
|
||||
console.error('Failed to copy text: ', err);
|
||||
notify.error({
|
||||
title: 'Failed to copy link to clipboard',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
}, [copyTextToClipboard, invitationLink, t]);
|
||||
|
||||
const onReset = useCallback(() => {
|
||||
revokeInvitationLink()
|
||||
.then(() => {
|
||||
setInvitationLink('');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to revoke invitation link: ', err);
|
||||
notify.error({
|
||||
title: 'Failed to revoke invitation link',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
}, [revokeInvitationLink]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.modalSubTitle}>
|
||||
{t['com.affine.payment.member.team.invite.link-expiration']()}
|
||||
</div>
|
||||
<Menu
|
||||
items={items}
|
||||
contentOptions={{
|
||||
style: {
|
||||
width: 'var(--radix-dropdown-menu-trigger-width)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuTrigger style={{ width: '100%' }}>
|
||||
{currentSelectedLabel}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
<div className={styles.modalSubTitle}>
|
||||
{t['com.affine.payment.member.team.invite.invitation-link']()}
|
||||
</div>
|
||||
<div className={styles.invitationLinkContent}>
|
||||
<Input
|
||||
value={
|
||||
invitationLink
|
||||
? invitationLink
|
||||
: 'https://your-app.com/invite/xxxxxxxx'
|
||||
}
|
||||
inputMode="none"
|
||||
disabled
|
||||
inputStyle={{
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2(
|
||||
invitationLink ? 'text/primary' : 'text/placeholder'
|
||||
),
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
}}
|
||||
/>
|
||||
{invitationLink ? (
|
||||
<>
|
||||
<Button onClick={onCopy}>
|
||||
{t['com.affine.payment.member.team.invite.copy']()}
|
||||
</Button>
|
||||
<IconButton icon={<CloseIcon />} onClick={onReset} />
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={onGenerate}>
|
||||
{t['com.affine.payment.member.team.invite.generate']()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { EmailIcon, LinkIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import { RadioGroup } from '../../../ui/radio';
|
||||
import { EmailInvite } from './email-invite';
|
||||
import { LinkInvite } from './link-invite';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export type InviteMethodType = 'email' | 'link';
|
||||
export const ModalContent = ({
|
||||
inviteEmail,
|
||||
setInviteEmail,
|
||||
inviteMethod,
|
||||
onInviteMethodChange,
|
||||
handleConfirm,
|
||||
isMutating,
|
||||
isValidEmail,
|
||||
copyTextToClipboard,
|
||||
onGenerateInviteLink,
|
||||
onRevokeInviteLink,
|
||||
importCSV,
|
||||
}: {
|
||||
inviteEmail: string;
|
||||
importCSV: React.ReactNode;
|
||||
setInviteEmail: (value: string) => void;
|
||||
inviteMethod: InviteMethodType;
|
||||
onInviteMethodChange: (value: InviteMethodType) => void;
|
||||
handleConfirm: () => void;
|
||||
isMutating: boolean;
|
||||
isValidEmail: boolean;
|
||||
copyTextToClipboard: (text: string) => Promise<boolean>;
|
||||
onGenerateInviteLink: (
|
||||
expireTime: WorkspaceInviteLinkExpireTime
|
||||
) => Promise<string>;
|
||||
onRevokeInviteLink: () => Promise<boolean>;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div className={styles.modalContent}>
|
||||
<div>{t['com.affine.payment.member.team.invite.description']()}</div>
|
||||
<RadioGroup
|
||||
width={'100%'}
|
||||
value={inviteMethod}
|
||||
onChange={onInviteMethodChange}
|
||||
items={[
|
||||
{
|
||||
label: (
|
||||
<RadioItem
|
||||
icon={<EmailIcon className={styles.iconStyle} />}
|
||||
label={t[
|
||||
'com.affine.payment.member.team.invite.email-invite'
|
||||
]()}
|
||||
/>
|
||||
),
|
||||
value: 'email',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<RadioItem
|
||||
icon={<LinkIcon className={styles.iconStyle} />}
|
||||
label={t['com.affine.payment.member.team.invite.invite-link']()}
|
||||
/>
|
||||
),
|
||||
value: 'link',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{inviteMethod === 'email' ? (
|
||||
<EmailInvite
|
||||
inviteEmail={inviteEmail}
|
||||
setInviteEmail={setInviteEmail}
|
||||
handleConfirm={handleConfirm}
|
||||
isMutating={isMutating}
|
||||
isValidEmail={isValidEmail}
|
||||
importCSV={importCSV}
|
||||
/>
|
||||
) : (
|
||||
<LinkInvite
|
||||
copyTextToClipboard={copyTextToClipboard}
|
||||
generateInvitationLink={onGenerateInviteLink}
|
||||
revokeInvitationLink={onRevokeInviteLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RadioItem = ({
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.radioItem}>
|
||||
{icon}
|
||||
<div>{label}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+47
-8
@@ -1,8 +1,10 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const inviteModalTitle = style({
|
||||
fontWeight: '600',
|
||||
fontSize: 'var(--affine-font-h-6)',
|
||||
fontSize: cssVar('fontH6'),
|
||||
marginBottom: '20px',
|
||||
});
|
||||
|
||||
@@ -19,7 +21,7 @@ export const inviteModalButtonContainer = style({
|
||||
export const inviteName = style({
|
||||
marginLeft: '4px',
|
||||
marginRight: '10px',
|
||||
color: 'var(--affine-black)',
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
|
||||
export const pagination = style({
|
||||
@@ -36,27 +38,27 @@ export const pageItem = style({
|
||||
alignItems: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/primary'),
|
||||
borderRadius: '4px',
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
'&.active': {
|
||||
color: 'var(--affine-primary-color)',
|
||||
color: cssVarV2('text/emphasis'),
|
||||
cursor: 'default',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&.label': {
|
||||
color: 'var(--affine-icon-color)',
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: '16px',
|
||||
},
|
||||
'&.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
color: 'var(--affine-disable-color)',
|
||||
color: cssVarV2('text/disable'),
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
@@ -67,3 +69,40 @@ globalStyle(`${pageItem} a`, {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const modalContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const modalSubTitle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: '500',
|
||||
});
|
||||
|
||||
export const radioItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const iconStyle = style({
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: '16px',
|
||||
});
|
||||
|
||||
export const errorHint = style({
|
||||
color: cssVarV2('status/error'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
|
||||
export const contentStyle = style({
|
||||
paddingLeft: '0',
|
||||
paddingRight: '0',
|
||||
});
|
||||
|
||||
export const invitationLinkContent = style({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
});
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const ulStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '12px',
|
||||
});
|
||||
|
||||
export const liStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'start',
|
||||
fontSize: cssVar('fontBase'),
|
||||
});
|
||||
|
||||
export const prefixDot = style({
|
||||
background: cssVarV2('icon/activated'),
|
||||
width: '5px',
|
||||
height: '5px',
|
||||
borderRadius: '50%',
|
||||
marginRight: '12px',
|
||||
marginTop: '10px',
|
||||
});
|
||||
+46
-15
@@ -2,6 +2,8 @@ import { ConfirmModal } from '@affine/component/ui/modal';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import * as styles from './member-limit-modal.css';
|
||||
|
||||
export interface MemberLimitModalProps {
|
||||
isFreePlan: boolean;
|
||||
open: boolean;
|
||||
@@ -22,27 +24,18 @@ export const MemberLimitModal = ({
|
||||
const t = useI18n();
|
||||
const handleConfirm = useCallback(() => {
|
||||
setOpen(false);
|
||||
if (isFreePlan) {
|
||||
onConfirm();
|
||||
}
|
||||
}, [onConfirm, setOpen, isFreePlan]);
|
||||
onConfirm();
|
||||
}, [onConfirm, setOpen]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t['com.affine.payment.member-limit.title']()}
|
||||
description={t[
|
||||
isFreePlan
|
||||
? 'com.affine.payment.member-limit.free.description'
|
||||
: 'com.affine.payment.member-limit.pro.description'
|
||||
]({ planName: plan, quota: quota })}
|
||||
cancelButtonOptions={{ style: { display: isFreePlan ? '' : 'none' } }}
|
||||
confirmText={t[
|
||||
isFreePlan
|
||||
? 'com.affine.payment.member-limit.free.confirm'
|
||||
: 'com.affine.payment.member-limit.pro.confirm'
|
||||
]()}
|
||||
description={
|
||||
<ConfirmDescription plan={plan} quota={quota} isFreePlan={isFreePlan} />
|
||||
}
|
||||
confirmText={t['com.affine.payment.upgrade']()}
|
||||
confirmButtonOptions={{
|
||||
variant: 'primary',
|
||||
}}
|
||||
@@ -50,3 +43,41 @@ export const MemberLimitModal = ({
|
||||
></ConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfirmDescription = ({
|
||||
isFreePlan,
|
||||
plan,
|
||||
quota,
|
||||
}: {
|
||||
isFreePlan: boolean;
|
||||
plan: string;
|
||||
quota: string;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div>
|
||||
{t['com.affine.payment.member-limit.description']({
|
||||
planName: plan,
|
||||
quota: quota,
|
||||
})}
|
||||
<ul className={styles.ulStyle}>
|
||||
{isFreePlan && (
|
||||
<li className={styles.liStyle}>
|
||||
<div className={styles.prefixDot} />
|
||||
{t[
|
||||
'com.affine.payment.member-limit.description.tips-for-free-plan'
|
||||
]()}
|
||||
</li>
|
||||
)}
|
||||
<li className={styles.liStyle}>
|
||||
<div className={styles.prefixDot} />
|
||||
{t['com.affine.payment.member-limit.description.tips-1']()}
|
||||
</li>
|
||||
<li className={styles.liStyle}>
|
||||
<div className={styles.prefixDot} />
|
||||
{t['com.affine.payment.member-limit.description.tips-2']()}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const inviteModalTitle = style({
|
||||
fontWeight: '600',
|
||||
fontSize: cssVar('fontH6'),
|
||||
marginBottom: '20px',
|
||||
});
|
||||
|
||||
export const inviteModalContent = style({
|
||||
marginBottom: '10px',
|
||||
});
|
||||
|
||||
export const inviteModalButtonContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
// marginTop: 10,
|
||||
});
|
||||
|
||||
export const inviteName = style({
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const userWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const pagination = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
marginTop: 5,
|
||||
});
|
||||
|
||||
export const pageItem = style({
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/primary'),
|
||||
borderRadius: '4px',
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
'&.active': {
|
||||
color: cssVarV2('text/emphasis'),
|
||||
cursor: 'default',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&.label': {
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: '16px',
|
||||
},
|
||||
'&.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
color: cssVarV2('text/disable'),
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${pageItem} a`, {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const modalContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const modalSubTitle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: '500',
|
||||
});
|
||||
|
||||
export const radioItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const iconStyle = style({
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: '16px',
|
||||
});
|
||||
|
||||
export const errorHint = style({
|
||||
color: cssVarV2('status/error'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
|
||||
export const importButton = style({
|
||||
padding: '4px 8px',
|
||||
});
|
||||
@@ -3,10 +3,13 @@ import { SignOutIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import { Avatar } from '../../ui/avatar';
|
||||
import { Button, IconButton } from '../../ui/button';
|
||||
import { ThemedImg } from '../../ui/themed-img';
|
||||
import { AffineOtherPageLayout } from '../affine-other-page-layout';
|
||||
import illustrationDark from '../affine-other-page-layout/assets/other-page.dark.png';
|
||||
import illustrationLight from '../affine-other-page-layout/assets/other-page.light.png';
|
||||
import type { User } from '../auth-components';
|
||||
import { NotFoundPattern } from './not-found-pattern';
|
||||
import {
|
||||
illustration,
|
||||
largeButtonEffect,
|
||||
notFoundPageContainer,
|
||||
wrapper,
|
||||
@@ -32,7 +35,12 @@ export const NoPermissionOrNotFound = ({
|
||||
{user ? (
|
||||
<>
|
||||
<div className={wrapper}>
|
||||
<NotFoundPattern />
|
||||
<ThemedImg
|
||||
draggable={false}
|
||||
className={illustration}
|
||||
lightSrc={illustrationLight}
|
||||
darkSrc={illustrationDark}
|
||||
/>
|
||||
</div>
|
||||
<p className={wrapper}>{t['404.hint']()}</p>
|
||||
<div className={wrapper}>
|
||||
@@ -76,7 +84,12 @@ export const NotFoundPage = ({
|
||||
<AffineOtherPageLayout>
|
||||
<div className={notFoundPageContainer} data-testid="not-found">
|
||||
<div className={wrapper}>
|
||||
<NotFoundPattern />
|
||||
<ThemedImg
|
||||
draggable={false}
|
||||
className={illustration}
|
||||
lightSrc={illustrationLight}
|
||||
darkSrc={illustrationDark}
|
||||
/>
|
||||
</div>
|
||||
<p className={wrapper}>{t['404.hint']()}</p>
|
||||
<div className={wrapper}>
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
export const NotFoundPattern = () => {
|
||||
return (
|
||||
<svg
|
||||
width="240"
|
||||
height="209"
|
||||
viewBox="0 0 240 209"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M24.4197 172.91L119.045 8.64233L213.671 172.91H24.4197Z"
|
||||
stroke="var(--affine-text-disable-color)"
|
||||
strokeOpacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M165.921 91.5342L119.045 172.161L72.1684 91.5342L165.921 91.5342Z"
|
||||
stroke="var(--affine-text-disable-color)"
|
||||
strokeOpacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M179.022 68.1181C179.022 101.243 152.169 128.096 119.045 128.096C85.9202 128.096 59.0674 101.243 59.0674 68.1181C59.0674 34.9934 85.9202 8.14062 119.045 8.14062C152.169 8.14062 179.022 34.9934 179.022 68.1181Z"
|
||||
stroke="var(--affine-text-disable-color)"
|
||||
strokeOpacity="0.6"
|
||||
/>
|
||||
<circle
|
||||
cx="162.485"
|
||||
cy="142.984"
|
||||
r="59.9775"
|
||||
transform="rotate(120 162.485 142.984)"
|
||||
stroke="var(--affine-text-disable-color)"
|
||||
strokeOpacity="0.6"
|
||||
/>
|
||||
<circle
|
||||
cx="75.2925"
|
||||
cy="142.984"
|
||||
r="59.9775"
|
||||
transform="rotate(-120 75.2925 142.984)"
|
||||
stroke="var(--affine-text-disable-color)"
|
||||
strokeOpacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M119.045 7.64062V173.158"
|
||||
stroke="var(--affine-text-disable-color)"
|
||||
strokeOpacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M214.536 173.475L71.2998 91.0352"
|
||||
stroke="var(--affine-text-disable-color)"
|
||||
strokeOpacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M23.5547 173.475L166.791 91.0352"
|
||||
stroke="var(--affine-text-disable-color)"
|
||||
strokeOpacity="0.6"
|
||||
/>
|
||||
<ellipse
|
||||
cx="119.045"
|
||||
cy="7.63971"
|
||||
rx="5.09284"
|
||||
ry="5.09284"
|
||||
fill="var(--affine-text-primary-color)"
|
||||
/>
|
||||
<ellipse
|
||||
cx="214.536"
|
||||
cy="173.155"
|
||||
rx="5.09284"
|
||||
ry="5.09284"
|
||||
fill="var(--affine-text-primary-color)"
|
||||
/>
|
||||
<ellipse
|
||||
cx="166.79"
|
||||
cy="91.0342"
|
||||
rx="5.09284"
|
||||
ry="5.09284"
|
||||
fill="var(--affine-text-primary-color)"
|
||||
/>
|
||||
<ellipse
|
||||
cx="119.045"
|
||||
cy="173.155"
|
||||
rx="5.09284"
|
||||
ry="5.09284"
|
||||
fill="var(--affine-text-primary-color)"
|
||||
/>
|
||||
<ellipse
|
||||
cx="71.2999"
|
||||
cy="91.0342"
|
||||
rx="5.09284"
|
||||
ry="5.09284"
|
||||
fill="var(--affine-text-primary-color)"
|
||||
/>
|
||||
<ellipse
|
||||
cx="119.045"
|
||||
cy="91.0342"
|
||||
rx="5.09284"
|
||||
ry="5.09284"
|
||||
fill="var(--affine-text-primary-color)"
|
||||
/>
|
||||
<ellipse
|
||||
cx="95.4903"
|
||||
cy="131.776"
|
||||
rx="5.09284"
|
||||
ry="5.09284"
|
||||
fill="var(--affine-text-primary-color)"
|
||||
/>
|
||||
<ellipse
|
||||
cx="143.236"
|
||||
cy="131.776"
|
||||
rx="5.09284"
|
||||
ry="5.09284"
|
||||
fill="var(--affine-text-primary-color)"
|
||||
/>
|
||||
<ellipse
|
||||
cx="23.5548"
|
||||
cy="173.155"
|
||||
rx="5.09284"
|
||||
ry="5.09284"
|
||||
fill="var(--affine-text-primary-color)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -20,3 +20,8 @@ export const wrapper = style({
|
||||
export const largeButtonEffect = style({
|
||||
boxShadow: `${cssVar('largeButtonEffect')} !important`,
|
||||
});
|
||||
|
||||
export const illustration = style({
|
||||
maxWidth: '100%',
|
||||
width: '670px',
|
||||
});
|
||||
|
||||
+5
@@ -2,3 +2,8 @@ declare module '*.mp4' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import { Modal } from './modal';
|
||||
const styles = BUILD_CONFIG.isMobileEdition ? mobileStyles : desktopStyles;
|
||||
|
||||
export interface ConfirmModalProps extends ModalProps {
|
||||
customConfirmButton?: () => React.ReactNode;
|
||||
confirmButtonOptions?: Omit<ButtonProps, 'children'>;
|
||||
childrenContentClassName?: string;
|
||||
onConfirm?: (() => void) | (() => Promise<void>);
|
||||
onCancel?: () => void;
|
||||
confirmText?: React.ReactNode;
|
||||
@@ -29,6 +31,7 @@ export interface ConfirmModalProps extends ModalProps {
|
||||
export const ConfirmModal = ({
|
||||
children,
|
||||
confirmButtonOptions,
|
||||
customConfirmButton: CustomConfirmButton,
|
||||
// FIXME: we need i18n
|
||||
confirmText,
|
||||
cancelText = 'Cancel',
|
||||
@@ -40,6 +43,7 @@ export const ConfirmModal = ({
|
||||
autoFocusConfirm = true,
|
||||
headerClassName,
|
||||
descriptionClassName,
|
||||
childrenContentClassName,
|
||||
contentOptions,
|
||||
...props
|
||||
}: ConfirmModalProps) => {
|
||||
@@ -66,7 +70,11 @@ export const ConfirmModal = ({
|
||||
descriptionClassName={clsx(styles.description, descriptionClassName)}
|
||||
{...props}
|
||||
>
|
||||
{children ? <div className={styles.content}>{children}</div> : null}
|
||||
{children ? (
|
||||
<div className={clsx(styles.content, childrenContentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={clsx(styles.footer, {
|
||||
modalFooterWithChildren: !!children,
|
||||
@@ -83,15 +91,19 @@ export const ConfirmModal = ({
|
||||
{cancelText}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<Button
|
||||
className={styles.action}
|
||||
onClick={onConfirmClick}
|
||||
data-testid="confirm-modal-confirm"
|
||||
autoFocus={autoFocusConfirm}
|
||||
{...confirmButtonOptions}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
{CustomConfirmButton ? (
|
||||
<CustomConfirmButton data-testid="confirm-modal-confirm" />
|
||||
) : (
|
||||
<Button
|
||||
className={styles.action}
|
||||
onClick={onConfirmClick}
|
||||
data-testid="confirm-modal-confirm"
|
||||
autoFocus={autoFocusConfirm}
|
||||
{...confirmButtonOptions}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const ulStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '12px',
|
||||
});
|
||||
|
||||
export const liStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'start',
|
||||
fontSize: cssVar('fontBase'),
|
||||
});
|
||||
|
||||
export const prefixDot = style({
|
||||
background: cssVarV2('icon/activated'),
|
||||
width: '5px',
|
||||
height: '5px',
|
||||
borderRadius: '50%',
|
||||
marginRight: '12px',
|
||||
marginTop: '10px',
|
||||
});
|
||||
+32
-24
@@ -4,13 +4,15 @@ import { UserQuotaService } from '@affine/core/modules/cloud';
|
||||
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { type I18nString, useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
|
||||
import bytes from 'bytes';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import * as styles from './cloud-quota-modal.css';
|
||||
|
||||
export const CloudQuotaModal = () => {
|
||||
const t = useI18n();
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
@@ -39,10 +41,6 @@ export const CloudQuotaModal = () => {
|
||||
)
|
||||
);
|
||||
|
||||
const isFreePlanOwner = useMemo(() => {
|
||||
return isOwner && userQuota?.name === 'free';
|
||||
}, [isOwner, userQuota]);
|
||||
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
const handleUpgradeConfirm = useCallback(() => {
|
||||
globalDialogService.open('setting', {
|
||||
@@ -55,18 +53,8 @@ export const CloudQuotaModal = () => {
|
||||
}, [globalDialogService, setOpen]);
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (userQuota && isFreePlanOwner) {
|
||||
return t['com.affine.payment.blob-limit.description.owner.free']({
|
||||
planName: userQuota.name,
|
||||
currentQuota: userQuota.blobLimit,
|
||||
upgradeQuota: '100MB',
|
||||
});
|
||||
}
|
||||
if (isOwner && userQuota && userQuota.name.toLowerCase() === 'pro') {
|
||||
return t['com.affine.payment.blob-limit.description.owner.pro']({
|
||||
planName: userQuota.name,
|
||||
quota: userQuota.blobLimit,
|
||||
});
|
||||
if (userQuota && isOwner) {
|
||||
return <OwnerDescription quota={userQuota.blobLimit} />;
|
||||
}
|
||||
if (workspaceQuota) {
|
||||
return t['com.affine.payment.blob-limit.description.member']({
|
||||
@@ -76,7 +64,7 @@ export const CloudQuotaModal = () => {
|
||||
// loading
|
||||
return null;
|
||||
}
|
||||
}, [userQuota, isFreePlanOwner, isOwner, workspaceQuota, t]);
|
||||
}, [userQuota, isOwner, workspaceQuota, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceQuota) {
|
||||
@@ -100,16 +88,36 @@ export const CloudQuotaModal = () => {
|
||||
title={t['com.affine.payment.blob-limit.title']()}
|
||||
onOpenChange={setOpen}
|
||||
description={description}
|
||||
cancelButtonOptions={{
|
||||
hidden: !isFreePlanOwner,
|
||||
}}
|
||||
onConfirm={handleUpgradeConfirm}
|
||||
confirmText={
|
||||
isFreePlanOwner ? t['com.affine.payment.upgrade']() : t['Got it']()
|
||||
}
|
||||
confirmText={t['com.affine.payment.upgrade']()}
|
||||
confirmButtonOptions={{
|
||||
variant: 'primary',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const tips: I18nString[] = [
|
||||
'com.affine.payment.blob-limit.description.owner.tips-1',
|
||||
'com.affine.payment.blob-limit.description.owner.tips-2',
|
||||
'com.affine.payment.blob-limit.description.owner.tips-3',
|
||||
];
|
||||
|
||||
const OwnerDescription = ({ quota }: { quota: string }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div>
|
||||
{t['com.affine.payment.blob-limit.description.owner']({
|
||||
quota: quota,
|
||||
})}
|
||||
<ul className={styles.ulStyle}>
|
||||
{tips.map((tip, index) => (
|
||||
<li className={styles.liStyle} key={index}>
|
||||
<div className={styles.prefixDot} />
|
||||
{t.t(tip)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Permission } from '@affine/graphql';
|
||||
import { inviteByEmailMutation } from '@affine/graphql';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useMutation } from '../use-mutation';
|
||||
import { useMutateCloud } from './use-mutate-cloud';
|
||||
|
||||
export function useInviteMember(workspaceId: string) {
|
||||
const { trigger, isMutating } = useMutation({
|
||||
mutation: inviteByEmailMutation,
|
||||
});
|
||||
const mutate = useMutateCloud();
|
||||
return {
|
||||
invite: useCallback(
|
||||
async (email: string, permission: Permission, sendInviteMail = false) => {
|
||||
const res = await trigger({
|
||||
workspaceId,
|
||||
email,
|
||||
permission,
|
||||
sendInviteMail,
|
||||
});
|
||||
await mutate();
|
||||
// return is successful
|
||||
return res?.invite;
|
||||
},
|
||||
[mutate, trigger, workspaceId]
|
||||
),
|
||||
isMutating,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { revokeMemberPermissionMutation } from '@affine/graphql';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useMutation } from '../use-mutation';
|
||||
import { useMutateCloud } from './use-mutate-cloud';
|
||||
|
||||
export function useRevokeMemberPermission(workspaceId: string) {
|
||||
const mutate = useMutateCloud();
|
||||
const { trigger } = useMutation({
|
||||
mutation: revokeMemberPermissionMutation,
|
||||
});
|
||||
|
||||
return useCallback(
|
||||
async (userId: string) => {
|
||||
const res = await trigger({
|
||||
workspaceId,
|
||||
userId,
|
||||
});
|
||||
await mutate();
|
||||
return res;
|
||||
},
|
||||
[mutate, trigger, workspaceId]
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,13 @@ type TypeFormInfo = {
|
||||
const getTypeFormLink = (id: string, info: TypeFormInfo) => {
|
||||
const plans = Array.isArray(info.plan) ? info.plan : [info.plan];
|
||||
const product_id = plans
|
||||
.map(plan => (plan === SubscriptionPlan.AI ? 'ai' : 'cloud'))
|
||||
.map(plan =>
|
||||
plan === SubscriptionPlan.AI
|
||||
? 'ai'
|
||||
: plan === SubscriptionPlan.Team
|
||||
? 'team'
|
||||
: 'cloud'
|
||||
)
|
||||
.join('-');
|
||||
const product_price =
|
||||
info.recurring === SubscriptionRecurring.Monthly
|
||||
@@ -46,7 +52,11 @@ export const generateSubscriptionCallbackLink = (
|
||||
throw new Error('Account is required');
|
||||
}
|
||||
const baseUrl =
|
||||
plan === SubscriptionPlan.AI ? '/ai-upgrade-success' : '/upgrade-success';
|
||||
plan === SubscriptionPlan.AI
|
||||
? '/ai-upgrade-success'
|
||||
: plan === SubscriptionPlan.Team
|
||||
? '/upgrade-success/team'
|
||||
: '/upgrade-success';
|
||||
|
||||
let name = account?.info?.name ?? '';
|
||||
if (name.includes(separator)) {
|
||||
|
||||
@@ -38,17 +38,17 @@ export const OverCapacityNotification = () => {
|
||||
}
|
||||
if (isOwner) {
|
||||
notify.warning({
|
||||
title: t['com.affine.payment.storage-limit.title'](),
|
||||
title: t['com.affine.payment.storage-limit.new-title'](),
|
||||
message:
|
||||
t['com.affine.payment.storage-limit.description.owner'](),
|
||||
t['com.affine.payment.storage-limit.new-description.owner'](),
|
||||
action: {
|
||||
label: t['com.affine.payment.storage-limit.view'](),
|
||||
label: t['com.affine.payment.upgrade'](),
|
||||
onClick: jumpToPricePlan,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
notify.warning({
|
||||
title: t['com.affine.payment.storage-limit.title'](),
|
||||
title: t['com.affine.payment.storage-limit.new-title'](),
|
||||
message:
|
||||
t['com.affine.payment.storage-limit.description.member'](),
|
||||
});
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const iconWrapper = style({
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '16px',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
selectors: {
|
||||
'&:visited': {
|
||||
color: cssVar('textPrimaryColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Empty } from '@affine/component';
|
||||
import { Logo1Icon } from '@blocksuite/icons/rc';
|
||||
|
||||
export const SharePageNotFoundError = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="https://affine.pro/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '16px',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Logo1Icon />
|
||||
</a>
|
||||
<Empty
|
||||
description={'You do not have access or this content does not exist.'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
LocalWorkspaceIcon,
|
||||
NoNetworkIcon,
|
||||
SettingsIcon,
|
||||
TeamWorkspaceIcon,
|
||||
UnsyncIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import { forwardRef, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useCatchEventCallback } from '../../hooks/use-catch-event-hook';
|
||||
import * as styles from './styles.css';
|
||||
export { PureWorkspaceCard } from './pure-workspace-card';
|
||||
|
||||
const CloudWorkspaceStatus = () => {
|
||||
return (
|
||||
@@ -240,6 +242,7 @@ export const WorkspaceCard = forwardRef<
|
||||
avatarSize?: number;
|
||||
disable?: boolean;
|
||||
hideCollaborationIcon?: boolean;
|
||||
hideTeamWorkspaceIcon?: boolean;
|
||||
active?: boolean;
|
||||
onClickOpenSettings?: (workspaceMetadata: WorkspaceMetadata) => void;
|
||||
onClickEnableCloud?: (workspaceMetadata: WorkspaceMetadata) => void;
|
||||
@@ -256,6 +259,7 @@ export const WorkspaceCard = forwardRef<
|
||||
className,
|
||||
disable,
|
||||
hideCollaborationIcon,
|
||||
hideTeamWorkspaceIcon,
|
||||
active,
|
||||
...props
|
||||
},
|
||||
@@ -325,6 +329,9 @@ export const WorkspaceCard = forwardRef<
|
||||
{hideCollaborationIcon || information?.isOwner ? null : (
|
||||
<CollaborationIcon className={styles.collaborationIcon} />
|
||||
)}
|
||||
{hideTeamWorkspaceIcon || !information?.isTeam ? null : (
|
||||
<TeamWorkspaceIcon className={styles.collaborationIcon} />
|
||||
)}
|
||||
{onClickOpenSettings && (
|
||||
<div className={styles.settingButton} onClick={onOpenSettings}>
|
||||
<SettingsIcon width={16} height={16} />
|
||||
@@ -336,7 +343,7 @@ export const WorkspaceCard = forwardRef<
|
||||
|
||||
{active && (
|
||||
<div className={styles.activeContainer}>
|
||||
<DoneIcon className={styles.activeIcon} />{' '}
|
||||
<DoneIcon className={styles.activeIcon} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
|
||||
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
||||
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
|
||||
import { DoneIcon } from '@blocksuite/icons/rc';
|
||||
import { type WorkspaceMetadata } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const PureWorkspaceCard = forwardRef<
|
||||
HTMLDivElement,
|
||||
HTMLAttributes<HTMLDivElement> & {
|
||||
workspaceMetadata: WorkspaceMetadata;
|
||||
avatarSize?: number;
|
||||
disable?: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
workspaceMetadata,
|
||||
avatarSize = 32,
|
||||
className,
|
||||
disable,
|
||||
active,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const information = useWorkspaceInfo(workspaceMetadata);
|
||||
|
||||
const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.container,
|
||||
disable ? styles.disable : null,
|
||||
className
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="workspace-card"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.infoContainer}>
|
||||
{information ? (
|
||||
<WorkspaceAvatar
|
||||
meta={workspaceMetadata}
|
||||
rounded={3}
|
||||
data-testid="workspace-avatar"
|
||||
size={avatarSize}
|
||||
name={name}
|
||||
colorfulFallback
|
||||
/>
|
||||
) : (
|
||||
<Skeleton width={avatarSize} height={avatarSize} />
|
||||
)}
|
||||
<div className={styles.workspaceTitleContainer}>
|
||||
{information ? (
|
||||
<span className={styles.workspaceName}>{information.name}</span>
|
||||
) : (
|
||||
<Skeleton width={100} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<div className={styles.activeContainer}>
|
||||
<DoneIcon className={styles.activeIcon} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PureWorkspaceCard.displayName = 'PureWorkspaceCard';
|
||||
+64
-4
@@ -45,8 +45,7 @@ export const CancelAction = ({
|
||||
const prevRecurring = subscription.pro$.value?.recurring;
|
||||
setIsMutating(true);
|
||||
await subscription.cancelSubscription(idempotencyKey);
|
||||
subscription.revalidate();
|
||||
await subscription.isRevalidating$.waitFor(v => !v);
|
||||
await subscription.waitForRevalidation();
|
||||
// refresh idempotency key
|
||||
setIdempotencyKey(nanoid());
|
||||
onOpenChange(false);
|
||||
@@ -92,6 +91,68 @@ export const CancelAction = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const CancelTeamAction = ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
} & PropsWithChildren) => {
|
||||
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
const subscription = useService(SubscriptionService).subscription;
|
||||
const teamSubscription = useLiveData(subscription.team$);
|
||||
const authService = useService(AuthService);
|
||||
const downgradeNotify = useDowngradeNotify();
|
||||
|
||||
const downgrade = useAsyncCallback(async () => {
|
||||
try {
|
||||
const account = authService.session.account$.value;
|
||||
const prevRecurring = teamSubscription?.recurring;
|
||||
setIsMutating(true);
|
||||
await subscription.cancelSubscription(idempotencyKey);
|
||||
await subscription.waitForRevalidation();
|
||||
// refresh idempotency key
|
||||
setIdempotencyKey(nanoid());
|
||||
onOpenChange(false);
|
||||
|
||||
if (account && prevRecurring) {
|
||||
downgradeNotify(
|
||||
getDowngradeQuestionnaireLink({
|
||||
email: account.email ?? '',
|
||||
id: account.id,
|
||||
name: account.info?.name ?? '',
|
||||
plan: SubscriptionPlan.Team,
|
||||
recurring: prevRecurring,
|
||||
})
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsMutating(false);
|
||||
}
|
||||
}, [
|
||||
authService.session.account$.value,
|
||||
teamSubscription,
|
||||
subscription,
|
||||
idempotencyKey,
|
||||
onOpenChange,
|
||||
downgradeNotify,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<DowngradeModal
|
||||
open={open}
|
||||
onCancel={downgrade}
|
||||
onOpenChange={onOpenChange}
|
||||
loading={isMutating}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resume payment action with modal & request
|
||||
* @param param0
|
||||
@@ -114,8 +175,7 @@ export const ResumeAction = ({
|
||||
try {
|
||||
setIsMutating(true);
|
||||
await subscription.resumeSubscription(idempotencyKey);
|
||||
subscription.revalidate();
|
||||
await subscription.isRevalidating$.waitFor(v => !v);
|
||||
await subscription.waitForRevalidation();
|
||||
// refresh idempotency key
|
||||
setIdempotencyKey(nanoid());
|
||||
onOpenChange(false);
|
||||
|
||||
+31
-14
@@ -74,20 +74,15 @@ const proBenefits: BenefitsGetter = t => ({
|
||||
});
|
||||
|
||||
const teamBenefits: BenefitsGetter = t => ({
|
||||
[t['com.affine.payment.cloud.team.benefit.g1']()]: [
|
||||
[t['com.affine.payment.cloud.team-workspace.benefit.g1']()]: [
|
||||
{
|
||||
title: t['com.affine.payment.cloud.team.benefit.g1-1'](),
|
||||
title: t['com.affine.payment.cloud.team-workspace.benefit.g1-1'](),
|
||||
icon: <AfFiNeIcon />,
|
||||
},
|
||||
...([2, 3, 4] as const).map(i => ({
|
||||
title: t[`com.affine.payment.cloud.team.benefit.g1-${i}`](),
|
||||
...([2, 3, 4, 5, 6] as const).map(i => ({
|
||||
title: t[`com.affine.payment.cloud.team-workspace.benefit.g1-${i}`](),
|
||||
})),
|
||||
],
|
||||
[t['com.affine.payment.cloud.team.benefit.g2']()]: [
|
||||
{ title: t['com.affine.payment.cloud.team.benefit.g2-1']() },
|
||||
{ title: t['com.affine.payment.cloud.team.benefit.g2-2']() },
|
||||
{ title: t['com.affine.payment.cloud.team.benefit.g2-3']() },
|
||||
],
|
||||
});
|
||||
|
||||
export function getPlanDetail(t: T) {
|
||||
@@ -138,12 +133,34 @@ export function getPlanDetail(t: T) {
|
||||
[
|
||||
SubscriptionPlan.Team,
|
||||
{
|
||||
type: 'dynamic',
|
||||
type: 'fixed',
|
||||
plan: SubscriptionPlan.Team,
|
||||
contact: true,
|
||||
name: t['com.affine.payment.cloud.team.name'](),
|
||||
description: t['com.affine.payment.cloud.team.description'](),
|
||||
titleRenderer: () => t['com.affine.payment.cloud.team.title'](),
|
||||
price: '2',
|
||||
yearlyPrice: '2',
|
||||
name: t['com.affine.payment.cloud.team-workspace.name'](),
|
||||
description: t['com.affine.payment.cloud.team-workspace.description'](),
|
||||
titleRenderer: (recurring, detail) => {
|
||||
const price =
|
||||
recurring === SubscriptionRecurring.Yearly
|
||||
? detail.yearlyPrice
|
||||
: detail.price;
|
||||
return (
|
||||
<>
|
||||
{t['com.affine.payment.cloud.team-workspace.title.price-monthly'](
|
||||
{
|
||||
price: '$' + price,
|
||||
}
|
||||
)}
|
||||
{recurring === SubscriptionRecurring.Yearly ? (
|
||||
<span className={planTitleTitleCaption}>
|
||||
{t[
|
||||
'com.affine.payment.cloud.team-workspace.title.billed-yearly'
|
||||
]()}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
},
|
||||
benefits: teamBenefits(t),
|
||||
},
|
||||
],
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
|
||||
import { SubscriptionRecurring } from '@affine/graphql';
|
||||
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
@@ -46,6 +46,7 @@ export const LifetimePlan = () => {
|
||||
<Upgrade
|
||||
className={styles.purchase}
|
||||
recurring={SubscriptionRecurring.Lifetime}
|
||||
plan={SubscriptionPlan.Pro}
|
||||
>
|
||||
{t['com.affine.payment.lifetime.purchase']()}
|
||||
</Upgrade>
|
||||
|
||||
+60
-36
@@ -2,7 +2,11 @@ import { Button, type ButtonProps } from '@affine/component/ui/button';
|
||||
import { Tooltip } from '@affine/component/ui/tooltip';
|
||||
import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
AuthService,
|
||||
ServerService,
|
||||
SubscriptionService,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||
import {
|
||||
type CreateCheckoutSessionInput,
|
||||
@@ -91,6 +95,20 @@ export const PlanCard = (props: PlanCardProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getSignUpText = (
|
||||
plan: SubscriptionPlan,
|
||||
t: ReturnType<typeof useI18n>
|
||||
) => {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.Free:
|
||||
return t['com.affine.payment.sign-up-free']();
|
||||
case SubscriptionPlan.Team:
|
||||
return t['com.affine.payment.start-free-trial']();
|
||||
default:
|
||||
return t['com.affine.payment.buy-pro']();
|
||||
}
|
||||
};
|
||||
|
||||
const ActionButton = ({ detail, recurring }: PlanCardProps) => {
|
||||
const t = useI18n();
|
||||
const loggedIn =
|
||||
@@ -105,12 +123,18 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
|
||||
const isOnetime = useLiveData(subscriptionService.subscription.isOnetimePro$);
|
||||
const isFree = detail.plan === SubscriptionPlan.Free;
|
||||
|
||||
const signUpText = useMemo(
|
||||
() => getSignUpText(detail.plan, t),
|
||||
[detail.plan, t]
|
||||
);
|
||||
|
||||
// branches:
|
||||
// if contact => 'Contact Sales'
|
||||
// if not signed in:
|
||||
// if free => 'Sign up free'
|
||||
// else => 'Buy Pro'
|
||||
// else
|
||||
// if team => 'Start 14-day free trial'
|
||||
// if isBeliever => 'Included in Lifetime'
|
||||
// if onetime
|
||||
// if free => 'Included in Pro'
|
||||
@@ -122,20 +146,14 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
|
||||
// if currentRecurring !== recurring => 'Change to {recurring} Billing'
|
||||
// else => 'Upgrade'
|
||||
|
||||
// contact
|
||||
if (detail.type === 'dynamic') {
|
||||
return <BookDemo plan={detail.plan} />;
|
||||
}
|
||||
|
||||
// not signed in
|
||||
if (!loggedIn) {
|
||||
return (
|
||||
<SignUpAction>
|
||||
{detail.plan === SubscriptionPlan.Free
|
||||
? t['com.affine.payment.sign-up-free']()
|
||||
: t['com.affine.payment.buy-pro']()}
|
||||
</SignUpAction>
|
||||
);
|
||||
return <SignUpAction>{signUpText}</SignUpAction>;
|
||||
}
|
||||
|
||||
// team
|
||||
if (detail.plan === SubscriptionPlan.Team) {
|
||||
return <UpgradeToTeam />;
|
||||
}
|
||||
|
||||
// lifetime
|
||||
@@ -183,7 +201,10 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
|
||||
disabled={isCanceled}
|
||||
/>
|
||||
) : (
|
||||
<Upgrade recurring={recurring as SubscriptionRecurring} />
|
||||
<Upgrade
|
||||
recurring={recurring as SubscriptionRecurring}
|
||||
plan={SubscriptionPlan.Pro}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -226,19 +247,10 @@ const Downgrade = ({ disabled }: { disabled?: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const BookDemo = ({ plan }: { plan: SubscriptionPlan }) => {
|
||||
const UpgradeToTeam = () => {
|
||||
const t = useI18n();
|
||||
const url = useMemo(() => {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.Team:
|
||||
return 'https://6dxre9ihosp.typeform.com/to/niBcdkvs';
|
||||
case SubscriptionPlan.Enterprise:
|
||||
return 'https://6dxre9ihosp.typeform.com/to/rFfobTjf';
|
||||
default:
|
||||
return 'https://affine.pro/pricing';
|
||||
}
|
||||
}, [plan]);
|
||||
|
||||
const serverService = useService(ServerService);
|
||||
const url = `${serverService.server.baseUrl}/upgrade-to-team`;
|
||||
return (
|
||||
<a
|
||||
className={styles.planAction}
|
||||
@@ -249,10 +261,9 @@ const BookDemo = ({ plan }: { plan: SubscriptionPlan }) => {
|
||||
<Button
|
||||
className={styles.planAction}
|
||||
variant="primary"
|
||||
data-event-props="$.settingsPanel.billing.bookDemo"
|
||||
data-event-args-url={url}
|
||||
>
|
||||
{t['com.affine.payment.tell-us-use-case']()}
|
||||
{t['com.affine.payment.start-free-trial']()}
|
||||
</Button>
|
||||
</a>
|
||||
);
|
||||
@@ -261,43 +272,51 @@ const BookDemo = ({ plan }: { plan: SubscriptionPlan }) => {
|
||||
export const Upgrade = ({
|
||||
className,
|
||||
recurring,
|
||||
plan,
|
||||
children,
|
||||
checkoutInput,
|
||||
onCheckoutSuccess,
|
||||
onBeforeCheckout,
|
||||
...btnProps
|
||||
}: ButtonProps & {
|
||||
recurring: SubscriptionRecurring;
|
||||
plan: SubscriptionPlan;
|
||||
checkoutInput?: Partial<CreateCheckoutSessionInput>;
|
||||
onBeforeCheckout?: () => void;
|
||||
onCheckoutSuccess?: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const authService = useService(AuthService);
|
||||
|
||||
const onBeforeCheckout = useCallback(() => {
|
||||
const handleBeforeCheckout = useCallback(() => {
|
||||
track.$.settingsPanel.plans.checkout({
|
||||
plan: SubscriptionPlan.Pro,
|
||||
plan: plan,
|
||||
recurring: recurring,
|
||||
});
|
||||
}, [recurring]);
|
||||
onBeforeCheckout?.();
|
||||
}, [onBeforeCheckout, plan, recurring]);
|
||||
|
||||
const checkoutOptions = useMemo(
|
||||
() => ({
|
||||
recurring,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
plan: plan,
|
||||
variant: null,
|
||||
coupon: null,
|
||||
successCallbackLink: generateSubscriptionCallbackLink(
|
||||
authService.session.account$.value,
|
||||
SubscriptionPlan.Pro,
|
||||
plan,
|
||||
recurring
|
||||
),
|
||||
...checkoutInput,
|
||||
}),
|
||||
[authService.session.account$.value, checkoutInput, recurring]
|
||||
[authService.session.account$.value, checkoutInput, plan, recurring]
|
||||
);
|
||||
|
||||
return (
|
||||
<CheckoutSlot
|
||||
onBeforeCheckout={onBeforeCheckout}
|
||||
onBeforeCheckout={handleBeforeCheckout}
|
||||
checkoutOptions={checkoutOptions}
|
||||
onCheckoutSuccess={onCheckoutSuccess}
|
||||
renderer={props => (
|
||||
<Button
|
||||
className={clsx(styles.planAction, className)}
|
||||
@@ -437,9 +456,13 @@ const redeemCodeCheckoutInput = { variant: SubscriptionVariant.Onetime };
|
||||
export const RedeemCode = ({
|
||||
className,
|
||||
recurring = SubscriptionRecurring.Yearly,
|
||||
plan,
|
||||
children,
|
||||
...btnProps
|
||||
}: ButtonProps & { recurring?: SubscriptionRecurring }) => {
|
||||
}: ButtonProps & {
|
||||
recurring?: SubscriptionRecurring;
|
||||
plan?: SubscriptionPlan;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
@@ -447,6 +470,7 @@ export const RedeemCode = ({
|
||||
recurring={recurring}
|
||||
className={className}
|
||||
checkoutInput={redeemCodeCheckoutInput}
|
||||
plan={plan ?? SubscriptionPlan.Pro}
|
||||
{...btnProps}
|
||||
>
|
||||
{children ?? t['com.affine.payment.redeem-code']()}
|
||||
|
||||
@@ -251,20 +251,6 @@ export const WorkspaceList = ({
|
||||
);
|
||||
};
|
||||
|
||||
const subTabConfigs = [
|
||||
{
|
||||
key: 'workspace:preference',
|
||||
title: 'com.affine.settings.workspace.preferences',
|
||||
},
|
||||
{
|
||||
key: 'workspace:properties',
|
||||
title: 'com.affine.settings.workspace.properties',
|
||||
},
|
||||
] satisfies {
|
||||
key: SettingTab;
|
||||
title: keyof ReturnType<typeof useI18n>;
|
||||
}[];
|
||||
|
||||
const WorkspaceListItem = ({
|
||||
activeTab,
|
||||
meta,
|
||||
@@ -294,7 +280,30 @@ const WorkspaceListItem = ({
|
||||
onClick('workspace:preference');
|
||||
}, [onClick]);
|
||||
|
||||
const showBilling = information?.isTeam && information?.isOwner;
|
||||
const subTabs = useMemo(() => {
|
||||
const subTabConfigs = [
|
||||
{
|
||||
key: 'workspace:preference',
|
||||
title: 'com.affine.settings.workspace.preferences',
|
||||
},
|
||||
{
|
||||
key: 'workspace:properties',
|
||||
title: 'com.affine.settings.workspace.properties',
|
||||
},
|
||||
...(showBilling
|
||||
? [
|
||||
{
|
||||
key: 'workspace:billing' as SettingTab,
|
||||
title: 'com.affine.settings.workspace.billing',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
] satisfies {
|
||||
key: SettingTab;
|
||||
title: keyof ReturnType<typeof useI18n>;
|
||||
}[];
|
||||
|
||||
return subTabConfigs.map(({ key, title }) => {
|
||||
return (
|
||||
<div
|
||||
@@ -311,7 +320,7 @@ const WorkspaceListItem = ({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, [activeTab, onClick, t]);
|
||||
}, [activeTab, onClick, showBilling, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
import { Button, Loading } from '@affine/component';
|
||||
import { Pagination } from '@affine/component/member-components';
|
||||
import {
|
||||
SettingHeader,
|
||||
SettingRow,
|
||||
SettingWrapper,
|
||||
} from '@affine/component/setting-components';
|
||||
import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useMutation } from '@affine/core/components/hooks/use-mutation';
|
||||
import {
|
||||
AuthService,
|
||||
InvoicesService,
|
||||
SubscriptionService,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { UrlService } from '@affine/core/modules/url';
|
||||
import {
|
||||
createCustomerPortalMutation,
|
||||
type InvoicesQuery,
|
||||
InvoiceStatus,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
UserFriendlyError,
|
||||
} from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
FrameworkScope,
|
||||
useLiveData,
|
||||
useService,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
CancelTeamAction,
|
||||
ResumeAction,
|
||||
} from '../../general-setting/plans/actions';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const WorkspaceSettingBilling = () => {
|
||||
const t = useI18n();
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const subscriptionService = useService(SubscriptionService);
|
||||
const team = useLiveData(subscriptionService.subscription.team$);
|
||||
const title = useLiveData(workspace.name$) || 'untitled';
|
||||
|
||||
if (workspace === null) {
|
||||
console.log('workspace is null', title);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FrameworkScope scope={workspace.scope}>
|
||||
<SettingHeader
|
||||
title={t['com.affine.payment.billing-setting.title']()}
|
||||
subtitle={t['com.affine.payment.billing-setting.subtitle']()}
|
||||
/>
|
||||
<SettingWrapper
|
||||
title={t['com.affine.payment.billing-setting.information']()}
|
||||
>
|
||||
<TeamCard />
|
||||
<TypeFormLink />
|
||||
<PaymentMethodUpdater />
|
||||
{team.end && team.canceledAt ? (
|
||||
<ResumeSubscription expirationDate={team.end} />
|
||||
) : null}
|
||||
</SettingWrapper>
|
||||
|
||||
<SettingWrapper title={t['com.affine.payment.billing-setting.history']()}>
|
||||
<BillingHistory />
|
||||
</SettingWrapper>
|
||||
</FrameworkScope>
|
||||
);
|
||||
};
|
||||
|
||||
const TeamCard = () => {
|
||||
const t = useI18n();
|
||||
const subscriptionService = useService(SubscriptionService);
|
||||
const teamSubscription = useLiveData(subscriptionService.subscription.team$);
|
||||
const teamPrices = useLiveData(subscriptionService.prices.teamPrice$);
|
||||
|
||||
const [openCancelModal, setOpenCancelModal] = useState(false);
|
||||
useEffect(() => {
|
||||
subscriptionService.subscription.revalidate();
|
||||
subscriptionService.prices.revalidate();
|
||||
}, [subscriptionService]);
|
||||
const expiration = teamSubscription?.end;
|
||||
const nextBillingDate = teamSubscription?.nextBillAt;
|
||||
const recurring = teamSubscription?.recurring;
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (recurring === SubscriptionRecurring.Yearly) {
|
||||
return t[
|
||||
'com.affine.settings.workspace.billing.team-workspace.description.billed.annually'
|
||||
]();
|
||||
}
|
||||
if (recurring === SubscriptionRecurring.Monthly) {
|
||||
return t[
|
||||
'com.affine.settings.workspace.billing.team-workspace.description.billed.monthly'
|
||||
]();
|
||||
}
|
||||
return t['com.affine.payment.billing-setting.free-trial']();
|
||||
}, [recurring, t]);
|
||||
|
||||
const expirationDate = useMemo(() => {
|
||||
if (expiration) {
|
||||
return t[
|
||||
'com.affine.settings.workspace.billing.team-workspace.not-renewed'
|
||||
]({
|
||||
date: new Date(expiration).toLocaleDateString(),
|
||||
});
|
||||
}
|
||||
if (nextBillingDate) {
|
||||
return t[
|
||||
'com.affine.settings.workspace.billing.team-workspace.next-billing-date'
|
||||
]({
|
||||
date: new Date(nextBillingDate).toLocaleDateString(),
|
||||
});
|
||||
}
|
||||
return '';
|
||||
}, [expiration, nextBillingDate, t]);
|
||||
|
||||
const amount = teamSubscription
|
||||
? teamPrices
|
||||
? teamSubscription.recurring === SubscriptionRecurring.Monthly
|
||||
? String((teamPrices.amount ?? 0) / 100)
|
||||
: String((teamPrices.yearlyAmount ?? 0) / 100)
|
||||
: '?'
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<div className={styles.planCard}>
|
||||
<div className={styles.currentPlan}>
|
||||
<SettingRow
|
||||
spreadCol={false}
|
||||
name={t['com.affine.settings.workspace.billing.team-workspace']()}
|
||||
desc={
|
||||
<>
|
||||
<div>{description}</div>
|
||||
<div>{expirationDate}</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<CancelTeamAction
|
||||
open={openCancelModal}
|
||||
onOpenChange={setOpenCancelModal}
|
||||
>
|
||||
<Button variant="primary" className={styles.cancelPlanButton}>
|
||||
{t[
|
||||
'com.affine.settings.workspace.billing.team-workspace.cancel-plan'
|
||||
]()}
|
||||
</Button>
|
||||
</CancelTeamAction>
|
||||
</div>
|
||||
<p className={styles.planPrice}>
|
||||
${amount}
|
||||
<span className={styles.billingFrequency}>
|
||||
/
|
||||
{teamSubscription?.recurring === SubscriptionRecurring.Monthly
|
||||
? t['com.affine.payment.billing-setting.month']()
|
||||
: t['com.affine.payment.billing-setting.year']()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => {
|
||||
const t = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleClick = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
name={t['com.affine.payment.billing-setting.expiration-date']()}
|
||||
desc={t['com.affine.payment.billing-setting.expiration-date.description'](
|
||||
{
|
||||
expirationDate: new Date(expirationDate).toLocaleDateString(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<ResumeAction open={open} onOpenChange={setOpen}>
|
||||
<Button onClick={handleClick}>
|
||||
{t['com.affine.payment.billing-setting.resume-subscription']()}
|
||||
</Button>
|
||||
</ResumeAction>
|
||||
</SettingRow>
|
||||
);
|
||||
};
|
||||
|
||||
const TypeFormLink = () => {
|
||||
const t = useI18n();
|
||||
const subscriptionService = useService(SubscriptionService);
|
||||
const authService = useService(AuthService);
|
||||
|
||||
const team = useLiveData(subscriptionService.subscription.team$);
|
||||
const account = useLiveData(authService.session.account$);
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const plan = [];
|
||||
if (team) plan.push(SubscriptionPlan.Team);
|
||||
|
||||
const link = getUpgradeQuestionnaireLink({
|
||||
name: account.info?.name,
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
recurring: team?.recurring ?? SubscriptionRecurring.Yearly,
|
||||
plan,
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
className={styles.paymentMethod}
|
||||
name={t['com.affine.payment.billing-type-form.title']()}
|
||||
desc={t['com.affine.payment.billing-type-form.description']()}
|
||||
>
|
||||
<a target="_blank" href={link} rel="noreferrer">
|
||||
<Button>{t['com.affine.payment.billing-type-form.go']()}</Button>
|
||||
</a>
|
||||
</SettingRow>
|
||||
);
|
||||
};
|
||||
|
||||
const PaymentMethodUpdater = () => {
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: createCustomerPortalMutation,
|
||||
});
|
||||
const urlService = useService(UrlService);
|
||||
const t = useI18n();
|
||||
|
||||
const update = useAsyncCallback(async () => {
|
||||
await trigger(null, {
|
||||
onSuccess: data => {
|
||||
urlService.openPopupWindow(data.createCustomerPortal);
|
||||
},
|
||||
});
|
||||
}, [trigger, urlService]);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
className={styles.paymentMethod}
|
||||
name={t['com.affine.payment.billing-setting.payment-method']()}
|
||||
desc={t[
|
||||
'com.affine.payment.billing-setting.payment-method.description'
|
||||
]()}
|
||||
>
|
||||
<Button onClick={update} loading={isMutating} disabled={isMutating}>
|
||||
{t['com.affine.payment.billing-setting.payment-method.go']()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
);
|
||||
};
|
||||
|
||||
const BillingHistory = () => {
|
||||
const t = useI18n();
|
||||
|
||||
const invoicesService = useService(InvoicesService);
|
||||
const pageInvoices = useLiveData(invoicesService.invoices.pageInvoices$);
|
||||
const invoiceCount = useLiveData(invoicesService.invoices.invoiceCount$);
|
||||
const isLoading = useLiveData(invoicesService.invoices.isLoading$);
|
||||
const error = useLiveData(invoicesService.invoices.error$);
|
||||
const pageNum = useLiveData(invoicesService.invoices.pageNum$);
|
||||
|
||||
useEffect(() => {
|
||||
invoicesService.invoices.revalidate();
|
||||
}, [invoicesService]);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(_: number, pageNum: number) => {
|
||||
invoicesService.invoices.setPageNum(pageNum);
|
||||
invoicesService.invoices.revalidate();
|
||||
},
|
||||
[invoicesService]
|
||||
);
|
||||
|
||||
if (invoiceCount === undefined) {
|
||||
if (isLoading) {
|
||||
return <BillingHistorySkeleton />;
|
||||
} else {
|
||||
return (
|
||||
<span style={{ color: cssVar('errorColor') }}>
|
||||
{error
|
||||
? UserFriendlyError.fromAnyError(error).message
|
||||
: 'Failed to load members'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.history}>
|
||||
<div className={styles.historyContent}>
|
||||
{invoiceCount === 0 ? (
|
||||
<p className={styles.noInvoice}>
|
||||
{t['com.affine.payment.billing-setting.no-invoice']()}
|
||||
</p>
|
||||
) : (
|
||||
pageInvoices?.map(invoice => (
|
||||
<InvoiceLine key={invoice.id} invoice={invoice} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{invoiceCount > invoicesService.invoices.PAGE_SIZE && (
|
||||
<Pagination
|
||||
totalCount={invoiceCount}
|
||||
countPerPage={invoicesService.invoices.PAGE_SIZE}
|
||||
pageNum={pageNum}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InvoiceLine = ({
|
||||
invoice,
|
||||
}: {
|
||||
invoice: NonNullable<InvoicesQuery['currentUser']>['invoices'][0];
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const urlService = useService(UrlService);
|
||||
|
||||
const open = useCallback(() => {
|
||||
if (invoice.link) {
|
||||
urlService.openPopupWindow(invoice.link);
|
||||
}
|
||||
}, [invoice.link, urlService]);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
key={invoice.id}
|
||||
name={new Date(invoice.createdAt).toLocaleDateString()}
|
||||
desc={`${
|
||||
invoice.status === InvoiceStatus.Paid
|
||||
? t['com.affine.payment.billing-setting.paid']()
|
||||
: ''
|
||||
} $${invoice.amount / 100}`}
|
||||
>
|
||||
<Button onClick={open}>
|
||||
{t['com.affine.payment.billing-setting.view-invoice']()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
);
|
||||
};
|
||||
|
||||
const BillingHistorySkeleton = () => {
|
||||
return (
|
||||
<div className={styles.billingHistorySkeleton}>
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const paymentMethod = style({
|
||||
marginTop: '24px',
|
||||
});
|
||||
|
||||
export const history = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '24px',
|
||||
});
|
||||
export const historyContent = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const noInvoice = style({
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
|
||||
export const subscriptionSettingSkeleton = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
});
|
||||
|
||||
export const billingHistorySkeleton = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '72px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const planCard = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px',
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
borderRadius: '8px',
|
||||
});
|
||||
|
||||
export const currentPlan = style({
|
||||
flex: '1 0 0',
|
||||
});
|
||||
|
||||
export const planPrice = style({
|
||||
fontSize: cssVar('fontH6'),
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const billingFrequency = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
});
|
||||
|
||||
export const currentPlanName = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
color: cssVar('textEmphasisColor'),
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const cancelPlanButton = style({
|
||||
marginTop: '8px',
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { SettingTab } from '@affine/core/modules/dialogs/constant';
|
||||
import type { WorkspaceMetadata } from '@toeverything/infra';
|
||||
|
||||
import type { SettingState } from '../types';
|
||||
import { WorkspaceSettingBilling } from './billing';
|
||||
import { WorkspaceSettingDetail } from './new-workspace-setting-detail';
|
||||
import { WorkspaceSettingProperties } from './properties';
|
||||
|
||||
@@ -29,6 +30,8 @@ export const WorkspaceSetting = ({
|
||||
return (
|
||||
<WorkspaceSettingProperties workspaceMetadata={workspaceMetadata} />
|
||||
);
|
||||
case 'workspace:billing':
|
||||
return <WorkspaceSettingBilling />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { Switch } from '@affine/component';
|
||||
import {
|
||||
SettingRow,
|
||||
SettingWrapper,
|
||||
} from '@affine/component/setting-components';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { ServerService } from '@affine/core/modules/cloud';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { WorkspaceShareSettingService } from '@affine/core/modules/share-setting';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
export const AiSetting = () => {
|
||||
const t = useI18n();
|
||||
const shareSetting = useService(WorkspaceShareSettingService).sharePreview;
|
||||
const serverService = useService(ServerService);
|
||||
const serverEnableAi = useLiveData(
|
||||
serverService.server.features$.map(f => f?.copilot)
|
||||
);
|
||||
const workspaceEnableAi = useLiveData(shareSetting.enableAi$);
|
||||
const loading = useLiveData(shareSetting.isLoading$);
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
|
||||
const toggleAi = useAsyncCallback(
|
||||
async (checked: boolean) => {
|
||||
await shareSetting.setEnableAi(checked);
|
||||
},
|
||||
[shareSetting]
|
||||
);
|
||||
|
||||
if (!isOwner || !serverEnableAi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingWrapper
|
||||
title={t['com.affine.settings.workspace.affine-ai.title']()}
|
||||
>
|
||||
<SettingRow
|
||||
name={t['com.affine.settings.workspace.affine-ai.label']()}
|
||||
desc={t['com.affine.settings.workspace.affine-ai.description']()}
|
||||
>
|
||||
<Switch
|
||||
checked={!!workspaceEnableAi}
|
||||
onChange={toggleAi}
|
||||
disabled={loading}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingWrapper>
|
||||
);
|
||||
};
|
||||
+2
@@ -11,6 +11,7 @@ import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||
import { FrameworkScope } from '@toeverything/infra';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { AiSetting } from './ai';
|
||||
import { DeleteLeaveWorkspace } from './delete-leave-workspace';
|
||||
import { EnableCloudPanel } from './enable-cloud';
|
||||
import { DesktopExportPanel } from './export';
|
||||
@@ -70,6 +71,7 @@ export const WorkspaceSettingDetail = ({
|
||||
<EnableCloudPanel onCloseSetting={onCloseSetting} />
|
||||
<MembersPanel onChangeSettingState={onChangeSettingState} />
|
||||
</SettingWrapper>
|
||||
<AiSetting />
|
||||
<SharingPanel />
|
||||
{BUILD_CONFIG.isElectron && (
|
||||
<SettingWrapper title={t['Storage and Export']()}>
|
||||
|
||||
+27
-12
@@ -1,7 +1,7 @@
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { Workspace } from '@toeverything/infra';
|
||||
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import * as style from './style.css';
|
||||
@@ -12,6 +12,7 @@ type WorkspaceStatus =
|
||||
| 'selfHosted'
|
||||
| 'joinedWorkspace'
|
||||
| 'availableOffline'
|
||||
| 'teamWorkspace'
|
||||
| 'publishedToWeb';
|
||||
|
||||
type LabelProps = {
|
||||
@@ -38,42 +39,55 @@ const Label = ({ value, background }: LabelProps) => {
|
||||
|
||||
const getConditions = (
|
||||
isOwner: boolean | null,
|
||||
workspace: Workspace
|
||||
flavour: string,
|
||||
isTeam: boolean | null
|
||||
): labelConditionsProps[] => {
|
||||
return [
|
||||
{ condition: !isOwner, label: 'joinedWorkspace' },
|
||||
{ condition: workspace.flavour === 'local', label: 'local' },
|
||||
{ condition: flavour === 'local', label: 'local' },
|
||||
{
|
||||
condition: workspace.flavour === 'affine-cloud',
|
||||
condition: flavour === 'affine-cloud',
|
||||
label: 'syncCloud',
|
||||
},
|
||||
{
|
||||
condition: !!isTeam,
|
||||
label: 'teamWorkspace',
|
||||
},
|
||||
{
|
||||
condition: flavour !== 'affine-cloud' && flavour !== 'local',
|
||||
label: 'selfHosted',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getLabelMap = (t: ReturnType<typeof useI18n>): LabelMap => ({
|
||||
local: {
|
||||
value: t['com.affine.settings.workspace.state.local'](),
|
||||
background: 'var(--affine-tag-orange)',
|
||||
background: cssVarV2('chip/label/orange'),
|
||||
},
|
||||
syncCloud: {
|
||||
value: t['com.affine.settings.workspace.state.sync-affine-cloud'](),
|
||||
background: 'var(--affine-tag-blue)',
|
||||
background: cssVarV2('chip/label/blue'),
|
||||
},
|
||||
selfHosted: {
|
||||
value: t['com.affine.settings.workspace.state.self-hosted'](),
|
||||
background: 'var(--affine-tag-purple)',
|
||||
background: cssVarV2('chip/label/purple'),
|
||||
},
|
||||
joinedWorkspace: {
|
||||
value: t['com.affine.settings.workspace.state.joined'](),
|
||||
background: 'var(--affine-tag-yellow)',
|
||||
background: cssVarV2('chip/label/yellow'),
|
||||
},
|
||||
availableOffline: {
|
||||
value: t['com.affine.settings.workspace.state.available-offline'](),
|
||||
background: 'var(--affine-tag-green)',
|
||||
background: cssVarV2('chip/label/green'),
|
||||
},
|
||||
publishedToWeb: {
|
||||
value: t['com.affine.settings.workspace.state.published'](),
|
||||
background: 'var(--affine-tag-blue)',
|
||||
background: cssVarV2('chip/label/blue'),
|
||||
},
|
||||
teamWorkspace: {
|
||||
value: t['com.affine.settings.workspace.state.team'](),
|
||||
background: cssVarV2('chip/label/purple'),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,6 +95,7 @@ export const LabelsPanel = () => {
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
const isTeam = useLiveData(permissionService.permission.isTeam$);
|
||||
const t = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,8 +105,8 @@ export const LabelsPanel = () => {
|
||||
const labelMap = useMemo(() => getLabelMap(t), [t]);
|
||||
|
||||
const labelConditions = useMemo(
|
||||
() => getConditions(isOwner, workspace),
|
||||
[isOwner, workspace]
|
||||
() => getConditions(isOwner, workspace.flavour, isTeam),
|
||||
[isOwner, isTeam, workspace.flavour]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
-420
@@ -1,420 +0,0 @@
|
||||
import { notify } from '@affine/component';
|
||||
import type { InviteModalProps } from '@affine/component/member-components';
|
||||
import {
|
||||
InviteModal,
|
||||
MemberLimitModal,
|
||||
Pagination,
|
||||
} from '@affine/component/member-components';
|
||||
import { SettingRow } from '@affine/component/setting-components';
|
||||
import { Avatar } from '@affine/component/ui/avatar';
|
||||
import { Button, IconButton } from '@affine/component/ui/button';
|
||||
import { Loading } from '@affine/component/ui/loading';
|
||||
import { Menu, MenuItem } from '@affine/component/ui/menu';
|
||||
import { Tooltip } from '@affine/component/ui/tooltip';
|
||||
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
||||
import { useInviteMember } from '@affine/core/components/hooks/affine/use-invite-member';
|
||||
import { useRevokeMemberPermission } from '@affine/core/components/hooks/affine/use-revoke-member-permission';
|
||||
import {
|
||||
type Member,
|
||||
WorkspaceMembersService,
|
||||
WorkspacePermissionService,
|
||||
} from '@affine/core/modules/permissions';
|
||||
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
|
||||
import { Permission, UserFriendlyError } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
useEnsureLiveData,
|
||||
useLiveData,
|
||||
useService,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import clsx from 'clsx';
|
||||
import { clamp } from 'lodash-es';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
type AuthAccountInfo,
|
||||
AuthService,
|
||||
ServerService,
|
||||
SubscriptionService,
|
||||
} from '../../../../../modules/cloud';
|
||||
import type { SettingState } from '../../types';
|
||||
import * as style from './style.css';
|
||||
|
||||
type OnRevoke = (memberId: string) => void;
|
||||
const MembersPanelLocal = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Tooltip content={t['com.affine.settings.member-tooltip']()}>
|
||||
<div className={style.fakeWrapper}>
|
||||
<SettingRow name={`${t['Members']()} (0)`} desc={t['Members hint']()}>
|
||||
<Button>{t['Invite Members']()}</Button>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const CloudWorkspaceMembersPanel = ({
|
||||
onChangeSettingState,
|
||||
}: {
|
||||
onChangeSettingState: (settingState: SettingState) => void;
|
||||
}) => {
|
||||
const serverService = useService(ServerService);
|
||||
const hasPaymentFeature = useLiveData(
|
||||
serverService.server.features$.map(f => f?.payment)
|
||||
);
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
useEffect(() => {
|
||||
permissionService.permission.revalidate();
|
||||
}, [permissionService]);
|
||||
|
||||
const workspaceQuotaService = useService(WorkspaceQuotaService);
|
||||
useEffect(() => {
|
||||
workspaceQuotaService.quota.revalidate();
|
||||
}, [workspaceQuotaService]);
|
||||
const isLoading = useLiveData(workspaceQuotaService.quota.isLoading$);
|
||||
const error = useLiveData(workspaceQuotaService.quota.error$);
|
||||
const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$);
|
||||
const subscriptionService = useService(SubscriptionService);
|
||||
const plan = useLiveData(
|
||||
subscriptionService.subscription.pro$.map(s => s?.plan)
|
||||
);
|
||||
const isLimited =
|
||||
workspaceQuota && workspaceQuota.memberLimit
|
||||
? workspaceQuota.memberCount >= workspaceQuota.memberLimit
|
||||
: null;
|
||||
|
||||
const t = useI18n();
|
||||
const { invite, isMutating } = useInviteMember(workspace.id);
|
||||
const revokeMemberPermission = useRevokeMemberPermission(workspace.id);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const onInviteConfirm = useCallback<InviteModalProps['onConfirm']>(
|
||||
async ({ email, permission }) => {
|
||||
const success = await invite(
|
||||
email,
|
||||
permission,
|
||||
// send invite email
|
||||
true
|
||||
);
|
||||
if (success) {
|
||||
notify.success({
|
||||
title: t['Invitation sent'](),
|
||||
message: t['Invitation sent hint'](),
|
||||
});
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[invite, t]
|
||||
);
|
||||
|
||||
const handleUpgradeConfirm = useCallback(() => {
|
||||
onChangeSettingState({
|
||||
activeTab: 'plans',
|
||||
scrollAnchor: 'cloudPricingPlan',
|
||||
});
|
||||
track.$.settingsPanel.workspace.viewPlans({
|
||||
control: 'inviteMember',
|
||||
});
|
||||
}, [onChangeSettingState]);
|
||||
|
||||
const onRevoke = useCallback<OnRevoke>(
|
||||
async memberId => {
|
||||
const res = await revokeMemberPermission(memberId);
|
||||
if (res?.revoke) {
|
||||
notify.success({ title: t['Removed successfully']() });
|
||||
}
|
||||
},
|
||||
[revokeMemberPermission, t]
|
||||
);
|
||||
|
||||
const desc = useMemo(() => {
|
||||
if (!workspaceQuota) return null;
|
||||
return (
|
||||
<span>
|
||||
{t['com.affine.payment.member.description2']()}
|
||||
{hasPaymentFeature ? (
|
||||
<div
|
||||
className={style.goUpgradeWrapper}
|
||||
onClick={handleUpgradeConfirm}
|
||||
>
|
||||
<span className={style.goUpgrade}>
|
||||
{t['com.affine.payment.member.description.choose-plan']()}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}, [handleUpgradeConfirm, hasPaymentFeature, t, workspaceQuota]);
|
||||
|
||||
if (workspaceQuota === null) {
|
||||
if (isLoading) {
|
||||
return <MembersPanelFallback />;
|
||||
} else {
|
||||
return (
|
||||
<span style={{ color: cssVar('errorColor') }}>
|
||||
{error
|
||||
? UserFriendlyError.fromAnyError(error).message
|
||||
: 'Failed to load members'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow
|
||||
name={`${t['Members']()} (${workspaceQuota.memberCount}/${workspaceQuota.humanReadable.memberLimit})`}
|
||||
desc={desc}
|
||||
spreadCol={!!isOwner}
|
||||
>
|
||||
{isOwner ? (
|
||||
<>
|
||||
<Button onClick={openModal}>{t['Invite Members']()}</Button>
|
||||
{isLimited ? (
|
||||
<MemberLimitModal
|
||||
isFreePlan={!!plan}
|
||||
open={open}
|
||||
plan={workspaceQuota.humanReadable.name ?? ''}
|
||||
quota={workspaceQuota.humanReadable.memberLimit ?? ''}
|
||||
setOpen={setOpen}
|
||||
onConfirm={handleUpgradeConfirm}
|
||||
/>
|
||||
) : (
|
||||
<InviteModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onConfirm={onInviteConfirm}
|
||||
isMutating={isMutating}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</SettingRow>
|
||||
|
||||
<div className={style.membersPanel}>
|
||||
<MemberList isOwner={!!isOwner} onRevoke={onRevoke} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export const MembersPanelFallback = () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow
|
||||
name={t['Members']()}
|
||||
desc={t['com.affine.payment.member.description2']()}
|
||||
/>
|
||||
<div className={style.membersPanel}>
|
||||
<MemberListFallback memberCount={1} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberListFallback = ({ memberCount }: { memberCount?: number }) => {
|
||||
// prevent page jitter
|
||||
const height = useMemo(() => {
|
||||
if (memberCount) {
|
||||
// height and margin-bottom
|
||||
return memberCount * 58 + (memberCount - 1) * 6;
|
||||
}
|
||||
return 'auto';
|
||||
}, [memberCount]);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
}}
|
||||
className={style.membersFallback}
|
||||
>
|
||||
<Loading size={20} />
|
||||
<span>{t['com.affine.settings.member.loading']()}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberList = ({
|
||||
isOwner,
|
||||
onRevoke,
|
||||
}: {
|
||||
isOwner: boolean;
|
||||
onRevoke: OnRevoke;
|
||||
}) => {
|
||||
const membersService = useService(WorkspaceMembersService);
|
||||
const memberCount = useLiveData(membersService.members.memberCount$);
|
||||
const pageNum = useLiveData(membersService.members.pageNum$);
|
||||
const isLoading = useLiveData(membersService.members.isLoading$);
|
||||
const error = useLiveData(membersService.members.error$);
|
||||
const pageMembers = useLiveData(membersService.members.pageMembers$);
|
||||
|
||||
useEffect(() => {
|
||||
membersService.members.revalidate();
|
||||
}, [membersService]);
|
||||
|
||||
const session = useService(AuthService).session;
|
||||
const account = useEnsureLiveData(session.account$);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(_: number, pageNum: number) => {
|
||||
membersService.members.setPageNum(pageNum);
|
||||
membersService.members.revalidate();
|
||||
},
|
||||
[membersService]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={style.memberList}>
|
||||
{pageMembers === undefined ? (
|
||||
isLoading ? (
|
||||
<MemberListFallback
|
||||
memberCount={
|
||||
memberCount
|
||||
? clamp(
|
||||
memberCount - pageNum * membersService.members.PAGE_SIZE,
|
||||
1,
|
||||
membersService.members.PAGE_SIZE
|
||||
)
|
||||
: 1
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: cssVar('errorColor') }}>
|
||||
{error
|
||||
? UserFriendlyError.fromAnyError(error).message
|
||||
: 'Failed to load members'}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
pageMembers?.map(member => (
|
||||
<MemberItem
|
||||
currentAccount={account}
|
||||
key={member.id}
|
||||
member={member}
|
||||
isOwner={isOwner}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{memberCount !== undefined &&
|
||||
memberCount > membersService.members.PAGE_SIZE && (
|
||||
<Pagination
|
||||
totalCount={memberCount}
|
||||
countPerPage={membersService.members.PAGE_SIZE}
|
||||
pageNum={pageNum}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberItem = ({
|
||||
member,
|
||||
isOwner,
|
||||
currentAccount,
|
||||
onRevoke,
|
||||
}: {
|
||||
member: Member;
|
||||
isOwner: boolean;
|
||||
currentAccount: AuthAccountInfo;
|
||||
onRevoke: OnRevoke;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
const handleRevoke = useCallback(() => {
|
||||
onRevoke(member.id);
|
||||
}, [onRevoke, member.id]);
|
||||
|
||||
const operationButtonInfo = useMemo(() => {
|
||||
return {
|
||||
show: isOwner && currentAccount.id !== member.id,
|
||||
leaveOrRevokeText: t['Remove from workspace'](),
|
||||
};
|
||||
}, [currentAccount.id, isOwner, member.id, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className={style.memberListItem}
|
||||
data-testid="member-item"
|
||||
>
|
||||
<Avatar
|
||||
size={36}
|
||||
url={member.avatarUrl}
|
||||
name={(member.name ? member.name : member.email) as string}
|
||||
/>
|
||||
<div className={style.memberContainer}>
|
||||
{member.name ? (
|
||||
<>
|
||||
<div className={style.memberName}>{member.name}</div>
|
||||
<div className={style.memberEmail}>{member.email}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={style.memberName}>{member.email}</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(style.roleOrStatus, {
|
||||
pending: !member.accepted,
|
||||
})}
|
||||
>
|
||||
{member.accepted
|
||||
? member.permission === Permission.Owner
|
||||
? 'Workspace Owner'
|
||||
: 'Member'
|
||||
: 'Pending'}
|
||||
</div>
|
||||
<Menu
|
||||
items={
|
||||
<MenuItem data-member-id={member.id} onClick={handleRevoke}>
|
||||
{operationButtonInfo.leaveOrRevokeText}
|
||||
</MenuItem>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
disabled={!operationButtonInfo.show}
|
||||
style={{
|
||||
visibility: operationButtonInfo.show ? 'visible' : 'hidden',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MembersPanel = ({
|
||||
onChangeSettingState,
|
||||
}: {
|
||||
onChangeSettingState: (settingState: SettingState) => void;
|
||||
}): ReactElement | null => {
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
if (workspace.flavour === 'local') {
|
||||
return <MembersPanelLocal />;
|
||||
}
|
||||
return (
|
||||
<AffineErrorBoundary>
|
||||
<CloudWorkspaceMembersPanel onChangeSettingState={onChangeSettingState} />
|
||||
</AffineErrorBoundary>
|
||||
);
|
||||
};
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
import { Button, Loading, notify } from '@affine/component';
|
||||
import {
|
||||
InviteModal,
|
||||
type InviteModalProps,
|
||||
InviteTeamMemberModal,
|
||||
type InviteTeamMemberModalProps,
|
||||
MemberLimitModal,
|
||||
} from '@affine/component/member-components';
|
||||
import { SettingRow } from '@affine/component/setting-components';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { Upload } from '@affine/core/components/pure/file-upload';
|
||||
import { ServerService, SubscriptionService } from '@affine/core/modules/cloud';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
|
||||
import { copyTextToClipboard } from '@affine/core/utils/clipboard';
|
||||
import { emailRegex } from '@affine/core/utils/email-regex';
|
||||
import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql';
|
||||
import { UserFriendlyError } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { ExportIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { SettingState } from '../../../types';
|
||||
import { MemberList } from './member-list';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const parseCSV = async (blob: Blob): Promise<string[]> => {
|
||||
try {
|
||||
const textContent = await blob.text();
|
||||
const emails = textContent
|
||||
.split('\n')
|
||||
.map(email => email.trim())
|
||||
.filter(email => email.length > 0 && emailRegex.test(email));
|
||||
|
||||
return emails;
|
||||
} catch (error) {
|
||||
console.error('Error parsing CSV:', error);
|
||||
throw new Error('Failed to parse CSV');
|
||||
}
|
||||
};
|
||||
|
||||
export const CloudWorkspaceMembersPanel = ({
|
||||
onChangeSettingState,
|
||||
isTeam,
|
||||
}: {
|
||||
onChangeSettingState: (settingState: SettingState) => void;
|
||||
isTeam?: boolean;
|
||||
}) => {
|
||||
const serverService = useService(ServerService);
|
||||
const hasPaymentFeature = useLiveData(
|
||||
serverService.server.features$.map(f => f?.payment)
|
||||
);
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
|
||||
useEffect(() => {
|
||||
permissionService.permission.revalidate();
|
||||
}, [permissionService]);
|
||||
|
||||
const workspaceQuotaService = useService(WorkspaceQuotaService);
|
||||
useEffect(() => {
|
||||
workspaceQuotaService.quota.revalidate();
|
||||
}, [workspaceQuotaService]);
|
||||
const isLoading = useLiveData(workspaceQuotaService.quota.isLoading$);
|
||||
const error = useLiveData(workspaceQuotaService.quota.error$);
|
||||
const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$);
|
||||
const subscriptionService = useService(SubscriptionService);
|
||||
const plan = useLiveData(
|
||||
subscriptionService.subscription.pro$.map(s => s?.plan)
|
||||
);
|
||||
const isLimited =
|
||||
workspaceQuota && workspaceQuota.memberLimit
|
||||
? workspaceQuota.memberCount >= workspaceQuota.memberLimit
|
||||
: null;
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const onGenerateInviteLink = useCallback(
|
||||
async (expireTime: WorkspaceInviteLinkExpireTime) => {
|
||||
const link =
|
||||
await permissionService.permission.generateInviteLink(expireTime);
|
||||
return link;
|
||||
},
|
||||
[permissionService.permission]
|
||||
);
|
||||
|
||||
const onRevokeInviteLink = useCallback(async () => {
|
||||
const success = await permissionService.permission.revokeInviteLink();
|
||||
return success;
|
||||
}, [permissionService.permission]);
|
||||
|
||||
const onInviteConfirm = useCallback<InviteModalProps['onConfirm']>(
|
||||
async ({ email, permission }) => {
|
||||
setIsMutating(true);
|
||||
const success = await permissionService.permission.inviteMember(
|
||||
email,
|
||||
permission,
|
||||
true
|
||||
);
|
||||
if (success) {
|
||||
notify.success({
|
||||
title: t['Invitation sent'](),
|
||||
message: t['Invitation sent hint'](),
|
||||
});
|
||||
setOpen(false);
|
||||
}
|
||||
setIsMutating(false);
|
||||
},
|
||||
[permissionService.permission, t]
|
||||
);
|
||||
const onInviteBatchConfirm = useCallback<
|
||||
InviteTeamMemberModalProps['onConfirm']
|
||||
>(
|
||||
async ({ emails }) => {
|
||||
setIsMutating(true);
|
||||
const success = await permissionService.permission.inviteMembers(
|
||||
emails,
|
||||
true
|
||||
);
|
||||
if (success) {
|
||||
notify.success({
|
||||
title: t['Invitation sent'](),
|
||||
message: t['Invitation sent hint'](),
|
||||
});
|
||||
setOpen(false);
|
||||
}
|
||||
setIsMutating(false);
|
||||
},
|
||||
[permissionService.permission, t]
|
||||
);
|
||||
|
||||
const onImportCSV = useAsyncCallback(
|
||||
async (file: File) => {
|
||||
setIsMutating(true);
|
||||
const emails = await parseCSV(file);
|
||||
onInviteBatchConfirm({ emails });
|
||||
setIsMutating(false);
|
||||
},
|
||||
[onInviteBatchConfirm]
|
||||
);
|
||||
|
||||
const handleUpgradeConfirm = useCallback(() => {
|
||||
onChangeSettingState({
|
||||
activeTab: 'plans',
|
||||
scrollAnchor: 'cloudPricingPlan',
|
||||
});
|
||||
track.$.settingsPanel.workspace.viewPlans({
|
||||
control: 'inviteMember',
|
||||
});
|
||||
}, [onChangeSettingState]);
|
||||
|
||||
const desc = useMemo(() => {
|
||||
if (!workspaceQuota) return null;
|
||||
|
||||
if (isTeam) {
|
||||
return <span>{t['com.affine.payment.member.team.description']()}</span>;
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{t['com.affine.payment.member.description2']()}
|
||||
{hasPaymentFeature ? (
|
||||
<div
|
||||
className={styles.goUpgradeWrapper}
|
||||
onClick={handleUpgradeConfirm}
|
||||
>
|
||||
<span className={styles.goUpgrade}>
|
||||
{t['com.affine.payment.member.description.choose-plan']()}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}, [handleUpgradeConfirm, hasPaymentFeature, isTeam, t, workspaceQuota]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (isTeam) {
|
||||
return `${t['Members']()} (${workspaceQuota?.memberCount})`;
|
||||
}
|
||||
return `${t['Members']()} (${workspaceQuota?.memberCount}/${workspaceQuota?.memberLimit})`;
|
||||
}, [isTeam, t, workspaceQuota?.memberCount, workspaceQuota?.memberLimit]);
|
||||
|
||||
if (workspaceQuota === null) {
|
||||
if (isLoading) {
|
||||
return <MembersPanelFallback />;
|
||||
} else {
|
||||
return (
|
||||
<span className={styles.errorStyle}>
|
||||
{error
|
||||
? UserFriendlyError.fromAnyError(error).message
|
||||
: 'Failed to load members'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow name={title} desc={desc} spreadCol={!!isOwner}>
|
||||
{isOwner ? (
|
||||
<>
|
||||
<Button onClick={openModal}>{t['Invite Members']()}</Button>
|
||||
{isTeam ? (
|
||||
<InviteTeamMemberModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onConfirm={onInviteBatchConfirm}
|
||||
isMutating={isMutating}
|
||||
copyTextToClipboard={copyTextToClipboard}
|
||||
onGenerateInviteLink={onGenerateInviteLink}
|
||||
onRevokeInviteLink={onRevokeInviteLink}
|
||||
importCSV={<ImportCSV onImport={onImportCSV} />}
|
||||
/>
|
||||
) : isLimited ? (
|
||||
<MemberLimitModal
|
||||
isFreePlan={!plan}
|
||||
open={open}
|
||||
plan={workspaceQuota.humanReadable.name ?? ''}
|
||||
quota={workspaceQuota.humanReadable.memberLimit ?? ''}
|
||||
setOpen={setOpen}
|
||||
onConfirm={handleUpgradeConfirm}
|
||||
/>
|
||||
) : (
|
||||
<InviteModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onConfirm={onInviteConfirm}
|
||||
isMutating={isMutating}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</SettingRow>
|
||||
|
||||
<div className={styles.membersPanel}>
|
||||
<MemberList isOwner={!!isOwner} isAdmin={!!isAdmin} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MembersPanelFallback = () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow
|
||||
name={t['Members']()}
|
||||
desc={t['com.affine.payment.member.description2']()}
|
||||
/>
|
||||
<div className={styles.membersPanel}>
|
||||
<MemberListFallback memberCount={1} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberListFallback = ({ memberCount }: { memberCount?: number }) => {
|
||||
// prevent page jitter
|
||||
const height = useMemo(() => {
|
||||
if (memberCount) {
|
||||
// height and margin-bottom
|
||||
return memberCount * 58 + (memberCount - 1) * 6;
|
||||
}
|
||||
return 'auto';
|
||||
}, [memberCount]);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
}}
|
||||
className={styles.membersFallback}
|
||||
>
|
||||
<Loading size={20} />
|
||||
<span>{t['com.affine.settings.member.loading']()}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImportCSV = ({ onImport }: { onImport: (file: File) => void }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Upload accept="text/csv" fileChange={onImport}>
|
||||
<Button className={styles.importButton} prefix={<ExportIcon />}>
|
||||
{t['com.affine.payment.member.team.invite.import-csv']()}
|
||||
</Button>
|
||||
</Upload>
|
||||
);
|
||||
};
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { ConfirmModal, Input } from '@affine/component';
|
||||
import type { Member } from '@affine/core/modules/permissions';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const ConfirmAssignModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
member,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
isEquals,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
isEquals: boolean;
|
||||
member: Member;
|
||||
inputValue: string;
|
||||
onConfirm: () => void;
|
||||
setInputValue: (value: string) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
childrenContentClassName={styles.confirmAssignModalContent}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t['com.affine.payment.member.team.assign.confirm.title']()}
|
||||
confirmText={t['com.affine.payment.member.team.assign.confirm.button']()}
|
||||
onConfirm={onConfirm}
|
||||
confirmButtonOptions={{ disabled: !isEquals, variant: 'error' }}
|
||||
>
|
||||
<div className={styles.confirmAssignModalContent}>
|
||||
<div>
|
||||
<p>
|
||||
{t['com.affine.payment.member.team.assign.confirm.description']({
|
||||
name: member.name || member.email || member.id,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t['com.affine.payment.member.team.assign.confirm.description-1']()}
|
||||
</p>
|
||||
<p>
|
||||
{t['com.affine.payment.member.team.assign.confirm.description-2']()}
|
||||
</p>
|
||||
<p>
|
||||
{t['com.affine.payment.member.team.assign.confirm.description-3']()}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.confirmInputContainer}>
|
||||
{t['com.affine.payment.member.team.assign.confirm.description-4']()}
|
||||
<Input
|
||||
value={inputValue}
|
||||
inputStyle={{ fontSize: cssVar('fontSm') }}
|
||||
onChange={setInputValue}
|
||||
placeholder={t.t(
|
||||
'com.affine.payment.member.team.assign.confirm.placeholder'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { Button, Tooltip } from '@affine/component';
|
||||
import { SettingRow } from '@affine/component/setting-components';
|
||||
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
||||
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService, WorkspaceService } from '@toeverything/infra';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import type { SettingState } from '../../../types';
|
||||
import { CloudWorkspaceMembersPanel } from './cloud-members-panel';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const MembersPanel = ({
|
||||
onChangeSettingState,
|
||||
}: {
|
||||
onChangeSettingState: (settingState: SettingState) => void;
|
||||
}): ReactElement | null => {
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const isTeam = useWorkspaceInfo(workspace.meta)?.isTeam;
|
||||
if (workspace.flavour === 'local') {
|
||||
return <MembersPanelLocal />;
|
||||
}
|
||||
return (
|
||||
<AffineErrorBoundary>
|
||||
<CloudWorkspaceMembersPanel
|
||||
onChangeSettingState={onChangeSettingState}
|
||||
isTeam={isTeam}
|
||||
/>
|
||||
</AffineErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const MembersPanelLocal = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Tooltip content={t['com.affine.settings.member-tooltip']()}>
|
||||
<div className={styles.fakeWrapper}>
|
||||
<SettingRow name={`${t['Members']()} (0)`} desc={t['Members hint']()}>
|
||||
<Button>{t['Invite Members']()}</Button>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
import { Avatar, IconButton, Loading, Menu, notify } from '@affine/component';
|
||||
import { Pagination } from '@affine/component/member-components';
|
||||
import { type AuthAccountInfo, AuthService } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
type Member,
|
||||
WorkspaceMembersService,
|
||||
WorkspacePermissionService,
|
||||
} from '@affine/core/modules/permissions';
|
||||
import {
|
||||
Permission,
|
||||
UserFriendlyError,
|
||||
WorkspaceMemberStatus,
|
||||
} from '@affine/graphql';
|
||||
import { type I18nString, useI18n } from '@affine/i18n';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
useEnsureLiveData,
|
||||
useLiveData,
|
||||
useService,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ConfirmAssignModal } from './confirm-assign-modal';
|
||||
import { MemberOptions } from './member-option';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const MemberList = ({
|
||||
isOwner,
|
||||
isAdmin,
|
||||
}: {
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
}) => {
|
||||
const membersService = useService(WorkspaceMembersService);
|
||||
const memberCount = useLiveData(membersService.members.memberCount$);
|
||||
const pageNum = useLiveData(membersService.members.pageNum$);
|
||||
const isLoading = useLiveData(membersService.members.isLoading$);
|
||||
const error = useLiveData(membersService.members.error$);
|
||||
const pageMembers = useLiveData(membersService.members.pageMembers$);
|
||||
|
||||
useEffect(() => {
|
||||
membersService.members.revalidate();
|
||||
}, [membersService]);
|
||||
|
||||
const session = useService(AuthService).session;
|
||||
const account = useEnsureLiveData(session.account$);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(_: number, pageNum: number) => {
|
||||
membersService.members.setPageNum(pageNum);
|
||||
membersService.members.revalidate();
|
||||
},
|
||||
[membersService]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{pageMembers === undefined ? (
|
||||
isLoading ? (
|
||||
<MemberListFallback
|
||||
memberCount={
|
||||
memberCount
|
||||
? clamp(
|
||||
memberCount - pageNum * membersService.members.PAGE_SIZE,
|
||||
1,
|
||||
membersService.members.PAGE_SIZE
|
||||
)
|
||||
: 1
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.errorStyle}>
|
||||
{error
|
||||
? UserFriendlyError.fromAnyError(error).message
|
||||
: 'Failed to load members'}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
pageMembers?.map(member => (
|
||||
<MemberItem
|
||||
currentAccount={account}
|
||||
key={member.id}
|
||||
member={member}
|
||||
isOwner={isOwner}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{memberCount !== undefined &&
|
||||
memberCount > membersService.members.PAGE_SIZE && (
|
||||
<Pagination
|
||||
totalCount={memberCount}
|
||||
countPerPage={membersService.members.PAGE_SIZE}
|
||||
pageNum={pageNum}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberItem = ({
|
||||
member,
|
||||
isOwner,
|
||||
isAdmin,
|
||||
currentAccount,
|
||||
}: {
|
||||
member: Member;
|
||||
isAdmin: boolean;
|
||||
isOwner: boolean;
|
||||
currentAccount: AuthAccountInfo;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const workspaceName = useLiveData(workspace.name$);
|
||||
const permission = useService(WorkspacePermissionService).permission;
|
||||
const isEquals = workspaceName === inputValue;
|
||||
|
||||
const show = isOwner && currentAccount.id !== member.id;
|
||||
|
||||
const handleOpenAssignModal = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmAssign = useCallback(() => {
|
||||
permission
|
||||
.adjustMemberPermission(member.id, Permission.Owner)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setOpen(false);
|
||||
notify.success({
|
||||
title: t['com.affine.payment.member.team.assign.notify.title'](),
|
||||
message: t['com.affine.payment.member.team.assign.notify.message']({
|
||||
name: member.name || member.email || member.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
notify.error({
|
||||
title: 'Operation failed',
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}, [permission, member, t]);
|
||||
|
||||
const memberStatus = useMemo(() => getMemberStatus(member), [member]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className={styles.memberListItem}
|
||||
data-testid="member-item"
|
||||
>
|
||||
<Avatar
|
||||
size={36}
|
||||
url={member.avatarUrl}
|
||||
name={(member.name ? member.name : member.email) as string}
|
||||
/>
|
||||
<div className={styles.memberContainer}>
|
||||
{member.name ? (
|
||||
<>
|
||||
<div className={styles.memberName}>{member.name}</div>
|
||||
<div className={styles.memberEmail}>{member.email}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.memberName}>{member.email}</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.roleOrStatus, {
|
||||
pending: !member.accepted,
|
||||
})}
|
||||
>
|
||||
{t.t(memberStatus)}
|
||||
</div>
|
||||
<Menu
|
||||
items={
|
||||
<MemberOptions
|
||||
member={member}
|
||||
openAssignModal={handleOpenAssignModal}
|
||||
isAdmin={isAdmin}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
disabled={!show}
|
||||
style={{
|
||||
visibility: show ? 'visible' : 'hidden',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
<ConfirmAssignModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
member={member}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
isEquals={isEquals}
|
||||
onConfirm={confirmAssign}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getMemberStatus = (member: Member): I18nString => {
|
||||
if (member.status === WorkspaceMemberStatus.Pending) {
|
||||
return 'Pending';
|
||||
} else if (member.status === WorkspaceMemberStatus.UnderReview) {
|
||||
return 'Under-Review';
|
||||
} else if (member.status === WorkspaceMemberStatus.Accepted) {
|
||||
switch (member.permission) {
|
||||
case Permission.Owner:
|
||||
return 'Workspace Owner';
|
||||
case Permission.Admin:
|
||||
return 'Admin';
|
||||
case Permission.Write:
|
||||
return 'Collaborator';
|
||||
default:
|
||||
return 'Member';
|
||||
}
|
||||
} else {
|
||||
return 'Need-More-Seats';
|
||||
}
|
||||
};
|
||||
|
||||
export const MemberListFallback = ({
|
||||
memberCount,
|
||||
}: {
|
||||
memberCount?: number;
|
||||
}) => {
|
||||
// prevent page jitter
|
||||
const height = useMemo(() => {
|
||||
if (memberCount) {
|
||||
// height and margin-bottom
|
||||
return memberCount * 58 + (memberCount - 1) * 6;
|
||||
}
|
||||
return 'auto';
|
||||
}, [memberCount]);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
}}
|
||||
className={styles.membersFallback}
|
||||
>
|
||||
<Loading size={20} />
|
||||
<span>{t['com.affine.settings.member.loading']()}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
import { MenuItem, notify } from '@affine/component';
|
||||
import {
|
||||
type Member,
|
||||
WorkspacePermissionService,
|
||||
} from '@affine/core/modules/permissions';
|
||||
import { Permission, WorkspaceMemberStatus } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
export const MemberOptions = ({
|
||||
member,
|
||||
isOwner,
|
||||
isAdmin,
|
||||
openAssignModal,
|
||||
}: {
|
||||
member: Member;
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
openAssignModal: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const permission = useService(WorkspacePermissionService).permission;
|
||||
|
||||
const handleAssignOwner = useCallback(() => {
|
||||
openAssignModal();
|
||||
}, [openAssignModal]);
|
||||
|
||||
const handleRevoke = useCallback(() => {
|
||||
permission
|
||||
.revokeMember(member.id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
notify.success({
|
||||
title: t['com.affine.payment.member.team.revoke.notify.title'](),
|
||||
message: t['com.affine.payment.member.team.revoke.notify.message']({
|
||||
name: member.name || member.email || member.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
notify.error({
|
||||
title: 'Operation failed',
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}, [permission, member, t]);
|
||||
const handleApprove = useCallback(() => {
|
||||
permission
|
||||
.approveMember(member.id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
notify.success({
|
||||
title: t['com.affine.payment.member.team.approve.notify.title'](),
|
||||
message: t['com.affine.payment.member.team.approve.notify.message'](
|
||||
{
|
||||
name: member.name || member.email || member.id,
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
notify.error({
|
||||
title: 'Operation failed',
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}, [member, permission, t]);
|
||||
|
||||
const handleDecline = useCallback(() => {
|
||||
permission
|
||||
.revokeMember(member.id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
notify.success({
|
||||
title: t['com.affine.payment.member.team.decline.notify.title'](),
|
||||
message: t['com.affine.payment.member.team.decline.notify.message'](
|
||||
{
|
||||
name: member.name || member.email || member.id,
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
notify.error({
|
||||
title: 'Operation failed',
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}, [member, permission, t]);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
permission
|
||||
.revokeMember(member.id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
notify.success({
|
||||
title: t['com.affine.payment.member.team.remove.notify.title'](),
|
||||
message: t['com.affine.payment.member.team.remove.notify.message']({
|
||||
name: member.name || member.email || member.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
notify.error({
|
||||
title: 'Operation failed',
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}, [member, permission, t]);
|
||||
|
||||
const handleChangeToAdmin = useCallback(() => {
|
||||
permission
|
||||
.adjustMemberPermission(member.id, Permission.Admin)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
notify.success({
|
||||
title: t['com.affine.payment.member.team.change.notify.title'](),
|
||||
message: t[
|
||||
'com.affine.payment.member.team.change.admin.notify.message'
|
||||
]({
|
||||
name: member.name || member.email || member.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
notify.error({
|
||||
title: 'Operation failed',
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}, [member, permission, t]);
|
||||
const handleChangeToCollaborator = useCallback(() => {
|
||||
permission
|
||||
.adjustMemberPermission(member.id, Permission.Write)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
notify.success({
|
||||
title: t['com.affine.payment.member.team.change.notify.title'](),
|
||||
message: t[
|
||||
'com.affine.payment.member.team.change.collaborator.notify.message'
|
||||
]({
|
||||
name: member.name || member.email || member.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
notify.error({
|
||||
title: 'Operation failed',
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}, [member, permission, t]);
|
||||
|
||||
const operationButtonInfo = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t['com.affine.payment.member.team.approve'](),
|
||||
onClick: handleApprove,
|
||||
show: member.status === WorkspaceMemberStatus.UnderReview,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.decline'](),
|
||||
onClick: handleDecline,
|
||||
show:
|
||||
(isAdmin || isOwner) &&
|
||||
member.status === WorkspaceMemberStatus.UnderReview,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.revoke'](),
|
||||
onClick: handleRevoke,
|
||||
show:
|
||||
(isAdmin || isOwner) &&
|
||||
member.status === WorkspaceMemberStatus.Pending,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.remove'](),
|
||||
onClick: handleRemove,
|
||||
show:
|
||||
(isAdmin || isOwner) &&
|
||||
member.status === WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.change.collaborator'](),
|
||||
onClick: handleChangeToCollaborator,
|
||||
show:
|
||||
(isAdmin || isOwner) &&
|
||||
member.status === WorkspaceMemberStatus.Accepted &&
|
||||
member.permission === Permission.Admin,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.change.admin'](),
|
||||
onClick: handleChangeToAdmin,
|
||||
show:
|
||||
isOwner &&
|
||||
member.permission === Permission.Write &&
|
||||
member.status === WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.assign'](),
|
||||
onClick: handleAssignOwner,
|
||||
show: isOwner && member.status === WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
handleApprove,
|
||||
handleAssignOwner,
|
||||
handleChangeToAdmin,
|
||||
handleChangeToCollaborator,
|
||||
handleDecline,
|
||||
handleRemove,
|
||||
handleRevoke,
|
||||
isAdmin,
|
||||
isOwner,
|
||||
member,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{operationButtonInfo.map(item =>
|
||||
item.show ? (
|
||||
<MenuItem key={item.label} onSelect={item.onClick}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
) : null
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const fakeWrapper = style({
|
||||
position: 'relative',
|
||||
opacity: 0.4,
|
||||
marginTop: '24px',
|
||||
selectors: {
|
||||
'&::after': {
|
||||
content: '""',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const membersPanel = style({
|
||||
padding: '4px',
|
||||
borderRadius: '12px',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const goUpgradeWrapper = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const goUpgrade = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/emphasis'),
|
||||
cursor: 'pointer',
|
||||
marginLeft: '4px',
|
||||
display: 'inline',
|
||||
});
|
||||
|
||||
export const errorStyle = style({
|
||||
color: cssVarV2('status/error'),
|
||||
});
|
||||
|
||||
export const membersFallback = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flexStart',
|
||||
color: cssVarV2('text/secondary'),
|
||||
gap: '4px',
|
||||
padding: '8px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
|
||||
export const memberListItem = style({
|
||||
padding: '0 4px 0 16px',
|
||||
height: '58px',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
borderRadius: '8px',
|
||||
},
|
||||
'&:not(:last-of-type)': {
|
||||
marginBottom: '6px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const memberContainer = style({
|
||||
width: '250px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
marginLeft: '12px',
|
||||
marginRight: '20px',
|
||||
});
|
||||
|
||||
export const roleOrStatus = style({
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: cssVar('fontSm'),
|
||||
selectors: {
|
||||
'&.pending': {
|
||||
color: cssVarV2('text/emphasis'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const memberName = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVarV2('text/primary'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '22px',
|
||||
});
|
||||
|
||||
export const memberEmail = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '20px',
|
||||
});
|
||||
|
||||
export const confirmAssignModalContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
padding: '0',
|
||||
});
|
||||
|
||||
export const confirmInputContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
marginTop: '12px',
|
||||
marginBottom: '20px',
|
||||
});
|
||||
|
||||
export const importButton = style({
|
||||
padding: '4px 8px',
|
||||
});
|
||||
+6
-164
@@ -1,16 +1,12 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const profileWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
marginTop: '12px',
|
||||
});
|
||||
export const profileHandlerWrapper = style({
|
||||
flexGrow: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginLeft: '20px',
|
||||
});
|
||||
|
||||
export const labelWrapper = style({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
@@ -19,148 +15,10 @@ export const labelWrapper = style({
|
||||
gap: '10px',
|
||||
flexWrap: 'wrap',
|
||||
});
|
||||
export const avatarWrapper = style({
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
flexShrink: '0',
|
||||
selectors: {
|
||||
'&.disable': {
|
||||
cursor: 'default',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
|
||||
display: 'flex',
|
||||
});
|
||||
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
|
||||
display: 'flex',
|
||||
});
|
||||
globalStyle(`${avatarWrapper} .camera-icon-wrapper`, {
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
position: 'absolute',
|
||||
display: 'none',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(60, 61, 63, 0.5)',
|
||||
zIndex: '1',
|
||||
color: cssVar('white'),
|
||||
fontSize: '24px',
|
||||
});
|
||||
export const urlButton = style({
|
||||
width: 'calc(100% - 64px - 15px)',
|
||||
justifyContent: 'left',
|
||||
textAlign: 'left',
|
||||
});
|
||||
globalStyle(`${urlButton} span`, {
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: cssVar('placeholderColor'),
|
||||
fontWeight: '500',
|
||||
});
|
||||
export const fakeWrapper = style({
|
||||
position: 'relative',
|
||||
opacity: 0.4,
|
||||
marginTop: '24px',
|
||||
selectors: {
|
||||
'&::after': {
|
||||
content: '""',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const membersFallback = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flexStart',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
gap: '4px',
|
||||
padding: '8px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
export const membersPanel = style({
|
||||
padding: '4px',
|
||||
borderRadius: '12px',
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const memberList = style({});
|
||||
export const memberListItem = style({
|
||||
padding: '0 4px 0 16px',
|
||||
height: '58px',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: cssVar('hoverColor'),
|
||||
borderRadius: '8px',
|
||||
},
|
||||
'&:not(:last-of-type)': {
|
||||
marginBottom: '6px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const memberContainer = style({
|
||||
width: '250px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
marginLeft: '12px',
|
||||
marginRight: '20px',
|
||||
});
|
||||
export const roleOrStatus = style({
|
||||
// width: '20%',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: cssVar('fontSm'),
|
||||
selectors: {
|
||||
'&.pending': {
|
||||
color: cssVar('primaryColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const memberName = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVar('textPrimaryColor'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '22px',
|
||||
});
|
||||
export const memberEmail = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVar('textSecondaryColor'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '20px',
|
||||
});
|
||||
export const iconButton = style({});
|
||||
globalStyle(`${memberListItem}:hover ${iconButton}`, {
|
||||
opacity: 1,
|
||||
pointerEvents: 'all',
|
||||
});
|
||||
|
||||
export const label = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVar('textSecondaryColor'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
marginBottom: '5px',
|
||||
});
|
||||
export const workspaceLabel = style({
|
||||
@@ -173,23 +31,7 @@ export const workspaceLabel = style({
|
||||
padding: '2px 10px',
|
||||
border: `1px solid ${cssVar('white30')}`,
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVar('textPrimaryColor'),
|
||||
color: cssVarV2('text/primary'),
|
||||
lineHeight: '20px',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
export const goUpgrade = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVar('textEmphasisColor'),
|
||||
cursor: 'pointer',
|
||||
marginLeft: '4px',
|
||||
display: 'inline',
|
||||
});
|
||||
export const goUpgradeWrapper = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const arrowRight = style({
|
||||
fontSize: '16px',
|
||||
color: cssVar('textEmphasisColor'),
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ const products = {
|
||||
pro: 'pro_yearly',
|
||||
'monthly-pro': 'pro_monthly',
|
||||
believer: 'pro_lifetime',
|
||||
team: 'team_yearly',
|
||||
'monthly-team': 'team_monthly',
|
||||
'oneyear-ai': 'ai_yearly_onetime',
|
||||
'oneyear-pro': 'pro_yearly_onetime',
|
||||
'onemonth-pro': 'pro_monthly_onetime',
|
||||
@@ -42,6 +44,7 @@ const products = {
|
||||
const allowedPlan = {
|
||||
ai: SubscriptionPlan.AI,
|
||||
pro: SubscriptionPlan.Pro,
|
||||
team: SubscriptionPlan.Team,
|
||||
};
|
||||
const allowedRecurring = {
|
||||
monthly: SubscriptionRecurring.Monthly,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { AuthPageContainer } from '@affine/component/auth-components';
|
||||
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
/**
|
||||
* /upgrade-success/team page
|
||||
*
|
||||
* only on web
|
||||
*/
|
||||
export const Component = () => {
|
||||
const t = useI18n();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const { jumpToIndex, jumpToOpenInApp } = useNavigateHelper();
|
||||
const openAffine = useCallback(() => {
|
||||
if (params.get('schema')) {
|
||||
jumpToOpenInApp('bring-to-front');
|
||||
} else {
|
||||
jumpToIndex();
|
||||
}
|
||||
}, [jumpToIndex, jumpToOpenInApp, params]);
|
||||
|
||||
const subtitle = (
|
||||
<div className={styles.leftContentText}>
|
||||
<div>{t['com.affine.payment.upgrade-success-page.team.text-1']()}</div>
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey={'com.affine.payment.upgrade-success-page.team.text-2'}
|
||||
components={{
|
||||
1: (
|
||||
<a
|
||||
href="mailto:support@toeverything.info"
|
||||
className={styles.mail}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthPageContainer
|
||||
title={t['com.affine.payment.upgrade-success-page.title']()}
|
||||
subtitle={subtitle}
|
||||
>
|
||||
<Button variant="primary" size="extraLarge" onClick={openAffine}>
|
||||
{t['com.affine.other-page.nav.open-affine']()}
|
||||
</Button>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const leftContentText = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.6',
|
||||
maxWidth: '548px',
|
||||
});
|
||||
export const mail = style({
|
||||
color: cssVar('linkColor'),
|
||||
textDecoration: 'none',
|
||||
':visited': {
|
||||
color: cssVar('linkColor'),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
Modal,
|
||||
notify,
|
||||
} from '@affine/component';
|
||||
import { AuthPageContainer } from '@affine/component/auth-components';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
||||
import { PureWorkspaceCard } from '@affine/core/components/workspace-selector/workspace-card';
|
||||
import { buildShowcaseWorkspace } from '@affine/core/utils/first-app-data';
|
||||
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
|
||||
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
||||
import { type I18nString, Trans, useI18n } from '@affine/i18n';
|
||||
import { DoneIcon, NewPageIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
useLiveData,
|
||||
useService,
|
||||
type WorkspaceMetadata,
|
||||
WorkspacesService,
|
||||
} from '@toeverything/infra';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Upgrade } from '../../dialogs/setting/general-setting/plans/plan-card';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const benefitList: I18nString[] = [
|
||||
'com.affine.upgrade-to-team-page.benefit.g1',
|
||||
'com.affine.upgrade-to-team-page.benefit.g2',
|
||||
'com.affine.upgrade-to-team-page.benefit.g3',
|
||||
'com.affine.upgrade-to-team-page.benefit.g4',
|
||||
];
|
||||
|
||||
export const Component = () => {
|
||||
const t = useI18n();
|
||||
const workspacesList = useService(WorkspacesService).list;
|
||||
const workspaces = useLiveData(workspacesList.workspaces$);
|
||||
const [openUpgrade, setOpenUpgrade] = useState(false);
|
||||
const [openCreate, setOpenCreate] = useState(false);
|
||||
|
||||
const [selectedWorkspace, setSelectedWorkspace] =
|
||||
useState<WorkspaceMetadata | null>(null);
|
||||
|
||||
const information = useWorkspaceInfo(selectedWorkspace || undefined);
|
||||
|
||||
const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
|
||||
|
||||
const menuTriggerText = useMemo(() => {
|
||||
if (selectedWorkspace) {
|
||||
return name;
|
||||
}
|
||||
return t[
|
||||
'com.affine.upgrade-to-team-page.workspace-selector.placeholder'
|
||||
]();
|
||||
}, [name, selectedWorkspace, t]);
|
||||
|
||||
const onUpgradeButtonClick = useCallback(() => {
|
||||
setOpenUpgrade(true);
|
||||
}, []);
|
||||
|
||||
const onClickCreateWorkspace = useCallback(() => {
|
||||
setOpenCreate(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthPageContainer title={t['com.affine.upgrade-to-team-page.title']()}>
|
||||
<div className={styles.root}>
|
||||
<Menu
|
||||
items={
|
||||
<WorkspaceSelector
|
||||
metas={workspaces}
|
||||
onSelect={setSelectedWorkspace}
|
||||
onClickCreateWorkspace={onClickCreateWorkspace}
|
||||
/>
|
||||
}
|
||||
contentOptions={{
|
||||
style: {
|
||||
width: '410px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuTrigger className={styles.menuTrigger} tooltip={menuTriggerText}>
|
||||
{menuTriggerText}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
<div className={styles.upgradeButton}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="extraLarge"
|
||||
onClick={onUpgradeButtonClick}
|
||||
disabled={!selectedWorkspace}
|
||||
>
|
||||
{t['com.affine.upgrade-to-team-page.upgrade-button']()}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.contentContainer}>
|
||||
<div>{t['com.affine.upgrade-to-team-page.benefit.title']()}</div>
|
||||
<ul>
|
||||
{benefitList.map((benefit, index) => (
|
||||
<li key={`${benefit}:${index}`} className={styles.liStyle}>
|
||||
<DoneIcon className={styles.doneIcon} />
|
||||
{t.t(benefit)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div>
|
||||
{t['com.affine.upgrade-to-team-page.benefit.description']()}
|
||||
</div>
|
||||
<UpgradeDialog
|
||||
open={openUpgrade}
|
||||
onOpenChange={setOpenUpgrade}
|
||||
workspaceName={name}
|
||||
workspaceId={selectedWorkspace?.id ?? ''}
|
||||
/>
|
||||
<CreateWorkspaceDialog
|
||||
open={openCreate}
|
||||
onOpenChange={setOpenCreate}
|
||||
onSelect={setSelectedWorkspace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const UpgradeDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceName,
|
||||
workspaceId,
|
||||
}: {
|
||||
open: boolean;
|
||||
workspaceName: string;
|
||||
workspaceId: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const onClose = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
return (
|
||||
<Modal width={480} open={open} onOpenChange={onOpenChange}>
|
||||
<div className={styles.dialogTitle}>
|
||||
{t['com.affine.upgrade-to-team-page.upgrade-confirm.title']()}
|
||||
</div>
|
||||
<div className={styles.dialogMessage}>
|
||||
<Trans
|
||||
i18nKey="com.affine.upgrade-to-team-page.upgrade-confirm.description"
|
||||
components={{
|
||||
1: <span style={{ fontWeight: 600 }} />,
|
||||
}}
|
||||
values={{
|
||||
workspaceName,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.dialogFooter}>
|
||||
<Button onClick={onClose}>{t['Cancel']()}</Button>
|
||||
<Upgrade
|
||||
className={styles.upgradeButtonInDialog}
|
||||
recurring={SubscriptionRecurring.Monthly}
|
||||
plan={SubscriptionPlan.Team}
|
||||
onCheckoutSuccess={onClose}
|
||||
checkoutInput={{
|
||||
args: {
|
||||
workspaceId,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t['com.affine.payment.upgrade']()}
|
||||
</Upgrade>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
const WorkspaceSelector = ({
|
||||
metas,
|
||||
onSelect,
|
||||
onClickCreateWorkspace,
|
||||
}: {
|
||||
metas: WorkspaceMetadata[];
|
||||
onClickCreateWorkspace: () => void;
|
||||
onSelect: (meta: WorkspaceMetadata) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
const cloudWorkspaces = useMemo(
|
||||
() =>
|
||||
metas.filter(
|
||||
({ flavour }) => flavour === 'affine-cloud'
|
||||
) as WorkspaceMetadata[],
|
||||
[metas]
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(workspace: WorkspaceMetadata) => {
|
||||
onSelect(workspace);
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{cloudWorkspaces.length > 0 &&
|
||||
cloudWorkspaces.map(workspace => (
|
||||
<WorkspaceItem
|
||||
key={workspace.id}
|
||||
meta={workspace}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
{cloudWorkspaces.length > 0 && <Divider size="thinner" />}
|
||||
<MenuItem
|
||||
className={styles.createWorkspaceItem}
|
||||
prefix={<NewPageIcon className={styles.itemIcon} fontSize={28} />}
|
||||
onClick={onClickCreateWorkspace}
|
||||
>
|
||||
<div className={styles.itemContent}>
|
||||
{t[
|
||||
'com.affine.upgrade-to-team-page.workspace-selector.create-workspace'
|
||||
]()}
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkspaceItem = ({
|
||||
meta,
|
||||
onSelect,
|
||||
}: {
|
||||
meta: WorkspaceMetadata;
|
||||
onSelect: (meta: WorkspaceMetadata) => void;
|
||||
}) => {
|
||||
const information = useWorkspaceInfo(meta);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
onSelect(meta);
|
||||
}, [onSelect, meta]);
|
||||
if (information?.isTeam || !information?.isOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem className={styles.plainMenuItem} onClick={onClick}>
|
||||
<PureWorkspaceCard
|
||||
className={styles.workspaceItem}
|
||||
workspaceMetadata={meta}
|
||||
avatarSize={28}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateWorkspaceDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean;
|
||||
onSelect: (workspace: WorkspaceMetadata) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const onClose = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
const [name, setName] = useState('');
|
||||
const workspacesService = useService(WorkspacesService);
|
||||
|
||||
const onCreate = useCallback(async () => {
|
||||
const newWorkspace = await buildShowcaseWorkspace(
|
||||
workspacesService,
|
||||
'affine-cloud',
|
||||
name
|
||||
);
|
||||
notify.success({
|
||||
title: 'Workspace Created',
|
||||
});
|
||||
onSelect(newWorkspace.meta);
|
||||
onOpenChange(false);
|
||||
}, [name, onOpenChange, onSelect, workspacesService]);
|
||||
|
||||
const onBeforeCheckout = useAsyncCallback(async () => {
|
||||
await onCreate();
|
||||
}, [onCreate]);
|
||||
|
||||
return (
|
||||
<Modal width={480} open={open} onOpenChange={onOpenChange}>
|
||||
<div className={styles.dialogTitle}>
|
||||
{t[
|
||||
'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.title'
|
||||
]()}
|
||||
</div>
|
||||
|
||||
<div className={styles.createConfirmContent}>
|
||||
<div>
|
||||
{t[
|
||||
'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.description'
|
||||
]()}
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t[
|
||||
'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.placeholder'
|
||||
]()}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.dialogFooter}>
|
||||
<Button onClick={onClose}>{t['Cancel']()}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={styles.upgradeButtonInDialog}
|
||||
onClick={onBeforeCheckout}
|
||||
>
|
||||
{t[
|
||||
'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.confirm'
|
||||
]()}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'@media': {
|
||||
'screen and (max-width: 1024px)': {
|
||||
margin: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const menuTrigger = style({
|
||||
width: '410px',
|
||||
height: '40px',
|
||||
fontSize: cssVar('fontBase'),
|
||||
fontWeight: 500,
|
||||
color: cssVarV2('text/placeholder'),
|
||||
});
|
||||
|
||||
export const upgradeButton = style({
|
||||
marginTop: '16px',
|
||||
marginBottom: '28px',
|
||||
});
|
||||
|
||||
export const contentContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontSize: cssVar('fontBase'),
|
||||
lineHeight: '24px',
|
||||
});
|
||||
|
||||
export const liStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
});
|
||||
|
||||
export const doneIcon = style({
|
||||
color: cssVarV2('icon/activated'),
|
||||
fontSize: '20px',
|
||||
});
|
||||
|
||||
export const workspaceItem = style({
|
||||
padding: '8px 12px',
|
||||
height: '44px',
|
||||
});
|
||||
|
||||
globalStyle(`${workspaceItem} > div`, {
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const createWorkspaceItem = style({
|
||||
padding: '8px 12px',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const itemContent = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2('text/emphasis'),
|
||||
});
|
||||
|
||||
export const itemIcon = style({
|
||||
borderRadius: '4px',
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const plainMenuItem = style({
|
||||
padding: 0,
|
||||
':hover': {
|
||||
backgroundColor: 'unset',
|
||||
},
|
||||
});
|
||||
|
||||
export const createConfirmContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
marginBottom: '40px',
|
||||
});
|
||||
|
||||
export const dialogTitle = style({
|
||||
fontSize: cssVar('fontH6'),
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const dialogMessage = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
lineHeight: '24px',
|
||||
marginTop: '12px',
|
||||
marginBottom: '40px',
|
||||
});
|
||||
|
||||
export const dialogFooter = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '20px',
|
||||
});
|
||||
|
||||
export const upgradeButtonInDialog = style({
|
||||
width: 'unset',
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-blo
|
||||
import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-state';
|
||||
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
|
||||
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
|
||||
import { SharePageNotFoundError } from '@affine/core/components/share-page-not-found-error';
|
||||
import { AppContainer } from '@affine/core/desktop/components/app-container';
|
||||
import {
|
||||
AuthService,
|
||||
@@ -107,7 +106,6 @@ export const SharePage = ({
|
||||
}, [shareReaderService, docId, workspaceId]);
|
||||
|
||||
let element: ReactNode = null;
|
||||
|
||||
if (isLoading) {
|
||||
element = null;
|
||||
} else if (data) {
|
||||
@@ -125,7 +123,8 @@ export const SharePage = ({
|
||||
/>
|
||||
);
|
||||
} else if (error) {
|
||||
element = <SharePageNotFoundError />;
|
||||
// TODO(@JimmFly): handle error
|
||||
element = <PageNotFound />;
|
||||
} else {
|
||||
element = <PageNotFound noPermission />;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,10 @@ export const topLevelRoutes = [
|
||||
path: '/upgrade-success',
|
||||
lazy: () => import('./pages/upgrade-success'),
|
||||
},
|
||||
{
|
||||
path: '/upgrade-success/team',
|
||||
lazy: () => import('./pages/upgrade-success/team'),
|
||||
},
|
||||
{
|
||||
path: '/ai-upgrade-success',
|
||||
lazy: () => import('./pages/ai-upgrade-success'),
|
||||
@@ -80,6 +84,10 @@ export const topLevelRoutes = [
|
||||
path: '/subscribe',
|
||||
lazy: () => import('./pages/subscribe'),
|
||||
},
|
||||
{
|
||||
path: '/upgrade-to-team',
|
||||
lazy: () => import('./pages/upgrade-to-team'),
|
||||
},
|
||||
{
|
||||
path: '/try-cloud',
|
||||
loader: () => {
|
||||
|
||||
@@ -27,6 +27,9 @@ export class SubscriptionPrices extends Entity {
|
||||
aiPrice$ = this.prices$.map(prices =>
|
||||
prices ? prices.find(price => price.plan === 'AI') : null
|
||||
);
|
||||
teamPrice$ = this.prices$.map(prices =>
|
||||
prices ? prices.find(price => price.plan === 'Team') : null
|
||||
);
|
||||
|
||||
readableLifetimePrice$ = this.proPrice$.map(price =>
|
||||
price?.lifetimeAmount
|
||||
|
||||
@@ -42,6 +42,11 @@ export class Subscription extends Entity {
|
||||
? subscriptions.find(sub => sub.plan === SubscriptionPlan.AI)
|
||||
: null
|
||||
);
|
||||
team$ = this.subscription$.map(subscriptions =>
|
||||
subscriptions
|
||||
? subscriptions.find(sub => sub.plan === SubscriptionPlan.Team)
|
||||
: null
|
||||
);
|
||||
isBeliever$ = this.pro$.map(
|
||||
sub => sub?.recurring === SubscriptionRecurring.Lifetime
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ export type SettingTab =
|
||||
| 'experimental-features'
|
||||
| 'editor'
|
||||
| 'account'
|
||||
| `workspace:${'preference' | 'properties'}`;
|
||||
| `workspace:${'preference' | 'properties' | 'billing' | 'license'}`;
|
||||
|
||||
export type GLOBAL_DIALOG_SCHEMA = {
|
||||
'create-workspace': (props: { serverId?: string; forcedCloud?: boolean }) => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type {
|
||||
Permission,
|
||||
WorkspaceInviteLinkExpireTime,
|
||||
} from '@affine/graphql';
|
||||
import type { WorkspaceService } from '@toeverything/infra';
|
||||
import {
|
||||
backoffRetry,
|
||||
@@ -7,11 +11,10 @@ import {
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
mapInto,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { exhaustMap } from 'rxjs';
|
||||
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { WorkspacePermissionStore } from '../stores/permission';
|
||||
@@ -20,6 +23,8 @@ const logger = new DebugLogger('affine:workspace-permission');
|
||||
|
||||
export class WorkspacePermission extends Entity {
|
||||
isOwner$ = new LiveData<boolean | null>(null);
|
||||
isAdmin$ = new LiveData<boolean | null>(null);
|
||||
isTeam$ = new LiveData<boolean | null>(null);
|
||||
isLoading$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
|
||||
@@ -34,12 +39,18 @@ export class WorkspacePermission extends Entity {
|
||||
exhaustMap(() => {
|
||||
return fromPromise(async signal => {
|
||||
if (this.workspaceService.workspace.flavour !== 'local') {
|
||||
return await this.store.fetchIsOwner(
|
||||
const info = await this.store.fetchWorkspaceInfo(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
);
|
||||
|
||||
return {
|
||||
isOwner: info.isOwner,
|
||||
isAdmin: info.isAdmin,
|
||||
isTeam: info.workspace.team,
|
||||
};
|
||||
} else {
|
||||
return true;
|
||||
return { isOwner: true, isAdmin: false, isTeam: false };
|
||||
}
|
||||
}).pipe(
|
||||
backoffRetry({
|
||||
@@ -49,7 +60,12 @@ export class WorkspacePermission extends Entity {
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
mapInto(this.isOwner$),
|
||||
mergeMap(({ isOwner, isAdmin, isTeam }) => {
|
||||
this.isAdmin$.next(isAdmin);
|
||||
this.isOwner$.next(isOwner);
|
||||
this.isTeam$.next(isTeam);
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$, error => {
|
||||
logger.error('Failed to fetch isOwner', error);
|
||||
}),
|
||||
@@ -58,4 +74,89 @@ export class WorkspacePermission extends Entity {
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
async inviteMember(
|
||||
email: string,
|
||||
permission: Permission,
|
||||
sendInviteMail?: boolean
|
||||
) {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to invite members');
|
||||
}
|
||||
return await this.store.inviteMember(
|
||||
this.workspaceService.workspace.id,
|
||||
email,
|
||||
permission,
|
||||
sendInviteMail
|
||||
);
|
||||
}
|
||||
|
||||
async inviteMembers(emails: string[], sendInviteMail?: boolean) {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to invite members');
|
||||
}
|
||||
return await this.store.inviteBatch(
|
||||
this.workspaceService.workspace.id,
|
||||
emails,
|
||||
sendInviteMail
|
||||
);
|
||||
}
|
||||
|
||||
async generateInviteLink(expireTime: WorkspaceInviteLinkExpireTime) {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to generate invite link');
|
||||
}
|
||||
return await this.store.generateInviteLink(
|
||||
this.workspaceService.workspace.id,
|
||||
expireTime
|
||||
);
|
||||
}
|
||||
|
||||
async revokeInviteLink() {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to revoke invite link');
|
||||
}
|
||||
return await this.store.revokeInviteLink(
|
||||
this.workspaceService.workspace.id
|
||||
);
|
||||
}
|
||||
|
||||
async revokeMember(userId: string) {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to revoke members');
|
||||
}
|
||||
return await this.store.revokeMemberPermission(
|
||||
this.workspaceService.workspace.id,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
async acceptInvite(inviteId: string, sendAcceptMail?: boolean) {
|
||||
return await this.store.acceptInvite(
|
||||
this.workspaceService.workspace.id,
|
||||
inviteId,
|
||||
sendAcceptMail
|
||||
);
|
||||
}
|
||||
|
||||
async approveMember(userId: string) {
|
||||
if (!this.isAdmin$.value && !this.isOwner$.value) {
|
||||
throw new Error('User has no permission to accept invite');
|
||||
}
|
||||
return await this.store.approveMember(
|
||||
this.workspaceService.workspace.id,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
async adjustMemberPermission(userId: string, permission: Permission) {
|
||||
if (!this.isAdmin$.value) {
|
||||
throw new Error('User has no permission to adjust member permissions');
|
||||
}
|
||||
return await this.store.adjustMemberPermission(
|
||||
this.workspaceService.workspace.id,
|
||||
userId,
|
||||
permission
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import { getIsOwnerQuery, leaveWorkspaceMutation } from '@affine/graphql';
|
||||
import {
|
||||
acceptInviteByInviteIdMutation,
|
||||
approveWorkspaceTeamMemberMutation,
|
||||
getWorkspaceInfoQuery,
|
||||
grantWorkspaceTeamMemberMutation,
|
||||
inviteByEmailMutation,
|
||||
inviteByEmailsMutation,
|
||||
inviteLinkMutation,
|
||||
leaveWorkspaceMutation,
|
||||
type Permission,
|
||||
revokeInviteLinkMutation,
|
||||
revokeMemberPermissionMutation,
|
||||
type WorkspaceInviteLinkExpireTime,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
export class WorkspacePermissionStore extends Store {
|
||||
@@ -7,19 +20,161 @@ export class WorkspacePermissionStore extends Store {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchIsOwner(workspaceId: string, signal?: AbortSignal) {
|
||||
async fetchWorkspaceInfo(workspaceId: string, signal?: AbortSignal) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const isOwner = await this.workspaceServerService.server.gql({
|
||||
query: getIsOwnerQuery,
|
||||
const info = await this.workspaceServerService.server.gql({
|
||||
query: getWorkspaceInfoQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
|
||||
return isOwner.isOwner;
|
||||
return info;
|
||||
}
|
||||
|
||||
async inviteMember(
|
||||
workspaceId: string,
|
||||
email: string,
|
||||
permission: Permission,
|
||||
sendInviteMail = false
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const invite = await this.workspaceServerService.server.gql({
|
||||
query: inviteByEmailMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
email,
|
||||
permission,
|
||||
sendInviteMail,
|
||||
},
|
||||
});
|
||||
return invite.invite;
|
||||
}
|
||||
|
||||
async inviteBatch(
|
||||
workspaceId: string,
|
||||
emails: string[],
|
||||
sendInviteMail = false
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const inviteBatch = await this.workspaceServerService.server.gql({
|
||||
query: inviteByEmailsMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
emails,
|
||||
sendInviteMail,
|
||||
},
|
||||
});
|
||||
return inviteBatch.inviteBatch;
|
||||
}
|
||||
|
||||
async generateInviteLink(
|
||||
workspaceId: string,
|
||||
expireTime: WorkspaceInviteLinkExpireTime
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const inviteLink = await this.workspaceServerService.server.gql({
|
||||
query: inviteLinkMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
expireTime,
|
||||
},
|
||||
});
|
||||
return inviteLink.inviteLink;
|
||||
}
|
||||
|
||||
async revokeInviteLink(workspaceId: string, signal?: AbortSignal) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const revoke = await this.workspaceServerService.server.gql({
|
||||
query: revokeInviteLinkMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
return revoke.revokeInviteLink;
|
||||
}
|
||||
|
||||
async revokeMemberPermission(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const revoke = await this.workspaceServerService.server.gql({
|
||||
query: revokeMemberPermissionMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
return revoke.revoke;
|
||||
}
|
||||
|
||||
async acceptInvite(
|
||||
workspaceId: string,
|
||||
inviteId: string,
|
||||
sendAcceptMail = false
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const accept = await this.workspaceServerService.server.gql({
|
||||
query: acceptInviteByInviteIdMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
inviteId,
|
||||
sendAcceptMail,
|
||||
},
|
||||
});
|
||||
return accept.acceptInviteById;
|
||||
}
|
||||
|
||||
async approveMember(workspaceId: string, userId: string) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const member = await this.workspaceServerService.server.gql({
|
||||
query: approveWorkspaceTeamMemberMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return member.approveMember;
|
||||
}
|
||||
|
||||
async adjustMemberPermission(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
permission: Permission
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const member = await this.workspaceServerService.server.gql({
|
||||
query: grantWorkspaceTeamMemberMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
userId,
|
||||
permission,
|
||||
},
|
||||
});
|
||||
return member.grantMember;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,6 +81,14 @@ export class WorkspaceShareSetting extends Entity {
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async setEnableAi(enableAi: EnableAi) {
|
||||
await this.store.updateWorkspaceEnableAi(
|
||||
this.workspaceService.workspace.id,
|
||||
enableAi
|
||||
);
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DebugLogger } from '@affine/debug';
|
||||
import {
|
||||
createWorkspaceMutation,
|
||||
deleteWorkspaceMutation,
|
||||
getIsOwnerQuery,
|
||||
getWorkspaceInfoQuery,
|
||||
getWorkspacesQuery,
|
||||
} from '@affine/graphql';
|
||||
import { DocCollection } from '@blocksuite/affine/store';
|
||||
@@ -212,9 +212,11 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
error$ = new LiveData<any>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
workspaces$ = new LiveData<WorkspaceMetadata[]>([]);
|
||||
|
||||
async getWorkspaceProfile(
|
||||
id: string,
|
||||
signal?: AbortSignal
|
||||
@@ -228,11 +230,13 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
const localData = await docStorage.doc.get(id);
|
||||
const cloudData = await cloudStorage.pull(id);
|
||||
|
||||
const isOwner = await this.getIsOwner(id, signal);
|
||||
const info = await this.getWorkspaceInfo(id, signal);
|
||||
|
||||
if (!cloudData && !localData) {
|
||||
return {
|
||||
isOwner,
|
||||
isOwner: info.isOwner,
|
||||
isAdmin: info.isAdmin,
|
||||
isTeam: info.workspace.team,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,7 +251,9 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
return {
|
||||
name: bs.meta.name,
|
||||
avatar: bs.meta.avatar,
|
||||
isOwner,
|
||||
isOwner: info.isOwner,
|
||||
isAdmin: info.isAdmin,
|
||||
isTeam: info.workspace.team,
|
||||
};
|
||||
}
|
||||
async getWorkspaceBlob(id: string, blob: string): Promise<Blob | null> {
|
||||
@@ -300,16 +306,14 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
workspace.scope.get(WorkspaceServerService).bindServer(this.server);
|
||||
}
|
||||
|
||||
private async getIsOwner(workspaceId: string, signal?: AbortSignal) {
|
||||
return (
|
||||
await this.graphqlService.gql({
|
||||
query: getIsOwnerQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
context: { signal },
|
||||
})
|
||||
).isOwner;
|
||||
private async getWorkspaceInfo(workspaceId: string, signal?: AbortSignal) {
|
||||
return await this.graphqlService.gql({
|
||||
query: getWorkspaceInfoQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
}
|
||||
|
||||
private waitForLoaded() {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
query getIsAdmin($workspaceId: String!) {
|
||||
isAdmin(workspaceId: $workspaceId)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
query getWorkspaceInfo($workspaceId: String!) {
|
||||
isAdmin(workspaceId: $workspaceId)
|
||||
isOwner(workspaceId: $workspaceId)
|
||||
workspace(id: $workspaceId) {
|
||||
team
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,17 @@ query getInviteInfo($inviteId: String!) {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getIsAdminQuery = {
|
||||
id: 'getIsAdminQuery' as const,
|
||||
operationName: 'getIsAdmin',
|
||||
definitionName: 'isAdmin',
|
||||
containsFile: false,
|
||||
query: `
|
||||
query getIsAdmin($workspaceId: String!) {
|
||||
isAdmin(workspaceId: $workspaceId)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getIsOwnerQuery = {
|
||||
id: 'getIsOwnerQuery' as const,
|
||||
operationName: 'getIsOwner',
|
||||
@@ -611,6 +622,21 @@ query getWorkspaceFeatures($workspaceId: String!) {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getWorkspaceInfoQuery = {
|
||||
id: 'getWorkspaceInfoQuery' as const,
|
||||
operationName: 'getWorkspaceInfo',
|
||||
definitionName: 'isAdmin,isOwner,workspace',
|
||||
containsFile: false,
|
||||
query: `
|
||||
query getWorkspaceInfo($workspaceId: String!) {
|
||||
isAdmin(workspaceId: $workspaceId)
|
||||
isOwner(workspaceId: $workspaceId)
|
||||
workspace(id: $workspaceId) {
|
||||
team
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getWorkspacePageMetaByIdQuery = {
|
||||
id: 'getWorkspacePageMetaByIdQuery' as const,
|
||||
operationName: 'getWorkspacePageMetaById',
|
||||
@@ -1307,6 +1333,25 @@ mutation inviteByEmail($workspaceId: String!, $email: String!, $permission: Perm
|
||||
}`,
|
||||
};
|
||||
|
||||
export const inviteByEmailsMutation = {
|
||||
id: 'inviteByEmailsMutation' as const,
|
||||
operationName: 'inviteByEmails',
|
||||
definitionName: 'inviteBatch',
|
||||
containsFile: false,
|
||||
query: `
|
||||
mutation inviteByEmails($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) {
|
||||
inviteBatch(
|
||||
workspaceId: $workspaceId
|
||||
emails: $emails
|
||||
sendInviteMail: $sendInviteMail
|
||||
) {
|
||||
email
|
||||
inviteId
|
||||
sentSuccess
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const acceptInviteByInviteIdMutation = {
|
||||
id: 'acceptInviteByInviteIdMutation' as const,
|
||||
operationName: 'acceptInviteByInviteId',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
mutation inviteByEmails(
|
||||
$workspaceId: String!
|
||||
$emails: [String!]!
|
||||
$sendInviteMail: Boolean
|
||||
) {
|
||||
inviteBatch(
|
||||
workspaceId: $workspaceId
|
||||
emails: $emails
|
||||
sendInviteMail: $sendInviteMail
|
||||
) {
|
||||
email
|
||||
inviteId
|
||||
sentSuccess
|
||||
}
|
||||
}
|
||||
@@ -891,6 +891,8 @@ export interface Query {
|
||||
error: ErrorDataUnion;
|
||||
/** send workspace invitation */
|
||||
getInviteInfo: InvitationType;
|
||||
/** Get is admin of workspace */
|
||||
isAdmin: Scalars['Boolean']['output'];
|
||||
/** Get is owner of workspace */
|
||||
isOwner: Scalars['Boolean']['output'];
|
||||
/**
|
||||
@@ -936,6 +938,10 @@ export interface QueryGetInviteInfoArgs {
|
||||
inviteId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface QueryIsAdminArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface QueryIsOwnerArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
@@ -1738,6 +1744,12 @@ export type GetInviteInfoQuery = {
|
||||
};
|
||||
};
|
||||
|
||||
export type GetIsAdminQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type GetIsAdminQuery = { __typename?: 'Query'; isAdmin: boolean };
|
||||
|
||||
export type GetIsOwnerQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -1922,6 +1934,17 @@ export type GetWorkspaceFeaturesQuery = {
|
||||
workspace: { __typename?: 'WorkspaceType'; features: Array<FeatureType> };
|
||||
};
|
||||
|
||||
export type GetWorkspaceInfoQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type GetWorkspaceInfoQuery = {
|
||||
__typename?: 'Query';
|
||||
isAdmin: boolean;
|
||||
isOwner: boolean;
|
||||
workspace: { __typename?: 'WorkspaceType'; team: boolean };
|
||||
};
|
||||
|
||||
export type GetWorkspacePageMetaByIdQueryVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
pageId: Scalars['String']['input'];
|
||||
@@ -2542,6 +2565,22 @@ export type InviteByEmailMutationVariables = Exact<{
|
||||
|
||||
export type InviteByEmailMutation = { __typename?: 'Mutation'; invite: string };
|
||||
|
||||
export type InviteByEmailsMutationVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
emails: Array<Scalars['String']['input']> | Scalars['String']['input'];
|
||||
sendInviteMail?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
}>;
|
||||
|
||||
export type InviteByEmailsMutation = {
|
||||
__typename?: 'Mutation';
|
||||
inviteBatch: Array<{
|
||||
__typename?: 'InviteResult';
|
||||
email: string;
|
||||
inviteId: string | null;
|
||||
sentSuccess: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type AcceptInviteByInviteIdMutationVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
inviteId: Scalars['String']['input'];
|
||||
@@ -2684,6 +2723,11 @@ export type Queries =
|
||||
variables: GetInviteInfoQueryVariables;
|
||||
response: GetInviteInfoQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getIsAdminQuery';
|
||||
variables: GetIsAdminQueryVariables;
|
||||
response: GetIsAdminQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getIsOwnerQuery';
|
||||
variables: GetIsOwnerQueryVariables;
|
||||
@@ -2744,6 +2788,11 @@ export type Queries =
|
||||
variables: GetWorkspaceFeaturesQueryVariables;
|
||||
response: GetWorkspaceFeaturesQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getWorkspaceInfoQuery';
|
||||
variables: GetWorkspaceInfoQueryVariables;
|
||||
response: GetWorkspaceInfoQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getWorkspacePageMetaByIdQuery';
|
||||
variables: GetWorkspacePageMetaByIdQueryVariables;
|
||||
@@ -3061,6 +3110,11 @@ export type Mutations =
|
||||
variables: InviteByEmailMutationVariables;
|
||||
response: InviteByEmailMutation;
|
||||
}
|
||||
| {
|
||||
name: 'inviteByEmailsMutation';
|
||||
variables: InviteByEmailsMutationVariables;
|
||||
response: InviteByEmailsMutation;
|
||||
}
|
||||
| {
|
||||
name: 'acceptInviteByInviteIdMutation';
|
||||
variables: AcceptInviteByInviteIdMutationVariables;
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"ar": 74,
|
||||
"ar": 70,
|
||||
"ca": 5,
|
||||
"da": 6,
|
||||
"de": 27,
|
||||
"da": 5,
|
||||
"de": 26,
|
||||
"el-GR": 0,
|
||||
"en": 100,
|
||||
"es-AR": 13,
|
||||
"es-CL": 15,
|
||||
"es-CL": 14,
|
||||
"es": 13,
|
||||
"fr": 66,
|
||||
"fr": 62,
|
||||
"hi": 2,
|
||||
"it-IT": 1,
|
||||
"it": 1,
|
||||
"ja": 98,
|
||||
"ko": 78,
|
||||
"ja": 93,
|
||||
"ko": 73,
|
||||
"pl": 0,
|
||||
"pt-BR": 84,
|
||||
"ru": 72,
|
||||
"pt-BR": 80,
|
||||
"ru": 68,
|
||||
"sv-SE": 4,
|
||||
"ur": 3,
|
||||
"zh-Hans": 98,
|
||||
"zh-Hant": 98
|
||||
"ur": 2,
|
||||
"zh-Hans": 93,
|
||||
"zh-Hant": 93
|
||||
}
|
||||
@@ -75,6 +75,10 @@
|
||||
"Page": "Page",
|
||||
"Pen": "Pen",
|
||||
"Pending": "Pending",
|
||||
"Collaborator": "Collaborator",
|
||||
"Under-Review": "Under Review",
|
||||
"Need-More-Seats": "Need More Seats",
|
||||
"Admin": "Admin",
|
||||
"Publish": "Publish",
|
||||
"Published to Web": "Published to web",
|
||||
"Quick Search": "Quick search",
|
||||
@@ -116,7 +120,7 @@
|
||||
"Version": "Version",
|
||||
"Visit Workspace": "Visit workspace",
|
||||
"Workspace Name": "Workspace name",
|
||||
"Workspace Owner": "Workspace owner",
|
||||
"Workspace Owner": "Workspace Owner",
|
||||
"Workspace Profile": "Workspace profile",
|
||||
"Workspace Settings": "Workspace settings",
|
||||
"Workspace Settings with name": "{{name}}'s settings",
|
||||
@@ -843,8 +847,11 @@
|
||||
"com.affine.payment.billing-type-form.title": "Tell us your use case",
|
||||
"com.affine.payment.blob-limit.description.local": "The maximum file upload size for local workspaces is {{quota}}.",
|
||||
"com.affine.payment.blob-limit.description.member": "The maximum file upload size for this joined workspace is {{quota}}. You can contact the owner of this workspace.",
|
||||
"com.affine.payment.blob-limit.description.owner.free": "{{planName}} users can upload files with a maximum size of {{currentQuota}}. You can upgrade your account to unlock a maximum file size of {{upgradeQuota}}.",
|
||||
"com.affine.payment.blob-limit.description.owner.pro": "{{planName}} users can upload files with a maximum size of {{quota}}.",
|
||||
|
||||
"com.affine.payment.blob-limit.description.owner": "The maximum file upload size for this workspace is {{quota}}. To proceed, you can:",
|
||||
"com.affine.payment.blob-limit.description.owner.tips-1": "Upgrade your account for larger file upload limits",
|
||||
"com.affine.payment.blob-limit.description.owner.tips-2": "Upgrade the workspace plan to increase storage for all member",
|
||||
"com.affine.payment.blob-limit.description.owner.tips-3": "Compress your file and upload again",
|
||||
"com.affine.payment.blob-limit.title": "You have reached the limit",
|
||||
"com.affine.payment.book-a-demo": "Book a demo",
|
||||
"com.affine.payment.buy-pro": "Buy Pro",
|
||||
@@ -880,22 +887,22 @@
|
||||
"com.affine.payment.cloud.pro.benefit.g1-8": "Real-time syncing & collaboration for more people.",
|
||||
"com.affine.payment.cloud.pro.description": "For family and small teams.",
|
||||
"com.affine.payment.cloud.pro.name": "Pro",
|
||||
"com.affine.payment.cloud.pro.title.billed-yearly": "billed annually",
|
||||
"com.affine.payment.cloud.pro.title.billed-yearly": "annually",
|
||||
"com.affine.payment.cloud.pro.title.price-monthly": "{{price}} per month",
|
||||
"com.affine.payment.cloud.team.benefit.g1": "Both in Team & Enterprise",
|
||||
"com.affine.payment.cloud.team.benefit.g1-1": "Everything in AFFiNE Pro.",
|
||||
"com.affine.payment.cloud.team.benefit.g1-2": "Advanced permission control, page history and review mode.",
|
||||
"com.affine.payment.cloud.team.benefit.g1-3": "Pay for seats, fits all team size.",
|
||||
"com.affine.payment.cloud.team.benefit.g1-4": "Email & Slack support.",
|
||||
"com.affine.payment.cloud.team.benefit.g2": "Enterprise only",
|
||||
"com.affine.payment.cloud.team.benefit.g2-1": "SSO authorization.",
|
||||
"com.affine.payment.cloud.team.benefit.g2-2": "Solutions & best practices for dedicated needs.",
|
||||
"com.affine.payment.cloud.team.benefit.g2-3": "Embed-able & integrations with IT support.",
|
||||
"com.affine.payment.cloud.team.description": "Best for scalable teams.",
|
||||
"com.affine.payment.cloud.team.name": "Team / Enterprise",
|
||||
"com.affine.payment.cloud.team.title": "Coming soon",
|
||||
"com.affine.payment.cloud.team-workspace.benefit.g1": "Include in Team Workspace",
|
||||
"com.affine.payment.cloud.team-workspace.benefit.g1-1": "Everything in AFFiNE Pro.",
|
||||
"com.affine.payment.cloud.team-workspace.benefit.g1-2": "100 GB initial storage + 20 GB per seat.",
|
||||
"com.affine.payment.cloud.team-workspace.benefit.g1-3": "500 MB of maximum file size.",
|
||||
"com.affine.payment.cloud.team-workspace.benefit.g1-4": "Unlimited team members (10+ seats).",
|
||||
"com.affine.payment.cloud.team-workspace.benefit.g1-5": "Multiple admin roles.",
|
||||
"com.affine.payment.cloud.team-workspace.benefit.g1-6": "Priority customer support",
|
||||
"com.affine.payment.cloud.team-workspace.description": "Best for scalable teams.",
|
||||
"com.affine.payment.cloud.team-workspace.name": "Team Workspace",
|
||||
"com.affine.payment.cloud.team-workspace.title.billed-yearly": "annually",
|
||||
"com.affine.payment.cloud.team-workspace.title.price-monthly": "{{price}} per seat/month",
|
||||
"com.affine.payment.contact-sales": "Contact sales",
|
||||
"com.affine.payment.current-plan": "Current plan",
|
||||
"com.affine.payment.start-free-trial": "Start 14-day free trial",
|
||||
"com.affine.payment.discount-amount": "{{amount}}% off",
|
||||
"com.affine.payment.downgrade": "Downgrade",
|
||||
"com.affine.payment.downgraded-notify.content": "We'd like to hear more about where we fall short, so that we can make AFFiNE better.",
|
||||
@@ -919,14 +926,62 @@
|
||||
"com.affine.payment.lifetime.purchased": "Purchased",
|
||||
"com.affine.payment.lifetime.title": "Believer Plan",
|
||||
"com.affine.payment.member-limit.free.confirm": "Upgrade",
|
||||
"com.affine.payment.member-limit.free.description": "Each {{planName}} user can invite up to {{quota}} members to join their workspace. You can upgrade your account to unlock more members.",
|
||||
"com.affine.payment.member-limit.description": "Workspaces created by {{planName}} users are limited to {{quota}} members. To add more collaborators, you can:",
|
||||
"com.affine.payment.member-limit.description.tips-for-free-plan": "Upgrade to AFFiNE Pro for expanded member capacity",
|
||||
"com.affine.payment.member-limit.description.tips-1": "Convert to a Team Workspace for unlimited collaboration",
|
||||
"com.affine.payment.member-limit.description.tips-2": "Or create a new workspace",
|
||||
"com.affine.payment.member-limit.pro.confirm": "Got it",
|
||||
"com.affine.payment.member-limit.pro.description": "Each {{planName}} user can invite up to {{quota}} members to join their workspace. If you want to continue adding collaboration members, you can create a new workspace.",
|
||||
"com.affine.payment.member-limit.title": "You have reached the limit",
|
||||
"com.affine.payment.member.description": "Manage members here. {{planName}} users can invite up to {{memberLimit}}",
|
||||
"com.affine.payment.member.description.choose-plan": "Choose your plan",
|
||||
"com.affine.payment.member.description.go-upgrade": "go upgrade",
|
||||
"com.affine.payment.member.description2": "Looking to collaborate with more people?",
|
||||
"com.affine.payment.member.team.description": "Work together with unlimited team members.",
|
||||
"com.affine.payment.member.team.invite.title": "Invite team members",
|
||||
"com.affine.payment.member.team.invite.description": "Invite new members to join your workspace via email or share an invite link",
|
||||
"com.affine.payment.member.team.invite.email-invite": "Email Invite",
|
||||
"com.affine.payment.member.team.invite.invite-link": "Invite Link",
|
||||
"com.affine.payment.member.team.invite.email-addresses": "Email addresses",
|
||||
"com.affine.payment.member.team.invite.email-placeholder": "Enter email addresses (separated by commas)",
|
||||
"com.affine.payment.member.team.invite.import-csv": "Import CSV",
|
||||
"com.affine.payment.member.team.invite.send-invites": "Send Invites",
|
||||
"com.affine.payment.member.team.invite.link-expiration": "Link expiration",
|
||||
"com.affine.payment.member.team.invite.expiration-date": "{{number}} days",
|
||||
"com.affine.payment.member.team.invite.invitation-link": "Invitation link",
|
||||
"com.affine.payment.member.team.invite.invitation-link.description": "Generate a link to invite members to your workspace",
|
||||
"com.affine.payment.member.team.invite.generate": "Generate",
|
||||
"com.affine.payment.member.team.invite.copy": "Copy",
|
||||
"com.affine.payment.member.team.invite.done": "Done",
|
||||
"com.affine.payment.member.team.invite.notify.title": "Invitation sent",
|
||||
"com.affine.payment.member.team.invite.notify.message": "Invited members have been notified with email to join this Workspace.",
|
||||
"com.affine.payment.member.team.revoke": "Revoke invitation",
|
||||
"com.affine.payment.member.team.approve": "Approve",
|
||||
"com.affine.payment.member.team.decline": "Decline",
|
||||
"com.affine.payment.member.team.remove": "Remove member",
|
||||
"com.affine.payment.member.team.change.admin": "Change role to admin",
|
||||
"com.affine.payment.member.team.change.collaborator": "Change role to collaborator",
|
||||
"com.affine.payment.member.team.assign": "Assign as owner",
|
||||
"com.affine.payment.member.team.revoke.notify.title": "Invitation Revoked",
|
||||
"com.affine.payment.member.team.revoke.notify.message": "You have canceled the invitation for {{name}}",
|
||||
"com.affine.payment.member.team.approve.notify.title": "Request approved",
|
||||
"com.affine.payment.member.team.approve.notify.message": "You have approved the {{name}}’s request to join this workspace",
|
||||
"com.affine.payment.member.team.decline.notify.title": "Request declined",
|
||||
"com.affine.payment.member.team.decline.notify.message": "You have declined the {{name}}’s request to join this workspace",
|
||||
"com.affine.payment.member.team.remove.notify.title": "Member removed",
|
||||
"com.affine.payment.member.team.remove.notify.message": "You have removed {{name}} from this workspace",
|
||||
"com.affine.payment.member.team.change.notify.title": "Role Updated",
|
||||
"com.affine.payment.member.team.change.admin.notify.message": "You have successfully promoted {{name}} to Admin.",
|
||||
"com.affine.payment.member.team.change.collaborator.notify.message": "You have successfully changed {{name}} s role to collaborator.",
|
||||
"com.affine.payment.member.team.assign.notify.title": "Owner assigned",
|
||||
"com.affine.payment.member.team.assign.notify.message": "You have successfully assigned {{name}} as the owner of this workspace.",
|
||||
"com.affine.payment.member.team.assign.confirm.title": "Confirm new workspace owner",
|
||||
"com.affine.payment.member.team.assign.confirm.description": "You are about to transfer workspace ownership to {{name}}. Please review the following changes carefully:",
|
||||
"com.affine.payment.member.team.assign.confirm.description-1": "This action cannot be undone",
|
||||
"com.affine.payment.member.team.assign.confirm.description-2": "Your role will be changed to Admin",
|
||||
"com.affine.payment.member.team.assign.confirm.description-3": "You will lose ownership rights to the entire workspace",
|
||||
"com.affine.payment.member.team.assign.confirm.description-4": "To confirm this transfer, please type the workspace name",
|
||||
"com.affine.payment.member.team.assign.confirm.placeholder": "Type workspace name to confirm",
|
||||
"com.affine.payment.member.team.assign.confirm.button": "Transfer Ownership",
|
||||
"com.affine.payment.modal.change.cancel": "Cancel",
|
||||
"com.affine.payment.modal.change.confirm": "Change",
|
||||
"com.affine.payment.modal.change.title": "Change your subscription",
|
||||
@@ -949,7 +1004,8 @@
|
||||
"com.affine.payment.sign-up-free": "Sign up free",
|
||||
"com.affine.payment.storage-limit.description.member": "Cloud storage is insufficient. Please contact the owner of that workspace.",
|
||||
"com.affine.payment.storage-limit.description.owner": "Cloud storage is insufficient. You can upgrade your account to unlock more cloud storage.",
|
||||
"com.affine.payment.storage-limit.title": "Sync failed",
|
||||
"com.affine.payment.storage-limit.new-description.owner": "Unable to sync due to insufficient storage space. You can remove excess content, upgrade your account, or increase your workspace storage to resolve this issue.",
|
||||
"com.affine.payment.storage-limit.new-title": "Sync failed due to storage space limit",
|
||||
"com.affine.payment.storage-limit.view": "View",
|
||||
"com.affine.payment.subtitle-active": "You are currently on the {{currentPlan}} plan. If you have any questions, please contact our <3>customer support</3>.",
|
||||
"com.affine.payment.subtitle-canceled": "You are currently on the {{plan}} plan. After the current billing period ends, your account will automatically switch to the Free plan.",
|
||||
@@ -969,6 +1025,8 @@
|
||||
"com.affine.payment.upgrade-success-page.support": "If you have any questions, please contact our <1> customer support</1>.",
|
||||
"com.affine.payment.upgrade-success-page.text": "Congratulations! Your AFFiNE account has been successfully upgraded to a Pro account.",
|
||||
"com.affine.payment.upgrade-success-page.title": "Upgrade successful!",
|
||||
"com.affine.payment.upgrade-success-page.team.text-1": "Congratulations! Your workspace has been successfully upgraded to a Team Workspace. Now you can invite unlimited members to collaborate in this workspace.",
|
||||
"com.affine.payment.upgrade-success-page.team.text-2": "If you have any questions, please contact our <1>customer support</1>.",
|
||||
"com.affine.peek-view-controls.close": "Close",
|
||||
"com.affine.peek-view-controls.open-doc": "Open this doc",
|
||||
"com.affine.peek-view-controls.open-doc-in-new-tab": "Open in new tab",
|
||||
@@ -1262,12 +1320,21 @@
|
||||
"com.affine.settings.workspace.experimental-features.enable-mobile-edgeless-editing.description": "Once enabled, users can edit edgeless canvas.",
|
||||
"com.affine.settings.workspace.not-owner": "Only an owner can edit the workspace avatar and name. Changes will be shown for everyone.",
|
||||
"com.affine.settings.workspace.preferences": "Preference",
|
||||
"com.affine.settings.workspace.billing": "Billing",
|
||||
"com.affine.settings.workspace.billing.team-workspace": "Team Workspace",
|
||||
"com.affine.settings.workspace.billing.team-workspace.description.free-trail": "Your workspace is in a free trail period.",
|
||||
"com.affine.settings.workspace.billing.team-workspace.description.billed.annually": "Your workspace is billed annually.",
|
||||
"com.affine.settings.workspace.billing.team-workspace.description.billed.monthly": "Your workspace is billed monthly.",
|
||||
"com.affine.settings.workspace.billing.team-workspace.not-renewed": "Your subscription will end on {{date}}",
|
||||
"com.affine.settings.workspace.billing.team-workspace.next-billing-date": "Next billing date: {{date}}",
|
||||
"com.affine.settings.workspace.billing.team-workspace.cancel-plan": "Cancel Plan",
|
||||
"com.affine.settings.workspace.state.local": "Local",
|
||||
"com.affine.settings.workspace.state.sync-affine-cloud": "Sync with AFFiNE Cloud",
|
||||
"com.affine.settings.workspace.state.self-hosted": "Self-Hosted Server",
|
||||
"com.affine.settings.workspace.state.joined": "Joined Workspace",
|
||||
"com.affine.settings.workspace.state.available-offline": "Available Offline",
|
||||
"com.affine.settings.workspace.state.published": "Published to Web",
|
||||
"com.affine.settings.workspace.state.team": "Team Workspace",
|
||||
"com.affine.settings.workspace.properties": "Properties",
|
||||
"com.affine.settings.workspace.properties.add_property": "Add property",
|
||||
"com.affine.settings.workspace.properties.all": "All",
|
||||
@@ -1288,6 +1355,9 @@
|
||||
"com.affine.settings.workspace.sharing.title": "Sharing",
|
||||
"com.affine.settings.workspace.sharing.url-preview.description": "Allow URL unfurling by Slack & other social apps, even if a doc is only accessible by workspace members.",
|
||||
"com.affine.settings.workspace.sharing.url-preview.title": "Always enable url preview",
|
||||
"com.affine.settings.workspace.affine-ai.title": "AFFiNE AI",
|
||||
"com.affine.settings.workspace.affine-ai.label": "Allow AFFiNE AI Assistant",
|
||||
"com.affine.settings.workspace.affine-ai.description": "Allow workspace members to use AFFiNE AI features. This setting doesn't affect billing. Workspace members use AFFiNE AI through their personal accounts.",
|
||||
"com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.",
|
||||
"com.affine.share-menu.ShareMode": "Share mode",
|
||||
"com.affine.share-menu.SharePage": "Share doc",
|
||||
@@ -1504,5 +1574,21 @@
|
||||
"com.affine.editor.at-menu.journal": "Journal",
|
||||
"com.affine.editor.at-menu.date-picker": "Select a specific date",
|
||||
"com.affine.editor.bi-directional-link-panel.show": "Show",
|
||||
"com.affine.editor.bi-directional-link-panel.hide": "Hide"
|
||||
"com.affine.editor.bi-directional-link-panel.hide": "Hide",
|
||||
"com.affine.upgrade-to-team-page.title": "Empower Your Team with Seamless Collaboration",
|
||||
"com.affine.upgrade-to-team-page.workspace-selector.placeholder": "Select an existing workspace or create a new one",
|
||||
"com.affine.upgrade-to-team-page.workspace-selector.create-workspace": "Create Workspace",
|
||||
"com.affine.upgrade-to-team-page.upgrade-button": "Upgrade to Team Workspace",
|
||||
"com.affine.upgrade-to-team-page.benefit.title": "Team Workspace gives you everything you need for seamless team collaboration:",
|
||||
"com.affine.upgrade-to-team-page.benefit.g1": "Invite unlimited members to your workspace",
|
||||
"com.affine.upgrade-to-team-page.benefit.g2": "Set custom roles and permissions for better control",
|
||||
"com.affine.upgrade-to-team-page.benefit.g3": "Access advanced team management features",
|
||||
"com.affine.upgrade-to-team-page.benefit.g4": "Get priority customer support",
|
||||
"com.affine.upgrade-to-team-page.benefit.description": "Perfect for growing teams and organizations that need professional collaboration tools.",
|
||||
"com.affine.upgrade-to-team-page.upgrade-confirm.title": "Upgrade to Team Workspace",
|
||||
"com.affine.upgrade-to-team-page.upgrade-confirm.description": "Are you sure you want to upgrade <1>{{workspaceName}}</1> to a Team Workspace? This will allow unlimited members to collaborate in this workspace.",
|
||||
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.title": "Name Your Workspace",
|
||||
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.description": "A workspace is your virtual space to capture, create and plan as just one person or together as a team.",
|
||||
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.placeholder": "Set a workspace name",
|
||||
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.confirm": "Continue to Pricing"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user