mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: add multi-user support and role-based access control #31
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user