);
});
- }, [activeTab, onClick, t]);
+ }, [activeTab, onClick, showBilling, t]);
return (
<>
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/index.tsx
new file mode 100644
index 000000000..7ed07dc43
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/index.tsx
@@ -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
;
+ }
+
+ return (
+
+
+
+
+
+
+ {team.end && team.canceledAt ? (
+
+ ) : null}
+
+
+
+
+
+
+ );
+};
+
+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 (
+
+
+
+ {description}
+ {expirationDate}
+ >
+ }
+ />
+
+
+
+
+
+ ${amount}
+
+ /
+ {teamSubscription?.recurring === SubscriptionRecurring.Monthly
+ ? t['com.affine.payment.billing-setting.month']()
+ : t['com.affine.payment.billing-setting.year']()}
+
+
+
+ );
+};
+
+const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => {
+ const t = useI18n();
+ const [open, setOpen] = useState(false);
+ const handleClick = useCallback(() => {
+ setOpen(true);
+ }, []);
+
+ return (
+
+
+
+
+
+ );
+};
+
+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 (
+
+
+
+
+
+ );
+};
+
+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 (
+
+
+
+ );
+};
+
+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
;
+ } else {
+ return (
+
+ {error
+ ? UserFriendlyError.fromAnyError(error).message
+ : 'Failed to load members'}
+
+ );
+ }
+ }
+
+ return (
+
+
+ {invoiceCount === 0 ? (
+
+ {t['com.affine.payment.billing-setting.no-invoice']()}
+
+ ) : (
+ pageInvoices?.map(invoice => (
+
+ ))
+ )}
+
+
+ {invoiceCount > invoicesService.invoices.PAGE_SIZE && (
+
+ )}
+
+ );
+};
+
+const InvoiceLine = ({
+ invoice,
+}: {
+ invoice: NonNullable
['invoices'][0];
+}) => {
+ const t = useI18n();
+ const urlService = useService(UrlService);
+
+ const open = useCallback(() => {
+ if (invoice.link) {
+ urlService.openPopupWindow(invoice.link);
+ }
+ }, [invoice.link, urlService]);
+
+ return (
+
+
+
+ );
+};
+
+const BillingHistorySkeleton = () => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/styles.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/styles.css.ts
new file mode 100644
index 000000000..74ddd960b
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/styles.css.ts
@@ -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',
+});
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx
index 7ccd48ffa..56ff2a7a5 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx
@@ -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 (
);
+ case 'workspace:billing':
+ return ;
}
return null;
};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/ai.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/ai.tsx
new file mode 100644
index 000000000..2624b4591
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/ai.tsx
@@ -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 (
+
+
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/index.tsx
index 5b11e565b..1f884a921 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/index.tsx
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/index.tsx
@@ -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 = ({
+
{BUILD_CONFIG.isElectron && (
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/labels.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/labels.tsx
index d740aa5f6..27c3517f5 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/labels.tsx
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/labels.tsx
@@ -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): 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 (
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members.tsx
deleted file mode 100644
index 7c7598597..000000000
--- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
- );
-};
-
-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(
- 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(
- 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 (
-
- {t['com.affine.payment.member.description2']()}
- {hasPaymentFeature ? (
-
-
- {t['com.affine.payment.member.description.choose-plan']()}
-
-
- ) : null}
-
- );
- }, [handleUpgradeConfirm, hasPaymentFeature, t, workspaceQuota]);
-
- if (workspaceQuota === null) {
- if (isLoading) {
- return ;
- } else {
- return (
-
- {error
- ? UserFriendlyError.fromAnyError(error).message
- : 'Failed to load members'}
-
- );
- }
- }
-
- return (
- <>
-
- {isOwner ? (
- <>
-
- {isLimited ? (
-
- ) : (
-
- )}
- >
- ) : null}
-
-
-
-
-
- >
- );
-};
-export const MembersPanelFallback = () => {
- const t = useI18n();
-
- return (
- <>
-
-
-
-
- >
- );
-};
-
-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 (
-
-
- {t['com.affine.settings.member.loading']()}
-
- );
-};
-
-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 (
-
- {pageMembers === undefined ? (
- isLoading ? (
-
- ) : (
-
- {error
- ? UserFriendlyError.fromAnyError(error).message
- : 'Failed to load members'}
-
- )
- ) : (
- pageMembers?.map(member => (
-
- ))
- )}
- {memberCount !== undefined &&
- memberCount > membersService.members.PAGE_SIZE && (
-
- )}
-
- );
-};
-
-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 (
-
-
-
- {member.name ? (
- <>
-
{member.name}
-
{member.email}
- >
- ) : (
-
{member.email}
- )}
-
-
- {member.accepted
- ? member.permission === Permission.Owner
- ? 'Workspace Owner'
- : 'Member'
- : 'Pending'}
-
-
-
- );
-};
-
-export const MembersPanel = ({
- onChangeSettingState,
-}: {
- onChangeSettingState: (settingState: SettingState) => void;
-}): ReactElement | null => {
- const workspace = useService(WorkspaceService).workspace;
- if (workspace.flavour === 'local') {
- return ;
- }
- return (
-
-
-
- );
-};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx
new file mode 100644
index 000000000..7eeb0d61b
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx
@@ -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 => {
+ 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(
+ 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 {t['com.affine.payment.member.team.description']()};
+ }
+ return (
+
+ {t['com.affine.payment.member.description2']()}
+ {hasPaymentFeature ? (
+
+
+ {t['com.affine.payment.member.description.choose-plan']()}
+
+
+ ) : null}
+
+ );
+ }, [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 ;
+ } else {
+ return (
+
+ {error
+ ? UserFriendlyError.fromAnyError(error).message
+ : 'Failed to load members'}
+
+ );
+ }
+ }
+
+ return (
+ <>
+
+ {isOwner ? (
+ <>
+
+ {isTeam ? (
+ }
+ />
+ ) : isLimited ? (
+
+ ) : (
+
+ )}
+ >
+ ) : null}
+
+
+
+
+
+ >
+ );
+};
+
+export const MembersPanelFallback = () => {
+ const t = useI18n();
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+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 (
+
+
+ {t['com.affine.settings.member.loading']()}
+
+ );
+};
+
+const ImportCSV = ({ onImport }: { onImport: (file: File) => void }) => {
+ const t = useI18n();
+
+ return (
+
+ }>
+ {t['com.affine.payment.member.team.invite.import-csv']()}
+
+
+ );
+};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/confirm-assign-modal.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/confirm-assign-modal.tsx
new file mode 100644
index 000000000..d841d6e17
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/confirm-assign-modal.tsx
@@ -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 (
+
+
+
+
+ {t['com.affine.payment.member.team.assign.confirm.description']({
+ name: member.name || member.email || member.id,
+ })}
+
+
+ {t['com.affine.payment.member.team.assign.confirm.description-1']()}
+
+
+ {t['com.affine.payment.member.team.assign.confirm.description-2']()}
+
+
+ {t['com.affine.payment.member.team.assign.confirm.description-3']()}
+
+
+
+ {t['com.affine.payment.member.team.assign.confirm.description-4']()}
+
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/index.tsx
new file mode 100644
index 000000000..d41b06bf9
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/index.tsx
@@ -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 ;
+ }
+ return (
+
+
+
+ );
+};
+
+const MembersPanelLocal = () => {
+ const t = useI18n();
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-list.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-list.tsx
new file mode 100644
index 000000000..b89fc485d
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-list.tsx
@@ -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 (
+
+ {pageMembers === undefined ? (
+ isLoading ? (
+
+ ) : (
+
+ {error
+ ? UserFriendlyError.fromAnyError(error).message
+ : 'Failed to load members'}
+
+ )
+ ) : (
+ pageMembers?.map(member => (
+
+ ))
+ )}
+ {memberCount !== undefined &&
+ memberCount > membersService.members.PAGE_SIZE && (
+
+ )}
+
+ );
+};
+
+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 (
+
+
+
+ {member.name ? (
+ <>
+
{member.name}
+
{member.email}
+ >
+ ) : (
+
{member.email}
+ )}
+
+
+ {t.t(memberStatus)}
+
+
+ }
+ >
+
+
+
+
+
+
+ );
+};
+
+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 (
+
+
+ {t['com.affine.settings.member.loading']()}
+
+ );
+};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-option.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-option.tsx
new file mode 100644
index 000000000..0640e00bb
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-option.tsx
@@ -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 ? (
+
+ ) : null
+ )}
+ >
+ );
+};
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/styles.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/styles.css.ts
new file mode 100644
index 000000000..4d6f2419c
--- /dev/null
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/styles.css.ts
@@ -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',
+});
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/style.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/style.css.ts
index a7583b353..5288bf9a8 100644
--- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/style.css.ts
+++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/style.css.ts
@@ -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',
-});
diff --git a/packages/frontend/core/src/desktop/pages/subscribe/index.tsx b/packages/frontend/core/src/desktop/pages/subscribe/index.tsx
index 3adf84515..d41ddd60b 100644
--- a/packages/frontend/core/src/desktop/pages/subscribe/index.tsx
+++ b/packages/frontend/core/src/desktop/pages/subscribe/index.tsx
@@ -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,
diff --git a/packages/frontend/core/src/desktop/pages/upgrade-success/team/index.tsx b/packages/frontend/core/src/desktop/pages/upgrade-success/team/index.tsx
new file mode 100644
index 000000000..e63b0cd2e
--- /dev/null
+++ b/packages/frontend/core/src/desktop/pages/upgrade-success/team/index.tsx
@@ -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 = (
+
+
{t['com.affine.payment.upgrade-success-page.team.text-1']()}
+
+
+ ),
+ }}
+ />
+
+
+ );
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/desktop/pages/upgrade-success/team/styles.css.ts b/packages/frontend/core/src/desktop/pages/upgrade-success/team/styles.css.ts
new file mode 100644
index 000000000..578ec70be
--- /dev/null
+++ b/packages/frontend/core/src/desktop/pages/upgrade-success/team/styles.css.ts
@@ -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'),
+ },
+});
diff --git a/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx b/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx
new file mode 100644
index 000000000..b8153d797
--- /dev/null
+++ b/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx
@@ -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(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 (
+
+
+
+ }
+ contentOptions={{
+ style: {
+ width: '410px',
+ },
+ }}
+ >
+
+ {menuTriggerText}
+
+
+
+
+
+
+
{t['com.affine.upgrade-to-team-page.benefit.title']()}
+
+ {benefitList.map((benefit, index) => (
+ -
+
+ {t.t(benefit)}
+
+ ))}
+
+
+ {t['com.affine.upgrade-to-team-page.benefit.description']()}
+
+
+
+
+
+
+ );
+};
+
+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 (
+
+
+ {t['com.affine.upgrade-to-team-page.upgrade-confirm.title']()}
+
+
+ ,
+ }}
+ values={{
+ workspaceName,
+ }}
+ />
+
+
+
+
+
+ {t['com.affine.payment.upgrade']()}
+
+
+
+ );
+};
+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 (
+
+ {cloudWorkspaces.length > 0 &&
+ cloudWorkspaces.map(workspace => (
+
+ ))}
+ {cloudWorkspaces.length > 0 &&
}
+
}
+ onClick={onClickCreateWorkspace}
+ >
+
+ {t[
+ 'com.affine.upgrade-to-team-page.workspace-selector.create-workspace'
+ ]()}
+
+
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+
+
+ {t[
+ 'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.title'
+ ]()}
+
+
+
+
+ {t[
+ 'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.description'
+ ]()}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/desktop/pages/upgrade-to-team/styles.css.ts b/packages/frontend/core/src/desktop/pages/upgrade-to-team/styles.css.ts
new file mode 100644
index 000000000..e16860baa
--- /dev/null
+++ b/packages/frontend/core/src/desktop/pages/upgrade-to-team/styles.css.ts
@@ -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',
+});
diff --git a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx
index 582bf52ab..8d4219960 100644
--- a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx
+++ b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx
@@ -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 = ;
+ // TODO(@JimmFly): handle error
+ element = ;
} else {
element = ;
}
diff --git a/packages/frontend/core/src/desktop/router.tsx b/packages/frontend/core/src/desktop/router.tsx
index 6632f72e5..d311ade62 100644
--- a/packages/frontend/core/src/desktop/router.tsx
+++ b/packages/frontend/core/src/desktop/router.tsx
@@ -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: () => {
diff --git a/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts b/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts
index 73b94e006..ee2783a7f 100644
--- a/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts
+++ b/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts
@@ -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
diff --git a/packages/frontend/core/src/modules/cloud/entities/subscription.ts b/packages/frontend/core/src/modules/cloud/entities/subscription.ts
index b70ef49b2..ea6d993da 100644
--- a/packages/frontend/core/src/modules/cloud/entities/subscription.ts
+++ b/packages/frontend/core/src/modules/cloud/entities/subscription.ts
@@ -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
);
diff --git a/packages/frontend/core/src/modules/dialogs/constant.ts b/packages/frontend/core/src/modules/dialogs/constant.ts
index 53c772ee4..9eaa9855a 100644
--- a/packages/frontend/core/src/modules/dialogs/constant.ts
+++ b/packages/frontend/core/src/modules/dialogs/constant.ts
@@ -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 }) => {
diff --git a/packages/frontend/core/src/modules/permissions/entities/permission.ts b/packages/frontend/core/src/modules/permissions/entities/permission.ts
index 19c8bc8c6..3f2c2c424 100644
--- a/packages/frontend/core/src/modules/permissions/entities/permission.ts
+++ b/packages/frontend/core/src/modules/permissions/entities/permission.ts
@@ -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(null);
+ isAdmin$ = new LiveData(null);
+ isTeam$ = new LiveData(null);
isLoading$ = new LiveData(false);
error$ = new LiveData(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
+ );
+ }
}
diff --git a/packages/frontend/core/src/modules/permissions/stores/permission.ts b/packages/frontend/core/src/modules/permissions/stores/permission.ts
index 6a745f8f2..1528244f2 100644
--- a/packages/frontend/core/src/modules/permissions/stores/permission.ts
+++ b/packages/frontend/core/src/modules/permissions/stores/permission.ts
@@ -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;
}
/**
diff --git a/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts b/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts
index 7d66eb0e8..e99a0336e 100644
--- a/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts
+++ b/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts
@@ -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();
}
diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts
index fe5046736..23df74562 100644
--- a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts
+++ b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts
@@ -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(null);
isRevalidating$ = new LiveData(false);
workspaces$ = new LiveData([]);
+
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 {
@@ -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() {
diff --git a/packages/frontend/graphql/src/graphql/get-is-admin.gql b/packages/frontend/graphql/src/graphql/get-is-admin.gql
new file mode 100644
index 000000000..8e9199ab9
--- /dev/null
+++ b/packages/frontend/graphql/src/graphql/get-is-admin.gql
@@ -0,0 +1,3 @@
+query getIsAdmin($workspaceId: String!) {
+ isAdmin(workspaceId: $workspaceId)
+}
diff --git a/packages/frontend/graphql/src/graphql/get-workspace-info.gql b/packages/frontend/graphql/src/graphql/get-workspace-info.gql
new file mode 100644
index 000000000..3ebd59a25
--- /dev/null
+++ b/packages/frontend/graphql/src/graphql/get-workspace-info.gql
@@ -0,0 +1,7 @@
+query getWorkspaceInfo($workspaceId: String!) {
+ isAdmin(workspaceId: $workspaceId)
+ isOwner(workspaceId: $workspaceId)
+ workspace(id: $workspaceId) {
+ team
+ }
+}
diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts
index 0d9e5ceb0..6a9761a1b 100644
--- a/packages/frontend/graphql/src/graphql/index.ts
+++ b/packages/frontend/graphql/src/graphql/index.ts
@@ -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',
diff --git a/packages/frontend/graphql/src/graphql/workspace-intive-by-emails.gql b/packages/frontend/graphql/src/graphql/workspace-intive-by-emails.gql
new file mode 100644
index 000000000..a21e22e22
--- /dev/null
+++ b/packages/frontend/graphql/src/graphql/workspace-intive-by-emails.gql
@@ -0,0 +1,15 @@
+mutation inviteByEmails(
+ $workspaceId: String!
+ $emails: [String!]!
+ $sendInviteMail: Boolean
+) {
+ inviteBatch(
+ workspaceId: $workspaceId
+ emails: $emails
+ sendInviteMail: $sendInviteMail
+ ) {
+ email
+ inviteId
+ sentSuccess
+ }
+}
diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts
index e9509f954..19e80086d 100644
--- a/packages/frontend/graphql/src/schema.ts
+++ b/packages/frontend/graphql/src/schema.ts
@@ -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 };
};
+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'];
+ sendInviteMail?: InputMaybe;
+}>;
+
+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;
diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json
index 83cca997d..00d5cce5d 100644
--- a/packages/frontend/i18n/src/i18n-completenesses.json
+++ b/packages/frontend/i18n/src/i18n-completenesses.json
@@ -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
}
\ No newline at end of file
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index 8e86d2d7b..256eda0c7 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -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 support3>.",
"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 support1>.",
"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 support1>.",
"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"
}