Merge branch 'main' into feat/envelope-endpoint-and-api-improvements

This commit is contained in:
rustmailer
2025-12-30 22:32:11 +08:00
committed by GitHub
217 changed files with 26670 additions and 3785 deletions

View File

@@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -17,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
"registries": {
"@reui": "https://reui.io/r/{name}.json"
}
}

View File

@@ -58,9 +58,10 @@
"i18next": "^25.6.3",
"js-cookie": "^3.0.5",
"lucide-react": "^0.468.0",
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-ace": "^13.0.0",
"react-day-picker": "8.10.1",
"react-day-picker": "9.13.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.0",
"react-i18next": "^16.3.5",

1825
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,64 +0,0 @@
//
// 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 axiosInstance from "@/api/axiosInstance";
import { AccessToken } from "@/features/access-tokens/data/schema";
export const login = async (password: string) => {
const response = await axiosInstance.post(`/api/login`, password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const reset_root_token = async () => {
const response = await axiosInstance.post("/api/v1/reset-root-token");
return response.data;
};
export const reset_root_password = async (password: string) => {
const response = await axiosInstance.post("/api/v1/reset-root-password", password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const list_access_tokens = async () => {
const response = await axiosInstance.get<AccessToken[]>("/api/v1/access-token-list");
return response.data;
};
export const create_access_token = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/access-token", data);
return response.data;
}
export const update_access_token = async (token: string, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/access-token/${token}`, data);
return response.data;
}
export const delete_access_token = async (token: string) => {
const response = await axiosInstance.delete(`/api/v1/access-token/${token}`);
return response.data;
}

View File

@@ -18,7 +18,6 @@
import axiosInstance from "@/api/axiosInstance";
import { AccountModel } from "@/features/accounts/data/schema";
import { PaginatedResponse } from "..";
export interface MinimalAccount {
@@ -56,6 +55,59 @@ export interface MailboxBatchProgress {
current_batch: number;
}
type Encryption = 'Ssl' | 'StartTls' | 'None';
type AuthType = 'Password' | 'OAuth2';
type Unit = 'Days' | 'Months' | 'Years';
type AccountType = 'IMAP' | 'NoSync';
// Interface definitions
interface AuthConfig {
auth_type: AuthType;
password?: string;
}
export interface ImapConfig {
host: string;
port: number; // integer, 0-65535
encryption: Encryption;
auth: AuthConfig;
use_proxy?: number;
}
interface RelativeDate {
unit: Unit;
value: number; // integer, minimum 1
}
interface DateSelection {
fixed?: string; // format: "YYYY-MM-DD"
relative?: RelativeDate;
}
export interface AccountModel {
id: number;
account_type: AccountType;
imap?: ImapConfig;
enabled: boolean;
name?: string,
email: string;
capabilities?: string[];
date_since?: DateSelection;
date_before?: RelativeDate;
folder_limit?: number,
sync_folders: string[];
sync_interval_min?: number;
sync_batch_size?: number;
created_by: number;
created_user_name: string;
created_user_email: string;
created_at: number;
updated_at: number;
use_proxy?: number
use_dangerous: boolean
}
export const account_state = async (account_id: number) => {
const response = await axiosInstance.get<AccountRunningState>(`/api/v1/account-state/${account_id}`);
return response.data;
@@ -103,3 +155,8 @@ export const autoconfig = async (email: string) => {
const response = await axiosInstance.get<AutoConfigResult>(`/api/v1/autoconfig/${email}`);
return response.data;
};
export const access_assign = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/accounts/access/assignments", data);
return response.data;
};

View File

@@ -17,7 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { getAccessToken } from "@/stores/authStore";
import { getToken } from "@/stores/authStore";
import axios from "axios";
// Create an Axios instance
@@ -36,9 +36,9 @@ const axiosInstance = axios.create({
// Add a request interceptor to include the access token in headers
axiosInstance.interceptors.request.use(
(config) => {
const accessToken = getAccessToken(); // Retrieve access token from localStorage
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
const stored = getToken(); // Retrieve access token from localStorage
if (stored) {
config.headers.Authorization = `Bearer ${stored.accessToken}`;
}
return config;
},

View File

@@ -30,6 +30,8 @@ export interface EmailEnvelope {
id: number;
message_id: string;
account_id: number;
account_email?: string;
mailbox_name?: string;
uid: number;
subject: string;
text: string;

View File

@@ -18,7 +18,6 @@
import axiosInstance from "@/api/axiosInstance";
import { Proxy } from "@/features/settings/proxy/data/schema";
export interface Release {
tag_name: string;
@@ -73,6 +72,34 @@ export interface LargestEmail {
size_bytes: number; // Email size in bytes
}
export interface Proxy {
id: number;
url: string;
created_at: number;
updated_at: number;
}
export type ServerConfigurations = {
bichon_log_level: string
bichon_http_port: number
bichon_bind_ip?: string | null
bichon_public_url: string
bichon_cors_origins?: string[] | null
bichon_cors_max_age: number
bichon_ansi_logs: boolean
bichon_log_to_file: boolean
bichon_json_logs: boolean
bichon_max_server_log_files: number
bichon_encrypt_password_set: boolean
bichon_webui_token_expiration_hours: number
bichon_root_dir: string
bichon_metadata_cache_size?: number | null
bichon_envelope_cache_size?: number | null
bichon_enable_rest_https: boolean
bichon_http_compression_enabled: boolean
bichon_sync_concurrency?: number | null
}
export const get_dashboard_stats = async () => {
const response = await axiosInstance.get<DashboardStats>(`/api/v1/dashboard-stats`);
return response.data;
@@ -104,4 +131,10 @@ export const add_proxy = async (url: string) => {
},
});
return response.data;
};
export const get_system_configurations = async () => {
const response = await axiosInstance.get<ServerConfigurations>(`/api/v1/system-configurations`);
return response.data;
};

208
web/src/api/users/api.ts Normal file
View File

@@ -0,0 +1,208 @@
import axiosInstance from "@/api/axiosInstance";
export type RoleType = 'Global' | 'Account';
export interface UserRole {
id: number;
name: string;
description?: string | null;
permissions: string[];
is_builtin: boolean;
role_type: RoleType;
created_at: number;
updated_at: number;
}
export function getPermissions(t: (key: string) => string) {
return [
// 1. Global Management
{ label: t('permission.system.access'), value: 'system:access' },
{ label: t('permission.system.root'), value: 'system:root' },
{ label: t('permission.user.manage'), value: 'user:manage' },
{ label: t('permission.user.view'), value: 'user:view' },
{ label: t('permission.token.manage'), value: 'token:manage' },
{ label: t('permission.account.create'), value: 'account:create' },
// 2. Global "ALL" Scoped (Admin)
{ label: t('permission.account.manage_all'), value: 'account:manage:all' },
{ label: t('permission.data.read_all'), value: 'data:read:all' },
{ label: t('permission.data.manage_all'), value: 'data:manage:all' },
{ label: t('permission.data.raw_download_all'), value: 'data:raw:download:all' },
{ label: t('permission.data.delete_all'), value: 'data:delete:all' },
{ label: t('permission.data.export_batch_all'), value: 'data:export:batch:all' },
// 3. Scoped / Limited
{ label: t('permission.account.manage'), value: 'account:manage' },
{ label: t('permission.account.read_details'), value: 'account:read_details' },
{ label: t('permission.data.read'), value: 'data:read' },
{ label: t('permission.data.manage'), value: 'data:manage' },
{ label: t('permission.data.raw_download'), value: 'data:raw:download' },
{ label: t('permission.data.delete'), value: 'data:delete' },
{ label: t('permission.data.export_batch'), value: 'data:export:batch' },
{ label: t('permission.data.import_batch'), value: 'data:import:batch' },
]
}
export interface RateLimit {
quota: number;
interval: number;
}
export interface AccessControl {
ip_whitelist?: string[];
rate_limit?: RateLimit;
}
export type TokenType = "WebUI" | "Api";
export interface AccessToken {
user_id: number;
user_name: string,
user_email: string,
token: string;
created_at: number;
updated_at: number;
name?: string;
last_access_at: number;
expire_at?: number | null;
token_type: TokenType;
}
export interface User {
id: number;
username: string;
email: string;
password?: string | null;
description?: string | null;
global_roles: number[];
global_roles_names: string[];
avatar?: string;
acl?: AccessControl;
account_access_map: Record<number, number>;
account_roles_summary: Record<number, string>;
global_permissions: string[]
account_permissions: Record<number, string[]>
created_at: number;
updated_at: number;
}
type Theme = 'dark' | 'light'
export interface LoginResult {
success: boolean;
error_message?: string | null;
access_token?: string | null;
theme?: Theme,
language?: string,
}
export interface MinimalUser {
id: number;
username: string;
email: string;
}
export const login = async (data: Record<string, any>) => {
const response = await axiosInstance.post<LoginResult>(`/api/login`, data);
return response.data;
};
export const reset_admin_token = async () => {
const response = await axiosInstance.post("/api/v1/reset-admin-token");
return response.data;
};
export const reset_admin_password = async (password: string) => {
const response = await axiosInstance.post("/api/v1/reset-admin-password", password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const list_access_tokens = async () => {
const response = await axiosInstance.get<AccessToken[]>("/api/v1/access-token-list");
return response.data;
};
export const create_access_token = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/access-token", data);
return response.data;
}
export const update_access_token = async (token: string, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/access-token/${token}`, data);
return response.data;
}
export const remove_access_token = async (token: string) => {
const response = await axiosInstance.delete(`/api/v1/access-token/${token}`);
return response.data;
}
export const list_roles = async () => {
const response = await axiosInstance.get<UserRole[]>("/api/v1/list-roles");
return response.data;
};
export const remove_role = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/roles/${id}`);
return response.data;
};
export const create_role = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/roles", data);
return response.data;
};
export const update_role = async (id: number, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/roles/${id}`, data);
return response.data;
};
export const list_users = async () => {
const response = await axiosInstance.get<User[]>("/api/v1/list-users");
return response.data;
};
export const list_minimal_users = async () => {
const response = await axiosInstance.get<MinimalUser[]>("/api/v1/minimal-user-list");
return response.data;
};
export const remove_user = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/users/${id}`);
return response.data;
};
export const create_user = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/users", data);
return response.data;
};
export const update_user = async (id: number, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/users/${id}`, data);
return response.data;
};
export const get_user_tokens = async (id: number) => {
const response = await axiosInstance.get<AccessToken[]>(`/api/v1/user-tokens/${id}`);
return response.data;
};
export const get_current_user = async () => {
const response = await axiosInstance.get<User>("/api/v1/current-user");
return response.data;
};

View File

