From a0d69f43b6a458e974f05b01a98e6e5c0c33df82 Mon Sep 17 00:00:00 2001 From: rustmailer Date: Tue, 25 Aug 2026 17:03:25 +0800 Subject: [PATCH] update --- web/src/api/license/api.ts | 52 ++++ .../components/layout/data/sidebar-data.ts | 9 +- web/src/features/license/index.tsx | 290 ++++++++++++++++++ web/src/locales/en.json | 51 ++- web/src/locales/zh.json | 49 ++- web/src/routeTree.gen.ts | 31 ++ .../routes/_authenticated/license.lazy.tsx | 24 ++ 7 files changed, 496 insertions(+), 10 deletions(-) create mode 100644 web/src/api/license/api.ts create mode 100644 web/src/features/license/index.tsx create mode 100644 web/src/routes/_authenticated/license.lazy.tsx diff --git a/web/src/api/license/api.ts b/web/src/api/license/api.ts new file mode 100644 index 0000000..d6199da --- /dev/null +++ b/web/src/api/license/api.ts @@ -0,0 +1,52 @@ +// +// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import axiosInstance from '@/api/axiosInstance' + +export interface LicenseStatusResponse { + status: string + email?: string | null + edition?: string | null + updates_until?: string | null + features?: string[] | null + days_remaining?: number | null + build_date?: string | null + valid_until?: string | null + machine_id: string + account_limit?: number | null + accounts_used: number +} + +export interface UploadLicenseResponse { + success: boolean + email: string + edition: string + updates_until: string +} + +export async function get_license_status(): Promise { + const { data } = await axiosInstance.get('api/v1/license/status') + return data +} + +export async function upload_license(license: string): Promise { + const { data } = await axiosInstance.post('api/v1/license/upload', { + license, + }) + return data +} diff --git a/web/src/components/layout/data/sidebar-data.ts b/web/src/components/layout/data/sidebar-data.ts index 9aea766..8202113 100644 --- a/web/src/components/layout/data/sidebar-data.ts +++ b/web/src/components/layout/data/sidebar-data.ts @@ -22,7 +22,7 @@ import { IconLayoutDashboard, IconSettings } from '@tabler/icons-react' -import { IdCard, Inbox, Paperclip, Search, Upload, Users2, ScrollText } from 'lucide-react' +import { BadgeCheck, IdCard, Inbox, Paperclip, Search, Upload, Users2, ScrollText } from 'lucide-react' import { type SidebarData } from '../types' import { useTranslation } from 'react-i18next' import { useCurrentUser } from '@/hooks/use-current-user' @@ -34,6 +34,7 @@ export function useSidebarData(): SidebarData { const { require_any_permission } = useCurrentUser() const { features } = useEdition() const auditEnabled = features.includes('audit_log') + const licenseEnabled = features.includes('license') return { navGroups: [ @@ -107,6 +108,12 @@ export function useSidebarData(): SidebarData { url: '/api-docs', icon: IconHelp, }, + { + title: t('navigation.license'), + url: '/license', + icon: BadgeCheck, + visible: licenseEnabled && require_any_permission(['system:root', 'user:manage']), + }, { title: t('navigation.auditLog'), url: '/audit-log', diff --git a/web/src/features/license/index.tsx b/web/src/features/license/index.tsx new file mode 100644 index 0000000..2c9cec6 --- /dev/null +++ b/web/src/features/license/index.tsx @@ -0,0 +1,290 @@ +// +// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { AxiosError } from 'axios' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Copy, FileUp, Loader2 } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { FixedHeader } from '@/components/layout/fixed-header' +import { Main } from '@/components/layout/main' +import { useEdition } from '@/hooks/use-edition' +import { useCurrentUser } from '@/hooks/use-current-user' +import { useToast } from '@/hooks/use-toast' +import { + get_license_status, + upload_license, + type LicenseStatusResponse, +} from '@/api/license/api' + +function formatEpoch(ts?: string | null): string { + if (!ts) return '—' + const n = Number(ts) + if (!Number.isFinite(n)) return ts + return new Date(n * 1000).toLocaleDateString() +} + +function InfoRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
+ {children} +
+
+ ) +} + +export default function LicensePage() { + const { t } = useTranslation() + const { toast } = useToast() + const queryClient = useQueryClient() + const { isPro, features } = useEdition() + const { require_any_permission } = useCurrentUser() + const [licenseText, setLicenseText] = useState('') + + const { data, isLoading, error } = useQuery({ + queryKey: ['license-status'], + queryFn: get_license_status, + retry: false, + }) + + const upload = useMutation({ + mutationFn: upload_license, + onSuccess: () => { + toast({ title: t('license.uploadSuccess') }) + setLicenseText('') + queryClient.invalidateQueries({ queryKey: ['license-status'] }) + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast({ + title: t('license.uploadFailed'), + description: err.response?.data?.error ?? t('license.uploadFailedDesc'), + variant: 'destructive', + }) + }, + }) + + const copyMachineId = async () => { + if (!data?.machine_id) return + try { + await navigator.clipboard.writeText(data.machine_id) + toast({ title: t('license.copied') }) + } catch { + toast({ title: t('license.copyFailed'), variant: 'destructive' }) + } + } + + const onFilePicked = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = () => setLicenseText(String(reader.result ?? '').trim()) + reader.onerror = () => toast({ title: t('license.readFileFailed'), variant: 'destructive' }) + reader.readAsText(file) + e.target.value = '' + } + + const licenseEnabled = features.includes('license') + const canView = isPro && licenseEnabled && require_any_permission(['system:root', 'user:manage']) + + if (!canView) { + return ( + <> + +
+
+ {t('license.forbidden', 'License management is available in the Pro edition only.')} +
+
+ + ) + } + + const statusLabels: Record = { + valid: t('license.statusValid'), + trial: t('license.statusTrial'), + trial_expired: t('license.statusTrialExpired'), + update_expired: t('license.statusUpdateExpired'), + machine_mismatch: t('license.statusMachineMismatch'), + invalid_signature: t('license.statusInvalid'), + error: t('license.statusError'), + } + + const statusVariant: Record = { + valid: 'default', + trial: 'secondary', + } + + const status = data?.status ?? '' + + return ( + <> + +
+
+

{t('license.title')}

+

{t('license.description')}

+ + {isLoading && ( +
+ +
+ )} + + {error && !isLoading && ( +
+ {t('license.loadFailed')} +
+ )} + + {data && ( +
+ + + {t('license.statusTitle')} + {t('license.statusDesc')} + + + + + {statusLabels[status] ?? status} + + + + {data.edition ? ( + {data.edition} + ) : ( + t('license.notAvailable') + )} + + + {data.email ?? t('license.notAvailable')} + + + {formatEpoch(data.updates_until)} + + {data.days_remaining !== null && data.days_remaining !== undefined && ( + + {t('license.trialDaysRemaining', { days: data.days_remaining })} + + )} + + {data.account_limit + ? t('license.accountsUsed', { + used: data.accounts_used, + limit: data.account_limit, + }) + : t('license.notAvailable')} + + + {data.features && data.features.length > 0 ? ( + data.features.map((f) => ( + + {f} + + )) + ) : ( + t('license.notAvailable') + )} + + + + +
+ + + {t('license.machineIdTitle')} + {t('license.machineIdDesc')} + + +
+ + {data.machine_id || t('license.notAvailable')} + + +
+
+
+ + + + {t('license.uploadTitle')} + {t('license.uploadDesc')} + + +
+ +
+