diff --git a/packages/backend/server/src/plugins/license/index.ts b/packages/backend/server/src/plugins/license/index.ts index f2480a2052..5aef0d83fa 100644 --- a/packages/backend/server/src/plugins/license/index.ts +++ b/packages/backend/server/src/plugins/license/index.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; +import { FeatureModule } from '../../core/features'; import { PermissionModule } from '../../core/permission'; import { QuotaModule } from '../../core/quota'; import { WorkspaceModule } from '../../core/workspaces'; -import { LicenseResolver } from './resolver'; +import { AdminLicenseResolver, LicenseResolver } from './resolver'; import { LicenseService } from './service'; @Module({ - imports: [QuotaModule, PermissionModule, WorkspaceModule], - providers: [LicenseService, LicenseResolver], + imports: [FeatureModule, QuotaModule, PermissionModule, WorkspaceModule], + providers: [LicenseService, LicenseResolver, AdminLicenseResolver], }) export class LicenseModule {} diff --git a/packages/backend/server/src/plugins/license/resolver.ts b/packages/backend/server/src/plugins/license/resolver.ts index 0108d401d0..f0f809f1b3 100644 --- a/packages/backend/server/src/plugins/license/resolver.ts +++ b/packages/backend/server/src/plugins/license/resolver.ts @@ -14,9 +14,14 @@ import GraphQLUpload, { import { toBuffer, UseNamedGuard } from '../../base'; import { CurrentUser } from '../../core/auth'; +import { Admin } from '../../core/common'; import { AccessController } from '../../core/permission'; import { WorkspaceType } from '../../core/workspaces'; -import { SubscriptionRecurring, SubscriptionVariant } from '../payment/types'; +import { + SubscriptionPlan, + SubscriptionRecurring, + SubscriptionVariant, +} from '../payment/types'; import { LicenseService } from './service'; @ObjectType() @@ -40,6 +45,42 @@ export class License { expiredAt!: Date | null; } +@ObjectType() +export class AdminLicensePreview { + @Field() + id!: string; + + @Field() + workspaceId!: string; + + @Field(() => SubscriptionPlan) + plan!: string; + + @Field(() => SubscriptionRecurring) + recurring!: string; + + @Field(() => Int) + quantity!: number; + + @Field(() => Date) + issuedAt!: Date; + + @Field(() => Date) + expiresAt!: Date; + + @Field(() => Date) + endAt!: Date; + + @Field() + entity!: string; + + @Field() + issuer!: string; + + @Field() + valid!: boolean; +} + @UseNamedGuard('selfhost') @Resolver(() => WorkspaceType) export class LicenseResolver { @@ -124,3 +165,18 @@ export class LicenseResolver { return license; } } + +@Admin() +@Resolver(() => AdminLicensePreview) +export class AdminLicenseResolver { + constructor(private readonly service: LicenseService) {} + + @Mutation(() => AdminLicensePreview) + async previewLicense( + @Args('license', { type: () => GraphQLUpload }) licenseFile: FileUpload + ) { + const buffer = await toBuffer(licenseFile.createReadStream()); + + return this.service.previewLicense(buffer); + } +} diff --git a/packages/backend/server/src/plugins/license/service.ts b/packages/backend/server/src/plugins/license/service.ts index a96a6647fc..41ea08352d 100644 --- a/packages/backend/server/src/plugins/license/service.ts +++ b/packages/backend/server/src/plugins/license/service.ts @@ -31,6 +31,20 @@ interface License { endAt: number; } +export interface LicensePreview { + id: string; + workspaceId: string; + plan: SubscriptionPlan.SelfHostedTeam; + recurring: SubscriptionRecurring; + quantity: number; + issuedAt: Date; + expiresAt: Date; + endAt: Date; + entity: string; + issuer: string; + valid: boolean; +} + const BaseLicenseSchema = z.object({ entity: z.string().nonempty(), issuer: z.string().nonempty(), @@ -167,6 +181,34 @@ export class LicenseService { return installed; } + previewLicense(license: Buffer): LicensePreview { + const payload = this.decryptWorkspaceTeamLicensePayload(license); + const data = payload.data; + const now = new Date(); + const expiresAt = new Date(payload.expiresAt); + const endAt = new Date(data.endAt); + + if (expiresAt < now || endAt < now) { + throw new InvalidLicenseToActivate({ + reason: 'Invalid license.', + }); + } + + return { + id: data.id, + workspaceId: data.workspaceId, + plan: data.plan, + recurring: data.recurring, + quantity: data.quantity, + issuedAt: new Date(payload.issuedAt), + expiresAt, + endAt, + entity: payload.entity, + issuer: payload.issuer, + valid: true, + }; + } + async activateTeamLicense(workspaceId: string, licenseKey: string) { const installedLicense = await this.getLicense(workspaceId); @@ -480,6 +522,25 @@ export class LicenseService { } private decryptWorkspaceTeamLicense(workspaceId: string, buf: Buffer) { + const payload = this.decryptWorkspaceTeamLicensePayload(buf); + + if (new Date(payload.expiresAt) < new Date()) { + throw new InvalidLicenseToActivate({ + reason: + 'License file has expired. Please contact with Affine support to fetch a latest one.', + }); + } + + if (payload.data.workspaceId !== workspaceId) { + throw new InvalidLicenseToActivate({ + reason: 'Workspace mismatched with license.', + }); + } + + return payload; + } + + private decryptWorkspaceTeamLicensePayload(buf: Buffer) { if (!this.crypto.AFFiNEProPublicKey) { throw new InternalServerError( 'License public key is not loaded. Please contact with Affine support.' @@ -514,19 +575,6 @@ export class LicenseService { }); } - if (new Date(parseResult.data.expiresAt) < new Date()) { - throw new InvalidLicenseToActivate({ - reason: - 'License file has expired. Please contact with Affine support to fetch a latest one.', - }); - } - - if (parseResult.data.data.workspaceId !== workspaceId) { - throw new InvalidLicenseToActivate({ - reason: 'Workspace mismatched with license.', - }); - } - return parseResult.data; } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 9d95c6ef3a..879cb3a73c 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -91,6 +91,20 @@ type AdminDashboardValueDayPoint { value: SafeInt! } +type AdminLicensePreview { + endAt: DateTime! + entity: String! + expiresAt: DateTime! + id: String! + issuedAt: DateTime! + issuer: String! + plan: SubscriptionPlan! + quantity: Int! + recurring: SubscriptionRecurring! + valid: Boolean! + workspaceId: String! +} + type AdminSharedLinkTopItem { docId: String! guestViews: SafeInt! @@ -1632,6 +1646,7 @@ type Mutation { """mention user in a doc""" mentionUser(input: MentionInput!): ID! + previewLicense(license: Upload!): AdminLicensePreview! publishDoc(docId: String!, mode: PublicDocMode = Page, workspaceId: String!): DocType! """queue workspace doc embedding""" diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index c9f6fee6e6..9f49ded6a5 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -2582,6 +2582,27 @@ ${licenseBodyFragment}`, file: true, }; +export const previewLicenseMutation = { + id: 'previewLicenseMutation' as const, + op: 'previewLicense', + query: `mutation previewLicense($license: Upload!) { + previewLicense(license: $license) { + id + workspaceId + plan + recurring + quantity + issuedAt + expiresAt + endAt + entity + issuer + valid + } +}`, + file: true, +}; + export const listNotificationsQuery = { id: 'listNotificationsQuery' as const, op: 'listNotifications', diff --git a/packages/common/graphql/src/graphql/license/preview-license.gql b/packages/common/graphql/src/graphql/license/preview-license.gql new file mode 100644 index 0000000000..3bfe0ac944 --- /dev/null +++ b/packages/common/graphql/src/graphql/license/preview-license.gql @@ -0,0 +1,15 @@ +mutation previewLicense($license: Upload!) { + previewLicense(license: $license) { + id + workspaceId + plan + recurring + quantity + issuedAt + expiresAt + endAt + entity + issuer + valid + } +} diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 7a6a0dbce7..41bfc8fa09 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -132,6 +132,21 @@ export interface AdminDashboardValueDayPoint { value: Scalars['SafeInt']['output']; } +export interface AdminLicensePreview { + __typename?: 'AdminLicensePreview'; + endAt: Scalars['DateTime']['output']; + entity: Scalars['String']['output']; + expiresAt: Scalars['DateTime']['output']; + id: Scalars['String']['output']; + issuedAt: Scalars['DateTime']['output']; + issuer: Scalars['String']['output']; + plan: SubscriptionPlan; + quantity: Scalars['Int']['output']; + recurring: SubscriptionRecurring; + valid: Scalars['Boolean']['output']; + workspaceId: Scalars['String']['output']; +} + export interface AdminSharedLinkTopItem { __typename?: 'AdminSharedLinkTopItem'; docId: Scalars['String']['output']; @@ -1831,6 +1846,7 @@ export interface Mutation { linkCalendarAccount: Scalars['String']['output']; /** mention user in a doc */ mentionUser: Scalars['ID']['output']; + previewLicense: AdminLicensePreview; publishDoc: DocType; /** queue workspace doc embedding */ queueWorkspaceEmbedding: Scalars['Boolean']['output']; @@ -2152,6 +2168,10 @@ export interface MutationMentionUserArgs { input: MentionInput; } +export interface MutationPreviewLicenseArgs { + license: Scalars['Upload']['input']; +} + export interface MutationPublishDocArgs { docId: Scalars['String']['input']; mode?: InputMaybe; @@ -7172,6 +7192,28 @@ export type LicenseBodyFragment = { variant: SubscriptionVariant | null; }; +export type PreviewLicenseMutationVariables = Exact<{ + license: Scalars['Upload']['input']; +}>; + +export type PreviewLicenseMutation = { + __typename?: 'Mutation'; + previewLicense: { + __typename?: 'AdminLicensePreview'; + id: string; + workspaceId: string; + plan: SubscriptionPlan; + recurring: SubscriptionRecurring; + quantity: number; + issuedAt: string; + expiresAt: string; + endAt: string; + entity: string; + issuer: string; + valid: boolean; + }; +}; + export type ListNotificationsQueryVariables = Exact<{ pagination: PaginationInput; }>; @@ -8742,6 +8784,11 @@ export type Mutations = variables: InstallLicenseMutationVariables; response: InstallLicenseMutation; } + | { + name: 'previewLicenseMutation'; + variables: PreviewLicenseMutationVariables; + response: PreviewLicenseMutation; + } | { name: 'mentionUserMutation'; variables: MentionUserMutationVariables; diff --git a/packages/frontend/admin/src/modules/dashboard/index.spec.tsx b/packages/frontend/admin/src/modules/dashboard/index.spec.tsx index 5b30288cb1..d54555c7c2 100644 --- a/packages/frontend/admin/src/modules/dashboard/index.spec.tsx +++ b/packages/frontend/admin/src/modules/dashboard/index.spec.tsx @@ -13,6 +13,9 @@ vi.mock('@affine/admin/use-query', () => ({ })); vi.mock('../../use-mutation', () => ({ + useMutation: () => ({ + trigger: vi.fn(), + }), useMutateQueryResource: () => () => { mutateQueryResourceMock(); return Promise.resolve(); diff --git a/packages/frontend/admin/src/modules/dashboard/index.tsx b/packages/frontend/admin/src/modules/dashboard/index.tsx index 89c344b228..766499ea8b 100644 --- a/packages/frontend/admin/src/modules/dashboard/index.tsx +++ b/packages/frontend/admin/src/modules/dashboard/index.tsx @@ -12,6 +12,20 @@ import { ChartTooltip, ChartTooltipContent, } from '@affine/admin/components/ui/chart'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@affine/admin/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@affine/admin/components/ui/dropdown-menu'; import { Label } from '@affine/admin/components/ui/label'; import { Select, @@ -30,18 +44,30 @@ import { TableHeader, TableRow, } from '@affine/admin/components/ui/table'; +import { useMutation } from '@affine/admin/use-mutation'; import { useQuery } from '@affine/admin/use-query'; -import { adminDashboardQuery } from '@affine/graphql'; +import { adminDashboardQuery, previewLicenseMutation } from '@affine/graphql'; import { ROUTES } from '@affine/routes'; import { + ChevronDownIcon, DatabaseIcon, + FileSearchIcon, MessageSquareTextIcon, RefreshCwIcon, UsersIcon, } from 'lucide-react'; -import { type ReactNode, Suspense, useMemo, useState } from 'react'; +import { + type ChangeEvent, + type ReactNode, + Suspense, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; import { Link } from 'react-router-dom'; import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; +import { toast } from 'sonner'; import { useMutateQueryResource } from '../../use-mutation'; import { Header } from '../header'; @@ -154,6 +180,20 @@ type TrendPoint = { secondary?: number; }; +type LicensePreview = { + id: string; + workspaceId: string; + plan: string; + recurring: string; + quantity: number; + issuedAt: string; + expiresAt: string; + endAt: string; + entity: string; + issuer: string; + valid: boolean; +}; + function formatDateTime(value: string) { return utcDateTimeFormatter.format(new Date(value)); } @@ -418,6 +458,198 @@ function WindowSelect({ ); } +function LicensePreviewDialog({ + license, + onOpenChange, +}: { + license: LicensePreview | null; + onOpenChange: (open: boolean) => void; +}) { + const rows = license + ? [ + ['Status', license.valid ? 'Valid' : 'Invalid'], + ['License ID', license.id], + ['Workspace ID', license.workspaceId], + ['Plan', license.plan], + ['Recurring', license.recurring], + ['Seats', intFormatter.format(license.quantity)], + ['Issued At', formatDateTime(license.issuedAt)], + ['File Expires At', formatDateTime(license.expiresAt)], + ['License Ends At', formatDateTime(license.endAt)], + ['Entity', license.entity], + ['Issuer', license.issuer], + ] + : []; + + return ( + + + + License Preview + + Signature and payload format are valid. + + +
+ {rows.map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+ + + +
+
+ ); +} + +function DashboardActions({ + updatedAt, + isValidating, + onRefresh, +}: { + updatedAt: string; + isValidating: boolean; + onRefresh: () => void; +}) { + const inputRef = useRef(null); + const pickerOpenRef = useRef(false); + const [licensePreview, setLicensePreview] = useState( + null + ); + const { trigger: previewLicense } = useMutation({ + mutation: previewLicenseMutation, + }); + + const notifyNoFileSelected = useCallback(() => { + toast.error('No license file selected.'); + }, []); + + const openLicensePicker = useCallback(() => { + const input = inputRef.current; + if (!input) { + toast.error('Failed to open license file picker.'); + return; + } + + input.value = ''; + pickerOpenRef.current = true; + + const handleFocus = () => { + window.setTimeout(() => { + if (pickerOpenRef.current) { + pickerOpenRef.current = false; + notifyNoFileSelected(); + } + }, 200); + window.removeEventListener('focus', handleFocus); + }; + + window.addEventListener('focus', handleFocus); + input.click(); + }, [notifyNoFileSelected]); + + const handleLicenseFileChange = useCallback( + (event: ChangeEvent) => { + const file = event.target.files?.[0]; + pickerOpenRef.current = false; + + if (!file) { + notifyNoFileSelected(); + return; + } + + previewLicense({ license: file }) + .then(data => { + setLicensePreview(data.previewLicense); + }) + .catch(error => { + console.error(error); + toast.error('Failed to preview license.'); + }); + }, + [notifyNoFileSelected, previewLicense] + ); + + const menuItems = useMemo( + () => + environment.isSelfHosted + ? [] + : [ + { + key: 'preview-license', + label: 'Preview license', + onSelect: openLicensePicker, + }, + ], + [openLicensePicker] + ); + + return ( + <> +
+ + Updated at {formatDateTime(updatedAt)} + +
+ + {menuItems.length ? ( + + + + + + {menuItems.map(item => ( + + + {item.label} + + ))} + + + ) : null} +
+
+ + { + if (!open) { + setLicensePreview(null); + } + }} + /> + + ); +} + function DashboardPageSkeleton() { return (
@@ -665,25 +897,13 @@ function DashboardPageContent() {
- - Updated at {formatDateTime(dashboard.generatedAt)} - - -
+ { + revalidateQueryResource(adminDashboardQuery).catch(() => {}); + }} + /> } />