This commit is contained in:
rustmailer
2026-08-25 17:03:25 +08:00
parent c4a1e36c61
commit a0d69f43b6
7 changed files with 496 additions and 10 deletions

View File

@@ -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 <http://www.gnu.org/licenses/>.
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<LicenseStatusResponse> {
const { data } = await axiosInstance.get<LicenseStatusResponse>('api/v1/license/status')
return data
}
export async function upload_license(license: string): Promise<UploadLicenseResponse> {
const { data } = await axiosInstance.post<UploadLicenseResponse>('api/v1/license/upload', {
license,
})
return data
}

View File

@@ -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',

View File

@@ -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 <http://www.gnu.org/licenses/>.
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 (
<div className='flex items-start justify-between gap-4 py-2'>
<span className='text-sm text-muted-foreground'>{label}</span>
<div className='flex flex-wrap items-center justify-end gap-1.5 text-right text-sm'>
{children}
</div>
</div>
)
}
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<LicenseStatusResponse, AxiosError>({
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<HTMLInputElement>) => {
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 (
<>
<FixedHeader />
<Main>
<div className='mx-auto w-full max-w-7xl px-4 py-16 text-center text-muted-foreground'>
{t('license.forbidden', 'License management is available in the Pro edition only.')}
</div>
</Main>
</>
)
}
const statusLabels: Record<string, string> = {
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<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
valid: 'default',
trial: 'secondary',
}
const status = data?.status ?? ''
return (
<>
<FixedHeader />
<Main>
<div className='mx-auto w-full max-w-7xl px-4'>
<h1 className='mb-1 text-xl font-semibold'>{t('license.title')}</h1>
<p className='mb-6 text-sm text-muted-foreground'>{t('license.description')}</p>
{isLoading && (
<div className='flex h-40 items-center justify-center'>
<Loader2 className='h-6 w-6 animate-spin' />
</div>
)}
{error && !isLoading && (
<div className='mb-6 rounded-md border border-destructive/50 p-4 text-sm text-destructive'>
{t('license.loadFailed')}
</div>
)}
{data && (
<div className='grid gap-6 lg:grid-cols-2'>
<Card>
<CardHeader>
<CardTitle>{t('license.statusTitle')}</CardTitle>
<CardDescription>{t('license.statusDesc')}</CardDescription>
</CardHeader>
<CardContent className='divide-y'>
<InfoRow label={t('license.status')}>
<Badge variant={statusVariant[status] ?? 'destructive'}>
{statusLabels[status] ?? status}
</Badge>
</InfoRow>
<InfoRow label={t('license.edition')}>
{data.edition ? (
<Badge variant='outline'>{data.edition}</Badge>
) : (
t('license.notAvailable')
)}
</InfoRow>
<InfoRow label={t('license.licensee')}>
{data.email ?? t('license.notAvailable')}
</InfoRow>
<InfoRow label={t('license.updatesUntil')}>
{formatEpoch(data.updates_until)}
</InfoRow>
{data.days_remaining !== null && data.days_remaining !== undefined && (
<InfoRow label={t('license.trialDays')}>
{t('license.trialDaysRemaining', { days: data.days_remaining })}
</InfoRow>
)}
<InfoRow label={t('license.accounts')}>
{data.account_limit
? t('license.accountsUsed', {
used: data.accounts_used,
limit: data.account_limit,
})
: t('license.notAvailable')}
</InfoRow>
<InfoRow label={t('license.features')}>
{data.features && data.features.length > 0 ? (
data.features.map((f) => (
<Badge key={f} variant='outline' className='normal-case'>
{f}
</Badge>
))
) : (
t('license.notAvailable')
)}
</InfoRow>
</CardContent>
</Card>
<div className='flex flex-col gap-6'>
<Card>
<CardHeader>
<CardTitle>{t('license.machineIdTitle')}</CardTitle>
<CardDescription>{t('license.machineIdDesc')}</CardDescription>
</CardHeader>
<CardContent>
<div className='flex items-center gap-2'>
<code className='min-w-0 flex-1 break-all rounded-md bg-muted px-3 py-2 font-mono text-xs'>
{data.machine_id || t('license.notAvailable')}
</code>
<Button
variant='outline'
size='icon'
onClick={copyMachineId}
title={t('license.copyMachineId')}
disabled={!data.machine_id}
>
<Copy className='h-4 w-4' />
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('license.uploadTitle')}</CardTitle>
<CardDescription>{t('license.uploadDesc')}</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-3'>
<div className='flex items-center gap-2'>
<Label
htmlFor='license-file'
className='inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium shadow-sm transition-colors hover:bg-accent'
>
<FileUp className='h-4 w-4' />
{t('license.chooseFile')}
<input
id='license-file'
type='file'
accept='.jwt,.txt,text/plain,application/json'
className='hidden'
onChange={onFilePicked}
/>
</Label>
</div>
<Textarea
value={licenseText}
onChange={(e) => setLicenseText(e.target.value)}
placeholder={t('license.pasteHere')}
rows={5}
className='font-mono text-xs'
/>
<Button
onClick={() => upload.mutate(licenseText.trim())}
disabled={!licenseText.trim() || upload.isPending}
className='self-start'
>
{upload.isPending && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{upload.isPending ? t('license.uploading') : t('license.upload')}
</Button>
</CardContent>
</Card>
</div>
</div>
)}
</div>
</Main>
</>
)
}

