Skip to content

Commit 60f237c

Browse files
committed
feat(core): add self-host license tab to workspace setting
1 parent 047e035 commit 60f237c

File tree

21 files changed

+914
-91
lines changed

21 files changed

+914
-91
lines changed

packages/backend/server/src/plugins/license/service.ts

+8-7
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export class LicenseService {
5555
throw new WorkspaceLicenseAlreadyExists();
5656
}
5757

58-
const data = await this.fetch<License>(
58+
const data = await this.fetchAffinePro<License>(
5959
`/api/team/licenses/${licenseKey}/activate`,
6060
{
6161
method: 'POST',
@@ -105,7 +105,7 @@ export class LicenseService {
105105
throw new LicenseNotFound();
106106
}
107107

108-
await this.fetch(`/api/team/licenses/${license.key}/deactivate`, {
108+
await this.fetchAffinePro(`/api/team/licenses/${license.key}/deactivate`, {
109109
method: 'POST',
110110
});
111111

@@ -120,10 +120,11 @@ export class LicenseService {
120120
plan: SubscriptionPlan.SelfHostedTeam,
121121
recurring: SubscriptionRecurring.Monthly,
122122
});
123+
return true;
123124
}
124125

125126
async updateTeamRecurring(key: string, recurring: SubscriptionRecurring) {
126-
await this.fetch(`/api/team/licenses/${key}/recurring`, {
127+
await this.fetchAffinePro(`/api/team/licenses/${key}/recurring`, {
127128
method: 'POST',
128129
body: JSON.stringify({
129130
recurring,
@@ -142,7 +143,7 @@ export class LicenseService {
142143
throw new LicenseNotFound();
143144
}
144145

145-
return this.fetch<{ url: string }>(
146+
return this.fetchAffinePro<{ url: string }>(
146147
`/api/team/licenses/${license.key}/create-customer-portal`,
147148
{
148149
method: 'POST',
@@ -164,7 +165,7 @@ export class LicenseService {
164165
return;
165166
}
166167

167-
await this.fetch(`/api/team/licenses/${license.key}/seats`, {
168+
await this.fetchAffinePro(`/api/team/licenses/${license.key}/seats`, {
168169
method: 'POST',
169170
body: JSON.stringify({
170171
quantity: count,
@@ -218,7 +219,7 @@ export class LicenseService {
218219

219220
private async revalidateLicense(license: InstalledLicense) {
220221
try {
221-
const res = await this.fetch<License>(
222+
const res = await this.fetchAffinePro<License>(
222223
`/api/team/licenses/${license.key}/health`
223224
);
224225

@@ -262,7 +263,7 @@ export class LicenseService {
262263
}
263264
}
264265

265-
private async fetch<T = any>(
266+
private async fetchAffinePro<T = any>(
266267
path: string,
267268
init?: RequestInit
268269
): Promise<T & { res: Response }> {

packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx

+11-23
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { ConfirmModal } from '@affine/component/ui/modal';
22
import { openQuotaModalAtom } from '@affine/core/components/atoms';
3-
import { UserQuotaService } from '@affine/core/modules/cloud';
43
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
54
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
65
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
@@ -30,18 +29,6 @@ export const CloudQuotaModal = () => {
3029
permissionService.permission.revalidate();
3130
}, [permissionService]);
3231

33-
const quotaService = useService(UserQuotaService);
34-
const userQuota = useLiveData(
35-
quotaService.quota.quota$.map(q =>
36-
q
37-
? {
38-
name: q.humanReadable.name,
39-
blobLimit: q.humanReadable.blobLimit,
40-
}
41-
: null
42-
)
43-
);
44-
4532
const workspaceDialogService = useService(WorkspaceDialogService);
4633
const handleUpgradeConfirm = useCallback(() => {
4734
workspaceDialogService.open('setting', {
@@ -54,18 +41,19 @@ export const CloudQuotaModal = () => {
5441
}, [workspaceDialogService, setOpen]);
5542

5643
const description = useMemo(() => {
57-
if (userQuota && isOwner) {
58-
return <OwnerDescription quota={userQuota.blobLimit} />;
59-
}
60-
if (workspaceQuota) {
61-
return t['com.affine.payment.blob-limit.description.member']({
62-
quota: workspaceQuota.humanReadable.blobLimit,
63-
});
64-
} else {
65-
// loading
44+
if (!workspaceQuota) {
6645
return null;
6746
}
68-
}, [userQuota, isOwner, workspaceQuota, t]);
47+
if (isOwner) {
48+
return (
49+
<OwnerDescription quota={workspaceQuota.humanReadable.blobLimit} />
50+
);
51+
}
52+
53+
return t['com.affine.payment.blob-limit.description.member']({
54+
quota: workspaceQuota.humanReadable.blobLimit,
55+
});
56+
}, [isOwner, workspaceQuota, t]);
6957

7058
const onAbortLargeBlob = useAsyncCallback(
7159
async (byteSize: number) => {

packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx

+23-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
2+
import { ServerService } from '@affine/core/modules/cloud';
23
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
34
import { WorkspaceService } from '@affine/core/modules/workspace';
5+
import { ServerDeploymentType } from '@affine/graphql';
46
import { useI18n } from '@affine/i18n';
57
import {
68
CollaborationIcon,
@@ -9,11 +11,12 @@ import {
911
SaveIcon,
1012
SettingsIcon,
1113
} from '@blocksuite/icons/rc';
12-
import { useService } from '@toeverything/infra';
14+
import { useLiveData, useService } from '@toeverything/infra';
1315
import { useMemo } from 'react';
1416

1517
import type { SettingSidebarItem, SettingState } from '../types';
1618
import { WorkspaceSettingBilling } from './billing';
19+
import { WorkspaceSettingLicense } from './license';
1720
import { MembersPanel } from './members';
1821
import { WorkspaceSettingDetail } from './preference';
1922
import { WorkspaceSettingProperties } from './properties';
@@ -44,6 +47,10 @@ export const WorkspaceSetting = ({
4447
return <WorkspaceSettingBilling />;
4548
case 'workspace:storage':
4649
return <WorkspaceSettingStorage onCloseSetting={onCloseSetting} />;
50+
case 'workspace:license':
51+
return (
52+
<WorkspaceSettingLicense onChangeSettingState={onChangeSettingState} />
53+
);
4754
default:
4855
return null;
4956
}
@@ -52,10 +59,18 @@ export const WorkspaceSetting = ({
5259
export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
5360
const workspaceService = useService(WorkspaceService);
5461
const information = useWorkspaceInfo(workspaceService.workspace);
62+
const serverService = useService(ServerService);
63+
64+
const isSelfhosted = useLiveData(
65+
serverService.server.config$.selector(
66+
c => c.type === ServerDeploymentType.Selfhosted
67+
)
68+
);
5569

5670
const t = useI18n();
5771

5872
const showBilling = information?.isTeam && information?.isOwner;
73+
const showLicense = information?.isOwner && isSelfhosted;
5974
const items = useMemo<SettingSidebarItem[]>(() => {
6075
return [
6176
{
@@ -88,10 +103,14 @@ export const useWorkspaceSettingList = (): SettingSidebarItem[] => {
88103
icon: <PaymentIcon />,
89104
testId: 'workspace-setting:billing',
90105
},
91-
92-
// todo(@pengx17): add selfhost's team license
106+
showLicense && {
107+
key: 'workspace:license' as SettingTab,
108+
title: t['com.affine.settings.workspace.license'](),
109+
icon: <PaymentIcon />,
110+
testId: 'workspace-setting:license',
111+
},
93112
].filter((item): item is SettingSidebarItem => !!item);
94-
}, [showBilling, t]);
113+
}, [showBilling, showLicense, t]);
95114

96115
return items;
97116
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { Button, Switch } from '@affine/component';
2+
import {
3+
SettingHeader,
4+
SettingRow,
5+
} from '@affine/component/setting-components';
6+
import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
7+
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
8+
import { useMutation } from '@affine/core/components/hooks/use-mutation';
9+
import {
10+
AuthService,
11+
WorkspaceSubscriptionService,
12+
} from '@affine/core/modules/cloud';
13+
import { UrlService } from '@affine/core/modules/url';
14+
import { WorkspaceService } from '@affine/core/modules/workspace';
15+
import {
16+
createCustomerPortalMutation,
17+
SubscriptionPlan,
18+
SubscriptionRecurring,
19+
} from '@affine/graphql';
20+
import { useI18n } from '@affine/i18n';
21+
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
22+
import { useCallback, useEffect, useState } from 'react';
23+
24+
import type { SettingState } from '../../types';
25+
import { SelfHostTeamCard } from './self-host-team-card';
26+
import * as styles from './styles.css';
27+
28+
export const WorkspaceSettingLicense = ({
29+
onChangeSettingState,
30+
}: {
31+
onChangeSettingState: (state: SettingState) => void;
32+
}) => {
33+
const workspace = useService(WorkspaceService).workspace;
34+
35+
const t = useI18n();
36+
37+
const subscriptionService = workspace?.scope.get(
38+
WorkspaceSubscriptionService
39+
);
40+
const subscription = useLiveData(
41+
subscriptionService?.subscription.subscription$
42+
);
43+
44+
// TODO(@JimmFly): add sign in check
45+
46+
useEffect(() => {
47+
subscriptionService?.subscription.revalidate();
48+
}, [subscriptionService?.subscription]);
49+
50+
if (workspace === null) {
51+
return null;
52+
}
53+
54+
return (
55+
<FrameworkScope scope={workspace.scope}>
56+
<SettingHeader
57+
title={t['com.affine.settings.workspace.license']()}
58+
subtitle={t['com.affine.settings.workspace.license.description']()}
59+
/>
60+
{workspace.flavour !== 'local' ? (
61+
<>
62+
<SelfHostTeamCard onChangeSettingState={onChangeSettingState} />
63+
<TypeFormLink />
64+
<PaymentMethodUpdater />
65+
<Checkout />
66+
{subscription?.end ? (
67+
<ResumeSubscription expirationDate={subscription.end} />
68+
) : null}
69+
</>
70+
) : (
71+
'Self-hosted Team workspace depends on cloud workspace, please sign in and enable cloud first.'
72+
)}
73+
</FrameworkScope>
74+
);
75+
};
76+
77+
const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => {
78+
const t = useI18n();
79+
const handleClick = useCallback(() => {
80+
window.open('', '_blank');
81+
}, []);
82+
83+
return (
84+
<SettingRow
85+
name={t['com.affine.payment.billing-setting.expiration-date']()}
86+
desc={t['com.affine.payment.billing-setting.expiration-date.description'](
87+
{
88+
expirationDate: new Date(expirationDate).toLocaleDateString(),
89+
}
90+
)}
91+
>
92+
<Button onClick={handleClick} variant="primary">
93+
{t['com.affine.settings.workspace.license.buy-more-seat']()}
94+
</Button>
95+
</SettingRow>
96+
);
97+
};
98+
99+
const TypeFormLink = () => {
100+
const t = useI18n();
101+
const workspaceSubscriptionService = useService(WorkspaceSubscriptionService);
102+
const authService = useService(AuthService);
103+
104+
const workspaceSubscription = useLiveData(
105+
workspaceSubscriptionService.subscription.subscription$
106+
);
107+
const account = useLiveData(authService.session.account$);
108+
109+
if (!account) return null;
110+
111+
const link = getUpgradeQuestionnaireLink({
112+
name: account.info?.name,
113+
id: account.id,
114+
email: account.email,
115+
recurring: workspaceSubscription?.recurring ?? SubscriptionRecurring.Yearly,
116+
plan: SubscriptionPlan.SelfHostedTeam,
117+
});
118+
119+
return (
120+
<SettingRow
121+
className={styles.paymentMethod}
122+
name={t['com.affine.payment.billing-type-form.title']()}
123+
desc={t['com.affine.payment.billing-type-form.description']()}
124+
>
125+
<a target="_blank" href={link} rel="noreferrer">
126+
<Button>{t['com.affine.payment.billing-type-form.go']()}</Button>
127+
</a>
128+
</SettingRow>
129+
);
130+
};
131+
132+
const PaymentMethodUpdater = () => {
133+
const { isMutating, trigger } = useMutation({
134+
mutation: createCustomerPortalMutation,
135+
});
136+
const urlService = useService(UrlService);
137+
const t = useI18n();
138+
139+
const update = useAsyncCallback(async () => {
140+
await trigger(null, {
141+
onSuccess: data => {
142+
urlService.openPopupWindow(data.createCustomerPortal);
143+
},
144+
});
145+
}, [trigger, urlService]);
146+
147+
return (
148+
<SettingRow
149+
className={styles.paymentMethod}
150+
name={t['com.affine.payment.billing-setting.payment-method']()}
151+
desc={t[
152+
'com.affine.payment.billing-setting.payment-method.description'
153+
]()}
154+
>
155+
<Button onClick={update} loading={isMutating} disabled={isMutating}>
156+
{t['com.affine.payment.billing-setting.payment-method.go']()}
157+
</Button>
158+
</SettingRow>
159+
);
160+
};
161+
162+
const Checkout = () => {
163+
const t = useI18n();
164+
const [recurring, setRecurring] = useState(SubscriptionRecurring.Monthly);
165+
const onCheckout = useCallback(() => {
166+
// https://affine.fail/
167+
// http://localhost:8080/
168+
window.open(
169+
`http://localhost:8080/subscribe?product=${recurring === SubscriptionRecurring.Monthly ? 'monthly' : 'yearly'}-selfhost-team`,
170+
'_blank'
171+
);
172+
}, [recurring]);
173+
const toggleSwitch = useCallback((checked: boolean) => {
174+
if (checked) {
175+
setRecurring(SubscriptionRecurring.Yearly);
176+
return;
177+
}
178+
setRecurring(SubscriptionRecurring.Monthly);
179+
}, []);
180+
return (
181+
<SettingRow
182+
className={styles.paymentMethod}
183+
name={
184+
<Switch
185+
checked={recurring === SubscriptionRecurring.Yearly}
186+
onChange={toggleSwitch}
187+
>
188+
{t['com.affine.payment.cloud.pricing-plan.toggle-billed-yearly']()}
189+
</Switch>
190+
}
191+
desc={''}
192+
>
193+
<Button onClick={onCheckout}>Checkout</Button>
194+
</SettingRow>
195+
);
196+
};

0 commit comments

Comments
 (0)