From e68bdbde3ed5919931058549233c771e5af7f4b8 Mon Sep 17 00:00:00 2001 From: JimmFly Date: Fri, 7 Feb 2025 03:35:24 +0000 Subject: [PATCH] feat(core): add self host team plan (#9569) --- .../server/src/plugins/license/service.ts | 15 +- .../server/src/plugins/payment/resolver.ts | 4 +- .../member-components/invite-modal.tsx | 2 +- .../quota-reached-modal/cloud-quota-modal.tsx | 34 +-- .../hooks/affine/use-subscription-notify.tsx | 14 +- .../general-setting/plans/plan-card.tsx | 1 + .../setting/workspace-setting/index.tsx | 28 +- .../workspace-setting/license/index.tsx | 139 +++++++++ .../license/self-host-team-card.tsx | 280 +++++++++++++++++ .../license/self-host-team-plan.css.ts | 62 ++++ .../license/self-host-team-plan.tsx | 78 +++++ .../workspace-setting/license/styles.css.ts | 74 +++++ .../workspace-setting/members/member-list.tsx | 2 +- .../members/member-option.tsx | 2 +- .../src/desktop/pages/subscribe/index.tsx | 3 + .../upgrade-success/self-host-team/index.tsx | 124 ++++++++ .../self-host-team/styles.css.ts | 37 +++ packages/frontend/core/src/desktop/router.tsx | 4 + .../frontend/core/src/modules/cloud/index.ts | 14 +- .../services/selfhost-generate-license.ts | 54 ++++ .../cloud/services/selfhost-license.ts | 72 +++++ .../cloud/stores/selfhost-generate-license.ts | 24 ++ .../modules/cloud/stores/selfhost-license.ts | 66 ++++ .../permissions/entities/permission.ts | 5 +- .../graphql/src/graphql/activate-license.gql | 6 + .../create-self-host-customer-portal.gql | 3 + .../src/graphql/deactivate-license.gql | 3 + .../src/graphql/generate-license-key.gql | 3 + .../graphql/src/graphql/get-license.gql | 11 + .../frontend/graphql/src/graphql/index.ts | 66 ++++ packages/frontend/graphql/src/schema.ts | 289 +++++++++++++++++- .../i18n/src/i18n-completenesses.json | 36 +-- packages/frontend/i18n/src/i18n.gen.ts | 177 +++++++++++ packages/frontend/i18n/src/resources/en.json | 38 +++ .../e2e/local-first-delete-page.spec.ts | 2 +- tools/@types/build-config/__all.d.ts | 1 + tools/utils/src/build-config.ts | 1 + 37 files changed, 1702 insertions(+), 72 deletions(-) create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/index.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-card.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-plan.css.ts create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-plan.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/styles.css.ts create mode 100644 packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/index.tsx create mode 100644 packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/styles.css.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/selfhost-generate-license.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/selfhost-license.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/selfhost-generate-license.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/selfhost-license.ts create mode 100644 packages/frontend/graphql/src/graphql/activate-license.gql create mode 100644 packages/frontend/graphql/src/graphql/create-self-host-customer-portal.gql create mode 100644 packages/frontend/graphql/src/graphql/deactivate-license.gql create mode 100644 packages/frontend/graphql/src/graphql/generate-license-key.gql create mode 100644 packages/frontend/graphql/src/graphql/get-license.gql diff --git a/packages/backend/server/src/plugins/license/service.ts b/packages/backend/server/src/plugins/license/service.ts index 7621233a24..59ec1a2f2a 100644 --- a/packages/backend/server/src/plugins/license/service.ts +++ b/packages/backend/server/src/plugins/license/service.ts @@ -105,7 +105,7 @@ export class LicenseService implements OnModuleInit { throw new WorkspaceLicenseAlreadyExists(); } - const data = await this.fetch( + const data = await this.fetchAffinePro( `/api/team/licenses/${licenseKey}/activate`, { method: 'POST', @@ -155,7 +155,7 @@ export class LicenseService implements OnModuleInit { throw new LicenseNotFound(); } - await this.fetch(`/api/team/licenses/${license.key}/deactivate`, { + await this.fetchAffinePro(`/api/team/licenses/${license.key}/deactivate`, { method: 'POST', }); @@ -170,10 +170,11 @@ export class LicenseService implements OnModuleInit { plan: SubscriptionPlan.SelfHostedTeam, recurring: SubscriptionRecurring.Monthly, }); + return true; } async updateTeamRecurring(key: string, recurring: SubscriptionRecurring) { - await this.fetch(`/api/team/licenses/${key}/recurring`, { + await this.fetchAffinePro(`/api/team/licenses/${key}/recurring`, { method: 'POST', body: JSON.stringify({ recurring, @@ -192,7 +193,7 @@ export class LicenseService implements OnModuleInit { throw new LicenseNotFound(); } - return this.fetch<{ url: string }>( + return this.fetchAffinePro<{ url: string }>( `/api/team/licenses/${license.key}/create-customer-portal`, { method: 'POST', @@ -218,7 +219,7 @@ export class LicenseService implements OnModuleInit { return; } - await this.fetch(`/api/team/licenses/${license.key}/seats`, { + await this.fetchAffinePro(`/api/team/licenses/${license.key}/seats`, { method: 'POST', body: JSON.stringify({ quantity: count, @@ -276,7 +277,7 @@ export class LicenseService implements OnModuleInit { private async revalidateLicense(license: InstalledLicense) { try { - const res = await this.fetch( + const res = await this.fetchAffinePro( `/api/team/licenses/${license.key}/health`, { headers: { @@ -325,7 +326,7 @@ export class LicenseService implements OnModuleInit { } } - private async fetch( + private async fetchAffinePro( path: string, init?: RequestInit ): Promise { diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index 2153ceaf06..2d6d151405 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -196,7 +196,7 @@ class CreateCheckoutSessionInput implements z.infer { idempotencyKey?: string; @Field(() => GraphQLJSONObject, { nullable: true }) - args!: { workspaceId?: string; quantity?: number }; + args!: { workspaceId?: string; quantity?: number } | null; } @Resolver(() => SubscriptionType) @@ -278,7 +278,7 @@ export class SubscriptionResolver { if (input.plan === SubscriptionPlan.SelfHostedTeam) { session = await this.service.checkout(input, { plan: input.plan as any, - quantity: input.args.quantity ?? 10, + quantity: input.args?.quantity ?? 10, }); } else { if (!user) { diff --git a/packages/frontend/component/src/components/member-components/invite-modal.tsx b/packages/frontend/component/src/components/member-components/invite-modal.tsx index 8b3366fe21..96c274f761 100644 --- a/packages/frontend/component/src/components/member-components/invite-modal.tsx +++ b/packages/frontend/component/src/components/member-components/invite-modal.tsx @@ -21,7 +21,7 @@ export const InviteModal = ({ }: InviteModalProps) => { const t = useI18n(); const [inviteEmail, setInviteEmail] = useState(''); - const [permission] = useState(Permission.Write); + const [permission] = useState(Permission.Collaborator); const [isValidEmail, setIsValidEmail] = useState(true); const handleConfirm = useCallback(() => { diff --git a/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx index fe3005e60b..8dd1149bfd 100644 --- a/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx +++ b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx @@ -1,6 +1,5 @@ import { ConfirmModal } from '@affine/component/ui/modal'; import { openQuotaModalAtom } from '@affine/core/components/atoms'; -import { UserQuotaService } from '@affine/core/modules/cloud'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { WorkspaceQuotaService } from '@affine/core/modules/quota'; @@ -30,18 +29,6 @@ export const CloudQuotaModal = () => { permissionService.permission.revalidate(); }, [permissionService]); - const quotaService = useService(UserQuotaService); - const userQuota = useLiveData( - quotaService.quota.quota$.map(q => - q - ? { - name: q.humanReadable.name, - blobLimit: q.humanReadable.blobLimit, - } - : null - ) - ); - const workspaceDialogService = useService(WorkspaceDialogService); const handleUpgradeConfirm = useCallback(() => { workspaceDialogService.open('setting', { @@ -54,18 +41,19 @@ export const CloudQuotaModal = () => { }, [workspaceDialogService, setOpen]); const description = useMemo(() => { - if (userQuota && isOwner) { - return ; - } - if (workspaceQuota) { - return t['com.affine.payment.blob-limit.description.member']({ - quota: workspaceQuota.humanReadable.blobLimit, - }); - } else { - // loading + if (!workspaceQuota) { return null; } - }, [userQuota, isOwner, workspaceQuota, t]); + if (isOwner) { + return ( + + ); + } + + return t['com.affine.payment.blob-limit.description.member']({ + quota: workspaceQuota.humanReadable.blobLimit, + }); + }, [isOwner, workspaceQuota, t]); const onAbortLargeBlob = useAsyncCallback( async (byteSize: number) => { 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 9c48d62df2..a8cc253eb2 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 @@ -49,15 +49,21 @@ export const generateSubscriptionCallbackLink = ( recurring: SubscriptionRecurring, workspaceId?: string ) => { - if (account === null) { - throw new Error('Account is required'); - } const baseUrl = plan === SubscriptionPlan.AI ? '/ai-upgrade-success' : plan === SubscriptionPlan.Team ? '/upgrade-success/team' - : '/upgrade-success'; + : plan === SubscriptionPlan.SelfHostedTeam + ? '/upgrade-success/self-hosted-team' + : '/upgrade-success'; + + if (plan === SubscriptionPlan.SelfHostedTeam) { + return baseUrl; + } + if (account === null) { + throw new Error('Account is required'); + } let name = account?.info?.name ?? ''; if (name.includes(separator)) { diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx index ae65910b0f..9774de1f45 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx @@ -130,6 +130,7 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => { // if contact => 'Contact Sales' // if not signed in: // if free => 'Sign up free' + // if team => 'Upgrade' // else => 'Buy Pro' // else // if team => 'Start 14-day free trial' 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 af722dafdc..e48500b331 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 @@ -1,6 +1,8 @@ import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info'; +import { ServerService } from '@affine/core/modules/cloud'; import type { SettingTab } from '@affine/core/modules/dialogs/constant'; import { WorkspaceService } from '@affine/core/modules/workspace'; +import { ServerDeploymentType } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; import { CollaborationIcon, @@ -9,11 +11,12 @@ import { SaveIcon, SettingsIcon, } from '@blocksuite/icons/rc'; -import { useService } from '@toeverything/infra'; +import { useLiveData, useService } from '@toeverything/infra'; import { useMemo } from 'react'; import type { SettingSidebarItem, SettingState } from '../types'; import { WorkspaceSettingBilling } from './billing'; +import { WorkspaceSettingLicense } from './license'; import { MembersPanel } from './members'; import { WorkspaceSettingDetail } from './preference'; import { WorkspaceSettingProperties } from './properties'; @@ -44,6 +47,8 @@ export const WorkspaceSetting = ({ return ; case 'workspace:storage': return ; + case 'workspace:license': + return ; default: return null; } @@ -52,10 +57,19 @@ export const WorkspaceSetting = ({ export const useWorkspaceSettingList = (): SettingSidebarItem[] => { const workspaceService = useService(WorkspaceService); const information = useWorkspaceInfo(workspaceService.workspace); + const serverService = useService(ServerService); + + const isSelfhosted = useLiveData( + serverService.server.config$.selector( + c => c.type === ServerDeploymentType.Selfhosted + ) + ); const t = useI18n(); - const showBilling = information?.isTeam && information?.isOwner; + const showBilling = + !isSelfhosted && information?.isTeam && information?.isOwner; + const showLicense = information?.isOwner && isSelfhosted; const items = useMemo(() => { return [ { @@ -88,10 +102,14 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => { icon: , testId: 'workspace-setting:billing', }, - - // todo(@pengx17): add selfhost's team license + showLicense && { + key: 'workspace:license' as SettingTab, + title: t['com.affine.settings.workspace.license'](), + icon: , + testId: 'workspace-setting:license', + }, ].filter((item): item is SettingSidebarItem => !!item); - }, [showBilling, t]); + }, [showBilling, showLicense, t]); return items; }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/index.tsx new file mode 100644 index 0000000000..d72fb70aac --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/index.tsx @@ -0,0 +1,139 @@ +import { Button } from '@affine/component'; +import { + SettingHeader, + SettingRow, +} 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, + WorkspaceSubscriptionService, +} from '@affine/core/modules/cloud'; +import { WorkspacePermissionService } from '@affine/core/modules/permissions'; +import { UrlService } from '@affine/core/modules/url'; +import { WorkspaceService } from '@affine/core/modules/workspace'; +import { + createSelfhostCustomerPortalMutation, + SubscriptionPlan, + SubscriptionRecurring, +} from '@affine/graphql'; +import { useI18n } from '@affine/i18n'; +import { FrameworkScope, useLiveData, useService } from '@toeverything/infra'; + +import { EnableCloudPanel } from '../preference/enable-cloud'; +import { SelfHostTeamCard } from './self-host-team-card'; +import { SelfHostTeamPlan } from './self-host-team-plan'; +import * as styles from './styles.css'; + +export const WorkspaceSettingLicense = ({ + onCloseSetting, +}: { + onCloseSetting: () => void; +}) => { + const workspace = useService(WorkspaceService).workspace; + + const t = useI18n(); + + if (workspace === null) { + return null; + } + + return ( + + + + {workspace.flavour === 'local' ? ( + + ) : ( + <> + + + + + )} + + ); +}; + +const TypeFormLink = () => { + const t = useI18n(); + const workspaceSubscriptionService = useService(WorkspaceSubscriptionService); + const authService = useService(AuthService); + + const workspaceSubscription = useLiveData( + workspaceSubscriptionService.subscription.subscription$ + ); + const account = useLiveData(authService.session.account$); + + if (!account) return null; + + const link = getUpgradeQuestionnaireLink({ + name: account.info?.name, + id: account.id, + email: account.email, + recurring: workspaceSubscription?.recurring ?? SubscriptionRecurring.Yearly, + plan: SubscriptionPlan.SelfHostedTeam, + }); + + return ( + + + + + + ); +}; + +const PaymentMethodUpdater = () => { + const workspace = useService(WorkspaceService).workspace; + + const permission = useService(WorkspacePermissionService).permission; + const isTeam = useLiveData(permission.isTeam$); + + const { isMutating, trigger } = useMutation({ + mutation: createSelfhostCustomerPortalMutation, + }); + const urlService = useService(UrlService); + const t = useI18n(); + + const update = useAsyncCallback(async () => { + await trigger( + { + workspaceId: workspace.id, + }, + { + onSuccess: data => { + urlService.openPopupWindow( + data.createSelfhostWorkspaceCustomerPortal + ); + }, + } + ); + }, [trigger, urlService, workspace.id]); + + if (!isTeam) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-card.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-card.tsx new file mode 100644 index 0000000000..290eb4d08a --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-card.tsx @@ -0,0 +1,280 @@ +import { Button, ConfirmModal, Input, notify } from '@affine/component'; +import { SettingRow } from '@affine/component/setting-components'; +import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud'; +import { + SelfhostLicenseService, + WorkspaceSubscriptionService, +} from '@affine/core/modules/cloud'; +import { WorkspacePermissionService } from '@affine/core/modules/permissions'; +import { WorkspaceQuotaService } from '@affine/core/modules/quota'; +import { WorkspaceService } from '@affine/core/modules/workspace'; +import { UserFriendlyError } from '@affine/graphql'; +import { Trans, useI18n } from '@affine/i18n'; +import { useLiveData, useService } from '@toeverything/infra'; +import clsx from 'clsx'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import * as styles from './styles.css'; + +export const SelfHostTeamCard = () => { + const t = useI18n(); + + const workspace = useService(WorkspaceService).workspace; + const workspaceQuotaService = useService(WorkspaceQuotaService); + const workspaceSubscriptionService = useService(WorkspaceSubscriptionService); + + const permission = useService(WorkspacePermissionService).permission; + const isTeam = useLiveData(permission.isTeam$); + const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$); + const confirmEnableCloud = useEnableCloud(); + const isLocalWorkspace = workspace.flavour === 'local'; + + const [openModal, setOpenModal] = useState(false); + const [loading, setLoading] = useState(false); + const selfhostLicenseService = useService(SelfhostLicenseService); + const license = useLiveData(selfhostLicenseService.license$); + + useEffect(() => { + permission.revalidate(); + workspaceQuotaService.quota.revalidate(); + workspaceSubscriptionService.subscription.revalidate(); + selfhostLicenseService.revalidate(); + }, [ + permission, + selfhostLicenseService, + workspaceQuotaService, + workspaceSubscriptionService, + ]); + + const description = useMemo(() => { + if (isTeam) { + return t[ + 'com.affine.settings.workspace.license.self-host-team.team.description' + ]({ + expirationDate: new Date(license?.expiredAt || 0).toLocaleDateString(), + leftDays: Math.floor( + (new Date(license?.expiredAt || 0).getTime() - Date.now()) / + (1000 * 60 * 60 * 24) + ).toLocaleString(), + }); + } + return t[ + 'com.affine.settings.workspace.license.self-host-team.free.description' + ]({ + memberCount: workspaceQuota?.humanReadable.memberLimit || '10', + }); + }, [isTeam, license, t, workspaceQuota]); + const handleClick = useCallback(() => { + if (isLocalWorkspace) { + confirmEnableCloud(workspace); + return; + } + setOpenModal(true); + }, [confirmEnableCloud, isLocalWorkspace, workspace]); + + const onActivate = useCallback( + (license: string) => { + setLoading(true); + selfhostLicenseService + .activateLicense(workspace.id, license) + .then(() => { + setLoading(false); + setOpenModal(false); + permission.revalidate(); + selfhostLicenseService.revalidate(); + notify.success({ + title: + t['com.affine.settings.workspace.license.activate-success'](), + }); + }) + .catch(e => { + setLoading(false); + + console.error(e); + const error = UserFriendlyError.fromAnyError(e); + + notify.error({ + title: error.name, + message: error.message, + }); + }); + }, + [permission, selfhostLicenseService, t, workspace.id] + ); + + const onDeactivate = useCallback(() => { + setLoading(true); + selfhostLicenseService + .deactivateLicense(workspace.id) + .then(() => { + setLoading(false); + setOpenModal(false); + permission.revalidate(); + notify.success({ + title: + t['com.affine.settings.workspace.license.deactivate-success'](), + }); + }) + .catch(e => { + setLoading(false); + + console.error(e); + const error = UserFriendlyError.fromAnyError(e); + + notify.error({ + title: error.name, + message: error.message, + }); + }); + }, [permission, selfhostLicenseService, t, workspace.id]); + + const handleConfirm = useCallback( + (license: string) => { + if (isTeam) { + onDeactivate(); + } else { + onActivate(license); + } + }, + [isTeam, onActivate, onDeactivate] + ); + + return ( + <> +
+
+
+ +
+
+ + {t[ + 'com.affine.settings.workspace.license.self-host-team.seats' + ]()} + + + {workspaceQuota?.memberCount} + {isTeam ? '' : `/${workspaceQuota?.memberLimit}`} + +
+
+
+ +
+
+ + + ); +}; + +const ActionModal = ({ + open, + onOpenChange, + isTeam, + onConfirm, + loading, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + isTeam: boolean; + loading: boolean; + onConfirm: (key: string) => void; +}) => { + const t = useI18n(); + const [key, setKey] = useState(''); + + const handleConfirm = useCallback(() => { + onConfirm(key); + setKey(''); + }, [key, onConfirm]); + + const handleOpenChange = useCallback( + (open: boolean) => { + setKey(''); + onOpenChange(open); + }, + [onOpenChange] + ); + + return ( + + {isTeam ? null : ( + <> + + + + If you encounter any issues, please contact our + + customer support + + . + + + + )} + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-plan.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-plan.css.ts new file mode 100644 index 0000000000..a296464f6a --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-plan.css.ts @@ -0,0 +1,62 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const pricingPlan = style({ + display: 'flex', + flexDirection: 'column', + padding: '12px', + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + backgroundColor: cssVarV2('layer/white'), + borderRadius: '8px', + gap: '18px', + marginBottom: '24px', +}); + +export const planCardHeader = style({ + display: 'flex', + padding: '0px 6px', + gap: '4px', + flexDirection: 'column', + fontSize: cssVar('fontBase'), +}); + +export const planCardTitle = style({ + fontWeight: 600, + lineHeight: '24px', +}); + +export const planCardSubtitle = style({ + color: cssVarV2('text/secondary'), + fontWeight: 400, + lineHeight: '24px', +}); + +export const benefitItems = style({ + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '12px', + gridAutoRows: 'min-content', + '@media': { + 'screen and (max-width: 768px)': { + gridTemplateColumns: '1fr', + }, + }, +}); + +export const benefitItem = style({ + display: 'flex', + gap: '8px', + fontSize: cssVar('fontXs'), +}); + +export const doneIconStyle = style({ + color: cssVarV2('button/primary'), + width: '20px', + height: '20px', +}); + +export const leanMoreButtonContainer = style({ + display: 'flex', + justifyContent: 'flex-end', +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-plan.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-plan.tsx new file mode 100644 index 0000000000..60db33b3be --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/self-host-team-plan.tsx @@ -0,0 +1,78 @@ +import { Button } from '@affine/component'; +import { WorkspacePermissionService } from '@affine/core/modules/permissions'; +import { useI18n } from '@affine/i18n'; +import { DoneIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useCallback } from 'react'; + +import * as styles from './self-host-team-plan.css'; + +const initialQuota = '100 GB'; +const quotaPerSeat = '20 GB'; +const maxFileSize = '500 MB'; + +export const SelfHostTeamPlan = () => { + const t = useI18n(); + + const permission = useService(WorkspacePermissionService).permission; + const isTeam = useLiveData(permission.isTeam$); + + const handleClick = useCallback(() => { + window.open(BUILD_CONFIG.pricingUrl, '_blank'); + }, []); + + if (isTeam) { + return null; + } + + return ( +
+
+
+ {t['com.affine.settings.workspace.license.benefit.team.title']()} +
+
+ {t['com.affine.settings.workspace.license.benefit.team.subtitle']()} +
+
+ +
+
+ + {t['com.affine.settings.workspace.license.benefit.team.g1']()} +
+
+ + {t['com.affine.settings.workspace.license.benefit.team.g2']({ + initialQuota, + quotaPerSeat, + })} +
+
+ + {t['com.affine.settings.workspace.license.benefit.team.g3']({ + quota: maxFileSize, + })} +
+
+ + {t['com.affine.settings.workspace.license.benefit.team.g4']()} +
+
+ + {t['com.affine.settings.workspace.license.benefit.team.g5']()} +
+
+ + {t['com.affine.settings.workspace.license.benefit.team.g6']()} +
+
+ +
+ +
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/styles.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/styles.css.ts new file mode 100644 index 0000000000..2b74f3e4a2 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/license/styles.css.ts @@ -0,0 +1,74 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const paymentMethod = style({ + marginTop: '24px', +}); + +export const planCard = style({ + display: 'flex', + flexDirection: 'column', + padding: '12px', + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + backgroundColor: cssVarV2('layer/white'), + borderRadius: '8px', +}); + +export const container = style({ + display: 'flex', + justifyContent: 'space-between', +}); + +export const currentPlan = style({ + flex: '1 0 0', +}); + +export const planPrice = style({ + fontSize: cssVar('fontH6'), + fontWeight: 600, + display: 'flex', + gap: '4px', + margin: '0px 4px', + justifyContent: 'center', + height: '100%', + alignItems: 'center', + selectors: { + '&.hidden': { + visibility: 'hidden', + }, + }, +}); + +export const buttonContainer = style({ + display: 'flex', + justifyContent: 'flex-end', + marginTop: '12px', + selectors: { + '&.left': { + justifyContent: 'flex-start', + }, + }, +}); +export const activeButton = style({ + marginTop: '8px', +}); + +export const seat = style({ + fontSize: cssVar('fontXs'), + fontWeight: 400, +}); + +export const activateModalContent = style({ + padding: '0', + display: 'flex', + flexDirection: 'column', + gap: '12px', + marginTop: '12px', + marginBottom: '20px', +}); + +export const tips = style({ + color: cssVarV2('text/secondary'), + fontSize: cssVar('fontSm'), +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx index 7d037b40ba..f5ada09ef9 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-list.tsx @@ -260,7 +260,7 @@ const getMemberStatus = (member: Member): I18nString => { return 'Workspace Owner'; case Permission.Admin: return 'Admin'; - case Permission.Write: + case Permission.Collaborator: return 'Collaborator'; default: return 'Member'; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx index 12a366bdf5..8afce69a7a 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/members/member-option.tsx @@ -141,7 +141,7 @@ export const MemberOptions = ({ }, [member, membersService, t]); const handleChangeToCollaborator = useCallback(() => { membersService - .adjustMemberPermission(member.id, Permission.Write) + .adjustMemberPermission(member.id, Permission.Collaborator) .then(result => { if (result) { notify.success({ diff --git a/packages/frontend/core/src/desktop/pages/subscribe/index.tsx b/packages/frontend/core/src/desktop/pages/subscribe/index.tsx index d41ddd60b1..8a0008541e 100644 --- a/packages/frontend/core/src/desktop/pages/subscribe/index.tsx +++ b/packages/frontend/core/src/desktop/pages/subscribe/index.tsx @@ -36,6 +36,8 @@ const products = { believer: 'pro_lifetime', team: 'team_yearly', 'monthly-team': 'team_monthly', + 'yearly-selfhost-team': 'selfhost-team_yearly', + 'monthly-selfhost-team': 'selfhost-team_monthly', 'oneyear-ai': 'ai_yearly_onetime', 'oneyear-pro': 'pro_yearly_onetime', 'onemonth-pro': 'pro_monthly_onetime', @@ -45,6 +47,7 @@ const allowedPlan = { ai: SubscriptionPlan.AI, pro: SubscriptionPlan.Pro, team: SubscriptionPlan.Team, + 'selfhost-team': SubscriptionPlan.SelfHostedTeam, }; const allowedRecurring = { monthly: SubscriptionRecurring.Monthly, diff --git a/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/index.tsx b/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/index.tsx new file mode 100644 index 0000000000..8c8c9aacaf --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/index.tsx @@ -0,0 +1,124 @@ +import { Button, IconButton, Loading, notify } from '@affine/component'; +import { AuthPageContainer } from '@affine/component/auth-components'; +import { SelfhostGenerateLicenseService } from '@affine/core/modules/cloud'; +import { OpenInAppService } from '@affine/core/modules/open-in-app'; +import { copyTextToClipboard } from '@affine/core/utils/clipboard'; +import { Trans, useI18n } from '@affine/i18n'; +import { CopyIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useCallback, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { PageNotFound } from '../../404'; +import * as styles from './styles.css'; + +/** + * /upgrade-success/self-hosted-team page + * + * only on web + */ +export const Component = () => { + const [params] = useSearchParams(); + const sessionId = params.get('session_id'); + const selfhostGenerateLicenseService = useService( + SelfhostGenerateLicenseService + ); + const isMutating = useLiveData(selfhostGenerateLicenseService.isLoading$); + const key = useLiveData(selfhostGenerateLicenseService.licenseKey$); + const error = useLiveData(selfhostGenerateLicenseService.error$); + + useEffect(() => { + if (isMutating || error) { + return; + } + if (sessionId && !key) { + selfhostGenerateLicenseService.generateLicenseKey(sessionId); + } + }, [error, isMutating, key, selfhostGenerateLicenseService, sessionId]); + + if (!sessionId) { + return ; + } + if (isMutating || key) { + return ; + } else { + return ( + + ); + } +}; + +const Success = ({ licenseKey }: { licenseKey: string | null }) => { + const t = useI18n(); + const openInAppService = useService(OpenInAppService); + + const openAFFiNE = useCallback(() => { + openInAppService.showOpenInAppPage(); + }, [openInAppService]); + + const onCopy = useCallback(() => { + if (!licenseKey) { + notify.error({ title: 'Copy failed, please try again later' }); + return; + } + copyTextToClipboard(licenseKey) + .then(success => { + if (success) { + notify.success({ + title: t['com.affine.payment.license-success.copy'](), + }); + } + }) + .catch(err => { + console.error(err); + notify.error({ title: 'Copy failed, please try again later' }); + }); + }, [licenseKey, t]); + + const subtitle = ( + + {t['com.affine.payment.license-success.text-1']()} + + + ), + }} + /> + + + ); + return ( + +
+
+ {licenseKey ? licenseKey : } + } + className={styles.icon} + size="20" + tooltip={t['Copy']()} + onClick={onCopy} + /> +
+
{t['com.affine.payment.license-success.hint']()}
+
+ +
+
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/styles.css.ts b/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/styles.css.ts new file mode 100644 index 0000000000..474b38e0eb --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/styles.css.ts @@ -0,0 +1,37 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +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'), + }, +}); +export const content = style({ + display: 'flex', + flexDirection: 'column', + gap: '28px', +}); + +export const licenseKeyContainer = style({ + width: '100%', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: cssVarV2('layer/background/secondary'), + borderRadius: '4px', + border: `1px solid ${cssVarV2('layer/insideBorder/blackBorder')}`, + padding: '8px 10px', + gap: '8px', +}); + +export const icon = style({ + color: cssVarV2('icon/primary'), +}); diff --git a/packages/frontend/core/src/desktop/router.tsx b/packages/frontend/core/src/desktop/router.tsx index d311ade62c..b0173c75a3 100644 --- a/packages/frontend/core/src/desktop/router.tsx +++ b/packages/frontend/core/src/desktop/router.tsx @@ -68,6 +68,10 @@ export const topLevelRoutes = [ path: '/upgrade-success/team', lazy: () => import('./pages/upgrade-success/team'), }, + { + path: '/upgrade-success/self-hosted-team', + lazy: () => import('./pages/upgrade-success/self-host-team'), + }, { path: '/ai-upgrade-success', lazy: () => import('./pages/ai-upgrade-success'), diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index 91abe89d0b..967de67350 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -19,6 +19,8 @@ export { EventSourceService } from './services/eventsource'; export { FetchService } from './services/fetch'; export { GraphQLService } from './services/graphql'; export { InvoicesService } from './services/invoices'; +export { SelfhostGenerateLicenseService } from './services/selfhost-generate-license'; +export { SelfhostLicenseService } from './services/selfhost-license'; export { ServerService } from './services/server'; export { ServersService } from './services/servers'; export { SubscriptionService } from './services/subscription'; @@ -58,6 +60,8 @@ import { EventSourceService } from './services/eventsource'; import { FetchService } from './services/fetch'; import { GraphQLService } from './services/graphql'; import { InvoicesService } from './services/invoices'; +import { SelfhostGenerateLicenseService } from './services/selfhost-generate-license'; +import { SelfhostLicenseService } from './services/selfhost-license'; import { ServerService } from './services/server'; import { ServersService } from './services/servers'; import { SubscriptionService } from './services/subscription'; @@ -70,6 +74,8 @@ import { WorkspaceSubscriptionService } from './services/workspace-subscription' import { AuthStore } from './stores/auth'; import { CloudDocMetaStore } from './stores/cloud-doc-meta'; import { InvoicesStore } from './stores/invoices'; +import { SelfhostGenerateLicenseStore } from './stores/selfhost-generate-license'; +import { SelfhostLicenseStore } from './stores/selfhost-license'; import { ServerConfigStore } from './stores/server-config'; import { ServerListStore } from './stores/server-list'; import { SubscriptionStore } from './stores/subscription'; @@ -128,7 +134,9 @@ export function configureCloudModule(framework: Framework) { .store(UserFeatureStore, [GraphQLService]) .service(InvoicesService) .store(InvoicesStore, [GraphQLService]) - .entity(Invoices, [InvoicesStore]); + .entity(Invoices, [InvoicesStore]) + .service(SelfhostGenerateLicenseService, [SelfhostGenerateLicenseStore]) + .store(SelfhostGenerateLicenseStore, [GraphQLService]); framework .scope(WorkspaceScope) @@ -142,5 +150,7 @@ export function configureCloudModule(framework: Framework) { .service(WorkspaceSubscriptionService, [WorkspaceServerService]) .entity(WorkspaceSubscription, [WorkspaceService, WorkspaceServerService]) .service(WorkspaceInvoicesService) - .entity(WorkspaceInvoices, [WorkspaceService, WorkspaceServerService]); + .entity(WorkspaceInvoices, [WorkspaceService, WorkspaceServerService]) + .service(SelfhostLicenseService, [SelfhostLicenseStore, WorkspaceService]) + .store(SelfhostLicenseStore, [WorkspaceServerService]); } diff --git a/packages/frontend/core/src/modules/cloud/services/selfhost-generate-license.ts b/packages/frontend/core/src/modules/cloud/services/selfhost-generate-license.ts new file mode 100644 index 0000000000..c2e8644ad9 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/selfhost-generate-license.ts @@ -0,0 +1,54 @@ +import { UserFriendlyError } from '@affine/graphql'; +import { + backoffRetry, + effect, + fromPromise, + LiveData, + onComplete, + onStart, + Service, +} from '@toeverything/infra'; +import { catchError, EMPTY, exhaustMap, mergeMap } from 'rxjs'; + +import { isBackendError, isNetworkError } from '../error'; +import type { SelfhostGenerateLicenseStore } from '../stores/selfhost-generate-license'; + +export class SelfhostGenerateLicenseService extends Service { + constructor(private readonly store: SelfhostGenerateLicenseStore) { + super(); + } + licenseKey$ = new LiveData(null); + isLoading$ = new LiveData(false); + error$ = new LiveData(null); + + generateLicenseKey = effect( + exhaustMap((sessionId: string) => { + return fromPromise(async () => { + return await this.store.generateKey(sessionId); + }).pipe( + backoffRetry({ + when: isNetworkError, + count: Infinity, + }), + backoffRetry({ + when: isBackendError, + }), + mergeMap(key => { + this.licenseKey$.next(key); + return EMPTY; + }), + catchError(err => { + this.error$.next(UserFriendlyError.fromAnyError(err)); + console.error(err); + return EMPTY; + }), + onStart(() => { + this.isLoading$.next(true); + }), + onComplete(() => { + this.isLoading$.next(false); + }) + ); + }) + ); +} diff --git a/packages/frontend/core/src/modules/cloud/services/selfhost-license.ts b/packages/frontend/core/src/modules/cloud/services/selfhost-license.ts new file mode 100644 index 0000000000..afc9d22eff --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/selfhost-license.ts @@ -0,0 +1,72 @@ +import type { License } from '@affine/graphql'; +import { + backoffRetry, + catchErrorInto, + effect, + exhaustMapWithTrailing, + fromPromise, + LiveData, + onComplete, + onStart, + Service, +} from '@toeverything/infra'; +import { EMPTY, mergeMap } from 'rxjs'; + +import type { WorkspaceService } from '../../workspace'; +import { isBackendError, isNetworkError } from '../error'; +import type { SelfhostLicenseStore } from '../stores/selfhost-license'; + +export class SelfhostLicenseService extends Service { + constructor( + private readonly store: SelfhostLicenseStore, + private readonly workspaceService: WorkspaceService + ) { + super(); + } + license$ = new LiveData(null); + isRevalidating$ = new LiveData(false); + error$ = new LiveData(null); + + revalidate = effect( + exhaustMapWithTrailing(() => { + return fromPromise(async signal => { + const currentWorkspaceId = this.workspaceService.workspace.id; + if (!currentWorkspaceId) { + return undefined; // no subscription if no user + } + return await this.store.getLicense(currentWorkspaceId, signal); + }).pipe( + backoffRetry({ + when: isNetworkError, + count: Infinity, + }), + backoffRetry({ + when: isBackendError, + }), + mergeMap(data => { + if (data) { + this.license$.next(data); + } + + return EMPTY; + }), + catchErrorInto(this.error$), + onStart(() => this.isRevalidating$.next(true)), + onComplete(() => this.isRevalidating$.next(false)) + ); + }) + ); + + async activateLicense(workspaceId: string, licenseKey: string) { + return await this.store.activate(workspaceId, licenseKey); + } + + async deactivateLicense(workspaceId: string) { + return await this.store.deactivate(workspaceId); + } + + clear() { + this.license$.next(null); + this.error$.next(null); + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/selfhost-generate-license.ts b/packages/frontend/core/src/modules/cloud/stores/selfhost-generate-license.ts new file mode 100644 index 0000000000..e9b6ebf273 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/selfhost-generate-license.ts @@ -0,0 +1,24 @@ +import { generateLicenseKeyMutation } from '@affine/graphql'; +import { Store } from '@toeverything/infra'; + +import type { GraphQLService } from '../services/graphql'; + +export class SelfhostGenerateLicenseStore extends Store { + constructor(private readonly gqlService: GraphQLService) { + super(); + } + + async generateKey(sessionId: string, signal?: AbortSignal): Promise { + const data = await this.gqlService.gql({ + query: generateLicenseKeyMutation, + variables: { + sessionId: sessionId, + }, + context: { + signal, + }, + }); + + return data.generateLicenseKey; + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/selfhost-license.ts b/packages/frontend/core/src/modules/cloud/stores/selfhost-license.ts new file mode 100644 index 0000000000..d1b40588d6 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/selfhost-license.ts @@ -0,0 +1,66 @@ +import { + activateLicenseMutation, + deactivateLicenseMutation, + getLicenseQuery, +} from '@affine/graphql'; +import { Store } from '@toeverything/infra'; + +import type { WorkspaceServerService } from '../services/workspace-server'; + +export class SelfhostLicenseStore extends Store { + constructor(private readonly workspaceServerService: WorkspaceServerService) { + super(); + } + + async getLicense(workspaceId: string, signal?: AbortSignal) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const data = await this.workspaceServerService.server.gql({ + query: getLicenseQuery, + variables: { + workspaceId: workspaceId, + }, + context: { + signal, + }, + }); + + return data.workspace.license; + } + + async activate(workspaceId: string, license: string, signal?: AbortSignal) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const data = await this.workspaceServerService.server.gql({ + query: activateLicenseMutation, + variables: { + workspaceId: workspaceId, + license: license, + }, + context: { + signal, + }, + }); + + return data.activateLicense; + } + + async deactivate(workspaceId: string, signal?: AbortSignal) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const data = await this.workspaceServerService.server.gql({ + query: deactivateLicenseMutation, + variables: { + workspaceId: workspaceId, + }, + context: { + signal, + }, + }); + + return data.deactivateLicense; + } +} diff --git a/packages/frontend/core/src/modules/permissions/entities/permission.ts b/packages/frontend/core/src/modules/permissions/entities/permission.ts index 0b12f81c56..a4f4e59a8b 100644 --- a/packages/frontend/core/src/modules/permissions/entities/permission.ts +++ b/packages/frontend/core/src/modules/permissions/entities/permission.ts @@ -4,12 +4,13 @@ import { catchErrorInto, effect, Entity, + exhaustMapWithTrailing, fromPromise, LiveData, onComplete, onStart, } from '@toeverything/infra'; -import { EMPTY, exhaustMap, mergeMap } from 'rxjs'; +import { EMPTY, mergeMap } from 'rxjs'; import { isBackendError, isNetworkError } from '../../cloud'; import type { WorkspaceService } from '../../workspace'; @@ -32,7 +33,7 @@ export class WorkspacePermission extends Entity { } revalidate = effect( - exhaustMap(() => { + exhaustMapWithTrailing(() => { return fromPromise(async signal => { if (this.workspaceService.workspace.flavour !== 'local') { const info = await this.store.fetchWorkspaceInfo( diff --git a/packages/frontend/graphql/src/graphql/activate-license.gql b/packages/frontend/graphql/src/graphql/activate-license.gql new file mode 100644 index 0000000000..3434fa52b7 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/activate-license.gql @@ -0,0 +1,6 @@ +mutation activateLicense($workspaceId: String!, $license: String!) { + activateLicense(workspaceId: $workspaceId, license: $license) { + installedAt + validatedAt + } +} \ No newline at end of file diff --git a/packages/frontend/graphql/src/graphql/create-self-host-customer-portal.gql b/packages/frontend/graphql/src/graphql/create-self-host-customer-portal.gql new file mode 100644 index 0000000000..de280ab032 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/create-self-host-customer-portal.gql @@ -0,0 +1,3 @@ +mutation createSelfhostCustomerPortal ($workspaceId: String!) { + createSelfhostWorkspaceCustomerPortal(workspaceId: $workspaceId) +} diff --git a/packages/frontend/graphql/src/graphql/deactivate-license.gql b/packages/frontend/graphql/src/graphql/deactivate-license.gql new file mode 100644 index 0000000000..304ca77820 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/deactivate-license.gql @@ -0,0 +1,3 @@ +mutation deactivateLicense($workspaceId: String!) { + deactivateLicense(workspaceId: $workspaceId) +} \ No newline at end of file diff --git a/packages/frontend/graphql/src/graphql/generate-license-key.gql b/packages/frontend/graphql/src/graphql/generate-license-key.gql new file mode 100644 index 0000000000..43f66dbfab --- /dev/null +++ b/packages/frontend/graphql/src/graphql/generate-license-key.gql @@ -0,0 +1,3 @@ +mutation generateLicenseKey($sessionId: String!) { + generateLicenseKey(sessionId: $sessionId) +} diff --git a/packages/frontend/graphql/src/graphql/get-license.gql b/packages/frontend/graphql/src/graphql/get-license.gql new file mode 100644 index 0000000000..cc176c6761 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-license.gql @@ -0,0 +1,11 @@ +query getLicense($workspaceId: String!) { + workspace(id: $workspaceId) { + license { + expiredAt + installedAt + quantity + recurring + validatedAt + } + } +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index a95a830055..f1388c9d67 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -18,6 +18,20 @@ fragment CredentialsRequirements on CredentialsRequirementType { ...PasswordLimits } }` +export const activateLicenseMutation = { + id: 'activateLicenseMutation' as const, + operationName: 'activateLicense', + definitionName: 'activateLicense', + containsFile: false, + query: ` +mutation activateLicense($workspaceId: String!, $license: String!) { + activateLicense(workspaceId: $workspaceId, license: $license) { + installedAt + validatedAt + } +}`, +}; + export const adminServerConfigQuery = { id: 'adminServerConfigQuery' as const, operationName: 'adminServerConfig', @@ -217,6 +231,17 @@ mutation createCustomerPortal { }`, }; +export const createSelfhostCustomerPortalMutation = { + id: 'createSelfhostCustomerPortalMutation' as const, + operationName: 'createSelfhostCustomerPortal', + definitionName: 'createSelfhostWorkspaceCustomerPortal', + containsFile: false, + query: ` +mutation createSelfhostCustomerPortal($workspaceId: String!) { + createSelfhostWorkspaceCustomerPortal(workspaceId: $workspaceId) +}`, +}; + export const createUserMutation = { id: 'createUserMutation' as const, operationName: 'createUser', @@ -245,6 +270,17 @@ mutation createWorkspace { }`, }; +export const deactivateLicenseMutation = { + id: 'deactivateLicenseMutation' as const, + operationName: 'deactivateLicense', + definitionName: 'deactivateLicense', + containsFile: false, + query: ` +mutation deactivateLicense($workspaceId: String!) { + deactivateLicense(workspaceId: $workspaceId) +}`, +}; + export const deleteAccountMutation = { id: 'deleteAccountMutation' as const, operationName: 'deleteAccount', @@ -293,6 +329,17 @@ mutation forkCopilotSession($options: ForkChatSessionInput!) { }`, }; +export const generateLicenseKeyMutation = { + id: 'generateLicenseKeyMutation' as const, + operationName: 'generateLicenseKey', + definitionName: 'generateLicenseKey', + containsFile: false, + query: ` +mutation generateLicenseKey($sessionId: String!) { + generateLicenseKey(sessionId: $sessionId) +}`, +}; + export const getCopilotHistoriesQuery = { id: 'getCopilotHistoriesQuery' as const, operationName: 'getCopilotHistories', @@ -440,6 +487,25 @@ query getIsOwner($workspaceId: String!) { }`, }; +export const getLicenseQuery = { + id: 'getLicenseQuery' as const, + operationName: 'getLicense', + definitionName: 'workspace', + containsFile: false, + query: ` +query getLicense($workspaceId: String!) { + workspace(id: $workspaceId) { + license { + expiredAt + installedAt + quantity + recurring + validatedAt + } + } +}`, +}; + export const getMemberCountByWorkspaceIdQuery = { id: 'getMemberCountByWorkspaceIdQuery' as const, operationName: 'getMemberCountByWorkspaceId', diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 1a0c69b124..9bd59907ea 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -253,6 +253,23 @@ export interface DocNotFoundDataType { spaceId: Scalars['String']['output']; } +/** User permission in doc */ +export enum DocRole { + Editor = 'Editor', + External = 'External', + Manager = 'Manager', + Owner = 'Owner', + Reader = 'Reader', +} + +export interface DocType { + __typename?: 'DocType'; + id: Scalars['String']['output']; + permissions: RolePermissions; + public: Scalars['Boolean']['output']; + role: DocRole; +} + export interface EditorType { __typename?: 'EditorType'; avatarUrl: Maybe; @@ -268,6 +285,9 @@ export type ErrorDataUnion = | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType + | ExpectToGrantDocUserRolesDataType + | ExpectToRevokeDocUserRolesDataType + | ExpectToUpdateDocUserRoleDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType @@ -282,6 +302,7 @@ export type ErrorDataUnion = | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType + | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType @@ -289,11 +310,13 @@ export type ErrorDataUnion = | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType + | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType; export enum ErrorNames { ACCESS_DENIED = 'ACCESS_DENIED', ACTION_FORBIDDEN = 'ACTION_FORBIDDEN', + ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE = 'ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE', ALREADY_IN_SPACE = 'ALREADY_IN_SPACE', AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED', BLOB_NOT_FOUND = 'BLOB_NOT_FOUND', @@ -320,8 +343,11 @@ export enum ErrorNames { EMAIL_ALREADY_USED = 'EMAIL_ALREADY_USED', EMAIL_TOKEN_NOT_FOUND = 'EMAIL_TOKEN_NOT_FOUND', EMAIL_VERIFICATION_REQUIRED = 'EMAIL_VERIFICATION_REQUIRED', + EXPECT_TO_GRANT_DOC_USER_ROLES = 'EXPECT_TO_GRANT_DOC_USER_ROLES', EXPECT_TO_PUBLISH_PAGE = 'EXPECT_TO_PUBLISH_PAGE', + EXPECT_TO_REVOKE_DOC_USER_ROLES = 'EXPECT_TO_REVOKE_DOC_USER_ROLES', EXPECT_TO_REVOKE_PUBLIC_PAGE = 'EXPECT_TO_REVOKE_PUBLIC_PAGE', + EXPECT_TO_UPDATE_DOC_USER_ROLE = 'EXPECT_TO_UPDATE_DOC_USER_ROLE', FAILED_TO_CHECKOUT = 'FAILED_TO_CHECKOUT', FAILED_TO_SAVE_UPDATES = 'FAILED_TO_SAVE_UPDATES', FAILED_TO_UPSERT_SNAPSHOT = 'FAILED_TO_UPSERT_SNAPSHOT', @@ -359,6 +385,7 @@ export enum ErrorNames { SPACE_ACCESS_DENIED = 'SPACE_ACCESS_DENIED', SPACE_NOT_FOUND = 'SPACE_NOT_FOUND', SPACE_OWNER_NOT_FOUND = 'SPACE_OWNER_NOT_FOUND', + SPACE_SHOULD_HAVE_ONLY_ONE_OWNER = 'SPACE_SHOULD_HAVE_ONLY_ONE_OWNER', SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS', SUBSCRIPTION_EXPIRED = 'SUBSCRIPTION_EXPIRED', SUBSCRIPTION_HAS_BEEN_CANCELED = 'SUBSCRIPTION_HAS_BEEN_CANCELED', @@ -376,10 +403,29 @@ export enum ErrorNames { WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION', WORKSPACE_LICENSE_ALREADY_EXISTS = 'WORKSPACE_LICENSE_ALREADY_EXISTS', WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE = 'WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE', + WORKSPACE_PERMISSION_NOT_FOUND = 'WORKSPACE_PERMISSION_NOT_FOUND', WRONG_SIGN_IN_CREDENTIALS = 'WRONG_SIGN_IN_CREDENTIALS', WRONG_SIGN_IN_METHOD = 'WRONG_SIGN_IN_METHOD', } +export interface ExpectToGrantDocUserRolesDataType { + __typename?: 'ExpectToGrantDocUserRolesDataType'; + docId: Scalars['String']['output']; + spaceId: Scalars['String']['output']; +} + +export interface ExpectToRevokeDocUserRolesDataType { + __typename?: 'ExpectToRevokeDocUserRolesDataType'; + docId: Scalars['String']['output']; + spaceId: Scalars['String']['output']; +} + +export interface ExpectToUpdateDocUserRoleDataType { + __typename?: 'ExpectToUpdateDocUserRoleDataType'; + docId: Scalars['String']['output']; + spaceId: Scalars['String']['output']; +} + export enum FeatureType { AIEarlyAccess = 'AIEarlyAccess', Admin = 'Admin', @@ -400,6 +446,32 @@ export interface ForkChatSessionInput { workspaceId: Scalars['String']['input']; } +export interface GrantDocUserRolesInput { + docId: Scalars['String']['input']; + role: DocRole; + userIds: Array; + workspaceId: Scalars['String']['input']; +} + +export interface GrantedDocUserEdge { + __typename?: 'GrantedDocUserEdge'; + cursor: Scalars['String']['output']; + user: GrantedDocUserType; +} + +export interface GrantedDocUserType { + __typename?: 'GrantedDocUserType'; + role: DocRole; + user: UserType; +} + +export interface GrantedDocUsersConnection { + __typename?: 'GrantedDocUsersConnection'; + edges: Array; + pageInfo: PageInfo; + totalCount: Scalars['Int']['output']; +} + export interface InvalidEmailDataType { __typename?: 'InvalidEmailDataType'; email: Scalars['String']['output']; @@ -489,8 +561,13 @@ export interface InviteUserType { inviteId: Scalars['String']['output']; /** User name */ name: Maybe; - /** User permission in workspace */ + /** + * User permission in workspace + * @deprecated Use role instead + */ permission: Permission; + /** User role in workspace */ + role: Permission; /** Member invite status in workspace */ status: WorkspaceMemberStatus; } @@ -606,6 +683,7 @@ export interface Mutation { /** Create a chat session */ forkCopilotSession: Scalars['String']['output']; generateLicenseKey: Scalars['String']['output']; + grantDocUserRoles: Scalars['Boolean']['output']; grantMember: Scalars['String']['output']; invite: Scalars['String']['output']; inviteBatch: Array; @@ -618,6 +696,7 @@ export interface Mutation { removeWorkspaceFeature: Scalars['Boolean']['output']; resumeSubscription: SubscriptionType; revoke: Scalars['Boolean']['output']; + revokeDocUserRoles: Scalars['Boolean']['output']; revokeInviteLink: Scalars['Boolean']['output']; /** @deprecated use revokePublicPage */ revokePage: Scalars['Boolean']['output']; @@ -634,6 +713,7 @@ export interface Mutation { updateCopilotPrompt: CopilotPromptType; /** Update a chat session */ updateCopilotSession: Scalars['String']['output']; + updateDocUserRole: Scalars['Boolean']['output']; updateProfile: UserType; /** update server runtime configurable setting */ updateRuntimeConfig: ServerRuntimeConfigType; @@ -758,6 +838,10 @@ export interface MutationGenerateLicenseKeyArgs { sessionId: Scalars['String']['input']; } +export interface MutationGrantDocUserRolesArgs { + input: GrantDocUserRolesInput; +} + export interface MutationGrantMemberArgs { permission: Permission; userId: Scalars['String']['input']; @@ -815,6 +899,11 @@ export interface MutationRevokeArgs { workspaceId: Scalars['String']['input']; } +export interface MutationRevokeDocUserRolesArgs { + docId: Scalars['String']['input']; + userIds: Array; +} + export interface MutationRevokeInviteLinkArgs { workspaceId: Scalars['String']['input']; } @@ -873,6 +962,12 @@ export interface MutationUpdateCopilotSessionArgs { options: UpdateChatSessionInput; } +export interface MutationUpdateDocUserRoleArgs { + docId: Scalars['String']['input']; + role: DocRole; + userId: Scalars['String']['input']; +} + export interface MutationUpdateProfileArgs { input: UpdateUserInput; } @@ -926,6 +1021,23 @@ export enum OAuthProviderType { OIDC = 'OIDC', } +export interface PageGrantedUsersInput { + /** Cursor */ + after?: InputMaybe; + /** Cursor */ + before?: InputMaybe; + first: Scalars['Int']['input']; + offset: Scalars['Int']['input']; +} + +export interface PageInfo { + __typename?: 'PageInfo'; + endCursor: Maybe; + hasNextPage: Scalars['Boolean']['output']; + hasPreviousPage: Scalars['Boolean']['output']; + startCursor: Maybe; +} + export interface PasswordLimitsType { __typename?: 'PasswordLimitsType'; maxLength: Scalars['Int']['output']; @@ -935,9 +1047,9 @@ export interface PasswordLimitsType { /** User permission in workspace */ export enum Permission { Admin = 'Admin', + Collaborator = 'Collaborator', + External = 'External', Owner = 'Owner', - Read = 'Read', - Write = 'Write', } /** The mode which the public page default in */ @@ -984,6 +1096,8 @@ export interface Query { usersCount: Scalars['Int']['output']; /** Get workspace by id */ workspace: WorkspaceType; + /** Get workspace role permissions */ + workspaceRolePermissions: WorkspaceRolePermissions; /** Get all accessible workspaces for current user */ workspaces: Array; } @@ -1028,6 +1142,10 @@ export interface QueryWorkspaceArgs { id: Scalars['String']['input']; } +export interface QueryWorkspaceRolePermissionsArgs { + id: Scalars['String']['input']; +} + export interface QueryChatHistoriesInput { action?: InputMaybe; fork?: InputMaybe; @@ -1048,6 +1166,36 @@ export interface RemoveAvatar { success: Scalars['Boolean']['output']; } +export interface RolePermissions { + __typename?: 'RolePermissions'; + Doc_Copy: Scalars['Boolean']['output']; + Doc_Delete: Scalars['Boolean']['output']; + Doc_Duplicate: Scalars['Boolean']['output']; + Doc_Properties_Read: Scalars['Boolean']['output']; + Doc_Properties_Update: Scalars['Boolean']['output']; + Doc_Publish: Scalars['Boolean']['output']; + Doc_Read: Scalars['Boolean']['output']; + Doc_Restore: Scalars['Boolean']['output']; + Doc_TransferOwner: Scalars['Boolean']['output']; + Doc_Trash: Scalars['Boolean']['output']; + Doc_Update: Scalars['Boolean']['output']; + Doc_Users_Manage: Scalars['Boolean']['output']; + Doc_Users_Read: Scalars['Boolean']['output']; + Workspace_CreateDoc: Scalars['Boolean']['output']; + Workspace_Delete: Scalars['Boolean']['output']; + Workspace_Organize_Read: Scalars['Boolean']['output']; + Workspace_Properties_Create: Scalars['Boolean']['output']; + Workspace_Properties_Delete: Scalars['Boolean']['output']; + Workspace_Properties_Read: Scalars['Boolean']['output']; + Workspace_Properties_Update: Scalars['Boolean']['output']; + Workspace_Settings_Read: Scalars['Boolean']['output']; + Workspace_Settings_Update: Scalars['Boolean']['output']; + Workspace_Sync: Scalars['Boolean']['output']; + Workspace_TransferOwner: Scalars['Boolean']['output']; + Workspace_Users_Manage: Scalars['Boolean']['output']; + Workspace_Users_Read: Scalars['Boolean']['output']; +} + export interface RuntimeConfigNotFoundDataType { __typename?: 'RuntimeConfigNotFoundDataType'; key: Scalars['String']['output']; @@ -1146,6 +1294,11 @@ export interface SpaceOwnerNotFoundDataType { spaceId: Scalars['String']['output']; } +export interface SpaceShouldHaveOnlyOneOwnerDataType { + __typename?: 'SpaceShouldHaveOnlyOneOwnerDataType'; + spaceId: Scalars['String']['output']; +} + export interface SubscriptionAlreadyExistsDataType { __typename?: 'SubscriptionAlreadyExistsDataType'; plan: Scalars['String']['output']; @@ -1377,6 +1530,28 @@ export interface WorkspacePageMeta { updatedBy: Maybe; } +export interface WorkspacePermissionNotFoundDataType { + __typename?: 'WorkspacePermissionNotFoundDataType'; + spaceId: Scalars['String']['output']; +} + +export interface WorkspacePermissions { + __typename?: 'WorkspacePermissions'; + Workspace_CreateDoc: Scalars['Boolean']['output']; + Workspace_Delete: Scalars['Boolean']['output']; + Workspace_Organize_Read: Scalars['Boolean']['output']; + Workspace_Properties_Create: Scalars['Boolean']['output']; + Workspace_Properties_Delete: Scalars['Boolean']['output']; + Workspace_Properties_Read: Scalars['Boolean']['output']; + Workspace_Properties_Update: Scalars['Boolean']['output']; + Workspace_Settings_Read: Scalars['Boolean']['output']; + Workspace_Settings_Update: Scalars['Boolean']['output']; + Workspace_Sync: Scalars['Boolean']['output']; + Workspace_TransferOwner: Scalars['Boolean']['output']; + Workspace_Users_Manage: Scalars['Boolean']['output']; + Workspace_Users_Read: Scalars['Boolean']['output']; +} + export interface WorkspaceQuotaHumanReadableType { __typename?: 'WorkspaceQuotaHumanReadableType'; blobLimit: Scalars['String']['output']; @@ -1402,6 +1577,12 @@ export interface WorkspaceQuotaType { usedStorageQuota: Scalars['SafeInt']['output']; } +export interface WorkspaceRolePermissions { + __typename?: 'WorkspaceRolePermissions'; + permissions: WorkspacePermissions; + role: Permission; +} + export interface WorkspaceType { __typename?: 'WorkspaceType'; /** List blobs of workspace */ @@ -1431,10 +1612,12 @@ export interface WorkspaceType { members: Array; /** Owner of workspace */ owner: UserType; + /** Page granted users list */ + pageGrantedUsersList: GrantedDocUsersConnection; /** Cloud page metadata of workspace */ pageMeta: WorkspacePageMeta; - /** Permission of current signed in user in workspace */ - permission: Permission; + /** Check if current user has permission to access the page */ + pagePermission: DocType; /** is Public workspace */ public: Scalars['Boolean']['output']; /** Get public page of a workspace by page id. */ @@ -1443,6 +1626,8 @@ export interface WorkspaceType { publicPages: Array; /** quota of workspace */ quota: WorkspaceQuotaType; + /** Role of current signed in user in workspace */ + role: Permission; /** * Shared pages of workspace * @deprecated use WorkspaceType.publicPages @@ -1471,10 +1656,19 @@ export interface WorkspaceTypeMembersArgs { take?: InputMaybe; } +export interface WorkspaceTypePageGrantedUsersListArgs { + pageGrantedUsersInput: PageGrantedUsersInput; + pageId: Scalars['String']['input']; +} + export interface WorkspaceTypePageMetaArgs { pageId: Scalars['String']['input']; } +export interface WorkspaceTypePagePermissionArgs { + pageId: Scalars['String']['input']; +} + export interface WorkspaceTypePublicPageArgs { pageId: Scalars['String']['input']; } @@ -1491,6 +1685,20 @@ export interface TokenType { token: Scalars['String']['output']; } +export type ActivateLicenseMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + license: Scalars['String']['input']; +}>; + +export type ActivateLicenseMutation = { + __typename?: 'Mutation'; + activateLicense: { + __typename?: 'License'; + installedAt: string; + validatedAt: string; + }; +}; + export type AdminServerConfigQueryVariables = Exact<{ [key: string]: never }>; export type AdminServerConfigQuery = { @@ -1669,6 +1877,15 @@ export type CreateCustomerPortalMutation = { createCustomerPortal: string; }; +export type CreateSelfhostCustomerPortalMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + +export type CreateSelfhostCustomerPortalMutation = { + __typename?: 'Mutation'; + createSelfhostWorkspaceCustomerPortal: string; +}; + export type CreateUserMutationVariables = Exact<{ input: CreateUserInput; }>; @@ -1690,6 +1907,15 @@ export type CreateWorkspaceMutation = { }; }; +export type DeactivateLicenseMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + +export type DeactivateLicenseMutation = { + __typename?: 'Mutation'; + deactivateLicense: boolean; +}; + export type DeleteAccountMutationVariables = Exact<{ [key: string]: never }>; export type DeleteAccountMutation = { @@ -1739,6 +1965,15 @@ export type PasswordLimitsFragment = { maxLength: number; }; +export type GenerateLicenseKeyMutationVariables = Exact<{ + sessionId: Scalars['String']['input']; +}>; + +export type GenerateLicenseKeyMutation = { + __typename?: 'Mutation'; + generateLicenseKey: string; +}; + export type GetCopilotHistoriesQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; docId?: InputMaybe; @@ -1879,6 +2114,25 @@ export type GetIsOwnerQueryVariables = Exact<{ export type GetIsOwnerQuery = { __typename?: 'Query'; isOwner: boolean }; +export type GetLicenseQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + +export type GetLicenseQuery = { + __typename?: 'Query'; + workspace: { + __typename?: 'WorkspaceType'; + license: { + __typename?: 'License'; + expiredAt: string | null; + installedAt: string; + quantity: number; + recurring: SubscriptionRecurring; + validatedAt: string; + } | null; + }; +}; + export type GetMemberCountByWorkspaceIdQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; }>; @@ -2843,6 +3097,11 @@ export type Queries = variables: GetIsOwnerQueryVariables; response: GetIsOwnerQuery; } + | { + name: 'getLicenseQuery'; + variables: GetLicenseQueryVariables; + response: GetLicenseQuery; + } | { name: 'getMemberCountByWorkspaceIdQuery'; variables: GetMemberCountByWorkspaceIdQueryVariables; @@ -2990,6 +3249,11 @@ export type Queries = }; export type Mutations = + | { + name: 'activateLicenseMutation'; + variables: ActivateLicenseMutationVariables; + response: ActivateLicenseMutation; + } | { name: 'deleteBlobMutation'; variables: DeleteBlobMutationVariables; @@ -3050,6 +3314,11 @@ export type Mutations = variables: CreateCustomerPortalMutationVariables; response: CreateCustomerPortalMutation; } + | { + name: 'createSelfhostCustomerPortalMutation'; + variables: CreateSelfhostCustomerPortalMutationVariables; + response: CreateSelfhostCustomerPortalMutation; + } | { name: 'createUserMutation'; variables: CreateUserMutationVariables; @@ -3060,6 +3329,11 @@ export type Mutations = variables: CreateWorkspaceMutationVariables; response: CreateWorkspaceMutation; } + | { + name: 'deactivateLicenseMutation'; + variables: DeactivateLicenseMutationVariables; + response: DeactivateLicenseMutation; + } | { name: 'deleteAccountMutation'; variables: DeleteAccountMutationVariables; @@ -3080,6 +3354,11 @@ export type Mutations = variables: ForkCopilotSessionMutationVariables; response: ForkCopilotSessionMutation; } + | { + name: 'generateLicenseKeyMutation'; + variables: GenerateLicenseKeyMutationVariables; + response: GenerateLicenseKeyMutation; + } | { name: 'leaveWorkspaceMutation'; variables: LeaveWorkspaceMutationVariables; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index ac99656558..dcf02b409d 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,26 +1,26 @@ { - "ar": 92, + "ar": 90, "ca": 5, "da": 5, - "de": 92, - "el-GR": 92, + "de": 90, + "el-GR": 90, "en": 100, - "es-AR": 92, - "es-CL": 94, - "es": 92, - "fa": 92, - "fr": 92, + "es-AR": 90, + "es-CL": 92, + "es": 90, + "fa": 90, + "fr": 90, "hi": 2, - "it-IT": 92, + "it-IT": 90, "it": 1, - "ja": 92, - "ko": 67, - "pl": 92, - "pt-BR": 92, - "ru": 92, - "sv-SE": 92, - "uk": 92, + "ja": 90, + "ko": 65, + "pl": 90, + "pt-BR": 90, + "ru": 90, + "sv-SE": 90, + "uk": 90, "ur": 2, - "zh-Hans": 92, - "zh-Hant": 92 + "zh-Hans": 90, + "zh-Hant": 90 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 8bb176db98..61c50a68f6 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -4185,6 +4185,26 @@ export function useAFFiNEI18N(): { * `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-1"](): string; + /** + * `Thank you for your purchase!` + */ + ["com.affine.payment.license-success.title"](): string; + /** + * `Thank you for purchasing the AFFiNE self-hosted license.` + */ + ["com.affine.payment.license-success.text-1"](): string; + /** + * `You can use this key to upgrade in Settings > Workspace > Billing > Upgrade` + */ + ["com.affine.payment.license-success.hint"](): string; + /** + * `Open AFFiNE` + */ + ["com.affine.payment.license-success.open-affine"](): string; + /** + * `Copied key to clipboard` + */ + ["com.affine.payment.license-success.copy"](): string; /** * `Close` */ @@ -5413,6 +5433,116 @@ export function useAFFiNEI18N(): { * `Cancel Plan` */ ["com.affine.settings.workspace.billing.team-workspace.cancel-plan"](): string; + /** + * `License` + */ + ["com.affine.settings.workspace.license"](): string; + /** + * `Manage license information and invoices for the self host team workspace.` + */ + ["com.affine.settings.workspace.license.description"](): string; + /** + * `Get teams plan for your self hosted workspace.` + */ + ["com.affine.settings.workspace.license.benefit.team.title"](): string; + /** + * `Need more seats? Best for scalable teams.` + */ + ["com.affine.settings.workspace.license.benefit.team.subtitle"](): string; + /** + * `Everything in Self Hosted FOSS` + */ + ["com.affine.settings.workspace.license.benefit.team.g1"](): string; + /** + * `{{initialQuota}} initial storage + {{quotaPerSeat}} per seat` + */ + ["com.affine.settings.workspace.license.benefit.team.g2"](options: Readonly<{ + initialQuota: string; + quotaPerSeat: string; + }>): string; + /** + * `{{quota}} of maximum file size` + */ + ["com.affine.settings.workspace.license.benefit.team.g3"](options: { + readonly quota: string; + }): string; + /** + * `Unlimited team members (10+ seats)` + */ + ["com.affine.settings.workspace.license.benefit.team.g4"](): string; + /** + * `Multiple admin roles` + */ + ["com.affine.settings.workspace.license.benefit.team.g5"](): string; + /** + * `Priority customer support` + */ + ["com.affine.settings.workspace.license.benefit.team.g6"](): string; + /** + * `Lean more` + */ + ["com.affine.settings.workspace.license.lean-more"](): string; + /** + * `Selfhosted workspace` + */ + ["com.affine.settings.workspace.license.self-host"](): string; + /** + * `Self-host Team Workspace` + */ + ["com.affine.settings.workspace.license.self-host-team"](): string; + /** + * `This license will expire on {{expirationDate}}, with {{leftDays}} days remaining.` + */ + ["com.affine.settings.workspace.license.self-host-team.team.description"](options: Readonly<{ + expirationDate: string; + leftDays: string; + }>): string; + /** + * `Basic version: {{memberCount}} seats. For more, purchase or use activation key.` + */ + ["com.affine.settings.workspace.license.self-host-team.free.description"](options: { + readonly memberCount: string; + }): string; + /** + * `Seats` + */ + ["com.affine.settings.workspace.license.self-host-team.seats"](): string; + /** + * `Active key` + */ + ["com.affine.settings.workspace.license.self-host-team.active-key"](): string; + /** + * `Deactivate` + */ + ["com.affine.settings.workspace.license.self-host-team.deactivate-license"](): string; + /** + * `Buy more seat` + */ + ["com.affine.settings.workspace.license.buy-more-seat"](): string; + /** + * `Activate License` + */ + ["com.affine.settings.workspace.license.activate-modal.title"](): string; + /** + * `Enter license key to activate this self host workspace.` + */ + ["com.affine.settings.workspace.license.activate-modal.description"](): string; + /** + * `License activated successfully.` + */ + ["com.affine.settings.workspace.license.activate-success"](): string; + /** + * `Deactivate License` + */ + ["com.affine.settings.workspace.license.deactivate-modal.title"](): string; + /** + * `Are you sure you want to deactivate this license?` + */ + ["com.affine.settings.workspace.license.deactivate-modal.description"](): string; + /** + * `License deactivated successfully.` + */ + ["com.affine.settings.workspace.license.deactivate-success"](): string; /** * `Local` */ @@ -6811,6 +6941,12 @@ export function useAFFiNEI18N(): { * `You must verify your email before accessing this resource.` */ ["error.EMAIL_VERIFICATION_REQUIRED"](): string; + /** + * `Space {{spaceId}} permission not found.` + */ + ["error.WORKSPACE_PERMISSION_NOT_FOUND"](options: { + readonly spaceId: string; + }): string; /** * `Space {{spaceId}} not found.` */ @@ -6847,6 +6983,10 @@ export function useAFFiNEI18N(): { ["error.SPACE_OWNER_NOT_FOUND"](options: { readonly spaceId: string; }): string; + /** + * `Space should have only one owner.` + */ + ["error.SPACE_SHOULD_HAVE_ONLY_ONE_OWNER"](): string; /** * `Doc {{docId}} under Space {{spaceId}} not found.` */ @@ -6895,6 +7035,27 @@ export function useAFFiNEI18N(): { * `Expected to revoke a public page, not a Space.` */ ["error.EXPECT_TO_REVOKE_PUBLIC_PAGE"](): string; + /** + * `Expect grant roles on doc {{docId}} under Space {{spaceId}}, not a Space.` + */ + ["error.EXPECT_TO_GRANT_DOC_USER_ROLES"](options: Readonly<{ + docId: string; + spaceId: string; + }>): string; + /** + * `Expect revoke roles on doc {{docId}} under Space {{spaceId}}, not a Space.` + */ + ["error.EXPECT_TO_REVOKE_DOC_USER_ROLES"](options: Readonly<{ + docId: string; + spaceId: string; + }>): string; + /** + * `Expect update roles on doc {{docId}} under Space {{spaceId}}, not a Space.` + */ + ["error.EXPECT_TO_UPDATE_DOC_USER_ROLE"](options: Readonly<{ + docId: string; + spaceId: string; + }>): string; /** * `Page is not public.` */ @@ -6907,6 +7068,10 @@ export function useAFFiNEI18N(): { * `Failed to store doc snapshot.` */ ["error.FAILED_TO_UPSERT_SNAPSHOT"](): string; + /** + * `A Team workspace is required to perform this action.` + */ + ["error.ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE"](): string; /** * `Unsupported subscription plan: {{plan}}.` */ @@ -7376,6 +7541,12 @@ export const TypedTrans: { ["com.affine.payment.upgrade-success-page.team.text-2"]: ComponentType, { ["1"]: JSX.Element; }>>; + /** + * `If you have any questions, please contact our <1>customer support.` + */ + ["com.affine.payment.license-success.text-2"]: ComponentType, { + ["1"]: JSX.Element; + }>>; /** * `This action deletes the old Favorites section. Your documents are safe, ensure you've moved your frequently accessed documents to the new personal Favorites section.` */ @@ -7415,6 +7586,12 @@ export const TypedTrans: { ["1"]: JSX.Element; ["2"]: JSX.Element; }>>; + /** + * `If you encounter any issues, please contact our <1>customer support.` + */ + ["com.affine.settings.workspace.license.activate-modal.tips"]: ComponentType, { + ["1"]: JSX.Element; + }>>; /** * `The "<1>{{ name }}" property will be removed. This action cannot be undone.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index d942c0bb83..a001b06fe7 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1038,6 +1038,12 @@ "com.affine.payment.upgrade-success-page.title": "Upgrade successful!", "com.affine.payment.upgrade-success-page.team.text-1": "Congratulations! Your workspace has been successfully upgraded to a Team Workspace. Now you can invite unlimited members to collaborate in this workspace.", "com.affine.payment.upgrade-success-page.team.text-2": "If you have any questions, please contact our <1>customer support.", + "com.affine.payment.license-success.title": "Thank you for your purchase!", + "com.affine.payment.license-success.text-1": "Thank you for purchasing the AFFiNE self-hosted license.", + "com.affine.payment.license-success.text-2": "If you have any questions, please contact our <1>customer support.", + "com.affine.payment.license-success.hint": "You can use this key to upgrade in Settings > Workspace > Billing > Upgrade", + "com.affine.payment.license-success.open-affine": "Open AFFiNE", + "com.affine.payment.license-success.copy": "Copied key to clipboard", "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", @@ -1350,6 +1356,32 @@ "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.license": "License", + "com.affine.settings.workspace.license.description": "Manage license information and invoices for the self host team workspace.", + "com.affine.settings.workspace.license.benefit.team.title": "Get teams plan for your self hosted workspace.", + "com.affine.settings.workspace.license.benefit.team.subtitle": "Need more seats? Best for scalable teams.", + "com.affine.settings.workspace.license.benefit.team.g1": "Everything in Self Hosted FOSS", + "com.affine.settings.workspace.license.benefit.team.g2": "{{initialQuota}} initial storage + {{quotaPerSeat}} per seat", + "com.affine.settings.workspace.license.benefit.team.g3": "{{quota}} of maximum file size", + "com.affine.settings.workspace.license.benefit.team.g4": "Unlimited team members (10+ seats)", + "com.affine.settings.workspace.license.benefit.team.g5": "Multiple admin roles", + "com.affine.settings.workspace.license.benefit.team.g6": "Priority customer support", + "com.affine.settings.workspace.license.lean-more": "Lean more", + "com.affine.settings.workspace.license.self-host": "Selfhosted workspace", + "com.affine.settings.workspace.license.self-host-team": "Self-host Team Workspace", + "com.affine.settings.workspace.license.self-host-team.team.description": "This license will expire on {{expirationDate}}, with {{leftDays}} days remaining.", + "com.affine.settings.workspace.license.self-host-team.free.description": "Basic version: {{memberCount}} seats. For more, purchase or use activation key.", + "com.affine.settings.workspace.license.self-host-team.seats": "Seats", + "com.affine.settings.workspace.license.self-host-team.active-key": "Active key", + "com.affine.settings.workspace.license.self-host-team.deactivate-license": "Deactivate", + "com.affine.settings.workspace.license.buy-more-seat": "Buy more seat", + "com.affine.settings.workspace.license.activate-modal.title": "Activate License", + "com.affine.settings.workspace.license.activate-modal.description": "Enter license key to activate this self host workspace.", + "com.affine.settings.workspace.license.activate-modal.tips": "If you encounter any issues, please contact our <1>customer support.", + "com.affine.settings.workspace.license.activate-success": "License activated successfully.", + "com.affine.settings.workspace.license.deactivate-modal.title": "Deactivate License", + "com.affine.settings.workspace.license.deactivate-modal.description": "Are you sure you want to deactivate this license?", + "com.affine.settings.workspace.license.deactivate-success": "License deactivated successfully.", "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", @@ -1695,12 +1727,14 @@ "error.ACTION_FORBIDDEN": "You are not allowed to perform this action.", "error.ACCESS_DENIED": "You do not have permission to access this resource.", "error.EMAIL_VERIFICATION_REQUIRED": "You must verify your email before accessing this resource.", + "error.WORKSPACE_PERMISSION_NOT_FOUND": "Space {{spaceId}} permission not found.", "error.SPACE_NOT_FOUND": "Space {{spaceId}} not found.", "error.MEMBER_NOT_FOUND_IN_SPACE": "Member not found in Space {{spaceId}}.", "error.NOT_IN_SPACE": "You should join in Space {{spaceId}} before broadcasting messages.", "error.ALREADY_IN_SPACE": "You have already joined in Space {{spaceId}}.", "error.SPACE_ACCESS_DENIED": "You do not have permission to access Space {{spaceId}}.", "error.SPACE_OWNER_NOT_FOUND": "Owner of Space {{spaceId}} not found.", + "error.SPACE_SHOULD_HAVE_ONLY_ONE_OWNER": "Space should have only one owner.", "error.DOC_NOT_FOUND": "Doc {{docId}} under Space {{spaceId}} not found.", "error.DOC_ACCESS_DENIED": "You do not have permission to access doc {{docId}} under Space {{spaceId}}.", "error.VERSION_REJECTED": "Your client with version {{version}} is rejected by remote sync server. Please upgrade to {{serverVersion}}.", @@ -1709,9 +1743,13 @@ "error.BLOB_NOT_FOUND": "Blob {{blobId}} not found in Space {{spaceId}}.", "error.EXPECT_TO_PUBLISH_PAGE": "Expected to publish a page, not a Space.", "error.EXPECT_TO_REVOKE_PUBLIC_PAGE": "Expected to revoke a public page, not a Space.", + "error.EXPECT_TO_GRANT_DOC_USER_ROLES": "Expect grant roles on doc {{docId}} under Space {{spaceId}}, not a Space.", + "error.EXPECT_TO_REVOKE_DOC_USER_ROLES": "Expect revoke roles on doc {{docId}} under Space {{spaceId}}, not a Space.", + "error.EXPECT_TO_UPDATE_DOC_USER_ROLE": "Expect update roles on doc {{docId}} under Space {{spaceId}}, not a Space.", "error.PAGE_IS_NOT_PUBLIC": "Page is not public.", "error.FAILED_TO_SAVE_UPDATES": "Failed to store doc updates.", "error.FAILED_TO_UPSERT_SNAPSHOT": "Failed to store doc snapshot.", + "error.ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE": "A Team workspace is required to perform this action.", "error.UNSUPPORTED_SUBSCRIPTION_PLAN": "Unsupported subscription plan: {{plan}}.", "error.FAILED_TO_CHECKOUT": "Failed to create checkout session.", "error.INVALID_CHECKOUT_PARAMETERS": "Invalid checkout parameters provided.", diff --git a/tests/affine-local/e2e/local-first-delete-page.spec.ts b/tests/affine-local/e2e/local-first-delete-page.spec.ts index a2cccadccd..42b49a438c 100644 --- a/tests/affine-local/e2e/local-first-delete-page.spec.ts +++ b/tests/affine-local/e2e/local-first-delete-page.spec.ts @@ -71,7 +71,7 @@ test('page delete -> create new page -> refresh page -> new page should be appea .click(); await page.getByText('Delete permanently?').dblclick(); await page.getByRole('button', { name: 'Delete' }).click(); - expect(page.getByText("There's no page here yet")).not.toBeUndefined(); + expect(page.getByText('Deleted docs will appear here')).not.toBeUndefined(); await page.getByTestId('all-pages').click(); await clickNewPageButton(page); diff --git a/tools/@types/build-config/__all.d.ts b/tools/@types/build-config/__all.d.ts index 818ee9e9e8..a4a8997199 100644 --- a/tools/@types/build-config/__all.d.ts +++ b/tools/@types/build-config/__all.d.ts @@ -23,6 +23,7 @@ declare interface BUILD_CONFIG_TYPE { githubUrl: string; changelogUrl: string; + pricingUrl: string; downloadUrl: string; // see: tools/workers imageProxyUrl: string; diff --git a/tools/utils/src/build-config.ts b/tools/utils/src/build-config.ts index 4c871e9dc6..01c40a60ec 100644 --- a/tools/utils/src/build-config.ts +++ b/tools/utils/src/build-config.ts @@ -42,6 +42,7 @@ export function getBuildConfig( githubUrl: 'https://github.com/toeverything/AFFiNE', changelogUrl: 'https://affine.pro/what-is-new', downloadUrl: 'https://affine.pro/download', + pricingUrl: 'https://affine.pro/pricing', imageProxyUrl: '/api/worker/image-proxy', linkPreviewUrl: '/api/worker/link-preview', };