View File

@@ -781,12 +781,52 @@
"successCount": "{{count}} imported",
"target": "1. Select target account",
"title": "Import",
"uploading": "Uploading…",
"uploadingFile": "Uploading file",
"willImportTo": "Will import to"
"uploading": "Uploading…",
"uploadingFile": "Uploading file",
"willImportTo": "Will import to"
},
"license": {
"title": "License",
"description": "Manage your machine-bound license: view the machine ID, upload a license, and check its status.",
"statusTitle": "License status",
"statusDesc": "Current status of the license on this server.",
"status": "Status",
"statusValid": "Valid",
"statusTrial": "Trial",
"statusTrialExpired": "Trial expired",
"statusUpdateExpired": "Update coverage expired",
"statusMachineMismatch": "Machine mismatch",
"statusInvalid": "Invalid",
"statusError": "Error",
"edition": "Edition",
"licensee": "Licensee",
"updatesUntil": "Updates until",
"features": "Enabled features",
"accounts": "IMAP accounts",
"accountsUsed": "{{used}} / {{limit}}",
"trialDays": "Trial",
"trialDaysRemaining": "{{days}} days remaining",
"machineIdTitle": "Machine ID",
"machineIdDesc": "Send this ID to your vendor to obtain a license bound to this machine.",
"copyMachineId": "Copy machine ID",
"copied": "Machine ID copied to clipboard",
"copyFailed": "Failed to copy machine ID",
"uploadTitle": "Upload license",
"uploadDesc": "Paste the license JWT from your vendor, or select the license file.",
"pasteHere": "Paste the license JWT here...",
"chooseFile": "Choose file",
"readFileFailed": "Failed to read the selected file",
"upload": "Upload",
"uploading": "Uploading...",
"uploadSuccess": "License uploaded and activated",
"uploadFailed": "License upload failed",
"uploadFailedDesc": "The license could not be uploaded. Check the license and try again.",
"loadFailed": "Failed to load license status.",
"notAvailable": "—",
"forbidden": "License management is available in the Pro edition only."
},
"mail": {
"account": "Account",
"account": "Account",
"attachments": "Attachments",
"bcc": "BCC",
"blockRemoteAgain": "Block again",
@@ -879,6 +919,7 @@
"dashboard": "Dashboard",
"general": "General",
"home": "Home",
"license": "License",
"mailbox": "Mailbox",
"oauth2": "OAuth2",
"other": "Other",
@@ -1914,4 +1955,4 @@
"singleRequestBatchSizeTooLarge": "Batch size must be at most 200",
"singleRequestBatchSizeTooSmall": "Batch size must be at least 10"
}
}
}

