chore: add utils

This commit is contained in:
DarkSky
2026-05-09 02:32:10 +08:00
parent 5813e7dd77
commit 32a94d68dc
9 changed files with 464 additions and 38 deletions
@@ -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 {}
@@ -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);
}
}
@@ -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;
}
+15
View File
@@ -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"""
@@ -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',
@@ -0,0 +1,15 @@
mutation previewLicense($license: Upload!) {
previewLicense(license: $license) {
id
workspaceId
plan
recurring
quantity
issuedAt
expiresAt
endAt
entity
issuer
valid
}
}
+47
View File
@@ -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<PublicDocMode>;
@@ -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;
@@ -13,6 +13,9 @@ vi.mock('@affine/admin/use-query', () => ({
}));
vi.mock('../../use-mutation', () => ({
useMutation: () => ({
trigger: vi.fn(),
}),
useMutateQueryResource: () => () => {
mutateQueryResourceMock();
return Promise.resolve();
@@ -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 (
<Dialog open={!!license} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>License Preview</DialogTitle>
<DialogDescription>
Signature and payload format are valid.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-border/60 overflow-hidden">
{rows.map(([label, value]) => (
<div
key={label}
className="grid grid-cols-[140px_1fr] gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="min-w-0 break-words text-sm font-medium tabular-nums">
{value}
</div>
</div>
))}
</div>
<DialogFooter className="mt-2">
<Button onClick={() => onOpenChange(false)}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function DashboardActions({
updatedAt,
isValidating,
onRefresh,
}: {
updatedAt: string;
isValidating: boolean;
onRefresh: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const pickerOpenRef = useRef(false);
const [licensePreview, setLicensePreview] = useState<LicensePreview | null>(
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<HTMLInputElement>) => {
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 (
<>
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="text-xs text-muted-foreground tabular-nums">
Updated at {formatDateTime(updatedAt)}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isValidating}
>
<RefreshCwIcon
className={`h-3.5 w-3.5 mr-1.5 ${isValidating ? 'animate-spin' : ''}`}
aria-hidden="true"
/>
Refresh
</Button>
{menuItems.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" aria-label="Dashboard menu">
<ChevronDownIcon className="h-3.5 w-3.5" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{menuItems.map(item => (
<DropdownMenuItem key={item.key} onSelect={item.onSelect}>
<FileSearchIcon className="mr-2 h-3.5 w-3.5" aria-hidden />
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
<input
ref={inputRef}
type="file"
accept=".lic,.license"
className="hidden"
onChange={handleLicenseFileChange}
/>
<LicensePreviewDialog
license={licensePreview}
onOpenChange={open => {
if (!open) {
setLicensePreview(null);
}
}}
/>
</>
);
}
function DashboardPageSkeleton() {
return (
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
@@ -665,25 +897,13 @@ function DashboardPageContent() {
<Header
title="Dashboard"
endFix={
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="text-xs text-muted-foreground tabular-nums">
Updated at {formatDateTime(dashboard.generatedAt)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
revalidateQueryResource(adminDashboardQuery).catch(() => {});
}}
disabled={isValidating}
>
<RefreshCwIcon
className={`h-3.5 w-3.5 mr-1.5 ${isValidating ? 'animate-spin' : ''}`}
aria-hidden="true"
/>
Refresh
</Button>
</div>
<DashboardActions
updatedAt={dashboard.generatedAt}
isValidating={isValidating}
onRefresh={() => {
revalidateQueryResource(adminDashboardQuery).catch(() => {});
}}
/>
}
/>