mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-10 13:38:49 +08:00
chore: add utils
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { FeatureModule } from '../../core/features';
|
||||||
import { PermissionModule } from '../../core/permission';
|
import { PermissionModule } from '../../core/permission';
|
||||||
import { QuotaModule } from '../../core/quota';
|
import { QuotaModule } from '../../core/quota';
|
||||||
import { WorkspaceModule } from '../../core/workspaces';
|
import { WorkspaceModule } from '../../core/workspaces';
|
||||||
import { LicenseResolver } from './resolver';
|
import { AdminLicenseResolver, LicenseResolver } from './resolver';
|
||||||
import { LicenseService } from './service';
|
import { LicenseService } from './service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [QuotaModule, PermissionModule, WorkspaceModule],
|
imports: [FeatureModule, QuotaModule, PermissionModule, WorkspaceModule],
|
||||||
providers: [LicenseService, LicenseResolver],
|
providers: [LicenseService, LicenseResolver, AdminLicenseResolver],
|
||||||
})
|
})
|
||||||
export class LicenseModule {}
|
export class LicenseModule {}
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ import GraphQLUpload, {
|
|||||||
|
|
||||||
import { toBuffer, UseNamedGuard } from '../../base';
|
import { toBuffer, UseNamedGuard } from '../../base';
|
||||||
import { CurrentUser } from '../../core/auth';
|
import { CurrentUser } from '../../core/auth';
|
||||||
|
import { Admin } from '../../core/common';
|
||||||
import { AccessController } from '../../core/permission';
|
import { AccessController } from '../../core/permission';
|
||||||
import { WorkspaceType } from '../../core/workspaces';
|
import { WorkspaceType } from '../../core/workspaces';
|
||||||
import { SubscriptionRecurring, SubscriptionVariant } from '../payment/types';
|
import {
|
||||||
|
SubscriptionPlan,
|
||||||
|
SubscriptionRecurring,
|
||||||
|
SubscriptionVariant,
|
||||||
|
} from '../payment/types';
|
||||||
import { LicenseService } from './service';
|
import { LicenseService } from './service';
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
@@ -40,6 +45,42 @@ export class License {
|
|||||||
expiredAt!: Date | null;
|
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')
|
@UseNamedGuard('selfhost')
|
||||||
@Resolver(() => WorkspaceType)
|
@Resolver(() => WorkspaceType)
|
||||||
export class LicenseResolver {
|
export class LicenseResolver {
|
||||||
@@ -124,3 +165,18 @@ export class LicenseResolver {
|
|||||||
return license;
|
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;
|
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({
|
const BaseLicenseSchema = z.object({
|
||||||
entity: z.string().nonempty(),
|
entity: z.string().nonempty(),
|
||||||
issuer: z.string().nonempty(),
|
issuer: z.string().nonempty(),
|
||||||
@@ -167,6 +181,34 @@ export class LicenseService {
|
|||||||
return installed;
|
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) {
|
async activateTeamLicense(workspaceId: string, licenseKey: string) {
|
||||||
const installedLicense = await this.getLicense(workspaceId);
|
const installedLicense = await this.getLicense(workspaceId);
|
||||||
|
|
||||||
@@ -480,6 +522,25 @@ export class LicenseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private decryptWorkspaceTeamLicense(workspaceId: string, buf: Buffer) {
|
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) {
|
if (!this.crypto.AFFiNEProPublicKey) {
|
||||||
throw new InternalServerError(
|
throw new InternalServerError(
|
||||||
'License public key is not loaded. Please contact with Affine support.'
|
'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;
|
return parseResult.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,20 @@ type AdminDashboardValueDayPoint {
|
|||||||
value: SafeInt!
|
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 {
|
type AdminSharedLinkTopItem {
|
||||||
docId: String!
|
docId: String!
|
||||||
guestViews: SafeInt!
|
guestViews: SafeInt!
|
||||||
@@ -1632,6 +1646,7 @@ type Mutation {
|
|||||||
|
|
||||||
"""mention user in a doc"""
|
"""mention user in a doc"""
|
||||||
mentionUser(input: MentionInput!): ID!
|
mentionUser(input: MentionInput!): ID!
|
||||||
|
previewLicense(license: Upload!): AdminLicensePreview!
|
||||||
publishDoc(docId: String!, mode: PublicDocMode = Page, workspaceId: String!): DocType!
|
publishDoc(docId: String!, mode: PublicDocMode = Page, workspaceId: String!): DocType!
|
||||||
|
|
||||||
"""queue workspace doc embedding"""
|
"""queue workspace doc embedding"""
|
||||||
|
|||||||
@@ -2582,6 +2582,27 @@ ${licenseBodyFragment}`,
|
|||||||
file: true,
|
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 = {
|
export const listNotificationsQuery = {
|
||||||
id: 'listNotificationsQuery' as const,
|
id: 'listNotificationsQuery' as const,
|
||||||
op: 'listNotifications',
|
op: 'listNotifications',
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
mutation previewLicense($license: Upload!) {
|
||||||
|
previewLicense(license: $license) {
|
||||||
|
id
|
||||||
|
workspaceId
|
||||||
|
plan
|
||||||
|
recurring
|
||||||
|
quantity
|
||||||
|
issuedAt
|
||||||
|
expiresAt
|
||||||
|
endAt
|
||||||
|
entity
|
||||||
|
issuer
|
||||||
|
valid
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,21 @@ export interface AdminDashboardValueDayPoint {
|
|||||||
value: Scalars['SafeInt']['output'];
|
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 {
|
export interface AdminSharedLinkTopItem {
|
||||||
__typename?: 'AdminSharedLinkTopItem';
|
__typename?: 'AdminSharedLinkTopItem';
|
||||||
docId: Scalars['String']['output'];
|
docId: Scalars['String']['output'];
|
||||||
@@ -1831,6 +1846,7 @@ export interface Mutation {
|
|||||||
linkCalendarAccount: Scalars['String']['output'];
|
linkCalendarAccount: Scalars['String']['output'];
|
||||||
/** mention user in a doc */
|
/** mention user in a doc */
|
||||||
mentionUser: Scalars['ID']['output'];
|
mentionUser: Scalars['ID']['output'];
|
||||||
|
previewLicense: AdminLicensePreview;
|
||||||
publishDoc: DocType;
|
publishDoc: DocType;
|
||||||
/** queue workspace doc embedding */
|
/** queue workspace doc embedding */
|
||||||
queueWorkspaceEmbedding: Scalars['Boolean']['output'];
|
queueWorkspaceEmbedding: Scalars['Boolean']['output'];
|
||||||
@@ -2152,6 +2168,10 @@ export interface MutationMentionUserArgs {
|
|||||||
input: MentionInput;
|
input: MentionInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationPreviewLicenseArgs {
|
||||||
|
license: Scalars['Upload']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationPublishDocArgs {
|
export interface MutationPublishDocArgs {
|
||||||
docId: Scalars['String']['input'];
|
docId: Scalars['String']['input'];
|
||||||
mode?: InputMaybe<PublicDocMode>;
|
mode?: InputMaybe<PublicDocMode>;
|
||||||
@@ -7172,6 +7192,28 @@ export type LicenseBodyFragment = {
|
|||||||
variant: SubscriptionVariant | null;
|
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<{
|
export type ListNotificationsQueryVariables = Exact<{
|
||||||
pagination: PaginationInput;
|
pagination: PaginationInput;
|
||||||
}>;
|
}>;
|
||||||
@@ -8742,6 +8784,11 @@ export type Mutations =
|
|||||||
variables: InstallLicenseMutationVariables;
|
variables: InstallLicenseMutationVariables;
|
||||||
response: InstallLicenseMutation;
|
response: InstallLicenseMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'previewLicenseMutation';
|
||||||
|
variables: PreviewLicenseMutationVariables;
|
||||||
|
response: PreviewLicenseMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'mentionUserMutation';
|
name: 'mentionUserMutation';
|
||||||
variables: MentionUserMutationVariables;
|
variables: MentionUserMutationVariables;
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ vi.mock('@affine/admin/use-query', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../use-mutation', () => ({
|
vi.mock('../../use-mutation', () => ({
|
||||||
|
useMutation: () => ({
|
||||||
|
trigger: vi.fn(),
|
||||||
|
}),
|
||||||
useMutateQueryResource: () => () => {
|
useMutateQueryResource: () => () => {
|
||||||
mutateQueryResourceMock();
|
mutateQueryResourceMock();
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ import {
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from '@affine/admin/components/ui/chart';
|
} 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 { Label } from '@affine/admin/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -30,18 +44,30 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@affine/admin/components/ui/table';
|
} from '@affine/admin/components/ui/table';
|
||||||
|
import { useMutation } from '@affine/admin/use-mutation';
|
||||||
import { useQuery } from '@affine/admin/use-query';
|
import { useQuery } from '@affine/admin/use-query';
|
||||||
import { adminDashboardQuery } from '@affine/graphql';
|
import { adminDashboardQuery, previewLicenseMutation } from '@affine/graphql';
|
||||||
import { ROUTES } from '@affine/routes';
|
import { ROUTES } from '@affine/routes';
|
||||||
import {
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
|
FileSearchIcon,
|
||||||
MessageSquareTextIcon,
|
MessageSquareTextIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from 'lucide-react';
|
} 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 { Link } from 'react-router-dom';
|
||||||
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { useMutateQueryResource } from '../../use-mutation';
|
import { useMutateQueryResource } from '../../use-mutation';
|
||||||
import { Header } from '../header';
|
import { Header } from '../header';
|
||||||
@@ -154,6 +180,20 @@ type TrendPoint = {
|
|||||||
secondary?: number;
|
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) {
|
function formatDateTime(value: string) {
|
||||||
return utcDateTimeFormatter.format(new Date(value));
|
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() {
|
function DashboardPageSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
|
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
|
||||||
@@ -665,25 +897,13 @@ function DashboardPageContent() {
|
|||||||
<Header
|
<Header
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
endFix={
|
endFix={
|
||||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
<DashboardActions
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">
|
updatedAt={dashboard.generatedAt}
|
||||||
Updated at {formatDateTime(dashboard.generatedAt)}
|
isValidating={isValidating}
|
||||||
</span>
|
onRefresh={() => {
|
||||||
<Button
|
revalidateQueryResource(adminDashboardQuery).catch(() => {});
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user