View File

@@ -783,6 +783,46 @@
"uploadingFile": "正在上传文件",
"willImportTo": "将导入至"
},
"license": {
"title": "许可证",
"description": "管理绑定到本机的许可证:查看机器码、上传许可证并检查状态。",
"statusTitle": "许可证状态",
"statusDesc": "当前服务器许可证的状态。",
"status": "状态",
"statusValid": "有效",
"statusTrial": "试用中",
"statusTrialExpired": "试用已过期",
"statusUpdateExpired": "更新服务已过期",
"statusMachineMismatch": "机器码不匹配",
"statusInvalid": "无效",
"statusError": "错误",
"edition": "版本",
"licensee": "授权用户",
"updatesUntil": "更新截止",
"features": "已启用功能",
"accounts": "IMAP 账户",
"accountsUsed": "{{used}} / {{limit}}",
"trialDays": "试用",
"trialDaysRemaining": "剩余 {{days}} 天",
"machineIdTitle": "机器码",
"machineIdDesc": "将此机器码发送给厂商,以获得绑定到本机的许可证。",
"copyMachineId": "复制机器码",
"copied": "机器码已复制到剪贴板",
"copyFailed": "复制机器码失败",
"uploadTitle": "上传许可证",
"uploadDesc": "粘贴厂商提供的许可证 JWT或选择许可证文件。",
"pasteHere": "在此粘贴许可证 JWT...",
"chooseFile": "选择文件",
"readFileFailed": "读取所选文件失败",
"upload": "上传",
"uploading": "上传中...",
"uploadSuccess": "许可证已上传并生效",
"uploadFailed": "许可证上传失败",
"uploadFailedDesc": "许可证无法上传,请检查许可证后重试。",
"loadFailed": "加载许可证状态失败。",
"notAvailable": "—",
"forbidden": "许可证管理仅在 Pro 版本中可用。"
},
"mail": {
"account": "账户",
"attachments": "附件",
@@ -874,9 +914,10 @@
"attachment": "附件",
"auth": "认证",
"dashboard": "仪表板",
"general": "常规",
"home": "首页",
"mailbox": "邮箱",
"general": "常规",
"home": "首页",
"license": "许可证",
"mailbox": "邮箱",
"oauth2": "OAuth2",
"other": "其他",
"settings": "设置",
@@ -1907,4 +1948,4 @@
"singleRequestBatchSizeTooLarge": "批大小必须最多为200",
"singleRequestBatchSizeTooSmall": "批大小必须至少为10"
}
}
}

View File

