feat: add multi-user support and role-based access control #31

This commit is contained in:
rustmailer
2025-12-26 14:27:04 +08:00
parent 1e2f526a07
commit 4af5176b65
181 changed files with 23745 additions and 3187 deletions
@@ -0,0 +1,277 @@
//
// Copyright (c) 2025 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 React from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, ShieldCheck, Users, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { useToast } from '@/hooks/use-toast'
import { AccountModel } from '../data/schema'
import { useRoles } from '@/hooks/use-roles'
import { useMinimalUsers } from '@/hooks/use-minimal-users'
import { access_assign } from '@/api/account/api'
interface Props {
currentRow: AccountModel
open: boolean
onOpenChange: (open: boolean) => void
}
export function AccountAccessAssignmentDialog({
currentRow,
open,
onOpenChange,
}: Props) {
const { t } = useTranslation()
const { toast } = useToast()
const queryClient = useQueryClient()
const { accountRoles, isLoading: isLoadingRoles } = useRoles()
const { users, isLoading: isLoadingUsers } = useMinimalUsers()
const [keyword, setKeyword] = React.useState('')
// 1. 定义校验 Schema (集成国际化错误提示)
const assignmentSchema = z.object({
account_ids: z.array(z.number()),
user_ids: z.array(z.number()).min(1, {
message: t('accounts.access_control.validation.user_required'),
}),
role_id: z.number({
required_error: t('accounts.access_control.validation.role_required'),
}),
})
type AssignmentFormValues = z.infer<typeof assignmentSchema>
const form = useForm<AssignmentFormValues>({
resolver: zodResolver(assignmentSchema),
defaultValues: {
account_ids: [currentRow.id],
user_ids: [],
role_id: undefined as any,
},
})
const filteredUsers = React.useMemo(() => {
if (!keyword.trim()) return users
const lowerKeyword = keyword.toLowerCase()
return users.filter(
(user) =>
user.username.toLowerCase().includes(lowerKeyword) ||
user.email.toLowerCase().includes(lowerKeyword)
)
}, [users, keyword])
const { mutate, isPending } = useMutation({
mutationFn: access_assign,
onSuccess: () => {
toast({
title: t('accounts.access_control.toast.success_title'),
description: t('accounts.access_control.toast.success_desc', { email: currentRow.email }),
})
queryClient.invalidateQueries({ queryKey: ['account-access-list'] })
onOpenChange(false)
},
onError: (error: any) => {
toast({
variant: 'destructive',
title: t('accounts.access_control.toast.failed_title'),
description: error.response?.data?.message || error.message,
})
},
})
const onSubmit = (data: AssignmentFormValues) => {
mutate(data)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-blue-600" />
{t('accounts.access_control.title')}
</DialogTitle>
<DialogDescription>
{t('accounts.access_control.description', { email: currentRow.email })}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="px-6 space-y-6">
<FormField
control={form.control}
name="role_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.access_control.role_label')}</FormLabel>
<Select
disabled={isLoadingRoles}
onValueChange={(value) => field.onChange(Number(value))}
value={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingRoles
? t('accounts.access_control.role_loading')
: t('accounts.access_control.role_placeholder')
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{accountRoles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<FormLabel className="flex items-center gap-2">
<Users className="w-4 h-4" />
{t('accounts.access_control.user_label')}
</FormLabel>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('accounts.access_control.user_search_placeholder')}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="pl-10"
/>
</div>
<div className="border rounded-md">
<ScrollArea className="h-64">
{isLoadingUsers ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="p-3 space-y-1">
{filteredUsers.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
{t('accounts.access_control.user_empty')}
</div>
) : (
filteredUsers.map((user) => (
<FormField
key={user.id}
control={form.control}
name="user_ids"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-md hover:bg-accent/50 px-2 py-2 transition-colors">
<FormControl>
<Checkbox
checked={field.value?.includes(user.id) ?? false}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([...(field.value ?? []), user.id])
} else {
field.onChange(
field.value?.filter((id: number) => id !== user.id) ?? []
)
}
}}
/>
</FormControl>
<label className="flex-1 cursor-pointer select-none space-y-1">
<div className="font-medium text-sm">{user.username}</div>
<div className="text-xs text-muted-foreground">
{user.email}
</div>
</label>
</FormItem>
)}
/>
))
)}
</div>
)}
</ScrollArea>
</div>
<FormMessage>{form.formState.errors.user_ids?.message}</FormMessage>
{form.watch('user_ids')?.length > 0 && (
<div className="text-sm text-muted-foreground">
{t('accounts.access_control.user_selected_count', { count: form.watch('user_ids').length })}
</div>
)}
</div>
</div>
<DialogFooter className="bg-muted/50 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('accounts.access_control.buttons.cancel')}
</Button>
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('accounts.access_control.buttons.save')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
@@ -47,7 +47,7 @@ export function useColumns(): ColumnDef<AccountModel>[] {
{
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accounts.email')} />
<DataTableColumnHeader column={column} title={t('accounts.email')} className="justify-center" />
),
cell: ({ row }) => {
return <LongText>{row.original.email}</LongText>
@@ -57,7 +57,7 @@ export function useColumns(): ColumnDef<AccountModel>[] {
{
accessorKey: "enabled",
header: ({ column }) => (
<DataTableColumnHeader className="text-center" column={column} title={t('accounts.enabled')} />
<DataTableColumnHeader className="justify-center" column={column} title={t('accounts.enabled')} />
),
cell: EnableAction,
meta: { className: 'w-18 text-center' },
@@ -69,7 +69,7 @@ export function useColumns(): ColumnDef<AccountModel>[] {
<DataTableColumnHeader column={column} title={t('accounts.auth')} />
),
cell: OAuth2Action,
meta: { className: 'w-18 text-center' },
meta: { className: 'text-center' },
enableHiding: false,
enableSorting: false
},
@@ -88,14 +88,14 @@ export function useColumns(): ColumnDef<AccountModel>[] {
{
accessorKey: "sync_interval_sec",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accounts.incSync')} />
<DataTableColumnHeader column={column} title={t('accounts.incSync')} className="justify-center" />
),
cell: ({ row }) => {
let account_type = row.original.account_type;
if (account_type === "NoSync") {
return <LongText>n/a</LongText>
return <LongText className="text-center">n/a</LongText>
}
return <LongText>{row.original.sync_interval_min} min</LongText>
return <LongText className="text-center">{row.original.sync_interval_min} min</LongText>
},
//meta: { className: 'w-18 text-center' },
enableHiding: false,
@@ -106,7 +106,28 @@ export function useColumns(): ColumnDef<AccountModel>[] {
<DataTableColumnHeader column={column} title={t('accounts.state')} />
),
cell: RunningStateCellAction,
meta: { className: 'w-36' },
meta: { className: 'text-center' },
enableHiding: false,
},
{
accessorKey: 'created_by',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Owner" className="justify-center" />
),
cell: ({ row }) => {
const { created_user_name, created_user_email } = row.original;
return (
<div className="flex flex-col py-1 text-center">
<span className="text-sm font-medium text-foreground">
{created_user_name}
</span>
<span className="text-[11px] text-muted-foreground font-mono">
{created_user_email}
</span>
</div>
);
},
meta: { className: 'w-60 text-center' },
enableHiding: false,
},
{
@@ -32,51 +32,66 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected?: boolean,
showSelected?: boolean
showPageSizeSelector?: boolean
}
export function DataTablePagination<TData>({
table,
showSelected = true,
showSelected = false,
showPageSizeSelector = true
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation()
return (
<div className='flex items-center justify-between overflow-auto px-2'>
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>}
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
10 rows per page.
</div>}
{showSelected && (
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{t('table.pagination.selected', {
selected: table.getFilteredSelectedRowModel().rows.length,
total: table.getFilteredRowModel().rows.length,
})}
</div>
)}
{!showPageSizeSelector && (
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{t('table.pagination.fixed_page_size', { size: 10 })}
</div>
)}
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
{showPageSizeSelector && <div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>}
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
{showPageSizeSelector && (
<div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>
{t('table.pagination.rows_per_page')}
</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className='flex w-[130px] items-center justify-center text-sm font-medium'>
{t('table.pagination.page_info', {
page: table.getState().pagination.pageIndex + 1,
total: table.getPageCount(),
})}
</div>
<div className='flex items-center space-x-2'>
<Button
@@ -85,7 +100,7 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to first page</span>
<span className='sr-only'>{t('table.pagination.first')}</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -94,7 +109,7 @@ export function DataTablePagination<TData>({
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to previous page</span>
<span className='sr-only'>{t('table.pagination.previous')}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -103,7 +118,7 @@ export function DataTablePagination<TData>({
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to next page</span>
<span className='sr-only'>{t('table.pagination.next')}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
@@ -112,7 +127,7 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to last page</span>
<span className='sr-only'>{t('table.pagination.last')}</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
@@ -19,7 +19,7 @@
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
import { Row } from '@tanstack/react-table'
import { IconEdit, IconTrash } from '@tabler/icons-react'
import { IconEdit, IconShieldLock, IconTrash } from '@tabler/icons-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -33,6 +33,7 @@ import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
import { Mailbox, MessageSquareMore } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
interface DataTableRowActionsProps {
row: Row<AccountModel>
@@ -43,11 +44,21 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useAccountContext()
const account_type = row.original.account_type;
const { require_any_permission } = useCurrentUser()
const hasPermission = require_any_permission(['system:root', 'account:manage'], row.original.id);
const hasReadPermission = require_any_permission(['system:root', 'account:read_details'], row.original.id);
const canShowAnyAction =
(hasPermission) ||
(account_type === 'IMAP' && hasPermission) ||
(account_type === 'IMAP' && hasReadPermission);
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild disabled={!canShowAnyAction}>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
@@ -57,7 +68,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
{hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
if (account_type === "IMAP") {
@@ -72,8 +83,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DropdownMenuShortcut>
<IconEdit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{account_type === "IMAP" && <DropdownMenuItem
</DropdownMenuItem>}
{account_type === "IMAP" && hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('sync-folders')
@@ -84,7 +95,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<Mailbox size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
{account_type === "IMAP" && <DropdownMenuItem
{account_type === "IMAP" && hasReadPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('detail')
@@ -95,8 +106,20 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<MessageSquareMore size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
<DropdownMenuSeparator />
<DropdownMenuItem
{hasPermission && <DropdownMenuSeparator />}
{hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('access-assign')
}}
>
<span>Access Control</span>
<DropdownMenuShortcut>
<IconShieldLock size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
{hasPermission && <DropdownMenuSeparator />}
{hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('delete')
@@ -107,7 +130,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DropdownMenuShortcut>
<IconTrash size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
</>
@@ -54,7 +54,7 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
}
function handleError(error: AxiosError) {
const errorMessage = error.response?.data ||
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
t('dialogs.deleteFailed');
@@ -28,6 +28,7 @@ import { update_account } from '@/api/account/api'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
interface DataTableRowActionsProps {
row: Row<AccountModel>
@@ -37,7 +38,10 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const { require_any_permission } = useCurrentUser()
const hasPermission = require_any_permission(['system:root', 'account:manage'], row.original.id);
const updateMutation = useMutation({
mutationFn: (enabled: boolean) =>
update_account(row.original.id, { enabled }),
@@ -74,7 +78,7 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
<Switch
checked={row.original.enabled}
onCheckedChange={() => setOpen(true)}
disabled={updateMutation.isPending}
disabled={!hasPermission || updateMutation.isPending}
/>
<ConfirmDialog
open={open}
@@ -22,6 +22,9 @@ import { Button } from '@/components/ui/button'
import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
interface DataTableRowActionsProps {
row: Row<AccountModel>
@@ -29,8 +32,10 @@ interface DataTableRowActionsProps {
export function OAuth2Action({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
const { require_any_permission } = useCurrentUser()
const mailer = row.original
const account_type = mailer.account_type;
const hasPermission = require_any_permission(['system:root', 'account:manage'], row.original.id)
if (account_type === "NoSync") {
return <Button variant={"ghost"} className="text-xs text-muted-foreground">n/a</Button>
@@ -45,8 +50,21 @@ export function OAuth2Action({ row }: DataTableRowActionsProps) {
size="sm"
className="text-xs text-blue-500 hover:text-blue-700 underline"
onClick={() => {
setCurrentRow(mailer)
setOpen("oauth2")
if (hasPermission) {
setCurrentRow(mailer)
setOpen("oauth2")
} else {
toast({
variant: 'destructive',
title: 'Forbidden',
description: 'You do not have permission to view oauth2 tokens.',
action: (
<ToastAction altText="Close">
Close
</ToastAction>
),
})
}
}}
>
OAuth2
@@ -22,6 +22,9 @@ import { Button } from '@/components/ui/button'
import { AccountModel } from '../data/schema';
import { useAccountContext } from '../context';
import { useTranslation } from 'react-i18next';
import { useCurrentUser } from '@/hooks/use-current-user';
import { toast } from '@/hooks/use-toast';
import { ToastAction } from '@/components/ui/toast';
interface Props {
row: Row<AccountModel>
@@ -30,16 +33,31 @@ interface Props {
export function RunningStateCellAction({ row }: Props) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
const { require_any_permission } = useCurrentUser()
let account_type = row.original.account_type;
if (account_type === "NoSync") {
return <span className="text-xs text-muted-foreground">n/a</span>
}
const hasPermission = require_any_permission(['system:root', 'account:read_details'], row.original.id)
return (
<Button variant='ghost' className="h-auto p-1" onClick={() => {
setCurrentRow(row.original)
setOpen('running-state')
if (hasPermission) {
setCurrentRow(row.original)
setOpen('running-state')
} else {
toast({
variant: 'destructive',
title: 'Forbidden',
description: 'You do not have permission to view this account.',
action: (
<ToastAction altText="Close">
Close
</ToastAction>
),
})
}
}}>
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">{t('accounts.viewDetails')}</span>
</Button>
@@ -52,6 +52,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
retry: 0,
refetchOnWindowFocus: false,
refetchInterval: 5000,
enabled: open && !!currentRow.id && currentRow.account_type != "NoSync",
})
const calculateDuration = (start?: number, end?: number) => {
+11 -1
View File
@@ -20,7 +20,17 @@
import React from 'react'
import { AccountModel } from '../data/schema'
export type AccountDialogType = 'add-imap' | 'add-nosync' | 'edit-imap' | 'edit-nosync' | 'delete' | 'detail' | 'oauth2' | 'running-state' | 'sync-folders'
export type AccountDialogType =
| 'add-imap'
| 'add-nosync'
| 'edit-imap'
| 'edit-nosync'
| 'delete'
| 'detail'
| 'oauth2'
| 'running-state'
| 'sync-folders'
| 'access-assign';
interface AccountContextType {
open: AccountDialogType | null
+3
View File
@@ -57,6 +57,9 @@ export interface AccountModel {
folder_limit?: number,
sync_folders: string[];
sync_interval_min?: number;
created_by: number;
created_user_name: string;
created_user_email: string;
created_at: number;
updated_at: number;
use_proxy?: number
+22 -9
View File
@@ -42,6 +42,8 @@ import { SyncFoldersDialog } from './components/sync-folders'
import { NoSyncAccountDialog } from './components/nosync-dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { AccountAccessAssignmentDialog } from './components/access-assignment-dialog'
import { useCurrentUser } from '@/hooks/use-current-user'
export default function Accounts() {
const { t } = useTranslation()
@@ -49,6 +51,7 @@ export default function Accounts() {
// Dialog states
const [currentRow, setCurrentRow] = useState<AccountModel | null>(null)
const [open, setOpen] = useDialogState<AccountDialogType>(null)
const { require_any_permission } = useCurrentUser()
const { data: accountList, isLoading } = useQuery({
queryKey: ['account-list'],
@@ -63,7 +66,6 @@ export default function Accounts() {
<Main>
<div className="mx-auto w-full max-w-[88rem] px-4">
{/* Header Section */}
<div className='mb-2 flex items-center justify-between flex-wrap gap-x-4 gap-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>{t('accounts.title')}</h2>
@@ -71,7 +73,7 @@ export default function Accounts() {
{t('accounts.description')}
</p>
</div>
<div className="flex gap-2">
{require_any_permission(['system:root', 'account:create']) && <div className="flex gap-2">
<div className="flex rounded-md shadow-sm">
<Button
onClick={() => setOpen("add-imap")}
@@ -98,10 +100,9 @@ export default function Accounts() {
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>}
</div>
{/* Table / Empty State Section */}
<div className='flex-1 overflow-auto py-1 flex-row lg:space-x-12 space-y-0'>
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
@@ -168,7 +169,8 @@ export default function Accounts() {
}}
currentRow={currentRow}
/>
<RunningStateDialog
{require_any_permission(['system:root', 'account:read_details'], currentRow.id) && <RunningStateDialog
key='running-state'
open={open === 'running-state'}
onOpenChange={() => {
@@ -178,7 +180,8 @@ export default function Accounts() {
}, 500)
}}
currentRow={currentRow}
/>
/>}
<AccountDeleteDialog
key={`account-delete-${currentRow.id}`}
open={open === 'delete'}
@@ -201,17 +204,27 @@ export default function Accounts() {
}}
currentRow={currentRow}
/>
{require_any_permission(['system:root', 'account:manage'], currentRow.id) && <AccountAccessAssignmentDialog
key={`access-assign-${currentRow.id}`}
open={open === 'access-assign'}
onOpenChange={() => {
setOpen('access-assign')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>}
<AccountDetailDrawer
open={open === 'detail'}
onOpenChange={() => setOpen('detail')}
currentRow={currentRow}
/>
<OAuth2TokensDialog open={open === 'oauth2'}
{require_any_permission(['system:root', 'account:manage'], currentRow.id) && <OAuth2TokensDialog open={open === 'oauth2'}
onOpenChange={() => setOpen('oauth2')}
currentRow={currentRow}
/>
/>}
</>
)}
</AccountProvider>