feat(core): support install license for self hosted client (#12287)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added the ability to upload and replace license files for self-hosted team workspaces via a new modal dialog.
  - Introduced clearer UI flows for activating, deactivating, and managing team licenses, including one-time purchase licenses.
  - Provided direct links and guidance for requesting licenses and managing payments.

- **Enhancements**
  - Improved license management interface with updated button labels and descriptions.
  - Added new styles for better layout and clarity in license dialogs.
  - Updated internationalization with new and revised texts for license operations.

- **Bug Fixes**
  - Prevented duplicate opening of license or plan dialogs when already open.

- **Chores**
  - Updated support and pricing links for clarity and accuracy.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
JimmFly
2025-05-27 04:56:14 +00:00
parent 382c237dac
commit 1b715e588c
13 changed files with 714 additions and 75 deletions
@@ -36,9 +36,9 @@ const links = [
label: 'Self-host Document', label: 'Self-host Document',
}, },
{ {
href: 'https://affine.pro/pricing', href: 'https://affine.pro/pricing/?type=selfhost#table',
icon: <UploadCloudIcon size={20} />, icon: <UploadCloudIcon size={20} />,
label: 'Upgrade to Pro', label: 'Upgrade to Team',
}, },
]; ];
@@ -8,6 +8,7 @@ import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hoo
import { useMutation } from '@affine/core/components/hooks/use-mutation'; import { useMutation } from '@affine/core/components/hooks/use-mutation';
import { import {
AuthService, AuthService,
SelfhostLicenseService,
WorkspaceSubscriptionService, WorkspaceSubscriptionService,
} from '@affine/core/modules/cloud'; } from '@affine/core/modules/cloud';
import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { WorkspacePermissionService } from '@affine/core/modules/permissions';
@@ -18,14 +19,17 @@ import {
createSelfhostCustomerPortalMutation, createSelfhostCustomerPortalMutation,
SubscriptionPlan, SubscriptionPlan,
SubscriptionRecurring, SubscriptionRecurring,
SubscriptionVariant,
} from '@affine/graphql'; } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra'; import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { EnableCloudPanel } from '../preference/enable-cloud'; import { EnableCloudPanel } from '../preference/enable-cloud';
import { SelfHostTeamCard } from './self-host-team-card'; import { SelfHostTeamCard } from './self-host-team-card';
import { SelfHostTeamPlan } from './self-host-team-plan'; import { SelfHostTeamPlan } from './self-host-team-plan';
import * as styles from './styles.css'; import * as styles from './styles.css';
import { UploadLicenseModal } from './upload-license-modal';
export const WorkspaceSettingLicense = ({ export const WorkspaceSettingLicense = ({
onCloseSetting, onCloseSetting,
@@ -52,6 +56,7 @@ export const WorkspaceSettingLicense = ({
) : ( ) : (
<> <>
<SelfHostTeamCard /> <SelfHostTeamCard />
<ReplaceLicenseModal />
<TypeFormLink /> <TypeFormLink />
<PaymentMethodUpdater /> <PaymentMethodUpdater />
</> </>
@@ -60,6 +65,52 @@ export const WorkspaceSettingLicense = ({
); );
}; };
const ReplaceLicenseModal = () => {
const t = useI18n();
const selfhostLicenseService = useService(SelfhostLicenseService);
const license = useLiveData(selfhostLicenseService.license$);
const isOneTimePurchase = license?.variant === SubscriptionVariant.Onetime;
const permission = useService(WorkspacePermissionService).permission;
const isTeam = useLiveData(permission.isTeam$);
const [openUploadModal, setOpenUploadModal] = useState(false);
const handleClick = useCallback(() => {
setOpenUploadModal(true);
}, []);
useEffect(() => {
selfhostLicenseService.revalidate();
}, [selfhostLicenseService]);
if (!isTeam || !isOneTimePurchase) {
return null;
}
return (
<>
<SettingRow
className={styles.paymentMethod}
name={t[
'com.affine.settings.workspace.license.self-host-team.replace-license.title'
]()}
desc={t[
'com.affine.settings.workspace.license.self-host-team.replace-license.description'
]()}
>
<Button onClick={handleClick}>
{t[
'com.affine.settings.workspace.license.self-host-team.replace-license.upload'
]()}
</Button>
</SettingRow>
<UploadLicenseModal
open={openUploadModal}
onOpenChange={setOpenUploadModal}
/>
</>
);
};
const TypeFormLink = () => { const TypeFormLink = () => {
const t = useI18n(); const t = useI18n();
const workspaceSubscriptionService = useService(WorkspaceSubscriptionService); const workspaceSubscriptionService = useService(WorkspaceSubscriptionService);
@@ -1,20 +1,29 @@
import { Button, ConfirmModal, Input, notify } from '@affine/component'; import { Button, ConfirmModal, Input, Modal, notify } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components'; import { SettingRow } from '@affine/component/setting-components';
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud'; import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useMutation } from '@affine/core/components/hooks/use-mutation';
import { import {
SelfhostLicenseService, SelfhostLicenseService,
WorkspaceSubscriptionService, WorkspaceSubscriptionService,
} from '@affine/core/modules/cloud'; } from '@affine/core/modules/cloud';
import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceQuotaService } from '@affine/core/modules/quota'; import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { UrlService } from '@affine/core/modules/url';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/error'; import { UserFriendlyError } from '@affine/error';
import {
createSelfhostCustomerPortalMutation,
SubscriptionVariant,
} from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n'; import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import * as styles from './styles.css'; import * as styles from './styles.css';
import { UploadLicenseModal } from './upload-license-modal';
export const SelfHostTeamCard = () => { export const SelfHostTeamCard = () => {
const t = useI18n(); const t = useI18n();
@@ -30,11 +39,13 @@ export const SelfHostTeamCard = () => {
const isLocalWorkspace = workspace.flavour === 'local'; const isLocalWorkspace = workspace.flavour === 'local';
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
const [openUploadModal, setOpenUploadModal] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const selfhostLicenseService = useService(SelfhostLicenseService); const selfhostLicenseService = useService(SelfhostLicenseService);
const license = useLiveData(selfhostLicenseService.license$); const license = useLiveData(selfhostLicenseService.license$);
const isOneTimePurchase = license?.variant === SubscriptionVariant.Onetime;
useEffect(() => { const revalidate = useCallback(() => {
permission.revalidate(); permission.revalidate();
workspaceQuotaService.quota.revalidate(); workspaceQuotaService.quota.revalidate();
workspaceSubscriptionService.subscription.revalidate(); workspaceSubscriptionService.subscription.revalidate();
@@ -46,24 +57,44 @@ export const SelfHostTeamCard = () => {
workspaceSubscriptionService, workspaceSubscriptionService,
]); ]);
useEffect(() => {
revalidate();
}, [revalidate]);
const description = useMemo(() => { const description = useMemo(() => {
if (isTeam) { if (isTeam) {
return t[ return (
'com.affine.settings.workspace.license.self-host-team.team.description' <div>
]({ <p>
expirationDate: new Date(license?.expiredAt || 0).toLocaleDateString(), {t[
leftDays: Math.floor( 'com.affine.settings.workspace.license.self-host-team.team.description'
(new Date(license?.expiredAt || 0).getTime() - Date.now()) / ]({
(1000 * 60 * 60 * 24) expirationDate: new Date(
).toLocaleString(), license?.expiredAt || 0
}); ).toLocaleDateString(),
leftDays: Math.floor(
(new Date(license?.expiredAt || 0).getTime() - Date.now()) /
(1000 * 60 * 60 * 24)
).toLocaleString(),
})}
</p>
{isOneTimePurchase ? (
<p>
<Trans
i18nKey="com.affine.settings.workspace.license.self-host-team.team.license"
components={{ 1: <strong /> }}
/>
</p>
) : null}
</div>
);
} }
return t[ return t[
'com.affine.settings.workspace.license.self-host-team.free.description' 'com.affine.settings.workspace.license.self-host-team.free.description'
]({ ]({
memberCount: workspaceQuota?.humanReadable.memberLimit || '10', memberCount: workspaceQuota?.humanReadable.memberLimit || '10',
}); });
}, [isTeam, license, t, workspaceQuota]); }, [isOneTimePurchase, isTeam, license, t, workspaceQuota]);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (isLocalWorkspace) { if (isLocalWorkspace) {
confirmEnableCloud(workspace); confirmEnableCloud(workspace);
@@ -72,6 +103,10 @@ export const SelfHostTeamCard = () => {
setOpenModal(true); setOpenModal(true);
}, [confirmEnableCloud, isLocalWorkspace, workspace]); }, [confirmEnableCloud, isLocalWorkspace, workspace]);
const handleOpenUploadModal = useCallback(() => {
setOpenUploadModal(true);
}, []);
const onActivate = useCallback( const onActivate = useCallback(
(license: string) => { (license: string) => {
setLoading(true); setLoading(true);
@@ -80,8 +115,7 @@ export const SelfHostTeamCard = () => {
.then(() => { .then(() => {
setLoading(false); setLoading(false);
setOpenModal(false); setOpenModal(false);
permission.revalidate(); revalidate();
selfhostLicenseService.revalidate();
notify.success({ notify.success({
title: title:
t['com.affine.settings.workspace.license.activate-success'](), t['com.affine.settings.workspace.license.activate-success'](),
@@ -99,7 +133,7 @@ export const SelfHostTeamCard = () => {
}); });
}); });
}, },
[permission, selfhostLicenseService, t, workspace.id] [revalidate, selfhostLicenseService, t, workspace.id]
); );
const onDeactivate = useCallback(() => { const onDeactivate = useCallback(() => {
@@ -109,7 +143,7 @@ export const SelfHostTeamCard = () => {
.then(() => { .then(() => {
setLoading(false); setLoading(false);
setOpenModal(false); setOpenModal(false);
permission.revalidate(); revalidate();
notify.success({ notify.success({
title: title:
t['com.affine.settings.workspace.license.deactivate-success'](), t['com.affine.settings.workspace.license.deactivate-success'](),
@@ -126,7 +160,7 @@ export const SelfHostTeamCard = () => {
message: error.message, message: error.message,
}); });
}); });
}, [permission, selfhostLicenseService, t, workspace.id]); }, [revalidate, selfhostLicenseService, t, workspace.id]);
const handleConfirm = useCallback( const handleConfirm = useCallback(
(license: string) => { (license: string) => {
@@ -163,7 +197,7 @@ export const SelfHostTeamCard = () => {
]()} ]()}
</span> </span>
<span> <span>
{isTeam {isTeam && !isOneTimePurchase
? license?.quantity || '' ? license?.quantity || ''
: `${workspaceQuota?.memberCount}/${workspaceQuota?.memberLimit}`} : `${workspaceQuota?.memberCount}/${workspaceQuota?.memberLimit}`}
</span> </span>
@@ -174,13 +208,24 @@ export const SelfHostTeamCard = () => {
left: isTeam || isLocalWorkspace, left: isTeam || isLocalWorkspace,
})} })}
> >
{!isTeam && !isLocalWorkspace ? (
<Button
variant="plain"
className={styles.uploadButton}
onClick={handleOpenUploadModal}
>
{t[
'com.affine.settings.workspace.license.self-host-team.upload-license-file'
]()}
</Button>
) : null}
<Button <Button
variant="primary" variant="primary"
className={styles.activeButton} className={styles.activeButton}
onClick={handleClick} onClick={handleClick}
> >
{t[ {t[
`com.affine.settings.workspace.license.self-host-team.${isTeam ? 'deactivate-license' : 'active-key'}` `com.affine.settings.workspace.license.self-host-team.${isTeam ? 'deactivate-license' : 'use-purchased-key'}`
]()} ]()}
</Button> </Button>
</div> </div>
@@ -191,6 +236,11 @@ export const SelfHostTeamCard = () => {
isTeam={!!isTeam} isTeam={!!isTeam}
loading={loading} loading={loading}
onConfirm={handleConfirm} onConfirm={handleConfirm}
isOneTimePurchase={isOneTimePurchase}
/>
<UploadLicenseModal
open={openUploadModal}
onOpenChange={setOpenUploadModal}
/> />
</> </>
); );
@@ -202,16 +252,41 @@ const ActionModal = ({
isTeam, isTeam,
onConfirm, onConfirm,
loading, loading,
isOneTimePurchase,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
isTeam: boolean; isTeam: boolean;
loading: boolean; loading: boolean;
isOneTimePurchase: boolean;
onConfirm: (key: string) => void; onConfirm: (key: string) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const [key, setKey] = useState(''); const [key, setKey] = useState('');
const workspace = useService(WorkspaceService).workspace;
const { isMutating, trigger } = useMutation({
mutation: createSelfhostCustomerPortalMutation,
});
const urlService = useService(UrlService);
const update = useAsyncCallback(async () => {
await trigger(
{
workspaceId: workspace.id,
},
{
onSuccess: data => {
urlService.openExternal(data.createSelfhostWorkspaceCustomerPortal);
},
}
).catch(e => {
const userFriendlyError = UserFriendlyError.fromAny(e);
notify.error(userFriendlyError);
});
}, [trigger, urlService, workspace.id]);
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
onConfirm(key); onConfirm(key);
setKey(''); setKey('');
@@ -225,23 +300,96 @@ const ActionModal = ({
[onOpenChange] [onOpenChange]
); );
const handleCancel = useCallback(() => {
setKey('');
onOpenChange(false);
}, [onOpenChange]);
if (isTeam && isOneTimePurchase) {
return (
<ConfirmModal
width={480}
open={open}
onOpenChange={handleOpenChange}
title={t[
`com.affine.settings.workspace.license.deactivate-modal.title`
]()}
description={t[
'com.affine.settings.workspace.license.deactivate-modal.description-license'
]()}
cancelText={t['Cancel']()}
cancelButtonOptions={{
variant: 'secondary',
}}
confirmText={t['Confirm']()}
confirmButtonOptions={{
loading: loading,
disabled: loading,
variant: 'primary',
}}
onConfirm={handleConfirm}
childrenContentClassName={styles.activateModalContent}
/>
);
}
if (isTeam) {
return (
<Modal
width={480}
open={open}
onOpenChange={handleOpenChange}
title={t[
`com.affine.settings.workspace.license.deactivate-modal.title`
]()}
description={
<Trans
i18nKey="com.affine.settings.workspace.license.deactivate-modal.description"
components={{
1: <strong />,
}}
/>
}
>
<div className={styles.footer}>
<Button
variant="secondary"
onClick={update}
loading={isMutating}
disabled={isMutating}
>
{t[
'com.affine.settings.workspace.license.deactivate-modal.manage-payment'
]()}
</Button>
<div className={styles.rightActions}>
<Button variant="secondary" onClick={handleCancel}>
{t['Cancel']()}
</Button>
<Button variant="primary" onClick={handleConfirm}>
{t['Confirm']()}
</Button>
</div>
</div>
</Modal>
);
}
return ( return (
<ConfirmModal <ConfirmModal
width={480} width={480}
open={open} open={open}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
title={t[ title={t['com.affine.settings.workspace.license.activate-modal.title']()}
`com.affine.settings.workspace.license.${isTeam ? 'deactivate' : 'activate'}-modal.title`
]()}
description={t[ description={t[
`com.affine.settings.workspace.license.${isTeam ? 'deactivate' : 'activate'}-modal.description` 'com.affine.settings.workspace.license.activate-modal.description'
]()} ]()}
cancelText={t['Cancel']()} cancelText={t['Cancel']()}
cancelButtonOptions={{ cancelButtonOptions={{
variant: 'secondary', variant: 'secondary',
}} }}
contentOptions={{ contentOptions={{
['data-testid' as string]: 'invite-modal', ['data-testid' as string]: 'license-modal',
style: { style: {
padding: '20px 24px', padding: '20px 24px',
}, },
@@ -249,46 +397,32 @@ const ActionModal = ({
confirmText={t['Confirm']()} confirmText={t['Confirm']()}
confirmButtonOptions={{ confirmButtonOptions={{
loading: loading, loading: loading,
variant: isTeam ? 'error' : 'primary', variant: 'primary',
disabled: loading || (!isTeam && !key), disabled: loading || (!isTeam && !key),
}} }}
onConfirm={handleConfirm} onConfirm={handleConfirm}
childrenContentClassName={styles.activateModalContent} childrenContentClassName={styles.activateModalContent}
> >
{isTeam ? null : ( <Input
<> value={key}
<Input onChange={setKey}
value={key} placeholder="AAAA-AAAA-AAAA-AAAA-AAAA"
onChange={setKey} />
placeholder="AAAA-AAAA-AAAA-AAAA-AAAA" <span className={styles.tips}>
/> <Trans
<span> i18nKey="com.affine.settings.workspace.license.activate-modal.tips"
<Trans components={{
i18nKey="com.affine.settings.workspace.license.activate-modal.tips" 1: (
components={{ <a
1: ( href="https://affine.pro/pricing/?type=selfhost#table"
<a target="_blank"
href="mailto:support@toeverything.info" rel="noreferrer"
style={{ color: 'var(--affine-link-color)' }} style={{ color: cssVarV2('text/link') }}
> />
customer support ),
</a> }}
), />
2: ( </span>
<a
href="https://affine.pro/pricing/?type=selfhost#table"
target="_blank"
rel="noreferrer"
style={{ color: 'var(--affine-link-color)' }}
>
click to purchase
</a>
),
}}
/>
</span>
</>
)}
</ConfirmModal> </ConfirmModal>
); );
}; };
@@ -53,6 +53,10 @@ export const buttonContainer = style({
export const activeButton = style({ export const activeButton = style({
marginTop: '8px', marginTop: '8px',
}); });
export const uploadButton = style({
marginTop: '8px',
marginRight: '9px',
});
export const seat = style({ export const seat = style({
fontSize: cssVar('fontXs'), fontSize: cssVar('fontXs'),
@@ -72,3 +76,17 @@ export const tips = style({
color: cssVarV2('text/secondary'), color: cssVarV2('text/secondary'),
fontSize: cssVar('fontSm'), fontSize: cssVar('fontSm'),
}); });
export const footer = style({
marginTop: 'auto',
marginBottom: '0',
display: 'flex',
alignItems: 'center',
paddingTop: '20px',
justifyContent: 'space-between',
});
export const rightActions = style({
display: 'flex',
gap: '20px',
});
@@ -0,0 +1,109 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const activateModalContent = style({
display: 'flex',
flexDirection: 'column',
gap: '12px',
marginTop: '12px',
});
export const tipsContainer = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '8px',
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontSm'),
borderRadius: '8px',
backgroundColor: cssVarV2('layer/background/tertiary'),
});
export const tipsTitle = style({
fontWeight: 500,
});
export const tipsContent = style({
fontSize: cssVar('fontSm'),
});
export const textLink = style({
color: cssVarV2('text/link'),
selectors: {
'&:visited': {
color: cssVarV2('text/link'),
},
},
});
export const workspaceIdContainer = style({
display: 'flex',
justifyContent: 'space-between',
fontSize: cssVar('fontXs'),
alignItems: 'center',
'@media': {
'screen and (max-width: 768px)': {
flexWrap: 'wrap',
},
},
});
export const workspaceIdLabel = style({
fontWeight: 500,
});
export const copyButton = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
padding: '4px 12px',
borderRadius: '8px',
width: '100%',
maxWidth: '300px',
});
export const copyButtonContent = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
});
export const copyButtonText = style({
fontSize: cssVar('fontXs'),
padding: '0 4px',
color: cssVarV2('text/secondary'),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
flex: 1,
});
export const copyIcon = style({
width: '16px',
height: '16px',
color: cssVarV2('icon/primary'),
});
export const uploadButton = style({
width: '100%',
});
export const uploadButtonContent = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
justifyContent: 'center',
});
export const uploadButtonIcon = style({
width: '16px',
height: '16px',
color: cssVarV2('layer/pureWhite'),
});
export const footer = style({
display: 'flex',
fontSize: cssVar('fontSm'),
fontWeight: 400,
color: cssVarV2('text/secondary'),
});
@@ -0,0 +1,203 @@
import { Button, Modal, notify, useConfirmModal } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { Upload } from '@affine/core/components/pure/file-upload';
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 { copyTextToClipboard } from '@affine/core/utils/clipboard';
import { UserFriendlyError } from '@affine/error';
import { Trans, useI18n } from '@affine/i18n';
import { CopyIcon, FileIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import * as styles from './upload-license-modal.css';
export const UploadLicenseModal = ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
const t = useI18n();
const workspaceService = useService(WorkspaceService);
const workspace = workspaceService.workspace;
const licenseService = useService(SelfhostLicenseService);
const quotaService = useService(WorkspaceQuotaService);
const workspaceSubscriptionService = useService(WorkspaceSubscriptionService);
const permission = useService(WorkspacePermissionService).permission;
const { openConfirmModal } = useConfirmModal();
const [isInstalling, setIsInstalling] = useState(false);
const revalidate = useCallback(() => {
permission.revalidate();
quotaService.quota.revalidate();
workspaceSubscriptionService.subscription.revalidate();
licenseService.revalidate();
}, [licenseService, permission, quotaService, workspaceSubscriptionService]);
const handleInstallLicense = useAsyncCallback(
async (file: File) => {
setIsInstalling(true);
try {
await licenseService.installLicense(workspace.id, file);
revalidate();
onOpenChange(false);
openConfirmModal({
title:
t[
'com.affine.settings.workspace.license.self-host-team.upload-license-file.success.title'
](),
description:
t[
'com.affine.settings.workspace.license.self-host-team.upload-license-file.success.description'
](),
confirmText: t['Confirm'](),
cancelButtonOptions: {
style: {
display: 'none',
},
},
confirmButtonOptions: {
variant: 'primary',
},
});
} catch (e) {
const err = UserFriendlyError.fromAny(e);
onOpenChange(false);
console.error(err);
openConfirmModal({
title:
t[
'com.affine.settings.workspace.license.self-host-team.upload-license-file.failed'
](),
description: err.message,
confirmText: t['Confirm'](),
cancelButtonOptions: {
style: {
display: 'none',
},
},
confirmButtonOptions: {
variant: 'primary',
},
});
}
setIsInstalling(false);
},
[
licenseService,
onOpenChange,
openConfirmModal,
revalidate,
t,
workspace.id,
]
);
const handleOpenChange = useCallback(
(open: boolean) => {
onOpenChange(open);
},
[onOpenChange]
);
const copyWorkspaceId = useCallback(() => {
copyTextToClipboard(workspace.id)
.then(success => {
if (success) {
notify.success({ title: t['Copied link to clipboard']() });
}
})
.catch(err => {
console.error(err);
});
}, [t, workspace.id]);
useEffect(() => {
revalidate();
}, [revalidate]);
return (
<Modal
width={480}
open={open}
onOpenChange={handleOpenChange}
title={t[
'com.affine.settings.workspace.license.self-host-team.upload-license-file'
]()}
description={t[
'com.affine.settings.workspace.license.self-host-team.upload-license-file.description'
]()}
>
<div className={styles.activateModalContent}>
<div className={styles.tipsContainer}>
<div className={styles.tipsTitle}>
{t[
'com.affine.settings.workspace.license.self-host-team.upload-license-file.tips.title'
]()}
</div>
<div className={styles.tipsContent}>
<Trans
i18nKey="com.affine.settings.workspace.license.self-host-team.upload-license-file.tips.content"
components={{
1: (
<a
href={`${BUILD_CONFIG.requestLicenseUrl}?usp=pp_url&entry.1000023=${workspace.id}`}
target="_blank"
rel="noreferrer"
className={styles.textLink}
/>
),
}}
/>
</div>
<div className={styles.workspaceIdContainer}>
<div className={styles.workspaceIdLabel}>
{t[
'com.affine.settings.workspace.license.self-host-team.upload-license-file.tips.workspace-id'
]()}
</div>
<Button
variant="secondary"
onClick={copyWorkspaceId}
className={styles.copyButton}
>
<span className={styles.copyButtonContent}>
<span className={styles.copyButtonText} title={workspace.id}>
{workspace.id}
</span>
<CopyIcon className={styles.copyIcon} />
</span>
</Button>
</div>
</div>
<Upload accept=".lic, .license" fileChange={handleInstallLicense}>
<Button
variant="primary"
className={styles.uploadButton}
loading={isInstalling}
disabled={isInstalling}
>
<span className={styles.uploadButtonContent}>
<FileIcon className={styles.uploadButtonIcon} />
{t[
'com.affine.settings.workspace.license.self-host-team.upload-license-file.click-to-upload'
]()}
</span>
</Button>
</Upload>
<div className={styles.footer}>
{t[
'com.affine.settings.workspace.license.self-host-team.upload-license-file.help'
]()}
</div>
</div>
</Modal>
);
};
@@ -56,6 +56,10 @@ export class SelfhostLicenseService extends Service {
return await this.store.deactivate(workspaceId); return await this.store.deactivate(workspaceId);
} }
async installLicense(workspaceId: string, licenseFile: File) {
return await this.store.installLicense(workspaceId, licenseFile);
}
clear() { clear() {
this.license$.next(null); this.license$.next(null);
this.error$.next(null); this.error$.next(null);
@@ -2,6 +2,7 @@ import {
activateLicenseMutation, activateLicenseMutation,
deactivateLicenseMutation, deactivateLicenseMutation,
getLicenseQuery, getLicenseQuery,
installLicenseMutation,
} from '@affine/graphql'; } from '@affine/graphql';
import { Store } from '@toeverything/infra'; import { Store } from '@toeverything/infra';
@@ -63,4 +64,26 @@ export class SelfhostLicenseStore extends Store {
return data.deactivateLicense; return data.deactivateLicense;
} }
async installLicense(
workspaceId: string,
license: File,
signal?: AbortSignal
) {
if (!this.workspaceServerService.server) {
throw new Error('No Server');
}
const data = await this.workspaceServerService.server.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspaceId,
license: license,
},
context: {
signal,
},
});
return data.installLicense;
}
} }
@@ -37,17 +37,27 @@ export const QuotaCheck = ({
const isOwner = profile?.isOwner; const isOwner = profile?.isOwner;
const isTeam = profile?.isTeam; const isTeam = profile?.isTeam;
const workspaceDialogService = useService(WorkspaceDialogService); const workspaceDialogService = useService(WorkspaceDialogService);
const dialog = useLiveData(workspaceDialogService.dialogs$);
const t = useI18n(); const t = useI18n();
const onConfirm = useCallback(() => { const onConfirm = useCallback(() => {
if (!isOwner) { if (!isOwner) {
return; return;
} }
if (
dialog.some(
d =>
(d.type === 'setting' && d.props.activeTab === 'plans') ||
(d.type === 'setting' && d.props.activeTab === 'workspace:license')
)
) {
return;
}
workspaceDialogService.open('setting', { workspaceDialogService.open('setting', {
activeTab: 'plans', activeTab: 'plans',
scrollAnchor: 'cloudPricingPlan', scrollAnchor: 'cloudPricingPlan',
}); });
}, [workspaceDialogService, isOwner]); }, [dialog, isOwner, workspaceDialogService]);
useEffect(() => { useEffect(() => {
workspaceQuota?.revalidate(); workspaceQuota?.revalidate();
+77 -8
View File
@@ -4454,7 +4454,7 @@ export function useAFFiNEI18N(): {
*/ */
["com.affine.payment.license-success.text-1"](): string; ["com.affine.payment.license-success.text-1"](): string;
/** /**
* `You can use this key to upgrade in Settings > Workspace > Billing > Upgrade` * `You can use this key to upgrade in Settings > Workspace > License > Use purchased key`
*/ */
["com.affine.payment.license-success.hint"](): string; ["com.affine.payment.license-success.hint"](): string;
/** /**
@@ -6025,13 +6025,61 @@ export function useAFFiNEI18N(): {
*/ */
["com.affine.settings.workspace.license.self-host-team.seats"](): string; ["com.affine.settings.workspace.license.self-host-team.seats"](): string;
/** /**
* `Active key` * `Use purchased key`
*/ */
["com.affine.settings.workspace.license.self-host-team.active-key"](): string; ["com.affine.settings.workspace.license.self-host-team.use-purchased-key"](): string;
/**
* `Upload license file`
*/
["com.affine.settings.workspace.license.self-host-team.upload-license-file"](): string;
/**
* `Upload license file locally and verify the license information.`
*/
["com.affine.settings.workspace.license.self-host-team.upload-license-file.description"](): string;
/**
* `To purchase a license:`
*/
["com.affine.settings.workspace.license.self-host-team.upload-license-file.tips.title"](): string;
/**
* `Workspace id`
*/
["com.affine.settings.workspace.license.self-host-team.upload-license-file.tips.workspace-id"](): string;
/**
* `Click to upload`
*/
["com.affine.settings.workspace.license.self-host-team.upload-license-file.click-to-upload"](): string;
/**
* `Activation failed`
*/
["com.affine.settings.workspace.license.self-host-team.upload-license-file.failed"](): string;
/**
* `Activation Success`
*/
["com.affine.settings.workspace.license.self-host-team.upload-license-file.success.title"](): string;
/**
* `License has been successfully applied`
*/
["com.affine.settings.workspace.license.self-host-team.upload-license-file.success.description"](): string;
/**
* `If you encounter any issues, contact support@toeverything.info.`
*/
["com.affine.settings.workspace.license.self-host-team.upload-license-file.help"](): string;
/** /**
* `Deactivate` * `Deactivate`
*/ */
["com.affine.settings.workspace.license.self-host-team.deactivate-license"](): string; ["com.affine.settings.workspace.license.self-host-team.deactivate-license"](): string;
/**
* `Replace your license file`
*/
["com.affine.settings.workspace.license.self-host-team.replace-license.title"](): string;
/**
* `Replace the existing license file with a new, updated version.`
*/
["com.affine.settings.workspace.license.self-host-team.replace-license.description"](): string;
/**
* `Upload license file`
*/
["com.affine.settings.workspace.license.self-host-team.replace-license.upload"](): string;
/** /**
* `Buy more seat` * `Buy more seat`
*/ */
@@ -6049,13 +6097,17 @@ export function useAFFiNEI18N(): {
*/ */
["com.affine.settings.workspace.license.activate-success"](): string; ["com.affine.settings.workspace.license.activate-success"](): string;
/** /**
* `Deactivate License` * `Confirm deactivation?`
*/ */
["com.affine.settings.workspace.license.deactivate-modal.title"](): string; ["com.affine.settings.workspace.license.deactivate-modal.title"](): string;
/** /**
* `Are you sure you want to deactivate this license?` * `After deactivation, you will need to upload a new license to continue using team feature`
*/ */
["com.affine.settings.workspace.license.deactivate-modal.description"](): string; ["com.affine.settings.workspace.license.deactivate-modal.description-license"](): string;
/**
* `Manage Payment`
*/
["com.affine.settings.workspace.license.deactivate-modal.manage-payment"](): string;
/** /**
* `License deactivated successfully.` * `License deactivated successfully.`
*/ */
@@ -9123,11 +9175,28 @@ export const TypedTrans: {
["1"]: JSX.Element; ["1"]: JSX.Element;
}>>; }>>;
/** /**
* `If you encounter any issues, please contact our <1>customer support</1>. No license yet? <2>Click to purchase</2>.` * `Activate using the local key from <1>Toeverything.Inc</1>`
*/
["com.affine.settings.workspace.license.self-host-team.team.license"]: ComponentType<TypedTransProps<Readonly<{}>, {
["1"]: JSX.Element;
}>>;
/**
* `Copy your workspace id and <1>reach out to us</1>.`
*/
["com.affine.settings.workspace.license.self-host-team.upload-license-file.tips.content"]: ComponentType<TypedTransProps<Readonly<{}>, {
["1"]: JSX.Element;
}>>;
/**
* `If you encounter any issues, contact support@toeverything.info. No license yet? <1>Click to purchase</1>.`
*/ */
["com.affine.settings.workspace.license.activate-modal.tips"]: ComponentType<TypedTransProps<Readonly<{}>, { ["com.affine.settings.workspace.license.activate-modal.tips"]: ComponentType<TypedTransProps<Readonly<{}>, {
["1"]: JSX.Element; ["1"]: JSX.Element;
["2"]: JSX.Element; }>>;
/**
* `This will make the workspace read-only. Your key remains usable elsewhere. Deactivation doesn't cancel your Team plan. To cancel, go to <1>Manage Payment</1>.`
*/
["com.affine.settings.workspace.license.deactivate-modal.description"]: ComponentType<TypedTransProps<Readonly<{}>, {
["1"]: JSX.Element;
}>>; }>>;
/** /**
* `The "<1>{{ name }}</1>" property will be removed. This action cannot be undone.` * `The "<1>{{ name }}</1>" property will be removed. This action cannot be undone.`
+21 -5
View File
@@ -1103,7 +1103,7 @@
"com.affine.payment.license-success.title": "Thank you for your purchase!", "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-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</1>.", "com.affine.payment.license-success.text-2": "If you have any questions, please contact our <1>customer support</1>.",
"com.affine.payment.license-success.hint": "You can use this key to upgrade in Settings > Workspace > Billing > Upgrade", "com.affine.payment.license-success.hint": "You can use this key to upgrade in Settings > Workspace > License > Use purchased key",
"com.affine.payment.license-success.open-affine": "Open AFFiNE", "com.affine.payment.license-success.open-affine": "Open AFFiNE",
"com.affine.payment.license-success.copy": "Copied key to clipboard", "com.affine.payment.license-success.copy": "Copied key to clipboard",
"com.affine.peek-view-controls.close": "Close", "com.affine.peek-view-controls.close": "Close",
@@ -1498,17 +1498,33 @@
"com.affine.settings.workspace.license.self-host": "Selfhosted workspace", "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": "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.team.description": "This license will expire on {{expirationDate}}, with {{leftDays}} days remaining.",
"com.affine.settings.workspace.license.self-host-team.team.license": "Activate using the local key from <1>Toeverything.Inc</1>",
"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.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.seats": "Seats",
"com.affine.settings.workspace.license.self-host-team.active-key": "Active key", "com.affine.settings.workspace.license.self-host-team.use-purchased-key": "Use purchased key",
"com.affine.settings.workspace.license.self-host-team.upload-license-file": "Upload license file",
"com.affine.settings.workspace.license.self-host-team.upload-license-file.description": "Upload license file locally and verify the license information.",
"com.affine.settings.workspace.license.self-host-team.upload-license-file.tips.title": "To purchase a license:",
"com.affine.settings.workspace.license.self-host-team.upload-license-file.tips.content": "Copy your workspace id and <1>reach out to us</1>.",
"com.affine.settings.workspace.license.self-host-team.upload-license-file.tips.workspace-id": "Workspace id",
"com.affine.settings.workspace.license.self-host-team.upload-license-file.click-to-upload": "Click to upload",
"com.affine.settings.workspace.license.self-host-team.upload-license-file.failed": "Activation failed",
"com.affine.settings.workspace.license.self-host-team.upload-license-file.success.title": "Activation Success",
"com.affine.settings.workspace.license.self-host-team.upload-license-file.success.description": "License has been successfully applied",
"com.affine.settings.workspace.license.self-host-team.upload-license-file.help": "If you encounter any issues, contact support@toeverything.info.",
"com.affine.settings.workspace.license.self-host-team.deactivate-license": "Deactivate", "com.affine.settings.workspace.license.self-host-team.deactivate-license": "Deactivate",
"com.affine.settings.workspace.license.self-host-team.replace-license.title": "Replace your license file",
"com.affine.settings.workspace.license.self-host-team.replace-license.description": "Replace the existing license file with a new, updated version.",
"com.affine.settings.workspace.license.self-host-team.replace-license.upload": "Upload license file",
"com.affine.settings.workspace.license.buy-more-seat": "Buy more seat", "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.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.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</1>. No license yet? <2>Click to purchase</2>.", "com.affine.settings.workspace.license.activate-modal.tips": "If you encounter any issues, contact support@toeverything.info. No license yet? <1>Click to purchase</1>.",
"com.affine.settings.workspace.license.activate-success": "License activated successfully.", "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.title": "Confirm deactivation?",
"com.affine.settings.workspace.license.deactivate-modal.description": "Are you sure you want to deactivate this license?", "com.affine.settings.workspace.license.deactivate-modal.description": "This will make the workspace read-only. Your key remains usable elsewhere. Deactivation doesn't cancel your Team plan. To cancel, go to <1>Manage Payment</1>.",
"com.affine.settings.workspace.license.deactivate-modal.description-license": "After deactivation, you will need to upload a new license to continue using team feature",
"com.affine.settings.workspace.license.deactivate-modal.manage-payment": "Manage Payment",
"com.affine.settings.workspace.license.deactivate-success": "License deactivated successfully.", "com.affine.settings.workspace.license.deactivate-success": "License deactivated successfully.",
"com.affine.settings.workspace.state.local": "Local", "com.affine.settings.workspace.state.local": "Local",
"com.affine.settings.workspace.state.sync-affine-cloud": "Sync with AFFiNE Cloud", "com.affine.settings.workspace.state.sync-affine-cloud": "Sync with AFFiNE Cloud",
+1
View File
@@ -30,6 +30,7 @@ declare interface BUILD_CONFIG_TYPE {
pricingUrl: string; pricingUrl: string;
downloadUrl: string; downloadUrl: string;
discordUrl: string; discordUrl: string;
requestLicenseUrl: string;
// see: tools/workers // see: tools/workers
imageProxyUrl: string; imageProxyUrl: string;
linkPreviewUrl: string; linkPreviewUrl: string;
+1
View File
@@ -48,6 +48,7 @@ export function getBuildConfig(
downloadUrl: 'https://affine.pro/download', downloadUrl: 'https://affine.pro/download',
pricingUrl: 'https://affine.pro/pricing', pricingUrl: 'https://affine.pro/pricing',
discordUrl: 'https://affine.pro/redirect/discord', discordUrl: 'https://affine.pro/redirect/discord',
requestLicenseUrl: 'https://affine.pro/redirect/license',
imageProxyUrl: '/api/worker/image-proxy', imageProxyUrl: '/api/worker/image-proxy',
linkPreviewUrl: '/api/worker/link-preview', linkPreviewUrl: '/api/worker/link-preview',
CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '', CAPTCHA_SITE_KEY: process.env.CAPTCHA_SITE_KEY ?? '',