@@ -23,6 +23,9 @@ import { Route as AuthenticatedAttachmentIndexImport } from './routes/_authentic
// Create Virtual Routes
const AuthenticatedLicenseLazyImport = createFileRoute(
'/_authenticated/license',
)()
const AuthenticatedAuditLogLazyImport = createFileRoute(
'/_authenticated/audit-log',
)()
@@ -99,6 +102,14 @@ const AuthenticatedIndexRoute = AuthenticatedIndexImport.update({
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedLicenseLazyRoute = AuthenticatedLicenseLazyImport.update({
id: '/license',
path: '/license',
getParentRoute: () => AuthenticatedRouteRoute,
} as any).lazy(() =>
import('./routes/_authenticated/license.lazy').then((d) => d.Route),
)
const AuthenticatedAuditLogLazyRoute = AuthenticatedAuditLogLazyImport.update({
id: '/audit-log',
path: '/audit-log',
@@ -435,6 +446,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedAuditLogLazyImport
parentRoute: typeof AuthenticatedRouteImport
}
'/_authenticated/license': {
id: '/_authenticated/license'
path: '/license'
fullPath: '/license'
preLoaderRoute: typeof AuthenticatedLicenseLazyImport
parentRoute: typeof AuthenticatedRouteImport
}
'/_authenticated/': {
id: '/_authenticated/'
path: '/'
@@ -632,6 +650,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedSettingsRouteLazyRoute: typeof AuthenticatedSettingsRouteLazyRouteWithChildren
AuthenticatedUsersRouteLazyRoute: typeof AuthenticatedUsersRouteLazyRouteWithChildren
AuthenticatedAuditLogLazyRoute: typeof AuthenticatedAuditLogLazyRoute
AuthenticatedLicenseLazyRoute: typeof AuthenticatedLicenseLazyRoute
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsNewLazyRoute: typeof AuthenticatedAccountsNewLazyRoute
AuthenticatedAttachmentIndexRoute: typeof AuthenticatedAttachmentIndexRoute
@@ -650,6 +669,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedUsersRouteLazyRoute:
AuthenticatedUsersRouteLazyRouteWithChildren,
AuthenticatedAuditLogLazyRoute: AuthenticatedAuditLogLazyRoute,
AuthenticatedLicenseLazyRoute: AuthenticatedLicenseLazyRoute,
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedAccountsNewLazyRoute: AuthenticatedAccountsNewLazyRoute,
AuthenticatedAttachmentIndexRoute: AuthenticatedAttachmentIndexRoute,
@@ -678,6 +698,7 @@ export interface FileRoutesByFullPath {
'/404': typeof errors404LazyRoute
'/503': typeof errors503LazyRoute
'/audit-log': typeof AuthenticatedAuditLogLazyRoute
'/license': typeof AuthenticatedLicenseLazyRoute
'/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
'/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
@@ -708,6 +729,7 @@ export interface FileRoutesByTo {
'/404': typeof errors404LazyRoute
'/503': typeof errors503LazyRoute
'/audit-log': typeof AuthenticatedAuditLogLazyRoute
'/license': typeof AuthenticatedLicenseLazyRoute
'/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
'/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
@@ -743,6 +765,7 @@ export interface FileRoutesById {
'/(errors)/500': typeof errors500LazyRoute
'/(errors)/503': typeof errors503LazyRoute
'/_authenticated/audit-log': typeof AuthenticatedAuditLogLazyRoute
'/_authenticated/license': typeof AuthenticatedLicenseLazyRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
'/_authenticated/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
@@ -778,6 +801,7 @@ export interface FileRouteTypes {
| '/404'
| '/503'
| '/audit-log'
| '/license'
| '/'
| '/accounts/new'
| '/settings/access'
@@ -807,6 +831,7 @@ export interface FileRouteTypes {
| '/404'
| '/503'
| '/audit-log'
| '/license'
| '/'
| '/accounts/new'
| '/settings/access'
@@ -840,6 +865,7 @@ export interface FileRouteTypes {
| '/(errors)/500'
| '/(errors)/503'
| '/_authenticated/audit-log'
| '/_authenticated/license'
| '/_authenticated/'
| '/_authenticated/accounts/new'
| '/_authenticated/settings/access'
@@ -911,6 +937,7 @@ export const routeTree = rootRoute
"/_authenticated/settings",
"/_authenticated/users",
"/_authenticated/audit-log",
"/_authenticated/license",
"/_authenticated/",
"/_authenticated/accounts/new",
"/_authenticated/attachment/",
@@ -970,6 +997,10 @@ export const routeTree = rootRoute
"filePath": "_authenticated/audit-log.lazy.tsx",
"parent": "/_authenticated"
},
"/_authenticated/license": {
"filePath": "_authenticated/license.lazy.tsx",
"parent": "/_authenticated"
},
"/_authenticated/": {
"filePath": "_authenticated/index.tsx",
"parent": "/_authenticated"

View File

@@ -0,0 +1,24 @@
//
// 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 <http://www.gnu.org/licenses/>.
import LicensePage from '@/features/license'
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/_authenticated/license')({
component: LicensePage,
})