@@ -22,10 +22,11 @@ import { FixedHeader } from "./layout/fixed-header";
import { Main } from "./layout/main";
import Logo from '@/assets/logo.svg'
import { useTranslation } from 'react-i18next'
import { Separator } from "./ui/separator";
export default function APIDocs() {
const { t } = useTranslation()
const docsOptions = [
{ name: t('apiDocs.swaggerUI'), path: "/api-docs/swagger" },
{ name: t('apiDocs.reDoc'), path: "/api-docs/redoc" },
@@ -51,6 +52,7 @@ export default function APIDocs() {
</p>
</div>
</div>
<Separator className='mt-2 mb-4 lg:mt-3 lg:mb-6' />
<div className='-mx-4 flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0'>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-6 p-4'>
<div className="grid w-full gap-4 sm:grid-cols-1 md:grid-cols-2 xl:max-w-4xl">

View File

@@ -7,6 +7,9 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import i18n from '@/i18n'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
type DatePickerProps = {
selected: Date | undefined
@@ -19,6 +22,10 @@ export function DatePicker({
onSelect,
placeholder = 'Pick a date',
}: DatePickerProps) {
const currentLang = i18n.language.toLowerCase().replace('_', '-');
const dateLocale = dateFnsLocaleMap[currentLang] || enUS;
return (
<Popover>
<PopoverTrigger asChild>
@@ -28,7 +35,7 @@ export function DatePicker({
className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal'
>
{selected ? (
format(selected, 'MMM d, yyyy')
format(selected, 'PPP', { locale: dateLocale })
) : (
<span>{placeholder}</span>
)}

View File

@@ -20,16 +20,18 @@
import {
IconHelp,
IconLayoutDashboard,
IconLockAccess,
IconSettings
} from '@tabler/icons-react'
import { IdCard, Inbox, Mailbox, Search } from 'lucide-react'
import { IdCard, Inbox, Mailbox, Search, Users2 } from 'lucide-react'
import { type SidebarData } from '../types'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
export function useSidebarData(): SidebarData {
const { t } = useTranslation()
const { require_any_permission } = useCurrentUser()
return {
navGroups: [
{
@@ -69,11 +71,18 @@ export function useSidebarData(): SidebarData {
title: t('navigation.oauth2'),
url: '/oauth2',
icon: IdCard,
},
visible: require_any_permission(['system:root', 'account:create']),
}
]
},
{
title: t('navigation.users'),
items: [
{
title: t('navigation.accessTokens'),
url: '/access-tokens',
icon: IconLockAccess,
title: t('navigation.users'),
url: '/users',
icon: Users2,
visible: require_any_permission(['system:root', 'user:manage']),
}
]
},

View File

@@ -1,22 +1,3 @@
//
// 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 { ReactNode } from 'react'
import { Link, useLocation } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
@@ -50,11 +31,16 @@ import { NavCollapsible, NavItem, NavLink, type NavGroup } from './types'
export function NavGroup({ title, items }: NavGroup) {
const { state } = useSidebar()
const href = useLocation({ select: (location) => location.href })
const visibleItems = items.filter(item => item.visible !== false)
if (visibleItems.length === 0) return null
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
{visibleItems.map((item) => {
const key = `${item.title}-${item.url}`
if (!item.items)
@@ -103,6 +89,10 @@ const SidebarMenuCollapsible = ({
href: string
}) => {
const { setOpenMobile } = useSidebar()
const visibleSubItems = item.items.filter(sub => sub.visible !== false)
if (visibleSubItems.length === 0) return null
return (
<Collapsible
asChild
@@ -120,7 +110,7 @@ const SidebarMenuCollapsible = ({
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{item.items.map((subItem) => (
{visibleSubItems.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
@@ -148,6 +138,10 @@ const SidebarMenuCollapsedDropdown = ({
item: NavCollapsible
href: string
}) => {
const visibleSubItems = item.items.filter(sub => sub.visible !== false)
if (visibleSubItems.length === 0) return null
return (
<SidebarMenuItem>
<DropdownMenu>
@@ -167,7 +161,7 @@ const SidebarMenuCollapsedDropdown = ({
{item.title} {item.badge ? `(${item.badge})` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items.map((sub) => (
{visibleSubItems.map((sub) => (
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
<Link
to={sub.url}
@@ -189,11 +183,11 @@ const SidebarMenuCollapsedDropdown = ({
function checkIsActive(href: string, item: NavItem, mainNav = false) {
return (
href === item.url || // /endpint?search=param
href.split('?')[0] === item.url || // endpoint
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
href === item.url ||
href.split('?')[0] === item.url ||
!!item?.items?.filter((i) => i.url === href).length ||
(mainNav &&
href.split('/')[1] !== '' &&
href.split('/')[1] === item?.url?.split('/')[1])
)
}
}

View File

@@ -23,6 +23,7 @@ interface BaseNavItem {
title: string
badge?: string
icon?: React.ElementType
visible?: boolean
}
type NavLink = BaseNavItem & {
@@ -31,7 +32,7 @@ type NavLink = BaseNavItem & {
}
type NavCollapsible = BaseNavItem & {
items: (BaseNavItem & { url: LinkProps['to'] })[]
items: (BaseNavItem & { url: LinkProps['to']; visible?: boolean })[]
url?: never
}

View File

@@ -20,6 +20,8 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { Button } from '@/components/ui/button'
import {
@@ -30,6 +32,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
import { showNumbers } from '@/lib/utils'
interface PaginationProps {
totalItems: number
@@ -66,6 +69,9 @@ export function EnvelopeListPagination({
setPageIndex(newPageIndex)
}
const currentPage = pageIndex + 1;
const pageNumbers = showNumbers(currentPage, pageCount)
return (
<div className='flex items-center justify-between space-x-2 overflow-auto px-2'>
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
@@ -94,6 +100,14 @@ export function EnvelopeListPagination({
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
onClick={() => setPageIndex(0)}
disabled={pageIndex === 0}
>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
@@ -103,6 +117,22 @@ export function EnvelopeListPagination({
<span className='sr-only'>{t("table.prevPage")}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
{pageNumbers.map((pageNumber, index) => (
<div key={`${pageNumber}-${index}`} className='flex items-center'>
{pageNumber === '...' ? (
<span className='px-1 text-sm text-muted-foreground'>...</span>
) : (
<Button
variant={currentPage === pageNumber ? 'default' : 'outline'}
className='h-8 min-w-8 px-2'
onClick={() => setPageIndex((pageNumber as number) - 1)}
>
{pageNumber}
</Button>
)}
</div>
))}
<Button
variant='outline'
className='h-8 w-8 p-0'
@@ -112,6 +142,14 @@ export function EnvelopeListPagination({
<span className='sr-only'>{t("table.nextPage")}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
onClick={() => setPageIndex(pageCount - 1)}
disabled={!hasNextPage()}
>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>

View File

@@ -22,48 +22,75 @@ import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { LogoutConfirmDialog } from '@/features/auth/sign-in/components/logout';
import { resetAccessToken } from '@/stores/authStore';
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useCurrentUser } from '@/hooks/use-current-user';
import useDialogState from '@/hooks/use-dialog-state';
import { useMemo } from 'react';
import { SignOutDialog } from './sign-out-dialog';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
export function ProfileDropdown() {
const navigate = useNavigate()
const [open, setOpen] = useDialogState()
const { t } = useTranslation()
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false)
const handleLogout = () => {
resetAccessToken()
navigate({ to: '/sign-in' })
}
const { data: user } = useCurrentUser()
const avatarSrc = useMemo(() => {
const base64 = user?.avatar;
if (!base64 || base64.length === 0) return null;
return `data:image/png;base64,${base64}`;
}, [user]);
const fallbackName = user?.username ? user.username.charAt(0).toUpperCase() : 'U';
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
<Avatar className='h-8 w-8'>
<AvatarFallback className='text-xs'>root</AvatarFallback>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
{avatarSrc ? (
<img src={avatarSrc} alt={t('profile.avatar_alt')} className="h-full w-full object-cover" />
) : (
<AvatarFallback className="text-xs">{fallbackName}</AvatarFallback>
)}
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' forceMount>
<DropdownMenuItem onClick={() => setIsLogoutDialogOpen(true)}>
{t('auth.logout')}
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
<DropdownMenuLabel className='font-normal'>
<div className='flex flex-col gap-1.5'>
<p className='text-sm leading-none font-medium'>{user?.username}</p>
<p className='text-muted-foreground text-xs leading-none'>
{user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link to='/settings/profile'>{t('profile.menu.profile')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to='/settings'>{t('profile.menu.settings')}</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setOpen(true)}>
{t('profile.menu.sign_out')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LogoutConfirmDialog
open={isLogoutDialogOpen}
onOpenChange={setIsLogoutDialogOpen}
handleConfirm={handleLogout}
/>
<SignOutDialog open={!!open} onOpenChange={setOpen} />
</>
)
}

View File

@@ -0,0 +1,36 @@
import { useNavigate, useLocation } from '@tanstack/react-router'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetToken } from '@/stores/authStore'
interface SignOutDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
const navigate = useNavigate()
const location = useLocation()
const handleSignOut = () => {
resetToken()
const currentPath = location.href
navigate({
to: '/sign-in',
search: { redirect: currentPath },
replace: true,
})
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title='Sign out'
desc='Are you sure you want to sign out? You will need to sign in again to access your account.'
confirmText='Sign out'
destructive
handleConfirm={handleSignOut}
className='sm:max-w-sm'
/>
)
}

View File

@@ -55,4 +55,4 @@ const AlertDescription = React.forwardRef<
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,69 +1,210 @@
import * as React from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { DayPicker } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: CalendarProps) {
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
: '[&:has([aria-selected])]:rounded-md'
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal aria-selected:opacity-100'
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
day_range_start: 'day-range-start',
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
IconLeft: () => <ChevronLeft className='h-4 w-4' />,
IconRight: () => <ChevronRight className='h-4 w-4' />,
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
Calendar.displayName = 'Calendar'
export { Calendar }
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -273,8 +273,7 @@ export function VirtualizedSelect({
.filter(Boolean);
if (selectedLabels.length === 0) return placeholder;
if (selectedLabels.length <= 3) return selectedLabels.join(', ');
return `${selectedLabels[0]}, ${selectedLabels[1]} +${selectedLabels.length - 2} more`;
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`;
};
return (

View File

@@ -1,50 +0,0 @@
//
// 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 { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { AccountInfo } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
export const getColumns = (t: (key: string) => string): ColumnDef<AccountInfo>[] => [
{
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accessTokens.accountId')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.original.id}</LongText>
),
meta: { className: 'w-80' },
enableHiding: false,
enableSorting: false
},
{
accessorKey: 'email',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accounts.email')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.getValue('email')}</LongText>
),
meta: { className: 'w-80' },
enableHiding: true,
enableSorting: false
},
]

View File

@@ -1,69 +0,0 @@
//
// 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 {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AccessToken } from '../data/schema'
import { Button } from '@/components/ui/button'
import { AccountsDetailTable } from './accounts-detail-table'
import { getColumns } from './accounts-detail-columns'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const columns = getColumns(t)
return (
<Dialog
open={open}
onOpenChange={(state) => {
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-4xl'>
<DialogHeader className='text-left'>
<DialogTitle>{t('settings.accounts')}</DialogTitle>
<DialogDescription>
{t('accessTokens.theListOfAccountsThatCanBeQueried')}
</DialogDescription>
</DialogHeader>
<div className="h-[33rem] overflow-x-auto overflow-y-auto">
<AccountsDetailTable data={currentRow.accounts} columns={columns} />
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,99 +0,0 @@
//
// 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 {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AccessToken } from '../data/schema'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
export function AclDetailDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={(state) => {
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-xl'>
<DialogHeader className='text-left'>
<DialogTitle>{t('settings.acl')}</DialogTitle>
<DialogDescription>
{t('accessTokens.aclRulesForAccessTokens')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[33rem] w-full pr-4 -mr-4 py-1'>
<div className="space-y-4">
{/* IP Whitelist */}
<div className="grid w-full items-center">
<Label className="mb-2">IP Whitelist</Label>
<Textarea
className="col-span-5 max-h-[240px] min-h-[300px]"
value={currentRow.acl?.ip_whitelist?.join('\n')}
/>
</div>
{/* Quota */}
<div className="grid w-full items-center">
<Label className="mb-2">Quota</Label>
<Input
type="number"
value={currentRow.acl?.rate_limit?.quota}
className="col-span-5"
/>
</div>
{/* Interval (seconds) */}
<div className="grid w-full items-center">
<Label className="mb-2">Interval (seconds)</Label>
<Input
type="number"
className="col-span-5"
value={currentRow.acl?.rate_limit?.interval}
/>
</div>
</div>
</ScrollArea>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,404 +0,0 @@
//
// 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 { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from '@/hooks/use-toast'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
// import { MultiSelect } from '@/components/multi-select'
import { Textarea } from '@/components/ui/textarea'
import { AccessToken } from '../data/schema'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { create_access_token, update_access_token } from '@/api/access-tokens/api'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { VirtualizedSelect } from '@/components/virtualized-select'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
const isValidIP = (ip: string) => {
const ipv4Regex = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/;
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
};
const RateLimitBaseSchema = z.object({
quota: z.optional(z.number()),
interval: z.optional(z.number()),
});
const AccessControlBaseSchema = z.object({
ip_whitelist: z.string().optional(),
rate_limit: z.optional(RateLimitBaseSchema),
});
const AccessTokenBaseSchema = z.object({
accounts: z.array(z.number()),
description: z.optional(z.string()),
acl: z.optional(AccessControlBaseSchema),
});
export type AccessTokenForm = z.infer<typeof AccessTokenBaseSchema>;
const getRateLimitSchema = (t: (key: string) => string) => z.object({
quota: z.optional(z.number().int().positive({ message: t('accessTokens.quotaMustBeAPositiveInteger') })),
interval: z.optional(z.number().int().positive({ message: t('accessTokens.intervalMustBeAPositiveInteger') })),
});
const getAccessControlSchema = (t: (key: string) => string) => AccessControlBaseSchema.extend({
rate_limit: getRateLimitSchema(t).optional(),
}).transform((data) => {
if (data.ip_whitelist) {
const ips = data.ip_whitelist
.split('\n')
.map((ip) => ip.trim())
.filter((ip) => ip !== '');
return {
...data,
ip_whitelist: ips.join('\n'),
};
}
return data;
}).refine(
(data) => {
if (data.ip_whitelist) {
const ips = data.ip_whitelist.split('\n');
const invalidIPs = ips.filter((ip) => !isValidIP(ip));
return invalidIPs.length === 0;
}
return true;
},
{
message: t('accessTokens.invalidIpAddressesFound'),
path: ['ip_whitelist'],
}
).transform((data) => {
if (data.rate_limit && !data.rate_limit.interval && !data.rate_limit.quota) {
return {
...data,
rate_limit: undefined,
};
}
return data;
})
.transform((data) => {
if (!data.ip_whitelist && !data.rate_limit) {
return undefined;
}
return data;
});
const getAccessTokenFormSchema = (t: (key: string) => string) => AccessTokenBaseSchema.extend({
accounts: z
.array(z.number())
.min(1, { message: t('accessTokens.atLeastOneAccountIsRequired') }),
description: z
.optional(z.string().max(255, { message: t('accessTokens.descriptionMustNotExceed255Characters') })),
acl: z.optional(getAccessControlSchema(t)),
});
interface Props {
currentRow?: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
const defaultValues = {
accounts: [],
description: undefined,
access_scopes: [],
acl: undefined,
};
export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const isEdit = !!currentRow
const queryClient = useQueryClient();
const form = useForm<AccessTokenForm>({
resolver: zodResolver(getAccessTokenFormSchema(t)),
defaultValues: isEdit
? {
accounts: currentRow.accounts.map(value => value.id),
description: currentRow.description ?? undefined,
acl: currentRow.acl
? {
ip_whitelist: currentRow.acl.ip_whitelist
? currentRow.acl.ip_whitelist.join('\n')
: undefined,
rate_limit: currentRow.acl.rate_limit ? currentRow.acl.rate_limit : undefined
}
: undefined,
}
: defaultValues,
});
const createMutation = useMutation({
mutationFn: create_access_token,
onSuccess: handleSuccess,
onError: handleError
});
const updateMutation = useMutation({
mutationFn: (data: Record<string, any>) => update_access_token(currentRow?.token ?? '', data),
onSuccess: handleSuccess,
onError: handleError
})
function handleSuccess() {
toast({
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updated') : t('accessTokens.created')}`,
description: t('accessTokens.yourAccessTokenHasBeenSuccessfully', { action: isEdit ? t('accessTokens.updated').toLowerCase() : t('accessTokens.created').toLowerCase() }),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['access-tokens'] });
form.reset();
onOpenChange(false);
}
function handleError(error: AxiosError) {
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
t('accessTokens.updateOrCreationFailed', { action: isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed') });
toast({
variant: "destructive",
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed')}`,
description: errorMessage as string,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
const { accountsOptions, isLoading } = useMinimalAccountList();
const onSubmit = (values: AccessTokenForm) => {
const payload = {
accounts: values.accounts,
description: values.description,
acl: values.acl
? {
...values.acl,
ip_whitelist: values.acl.ip_whitelist
? (() => {
const ipSet = new Set(
values.acl.ip_whitelist
.split('\n')
.map(ip => ip.trim())
.filter(ip => ip !== ''),
);
return ipSet.size > 0 ? Array.from(ipSet) : undefined;
})()
: undefined,
}
: undefined,
};
if (isEdit) {
updateMutation.mutate(payload);
} else {
createMutation.mutate(payload);
}
}
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset()
onOpenChange(state)
}}
>
<DialogContent className='max-w-4xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? t('accessTokens.editToken') : t('accessTokens.addNew')}</DialogTitle>
<DialogDescription>
{isEdit ? t('accessTokens.updateTheAccessTokenHere') : t('accessTokens.createNewAccessTokenHere')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[28rem] w-full pr-4 -mr-4 py-1'>
<Form {...form}>
<form
id='token-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4 p-0.5'
>
<FormField
control={form.control}
name='accounts'
render={({ field }) => (
<FormItem className='flex flex-col gap-y-1 space-y-0'>
<FormLabel className='mb-1'>{t('accessTokens.accounts')}:</FormLabel>
<FormControl>
<VirtualizedSelect
multiple
options={accountsOptions}
className='w-full'
isLoading={isLoading}
onSelectOption={(options) => {
const numberArray = options.map((v) => parseInt(v, 10));
return field.onChange(numberArray);
}}
value={field.value.map(String)}
placeholder={t('accessTokens.selectAccounts')}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t('accessTokens.selectMultipleAccountsForTheAccessToken')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="acl.ip_whitelist"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0">
<FormLabel className='mb-1'>{t('settings.acl')}:</FormLabel>
<FormControl>
<Textarea
placeholder={t('accessTokens.enterOneIpAddressPerLine')}
{...field}
className="max-h-[500px] min-h-[180px]"
/>
</FormControl>
<FormDescription>
{t('accessTokens.aListOfIpAddressesAllowed')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4">
<FormField
control={form.control}
name="acl.rate_limit.quota"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
<FormLabel className='mb-1'>{t('accessTokens.quota')}:</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('accessTokens.enterQuota')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>
{t('accessTokens.theMaximumNumberOfRequestsAllowed')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="acl.rate_limit.interval"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
<FormLabel className='mb-1'>{t('accessTokens.interval')}:</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('accessTokens.enterIntervalInSeconds')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>
{t('accessTokens.theTimeWindowForTheRateLimit')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem className='flex flex-col gap-y-1 space-y-0'>
<FormLabel className='mb-1'>{t('settings.description')}:</FormLabel>
<FormControl>
<Textarea
placeholder={t('accessTokens.describeThePurposeOfTheAccessToken')}
{...field}
className="max-h-[240px] min-h-[80px]"
/>
</FormControl>
<FormDescription>{t('oauth2.optional')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<DialogFooter>
<Button
type="submit"
form="token-form"
disabled={isEdit ? updateMutation.isPending : createMutation.isPending}
className="min-w-[100px] relative transition-all"
>
<span className="inline-flex items-center justify-center gap-2">
{(isEdit ? updateMutation.isPending : createMutation.isPending) && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
<span>
{isEdit
? updateMutation.isPending
? t('accessTokens.updating')
: t('accessTokens.saveChanges')
: createMutation.isPending
? t('accessTokens.creating')
: t('accessTokens.save')}
</span>
</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,127 +0,0 @@
//
// 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 { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { AccessToken } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format, formatDistanceToNow, Locale } from 'date-fns'
import { AccountCellAction } from './account-action'
import { AclCellAction } from './acl-action'
export const getColumns = (t: (key: string) => string, locale: Locale): ColumnDef<AccessToken>[] => [
{
accessorKey: 'token',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.token')} />
),
cell: ({ row }) => {
return <LongText className='w-40'>{row.original.token}</LongText>
},
meta: { className: 'w-40' },
enableHiding: false,
enableSorting: false,
},
{
accessorKey: 'accounts',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.accounts')} />
),
cell: AccountCellAction,
meta: { className: 'w-10 text-center' },
filterFn: (row, columnId, filterValue) => {
const accounts = row.getValue(columnId) as { account_id: number; email: string }[];
if (!filterValue) return true;
return accounts.some(
(account) =>
`${account.account_id}`.includes(filterValue) ||
account.email.includes(filterValue)
);
},
},
{
id: 'acl',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.acl')} />
),
cell: AclCellAction,
meta: { className: 'w-8 text-center' },
enableSorting: false
},
{
accessorKey: 'description',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.description')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.original.description}</LongText>
),
meta: { className: 'w-80' },
enableHiding: true,
enableSorting: false
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.createdAt')} />
),
cell: ({ row }) => {
const created_at = row.original.created_at;
const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText className='max-w-36'>{date}</LongText>;
},
meta: { className: 'w-36' },
enableHiding: false,
},
{
accessorKey: 'updated_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.updatedAt')} />
),
cell: ({ row }) => {
const updated_at = row.original.updated_at;
const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText className='max-w-36'>{date}</LongText>;
},
meta: { className: 'w-36' },
enableHiding: false,
},
{
accessorKey: 'last_access_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.lastAccess')} />
),
cell: ({ row }) => {
const last_access_at = row.original.last_access_at;
if (last_access_at === 0) {
return <LongText className='max-w-40'>{t('accessTokens.notUsedYet')}</LongText>;
}
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true, locale });
return <LongText className='max-w-40'>{result}</LongText>;
},
meta: { className: 'w-40' },
enableHiding: false,
},
{
id: 'actions',
cell: DataTableRowActions,
},
]

View File

@@ -1,103 +0,0 @@
//
// 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 {
ChevronLeftIcon,
ChevronRightIcon,
} from '@radix-ui/react-icons'
import { Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected: boolean,
showPageSizeSelector: boolean
}
export function DataTablePagination<TData>({
table,
showSelected = true,
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.getFilteredRowModel().rows.length} {t("table.results")}
</div>}
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
10 {t("table.rowsPerPage")}.
</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'>{t("table.rowsPerPage")}</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'>
{t("table.page")} {table.getState().pagination.pageIndex + 1}{" "}
{t("table.of")} {table.getPageCount()}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t("table.prevPage")}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t("table.nextPage")}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,160 +0,0 @@
//
// 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 { useState } from 'react'
import useDialogState from '@/hooks/use-dialog-state'
import { Button } from '@/components/ui/button'
import { Main } from '@/components/layout/main'
import { TokensActionDialog } from './components/action-dialog'
import { getColumns } from './components/columns'
import { TokenDeleteDialog } from './components/delete-dialog'
import { AccessTokensTable } from './components/access-token-table'
import AccessTokensProvider, {
type AccessTokensDialogType,
} from './context'
import Logo from '@/assets/logo.svg'
import { Plus } from 'lucide-react'
import { AccessToken } from './data/schema'
import { AccountDetailDialog } from './components/accounts-detail-dialog'
import { AclDetailDialog } from './components/acl-detail-dialog'
import { useQuery } from '@tanstack/react-query'
import { list_access_tokens } from '@/api/access-tokens/api'
import { TableSkeleton } from '@/components/table-skeleton'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useTranslation } from 'react-i18next'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
export default function AccessTokens() {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
// Dialog states
const [currentRow, setCurrentRow] = useState<AccessToken | null>(null)
const [open, setOpen] = useDialogState<AccessTokensDialogType>(null)
const { data: accessTokens, isLoading } = useQuery({
queryKey: ['access-tokens'],
queryFn: list_access_tokens,
})
const columns = getColumns(t, locale)
return (
<AccessTokensProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
{/* ===== Top Heading ===== */}
<FixedHeader />
<Main>
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
<p className="text-muted-foreground">
{t('accessTokens.description')}
</p>
</div>
<div className="flex gap-2">
<Button className="space-x-1" onClick={() => setOpen('add')}>
<span>{t('common.add')}</span> <Plus size={18} />
</Button>
</div>
</div>
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : accessTokens?.length ? (
<AccessTokensTable data={accessTokens} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('accessTokens.noTokensDesc')}
</p>
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
</div>
</div>
)}
</div>
</Main>
<TokensActionDialog
key='token-add'
open={open === 'add'}
onOpenChange={() => setOpen('add')}
/>
{currentRow && (
<>
<TokensActionDialog
key={`token-edit-${currentRow.token}`}
open={open === 'edit'}
onOpenChange={() => {
setOpen('edit')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<TokenDeleteDialog
key={`token-delete-${currentRow.token}`}
open={open === 'delete'}
onOpenChange={() => {
setOpen('delete')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<AccountDetailDialog
key={`accounts-detail-${currentRow.token}`}
currentRow={currentRow}
open={open === 'account-detail'}
onOpenChange={() => {
setOpen('account-detail')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}} />
<AclDetailDialog
key={`acl-detail-${currentRow.token}`}
currentRow={currentRow}
open={open === 'acl-detail'}
onOpenChange={() => {
setOpen('acl-detail')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}} />
</>
)}
</AccessTokensProvider>
)
}

View File

@@ -0,0 +1,275 @@
//
// 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 { useRoles } from '@/hooks/use-roles'
import { useMinimalUsers } from '@/hooks/use-minimal-users'
import { access_assign, AccountModel } 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>
)
}

View File

@@ -17,7 +17,6 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { AccountModel } from '../data/schema'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -25,6 +24,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useTranslation } from 'react-i18next'
import { AccountModel } from '@/api/account/api'
interface Props {
open: boolean
@@ -34,6 +34,30 @@ interface Props {
export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
const { t } = useTranslation()
const sinceText = (() => {
if (currentRow.date_since?.fixed) {
return currentRow.date_since.fixed;
}
if (currentRow.date_since?.relative?.value) {
return `${t('accounts.sinceRelativeValue', {
value: currentRow.date_since!.relative!.value,
unit: t(`accounts.${currentRow.date_since!.relative!.unit!.toLowerCase()}`)
})}`;
}
return t('accounts.syncAll');
})();
const hasSince = !!currentRow.date_since;
const hasBefore = !!currentRow.date_before?.value;
return (
<Dialog
open={open}
@@ -77,6 +101,10 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
<span className="text-muted-foreground">{t('accounts.incrementalSyncInterval')}:</span>
<span>{t('accounts.everyMinutes', { minutes: currentRow.sync_interval_min })}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.syncBatchSize')}:</span>
<span>{currentRow.sync_batch_size}</span>
</div>
<div className="flex flex-col gap-2">
<span className="text-muted-foreground">{t('accounts.capabilities')}:</span>
<code className="rounded-md bg-muted/50 px-2 py-1 text-sm border overflow-x-auto inline-block">
@@ -84,14 +112,33 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
</code>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.dateSelection')}:</span>
<span>
{currentRow.date_since?.fixed
? t('accounts.since') + ' ' + currentRow.date_since.fixed
: currentRow.date_since?.relative
? t('accounts.recent') + ' ' + currentRow.date_since.relative.value + ' ' + currentRow.date_since.relative.unit
: t('accounts.notAvailable')}
</span>
<span className="text-muted-foreground">{t('accounts.syncScope')}:</span>
{hasSince && (
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
{t('accounts.sinceFixed')}:
</span>
<span className="text-sm">{sinceText}</span>
</div>
)}
{hasBefore && (
<div className="flex flex-col border-t pt-2">
<span className="text-xs text-muted-foreground">
{t('accounts.beforeRelative')}:
</span>
<span className="text-sm">
{t('accounts.beforeRelativeValue', {
value: currentRow.date_before!.value,
unit: t(`accounts.${currentRow.date_before!.unit!.toLowerCase()}`)
})}
</span>
</div>
)}
{!hasSince && !hasBefore && (
<span className="text-sm text-muted-foreground">
{t('accounts.syncAll')}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.folderLimit')}:</span>
@@ -100,8 +147,6 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
</div>
</CardContent>
</Card>
{/* Server Configuration Card */}
<Card>
<CardHeader>
<CardTitle>{t('accounts.serverConfiguration')}</CardTitle>
@@ -143,8 +188,6 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
</div>
</CardContent>
</Card>
{/* Sync Folders Card */}
<Card>
<CardHeader>
<CardTitle>{t('accounts.syncFoldersTitle')}</CardTitle>

View File

@@ -16,14 +16,12 @@
// 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 { zodResolver } from '@hookform/resolvers/zod';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { AccountModel, ImapConfig } from '../data/schema';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useToast } from '@/hooks/use-toast';
@@ -31,11 +29,12 @@ import Step1 from './step1';
import Step2 from './step2';
import Step3 from './step3';
import Step4 from './step4';
import { create_account, autoconfig, update_account } from '@/api/account/api';
import { create_account, autoconfig, update_account, AccountModel, ImapConfig } from '@/api/account/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ToastAction } from '@/components/ui/toast';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { cn } from "@/lib/utils";
const encryptionSchema = z.union([
z.literal('Ssl'),
@@ -80,7 +79,7 @@ const getRelativeDateSchema = (t: (key: string) => string) => z.object({
});
const getDateSelectionSchema = (t: (key: string) => string) => z.union([
z.object({ fixed: z.string({ message: t('accounts.selectDate') }) },),
z.object({ fixed: z.string({ message: t('accounts.selectDate') }) }),
z.object({ relative: getRelativeDateSchema(t) }),
z.undefined(),
]);
@@ -107,8 +106,13 @@ export type Account = {
value?: number;
};
};
date_before?: {
unit?: 'Days' | 'Months' | 'Years';
value?: number;
};
folder_limit?: number;
sync_interval_min: number;
sync_batch_size: number;
};
const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
@@ -119,12 +123,18 @@ const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
enabled: z.boolean(),
use_dangerous: z.boolean(),
date_since: getDateSelectionSchema(t).optional(),
date_before: getRelativeDateSchema(t).optional(),
folder_limit: z
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
.int()
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
.optional(),
sync_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
sync_batch_size: z
.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') })
.int()
.min(30, { message: t('validation.incrementalSyncMustBeAtLeast10') })
.max(200, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
});
type Step = {
@@ -133,14 +143,12 @@ type Step = {
fields: (keyof Account)[];
};
export type Steps = [
...Step[]
];
export type Steps = [...Step[]];
const getSteps = (t: (key: string) => string): Steps => [
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email"] },
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous", "name"] },
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "date_before", "folder_limit", "sync_interval_min", "sync_batch_size"] },
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
];
@@ -168,8 +176,10 @@ const defaultValues: Account = {
enabled: true,
use_dangerous: false,
date_since: undefined,
date_before: undefined,
folder_limit: undefined,
sync_interval_min: 10,
sync_batch_size: 50,
};
const emptyImap: ImapConfig = {
@@ -194,8 +204,10 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
enabled: currentRow.enabled,
use_dangerous: currentRow.use_dangerous,
date_since: currentRow.date_since ?? undefined,
date_before: currentRow.date_before ?? undefined,
folder_limit: currentRow.folder_limit ?? undefined,
sync_interval_min: currentRow.sync_interval_min ?? 10,
sync_batch_size: currentRow.sync_batch_size ?? 50,
};
};
@@ -272,8 +284,10 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
enabled: data.enabled,
use_dangerous: data.use_dangerous,
date_since: data.date_since,
date_before: data.date_before,
folder_limit: data.folder_limit,
sync_interval_min: data.sync_interval_min,
sync_batch_size: data.sync_batch_size,
};
if (isEdit) {
updateMutation.mutate(commonData);
@@ -330,61 +344,67 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
<Dialog
open={open}
onOpenChange={(state) => {
form.reset();
setCurrentStep(1);
if (!state) {
form.reset();
setCurrentStep(1);
}
onOpenChange(state);
}}
>
<DialogContent className='max-w-5xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
<DialogDescription>
{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[38rem] w-full pr-4 -mr-4 py-1">
<>
<div className="flex my-5 space-x-4 md:hidden">
{steps.map((step, index) => (
<DialogContent className="max-w-[95vw] md:max-w-5xl w-full p-0 overflow-hidden flex flex-col h-[90vh]">
<div className="p-6 pb-2 flex-shrink-0">
<DialogHeader className="text-left">
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
<DialogDescription>
{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
</div>
<div className="flex flex-col md:flex-row flex-1 min-h-0 overflow-hidden border-y">
<div className="md:hidden flex px-6 py-2 space-x-2 overflow-x-auto border-b flex-shrink-0 bg-background/50">
{steps.map((step, index) => (
<div key={step.id} className="flex flex-col items-center flex-shrink-0 min-w-[70px]">
<Button
key={step.id}
className={`size-9 rounded-full border font-bold ${currentStep === index + 1 ? "bg-primary text-white" : "bg-gray-200 text-black"
}`}
variant={currentStep === index + 1 ? "default" : "secondary"}
className="size-8 rounded-full font-bold p-0"
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
))}
</div>
<div className="w-full max-w-full p-4">
<div className="flex md:h-min rounded-xl md:rounded-2xl p-4">
<div className="hidden md:block w-[260px] flex-shrink-0 rounded-xl p-5 pt-7 fixed">
{steps.map((step, index) => (
<div className="my-3 ml-2 flex items-center" key={step.id}>
<Button
className={`size-8 border rounded-full text-sm font-bold ${currentStep === index + 1 ? "bg-primary text-white" : "bg-gray-200 text-black"
}`}
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
<div className="flex flex-col items-baseline uppercase ml-5">
<span className="text-xs">{t('accounts.step', { index: index + 1 })}</span>
<span className="font-bold text-sm tracking-wider">{step.name}</span>
</div>
</div>
))}
</div>
<span className="text-[10px] mt-1 text-muted-foreground line-clamp-1">{step.name}</span>
</div>
))}
</div>
<div className="hidden md:block w-[240px] flex-shrink-0 px-8 py-4 border-r overflow-y-auto">
{steps.map((step, index) => (
<div className="mb-8 flex items-center" key={step.id}>
<Button
variant={currentStep === index + 1 ? "default" : "secondary"}
className="size-9 rounded-full text-sm font-bold"
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
<div className="flex flex-col items-baseline uppercase ml-4">
<span className="text-[10px] text-muted-foreground">{t('accounts.step', { index: index + 1 })}</span>
<span className={cn("font-bold text-sm tracking-wider", currentStep === index + 1 ? "text-foreground" : "text-muted-foreground")}>
{step.name}
</span>
</div>
</div>
))}
</div>
<div className="flex-1 min-h-0 relative">
<ScrollArea className="h-full w-full">
<div className="p-6 md:p-10 lg:p-14">
<Form {...form}>
<form
id="account-register-form"
className="flex-grow flex flex-col px-4 md:px-8 lg:px-12 ml-[240px]"
onSubmit={form.handleSubmit(onSubmit)}
>
<form id="account-register-form" onSubmit={form.handleSubmit(onSubmit)}>
{currentStep === 1 && <Step1 isEdit={isEdit} />}
{currentStep === 2 && <Step2 isEdit={isEdit} />}
{currentStep === 3 && <Step3 />}
@@ -392,14 +412,16 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
</form>
</Form>
</div>
</div>
</>
</ScrollArea>
<DialogFooter className="flex flex-wrap gap-2">
</ScrollArea>
</div>
</div>
<DialogFooter className="p-4 md:p-6 bg-background flex flex-row sm:justify-end gap-2 flex-shrink-0">
{currentStep > 1 && (
<Button
type="button"
className="flex-grow sm:flex-grow-0 shadow-none text-nowrap text-sm"
variant="outline"
className="flex-1 sm:flex-none"
onClick={() => setCurrentStep(currentStep - 1)}
>
{t('accounts.goBack')}
@@ -408,7 +430,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
{currentStep < LAST_STEP && (
<Button
type="button"
className="flex-grow sm:flex-grow-0 rounded-md md:rounded-lg px-6 text-sm"
className="flex-1 sm:flex-none px-8"
onClick={handleContinue}
>
{autoConfigLoading ? t('accounts.autoConfiguring') : t('accounts.continue')}
@@ -418,7 +440,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
<Button
type="submit"
form="account-register-form"
className="flex-grow sm:flex-grow-0 rounded-md text-sm px-7 md:rounded-lg"
className="flex-1 sm:flex-none px-10"
>
{isEdit ? t('accounts.saveChanges') : t('accounts.submit')}
</Button>

View File

@@ -19,8 +19,6 @@
import { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { AccountModel } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format } from 'date-fns'
@@ -28,6 +26,7 @@ import { OAuth2Action } from './oauth2-action'
import { RunningStateCellAction } from './running-state-action'
import { EnableAction } from './enable-action'
import { useTranslation } from 'react-i18next'
import { AccountModel } from '@/api/account/api'
export function useColumns(): ColumnDef<AccountModel>[] {
const { t } = useTranslation()
@@ -47,7 +46,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 +56,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 +68,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 +87,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 +105,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={t('accounts.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,
},
{

View File

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

View File

@@ -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,
@@ -30,9 +30,10 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
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'
import { AccountModel } from '@/api/account/api'
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>{t('accounts.accessControl')}</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>
</>

View File

@@ -24,11 +24,10 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { AccountModel } from '../data/schema'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { remove_account } from '@/api/account/api'
import { AccountModel, remove_account } from '@/api/account/api'
import { useTranslation } from 'react-i18next'
interface Props {
@@ -54,7 +53,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');

View File

@@ -18,16 +18,16 @@
import { Row } from '@tanstack/react-table'
import { AccountModel } from '../data/schema'
import { Switch } from '@/components/ui/switch'
import { useState } from 'react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ToastAction } from '@/components/ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { update_account } from '@/api/account/api'
import { AccountModel, 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 +37,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 +77,7 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
<Switch
checked={row.original.enabled}
onCheckedChange={() => setOpen(true)}
disabled={updateMutation.isPending}
disabled={!hasPermission || updateMutation.isPending}
/>
<ConfirmDialog
open={open}

View File

@@ -28,12 +28,11 @@ import { AxiosError } from 'axios';
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { create_account, update_account } from '@/api/account/api';
import { AccountModel, create_account, update_account } from '@/api/account/api';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2 } from 'lucide-react';
import { AccountModel } from '../data/schema';
import { useTranslation } from 'react-i18next';

View File

@@ -20,8 +20,11 @@
import { Row } from '@tanstack/react-table'
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'
import { AccountModel } from '@/api/account/api'
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

View File

@@ -26,7 +26,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AccountModel } from '../data/schema'
import { Button } from '@/components/ui/button'
import { get_oauth2_tokens } from '@/api/oauth2/api'
import { useQuery } from '@tanstack/react-query'
@@ -44,6 +43,7 @@ import { ToastAction } from '@/components/ui/toast'
import { useNavigate } from '@tanstack/react-router'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
import { AccountModel } from '@/api/account/api'
interface Props {
currentRow: AccountModel

View File

@@ -19,9 +19,12 @@
import { Row } from '@tanstack/react-table'
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';
import { AccountModel } from '@/api/account/api';
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>

View File

@@ -25,9 +25,8 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AccountModel } from '../data/schema'
import { useQuery } from '@tanstack/react-query'
import { account_state } from '@/api/account/api'
import { account_state, AccountModel } from '@/api/account/api'
import { formatDistanceToNow, formatDuration, intervalToDuration } from 'date-fns'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
@@ -52,6 +51,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) => {

View File

@@ -39,189 +39,206 @@ import { Button } from "@/components/ui/button";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { Calendar } from "@/components/ui/calendar";
import { cn } from "@/lib/utils";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn, dateFnsLocaleMap } from "@/lib/utils";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { useTranslation } from "react-i18next";
import { enUS } from "date-fns/locale";
import i18n from "@/i18n";
type SyncMode = 'all' | 'since_fixed' | 'since_relative' | 'before_relative';
export default function Step3() {
const { t } = useTranslation();
const { control, getValues, setValue } = useFormContext<Account>();
const current = getValues();
const [rangeType, setRangeType] = useState<'none' | 'fixed' | 'relative'>(
current.date_since ? (current.date_since.fixed ? 'fixed' : 'relative') : 'none'
);
const [syncMode, setSyncMode] = useState<SyncMode>(() => {
if (current.date_before) return 'before_relative';
if (current.date_since?.fixed) return 'since_fixed';
if (current.date_since?.relative) return 'since_relative';
return 'all';
});
const handleModeChange = (mode: SyncMode) => {
setSyncMode(mode);
setValue("date_since", undefined);
setValue("date_before", undefined);
if (mode === 'since_fixed') {
setValue("date_since.fixed", undefined);
} else if (mode === 'since_relative') {
setValue("date_since.relative", { value: 1, unit: 'Months' });
} else if (mode === 'before_relative') {
setValue("date_before", { value: 1, unit: 'Years' });
}
};
return (
<div className="space-y-8">
<FormField
control={control}
name="sync_interval_min"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">
{t('accounts.incrementalSync')}:
</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('accounts.incrementalSyncPlaceholder')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={control}
name="sync_interval_min"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.incrementalSync')}</FormLabel>
<FormControl>
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
<FormDescription>
{t('accounts.incrementalSyncDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={control}
name="sync_batch_size"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.syncBatchSize')}</FormLabel>
<FormControl>
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
<FormDescription>
{t('accounts.syncBatchSizeDescription')}
</FormDescription>
</FormItem>
)}
/>
</div>
<FormField
control={control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-col items-start gap-y-1">
<FormLabel>{t('accounts.enabled')}:</FormLabel>
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 shadow-sm">
<FormControl>
<Checkbox
className="mt-2"
checked={field.value}
onCheckedChange={field.onChange}
/>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>{t('accounts.enabledDescription')}</FormDescription>
<div className="space-y-1 leading-none">
<FormLabel>{t('accounts.enabled')}</FormLabel>
<FormDescription>{t('accounts.enabledDescription')}</FormDescription>
</div>
</FormItem>
)}
/>
<FormLabel className="flex items-center justify-between">{t('accounts.dateSince')}:</FormLabel>
<RadioGroup
defaultValue={rangeType}
onValueChange={(value: 'fixed' | 'relative' | 'none') => {
setRangeType(value);
if (value === 'none') {
setValue("date_since", undefined, { shouldValidate: true });
}
if (value === 'fixed') {
setValue("date_since", { fixed: undefined }, { shouldValidate: true });
}
if (value === 'relative') {
setValue("date_since", { relative: { value: undefined, unit: undefined } }, { shouldValidate: true });
}
}}
className="flex flex-row space-x-4"
>
<FormItem className="flex items-center space-x-3">
<RadioGroupItem value="none" />
<FormLabel className="font-normal">{t('accounts.none')}</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3">
<RadioGroupItem value="fixed" />
<FormLabel className="font-normal">{t('accounts.fixed')}</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3">
<RadioGroupItem value="relative" />
<FormLabel className="font-normal">{t('accounts.relative')}</FormLabel>
</FormItem>
</RadioGroup>
<FormDescription>
{t('accounts.syncStartDateDescription', {
fixedPart: rangeType === 'fixed' ? t('accounts.syncAfterDate') : t('accounts.syncRecentData'),
})}
</FormDescription>
<hr className="my-4" />
<div className="space-y-4">
<FormItem>
<FormLabel className="text-base font-semibold">{t('accounts.syncScope', 'Sync Strategy')}</FormLabel>
<FormDescription>
{t('accounts.syncScopeDescription', 'Choose which emails should be indexed and archived.')}
</FormDescription>
<Select value={syncMode} onValueChange={(v) => handleModeChange(v as SyncMode)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('accounts.selectMode')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('accounts.syncAll', 'Sync All Emails')}</SelectItem>
<SelectItem value="since_fixed">{t('accounts.sinceFixed', 'Since Specific Date')}</SelectItem>
<SelectItem value="since_relative">{t('accounts.sinceRelative', 'Keep Recent Emails')}</SelectItem>
<SelectItem value="before_relative">{t('accounts.beforeRelative', 'Archive Old Emails Only')}</SelectItem>
</SelectContent>
</Select>
</FormItem>
<div className="pl-2 border-l-2 border-primary/20 space-y-4 pt-2">
{syncMode === 'since_fixed' && (
<FormField
control={control}
name="date_since.fixed"
render={({ field }) => {
const currentLang = i18n.language.toLowerCase().replace('_', '-');
const dateLocale = dateFnsLocaleMap[currentLang] || enUS;
return <FormItem className="flex flex-col">
<FormLabel>{t('accounts.selectDate')}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn("w-[440px] pl-3 text-left font-normal", !field.value && "text-muted-foreground")}
>
{field.value ? format(new Date(field.value), "PPP", { locale: dateLocale }) : <span>{t('accounts.selectDate')}</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date?.toLocaleDateString('en-CA'))}
disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
locale={dateLocale}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>;
{rangeType === 'fixed' && (
<FormField
control={control}
name="date_since.fixed"
render={({ field }) => (
<FormItem className="flex flex-col">
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-[240px] pl-3 text-left font-normal text-brand-marine-blue",
!field.value && "text-muted-foreground"
)}
>
{field.value ? format(field.value, "PPP") : <span>{t('accounts.selectDate')}</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? new Date(new Date(field.value).setHours(0, 0, 0, 0)) : undefined}
onSelect={(value) => {
if (value) {
const formattedDate = value.toLocaleDateString('en-CA');
field.onChange(formattedDate);
} else {
field.onChange(null);
}
}}
disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
}}
/>
)}
/>
)}
{rangeType === 'relative' && (
<div className="flex flex-row gap-4">
<div className="flex-1">
<FormField
control={control}
name="date_since.relative.value"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="number" placeholder="e.g. 1" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={control}
name="date_since.relative.unit"
render={({ field }) => (
<FormItem>
<Select onValueChange={field.onChange} defaultValue={field.value}>
{(syncMode === 'since_relative' || syncMode === 'before_relative') && (
<div className="flex flex-row items-end gap-4 animate-in fade-in slide-in-from-left-2">
<FormField
control={control}
name={syncMode === 'since_relative' ? "date_since.relative.value" : "date_before.value"}
render={({ field }) => (
<FormItem className="flex-1 max-w-[150px]">
<FormLabel>{t('accounts.duration', 'Duration')}</FormLabel>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('accounts.selectUnit')} />
</SelectTrigger>
<Input type="number" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<SelectContent>
<SelectItem value="Days">{t('accounts.days')}</SelectItem>
<SelectItem value="Months">{t('accounts.months')}</SelectItem>
<SelectItem value="Years">{t('accounts.years')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={syncMode === 'since_relative' ? "date_since.relative.unit" : "date_before.unit"}
render={({ field }) => (
<FormItem className="w-[180px]">
<FormLabel>{t('accounts.unit', 'Unit')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('accounts.selectUnit')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Days">{t('accounts.days')}</SelectItem>
<SelectItem value="Months">{t('accounts.months')}</SelectItem>
<SelectItem value="Years">{t('accounts.years')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
</div>
<hr className="my-4" />
<FormField
control={control}
name="folder_limit"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center justify-between">{t('accounts.folderLimit')}:</FormLabel>
<FormLabel>{t('accounts.folderLimit')}</FormLabel>
<FormDescription>{t('accounts.folderLimitDescription')}</FormDescription>
<FormControl>
<Input
@@ -237,4 +254,4 @@ export default function Step3() {
/>
</div>
);
}
}

View File

@@ -27,9 +27,28 @@ export default function Step4() {
const { getValues } = useFormContext<Account>();
const summaryData = getValues();
const sinceText = (() => {
if (summaryData.date_since?.fixed) {
return summaryData.date_since.fixed;
}
if (summaryData.date_since?.relative?.value) {
return `${t('accounts.sinceRelativeValue', {
value: summaryData.date_since!.relative!.value,
unit: t(`accounts.${summaryData.date_since!.relative!.unit!.toLowerCase()}`)
})}`;
}
return t('accounts.syncAll');
})();
const hasSince = !!summaryData.date_since;
const hasBefore = !!summaryData.date_before?.value;
return (
<div className="p-5 rounded-xl">
<Accordion type="multiple" defaultValue={['email', 'name', 'imap', 'date_since', 'folder_limit', 'sync_interval']}>
<Accordion type="multiple" defaultValue={['email', 'name', 'imap', 'date_since', 'folder_limit', 'sync_interval', 'sync_scope', 'sync_batch_size']}>
<AccordionItem key="email" value="email">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.email')}:</AccordionTrigger>
<AccordionContent>{summaryData.email}</AccordionContent>
@@ -82,17 +101,44 @@ export default function Step4() {
</AccordionContent>
</AccordionItem>
<AccordionItem key="date_since" value="date_since">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.dateSelection')}:</AccordionTrigger>
<AccordionContent>
{summaryData.date_since?.fixed
? t('accounts.since') + ' ' + summaryData.date_since.fixed
: summaryData.date_since?.relative && summaryData.date_since.relative.value && summaryData.date_since.relative.unit
? t('accounts.recent') + ' ' + summaryData.date_since.relative.value + ' ' + summaryData.date_since.relative.unit
: t('accounts.notAvailable')}
<AccordionItem key="sync_scope" value="sync_scope">
<AccordionTrigger className="font-medium capitalize text-gray-600">
{t('accounts.syncScope')}:
</AccordionTrigger>
<AccordionContent className="space-y-3">
{hasSince && (
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
{t('accounts.sinceFixed')}:
</span>
<span className="text-sm">{sinceText}</span>
</div>
)}
{hasBefore && (
<div className="flex flex-col border-t pt-2">
<span className="text-xs text-muted-foreground">
{t('accounts.beforeRelative')}:
</span>
<span className="text-sm">
{t('accounts.beforeRelativeValue', {
value: summaryData.date_before!.value,
unit: t(`accounts.${summaryData.date_before!.unit!.toLowerCase()}`)
})}
</span>
</div>
)}
{!hasSince && !hasBefore && (
<span className="text-sm text-muted-foreground">
{t('accounts.syncAll')}
</span>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem key="folder_limit" value="folder_limit">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.folderLimit')}:</AccordionTrigger>
<AccordionContent>{summaryData.folder_limit ?? t('accounts.notAvailable')}</AccordionContent>
@@ -102,6 +148,11 @@ export default function Step4() {
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.incrementalSync')}:</AccordionTrigger>
<AccordionContent>{summaryData.sync_interval_min} {t('accounts.minutes')}</AccordionContent>
</AccordionItem>
<AccordionItem key="sync_batch_size" value="sync_batch_size">
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.syncBatchSize')}:</AccordionTrigger>
<AccordionContent>{summaryData.sync_batch_size}</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);

View File

@@ -29,12 +29,11 @@ import { Button } from '@/components/ui/button'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, CheckSquare, Square } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AccountModel } from '../data/schema'
import { toast } from '@/hooks/use-toast'
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree'
import { Skeleton } from '@/components/ui/skeleton'
import { update_account } from '@/api/account/api'
import { AccountModel, update_account } from '@/api/account/api'
import { ToastAction } from '@/components/ui/toast'
import axios, { AxiosError } from 'axios'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -251,7 +250,12 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIcon status={status} />
</TreeItemIconContainer>
<TreeItemCheckbox {...getCheckboxProps()} />
<TreeItemCheckbox {...getCheckboxProps()} sx={{
color: 'hsl(var(--muted-foreground) / 0.4)',
'&.Mui-checked': {
color: 'hsl(var(--primary))',
},
}} />
<CustomLabel
{...getLabelProps({
exists: item.exists,

View File

@@ -41,10 +41,10 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { AccountModel } from '../data/schema'
import { DataTablePagination } from './data-table-pagination'
import { DataTableToolbar } from './data-table-toolbar'
import { useTranslation } from 'react-i18next'
import { AccountModel } from '@/api/account/api'
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@@ -17,10 +17,20 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { AccountModel } from '@/api/account/api';
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

View File

@@ -1,64 +0,0 @@
//
// 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/>.
type Encryption = 'Ssl' | 'StartTls' | 'None';
type AuthType = 'Password' | 'OAuth2';
type Unit = 'Days' | 'Months' | 'Years';
type AccountType = 'IMAP' | 'NoSync';
// Interface definitions
interface AuthConfig {
auth_type: AuthType;
password?: string;
}
export interface ImapConfig {
host: string;
port: number; // integer, 0-65535
encryption: Encryption;
auth: AuthConfig;
use_proxy?: number;
}
interface RelativeDate {
unit: Unit;
value: number; // integer, minimum 1
}
interface DateSelection {
fixed?: string; // format: "YYYY-MM-DD"
relative?: RelativeDate;
}
export interface AccountModel {
id: number;
account_type: AccountType;
imap?: ImapConfig;
enabled: boolean;
name?: string,
email: string;
capabilities?: string[];
date_since?: DateSelection;
folder_limit?: number,
sync_folders: string[];
sync_interval_min?: number;
created_at: number;
updated_at: number;
use_proxy?: number
use_dangerous: boolean
}

View File

@@ -30,9 +30,8 @@ import AccountProvider, {
} from './context'
import { MoreVertical, Plus } from 'lucide-react'
import Logo from '@/assets/logo.svg'
import { AccountModel } from './data/schema'
import { AccountDetailDrawer } from './components/account-detail'
import { list_accounts } from '@/api/account/api'
import { AccountModel, list_accounts } from '@/api/account/api'
import { TableSkeleton } from '@/components/table-skeleton'
import { useQuery } from '@tanstack/react-query'
import { OAuth2TokensDialog } from './components/oauth2-tokens'
@@ -42,6 +41,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 +50,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 +65,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 +72,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 +99,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 +168,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 +179,8 @@ export default function Accounts() {
}, 500)
}}
currentRow={currentRow}
/>
/>}
<AccountDeleteDialog
key={`account-delete-${currentRow.id}`}
open={open === 'delete'}
@@ -201,17 +203,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>

View File

@@ -16,26 +16,26 @@
// 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 Logo from '@/assets/logo.svg'
import { Row } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { useAccessTokensContext } from '../context'
import { AccessToken } from '../data/schema'
interface DataTableRowActionsProps {
row: Row<AccessToken>
type AuthLayoutProps = {
children: React.ReactNode
}
export function AccountCellAction({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useAccessTokensContext()
const accounts = row.original.accounts;
export function AuthLayout({ children }: AuthLayoutProps) {
return (
<Button variant='ghost' onClick={() => {
setCurrentRow(row.original)
setOpen('account-detail')
}}>
<span>{accounts.length}</span>
</Button>
<div className='container grid h-svh max-w-none items-center justify-center'>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 py-8 sm:w-[480px] sm:p-8'>
<div className='mb-4 flex items-center justify-center'>
<img
src={Logo}
width={150}
height={150}
alt='Bichon Logo'
/>
</div>
{children}
</div>
</div>
)
}

View File

@@ -33,8 +33,7 @@ import {
import { Input } from '@/components/ui/input'
import { PasswordInput } from '@/components/password-input'
import { useMutation } from '@tanstack/react-query'
import { login } from '@/api/access-tokens/api'
import { setAccessToken } from '@/stores/authStore'
import { setToken } from '@/stores/authStore'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
import { ToastAction } from '@/components/ui/toast'
@@ -42,20 +41,26 @@ import { useLocation, useNavigate } from '@tanstack/react-router'
import { Button } from '@/components/button'
import { useTranslation } from 'react-i18next'
import i18n from '@/i18n'
import { Loader2, LogIn } from 'lucide-react'
import { login } from '@/api/users/api'
import { useTheme } from '@/context/theme-context'
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
const getFormSchema = (t: (key: string, options?: Record<string, any>) => string) => z.object({
username: z
.string(),
password: z
.string()
.min(1, { message: t('validation.pleaseEnterPassword') })
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
});
const getFormSchema = (t: (key: string, options?: Record<string, any>) => string) =>
z.object({
username: z
.string()
.min(1, { message: t('validation.pleaseEnterUsernameOrEmail') }),
password: z
.string()
.min(1, { message: t('validation.pleaseEnterPassword') })
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
});
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState(false)
const { setTheme } = useTheme();
const navigate = useNavigate()
const { t } = useTranslation()
@@ -66,24 +71,42 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: 'root',
username: '',
password: '',
},
})
const mutation = useMutation({
mutationFn: (password: string) => login(password),
mutationFn: (data: Record<string, any>) => login(data),
retry: 0,
});
async function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true)
mutation.mutate(data.password, {
onSuccess: (rootToken) => {
setAccessToken(rootToken);
mutation.mutate(data, {
onSuccess: (result) => {
if (result.success) {
setToken(result);
if (result.theme) {
setTheme(result.theme);
}
if (result.language) {
i18n.changeLanguage(result.language);
}
navigate({ to: redirect });
} else {
toast({
variant: "destructive",
title: t('auth.loginFailed'),
description: `${result.error_message!}`,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
})
}
setIsLoading(false);
navigate({ to: redirect });
},
onError: (error) => {
const { t } = i18n
@@ -119,7 +142,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
<FormItem className='space-y-1'>
<FormLabel>{t('auth.username')}</FormLabel>
<FormControl>
<Input disabled {...field} value={"root"} />
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -140,7 +163,8 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
</FormItem>
)}
/>
<Button className='mt-2' loading={isLoading}>
<Button className='mt-2' disabled={isLoading}>
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn size={16} className='mr-2' />}
{t('auth.login')}
</Button>
</div>

View File

@@ -16,30 +16,41 @@
// 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 Logo from '@/assets/logo.svg'
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { UserAuthForm } from './components/user-auth-form'
import { useTranslation } from 'react-i18next'
import { AuthLayout } from './auth-layout'
export default function SignIn() {
const { t } = useTranslation()
return (
<div className='container relative flex h-svh flex-col items-center justify-center'>
<div className='p-8 flex flex-col items-center'>
<img
src={Logo}
className='mb-6'
width={350}
height={350}
alt='Bichon Logo'
/>
<h2 className='mb-4 text-lg font-medium text-muted-foreground'>
{t('auth.welcome')}
</h2>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 sm:w-[350px]'>
<AuthLayout>
<Card className='gap-4'>
<CardHeader>
<CardTitle className='text-lg tracking-tight'>{t('auth.welcome')}</CardTitle>
</CardHeader>
<CardContent>
<UserAuthForm />
</div>
</div>
</div>
</CardContent>
<CardFooter>
<p className="text-muted-foreground px-8 text-center text-sm">
{t('common.project_description')}
<a
href="https://github.com/rustmailer/bichon"
className="hover:text-primary underline underline-offset-4 ml-1"
>
{t('common.view_on_github_button')}
</a>
.
</p>
</CardFooter>
</Card>
</AuthLayout>
)
}

View File

@@ -164,7 +164,7 @@ export default function MailArchiveDashboard() {
return (
<>
<FixedHeader />
<Main higher>
<Main>
<div className="flex-1 space-y-6 p-6 md:p-8">
<div className="flex items-center justify-between">
<div>

View File

@@ -142,7 +142,6 @@ export function MailList({
/>
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
{/* LEFT AREA: From + Subject + Tags */}
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-1">
<div className="flex items-center gap-1 min-w-0">
<p className="text-sm font-medium truncate">{item.from}</p>
@@ -154,7 +153,6 @@ export function MailList({
{item.subject}
</h3>
{/* TAGS BELOW SUBJECT */}
<div className="flex flex-wrap gap-1 mt-0.25">
{item.tags?.map((tag, i) => (
<Badge
@@ -167,7 +165,6 @@ export function MailList({
</div>
</div>
{/* RIGHT AREA actions & meta */}
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
{hasAttachments && (
<div className="flex items-center gap-1">

View File

@@ -104,7 +104,7 @@ function CustomLabel({
alignItems: 'center',
}}
>
<FolderIcon className="mr-2"/>
<FolderIcon className="mr-2" />
<span className="font-medium text-sm text-inherit">
{children}
</span>
@@ -330,18 +330,6 @@ export function Mail({
))}
</div>
) : (
// <TreeView
// data={buildTree(mailboxes ?? [])}
// clickRowToSelect={true}
// onSelectChange={(item) => {
// if (item) {
// setSelectedMailbox(mailboxes?.find(m => m.id === parseInt(item.id, 10)))
// setPage(0);
// } else {
// setSelectedMailbox(undefined)
// }
// }}
// />
<RichTreeView
//checkboxSelection
items={tree}
@@ -366,8 +354,8 @@ export function Mail({
<MailList
isLoading={isMessagesLoading}
items={(envelopes?.items ?? []).sort((a, b) => {
const dateA = a.internal_date;
const dateB = b.internal_date;
const dateA = a.date;
const dateB = b.date;
return dateB - dateA;
})}
/>

View File

@@ -116,11 +116,11 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
)}
{allMessages
.sort((a, b) => a.internal_date - b.internal_date)
.sort((a, b) => a.date - b.date)
.map((msg) => {
const isExpanded = expandedIds.has(msg.id);
const preview = msg.text?.slice(0, 120) + (msg.text?.length > 120 ? '...' : '');
const date = new Date(msg.internal_date);
const date = new Date(msg.date);
const formattedDate = isNaN(date.getTime())
? t('mailbox.thread.invalidDate')
: format(date, 'yyyy-MM-dd HH:mm:ss');

View File

@@ -34,7 +34,7 @@ export default function Mailboxes() {
<>
{/* ===== Top Heading ===== */}
<FixedHeader />
<Main higher>
<Main>
<Mail
defaultLayout={defaultLayout}
defaultCollapsed={defaultCollapsed}

View File

@@ -36,48 +36,64 @@ import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected: boolean,
showPageSizeSelector: boolean
showSelected?: boolean
showPageSizeSelector?: boolean
}
export function DataTablePagination<TData>({
table,
showSelected = true,
showPageSizeSelector = true
showSelected = false,
showPageSizeSelector = true,
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation();
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.getFilteredRowModel().rows.length} {t("table.results")}
</div>}
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
10 {t("table.rowsPerPage")}.
</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'>{t("table.rowsPerPage")}</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'>
{t("table.page")} {table.getState().pagination.pageIndex + 1}{" "}
{t("table.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
@@ -86,7 +102,7 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t("table.firstPage")}</span>
<span className='sr-only'>{t('table.pagination.first')}</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -95,7 +111,7 @@ export function DataTablePagination<TData>({
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t("table.prevPage")}</span>
<span className='sr-only'>{t('table.pagination.previous')}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -104,7 +120,7 @@ export function DataTablePagination<TData>({
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t("table.nextPage")}</span>
<span className='sr-only'>{t('table.pagination.next')}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
@@ -113,11 +129,11 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t("table.lastPage")}</span>
<span className='sr-only'>{t('table.pagination.last')}</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>
)
}
}

View File

@@ -28,6 +28,7 @@ import { toast } from '@/hooks/use-toast';
import { EmailEnvelope } from '@/api';
import { validateTag } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
interface Props {
open: boolean
@@ -37,6 +38,7 @@ interface Props {
export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
const { tags: availableTags } = useAvailableTags();
const queryClient = useQueryClient();
const { mutate, isPending } = useUpdateTags();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
@@ -74,6 +76,26 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
};
const handleSave = () => {
if (inputValue.trim()) {
const normalized = inputValue.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.addTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (!selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
}
const updates = {
[currentEnvelope.account_id]: [currentEnvelope.id],
};
@@ -81,7 +103,9 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
mutate(
{
updates,
tags: selectedTags
tags: inputValue.trim()
? [...selectedTags, inputValue.toLowerCase().trim()]
: selectedTags,
},
{
onSuccess: () => {
@@ -94,6 +118,7 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
</div>
),
});
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
onOpenChange(false);
},
onError: (error: any) => {

View File

@@ -146,9 +146,6 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
<span className="sr-only">{t('search.bulkActions.clear')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{t('search.bulkActions.clearWithKey', { key: 'Escape' })}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />

View File

@@ -37,6 +37,7 @@ import { Button } from '@/components/ui/button';
import { EnvelopeTags } from './tag-facet';
import { EditTagsDialog } from './add-tag-dialog';
import { useTranslation } from 'react-i18next';
import Logo from '@/assets/logo.svg'
export default function Search() {
const { t } = useTranslation()
@@ -134,16 +135,20 @@ export default function Search() {
</Card>
)}
{total === 0 && <div className="text-center py-12 space-y-4">
<div className="bg-muted/50 border-2 border-dashed rounded-xl w-24 h-24 mx-auto flex items-center justify-center">
<SearchIcon className="w-10 h-10 text-muted-foreground" />
{total === 0 && <div className="flex h-[750px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('search.noEmailsFound')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
{Object.keys(filter).length === 0
? t('search.startSearching')
: t('search.adjustSearch')}
</p>
</div>
<h3 className="text-lg font-medium">{t('search.noEmailsFound')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
{Object.keys(filter).length === 0
? t('search.startSearching')
: t('search.adjustSearch')}
</p>
</div>}
{total > 0 && <ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'>
<MailList

View File

@@ -182,7 +182,6 @@ export function MailList({
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
{/* LEFT AREA: From + Subject + Tags */}
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-1 min-w-0">
<p className="text-sm font-medium truncate">{item.from}</p>
@@ -190,7 +189,11 @@ export function MailList({
{item.subject}
</h3>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/60">
<span className="truncate">{item.account_email}</span>
<span className="scale-75 opacity-50"></span>
<span className="font-medium text-primary/70">{item.mailbox_name}</span>
</div>
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
{item.subject}
</h3>
@@ -201,8 +204,6 @@ export function MailList({
))}
</div>
</div>
{/* RIGHT AREA actions & meta */}
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
{hasAttachments && (

View File

@@ -37,6 +37,7 @@ import { useSearchContext } from "./context";
import { toast } from "@/hooks/use-toast";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useTranslation } from "react-i18next";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const getSearchFilterSchema = (t: (key: string) => string) => z.object({
text: z.string().optional().or(z.literal("")),
@@ -66,8 +67,7 @@ const getSearchFilterSchema = (t: (key: string) => string) => z.object({
before: z.date().optional(),
account_id: z.number().optional().or(z.literal("")),
mailbox_id: z.number().optional().or(z.literal("")),
min_size: z.number().optional().or(z.literal("")),
max_size: z.number().optional().or(z.literal("")),
size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large']).optional(),
message_id: z.string().optional().or(z.literal("")),
});
@@ -98,6 +98,23 @@ const cleanEmpty = <T extends Record<string, any>>(obj: T): Partial<T> => {
) as Partial<T>;
};
function withSizePreset(values: Record<string, any>) {
const { size_preset, ...rest } = values;
switch (size_preset) {
case 'tiny':
return { ...rest, max_size: 15 * 1024 };
case 'small':
return { ...rest, max_size: 2 * 1024 * 1024 };
case 'medium':
return { ...rest, max_size: 20 * 1024 * 1024 };
case 'large':
return { ...rest, min_size: 20 * 1024 * 1024 };
default:
return rest;
}
}
export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) {
const { t } = useTranslation()
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -116,8 +133,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
bcc: "",
attachment_name: "",
message_id: "",
min_size: undefined,
max_size: undefined,
size_preset: 'any',
has_attachment: false,
since: undefined,
before: undefined,
@@ -143,11 +159,16 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
const handleSubmit = (values: Record<string, any>) => {
let cleaned = cleanEmpty(values);
if (selectedTags.length > 0) {
cleaned.tags = selectedTags;
}
if (Object.keys(cleaned).length > 0) {
onSubmit(cleaned);
const payload = withSizePreset(cleaned);
const finalPayload =
selectedTags.length > 0
? { ...payload, tags: selectedTags }
: payload;
if (Object.keys(finalPayload).length > 0) {
onSubmit(finalPayload);
} else {
toast({
title: t('search.pleaseSelectAtLeastOne'),
@@ -168,8 +189,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
before: undefined,
account_id: undefined,
mailbox_id: undefined,
min_size: undefined,
max_size: undefined,
size_preset: 'any',
message_id: "",
});
setSelectedAccountId(undefined);
@@ -414,27 +434,33 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
/>
<FormField
control={form.control}
name="min_size"
name="size_preset"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">{t('search.minSize')} (bytes):</FormLabel>
<FormControl>
<Input type="number" placeholder="1MB = 1048576" className="h-9" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="max_size"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">{t('search.maxSize')} (bytes):</FormLabel>
<FormControl>
<Input type="number" placeholder="10MB = 10485760" className="h-9" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
</FormControl>
<FormLabel className="text-xs">
{t('search.size')}
</FormLabel>
<Select
value={field.value}
onValueChange={(value) => field.onChange(value)}
>
<FormControl>
<SelectTrigger className="h-9">
<SelectValue placeholder={t('search.any')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="any">{t('search.any')}</SelectItem>
<SelectItem value="tiny">{t('search.tiny')}</SelectItem>
<SelectItem value="small">{t('search.small')}</SelectItem>
<SelectItem value="medium">{t('search.medium')}</SelectItem>
<SelectItem value="large">{t('search.large')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
<FormDescription className="text-xs">
{t('search.sizeDescription')}
</FormDescription>
</FormItem>
)}
/>

View File

@@ -113,13 +113,13 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
)}
{allMessages
.sort((a, b) => a.internal_date - b.internal_date)
.sort((a, b) => a.date - b.date)
.map((msg) => {
const isExpanded = expandedIds.has(msg.id);
const preview =
msg.text?.slice(0, 120) +
(msg.text?.length > 120 ? '...' : '');
const date = new Date(msg.internal_date);
const date = new Date(msg.date);
const formattedDate = isNaN(date.getTime())
? t('search.thread.invalidDate')
: format(date, 'yyyy-MM-dd HH:mm:ss');

View File

@@ -0,0 +1,286 @@
//
// 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 { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from '@/hooks/use-toast'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { Loader2, Clock } from 'lucide-react'
import { AccessToken, create_access_token, update_access_token } from '@/api/users/api'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const getAccessTokenSchema = (t: any) => z.object({
name: z
.string()
.max(32, t('apiTokens.form.errorMax'))
.optional()
.or(z.literal('')),
expire_in: z
.number({
invalid_type_error: t('apiTokens.form.errorNumber'),
})
.int(t('apiTokens.form.errorInt'))
.positive(t('apiTokens.form.errorPositive'))
.optional(),
})
export type AccessTokenForm = z.infer<ReturnType<typeof getAccessTokenSchema>>
interface Props {
currentRow?: AccessToken
open: boolean
userId: number
onOpenChange: (open: boolean) => void
}
const defaultValues: AccessTokenForm = {
name: '',
expire_in: undefined,
}
export function TokensActionDialog({
currentRow,
open,
onOpenChange,
userId,
}: Props) {
const { t } = useTranslation()
const isEdit = !!currentRow
const queryClient = useQueryClient()
const form = useForm<AccessTokenForm>({
resolver: zodResolver(getAccessTokenSchema(t)),
defaultValues: isEdit
? {
name: currentRow.name ?? '',
expire_in: undefined,
}
: defaultValues,
})
const createMutation = useMutation({
mutationFn: create_access_token,
onSuccess: handleSuccess,
onError: handleError,
})
const updateMutation = useMutation({
mutationFn: (data: Record<string, any>) =>
update_access_token(currentRow?.token ?? '', data),
onSuccess: handleSuccess,
onError: handleError,
})
function handleSuccess() {
toast({
title: isEdit
? t('apiTokens.notifications.updateSuccess')
: t('apiTokens.notifications.createSuccess'),
description: isEdit
? t('apiTokens.notifications.updateSuccessDesc')
: t('apiTokens.notifications.createSuccessDesc'),
action: (
<ToastAction altText={t('apiTokens.notifications.close')}>
{t('apiTokens.notifications.close')}
</ToastAction>
),
})
queryClient.invalidateQueries({ queryKey: ['access-tokens'] })
queryClient.invalidateQueries({ queryKey: ['user-tokens', userId] })
form.reset(defaultValues)
onOpenChange(false)
}
function handleError(error: AxiosError) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
t('apiTokens.notifications.genericError')
toast({
variant: 'destructive',
title: isEdit
? t('apiTokens.notifications.updateFailed')
: t('apiTokens.notifications.createFailed'),
description: errorMessage,
action: (
<ToastAction altText={t('apiTokens.notifications.tryAgain')}>
{t('apiTokens.notifications.tryAgain')}
</ToastAction>
),
})
console.error(error)
}
const onSubmit = (values: AccessTokenForm) => {
const payload = {
user_id: userId,
name: values.name?.trim() || undefined,
expire_in: values.expire_in || undefined,
}
isEdit
? updateMutation.mutate(payload)
: createMutation.mutate(payload)
}
const isPending = isEdit
? updateMutation.isPending
: createMutation.isPending
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset(defaultValues)
onOpenChange(state)
}}
>
<DialogContent className="max-w-xl">
<DialogHeader className="text-left mb-4">
<DialogTitle>
{isEdit ? t('apiTokens.dialog.editTitle') : t('apiTokens.dialog.createTitle')}
</DialogTitle>
<DialogDescription>
{t('apiTokens.dialog.description')}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[14rem] w-full pr-4 -mr-4">
<Form {...form}>
<form
id="token-form"
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
{/* name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('apiTokens.form.nameLabel')}</FormLabel>
<FormControl>
<Input
{...field}
maxLength={32}
placeholder={t('apiTokens.form.namePlaceholder')}
/>
</FormControl>
<FormDescription>
{t('apiTokens.form.nameDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* expire_in */}
<FormField
control={form.control}
name="expire_in"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
{t('apiTokens.form.expirationLabel')}
</FormLabel>
<Select
value={field.value?.toString() ?? 'never'}
onValueChange={(value) => {
field.onChange(
value === 'never' ? undefined : Number(value)
)
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('apiTokens.form.expirationPlaceholder')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="never">{t('apiTokens.form.never')}</SelectItem>
<SelectItem value="24">{t('apiTokens.form.1day')}</SelectItem>
<SelectItem value="168">{t('apiTokens.form.7days')}</SelectItem>
<SelectItem value="720">{t('apiTokens.form.30days')}</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t('apiTokens.form.expirationDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<DialogFooter>
<Button
type="submit"
form="token-form"
disabled={isPending}
className="min-w-[120px]"
>
{isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit ? t('apiTokens.dialog.saveChanges') : t('apiTokens.dialog.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,108 @@
//
// 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 { useCurrentUser } from '@/hooks/use-current-user'
import { Loader2, Plus } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { get_user_tokens } from '@/api/users/api'
import { Skeleton } from '@/components/ui/skeleton'
import Logo from '@/assets/logo.svg'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { TokenCardList } from './token-list'
import { TokensActionDialog } from './access-token-action'
import { useTranslation } from 'react-i18next'
export function APITokens() {
const { t } = useTranslation()
const { data: user, isLoading, error } = useCurrentUser()
const [addOpen, setAddOpen] = useState(false)
const { data: tokens = [], isLoading: tokensLoading } = useQuery({
queryKey: ['user-tokens', user?.id!],
queryFn: () => get_user_tokens(user?.id!),
enabled: !!user?.id,
})
if (isLoading) {
return (
<div className="flex justify-center items-center h-64">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)
}
if (error || !user) {
return (
<div className="p-6 text-red-600">
{t('apiTokens.page.loadError')}
</div>
)
}
return (
<div className="w-full max-w-3xl px-4 sm:px-6 lg:px-8">
{tokensLoading ? (
<div className="flex flex-col gap-4 mt-4">
<Skeleton className="h-16 w-full rounded-lg" />
<Skeleton className="h-16 w-full rounded-lg" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
) : tokens.length === 0 ? (
<div className="flex h-[450px] items-center justify-center rounded-md border border-dashed mt-4">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center px-4">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('apiTokens.page.emptyTitle')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('apiTokens.page.emptyDescription')}
</p>
<Button onClick={() => setAddOpen(true)}>
<span>{t('apiTokens.page.addBtn')}</span>
<Plus size={18} className="ml-2" />
</Button>
</div>
</div>
) : (
<>
<div className="flex justify-end mb-4">
<Button onClick={() => setAddOpen(true)}>
<span>{t('apiTokens.page.addBtn')}</span>
<Plus size={18} className="ml-2" />
</Button>
</div>
<ScrollArea className="h-[40rem] w-full pr-4 -mr-4 py-1">
<TokenCardList tokens={tokens} userId={user.id} />
</ScrollArea>
</>
)}
<TokensActionDialog
key="api-token-add"
open={addOpen}
userId={user.id}
onOpenChange={setAddOpen}
/>
</div>
)
}

View File

@@ -0,0 +1,229 @@
//
// 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 {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Copy, Trash2 } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { format } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AccessToken, remove_access_token } from "@/api/users/api";
import { useTranslation } from "react-i18next";
import { toast } from "@/hooks/use-toast";
interface Props {
tokens: AccessToken[];
userId: number;
}
const isTokenExpired = (expireAt: number | null | undefined): boolean => {
if (!expireAt) return false;
return new Date(expireAt) < new Date();
};
export const TokenCardList: React.FC<Props> = ({ tokens, userId }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] =
React.useState<AccessToken | null>(null);
const deleteMutation = useMutation({
mutationFn: (token: string) => remove_access_token(token),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user-tokens', userId] });
setDeleteTarget(null);
toast({
description: t('apiTokens.notifications.deleteSuccess'),
});
},
onError: () => {
toast({
variant: "destructive",
description: t('apiTokens.notifications.deleteFailed'),
});
}
});
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
toast({
description: t('apiTokens.notifications.copied'),
});
};
return (
<>
<Accordion type="multiple" className="w-full space-y-4">
{tokens.map((token) => {
const expired = isTokenExpired(token.expire_at);
const itemValue = token.token;
return (
<AccordionItem
key={itemValue}
value={itemValue}
className={`border rounded-lg shadow-md transition-all duration-300 ${expired
? "border-red-400 bg-red-50/50"
: "border-gray-200"
}`}
>
<AccordionTrigger className="px-5 py-4 hover:no-underline">
<div className="flex items-center justify-between w-full gap-4">
<div className="flex flex-col gap-1 min-w-0">
<h3 className="text-sm font-semibold truncate">
{token.name || t('apiTokens.list.unnamedToken')}
</h3>
<div className="flex items-center gap-2">
<Badge
variant={expired ? "destructive" : "secondary"}
className="text-xs"
>
{expired ? t('apiTokens.list.statusExpired') : t('apiTokens.list.statusActive')}
</Badge>
</div>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{token.expire_at
? t('apiTokens.list.expiresOnShort', {
date: format(new Date(token.expire_at), "yyyy-MM-dd")
})
: t('apiTokens.list.neverExpires')}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-5 pb-5 pt-2 bg-muted/40">
<Separator className="mb-4" />
<div className="space-y-4">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('apiTokens.list.tokenLabel')}
</div>
<div className="flex items-center gap-2 bg-background border rounded-md px-3 py-2">
<code className="font-mono text-xs truncate flex-1">
{token.token}
</code>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => handleCopy(token.token)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
{/* Meta */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-muted-foreground">
<div>
<span className="block font-medium text-foreground">
{t('apiTokens.list.createdLabel')}
</span>
{format(new Date(token.created_at), "yyyy-MM-dd HH:mm")}
</div>
<div>
<span className="block font-medium text-foreground">
{t('apiTokens.list.lastUsedLabel')}
</span>
{token.last_access_at > 0
? format(new Date(token.last_access_at), "yyyy-MM-dd HH:mm")
: t('apiTokens.list.neverUsed')}
</div>
<div>
<span className="block font-medium text-foreground">
{t('apiTokens.list.expiresOnLabel')}
</span>
{token.expire_at
? format(new Date(token.expire_at), "yyyy-MM-dd HH:mm")
: t('apiTokens.list.neverLabel')}
</div>
</div>
<div className="pt-2 flex justify-end">
<Button
size="sm"
variant="destructive"
onClick={() => setDeleteTarget(token)}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('apiTokens.list.deleteBtn')}
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
<Dialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('apiTokens.deleteDialog.title')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('apiTokens.deleteDialog.description')}
</p>
<DialogFooter className="mt-4">
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
>
{t('apiTokens.deleteDialog.cancel')}
</Button>
<Button
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.token);
}}
>
{deleteMutation.isPending
? t('apiTokens.deleteDialog.deleting')
: t('apiTokens.deleteDialog.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,239 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons'
import { zodResolver } from '@hookform/resolvers/zod'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { useTheme } from '@/context/theme-context'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { update_user } from '@/api/users/api'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
const languages = [
{ value: 'ar', label: 'العربية' },
{ value: 'da', label: 'Dansk' },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Español' },
{ value: 'fi', label: 'Suomi' },
{ value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' },
{ value: 'jp', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'no', label: 'Norsk' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'sv', label: 'Svenska' },
{ value: 'zh', label: '中文' },
{ value: 'zh-tw', label: '繁體中文' },
]
const appearanceSchema = (t: (key: string) => string) => z.object({
theme: z.enum(['light', 'dark'], {
required_error: t('settings.appearance.validation.theme.required'),
}),
language: z.string({
required_error: t('settings.appearance.validation.language.required'),
})
})
type AppearanceFormValues = z.infer<ReturnType<typeof appearanceSchema>>
export function AppearanceForm() {
const { data: user } = useCurrentUser();
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
const { theme, setTheme } = useTheme();
const form = useForm<AppearanceFormValues>({
resolver: zodResolver(appearanceSchema(t)),
mode: 'onChange',
defaultValues: {
theme: (theme as 'light' | 'dark') || 'light',
language: i18n.language || 'en',
},
})
const mutation = useMutation({
mutationFn: async (values: AppearanceFormValues) => {
return update_user(user!.id, values)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user'] })
toast({ title: t('settings.profile.toast.updated') })
},
onError: (err: AxiosError) => {
toast({
variant: 'destructive',
title: t('settings.profile.toast.update_failed'),
description: (err.response?.data as any)?.message || err.message,
})
},
});
function onSubmit(data: AppearanceFormValues) {
i18n.changeLanguage(data.language);
setTheme(data.theme);
mutation.mutate(data);
}
return (
<div className="w-full max-w-6xl ml-0 px-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 w-full max-w-screen-xl mx-auto px-4 md:px-6"
>
<FormField
control={form.control}
name='language'
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel>{t('settings.appearance.field.language')}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant='outline'
role='combobox'
className={cn(
'w-[400px] justify-between',
!field.value && 'text-muted-foreground'
)}
>
{field.value
? languages.find((l) => l.value === field.value)?.label
: t('settings.appearance.placeholder.select_language')}
<CaretSortIcon className='ms-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-[400px] p-0' align="start">
<Command>
<CommandInput placeholder={t('settings.appearance.command.search')} />
<CommandEmpty>{t('settings.appearance.command.no_results')}</CommandEmpty>
<CommandList>
<CommandGroup>
{languages.map((language) => (
<CommandItem
value={language.label}
key={language.value}
onSelect={() => {
form.setValue('language', language.value)
}}
>
<CheckIcon
className={cn(
'mr-2 h-4 w-4',
language.value === field.value ? 'opacity-100' : 'opacity-0'
)}
/>
{language.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
{t('settings.appearance.description.language')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='theme'
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>{t('settings.appearance.field.theme')}</FormLabel>
<FormDescription>
{t('settings.appearance.description.theme')}
</FormDescription>
<FormMessage />
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='grid max-w-md grid-cols-2 gap-8 pt-2'
>
<FormItem>
<FormLabel className='[&:has([data-state=checked])>div]:border-primary cursor-pointer'>
<FormControl>
<RadioGroupItem value='light' className='sr-only' />
</FormControl>
<div className='items-center rounded-md border-2 border-muted p-1 hover:border-accent'>
<div className='space-y-2 rounded-sm bg-[#ecedef] p-2'>
<div className='space-y-2 rounded-md bg-white p-2 shadow-sm'>
<div className='h-2 w-[80px] rounded-lg bg-[#ecedef]' />
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
</div>
<div className='flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm'>
<div className='h-4 w-4 rounded-full bg-[#ecedef]' />
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
</div>
</div>
</div>
<span className='block w-full p-2 text-center font-normal'>
{t('settings.appearance.theme.light')}
</span>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className='[&:has([data-state=checked])>div]:border-primary cursor-pointer'>
<FormControl>
<RadioGroupItem value='dark' className='sr-only' />
</FormControl>
<div className='items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground'>
<div className='space-y-2 rounded-sm bg-slate-950 p-2'>
<div className='space-y-2 rounded-md bg-slate-800 p-2 shadow-sm'>
<div className='h-2 w-[80px] rounded-lg bg-slate-400' />
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
</div>
<div className='flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm'>
<div className='h-4 w-4 rounded-full bg-slate-400' />
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
</div>
</div>
</div>
<span className='block w-full p-2 text-center font-normal'>
{t('settings.appearance.theme.dark')}
</span>
</FormLabel>
</FormItem>
</RadioGroup>
</FormItem>
)}
/>
<div className="flex justify-start pt-4">
<Button type='submit'>
{t('settings.appearance.button.update')}
</Button>
</div>
</form>
</Form>
</div>
)
}

View File

@@ -0,0 +1,7 @@
import { AppearanceForm } from './appearance-form'
export function SettingsAppearance() {
return (
<AppearanceForm />
)
}

View File

@@ -1,50 +0,0 @@
//
// 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 { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
interface ContentSectionProps {
title: string
desc: string
children: React.JSX.Element,
showHeader?: boolean
}
export default function ContentSection({
title,
desc,
children,
showHeader = true
}: ContentSectionProps) {
return (
<div className='flex flex-1 flex-col'>
{showHeader && <div className='flex-none'>
<h3 className='text-lg font-medium'>{title}</h3>
<p className='text-sm text-muted-foreground'>{desc}</p>
</div>}
{showHeader && <Separator className='my-4 flex-none' />}
<ScrollArea className='faded-bottom -mx-4 flex-1 scroll-smooth px-4 md:pb-16'>
<div className='lg:max-w-2xl -mx-1 px-1.5'>{children}</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,232 @@
//
// 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 * as React from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Skeleton } from "@/components/ui/skeleton"
import { ShieldCheck, Server, Database, Globe, Lock, Activity, InfoIcon } from "lucide-react"
import { get_system_configurations } from "@/api/system/api"
import { useQuery } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
function BooleanBadge({ value }: { value: boolean }) {
const { t } = useTranslation()
return value ? (
<Badge variant="secondary">{t("systemConfig.status.enabled")}</Badge>
) : (
<Badge>{t("systemConfig.status.disabled")}</Badge>
)
}
function SettingRow({
label,
value,
description,
}: {
label: string
value: React.ReactNode
description?: string
}) {
return (
<div className="py-1">
<div className="grid grid-cols-[1fr_auto] items-center gap-3">
<div className="text-sm font-medium leading-tight">{label}</div>
<div className="text-sm text-right break-all leading-tight">{value}</div>
</div>
{description && (
<div className="mt-0.5 text-[11px] leading-tight text-muted-foreground">{description}</div>
)}
</div>
)
}
function SettingsCard({
icon: Icon,
title,
description,
children,
}: {
icon: React.ElementType
title: string
description?: string
children: React.ReactNode
}) {
return (
<Card className="h-full">
<CardHeader className="flex flex-row items-center gap-2 py-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle className="font-normal">{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</div>
</CardHeader>
<CardContent className="space-y-1">{children}</CardContent>
</Card>
)
}
function PageSkeleton() {
return (
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="py-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-4/6" />
</CardContent>
</Card>
))}
</div>
)
}
export default function ServerConfigurationsPage() {
const { t } = useTranslation()
const { data, isLoading, isError } = useQuery({
queryKey: ["system-configurations"],
queryFn: get_system_configurations,
})
if (isLoading) {
return (
<ScrollArea className="h-full">
<PageSkeleton />
</ScrollArea>
)
}
if (isError || !data) {
return (
<div className="p-4 text-sm text-destructive">{t("systemConfig.fields.loadError")}</div>
)
}
return (
<div className="w-full max-w-5xl ml-0 px-4">
<ScrollArea className="h-full w-full">
<div className="px-4 pt-6 pb-2">
<div className="flex items-start gap-3 p-4 rounded-xl border bg-secondary/30">
<InfoIcon className="h-5 w-5 mt-0.5 text-muted-foreground" />
<div>
<h4 className="text-sm font-semibold italic text-foreground/80">
{t("systemConfig.pageTitle")}
</h4>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{t("systemConfig.pageDescription")}
</p>
</div>
</div>
</div>
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<SettingsCard
icon={Server}
title={t("systemConfig.sections.network.title")}
description={t("systemConfig.sections.network.desc")}
>
<SettingRow label="bichon_bind_ip" value={data.bichon_bind_ip ?? "—"} />
<SettingRow label="bichon_http_port" value={data.bichon_http_port} />
<SettingRow label="bichon_public_url" value={data.bichon_public_url} />
<SettingRow
label="bichon_enable_rest_https"
value={<BooleanBadge value={data.bichon_enable_rest_https} />}
/>
</SettingsCard>
<SettingsCard
icon={Globe}
title={t("systemConfig.sections.cors.title")}
description={t("systemConfig.sections.cors.desc")}
>
<SettingRow
label="bichon_cors_origins"
value={data.bichon_cors_origins?.join(", ") ?? t("systemConfig.status.notSet")}
/>
<SettingRow label="bichon_cors_max_age" value={data.bichon_cors_max_age} />
</SettingsCard>
<SettingsCard
icon={Activity}
title={t("systemConfig.sections.logging.title")}
description={t("systemConfig.sections.logging.desc")}
>
<SettingRow label="bichon_log_level" value={data.bichon_log_level} />
<SettingRow label="bichon_ansi_logs" value={<BooleanBadge value={data.bichon_ansi_logs} />} />
<SettingRow label="bichon_json_logs" value={<BooleanBadge value={data.bichon_json_logs} />} />
<SettingRow label="bichon_log_to_file" value={<BooleanBadge value={data.bichon_log_to_file} />} />
<SettingRow label="bichon_max_server_log_files" value={data.bichon_max_server_log_files} />
</SettingsCard>
<SettingsCard
icon={Database}
title={t("systemConfig.sections.storage.title")}
description={t("systemConfig.sections.storage.desc")}
>
<SettingRow label="bichon_root_dir" value={data.bichon_root_dir} />
<SettingRow label="bichon_metadata_cache_size" value={data.bichon_metadata_cache_size ?? "—"} />
<SettingRow label="bichon_envelope_cache_size" value={data.bichon_envelope_cache_size ?? "—"} />
</SettingsCard>
<SettingsCard
icon={ShieldCheck}
title={t("systemConfig.sections.security.title")}
description={t("systemConfig.sections.security.desc")}
>
<SettingRow
label="bichon_encrypt_password_set"
value={
data.bichon_encrypt_password_set ? (
<Badge variant="secondary">{t("systemConfig.status.configured")}</Badge>
) : (
<Badge variant="destructive">{t("systemConfig.status.missing")}</Badge>
)
}
description={t("systemConfig.fields.encryptPasswordDesc")}
/>
<SettingRow
label="bichon_webui_token_expiration_hours"
value={data.bichon_webui_token_expiration_hours}
/>
</SettingsCard>
<SettingsCard
icon={Lock}
title={t("systemConfig.sections.performance.title")}
description={t("systemConfig.sections.performance.desc")}
>
<SettingRow
label="bichon_http_compression_enabled"
value={<BooleanBadge value={data.bichon_http_compression_enabled} />}
/>
<SettingRow
label="bichon_sync_concurrency"
value={data.bichon_sync_concurrency ?? t("systemConfig.status.auto")}
/>
</SettingsCard>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -18,38 +18,52 @@
import { Outlet } from '@tanstack/react-router'
import { Separator } from '@/components/ui/separator'
import { Main } from '@/components/layout/main'
import SidebarNav from './components/sidebar-nav'
import { ShieldEllipsis, Waypoints } from 'lucide-react'
import { KeyRound, Palette, SettingsIcon, UserCog, Waypoints } from 'lucide-react'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useCurrentUser } from '@/hooks/use-current-user'
import { useTranslation } from 'react-i18next'
export default function Settings() {
const { t } = useTranslation()
const { require_any_permission, canGlobal } = useCurrentUser()
const sidebarNavItems = [
{
title: t('settings.root', 'Root'),
icon: <ShieldEllipsis size={18} />,
href: '/settings/root',
title: t('settings.sidebar.profile'),
href: '/settings/profile',
icon: <UserCog size={18} />,
},
{
title: t('settings.proxy', 'Proxy'),
title: t('settings.appearance.title'),
href: '/settings/appearance',
icon: <Palette size={18} />,
},
{
title: t('settings.sidebar.apiTokens'),
href: '/settings/api-tokens',
icon: <KeyRound size={18} />,
},
{
title: t('settings.sidebar.proxy'),
icon: <Waypoints size={18} />,
href: '/settings/proxy',
}
]
visible: require_any_permission(['system:root', 'account:create']),
},
{
title: t('settings.sidebar.configurations'),
icon: <SettingsIcon size={18} />,
href: '/settings/configurations',
visible: canGlobal('system:root'),
},
].filter(item => item.visible !== false)
return (
<>
<FixedHeader />
<Main fixed>
<h1 className='text-2xl font-bold tracking-tight md:text-3xl'>
{t('settings.title', 'Settings')}
</h1>
<Separator className='my-4 lg:my-6' />
<Main>
<div className='flex flex-1 flex-col space-y-2 md:space-y-2 overflow-hidden lg:flex-row lg:space-x-12 lg:space-y-0'>
<aside className='top-0 lg:sticky lg:w-1/5'>
<SidebarNav items={sidebarNavItems} />

View File

@@ -0,0 +1,131 @@
//
// 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 { formatBytes, useFileUpload, type FileWithPreview } from '@/hooks/use-file-upload';
import { Button } from '@/components/ui/button';
import { TriangleAlert, User, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useTranslation } from 'react-i18next';
interface AvatarUploadProps {
maxSize?: number;
className?: string;
onFileChange?: (file: FileWithPreview | null) => void;
defaultAvatar?: string;
disabled: boolean;
}
export default function AvatarUpload({
maxSize = 128 * 1024,
className,
onFileChange,
defaultAvatar,
disabled
}: AvatarUploadProps) {
const { t } = useTranslation();
const [
{ files, isDragging, errors },
{ removeFile, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, openFileDialog, getInputProps },
] = useFileUpload({
maxFiles: 1,
maxSize,
accept: 'image/*',
multiple: false,
onFilesChange: (files) => {
onFileChange?.(files[0] || null);
},
});
const currentFile = files[0];
const previewUrl = currentFile?.preview || defaultAvatar;
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
if (currentFile) {
removeFile(currentFile.id);
}
};
return (
<div className={cn('flex flex-col items-center gap-4', className)}>
<div className="relative">
<div
className={cn(
'group/avatar relative h-24 w-24 cursor-pointer overflow-hidden rounded-full border border-dashed transition-colors',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer border-dashed hover:border-muted-foreground/20',
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/20',
previewUrl && 'border-solid',
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={openFileDialog}
>
<input {...getInputProps({ disabled })} className="sr-only" />
{previewUrl ? (
<img src={previewUrl} alt={t('settings.profile.avatarAlt')} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center">
<User className="size-6 text-muted-foreground" />
</div>
)}
</div>
{currentFile && (
<Button
size="icon"
variant="outline"
onClick={handleRemove}
className="size-6 absolute end-0 top-0 rounded-full"
aria-label={t('settings.profile.removeAvatar')}
disabled={disabled}
>
<X className="size-3.5" />
</Button>
)}
</div>
<div className="text-center space-y-0.5">
<p className="text-sm font-medium">{t('settings.profile.uploadTitle')}</p>
<p className="text-xs text-muted-foreground">
{t('settings.profile.maxSize', { size: formatBytes(maxSize) })}
</p>
</div>
{errors.length > 0 && (
<Alert variant="destructive" className="mt-5">
<AlertTitle className="flex items-center gap-2 font-semibold">
<TriangleAlert className="h-4 w-4 text-destructive" />
<span>{t('settings.profile.uploadError')}</span>
</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@@ -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 { useCurrentUser } from '@/hooks/use-current-user'
import { UserProfileForm } from './profile-form'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export function Profile() {
const { t } = useTranslation()
const { data: user, isLoading, error } = useCurrentUser()
if (isLoading) {
return (
<div className="flex justify-center items-center h-64">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)
}
if (error || !user) {
return (
<div className="p-6 text-red-600">
{t('settings.profile.loadError')}
</div>
)
}
return (
<div className="w-full max-w-6xl ml-0 px-4">
<UserProfileForm user={user!} />
</div>
)
}

View File

@@ -0,0 +1,216 @@
//
// 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 * as React from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { getPermissions, User } from '@/api/users/api'
import { CheckCircle2, XCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow?: User
open: boolean
onOpenChange: (open: boolean) => void
mode: 'global' | 'account'
accountId?: number
}
function getGlobalCategories(t: (key: string) => string) {
return [
{
title: t('permission.category.system_identity'),
keys: [
'system:access',
'system:root',
'user:manage',
'user:view',
'token:manage',
'account:create',
],
},
{
title: t('permission.category.global_data'),
keys: [
'account:manage:all',
'data:read:all',
'data:manage:all',
'data:raw:download:all',
'data:delete:all',
'data:export:batch:all',
],
},
]
}
function getAccountCategories(t: (key: string) => string) {
return [
{
title: t('permission.category.account'),
keys: [
'account:manage',
'account:read_details',
'data:read',
'data:manage',
'data:raw:download',
'data:delete',
'data:export:batch',
'data:import:batch',
],
},
]
}
export function PermissionsDialog({
currentRow,
open,
onOpenChange,
mode,
accountId,
}: Props) {
const { t } = useTranslation()
const ownedPermissions = React.useMemo<string[]>(() => {
if (!currentRow) return []
if (mode === 'global') {
return currentRow.global_permissions ?? []
}
if (mode === 'account' && accountId != null) {
return currentRow.account_permissions?.[accountId] ?? []
}
return []
}, [currentRow, mode, accountId])
const permissions = React.useMemo(() => {
const list = getPermissions(t)
return new Map(list.map((p) => [p.value, p]))
}, [t])
const categories =
mode === 'global'
? getGlobalCategories(t)
: getAccountCategories(t)
const title =
mode === 'global'
? t('permission.dialog.global_title')
: t('permission.dialog.account_title')
const description =
mode === 'global'
? t('permission.dialog.global_description')
: t('permission.dialog.account_description')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl w-[90vw] overflow-hidden flex flex-col max-h-[90vh]">
<DialogHeader className="pb-4 border-b">
<div className="flex items-center gap-3">
<DialogTitle>{title}</DialogTitle>
<Badge variant="outline" className="text-[10px]">
{mode === 'global'
? t('permission.scope.global')
: t('permission.scope.account', { id: accountId })}
</Badge>
</div>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
<div className="grid gap-6 px-1">
{categories.map((cat) => (
<div key={cat.title} className="flex flex-col">
<h3 className="text-[11px] font-bold text-slate-500 border-l-4 border-blue-500 pl-2 mb-4 uppercase tracking-widest">
{cat.title}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{cat.keys.map((key) => {
const item = permissions.get(key)
if (!item) return null
const hasPermission =
ownedPermissions.includes(item.value)
return (
<div
key={item.value}
className={cn(
'flex items-center gap-2.5 p-2 rounded-md transition-all text-xs border',
hasPermission
? 'bg-green-50/40 border-green-100 text-green-800 shadow-sm'
: 'bg-slate-50/30 border-transparent text-slate-400 opacity-60',
)}
>
{hasPermission ? (
<CheckCircle2 className="w-3.5 h-3.5 text-green-600 shrink-0" />
) : (
<XCircle className="w-3.5 h-3.5 text-slate-300 shrink-0" />
)}
<div className="flex flex-col min-w-0 flex-1">
<span
className={cn(
'font-semibold text-sm leading-none truncate',
hasPermission
? 'text-slate-900'
: 'text-slate-500',
)}
>
{item.label}
</span>
<span className="text-xs opacity-70 font-mono mt-1 truncate">
{item.value}
</span>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end pt-4 border-t mt-auto">
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
>
{t('common.close')}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,359 @@
//
// 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 { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { AxiosError } from 'axios'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { toast } from '@/hooks/use-toast'
import { PasswordInput } from '@/components/password-input'
import { update_user, User } from '@/api/users/api'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { FileWithPreview } from '@/hooks/use-file-upload'
import AvatarUpload from './avatar-upload'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { PermissionsDialog } from './permissions-dialog'
const profileSchema = (t: (key: string) => string) => z.object({
username: z
.string({
required_error: t('settings.profile.validation.username.required'),
})
.min(5, {
message: t('settings.profile.validation.username.min'),
})
.max(32, {
message: t('settings.profile.validation.username.max'),
}),
email: z
.string({
required_error: t('settings.profile.validation.email.required'),
})
.email({
message: t('settings.profile.validation.email.invalid'),
}),
password: z
.string()
.min(8, {
message: t('settings.profile.validation.password.min'),
})
.max(256, {
message: t('settings.profile.validation.password.max'),
})
.or(z.literal(''))
.optional()
.transform((v) => (v ? v : undefined)),
})
export type ProfileFormValues = z.infer<ReturnType<typeof profileSchema>>
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
resolve(result.split(',')[1])
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
interface UserProfileFormProps {
user: User
}
export function UserProfileForm({ user }: UserProfileFormProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [avatarFile, setAvatarFile] = useState<FileWithPreview | null>(null)
const [permissionsOpen, setPermissionsOpen] = useState(false)
const [permissionsMode, setPermissionsMode] =
useState<'global' | 'account'>('global')
const [permissionsAccountId, setPermissionsAccountId] =
useState<number | undefined>(undefined)
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema(t)),
mode: 'onChange',
defaultValues: {
username: user.username,
email: user.email,
password: '',
},
})
const avatarSrc = user.avatar
? `data:image/png;base64,${user.avatar}`
: undefined
const mutation = useMutation({
mutationFn: async (values: ProfileFormValues) => {
let avatar_base64: string | undefined
if (avatarFile?.file instanceof File) {
avatar_base64 = await fileToBase64(avatarFile.file)
}
return update_user(user.id, {
...values,
avatar_base64,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user'] })
toast({ title: t('settings.profile.toast.updated') })
},
onError: (err: AxiosError) => {
toast({
variant: 'destructive',
title: t('settings.profile.toast.update_failed'),
description: (err.response?.data as any)?.message || err.message,
})
},
})
const { getEmailById } = useMinimalAccountList()
const accessibleAccountIds = user.account_access_map instanceof Map
? Array.from(user.account_access_map.keys())
: Object.keys(user.account_access_map || {}).map(Number)
const hasAccess = accessibleAccountIds.length > 0
const roleNames = user.global_roles_names
const roleSummary = user.account_roles_summary || {}
return (
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
className="space-y-6 w-full max-w-screen-xl mx-auto px-4 md:px-6"
>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-6">
<div className="space-y-6">
<div className="flex justify-center">
<AvatarUpload
onFileChange={setAvatarFile}
defaultAvatar={avatarSrc}
disabled={mutation.isPending}
/>
</div>
{roleNames && roleNames.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-muted-foreground">
{t('settings.profile.section.roles')}
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs"
onClick={() => {
setPermissionsMode('global')
setPermissionsAccountId(undefined)
setPermissionsOpen(true)
}}
>
{t('settings.profile.button.view_global_permissions')}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{roleNames.map((role, index) => (
<Badge key={index} variant="secondary">
{role}
</Badge>
))}
</div>
</div>
)}
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('settings.profile.field.username')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('settings.profile.field.email')}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('settings.profile.field.password')}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t(
'settings.profile.placeholder.password_keep',
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{hasAccess && (
<Separator orientation="vertical" className="hidden lg:block" />
)}
{hasAccess && (
<div className="space-y-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
{t('settings.profile.section.accounts', {
count: accessibleAccountIds.length,
})}
</h2>
<ScrollArea className="h-[32rem] pr-4">
<div className="grid grid-cols-1 gap-3">
{accessibleAccountIds.map((accountId) => {
const email = getEmailById(accountId)
const roleName = roleSummary[accountId]
if (!email) return null
return (
<div
key={accountId}
className="group flex items-center justify-between p-3 rounded-xl border bg-card hover:bg-accent/40 transition-all shadow-sm"
>
<div className="flex items-center min-w-0">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10 text-primary text-sm font-bold mr-4 shrink-0">
{email.charAt(0).toUpperCase()}
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-semibold truncate">
{email}
</span>
<span className="text-[10px] text-muted-foreground font-mono">
{t('settings.profile.account.id', {
id: accountId,
})}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{roleName && (
<Badge
variant="outline"
className="text-[11px]"
>
{roleName}
</Badge>
)}
<Button
type="button"
variant="ghost"
className="text-[10px]"
onClick={() => {
setPermissionsMode('account')
setPermissionsAccountId(accountId)
setPermissionsOpen(true)
}}
>
{t('settings.profile.button.permissions')}
</Button>
</div>
</div>
)
})}
</div>
</ScrollArea>
</div>
)}
</div>
<div className="flex justify-start pt-4">
<Button
type="submit"
disabled={form.formState.isSubmitting || mutation.isPending}
>
{(form.formState.isSubmitting || mutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t('settings.profile.button.update_profile')}
</Button>
</div>
</form>
</Form>
<PermissionsDialog
currentRow={user}
open={permissionsOpen}
onOpenChange={setPermissionsOpen}
mode={permissionsMode}
accountId={permissionsAccountId}
/>
</>
)
}

View File

@@ -40,70 +40,79 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Proxy } from '../data/schema'
import { AxiosError } from 'axios'
import { ToastAction } from '@/components/ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { add_proxy, update_proxy } from '@/api/system/api'
import { useTranslation } from 'react-i18next'
import { Proxy } from '@/api/system/api'
const proxyFormSchema = z.object({
url: z.string()
.min(1, "Proxy address cannot be empty")
.refine(
(value) => {
try {
const url = new URL(value);
return url.protocol === 'socks5:' || url.protocol === 'http:';
} catch {
return false;
}
},
{
message: "URL must start with http:// or socks5://",
.superRefine((value, ctx) => {
if (value.length === 0) {
return;
}
)
.refine(
(value) => {
const url = new URL(value);
return /^[a-zA-Z0-9\-\.]+$/.test(url.hostname);
},
{
message: "Hostname contains invalid characters",
let url: URL;
try {
url = new URL(value);
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid URL format",
path: [],
});
return;
}
)
.refine(
(value) => {
const url = new URL(value);
const port = parseInt(url.port || '1080');
return port > 0 && port <= 65535;
},
{
message: "Port must be between 1-65535",
if (url.protocol !== 'socks5:' && url.protocol !== 'http:') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "URL must start with http:// or socks5://",
path: [],
});
}
)
.refine(
(value) => {
const url = new URL(value);
if (url.username && !url.password) return false;
return true;
},
{
message: "Password cannot be empty when username is provided",
if (!/^[a-zA-Z0-9\-\.]+$/.test(url.hostname)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Hostname contains invalid characters",
path: [],
});
}
)
.refine(
(value) => {
const url = new URL(value);
if (url.password) return url.password.length >= 8;
return true;
},
{
message: "Password must be at least 8 characters",
const port = parseInt(url.port || '1080');
if (port <= 0 || port > 65535) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Port must be between 1-65535",
path: [],
});
}
)
if (url.username && !url.password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password cannot be empty when username is provided",
path: [],
});
} else if (url.password && url.password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password must be at least 8 characters",
path: [],
});
}
})
});
export type ProxyForm = z.infer<typeof proxyFormSchema>;
@@ -174,7 +183,6 @@ export function ProxyActionDialog({ currentRow, open, onOpenChange }: Props) {
description: errorMessage as string,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}

View File

@@ -19,11 +19,11 @@
import { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { Proxy } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format } from 'date-fns'
import { Proxy } from '@/api/system/api'
export const getColumns = (t: (key: string) => string): ColumnDef<Proxy>[] => [
{

View File

@@ -32,51 +32,68 @@ 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,
showPageSizeSelector = 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 +102,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 +111,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 +120,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 +129,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>

View File

@@ -30,8 +30,9 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useProxyContext } from '../context'
import { Proxy } from '../data/schema'
import { useTranslation } from 'react-i18next'
import { Proxy } from '@/api/system/api'
interface DataTableRowActionsProps {
row: Row<Proxy>

View File

@@ -24,12 +24,13 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Proxy } from '../data/schema'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { delete_proxy } from '@/api/system/api'
import { useTranslation } from 'react-i18next'
import { Proxy } from '@/api/system/api'
interface Props {
open: boolean
@@ -53,7 +54,7 @@ export function ProxyDeleteDialog({ 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('proxyDelete.failedDesc');

View File

@@ -41,10 +41,11 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Proxy } from '../data/schema'
import { DataTablePagination } from './data-table-pagination'
import { DataTableToolbar } from './data-table-toolbar'
import { useTranslation } from 'react-i18next'
import { Proxy } from '@/api/system/api'
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@@ -18,7 +18,7 @@
import React from 'react'
import { Proxy } from '../data/schema'
import { Proxy } from '@/api/system/api'
export type ProxyDialogType = 'add' | 'edit' | 'delete'

View File

@@ -28,91 +28,89 @@ import ProxyProvider, {
type ProxyDialogType,
} from './context'
import { Plus } from 'lucide-react'
import { Proxy } from './data/schema'
import { TableSkeleton } from '@/components/table-skeleton'
import Logo from '@/assets/logo.svg'
import useProxyList from '@/hooks/use-proxy'
import { useTranslation } from 'react-i18next'
import { Proxy } from '@/api/system/api'
export default function ProxyManagerPage() {
const { t } = useTranslation()
// Dialog states
const [currentRow, setCurrentRow] = useState<Proxy | null>(null)
const [open, setOpen] = useDialogState<ProxyDialogType>(null)
const { proxyList, isLoading } = useProxyList();
const { proxyList, isLoading } = useProxyList()
const columns = getColumns(t)
return (
<ProxyProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
<div>
<div className='mb-2 flex items-center justify-between space-y-2 flex-wrap gap-x-4'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>{t('settings.proxy')}</h2>
</div>
<div className='flex gap-2'>
<Button className='space-x-1' onClick={() => setOpen('add')}>
<span>{t('settings.add')}</span> <Plus size={18} />
</Button>
</div>
</div>
<div className='-mx-4 flex-1 md:w-[960px] overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0'>
{isLoading ? <TableSkeleton columns={columns.length} rows={10} /> : proxyList?.length ? (
<ProxyTable data={proxyList} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('settings.noProxies')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('settings.noProxiesDesc')}
</p>
<Button onClick={() => setOpen('add')}>
{t('settings.add')} {t('settings.proxy')}
</Button>
</div>
<div className="w-full max-w-5xl ml-0 px-4">
<ProxyProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
<div>
<div className="mb-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div className="flex gap-2">
<Button className="space-x-1" onClick={() => setOpen('add')}>
<span>{t('settings.add')}</span> <Plus size={18} />
</Button>
</div>
)}
</div>
<div className="flex-1 w-full overflow-auto -mx-4 px-4 py-1">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : proxyList?.length ? (
<div className="overflow-x-auto">
<ProxyTable data={proxyList} columns={columns} />
</div>
) : (
<div className="flex min-h-[300px] items-center justify-center rounded-md border border-dashed p-4">
<div className="mx-auto flex w-full max-w-md flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('settings.noProxies')}</h3>
<p className="mt-2 mb-4 text-sm text-muted-foreground">
{t('settings.noProxiesDesc')}
</p>
<Button onClick={() => setOpen('add')}>
{t('settings.add')} {t('settings.proxy')}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
<ProxyActionDialog
key="Proxy-add"
open={open === 'add'}
onOpenChange={() => setOpen(null)}
/>
<ProxyActionDialog
key='Proxy-add'
open={open === 'add'}
onOpenChange={() => setOpen('add')}
/>
{currentRow && (
<>
<ProxyActionDialog
key={`Proxy-edit-${currentRow.id}`}
open={open === 'edit'}
currentRow={currentRow}
onOpenChange={() => {
setOpen(null)
setTimeout(() => setCurrentRow(null), 500)
}}
/>
{currentRow && (
<>
<ProxyActionDialog
key={`Proxy-edit-${currentRow.id}`}
open={open === 'edit'}
onOpenChange={() => {
setOpen('edit')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<ProxyDeleteDialog
key={`Proxy-delete-${currentRow.id}`}
open={open === 'delete'}
onOpenChange={() => {
setOpen('delete')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
</>
)}
</ProxyProvider>
<ProxyDeleteDialog
key={`Proxy-delete-${currentRow.id}`}
open={open === 'delete'}
currentRow={currentRow}
onOpenChange={() => {
setOpen(null)
setTimeout(() => setCurrentRow(null), 500)
}}
/>
</>
)}
</ProxyProvider>
</div>
)
}

View File

@@ -1,228 +0,0 @@
//
// 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 ContentSection from '../components/content-section'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { IconCheck, IconCopy } from '@tabler/icons-react'
import { useCallback, useState } from 'react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { useMutation } from '@tanstack/react-query'
import { reset_root_token, reset_root_password } from '@/api/access-tokens/api'
import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { PasswordInput } from '@/components/password-input'
import { resetAccessToken, setAccessToken } from '@/stores/authStore'
import { BellRing } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { Separator } from '@/components/ui/separator'
import { useTranslation } from 'react-i18next'
const useResetRootToken = () =>
useMutation({ mutationFn: reset_root_token, retry: 0 });
const useResetRootPassword = () =>
useMutation({
mutationFn: (password: string) => reset_root_password(password),
retry: 0,
});
export default function RootAccess() {
const navigate = useNavigate()
const { t } = useTranslation()
const [openToken, setOpenToken] = useState(false);
const [openPassword, setOpenPassword] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [isCopied, setIsCopied] = useState(false);
const [newPassword, setNewPassword] = useState("");
const tokenMutation = useResetRootToken();
const passwordMutation = useResetRootPassword();
const onConfirmToken = useCallback(() => {
tokenMutation.mutate(undefined, {
onSuccess: (data) => {
setNewToken(data);
setAccessToken(data);
toast({
title: t('settings.theRootTokenHasBeenReset'),
description: t('settings.yourLoginInformationHasBeenUpdated'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
setOpenToken(false);
},
});
}, [tokenMutation]);
const onConfirmPassword = useCallback(() => {
if (!newPassword || newPassword.length < 6) {
toast({
variant: "destructive",
title: t('settings.invalidPassword'),
description: t('settings.rootPasswordMustBeAtLeast6Characters'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
return;
}
passwordMutation.mutate(newPassword, {
onSuccess: () => {
toast({
title: t('settings.theRootPasswordHasBeenReset'),
description: t('settings.useNewPasswordForNextLogin'),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
setNewPassword("");
setOpenPassword(false);
resetAccessToken()
navigate({ to: '/sign-in' })
},
});
}, [newPassword, passwordMutation]);
const onCopy = useCallback(async () => {
if (newToken) {
try {
await navigator.clipboard.writeText(newToken);
setIsCopied(true);
} catch (err) {
toast({
variant: "destructive",
title: t('settings.failedToCopyText'),
description: (err as Error).message,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
}
}
}, [newToken]);
return (
<ContentSection
title={t('settings.rootTitle')}
desc={t('settings.rootDesc')}
showHeader={false}
>
<div className="flex justify-center w-full bg-muted/10 py-16">
<Card className="w-full max-w-2xl">
<CardHeader className="pb-4 text-center">
<CardTitle className="text-xl">{t('settings.rootAccessManagement')}</CardTitle>
<CardDescription className="text-sm">
{t('settings.manageRootTokenAndPassword')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Reset Token */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BellRing className="h-4 w-4 text-amber-500" />
<div>
<p className="text-sm font-medium">{t('settings.resetRootToken')}</p>
<p className="text-xs text-muted-foreground">
{t('settings.generateNewToken')}
</p>
</div>
</div>
<Button size="sm" variant="outline" onClick={() => setOpenToken(true)}>
{t('settings.reset')}
</Button>
</div>
<Separator />
{/* Reset Password */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BellRing className="h-4 w-4 text-amber-500" />
<div>
<p className="text-sm font-medium">{t('settings.resetRootPassword')}</p>
<p className="text-xs text-muted-foreground">
{t('settings.changePasswordForFutureLogins')}
</p>
</div>
</div>
<Button size="sm" variant="outline" onClick={() => setOpenPassword(true)}>
{t('settings.reset')}
</Button>
</div>
</CardContent>
</Card>
{/* Confirm dialogs */}
<ConfirmDialog
key="root-token-reset"
destructive
open={openToken}
onOpenChange={setOpenToken}
handleConfirm={onConfirmToken}
className="max-w-lg"
title={t('settings.resetRootToken')}
desc={
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('settings.youAreAboutToResetTheRootToken')}
</p>
{newToken && (
<div className="flex items-center space-x-2">
<PasswordInput value={newToken} readOnly className="w-full" />
<Button size="icon" variant="outline" onClick={onCopy}>
{isCopied ? (
<IconCheck className="h-5 w-5" />
) : (
<IconCopy className="h-5 w-5" />
)}
</Button>
</div>
)}
</div>
}
confirmText={t('settings.confirmReset')}
isLoading={tokenMutation.isPending}
/>
<ConfirmDialog
key="root-password-reset"
destructive
open={openPassword}
onOpenChange={setOpenPassword}
handleConfirm={onConfirmPassword}
className="max-w-lg"
title={t('settings.resetRootPassword')}
desc={
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('settings.youAreAboutToResetTheRootPassword')}
</p>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('settings.enterNewRootPassword')}
className="w-full"
/>
</div>
}
confirmText={t('settings.confirmReset')}
isLoading={passwordMutation.isPending}
/>
</div>
</ContentSection>
);
}

View File

@@ -0,0 +1,173 @@
//
// 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 { ColumnDef } from '@tanstack/react-table'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format } from 'date-fns'
import { AccessToken } from '@/api/users/api'
import { Badge } from '@/components/ui/badge'
export const getColumns = (t: (key: string) => string): ColumnDef<AccessToken>[] => {
return [
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('users.api_tokens.table.name')}
/>
),
cell: ({ row }) => (
<div className="flex flex-col py-1">
<span className="font-medium text-foreground">
{row.original.name || t('users.api_tokens.table.unnamed')}
</span>
<span className="text-[11px] text-muted-foreground font-mono leading-none mt-1">
{row.original.token.substring(0, 12)}...
</span>
</div>
),
enableHiding: false,
},
{
accessorKey: 'token_type',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('users.api_tokens.table.type')}
className="justify-center"
/>
),
cell: ({ row }) => (
<div className="flex justify-center">
<Badge
variant={row.original.token_type === 'Api' ? 'default' : 'secondary'}
className="font-normal shadow-none"
>
{row.original.token_type === 'Api'
? t('users.api_tokens.table.api_key')
: t('users.api_tokens.table.web_ui')}
</Badge>
</div>
),
meta: { className: 'text-center' },
},
{
accessorKey: 'last_access_at',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('users.api_tokens.table.last_used')}
className="justify-center"
/>
),
cell: ({ row }) => {
const last = row.original.last_access_at
return (
<div className="text-center text-xs text-muted-foreground">
{last > 0
? format(new Date(last), 'yyyy-MM-dd HH:mm')
: t('users.api_tokens.table.never')}
</div>
)
},
meta: { className: 'text-center' },
},
{
accessorKey: 'expire_at',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('users.api_tokens.table.expires_at')}
className="justify-center"
/>
),
cell: ({ row }) => {
const expireAt = row.original.expire_at
if (!expireAt) {
return (
<div className="text-center text-xs text-muted-foreground">
{t('users.api_tokens.table.permanent')}
</div>
)
}
const isExpired = new Date(expireAt) < new Date()
return (
<div
className={`text-center text-xs ${isExpired
? 'text-destructive font-bold'
: 'text-muted-foreground'
}`}
>
{format(new Date(expireAt), 'yyyy-MM-dd HH:mm')}
{isExpired && (
<span className="ml-1">
({t('users.api_tokens.table.expired')})
</span>
)}
</div>
)
},
meta: { className: 'text-center' },
},
{
accessorKey: 'owner',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('users.api_tokens.table.owner')}
/>
),
cell: ({ row }) => {
const { user_name, user_email } = row.original
return (
<div className="flex flex-col py-1 text-left">
<span className="text-sm font-medium text-foreground">
{user_name}
</span>
<span className="text-[11px] text-muted-foreground font-mono">
{user_email}
</span>
</div>
)
},
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t('users.api_tokens.table.created')}
className="justify-center"
/>
),
cell: ({ row }) => (
<div className="text-center text-xs text-muted-foreground">
{format(new Date(row.original.created_at), 'yyyy-MM-dd HH:mm')}
</div>
),
meta: { className: 'text-center' },
},
{
id: 'actions',
cell: DataTableRowActions,
},
]
}

View File

@@ -21,7 +21,6 @@ import {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
EyeNoneIcon,
} from '@radix-ui/react-icons'
import { Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
@@ -30,7 +29,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
@@ -78,11 +76,6 @@ export function DataTableColumnHeader<TData, TValue>({
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
{t('table.desc')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
{t('table.hide')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -0,0 +1,155 @@
//
// 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 {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected?: boolean
showPageSizeSelector?: boolean
}
export function DataTablePagination<TData>({
table,
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">
{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">
{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
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{t('table.pagination.first')}
</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{t('table.pagination.previous')}
</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">
{t('table.pagination.next')}
</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() =>
table.setPageIndex(table.getPageCount() - 1)
}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">
{t('table.pagination.last')}
</span>
<DoubleArrowRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,71 @@
//
// 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 { DotsHorizontalIcon } from '@radix-ui/react-icons'
import { Row } from '@tanstack/react-table'
import { IconTrash } from '@tabler/icons-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useApiTokenContext } from '../context'
import { useTranslation } from 'react-i18next'
import { AccessToken } from '@/api/users/api'
interface DataTableRowActionsProps {
row: Row<AccessToken>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useApiTokenContext()
const { t } = useTranslation()
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('delete')
}}
className='!text-red-500'
>
{t('table.delete')}
<DropdownMenuShortcut>
<IconTrash size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}

View File

@@ -0,0 +1,47 @@
//
// 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 { Table } from '@tanstack/react-table'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
// import { useTranslation } from 'react-i18next'
interface DataTableToolbarProps<TData> {
table: Table<TData>
}
export function DataTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
return (
<div className='flex items-center justify-between'>
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
<Input
placeholder={t('users.api_tokens.toolbar.search_placeholder')}
value={(table.getState().globalFilter as string) ?? ''}
onChange={(event) => {
table.setGlobalFilter(event.target.value);
}}
className='h-8 w-80'
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,160 @@
//
// 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 { useState } from 'react'
import { IconAlertTriangle } from '@tabler/icons-react'
import { toast } from '@/hooks/use-toast'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { AccessToken, remove_access_token } from '@/api/users/api'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
currentRow: AccessToken
}
export function ApiTokenDeleteDialog({ open, onOpenChange, currentRow }: Props) {
const { t } = useTranslation()
const [value, setValue] = useState('')
const queryClient = useQueryClient()
function handleSuccess() {
toast({
title: t('users.api_tokens.toast.deleted_title'),
description: t('users.api_tokens.toast.deleted_desc', {
name: currentRow.name || t('users.api_tokens.unnamed'),
}),
action: (
<ToastAction altText={t('common.close')}>
{t('common.close')}
</ToastAction>
),
})
queryClient.invalidateQueries({ queryKey: ['access-token-list'] })
onOpenChange(false)
setValue('')
}
function handleError(error: AxiosError) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
t('users.api_tokens.toast.delete_failed')
toast({
variant: 'destructive',
title: t('users.api_tokens.toast.delete_failed_title'),
description: errorMessage,
action: (
<ToastAction altText={t('common.tryAgain')}>
{t('common.tryAgain')}
</ToastAction>
),
})
}
const deleteMutation = useMutation({
mutationFn: (token: string) => remove_access_token(token),
onSuccess: handleSuccess,
onError: handleError,
})
const handleDelete = () => {
if (value.toLowerCase() !== 'delete') return
deleteMutation.mutate(currentRow.token)
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
handleConfirm={handleDelete}
disabled={value.toLowerCase() !== 'delete' || deleteMutation.isPending}
className="max-w-2xl"
title={
<span className="text-destructive">
<IconAlertTriangle
className="mr-1 inline-block stroke-destructive"
size={18}
/>{' '}
{t('users.api_tokens.delete.title')}
</span>
}
desc={
<div className="space-y-4 text-sm">
<div className="mb-2">
<p>
{t('users.api_tokens.delete.desc')}
<span className="ml-1 font-mono font-bold text-foreground">
"{currentRow.name || t('users.api_tokens.unnamed')}"
</span>
</p>
<p className="mt-1 text-muted-foreground">
{t('users.api_tokens.delete.prefix')}:{' '}
<span className="font-mono">
{currentRow.token.substring(0, 8)}...
</span>
</p>
<p className="mt-2 font-bold text-destructive">
{t('users.api_tokens.delete.irreversible')}
</p>
</div>
<div className="space-y-2">
<Label>
{t('users.api_tokens.delete.confirm_label', { word: 'delete' })}
</Label>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t('users.api_tokens.delete.confirm_placeholder', {
word: 'delete',
})}
className="mt-2"
autoFocus
/>
</div>
<Alert variant="destructive">
<AlertTitle>
{t('users.api_tokens.delete.warning_title')}
</AlertTitle>
<AlertDescription>
{t('users.api_tokens.delete.warning_desc')}
</AlertDescription>
</Alert>
</div>
}
confirmText={
deleteMutation.isPending
? t('users.api_tokens.delete.deleting')
: t('users.api_tokens.delete.confirm_button')
}
destructive
/>
)
}

View File

@@ -0,0 +1,168 @@
//
// 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 { useState } from 'react'
import {
ColumnDef,
ColumnFiltersState,
RowData,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { DataTablePagination } from './data-table-pagination'
import { DataTableToolbar } from './data-table-toolbar'
import { useTranslation } from 'react-i18next'
import { AccessToken } from '@/api/users/api'
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
className: string
}
}
interface DataTableProps {
columns: ColumnDef<AccessToken>[]
data: AccessToken[]
}
export function ApiTokensTable({ columns, data }: DataTableProps) {
const { t } = useTranslation()
const [rowSelection, setRowSelection] = useState({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
globalFilterFn: (row, _, filterValue) => {
const searchValue = filterValue.toLowerCase();
const name = row.original.name?.toLowerCase() ?? '';
const owner = row.original.user_name?.toLowerCase() ?? '';
const email = row.original.user_email?.toLowerCase() ?? '';
const token = row.original.token?.toLowerCase() ?? '';
return (
name.includes(searchValue) ||
owner.includes(searchValue) ||
email.includes(searchValue) ||
token.includes(searchValue)
);
},
})
return (
<div className='space-y-4'>
<DataTableToolbar table={table} />
<div className='rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='group/row'>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={header.column.columnDef.meta?.className ?? ''}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className='group/row'
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cell.column.columnDef.meta?.className ?? ''}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
{t('common.table.noResults')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
)
}

View File

@@ -17,37 +17,37 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { AccessToken } from '@/api/users/api'
import React from 'react'
import { AccessToken } from '../data/schema'
export type AccessTokensDialogType = 'add' | 'edit' | 'delete' | 'account-detail' | 'acl-detail'
export type ApiTokenDialogType = 'add' | 'edit' | 'delete'
interface AccessTokensContextType {
open: AccessTokensDialogType | null
setOpen: (str: AccessTokensDialogType | null) => void
interface ApiTokenContextType {
open: ApiTokenDialogType | null
setOpen: (str: ApiTokenDialogType | null) => void
currentRow: AccessToken | null
setCurrentRow: React.Dispatch<React.SetStateAction<AccessToken | null>>
}
const AccessTokensContext = React.createContext<AccessTokensContextType | null>(null)
const ApiTokenContext = React.createContext<ApiTokenContextType | null>(null)
interface Props {
children: React.ReactNode
value: AccessTokensContextType
value: ApiTokenContextType
}
export default function AccessTokensProvider({ children, value }: Props) {
return <AccessTokensContext.Provider value={value}>{children}</AccessTokensContext.Provider>
export default function ApiTokenProvider({ children, value }: Props) {
return <ApiTokenContext.Provider value={value}>{children}</ApiTokenContext.Provider>
}
export const useAccessTokensContext = () => {
const accessTokensContext = React.useContext(AccessTokensContext)
export const useApiTokenContext = () => {
const apiTokenContext = React.useContext(ApiTokenContext)
if (!accessTokensContext) {
if (!apiTokenContext) {
throw new Error(
'useAccessTokensContext has to be used within <AccessTokensContext.Provider>'
'useApiTokenContext has to be used within <ApiTokenContext.Provider>'
)
}
return accessTokensContext
return apiTokenContext
}

View File

@@ -0,0 +1,88 @@
//
// 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 { useState } from 'react'
import useDialogState from '@/hooks/use-dialog-state'
import { getColumns } from './components/columns'
import { ApiTokenDeleteDialog } from './components/delete-dialog'
import { ApiTokensTable } from './components/table'
import ApiTokenProvider, {
type ApiTokenDialogType,
} from './context'
import { TableSkeleton } from '@/components/table-skeleton'
import Logo from '@/assets/logo.svg'
import { AccessToken, list_access_tokens } from '@/api/users/api'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
export default function ApiTokens() {
const { t } = useTranslation()
const [currentRow, setCurrentRow] = useState<AccessToken | null>(null)
const [open, setOpen] = useDialogState<ApiTokenDialogType>(null)
const { data: apiTokens, isLoading } = useQuery({
queryKey: ['access-token-list'],
queryFn: list_access_tokens,
})
const columns = getColumns(t)
return (
<div className="w-full max-w-5xl ml-0 px-4">
<ApiTokenProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
<div className="w-full">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : apiTokens?.length ? (
<div className="overflow-x-auto">
<ApiTokensTable data={apiTokens} columns={columns} />
</div>
) : (
<div className="flex min-h-[300px] items-center justify-center rounded-md border border-dashed p-4">
<div className="mx-auto flex w-full max-w-md flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">
{t('users.api_tokens.empty.title')}
</h3>
<p className="mt-2 mb-4 text-sm text-muted-foreground">
{t('users.api_tokens.empty.description')}
</p>
</div>
</div>
)}
{currentRow && (
<ApiTokenDeleteDialog
key={`api-token-delete-${currentRow.token}`}
currentRow={currentRow}
open={open === 'delete'}
onOpenChange={() => {
setOpen(null)
setTimeout(() => setCurrentRow(null), 500)
}}
/>
)}
</div>
</ApiTokenProvider>
</div>
)
}

View File

@@ -0,0 +1,110 @@
//
// 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 { useState, type JSX } from 'react'
import { useLocation, useNavigate } from '@tanstack/react-router'
import { Link } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string
title: string
icon: JSX.Element
}[]
}
export default function SidebarNav({
className,
items,
...props
}: SidebarNavProps) {
const { t } = useTranslation()
const { pathname } = useLocation()
const navigate = useNavigate()
const [val, setVal] = useState(pathname ?? '/settings')
const handleSelect = (e: string) => {
setVal(e)
navigate({ to: e })
}
return (
<>
<div className='p-1 md:hidden'>
<Select value={val} onValueChange={handleSelect}>
<SelectTrigger className='h-12 sm:w-48'>
<SelectValue placeholder={t('settings.theme')} />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.href} value={item.href}>
<div className='flex gap-x-4 px-2 py-1'>
<span className='scale-125'>{item.icon}</span>
<span className='text-md'>{item.title}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ScrollArea
orientation='horizontal'
type='always'
className='hidden w-full bg-background px-1 py-2 md:block min-w-40'
>
<nav
className={cn(
'flex py-1 space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1',
className
)}
{...props}
>
{items.map((item) => (
<Link
key={item.href}
to={item.href}
className={cn(
buttonVariants({ variant: 'ghost' }),
pathname === item.href
? 'bg-muted hover:bg-muted'
: 'hover:bg-transparent hover:underline',
'justify-start'
)}
>
<span className='mr-2'>{item.icon}</span>
{item.title}
</Link>
))}
</nav>
</ScrollArea>
</>
)
}

View File

@@ -0,0 +1,66 @@
//
// 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 { Outlet } from '@tanstack/react-router'
import { Main } from '@/components/layout/main'
import SidebarNav from './components/sidebar-nav'
import { Users, ShieldCheck, Key } from "lucide-react";
import { FixedHeader } from '@/components/layout/fixed-header'
import { useTranslation } from 'react-i18next'
export default function UsersAndTokens() {
const { t } = useTranslation()
const sidebarNavItems = [
{
title: t('users.nav.users'),
icon: <Users size={18} />,
href: '/users',
},
{
title: t('users.nav.roles'),
icon: <ShieldCheck size={18} />,
href: '/users/roles',
},
{
title: t('users.nav.api_tokens'),
icon: <Key size={18} />,
href: '/users/api-tokens',
},
]
return (
<>
<FixedHeader />
<Main>
<div className='flex flex-1 flex-col space-y-2 md:space-y-2 overflow-hidden lg:flex-row lg:space-x-12 lg:space-y-0'>
<aside className='top-0 lg:sticky lg:w-1/5'>
<SidebarNav items={sidebarNavItems} />
</aside>
<div className='flex w-full p-1 pr-4 overflow-y-hidden'>
<Outlet />
</div>
</div>
</Main>
</>
)
}

View File

@@ -0,0 +1,364 @@
//
// 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 { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from '@/hooks/use-toast'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { AxiosError } from 'axios'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, LockIcon, ShieldCheck, UserCog } from 'lucide-react'
import { create_role, getPermissions, update_role, UserRole } from '@/api/users/api'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import {
RadioGroup,
RadioGroupItem,
} from '@/components/ui/radio-group'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow?: UserRole
open: boolean
onOpenChange: (open: boolean) => void
}
const CATEGORY_MAP: Record<'Global' | 'Account', { titleKey: string; keys: string[] }[]> = {
Global: [
{
titleKey: 'roles.categories.identity',
keys: ['system:access', 'system:root', 'user:manage', 'user:view', 'token:manage', 'account:create'],
},
{
titleKey: 'roles.categories.global_data',
keys: [
'account:manage:all',
'data:read:all',
'data:manage:all',
'data:raw:download:all',
'data:delete:all',
'data:export:batch:all',
],
},
],
Account: [
{
titleKey: 'roles.categories.account_resource',
keys: [
'account:manage',
'account:read_details',
'data:read',
'data:manage',
'data:raw:download',
'data:delete',
'data:export:batch',
'data:import:batch',
],
},
],
}
export function RoleActionDialog({ currentRow, open, onOpenChange }: Props) {
const isEdit = !!currentRow
const queryClient = useQueryClient()
const { t } = useTranslation()
const roleFormSchema = z.object({
name: z.string().min(1, t('roles.validation.name_required')),
role_type: z.enum(['Global', 'Account']),
permissions: z.array(z.string()).min(1, t('roles.validation.perm_required')),
description: z.string().optional(),
})
type RoleForm = z.infer<typeof roleFormSchema>
const form = useForm<RoleForm>({
resolver: zodResolver(roleFormSchema),
defaultValues: {
name: isEdit ? currentRow.name : '',
role_type: isEdit ? currentRow.role_type : 'Account',
permissions: isEdit ? Array.from(currentRow.permissions) : [],
description: isEdit ? currentRow.description ?? undefined : '',
},
})
const mutation = useMutation({
mutationFn: (values: RoleForm) =>
isEdit ? update_role(currentRow!.id, values) : create_role(values),
onSuccess: () => {
toast({ title: t(isEdit ? 'roles.actions.success_update' : 'roles.actions.success_create') })
queryClient.invalidateQueries({ queryKey: ['role-list'] })
onOpenChange(false)
},
onError: (error: AxiosError) => {
toast({
variant: 'destructive',
title: t('roles.actions.failed'),
description: error.message,
})
},
})
const selectedType = form.watch('role_type')
const handleOpenChange = (v: boolean) => {
if (!v) {
form.reset()
}
onOpenChange(v)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-7xl w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<div className="p-6 border-b bg-white">
<DialogHeader>
<DialogTitle>
{isEdit ? t('roles.title.edit', { name: currentRow?.name }) : t('roles.title.create')}
</DialogTitle>
<DialogDescription>
{t('roles.description_hint')}
</DialogDescription>
</DialogHeader>
</div>
<Form {...form}>
<form
id="role-form"
onSubmit={form.handleSubmit((v) => mutation.mutate(v))}
className="flex-1 overflow-y-auto p-6 bg-slate-50/50"
>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-1 space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[11px] font-bold text-slate-500 uppercase">
{t('roles.form.name_label')}
</FormLabel>
<FormControl>
<Input {...field} className="bg-white" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role_type"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[11px] font-bold text-slate-500 uppercase">
{t('roles.form.type_label')}
</FormLabel>
<FormControl>
<RadioGroup
value={field.value}
onValueChange={(v) => {
if (isEdit) return
field.onChange(v)
form.setValue('permissions', [])
}}
className="space-y-2"
>
{(['Global', 'Account'] as const).map((type) => {
const isSelected = field.value === type
const disabled = isEdit && !isSelected
return (
<label
key={type}
className={cn(
'flex items-center justify-between p-3 rounded-md border-2 transition-all',
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'border-slate-200 bg-white',
disabled
? 'opacity-40 cursor-not-allowed'
: 'cursor-pointer hover:shadow-sm'
)}
>
<div className="flex items-center gap-2 text-sm font-bold">
{type === 'Global'
? <ShieldCheck className="w-4 h-4 text-primary" />
: <UserCog className="w-4 h-4 text-primary" />}
{t(`roles.types.${type}`)}
</div>
<RadioGroupItem value={type} disabled={disabled} />
</label>
)
})}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel className="text-[11px] font-bold text-slate-500 uppercase">
{t('roles.form.desc_label')}
</FormLabel>
<FormControl>
<Textarea {...field} className="bg-white min-h-[120px]" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="lg:col-span-3">
<FormField
control={form.control}
name="permissions"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-4 block border-b pb-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-slate-500 uppercase">
{t('roles.form.matrix_label', { type: t(`roles.types.${selectedType}`) })}
</span>
{currentRow && currentRow.is_builtin && (
<LockIcon className="h-3 w-3 text-muted-foreground shrink-0" />
)}
</div>
</FormLabel>
{currentRow?.is_builtin && (
<div className="col-span-full px-4 pt-1 pb-2">
<div className="flex items-start gap-3 p-4 rounded-xl border bg-secondary/30 w-full">
<LockIcon className="h-5 w-5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<h4 className="text-sm font-semibold italic text-foreground/80">
{t('roles.form.builtin_badge')}
</h4>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{t('roles.form.builtin_desc')}
</p>
</div>
</div>
</div>
)}
<div
className={cn(
'grid gap-8',
selectedType === 'Global'
? 'grid-cols-1 md:grid-cols-2'
: 'grid-cols-1'
)}
>
{CATEGORY_MAP[selectedType].map((cat) => (
<div key={cat.titleKey} className="space-y-4">
<h3 className="text-[11px] font-black text-slate-400 uppercase tracking-widest">
{t(cat.titleKey)}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{cat.keys.map((key) => {
const item = getPermissions(t).find(p => p.value === key)
if (!item) return null
const checked = field.value.includes(item.value)
return (
<label
key={item.value}
className={cn(
'flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition',
checked
? 'bg-white border-primary/30 shadow-sm'
: 'bg-slate-50/50 border-slate-100 opacity-70'
)}
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
field.onChange(
v
? [...field.value, item.value]
: field.value.filter(x => x !== item.value)
)
}}
/>
<div>
<div className="text-xs font-bold">{item.label}</div>
<code className="text-[10px] text-slate-400">
{item.value}
</code>
</div>
</label>
)
})}
</div>
</div>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</form>
</Form>
<div className="p-4 border-t bg-white flex justify-end gap-3">
<Button variant="outline" size="sm" onClick={() => handleOpenChange(false)}>
{t('roles.actions.cancel')}
</Button>
<Button
type="submit"
form="role-form"
size="sm"
disabled={mutation.isPending}
className="px-8 font-bold"
>
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEdit ? t('roles.actions.submit_update') : t('roles.actions.submit_create')}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,112 @@
//
// 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 { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format } from 'date-fns'
import { UserRole } from '@/api/users/api'
import { Badge } from '@/components/ui/badge'
import { PermissionsCellAction } from './permissions-action'
import { LockIcon } from 'lucide-react'
export const getColumns = (t: (key: string) => string): ColumnDef<UserRole>[] => [
{
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('roles.columns.id')} className="justify-center" />
),
cell: ({ row }) => (
<LongText className='max-w-18 text-center'>{`${row.original.id}`}</LongText>
),
enableHiding: false,
meta: { className: 'text-center' },
enableSorting: false
},
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('roles.columns.name')} className="justify-center" />
),
cell: ({ row }) => {
return <LongText>{row.original.name}</LongText>
},
meta: { className: 'text-center' },
},
{
accessorKey: 'role_type',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('roles.columns.role_type')} className="justify-center" />
),
cell: ({ row }) => {
const { role_type, is_builtin } = row.original;
return (
<div className="flex justify-center">
<Badge variant="outline" className="capitalize flex items-center gap-1">
{t(`roles.types.${role_type}`)}
{is_builtin && (
<LockIcon className="h-3 w-3 text-muted-foreground" />
)}
</Badge>
</div>
);
},
meta: { className: 'text-center' },
},
{
id: 'permissions',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('roles.columns.permissions')} className="justify-center" />
),
cell: PermissionsCellAction,
meta: { className: 'w-36 text-center' },
enableHiding: false,
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('roles.columns.created_at')} className="justify-center" />
),
cell: ({ row }) => {
const created_at = row.original.created_at;
const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText>{date}</LongText>;
},
meta: { className: 'text-center' },
enableHiding: false,
},
{
accessorKey: 'updated_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('roles.columns.updated_at')} className="justify-center" />
),
cell: ({ row }) => {
const updated_at = row.original.updated_at;
const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText>{date}</LongText>;
},
meta: { className: 'text-center' },
enableHiding: false,
},
{
id: 'actions',
cell: DataTableRowActions,
},
]

View File

@@ -0,0 +1,83 @@
//
// 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 {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
} from '@radix-ui/react-icons'
import { Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
const { t } = useTranslation()
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className=' h-8 data-[state=open]:bg-accent'
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ml-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ml-2 h-4 w-4' />
) : (
<CaretSortIcon className='ml-2 h-4 w-4' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
{t('table.asc')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
{t('table.desc')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -0,0 +1,137 @@
//
// 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 {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected?: boolean,
showPageSizeSelector?: boolean
}
export function DataTablePagination<TData>({
table,
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'>
{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'>
{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
variant='outline'
className='hidden h-8 w-8 p-0 lg:flex'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t('table.pagination.first')}</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t('table.pagination.previous')}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t('table.pagination.next')}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='hidden h-8 w-8 p-0 lg:flex'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t('table.pagination.last')}</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -29,17 +29,17 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useAccessTokensContext } from '../context'
import { AccessToken } from '../data/schema'
import { useRoleContext } from '../context'
import { useTranslation } from 'react-i18next'
import { UserRole } from '@/api/users/api'
interface DataTableRowActionsProps {
row: Row<AccessToken>
row: Row<UserRole>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useRoleContext()
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccessTokensContext()
return (
<>
<DropdownMenu modal={false}>
@@ -49,7 +49,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>{t('accessTokens.actions.openMenu')}</span>
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
@@ -59,7 +59,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('edit')
}}
>
{t('accessTokens.actions.edit')}
{t('table.edit')}
<DropdownMenuShortcut>
<IconEdit size={16} />
</DropdownMenuShortcut>
@@ -72,7 +72,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}}
className='!text-red-500'
>
{t('accessTokens.actions.delete')}
{t('table.delete')}
<DropdownMenuShortcut>
<IconTrash size={16} />
</DropdownMenuShortcut>

View File

@@ -20,12 +20,13 @@
import { Table } from '@tanstack/react-table'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
// import { useTranslation } from 'react-i18next'
interface DataTableToolbarProps<TData> {
table: Table<TData>
}
export function AccountDetailTableToolbar<TData>({
export function DataTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
@@ -33,12 +34,12 @@ export function AccountDetailTableToolbar<TData>({
<div className='flex items-center justify-between'>
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
<Input
placeholder={t('accessTokens.filterByEmailAddressOrAccountId')}
placeholder={t('roles.placeholder.filter')}
value={(table.getState().globalFilter as string) ?? ''}
onChange={(event) => {
table.setGlobalFilter(event.target.value);
}}
className='h-8 w-[330px]'
className='h-8 w-80'
/>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More