diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index ab56cf9be4737..05604fd8d582f 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -224,6 +224,21 @@ export class WorkspaceResolver { return data?.user?.id === user.id; } + @Query(() => Boolean, { + description: 'Get is admin of workspace', + complexity: 2, + }) + async isAdmin( + @CurrentUser() user: CurrentUser, + @Args('workspaceId') workspaceId: string + ) { + return this.permissions.tryCheckWorkspaceIs( + workspaceId, + user.id, + Permission.Admin + ); + } + @Query(() => [WorkspaceType], { description: 'Get all accessible workspaces for current user', complexity: 2, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index d5021c6701e00..568731e117607 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -594,6 +594,9 @@ type Query { """send workspace invitation""" getInviteInfo(inviteId: String!): InvitationType! + """Get is admin of workspace""" + isAdmin(workspaceId: String!): Boolean! + """Get is owner of workspace""" isOwner(workspaceId: String!): Boolean! diff --git a/packages/common/infra/src/modules/workspace/entities/profile.ts b/packages/common/infra/src/modules/workspace/entities/profile.ts index 005b9251907f6..a4ed2445f842c 100644 --- a/packages/common/infra/src/modules/workspace/entities/profile.ts +++ b/packages/common/infra/src/modules/workspace/entities/profile.ts @@ -22,6 +22,8 @@ export interface WorkspaceProfileInfo { avatar?: string; name?: string; isOwner?: boolean; + isAdmin?: boolean; + isTeam?: boolean; } /** diff --git a/packages/frontend/component/src/components/member-components/invite-team-modal/index.tsx b/packages/frontend/component/src/components/member-components/invite-team-modal/index.tsx index 0219dccc90328..45f471e502aa7 100644 --- a/packages/frontend/component/src/components/member-components/invite-team-modal/index.tsx +++ b/packages/frontend/component/src/components/member-components/invite-team-modal/index.tsx @@ -1,4 +1,5 @@ import { emailRegex } from '@affine/component/auth-components'; +import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql'; import { Permission } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; import { useCallback, useEffect, useState } from 'react'; @@ -14,6 +15,10 @@ export interface InviteTeamMemberModalProps { onConfirm: (params: { email: string; permission: Permission }) => void; isMutating: boolean; copyTextToClipboard: (text: string) => Promise; + onGenerateInviteLink: ( + expireTime: WorkspaceInviteLinkExpireTime + ) => Promise; + onRevokeInviteLink: () => Promise; } export const InviteTeamMemberModal = ({ @@ -22,6 +27,8 @@ export const InviteTeamMemberModal = ({ onConfirm, isMutating, copyTextToClipboard, + onGenerateInviteLink, + onRevokeInviteLink, }: InviteTeamMemberModalProps) => { const t = useI18n(); const [inviteEmail, setInviteEmail] = useState(''); @@ -91,6 +98,8 @@ export const InviteTeamMemberModal = ({ inviteMethod={inviteMethod} onInviteMethodChange={setInviteMethod} copyTextToClipboard={copyTextToClipboard} + onGenerateInviteLink={onGenerateInviteLink} + onRevokeInviteLink={onRevokeInviteLink} /> ); diff --git a/packages/frontend/component/src/components/member-components/invite-team-modal/link-invite.tsx b/packages/frontend/component/src/components/member-components/invite-team-modal/link-invite.tsx index cc34b1d4a2c14..0932cb800aec8 100644 --- a/packages/frontend/component/src/components/member-components/invite-team-modal/link-invite.tsx +++ b/packages/frontend/component/src/components/member-components/invite-team-modal/link-invite.tsx @@ -1,3 +1,4 @@ +import { WorkspaceInviteLinkExpireTime } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; import { CloseIcon } from '@blocksuite/icons/rc'; import { cssVar } from '@toeverything/theme'; @@ -15,35 +16,43 @@ const getMenuItems = (t: ReturnType) => [ label: t['com.affine.payment.member.team.invite.expiration-date']({ number: '1', }), - value: 1, + value: WorkspaceInviteLinkExpireTime.OneDay, }, { label: t['com.affine.payment.member.team.invite.expiration-date']({ number: '3', }), - value: 3, + value: WorkspaceInviteLinkExpireTime.ThreeDays, }, { label: t['com.affine.payment.member.team.invite.expiration-date']({ number: '7', }), - value: 7, + value: WorkspaceInviteLinkExpireTime.OneWeek, }, { label: t['com.affine.payment.member.team.invite.expiration-date']({ number: '30', }), - value: 30, + value: WorkspaceInviteLinkExpireTime.OneMonth, }, ]; export const LinkInvite = ({ copyTextToClipboard, + generateInvitationLink, + revokeInvitationLink, }: { + generateInvitationLink: ( + expireTime: WorkspaceInviteLinkExpireTime + ) => Promise; + revokeInvitationLink: () => Promise; copyTextToClipboard: (text: string) => Promise; }) => { const t = useI18n(); - const [selectedValue, setSelectedValue] = useState(7); + const [selectedValue, setSelectedValue] = useState( + WorkspaceInviteLinkExpireTime.OneWeek + ); const [invitationLink, setInvitationLink] = useState(''); const menuItems = getMenuItems(t); const items = useMemo(() => { @@ -59,10 +68,19 @@ export const LinkInvite = ({ [menuItems, selectedValue] ); - //TODO(@JimmFly): implement team feature const onGenerate = useCallback(() => { - setInvitationLink('ggsimida'); - }, []); + generateInvitationLink(selectedValue) + .then(link => { + setInvitationLink(link); + }) + .catch(err => { + console.error('Failed to generate invitation link: ', err); + notify.error({ + title: 'Failed to generate invitation link', + message: err.message, + }); + }); + }, [generateInvitationLink, selectedValue]); const onCopy = useCallback(() => { copyTextToClipboard(invitationLink) @@ -81,8 +99,18 @@ export const LinkInvite = ({ }, [copyTextToClipboard, invitationLink, t]); const onReset = useCallback(() => { - setInvitationLink(''); - }, []); + revokeInvitationLink() + .then(() => { + setInvitationLink(''); + }) + .catch(err => { + console.error('Failed to revoke invitation link: ', err); + notify.error({ + title: 'Failed to revoke invitation link', + message: err.message, + }); + }); + }, [revokeInvitationLink]); return ( <> diff --git a/packages/frontend/component/src/components/member-components/invite-team-modal/modal-content.tsx b/packages/frontend/component/src/components/member-components/invite-team-modal/modal-content.tsx index 308f5b667d3ff..f09d37c0ed4ae 100644 --- a/packages/frontend/component/src/components/member-components/invite-team-modal/modal-content.tsx +++ b/packages/frontend/component/src/components/member-components/invite-team-modal/modal-content.tsx @@ -1,3 +1,4 @@ +import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; import { EmailIcon, LinkIcon } from '@blocksuite/icons/rc'; @@ -16,6 +17,8 @@ export const ModalContent = ({ isMutating, isValidEmail, copyTextToClipboard, + onGenerateInviteLink, + onRevokeInviteLink, }: { inviteEmail: string; setInviteEmail: (value: string) => void; @@ -25,6 +28,10 @@ export const ModalContent = ({ isMutating: boolean; isValidEmail: boolean; copyTextToClipboard: (text: string) => Promise; + onGenerateInviteLink: ( + expireTime: WorkspaceInviteLinkExpireTime + ) => Promise; + onRevokeInviteLink: () => Promise; }) => { const t = useI18n(); @@ -67,7 +74,11 @@ export const ModalContent = ({ isValidEmail={isValidEmail} /> ) : ( - + )} ); diff --git a/packages/frontend/core/src/components/hooks/affine/use-invite-member.ts b/packages/frontend/core/src/components/hooks/affine/use-invite-member.ts deleted file mode 100644 index b56e5969fa340..0000000000000 --- a/packages/frontend/core/src/components/hooks/affine/use-invite-member.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Permission } from '@affine/graphql'; -import { inviteByEmailMutation } from '@affine/graphql'; -import { useCallback } from 'react'; - -import { useMutation } from '../use-mutation'; -import { useMutateCloud } from './use-mutate-cloud'; - -export function useInviteMember(workspaceId: string) { - const { trigger, isMutating } = useMutation({ - mutation: inviteByEmailMutation, - }); - const mutate = useMutateCloud(); - return { - invite: useCallback( - async (email: string, permission: Permission, sendInviteMail = false) => { - const res = await trigger({ - workspaceId, - email, - permission, - sendInviteMail, - }); - await mutate(); - // return is successful - return res?.invite; - }, - [mutate, trigger, workspaceId] - ), - isMutating, - }; -} diff --git a/packages/frontend/core/src/components/hooks/affine/use-revoke-member-permission.ts b/packages/frontend/core/src/components/hooks/affine/use-revoke-member-permission.ts deleted file mode 100644 index 2dbd8444328c9..0000000000000 --- a/packages/frontend/core/src/components/hooks/affine/use-revoke-member-permission.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { revokeMemberPermissionMutation } from '@affine/graphql'; -import { useCallback } from 'react'; - -import { useMutation } from '../use-mutation'; -import { useMutateCloud } from './use-mutate-cloud'; - -export function useRevokeMemberPermission(workspaceId: string) { - const mutate = useMutateCloud(); - const { trigger } = useMutation({ - mutation: revokeMemberPermissionMutation, - }); - - return useCallback( - async (userId: string) => { - const res = await trigger({ - workspaceId, - userId, - }); - await mutate(); - return res; - }, - [mutate, trigger, workspaceId] - ); -} diff --git a/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx b/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx index 7e5a9f19d7700..0bf082c7f5495 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx +++ b/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx @@ -19,7 +19,13 @@ type TypeFormInfo = { const getTypeFormLink = (id: string, info: TypeFormInfo) => { const plans = Array.isArray(info.plan) ? info.plan : [info.plan]; const product_id = plans - .map(plan => (plan === SubscriptionPlan.AI ? 'ai' : 'cloud')) + .map(plan => + plan === SubscriptionPlan.AI + ? 'ai' + : plan === SubscriptionPlan.Team + ? 'team' + : 'cloud' + ) .join('-'); const product_price = info.recurring === SubscriptionRecurring.Monthly diff --git a/packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx b/packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx index 7191af4db0f6f..aeded8663e473 100644 --- a/packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx +++ b/packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx @@ -14,6 +14,7 @@ import { LocalWorkspaceIcon, NoNetworkIcon, SettingsIcon, + TeamWorkspaceIcon, UnsyncIcon, } from '@blocksuite/icons/rc'; import { @@ -241,6 +242,7 @@ export const WorkspaceCard = forwardRef< avatarSize?: number; disable?: boolean; hideCollaborationIcon?: boolean; + hideTeamWorkspaceIcon?: boolean; active?: boolean; onClickOpenSettings?: (workspaceMetadata: WorkspaceMetadata) => void; onClickEnableCloud?: (workspaceMetadata: WorkspaceMetadata) => void; @@ -257,6 +259,7 @@ export const WorkspaceCard = forwardRef< className, disable, hideCollaborationIcon, + hideTeamWorkspaceIcon, active, ...props }, @@ -326,12 +329,9 @@ export const WorkspaceCard = forwardRef< {hideCollaborationIcon || information?.isOwner ? null : ( )} - {/* {hideTeamWorkspaceIcon || !isTeamWorkspace ? null : ( - - - - - )} */} + {hideTeamWorkspaceIcon || !information?.isTeam ? null : ( + + )} {onClickOpenSettings && (
diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx index e0fe87bd70a79..a6676cf527042 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx @@ -92,6 +92,69 @@ export const CancelAction = ({ ); }; +export const CancelTeamAction = ({ + children, + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +} & PropsWithChildren) => { + const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); + const [isMutating, setIsMutating] = useState(false); + const subscription = useService(SubscriptionService).subscription; + const teamSubscription = useLiveData(subscription.team$); + const authService = useService(AuthService); + const downgradeNotify = useDowngradeNotify(); + + const downgrade = useAsyncCallback(async () => { + try { + const account = authService.session.account$.value; + const prevRecurring = teamSubscription?.recurring; + setIsMutating(true); + await subscription.cancelSubscription(idempotencyKey); + subscription.revalidate(); + await subscription.isRevalidating$.waitFor(v => !v); + // refresh idempotency key + setIdempotencyKey(nanoid()); + onOpenChange(false); + + if (account && prevRecurring) { + downgradeNotify( + getDowngradeQuestionnaireLink({ + email: account.email ?? '', + id: account.id, + name: account.info?.name ?? '', + plan: SubscriptionPlan.Team, + recurring: prevRecurring, + }) + ); + } + } finally { + setIsMutating(false); + } + }, [ + authService.session.account$.value, + teamSubscription, + subscription, + idempotencyKey, + onOpenChange, + downgradeNotify, + ]); + + return ( + <> + {children} + + + ); +}; + /** * Resume payment action with modal & request * @param param0 diff --git a/packages/frontend/core/src/desktop/dialogs/setting/setting-sidebar/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/setting-sidebar/index.tsx index fc6c052a93b27..c2369d5f9ee90 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/setting-sidebar/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/setting-sidebar/index.tsx @@ -280,8 +280,7 @@ const WorkspaceListItem = ({ onClick('workspace:preference'); }, [onClick]); - //TODO(@JimmFly): implement isTeamWorkspace - const isTeamWorkspace = true; + const showBilling = information?.isTeam && information?.isOwner; const subTabs = useMemo(() => { const subTabConfigs = [ { @@ -292,10 +291,14 @@ const WorkspaceListItem = ({ key: 'workspace:properties', title: 'com.affine.settings.workspace.properties', }, - isTeamWorkspace && { - key: 'workspace:billing', - title: 'com.affine.settings.workspace.billing', - }, + ...(showBilling + ? [ + { + key: 'workspace:billing' as SettingTab, + title: 'com.affine.settings.workspace.billing', + }, + ] + : []), ] satisfies { key: SettingTab; title: keyof ReturnType; @@ -317,7 +320,7 @@ const WorkspaceListItem = ({
); }); - }, [activeTab, isTeamWorkspace, 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 index 305fbbf09dd5c..d9d1073fcffc4 100644 --- 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 @@ -34,7 +34,10 @@ import { cssVar } from '@toeverything/theme'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useWorkspace } from '../../../../../components/hooks/use-workspace'; -import { ResumeAction } from '../../general-setting/plans/actions'; +import { + CancelTeamAction, + ResumeAction, +} from '../../general-setting/plans/actions'; import * as styles from './styles.css'; export const WorkspaceSettingBilling = ({ @@ -45,6 +48,8 @@ export const WorkspaceSettingBilling = ({ const t = useI18n(); const workspace = useWorkspace(workspaceMetadata); const workspaceInfo = useWorkspaceInfo(workspaceMetadata); + const subscriptionService = useService(SubscriptionService); + const team = useLiveData(subscriptionService.subscription.team$); const title = workspaceInfo?.name || 'untitled'; if (workspace === null) { @@ -53,6 +58,10 @@ export const WorkspaceSettingBilling = ({ return null; } + if (!team) { + return ; + } + return ( - + {team.end && team.canceledAt ? ( + + ) : null} @@ -75,12 +86,20 @@ export const WorkspaceSettingBilling = ({ ); }; -//TODO(@JimmFly): implement the logic for team plan const TeamCard = () => { const t = useI18n(); + const subscriptionService = useService(SubscriptionService); + const teamSubscription = useLiveData(subscriptionService.subscription.team$); + const teamPrices = useLiveData(subscriptionService.prices.teamPrice$); - const expiration = false; - const recurring = SubscriptionRecurring.Yearly; + 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) { @@ -101,15 +120,26 @@ const TeamCard = () => { return t[ 'com.affine.settings.workspace.billing.team-workspace.not-renewed' ]({ - date: new Date().toLocaleDateString(), + date: new Date(expiration).toLocaleDateString(), }); } - return t[ - 'com.affine.settings.workspace.billing.team-workspace.next-billing-date' - ]({ - date: new Date().toLocaleDateString(), - }); - }, [expiration, t]); + 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 (
@@ -124,27 +154,33 @@ const TeamCard = () => { } /> - + + +

- $?? + ${amount} - /{t['com.affine.payment.billing-setting.year']()} + / + {teamSubscription?.recurring === SubscriptionRecurring.Monthly + ? t['com.affine.payment.billing-setting.month']() + : t['com.affine.payment.billing-setting.year']()}

); }; -//TODO(@JimmFly): implement the logic for team plan -const ResumeSubscription = () => { +const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => { const t = useI18n(); const [open, setOpen] = useState(false); - const subscription = useService(SubscriptionService).subscription; const handleClick = useCallback(() => { setOpen(true); }, []); @@ -154,17 +190,12 @@ const ResumeSubscription = () => { name={t['com.affine.payment.billing-setting.expiration-date']()} desc={t['com.affine.payment.billing-setting.expiration-date.description']( { - expirationDate: new Date().toLocaleDateString(), + expirationDate: new Date(expirationDate).toLocaleDateString(), } )} > - @@ -177,22 +208,19 @@ const TypeFormLink = () => { const subscriptionService = useService(SubscriptionService); const authService = useService(AuthService); - //TODO(@JimmFly): implement the logic for Team plan - const pro = useLiveData(subscriptionService.subscription.pro$); - const ai = useLiveData(subscriptionService.subscription.ai$); + const team = useLiveData(subscriptionService.subscription.team$); const account = useLiveData(authService.session.account$); if (!account) return null; const plan = []; - if (pro) plan.push(SubscriptionPlan.Pro); - if (ai) plan.push(SubscriptionPlan.AI); + if (team) plan.push(SubscriptionPlan.Team); const link = getUpgradeQuestionnaireLink({ name: account.info?.name, id: account.id, email: account.email, - recurring: pro?.recurring ?? ai?.recurring ?? SubscriptionRecurring.Yearly, + recurring: team?.recurring ?? SubscriptionRecurring.Yearly, plan, }); @@ -216,7 +244,6 @@ const PaymentMethodUpdater = () => { const urlService = useService(UrlService); const t = useI18n(); - //TODO(@JimmFly): implement the logic for team plan const update = useAsyncCallback(async () => { await trigger(null, { onSuccess: data => { @@ -240,7 +267,6 @@ const PaymentMethodUpdater = () => { ); }; -//TODO(@JimmFly): implement the logic for team plan const BillingHistory = () => { const t = useI18n(); 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 index 5612b79aff730..94f4e2252dbde 100644 --- 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 @@ -6,39 +6,35 @@ import { MemberLimitModal, } from '@affine/component/member-components'; import { SettingRow } from '@affine/component/setting-components'; -import { useInviteMember } from '@affine/core/components/hooks/affine/use-invite-member'; -import { useRevokeMemberPermission } from '@affine/core/components/hooks/affine/use-revoke-member-permission'; -import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; 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 type { WorkspaceInviteLinkExpireTime } from '@affine/graphql'; import { UserFriendlyError } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; import { track } from '@affine/track'; -import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; +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'; -//TODO(@JimmFly): implement team feature -const isTeam = true; - 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 workspace = useService(WorkspaceService).workspace; - const permissionService = useService(WorkspacePermissionService); const isOwner = useLiveData(permissionService.permission.isOwner$); + const isAdmin = useLiveData(permissionService.permission.isAdmin$); useEffect(() => { permissionService.permission.revalidate(); }, [permissionService]); @@ -60,21 +56,34 @@ export const CloudWorkspaceMembersPanel = ({ : null; const t = useI18n(); - const { invite, isMutating } = useInviteMember(workspace.id); - const revokeMemberPermission = useRevokeMemberPermission(workspace.id); 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 }) => { - const success = await invite( + setIsMutating(true); + const success = await permissionService.permission.inviteMember( email, permission, - // send invite email true ); if (success) { @@ -84,8 +93,9 @@ export const CloudWorkspaceMembersPanel = ({ }); setOpen(false); } + setIsMutating(false); }, - [invite, t] + [permissionService.permission, t] ); const handleUpgradeConfirm = useCallback(() => { @@ -98,16 +108,6 @@ export const CloudWorkspaceMembersPanel = ({ }); }, [onChangeSettingState]); - const onRevoke = useAsyncCallback( - async (memberId: string) => { - const res = await revokeMemberPermission(memberId); - if (res?.revoke) { - notify.success({ title: t['Removed successfully']() }); - } - }, - [revokeMemberPermission, t] - ); - const desc = useMemo(() => { if (!workspaceQuota) return null; @@ -129,14 +129,14 @@ export const CloudWorkspaceMembersPanel = ({ ) : null} ); - }, [handleUpgradeConfirm, hasPaymentFeature, t, workspaceQuota]); + }, [handleUpgradeConfirm, hasPaymentFeature, isTeam, t, workspaceQuota]); const title = useMemo(() => { if (isTeam) { return `${t['Members']()} (${workspaceQuota?.memberCount})`; } return `${t['Members']()} (${workspaceQuota?.memberCount}/${workspaceQuota?.memberLimit})`; - }, [t, workspaceQuota?.memberCount, workspaceQuota?.memberLimit]); + }, [isTeam, t, workspaceQuota?.memberCount, workspaceQuota?.memberLimit]); if (workspaceQuota === null) { if (isLoading) { @@ -165,6 +165,8 @@ export const CloudWorkspaceMembersPanel = ({ onConfirm={onInviteConfirm} isMutating={isMutating} copyTextToClipboard={copyTextToClipboard} + onGenerateInviteLink={onGenerateInviteLink} + onRevokeInviteLink={onRevokeInviteLink} /> ) : isLimited ? (
- +
); 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 index 802af82c8a7d0..d841d6e179acd 100644 --- 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 @@ -1,4 +1,4 @@ -import { ConfirmModal, Input, notify } from '@affine/component'; +import { ConfirmModal, Input } from '@affine/component'; import type { Member } from '@affine/core/modules/permissions'; import { useI18n } from '@affine/i18n'; import { cssVar } from '@toeverything/theme'; @@ -12,12 +12,14 @@ export const ConfirmAssignModal = ({ 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(); @@ -29,14 +31,7 @@ export const ConfirmAssignModal = ({ onOpenChange={setOpen} title={t['com.affine.payment.member.team.assign.confirm.title']()} confirmText={t['com.affine.payment.member.team.assign.confirm.button']()} - onConfirm={() => { - 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, - }), - }); - }} + onConfirm={onConfirm} confirmButtonOptions={{ disabled: !isEquals, variant: 'error' }} >
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 index 13959b6bcaa9f..d41b06bf93710 100644 --- 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 @@ -1,6 +1,7 @@ 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'; @@ -15,12 +16,16 @@ export const MembersPanel = ({ onChangeSettingState: (settingState: SettingState) => void; }): ReactElement | null => { const workspace = useService(WorkspaceService).workspace; + const isTeam = useWorkspaceInfo(workspace.meta)?.isTeam; if (workspace.flavour === 'local') { return ; } 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 index d7b925c2f0a8a..b89fc485d2701 100644 --- 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 @@ -1,12 +1,17 @@ -import { Avatar, IconButton, Loading, Menu } from '@affine/component'; +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 } from '@affine/graphql'; -import { useI18n } from '@affine/i18n'; +import { + Permission, + UserFriendlyError, + WorkspaceMemberStatus, +} from '@affine/graphql'; +import { type I18nString, useI18n } from '@affine/i18n'; import { MoreVerticalIcon } from '@blocksuite/icons/rc'; import { useEnsureLiveData, @@ -24,10 +29,10 @@ import * as styles from './styles.css'; export const MemberList = ({ isOwner, - onRevoke, + isAdmin, }: { isOwner: boolean; - onRevoke: (memberId: string) => void; + isAdmin: boolean; }) => { const membersService = useService(WorkspaceMembersService); const memberCount = useLiveData(membersService.members.memberCount$); @@ -80,7 +85,7 @@ export const MemberList = ({ key={member.id} member={member} isOwner={isOwner} - onRevoke={onRevoke} + isAdmin={isAdmin} /> )) )} @@ -100,30 +105,52 @@ export const MemberList = ({ const MemberItem = ({ member, isOwner, + isAdmin, currentAccount, - onRevoke, }: { member: Member; + isAdmin: boolean; isOwner: boolean; currentAccount: AuthAccountInfo; - onRevoke: (memberId: string) => void; }) => { 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; - //TODO(@JimmFly): implement team feature - const underReview = member.accepted === false; - 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.accepted - ? member.permission === Permission.Owner - ? t.t('Workspace Owner') - : member.permission === Permission.Admin - ? t.t('Admin') - : t.t('Collaborator') - : underReview - ? t.t('Under-Review') - : t.t('Pending')} + {t.t(memberStatus)}
} > @@ -186,11 +206,33 @@ const MemberItem = ({ inputValue={inputValue} setInputValue={setInputValue} isEquals={isEquals} + onConfirm={confirmAssign} />
); }; +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, }: { 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 index 021cb489e82b6..0640e00bb0760 100644 --- 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 @@ -1,112 +1,211 @@ import { MenuItem, notify } from '@affine/component'; -import type { Member } from '@affine/core/modules/permissions'; +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, - onRevoke, + isOwner, + isAdmin, openAssignModal, }: { member: Member; - onRevoke: (memberId: string) => void; + isOwner: boolean; + isAdmin: boolean; openAssignModal: () => void; }) => { const t = useI18n(); + const permission = useService(WorkspacePermissionService).permission; const handleAssignOwner = useCallback(() => { openAssignModal(); }, [openAssignModal]); const handleRevoke = useCallback(() => { - onRevoke(member.id); - 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, - }), - }); - }, [onRevoke, member, t]); + 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(() => { - 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, - }), - }); - }, [member, t]); + 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(() => { - 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, - }), - }); - }, [member.email, member.id, member.name, t]); + 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(() => { - onRevoke(member.id); - 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, - }), - }); - }, [member, onRevoke, t]); + 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(() => { - 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, - }), - }); - }, [member, t]); + 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(() => { - 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, - }), - }); - }, [member, t]); + 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: true, + show: member.status === WorkspaceMemberStatus.UnderReview, }, { label: t['com.affine.payment.member.team.decline'](), onClick: handleDecline, - show: true, + show: + (isAdmin || isOwner) && + member.status === WorkspaceMemberStatus.UnderReview, }, { label: t['com.affine.payment.member.team.revoke'](), onClick: handleRevoke, - show: true, + show: + (isAdmin || isOwner) && + member.status === WorkspaceMemberStatus.Pending, }, { label: t['com.affine.payment.member.team.remove'](), onClick: handleRemove, - show: true, + show: + (isAdmin || isOwner) && + member.status === WorkspaceMemberStatus.Accepted, }, { label: t['com.affine.payment.member.team.change.collaborator'](), onClick: handleChangeToCollaborator, - show: true, + show: + (isAdmin || isOwner) && + member.status === WorkspaceMemberStatus.Accepted && + member.permission === Permission.Admin, }, { label: t['com.affine.payment.member.team.change.admin'](), onClick: handleChangeToAdmin, - show: true, + show: + isOwner && + member.permission === Permission.Write && + member.status === WorkspaceMemberStatus.Accepted, }, { label: t['com.affine.payment.member.team.assign'](), onClick: handleAssignOwner, - show: true, + show: isOwner && member.status === WorkspaceMemberStatus.Accepted, }, ]; }, [ @@ -117,6 +216,9 @@ export const MemberOptions = ({ handleDecline, handleRemove, handleRevoke, + isAdmin, + isOwner, + member, t, ]); 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 index ed31e4d79f7a5..b8153d7979852 100644 --- a/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx +++ b/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx @@ -6,10 +6,13 @@ import { 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'; @@ -111,10 +114,12 @@ export const Component = () => { open={openUpgrade} onOpenChange={setOpenUpgrade} workspaceName={name} + workspaceId={selectedWorkspace?.id ?? ''} /> - @@ -126,9 +131,11 @@ const UpgradeDialog = ({ open, onOpenChange, workspaceName, + workspaceId, }: { open: boolean; workspaceName: string; + workspaceId: string; onOpenChange: (open: boolean) => void; }) => { const t = useI18n(); @@ -159,6 +166,11 @@ const UpgradeDialog = ({ recurring={SubscriptionRecurring.Monthly} plan={SubscriptionPlan.Team} onCheckoutSuccess={onClose} + checkoutInput={{ + args: { + workspaceId, + }, + }} > {t['com.affine.payment.upgrade']()} @@ -177,7 +189,6 @@ const WorkspaceSelector = ({ }) => { const t = useI18n(); - // TODO(@JimmFly): filter out team workspaces and not owned by the user const cloudWorkspaces = useMemo( () => metas.filter( @@ -197,17 +208,11 @@ const WorkspaceSelector = ({
{cloudWorkspaces.length > 0 && cloudWorkspaces.map(workspace => ( - handleSelect(workspace)} - > - - + meta={workspace} + onSelect={handleSelect} + /> ))} {cloudWorkspaces.length > 0 && } 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 ( @@ -255,21 +308,22 @@ const CreateAndUpgradeDialog = ({ placeholder={t[ 'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.placeholder' ]()} + value={name} + onChange={setName} />
- {t[ 'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.confirm' ]()} - +
); 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 73b94e0064d42..ee2783a7fcb3d 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 b70ef49b26282..ea6d993dacb49 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/permissions/entities/permission.ts b/packages/frontend/core/src/modules/permissions/entities/permission.ts index 19c8bc8c662d4..0b36d5ce99338 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,7 @@ const logger = new DebugLogger('affine:workspace-permission'); export class WorkspacePermission extends Entity { isOwner$ = new LiveData(null); + isAdmin$ = new LiveData(null); isLoading$ = new LiveData(false); error$ = new LiveData(null); @@ -34,12 +38,17 @@ export class WorkspacePermission extends Entity { exhaustMap(() => { return fromPromise(async signal => { if (this.workspaceService.workspace.flavour !== 'local') { - return await this.store.fetchIsOwner( + const isOwner = await this.store.fetchIsOwner( this.workspaceService.workspace.id, signal ); + const isAdmin = await this.store.fetchIsAdmin( + this.workspaceService.workspace.id, + signal + ); + return { isOwner, isAdmin }; } else { - return true; + return { isOwner: true, isAdmin: false }; } }).pipe( backoffRetry({ @@ -49,7 +58,11 @@ export class WorkspacePermission extends Entity { backoffRetry({ when: isBackendError, }), - mapInto(this.isOwner$), + mergeMap(({ isOwner, isAdmin }) => { + this.isAdmin$.next(isAdmin); + this.isOwner$.next(isOwner); + return EMPTY; + }), catchErrorInto(this.error$, error => { logger.error('Failed to fetch isOwner', error); }), @@ -58,4 +71,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 6a745f8f2e0a6..19796f13c0723 100644 --- a/packages/frontend/core/src/modules/permissions/stores/permission.ts +++ b/packages/frontend/core/src/modules/permissions/stores/permission.ts @@ -1,5 +1,19 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud'; -import { getIsOwnerQuery, leaveWorkspaceMutation } from '@affine/graphql'; +import { + acceptInviteByInviteIdMutation, + approveWorkspaceTeamMemberMutation, + getIsAdminQuery, + getIsOwnerQuery, + grantWorkspaceTeamMemberMutation, + inviteByEmailMutation, + inviteByEmailsMutation, + inviteLinkMutation, + leaveWorkspaceMutation, + type Permission, + revokeInviteLinkMutation, + revokeMemberPermissionMutation, + type WorkspaceInviteLinkExpireTime, +} from '@affine/graphql'; import { Store } from '@toeverything/infra'; export class WorkspacePermissionStore extends Store { @@ -22,6 +36,163 @@ export class WorkspacePermissionStore extends Store { return isOwner.isOwner; } + async fetchIsAdmin(workspaceId: string, signal?: AbortSignal) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const isAdmin = await this.workspaceServerService.server.gql({ + query: getIsAdminQuery, + variables: { + workspaceId, + }, + context: { signal }, + }); + + return isAdmin.isAdmin; + } + + 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; + } + /** * @param workspaceName for send email */ 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 fe5046736df11..0504e61ce9c52 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,10 @@ import { DebugLogger } from '@affine/debug'; import { createWorkspaceMutation, deleteWorkspaceMutation, + FeatureType, + getIsAdminQuery, getIsOwnerQuery, + getWorkspaceFeaturesQuery, getWorkspacesQuery, } from '@affine/graphql'; import { DocCollection } from '@blocksuite/affine/store'; @@ -212,9 +215,11 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { } ) ); + error$ = new LiveData(null); isRevalidating$ = new LiveData(false); workspaces$ = new LiveData([]); + async getWorkspaceProfile( id: string, signal?: AbortSignal @@ -229,10 +234,13 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { const cloudData = await cloudStorage.pull(id); const isOwner = await this.getIsOwner(id, signal); + const isAdmin = await this.getIsAdmin(id, signal); + const isTeam = await this.getIsTeam(id, signal); if (!cloudData && !localData) { return { isOwner, + isTeam, }; } @@ -248,6 +256,8 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { name: bs.meta.name, avatar: bs.meta.avatar, isOwner, + isAdmin, + isTeam, }; } async getWorkspaceBlob(id: string, blob: string): Promise { @@ -312,6 +322,29 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { ).isOwner; } + private async getIsAdmin(workspaceId: string, signal?: AbortSignal) { + return ( + await this.graphqlService.gql({ + query: getIsAdminQuery, + variables: { + workspaceId, + }, + context: { signal }, + }) + ).isAdmin; + } + + private async getIsTeam(workspaceId: string, signal?: AbortSignal) { + return ( + await this.graphqlService.gql({ + query: getWorkspaceFeaturesQuery, + variables: { + workspaceId, + }, + context: { signal }, + }) + ).workspace.features.includes(FeatureType.TeamWorkspace); + } private waitForLoaded() { return this.isRevalidating$.waitFor(loading => !loading); } 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 0000000000000..8e9199ab9b479 --- /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/index.ts b/packages/frontend/graphql/src/graphql/index.ts index d7ff05c9ccd13..39c1269090db6 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', @@ -1307,6 +1318,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 0000000000000..a21e22e22e73a --- /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 ab29e0c7c4fc9..9954402e4b6d6 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']; } @@ -1735,6 +1741,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']; }>; @@ -2539,6 +2551,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']; @@ -2665,6 +2693,11 @@ export type Queries = variables: GetInviteInfoQueryVariables; response: GetInviteInfoQuery; } + | { + name: 'getIsAdminQuery'; + variables: GetIsAdminQueryVariables; + response: GetIsAdminQuery; + } | { name: 'getIsOwnerQuery'; variables: GetIsOwnerQueryVariables; @@ -3042,6 +3075,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 cb2fe929131af..5d04acbcfe25e 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,24 +1,24 @@ { - "ar": 71, + "ar": 70, "ca": 5, "da": 5, "de": 26, "el-GR": 0, "en": 100, "es-AR": 13, - "es-CL": 15, + "es-CL": 14, "es": 13, - "fr": 63, + "fr": 62, "hi": 2, "it-IT": 1, "it": 1, - "ja": 94, + "ja": 93, "ko": 74, "pl": 0, - "pt-BR": 81, - "ru": 69, + "pt-BR": 80, + "ru": 68, "sv-SE": 4, "ur": 2, - "zh-Hans": 94, - "zh-Hant": 94 + "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 73d507108d4c5..9d409a4f7077b 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -77,6 +77,7 @@ "Pending": "Pending", "Collaborator": "Collaborator", "Under-Review": "Under Review", + "Need-More-Seats": "Need More Seats", "Admin": "Admin", "Publish": "Publish", "Published to Web": "Published to web",