Skip to content

feat(core): add self host team plan #9569

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions packages/backend/server/src/plugins/license/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class LicenseService implements OnModuleInit {
throw new WorkspaceLicenseAlreadyExists();
}

const data = await this.fetch<License>(
const data = await this.fetchAffinePro<License>(
`/api/team/licenses/${licenseKey}/activate`,
{
method: 'POST',
Expand Down Expand Up @@ -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',
});

Expand All @@ -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,
Expand All @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -276,7 +277,7 @@ export class LicenseService implements OnModuleInit {

private async revalidateLicense(license: InstalledLicense) {
try {
const res = await this.fetch<License>(
const res = await this.fetchAffinePro<License>(
`/api/team/licenses/${license.key}/health`,
{
headers: {
Expand Down Expand Up @@ -325,7 +326,7 @@ export class LicenseService implements OnModuleInit {
}
}

private async fetch<T = any>(
private async fetchAffinePro<T = any>(
path: string,
init?: RequestInit
): Promise<T & { res: Response }> {
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/server/src/plugins/payment/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ class CreateCheckoutSessionInput implements z.infer<typeof CheckoutParams> {
idempotencyKey?: string;

@Field(() => GraphQLJSONObject, { nullable: true })
args!: { workspaceId?: string; quantity?: number };
args!: { workspaceId?: string; quantity?: number } | null;
}

@Resolver(() => SubscriptionType)
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', {
Expand All @@ -54,18 +41,19 @@ export const CloudQuotaModal = () => {
}, [workspaceDialogService, setOpen]);

const description = useMemo(() => {
if (userQuota && isOwner) {
return <OwnerDescription quota={userQuota.blobLimit} />;
}
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 (
<OwnerDescription quota={workspaceQuota.humanReadable.blobLimit} />
);
}

return t['com.affine.payment.blob-limit.description.member']({
quota: workspaceQuota.humanReadable.blobLimit,
});
}, [isOwner, workspaceQuota, t]);

const onAbortLargeBlob = useAsyncCallback(
async (byteSize: number) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -44,6 +47,8 @@ export const WorkspaceSetting = ({
return <WorkspaceSettingBilling />;
case 'workspace:storage':
return <WorkspaceSettingStorage onCloseSetting={onCloseSetting} />;
case 'workspace:license':
return <WorkspaceSettingLicense onCloseSetting={onCloseSetting} />;
default:
return null;
}
Expand All @@ -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<SettingSidebarItem[]>(() => {
return [
{
Expand Down Expand Up @@ -88,10 +102,14 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
icon: <PaymentIcon />,
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: <PaymentIcon />,
testId: 'workspace-setting:license',
},
].filter((item): item is SettingSidebarItem => !!item);
}, [showBilling, t]);
}, [showBilling, showLicense, t]);

return items;
};
Original file line number Diff line number Diff line change
@@ -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 (
<FrameworkScope scope={workspace.scope}>
<SettingHeader
title={t['com.affine.settings.workspace.license']()}
subtitle={t['com.affine.settings.workspace.license.description']()}
/>
<SelfHostTeamPlan />
{workspace.flavour === 'local' ? (
<EnableCloudPanel onCloseSetting={onCloseSetting} />
) : (
<>
<SelfHostTeamCard />
<TypeFormLink />
<PaymentMethodUpdater />
</>
)}
</FrameworkScope>
);
};

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 (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-type-form.title']()}
desc={t['com.affine.payment.billing-type-form.description']()}
>
<a target="_blank" href={link} rel="noreferrer">
<Button>{t['com.affine.payment.billing-type-form.go']()}</Button>
</a>
</SettingRow>
);
};

const PaymentMethodUpdater = () => {
const 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 (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-setting.payment-method']()}
desc={t[
'com.affine.payment.billing-setting.payment-method.description'
]()}
>
<Button onClick={update} loading={isMutating} disabled={isMutating}>
{t['com.affine.payment.billing-setting.payment-method.go']()}
</Button>
</SettingRow>
);
};
Loading
Loading