mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
feat(audit): audit log page, full event coverage, retention cleanup, and duplicate-view dedup
This commit is contained in:
61
web/src/api/audit/api.ts
Normal file
61
web/src/api/audit/api.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// Audit log API client (Pro edition).
|
||||
//
|
||||
|
||||
import axiosInstance from '@/api/axiosInstance'
|
||||
|
||||
export interface AuditRecord {
|
||||
id: string
|
||||
seq: number
|
||||
ts_ms: number
|
||||
event_type: string
|
||||
user: string
|
||||
account_id: number | null
|
||||
mailbox_id: number | null
|
||||
email_id: string | null
|
||||
content_hash: string | null
|
||||
ip: string | null
|
||||
payload: Record<string, unknown>
|
||||
prev_hash: string | null
|
||||
}
|
||||
|
||||
export interface AuditPageResponse {
|
||||
items: AuditRecord[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AuditQueryParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
start_ms?: number
|
||||
end_ms?: number
|
||||
user?: string
|
||||
event_type?: string
|
||||
account_id?: number
|
||||
email_id?: string
|
||||
}
|
||||
|
||||
export async function list_audit_log(
|
||||
params: AuditQueryParams,
|
||||
): Promise<AuditPageResponse> {
|
||||
const { data } = await axiosInstance.get<AuditPageResponse>('api/v1/audit-log', {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function list_audit_log_by_email(
|
||||
envelopeId: string,
|
||||
page: number,
|
||||
page_size: number,
|
||||
): Promise<AuditPageResponse> {
|
||||
const { data } = await axiosInstance.get<AuditPageResponse>(
|
||||
`api/v1/audit-log/email/${envelopeId}`,
|
||||
{ params: { page, page_size } },
|
||||
)
|
||||
return data
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import i18n from '@/i18n'
|
||||
import { dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { cn, dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { enUS } from 'date-fns/locale'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
@@ -35,12 +35,14 @@ type DatePickerProps = {
|
||||
selected: Date | undefined
|
||||
onSelect: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
placeholder = 'Pick a date',
|
||||
className,
|
||||
}: DatePickerProps) {
|
||||
|
||||
const currentLang = i18n.language.toLowerCase().replace('_', '-');
|
||||
@@ -60,7 +62,11 @@ export function DatePicker({
|
||||
<Button
|
||||
variant='outline'
|
||||
data-empty={!selected}
|
||||
className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal'
|
||||
className={cn(
|
||||
'h-9 w-[240px] justify-start text-start text-sm font-normal',
|
||||
'data-[empty=true]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{selected ? (
|
||||
format(selected, 'PPP', { locale: dateLocale })
|
||||
|
||||
@@ -22,15 +22,18 @@ import {
|
||||
IconLayoutDashboard,
|
||||
IconSettings
|
||||
} from '@tabler/icons-react'
|
||||
import { IdCard, Inbox, Paperclip, Search, Upload, Users2 } from 'lucide-react'
|
||||
import { 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'
|
||||
import { useEdition } from '@/hooks/use-edition'
|
||||
|
||||
export function useSidebarData(): SidebarData {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { require_any_permission } = useCurrentUser()
|
||||
const { features } = useEdition()
|
||||
const auditEnabled = features.includes('audit_log')
|
||||
|
||||
return {
|
||||
navGroups: [
|
||||
@@ -104,6 +107,12 @@ export function useSidebarData(): SidebarData {
|
||||
url: '/api-docs',
|
||||
icon: IconHelp,
|
||||
},
|
||||
{
|
||||
title: t('navigation.auditLog'),
|
||||
url: '/audit-log',
|
||||
icon: ScrollText,
|
||||
visible: auditEnabled && require_any_permission(['system:root', 'user:manage', 'data:read:all']),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -80,7 +80,8 @@ const VirtualizedCommand = ({
|
||||
setFilteredOptions(
|
||||
options.filter((option) =>
|
||||
option.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||
option.label.toLowerCase().includes(search.toLowerCase())
|
||||
option.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(option.description ?? '').toLowerCase().includes(search.toLowerCase())
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react';
|
||||
|
||||
@@ -167,7 +167,11 @@ export function MailMessageView({
|
||||
setBlockRemote(true);
|
||||
}, [envelope.id]);
|
||||
|
||||
const loadedKeyRef = useRef('');
|
||||
useEffect(() => {
|
||||
const key = `${envelope.account_id}:${envelope.id}:${blockRemote}`;
|
||||
if (loadedKeyRef.current === key) return;
|
||||
loadedKeyRef.current = key;
|
||||
setLoading(true);
|
||||
loadMessageMutation.mutate();
|
||||
}, [envelope.id, blockRemote]);
|
||||
|
||||
486
web/src/features/audit-log/index.tsx
Normal file
486
web/src/features/audit-log/index.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// Audit log page (Pro edition) — query who did what, when.
|
||||
//
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Main } from '@/components/layout/main'
|
||||
import { FixedHeader } from '@/components/layout/fixed-header'
|
||||
import { AttachmentListPagination } from '@/components/pagination'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { DatePicker } from '@/components/date-picker'
|
||||
import { VirtualizedSelect } from '@/components/virtualized-select'
|
||||
import { list_audit_log, type AuditRecord } from '@/api/audit/api'
|
||||
import { list_minimal_users } from '@/api/users/api'
|
||||
import { minimal_account_list } from '@/api/account/api'
|
||||
import { useEdition } from '@/hooks/use-edition'
|
||||
import { useCurrentUser } from '@/hooks/use-current-user'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
const EVENT_TYPES = [
|
||||
'email.viewed',
|
||||
'email.deleted',
|
||||
'email.exported',
|
||||
'email.restored',
|
||||
'email.tagged',
|
||||
'attachment.downloaded',
|
||||
'attachment.previewed',
|
||||
'attachment.tagged',
|
||||
'user.login',
|
||||
'user.created',
|
||||
'user.updated',
|
||||
'user.removed',
|
||||
'role.created',
|
||||
'role.updated',
|
||||
'role.removed',
|
||||
'account.created',
|
||||
'account.updated',
|
||||
'account.removed',
|
||||
'account.download_started',
|
||||
'account.download_stopped',
|
||||
'account.role_assigned',
|
||||
'access_token.created',
|
||||
'access_token.removed',
|
||||
'oauth2.created',
|
||||
'oauth2.updated',
|
||||
'oauth2.removed',
|
||||
'oauth2.token_stored',
|
||||
'import.performed',
|
||||
'mailbox.removed',
|
||||
'proxy.created',
|
||||
'proxy.updated',
|
||||
'proxy.removed',
|
||||
'sso.login',
|
||||
'sso.logout',
|
||||
'license.uploaded',
|
||||
'search.performed',
|
||||
'settings.changed',
|
||||
] as const
|
||||
|
||||
function eventTypeLabel(t: (key: string, defaultValue: string) => string, et: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'email.viewed': t('audit.eventTypes.emailViewed', 'Email viewed'),
|
||||
'email.deleted': t('audit.eventTypes.emailDeleted', 'Email deleted'),
|
||||
'email.exported': t('audit.eventTypes.emailExported', 'Email exported'),
|
||||
'email.restored': t('audit.eventTypes.emailRestored', 'Email restored'),
|
||||
'email.tagged': t('audit.eventTypes.emailTagged', 'Email tags changed'),
|
||||
'attachment.downloaded': t('audit.eventTypes.attachmentDownloaded', 'Attachment downloaded'),
|
||||
'attachment.previewed': t('audit.eventTypes.attachmentPreviewed', 'Attachment previewed'),
|
||||
'attachment.tagged': t('audit.eventTypes.attachmentTagged', 'Attachment tags changed'),
|
||||
'user.login': t('audit.eventTypes.userLogin', 'User login'),
|
||||
'user.created': t('audit.eventTypes.userCreated', 'User created'),
|
||||
'user.updated': t('audit.eventTypes.userUpdated', 'User updated'),
|
||||
'user.removed': t('audit.eventTypes.userRemoved', 'User removed'),
|
||||
'role.created': t('audit.eventTypes.roleCreated', 'Role created'),
|
||||
'role.updated': t('audit.eventTypes.roleUpdated', 'Role updated'),
|
||||
'role.removed': t('audit.eventTypes.roleRemoved', 'Role removed'),
|
||||
'account.created': t('audit.eventTypes.accountCreated', 'Account created'),
|
||||
'account.updated': t('audit.eventTypes.accountUpdated', 'Account updated'),
|
||||
'account.removed': t('audit.eventTypes.accountRemoved', 'Account removed'),
|
||||
'account.download_started': t(
|
||||
'audit.eventTypes.accountDownloadStarted',
|
||||
'Account sync started',
|
||||
),
|
||||
'account.download_stopped': t(
|
||||
'audit.eventTypes.accountDownloadStopped',
|
||||
'Account sync stopped',
|
||||
),
|
||||
'account.role_assigned': t('audit.eventTypes.accountRoleAssigned', 'Account access assigned'),
|
||||
'access_token.created': t('audit.eventTypes.accessTokenCreated', 'Access token created'),
|
||||
'access_token.removed': t('audit.eventTypes.accessTokenRemoved', 'Access token removed'),
|
||||
'oauth2.created': t('audit.eventTypes.oauth2Created', 'OAuth2 config created'),
|
||||
'oauth2.updated': t('audit.eventTypes.oauth2Updated', 'OAuth2 config updated'),
|
||||
'oauth2.removed': t('audit.eventTypes.oauth2Removed', 'OAuth2 config removed'),
|
||||
'oauth2.token_stored': t('audit.eventTypes.oauth2TokenStored', 'OAuth2 token stored'),
|
||||
'import.performed': t('audit.eventTypes.importPerformed', 'Import performed'),
|
||||
'mailbox.removed': t('audit.eventTypes.mailboxRemoved', 'Mailbox removed'),
|
||||
'proxy.created': t('audit.eventTypes.proxyCreated', 'Proxy created'),
|
||||
'proxy.updated': t('audit.eventTypes.proxyUpdated', 'Proxy updated'),
|
||||
'proxy.removed': t('audit.eventTypes.proxyRemoved', 'Proxy removed'),
|
||||
'sso.login': t('audit.eventTypes.ssoLogin', 'SSO login'),
|
||||
'sso.logout': t('audit.eventTypes.ssoLogout', 'SSO logout'),
|
||||
'license.uploaded': t('audit.eventTypes.licenseUploaded', 'License uploaded'),
|
||||
'search.performed': t('audit.eventTypes.searchPerformed', 'Search performed'),
|
||||
'settings.changed': t('audit.eventTypes.settingsChanged', 'Settings changed'),
|
||||
}
|
||||
return labels[et] ?? et
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function describeEvent(rec: AuditRecord): string {
|
||||
const p = rec.payload ?? {}
|
||||
switch (rec.event_type) {
|
||||
case 'email.viewed':
|
||||
return typeof p.subject === 'string' ? p.subject : rec.email_id ?? ''
|
||||
case 'email.deleted':
|
||||
return typeof p.subject === 'string' ? p.subject : rec.email_id ?? ''
|
||||
case 'email.exported':
|
||||
case 'email.restored':
|
||||
return typeof p.subject === 'string' ? p.subject : rec.email_id ?? ''
|
||||
case 'email.tagged':
|
||||
return typeof p.count === 'number' ? `${p.count} email(s)` : ''
|
||||
case 'attachment.downloaded':
|
||||
case 'attachment.previewed':
|
||||
return typeof p.filename === 'string' ? p.filename : rec.content_hash ?? ''
|
||||
case 'attachment.tagged':
|
||||
return typeof p.count === 'number' ? `${p.count} attachment(s)` : ''
|
||||
case 'search.performed':
|
||||
return typeof p.query === 'string' ? `"${p.query}"` : ''
|
||||
case 'user.created':
|
||||
return typeof p.new_user === 'string' ? p.new_user : ''
|
||||
case 'user.updated':
|
||||
case 'user.removed':
|
||||
return typeof p.target_user === 'string' ? p.target_user : ''
|
||||
case 'role.created':
|
||||
case 'role.updated':
|
||||
case 'role.removed':
|
||||
return typeof p.role === 'string' ? p.role : ''
|
||||
case 'account.created':
|
||||
case 'account.updated':
|
||||
case 'account.removed':
|
||||
return typeof p.email === 'string' ? p.email : ''
|
||||
case 'account.download_started':
|
||||
return typeof p.run_gap_fill === 'boolean'
|
||||
? `gap_fill=${p.run_gap_fill}`
|
||||
: ''
|
||||
case 'account.role_assigned':
|
||||
return typeof p.target_user === 'string' ? p.target_user : ''
|
||||
case 'access_token.created':
|
||||
case 'access_token.removed':
|
||||
return typeof p.name === 'string' && p.name
|
||||
? p.name
|
||||
: typeof p.target_user === 'string'
|
||||
? p.target_user
|
||||
: ''
|
||||
case 'oauth2.created':
|
||||
case 'oauth2.updated':
|
||||
case 'oauth2.removed':
|
||||
return typeof p.name === 'string' && p.name ? p.name : ''
|
||||
case 'import.performed':
|
||||
return typeof p.total === 'number'
|
||||
? `total=${p.total} success=${p.success} failed=${p.failed}`
|
||||
: ''
|
||||
case 'mailbox.removed':
|
||||
return rec.mailbox_id !== null && rec.mailbox_id !== undefined
|
||||
? `mailbox ${rec.mailbox_id}`
|
||||
: ''
|
||||
case 'proxy.created':
|
||||
case 'proxy.updated':
|
||||
case 'proxy.removed':
|
||||
return typeof p.url === 'string' ? p.url : ''
|
||||
case 'license.uploaded':
|
||||
return typeof p.email === 'string' ? p.email : ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function PayloadView({ record }: { record: AuditRecord }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!record.payload || Object.keys(record.payload).length === 0) return null
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
<Button variant='ghost' size='sm' onClick={() => setOpen((v) => !v)}>
|
||||
{open ? t('audit.hideDetails', 'Hide details') : t('audit.showDetails', 'Show details')}
|
||||
</Button>
|
||||
{open && (
|
||||
<pre className='mt-2 max-h-64 overflow-auto rounded border p-2 text-xs'>
|
||||
{JSON.stringify(record.payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuditLog() {
|
||||
const { t } = useTranslation()
|
||||
const { isPro } = useEdition()
|
||||
const { require_any_permission } = useCurrentUser()
|
||||
const [page, setPage] = useState(1)
|
||||
const [userFilter, setUserFilter] = useState('all')
|
||||
const [eventType, setEventType] = useState('all')
|
||||
const [accountFilter, setAccountFilter] = useState('all')
|
||||
const [startDate, setStartDate] = useState<Date | undefined>()
|
||||
const [endDate, setEndDate] = useState<Date | undefined>()
|
||||
const [applied, setApplied] = useState({
|
||||
user: 'all',
|
||||
type: 'all',
|
||||
account: 'all',
|
||||
start: undefined as Date | undefined,
|
||||
end: undefined as Date | undefined,
|
||||
})
|
||||
|
||||
const { data: users, isLoading: isUsersLoading } = useQuery({
|
||||
queryKey: ['audit-log-users'],
|
||||
queryFn: () => list_minimal_users(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const { data: accounts, isLoading: isAccountsLoading } = useQuery({
|
||||
queryKey: ['audit-log-accounts'],
|
||||
queryFn: () => minimal_account_list(),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const userOptions = [
|
||||
{ value: 'all', label: t('audit.allTypes', 'All') },
|
||||
...(users ?? []).map((u) => ({
|
||||
value: u.username,
|
||||
label: `${u.username}${u.email ? ` · ${u.email}` : ''}`,
|
||||
})),
|
||||
]
|
||||
|
||||
const accountOptions = [
|
||||
{ value: 'all', label: t('audit.allTypes', 'All') },
|
||||
...(accounts ?? []).map((a) => ({
|
||||
value: String(a.id),
|
||||
label: a.email,
|
||||
})),
|
||||
]
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ['audit-log', page, applied],
|
||||
queryFn: () =>
|
||||
list_audit_log({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
user: applied.user === 'all' || !applied.user ? undefined : applied.user,
|
||||
event_type: applied.type === 'all' || !applied.type ? undefined : applied.type,
|
||||
account_id: applied.account === 'all' || !applied.account ? undefined : Number(applied.account),
|
||||
start_ms: applied.start ? applied.start.getTime() : undefined,
|
||||
end_ms: applied.end
|
||||
? new Date(
|
||||
applied.end.getFullYear(),
|
||||
applied.end.getMonth(),
|
||||
applied.end.getDate() + 1,
|
||||
).getTime()
|
||||
: undefined,
|
||||
}),
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const applyFilters = () => {
|
||||
setPage(1)
|
||||
setApplied({
|
||||
user: userFilter,
|
||||
type: eventType,
|
||||
account: accountFilter,
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
})
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setUserFilter('all')
|
||||
setEventType('all')
|
||||
setAccountFilter('all')
|
||||
setStartDate(undefined)
|
||||
setEndDate(undefined)
|
||||
setPage(1)
|
||||
setApplied({ user: 'all', type: 'all', account: 'all', start: undefined, end: undefined })
|
||||
}
|
||||
|
||||
const canView = isPro && require_any_permission(['system:root', 'user:manage', 'data:read:all'])
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<div className='mx-auto w-full max-w-7xl px-4 py-16 text-center text-muted-foreground'>
|
||||
{t('audit.forbidden', 'Audit log is available in the Pro edition only.')}
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<div className='mx-auto w-full max-w-7xl px-4'>
|
||||
<h1 className='mb-4 text-xl font-semibold'>
|
||||
{t('audit.title', 'Audit Log')}
|
||||
</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className='mb-4 flex flex-wrap items-end gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-xs text-muted-foreground'>
|
||||
{t('audit.user', 'User')}
|
||||
</label>
|
||||
<VirtualizedSelect
|
||||
options={userOptions}
|
||||
value={userFilter}
|
||||
onSelectOption={(values) => setUserFilter(values[0])}
|
||||
placeholder={t('audit.userPlaceholder', 'username')}
|
||||
isLoading={isUsersLoading}
|
||||
className='h-9 w-52 justify-start text-sm font-normal'
|
||||
noItemsComponent={
|
||||
<span className='text-xs'>{t('audit.noUsers', 'No users found')}</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-xs text-muted-foreground'>
|
||||
{t('audit.eventType', 'Event type')}
|
||||
</label>
|
||||
<Select value={eventType} onValueChange={setEventType}>
|
||||
<SelectTrigger className='h-9 w-52 text-sm'>
|
||||
<SelectValue placeholder={t('audit.allTypes', 'All')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>{t('audit.allTypes', 'All')}</SelectItem>
|
||||
{EVENT_TYPES.map((et) => (
|
||||
<SelectItem key={et} value={et}>
|
||||
{eventTypeLabel(t, et)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-xs text-muted-foreground'>
|
||||
{t('audit.account', 'Account')}
|
||||
</label>
|
||||
<VirtualizedSelect
|
||||
options={accountOptions}
|
||||
value={accountFilter}
|
||||
onSelectOption={(values) => setAccountFilter(values[0])}
|
||||
placeholder={t('audit.accountPlaceholder', 'Select account')}
|
||||
isLoading={isAccountsLoading}
|
||||
height='240px'
|
||||
className='h-9 w-52 justify-start text-sm font-normal'
|
||||
noItemsComponent={
|
||||
<span className='text-xs'>{t('audit.noAccounts', 'No accounts found')}</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-xs text-muted-foreground'>
|
||||
{t('audit.startDate', 'Start date')}
|
||||
</label>
|
||||
<DatePicker
|
||||
placeholder={t('audit.startDate', 'Start date')}
|
||||
selected={startDate}
|
||||
onSelect={setStartDate}
|
||||
className='w-44'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-xs text-muted-foreground'>
|
||||
{t('audit.endDate', 'End date')}
|
||||
</label>
|
||||
<DatePicker
|
||||
placeholder={t('audit.endDate', 'End date')}
|
||||
selected={endDate}
|
||||
onSelect={setEndDate}
|
||||
className='w-44'
|
||||
/>
|
||||
</div>
|
||||
<div className='ms-auto flex items-end gap-2'>
|
||||
<Button onClick={applyFilters}>{t('audit.apply', 'Apply')}</Button>
|
||||
<Button variant='outline' onClick={resetFilters}>
|
||||
{t('audit.reset', 'Reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton columns={6} rows={10} />
|
||||
) : (
|
||||
<>
|
||||
<div className='overflow-x-auto rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='text-xs'>{t('audit.time', 'Time')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('audit.user', 'User')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('audit.eventType', 'Event type')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('audit.detail', 'Detail')}</TableHead>
|
||||
<TableHead className='text-xs'>
|
||||
{t('audit.ip', 'IP')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.items?.map((rec) => (
|
||||
<TableRow key={rec.id}>
|
||||
<TableCell className='whitespace-nowrap text-sm'>
|
||||
{formatTime(rec.ts_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='text-sm'>{rec.user}</TableCell>
|
||||
<TableCell>
|
||||
<span className='rounded bg-muted px-1.5 py-0.5 text-sm'>
|
||||
{eventTypeLabel(t, rec.event_type)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='max-w-md'>
|
||||
<div className='truncate text-sm'>{describeEvent(rec) || '—'}</div>
|
||||
<PayloadView record={rec} />
|
||||
</TableCell>
|
||||
<TableCell className='text-sm'>{rec.ip ?? '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data?.items?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className='py-8 text-center text-muted-foreground'>
|
||||
{t('audit.empty', 'No audit events found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className='mt-3 flex items-center justify-between'>
|
||||
<span className='text-sm text-muted-foreground'>
|
||||
{t('table.total', { total: data?.total ?? 0 })}
|
||||
</span>
|
||||
<AttachmentListPagination
|
||||
totalItems={data?.total ?? 0}
|
||||
pageIndex={page - 1}
|
||||
pageSize={PAGE_SIZE}
|
||||
hasNextPage={() => (data?.total ?? 0) > page * PAGE_SIZE}
|
||||
setPageIndex={(i) => setPage(i + 1)}
|
||||
setPageSize={() => {}}
|
||||
/>
|
||||
</div>
|
||||
{isFetching && (
|
||||
<div className='mt-2 text-xs text-muted-foreground'>
|
||||
{t('audit.loading', 'Loading…')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Pencil } from 'lucide-react';
|
||||
|
||||
@@ -177,7 +177,11 @@ export function MailMessageView({
|
||||
setBlockRemote(true);
|
||||
}, [envelope.id]);
|
||||
|
||||
const loadedKeyRef = useRef('');
|
||||
useEffect(() => {
|
||||
const key = `${envelope.account_id}:${envelope.id}:${blockRemote}`;
|
||||
if (loadedKeyRef.current === key) return;
|
||||
loadedKeyRef.current = key;
|
||||
setLoading(true);
|
||||
loadMessageMutation.mutate();
|
||||
}, [envelope.id, blockRemote]);
|
||||
|
||||
@@ -811,7 +811,8 @@
|
||||
"oauth2": "OAuth2",
|
||||
"other": "Other",
|
||||
"settings": "Settings",
|
||||
"users": "System Users"
|
||||
"users": "System Users",
|
||||
"auditLog": "Audit Log"
|
||||
},
|
||||
"oauth2": {
|
||||
"aSecretKeyProvidedByTheOAuthProvider": "A secret key provided by the OAuth provider to authenticate your application.",
|
||||
@@ -1527,6 +1528,22 @@
|
||||
"notSet": "Not set"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"account": "Account ID",
|
||||
"allTypes": "All",
|
||||
"apply": "Apply",
|
||||
"detail": "Detail",
|
||||
"empty": "No audit events found",
|
||||
"endDate": "End date",
|
||||
"eventType": "Event type",
|
||||
"loading": "Loading…",
|
||||
"reset": "Reset",
|
||||
"startDate": "Start date",
|
||||
"time": "Time",
|
||||
"title": "Audit Log",
|
||||
"user": "User",
|
||||
"userPlaceholder": "username"
|
||||
},
|
||||
"table": {
|
||||
"asc": "Asc",
|
||||
"delete": "Delete",
|
||||
|
||||
@@ -23,6 +23,9 @@ import { Route as AuthenticatedAttachmentIndexImport } from './routes/_authentic
|
||||
|
||||
// Create Virtual Routes
|
||||
|
||||
const AuthenticatedAuditLogLazyImport = createFileRoute(
|
||||
'/_authenticated/audit-log',
|
||||
)()
|
||||
const errors503LazyImport = createFileRoute('/(errors)/503')()
|
||||
const errors500LazyImport = createFileRoute('/(errors)/500')()
|
||||
const errors404LazyImport = createFileRoute('/(errors)/404')()
|
||||
@@ -96,6 +99,14 @@ const AuthenticatedIndexRoute = AuthenticatedIndexImport.update({
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
|
||||
const AuthenticatedAuditLogLazyRoute = AuthenticatedAuditLogLazyImport.update({
|
||||
id: '/audit-log',
|
||||
path: '/audit-log',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/_authenticated/audit-log.lazy').then((d) => d.Route),
|
||||
)
|
||||
|
||||
const errors503LazyRoute = errors503LazyImport
|
||||
.update({
|
||||
id: '/(errors)/503',
|
||||
@@ -417,6 +428,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof errors503LazyImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/_authenticated/audit-log': {
|
||||
id: '/_authenticated/audit-log'
|
||||
path: '/audit-log'
|
||||
fullPath: '/audit-log'
|
||||
preLoaderRoute: typeof AuthenticatedAuditLogLazyImport
|
||||
parentRoute: typeof AuthenticatedRouteImport
|
||||
}
|
||||
'/_authenticated/': {
|
||||
id: '/_authenticated/'
|
||||
path: '/'
|
||||
@@ -613,6 +631,7 @@ const AuthenticatedUsersRouteLazyRouteWithChildren =
|
||||
interface AuthenticatedRouteRouteChildren {
|
||||
AuthenticatedSettingsRouteLazyRoute: typeof AuthenticatedSettingsRouteLazyRouteWithChildren
|
||||
AuthenticatedUsersRouteLazyRoute: typeof AuthenticatedUsersRouteLazyRouteWithChildren
|
||||
AuthenticatedAuditLogLazyRoute: typeof AuthenticatedAuditLogLazyRoute
|
||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||
AuthenticatedAccountsNewLazyRoute: typeof AuthenticatedAccountsNewLazyRoute
|
||||
AuthenticatedAttachmentIndexRoute: typeof AuthenticatedAttachmentIndexRoute
|
||||
@@ -630,6 +649,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||
AuthenticatedSettingsRouteLazyRouteWithChildren,
|
||||
AuthenticatedUsersRouteLazyRoute:
|
||||
AuthenticatedUsersRouteLazyRouteWithChildren,
|
||||
AuthenticatedAuditLogLazyRoute: AuthenticatedAuditLogLazyRoute,
|
||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||
AuthenticatedAccountsNewLazyRoute: AuthenticatedAccountsNewLazyRoute,
|
||||
AuthenticatedAttachmentIndexRoute: AuthenticatedAttachmentIndexRoute,
|
||||
@@ -657,6 +677,7 @@ export interface FileRoutesByFullPath {
|
||||
'/403': typeof errors403LazyRoute
|
||||
'/404': typeof errors404LazyRoute
|
||||
'/503': typeof errors503LazyRoute
|
||||
'/audit-log': typeof AuthenticatedAuditLogLazyRoute
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
|
||||
'/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
|
||||
@@ -686,6 +707,7 @@ export interface FileRoutesByTo {
|
||||
'/403': typeof errors403LazyRoute
|
||||
'/404': typeof errors404LazyRoute
|
||||
'/503': typeof errors503LazyRoute
|
||||
'/audit-log': typeof AuthenticatedAuditLogLazyRoute
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
|
||||
'/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
|
||||
@@ -720,6 +742,7 @@ export interface FileRoutesById {
|
||||
'/(errors)/404': typeof errors404LazyRoute
|
||||
'/(errors)/500': typeof errors500LazyRoute
|
||||
'/(errors)/503': typeof errors503LazyRoute
|
||||
'/_authenticated/audit-log': typeof AuthenticatedAuditLogLazyRoute
|
||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewLazyRoute
|
||||
'/_authenticated/settings/access': typeof AuthenticatedSettingsAccessLazyRoute
|
||||
@@ -754,6 +777,7 @@ export interface FileRouteTypes {
|
||||
| '/403'
|
||||
| '/404'
|
||||
| '/503'
|
||||
| '/audit-log'
|
||||
| '/'
|
||||
| '/accounts/new'
|
||||
| '/settings/access'
|
||||
@@ -782,6 +806,7 @@ export interface FileRouteTypes {
|
||||
| '/403'
|
||||
| '/404'
|
||||
| '/503'
|
||||
| '/audit-log'
|
||||
| '/'
|
||||
| '/accounts/new'
|
||||
| '/settings/access'
|
||||
@@ -814,6 +839,7 @@ export interface FileRouteTypes {
|
||||
| '/(errors)/404'
|
||||
| '/(errors)/500'
|
||||
| '/(errors)/503'
|
||||
| '/_authenticated/audit-log'
|
||||
| '/_authenticated/'
|
||||
| '/_authenticated/accounts/new'
|
||||
| '/_authenticated/settings/access'
|
||||
@@ -884,6 +910,7 @@ export const routeTree = rootRoute
|
||||
"children": [
|
||||
"/_authenticated/settings",
|
||||
"/_authenticated/users",
|
||||
"/_authenticated/audit-log",
|
||||
"/_authenticated/",
|
||||
"/_authenticated/accounts/new",
|
||||
"/_authenticated/attachment/",
|
||||
@@ -939,6 +966,10 @@ export const routeTree = rootRoute
|
||||
"/(errors)/503": {
|
||||
"filePath": "(errors)/503.lazy.tsx"
|
||||
},
|
||||
"/_authenticated/audit-log": {
|
||||
"filePath": "_authenticated/audit-log.lazy.tsx",
|
||||
"parent": "/_authenticated"
|
||||
},
|
||||
"/_authenticated/": {
|
||||
"filePath": "_authenticated/index.tsx",
|
||||
"parent": "/_authenticated"
|
||||
|
||||
12
web/src/routes/_authenticated/audit-log.lazy.tsx
Normal file
12
web/src/routes/_authenticated/audit-log.lazy.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// Audit log route (Pro edition).
|
||||
//
|
||||
|
||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||
import AuditLog from '@/features/audit-log'
|
||||
|
||||
export const Route = createLazyFileRoute('/_authenticated/audit-log')({
|
||||
component: AuditLog,
|
||||
})
|
||||
Reference in New Issue
Block a user