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,74 @@
|
||||
//
|
||||
// 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 { useQuery } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import { get_current_user, User } from '@/api/users/api'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export function useCurrentUser() {
|
||||
const query = useQuery<User | null, AxiosError>({
|
||||
queryKey: ['current-user'],
|
||||
queryFn: get_current_user,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const permission = useMemo(() => {
|
||||
if (!query.data) {
|
||||
return {
|
||||
canGlobal: () => false,
|
||||
canAccount: () => false,
|
||||
require_any_permission: () => false,
|
||||
}
|
||||
}
|
||||
|
||||
const globalSet = new Set(query.data.global_permissions ?? [])
|
||||
const accountMap = new Map<number, Set<string>>(
|
||||
Object.entries(query.data.account_permissions ?? {}).map(
|
||||
([id, perms]) => [Number(id), new Set(perms)],
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
canGlobal(permission: string) {
|
||||
return globalSet.has(permission)
|
||||
},
|
||||
|
||||
canAccount(accountId: number, permission: string) {
|
||||
return accountMap.get(accountId)?.has(permission) ?? false
|
||||
},
|
||||
|
||||
require_any_permission(permissions: string[], accountId?: number) {
|
||||
return permissions.some(perm => {
|
||||
if (globalSet.has(perm)) return true
|
||||
if (accountId !== undefined) {
|
||||
return accountMap.get(accountId)?.has(perm) ?? false
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [query.data])
|
||||
|
||||
return {
|
||||
...query,
|
||||
user: query.data,
|
||||
...permission,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import type React from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
type InputHTMLAttributes,
|
||||
} from 'react'
|
||||
|
||||
export type FileMetadata = {
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
url: string
|
||||
id: string
|
||||
}
|
||||
|
||||
export type FileWithPreview = {
|
||||
file: File | FileMetadata
|
||||
id: string
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type FileUploadOptions = {
|
||||
maxFiles?: number
|
||||
maxSize?: number
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
initialFiles?: FileMetadata[]
|
||||
onFilesChange?: (files: FileWithPreview[]) => void
|
||||
onFilesAdded?: (addedFiles: FileWithPreview[]) => void
|
||||
onError?: (errors: string[]) => void
|
||||
}
|
||||
|
||||
export type FileUploadState = {
|
||||
files: FileWithPreview[]
|
||||
isDragging: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export type FileUploadActions = {
|
||||
addFiles: (files: FileList | File[]) => void
|
||||
removeFile: (id: string) => void
|
||||
clearFiles: () => void
|
||||
clearErrors: () => void
|
||||
handleDragEnter: (e: DragEvent<HTMLElement>) => void
|
||||
handleDragLeave: (e: DragEvent<HTMLElement>) => void
|
||||
handleDragOver: (e: DragEvent<HTMLElement>) => void
|
||||
handleDrop: (e: DragEvent<HTMLElement>) => void
|
||||
handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void
|
||||
openFileDialog: () => void
|
||||
getInputProps: (
|
||||
props?: InputHTMLAttributes<HTMLInputElement>,
|
||||
) => InputHTMLAttributes<HTMLInputElement> & {
|
||||
ref: React.Ref<HTMLInputElement>
|
||||
}
|
||||
}
|
||||
|
||||
export const useFileUpload = (
|
||||
options: FileUploadOptions = {},
|
||||
): [FileUploadState, FileUploadActions] => {
|
||||
const {
|
||||
maxFiles = Number.POSITIVE_INFINITY,
|
||||
maxSize = Number.POSITIVE_INFINITY,
|
||||
accept = '*',
|
||||
multiple = false,
|
||||
initialFiles = [],
|
||||
onFilesChange,
|
||||
onFilesAdded,
|
||||
onError,
|
||||
} = options
|
||||
|
||||
const [state, setState] = useState<FileUploadState>({
|
||||
files: initialFiles.map((file) => ({
|
||||
file,
|
||||
id: file.id,
|
||||
preview: file.url,
|
||||
})),
|
||||
isDragging: false,
|
||||
errors: [],
|
||||
})
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const validateFile = useCallback(
|
||||
(file: File | FileMetadata): string | null => {
|
||||
const size = file instanceof File ? file.size : file.size
|
||||
|
||||
if (size > maxSize) {
|
||||
return `File "${file.name}" exceeds the maximum size of ${formatBytes(
|
||||
maxSize,
|
||||
)}.`
|
||||
}
|
||||
|
||||
if (accept !== '*') {
|
||||
const acceptedTypes = accept.split(',').map((t) => t.trim())
|
||||
const fileType = file instanceof File ? file.type || '' : file.type
|
||||
const ext = `.${file.name.split('.').pop()}`
|
||||
|
||||
const ok = acceptedTypes.some((type) => {
|
||||
if (type.startsWith('.')) return ext.toLowerCase() === type.toLowerCase()
|
||||
if (type.endsWith('/*')) return fileType.startsWith(type.replace('/*', '/'))
|
||||
return fileType === type
|
||||
})
|
||||
|
||||
if (!ok) {
|
||||
return `File "${file.name}" is not an accepted file type.`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[accept, maxSize],
|
||||
)
|
||||
|
||||
const createPreview = useCallback((file: File | FileMetadata) => {
|
||||
if (file instanceof File) {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
return file.url
|
||||
}, [])
|
||||
|
||||
const generateId = useCallback((file: File | FileMetadata) => {
|
||||
if (file instanceof File) {
|
||||
return `${file.name}-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 9)}`
|
||||
}
|
||||
return file.id
|
||||
}, [])
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setState((prev) => {
|
||||
prev.files.forEach((f) => {
|
||||
if (
|
||||
f.preview &&
|
||||
f.file instanceof File &&
|
||||
f.file.type.startsWith('image/')
|
||||
) {
|
||||
URL.revokeObjectURL(f.preview)
|
||||
}
|
||||
})
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
|
||||
onFilesChange?.([])
|
||||
return { ...prev, files: [], errors: [] }
|
||||
})
|
||||
}, [onFilesChange])
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
if (!files || files.length === 0) return
|
||||
if (inputRef.current?.disabled) return
|
||||
|
||||
const incoming = Array.from(files)
|
||||
const errors: string[] = []
|
||||
const valid: FileWithPreview[] = []
|
||||
|
||||
if (!multiple) {
|
||||
clearFiles()
|
||||
}
|
||||
|
||||
if (
|
||||
multiple &&
|
||||
maxFiles !== Number.POSITIVE_INFINITY &&
|
||||
state.files.length + incoming.length > maxFiles
|
||||
) {
|
||||
errors.push(`You can only upload up to ${maxFiles} files.`)
|
||||
onError?.(errors)
|
||||
setState((p) => ({ ...p, errors }))
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of incoming) {
|
||||
const err = validateFile(file)
|
||||
if (err) {
|
||||
errors.push(err)
|
||||
continue
|
||||
}
|
||||
|
||||
valid.push({
|
||||
file,
|
||||
id: generateId(file),
|
||||
preview: createPreview(file),
|
||||
})
|
||||
}
|
||||
|
||||
if (valid.length > 0) {
|
||||
onFilesAdded?.(valid)
|
||||
|
||||
setState((prev) => {
|
||||
const next = multiple ? [...prev.files, ...valid] : valid
|
||||
onFilesChange?.(next)
|
||||
return { ...prev, files: next, errors }
|
||||
})
|
||||
} else if (errors.length > 0) {
|
||||
onError?.(errors)
|
||||
setState((p) => ({ ...p, errors }))
|
||||
}
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
},
|
||||
[
|
||||
state.files,
|
||||
multiple,
|
||||
maxFiles,
|
||||
validateFile,
|
||||
generateId,
|
||||
createPreview,
|
||||
clearFiles,
|
||||
onFilesChange,
|
||||
onFilesAdded,
|
||||
onError,
|
||||
],
|
||||
)
|
||||
|
||||
const removeFile = useCallback(
|
||||
(id: string) => {
|
||||
setState((prev) => {
|
||||
const file = prev.files.find((f) => f.id === id)
|
||||
if (
|
||||
file?.preview &&
|
||||
file.file instanceof File &&
|
||||
file.file.type.startsWith('image/')
|
||||
) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
|
||||
const next = prev.files.filter((f) => f.id !== id)
|
||||
onFilesChange?.(next)
|
||||
return { ...prev, files: next, errors: [] }
|
||||
})
|
||||
},
|
||||
[onFilesChange],
|
||||
)
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
setState((p) => ({ ...p, errors: [] }))
|
||||
}, [])
|
||||
|
||||
const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (inputRef.current?.disabled) return
|
||||
setState((p) => ({ ...p, isDragging: true }))
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node)) return
|
||||
setState((p) => ({ ...p, isDragging: false }))
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: DragEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setState((p) => ({ ...p, isDragging: false }))
|
||||
|
||||
if (inputRef.current?.disabled) return
|
||||
|
||||
if (e.dataTransfer.files?.length) {
|
||||
addFiles(
|
||||
multiple ? e.dataTransfer.files : [e.dataTransfer.files[0]],
|
||||
)
|
||||
}
|
||||
},
|
||||
[addFiles, multiple],
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.disabled) return
|
||||
if (e.target.files?.length) {
|
||||
addFiles(e.target.files)
|
||||
}
|
||||
},
|
||||
[addFiles],
|
||||
)
|
||||
|
||||
const openFileDialog = useCallback(() => {
|
||||
if (!inputRef.current || inputRef.current.disabled) return
|
||||
inputRef.current.click()
|
||||
}, [])
|
||||
|
||||
const getInputProps = useCallback(
|
||||
(props: InputHTMLAttributes<HTMLInputElement> = {}) => ({
|
||||
...props,
|
||||
type: 'file' as const,
|
||||
accept: props.accept ?? accept,
|
||||
multiple: props.multiple ?? multiple,
|
||||
disabled: props.disabled,
|
||||
onChange: handleFileChange,
|
||||
ref: inputRef,
|
||||
}),
|
||||
[accept, multiple, handleFileChange],
|
||||
)
|
||||
|
||||
return [
|
||||
state,
|
||||
{
|
||||
addFiles,
|
||||
removeFile,
|
||||
clearFiles,
|
||||
clearErrors,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handleFileChange,
|
||||
openFileDialog,
|
||||
getInputProps,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const formatBytes = (bytes: number, decimals = 2): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / k ** i).toFixed(dm))}${sizes[i]}`
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// 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 { list_minimal_users, MinimalUser } from '@/api/users/api'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export function useMinimalUsers() {
|
||||
const query = useQuery<MinimalUser[]>({
|
||||
queryKey: ['minimal-user-list'],
|
||||
queryFn: list_minimal_users,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const users = query.data ?? []
|
||||
const userMap = users.reduce((map, user) => {
|
||||
map[user.id] = user
|
||||
return map
|
||||
}, {} as Record<number, MinimalUser>)
|
||||
|
||||
|
||||
const getUsername = (id: number) => userMap[id]?.username ?? ''
|
||||
const getEmail = (id: number) => userMap[id]?.email ?? ''
|
||||
const getUser = (id: number) => userMap[id] ?? null
|
||||
const hasUser = (id: number) => !!userMap[id]
|
||||
|
||||
return {
|
||||
...query,
|
||||
users,
|
||||
userMap,
|
||||
getUsername,
|
||||
getEmail,
|
||||
getUser,
|
||||
hasUser,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// 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 { list_roles, RoleType, UserRole } from '@/api/users/api'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
export function useRoles() {
|
||||
const query = useQuery<UserRole[]>({
|
||||
queryKey: ['role-list'],
|
||||
queryFn: list_roles,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const roles = query.data ?? []
|
||||
|
||||
const globalRoles = roles.filter(r => r.role_type === 'Global')
|
||||
const accountRoles = roles.filter(r => r.role_type === 'Account')
|
||||
|
||||
const hasPermission = (
|
||||
roleIds: number[],
|
||||
permission: string,
|
||||
type?: RoleType
|
||||
) => {
|
||||
if (!roleIds.length) return false
|
||||
|
||||
const pool =
|
||||
type === 'Global'
|
||||
? globalRoles
|
||||
: type === 'Account'
|
||||
? accountRoles
|
||||
: roles
|
||||
|
||||
return pool
|
||||
.filter(role => roleIds.includes(role.id))
|
||||
.some(role => role.permissions.includes(permission))
|
||||
}
|
||||
|
||||
|
||||
const global = {
|
||||
roles: globalRoles,
|
||||
hasPermission: (roleIds: number[], permission: string) =>
|
||||
hasPermission(roleIds, permission, 'Global'),
|
||||
isAdmin: (roleIds: number[]) =>
|
||||
hasPermission(roleIds, 'system:root', 'Global'),
|
||||
}
|
||||
|
||||
const account = {
|
||||
roles: accountRoles,
|
||||
hasPermission: (roleIds: number[], permission: string) =>
|
||||
hasPermission(roleIds, permission, 'Account'),
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
roles,
|
||||
globalRoles,
|
||||
accountRoles,
|
||||
global,
|
||||
account,
|
||||
hasPermission,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user