mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Merge branch 'main' into feat/envelope-endpoint-and-api-improvements
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
1825
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
208
web/src/api/users/api.ts
Normal 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;
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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']),
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
36
web/src/components/sign-out-dialog.tsx
Normal file
36
web/src/components/sign-out-dialog.tsx
Normal 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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -55,4 +55,4 @@ const AlertDescription = React.forwardRef<
|
||||
))
|
||||
AlertDescription.displayName = 'AlertDescription'
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,51 +32,66 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>
|
||||
showSelected?: boolean,
|
||||
showSelected?: boolean
|
||||
showPageSizeSelector?: boolean
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
showSelected = true,
|
||||
showSelected = false,
|
||||
showPageSizeSelector = true
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between overflow-auto px-2'>
|
||||
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{' '}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>}
|
||||
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
|
||||
10 rows per page.
|
||||
</div>}
|
||||
{showSelected && (
|
||||
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
|
||||
{t('table.pagination.selected', {
|
||||
selected: table.getFilteredSelectedRowModel().rows.length,
|
||||
total: table.getFilteredRowModel().rows.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!showPageSizeSelector && (
|
||||
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
|
||||
{t('table.pagination.fixed_page_size', { size: 10 })}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
|
||||
{showPageSizeSelector && <div className='flex items-center space-x-2'>
|
||||
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[70px]'>
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>}
|
||||
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
|
||||
Page {table.getState().pagination.pageIndex + 1} of{' '}
|
||||
{table.getPageCount()}
|
||||
{showPageSizeSelector && (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<p className='hidden text-sm font-medium sm:block'>
|
||||
{t('table.pagination.rows_per_page')}
|
||||
</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[70px]'>
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex w-[130px] items-center justify-center text-sm font-medium'>
|
||||
{t('table.pagination.page_info', {
|
||||
page: table.getState().pagination.pageIndex + 1,
|
||||
total: table.getPageCount(),
|
||||
})}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
@@ -85,7 +100,7 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to first page</span>
|
||||
<span className='sr-only'>{t('table.pagination.first')}</span>
|
||||
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -94,7 +109,7 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to previous page</span>
|
||||
<span className='sr-only'>{t('table.pagination.previous')}</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -103,7 +118,7 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to next page</span>
|
||||
<span className='sr-only'>{t('table.pagination.next')}</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -112,7 +127,7 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to last page</span>
|
||||
<span className='sr-only'>{t('table.pagination.last')}</span>
|
||||
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { Row } from '@tanstack/react-table'
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react'
|
||||
import { IconEdit, IconShieldLock, IconTrash } from '@tabler/icons-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Mailboxes() {
|
||||
<>
|
||||
{/* ===== Top Heading ===== */}
|
||||
<FixedHeader />
|
||||
<Main higher>
|
||||
<Main>
|
||||
<Mail
|
||||
defaultLayout={defaultLayout}
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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');
|
||||
|
||||
286
web/src/features/settings/api-tokens/access-token-action.tsx
Normal file
286
web/src/features/settings/api-tokens/access-token-action.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
web/src/features/settings/api-tokens/index.tsx
Normal file
108
web/src/features/settings/api-tokens/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
web/src/features/settings/api-tokens/token-list.tsx
Normal file
229
web/src/features/settings/api-tokens/token-list.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
239
web/src/features/settings/appearance/appearance-form.tsx
Normal file
239
web/src/features/settings/appearance/appearance-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
web/src/features/settings/appearance/index.tsx
Normal file
7
web/src/features/settings/appearance/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AppearanceForm } from './appearance-form'
|
||||
|
||||
export function SettingsAppearance() {
|
||||
return (
|
||||
<AppearanceForm />
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
232
web/src/features/settings/configurations/index.tsx
Normal file
232
web/src/features/settings/configurations/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
131
web/src/features/settings/profile/avatar-upload.tsx
Normal file
131
web/src/features/settings/profile/avatar-upload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
web/src/features/settings/profile/index.tsx
Normal file
51
web/src/features/settings/profile/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
216
web/src/features/settings/profile/permissions-dialog.tsx
Normal file
216
web/src/features/settings/profile/permissions-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
359
web/src/features/settings/profile/profile-form.tsx
Normal file
359
web/src/features/settings/profile/profile-form.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>[] => [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
173
web/src/features/users/api-tokens/components/columns.tsx
Normal file
173
web/src/features/users/api-tokens/components/columns.tsx
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
160
web/src/features/users/api-tokens/components/delete-dialog.tsx
Normal file
160
web/src/features/users/api-tokens/components/delete-dialog.tsx
Normal 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
|
||||
/>
|
||||
)
|
||||
}
|
||||
168
web/src/features/users/api-tokens/components/table.tsx
Normal file
168
web/src/features/users/api-tokens/components/table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
88
web/src/features/users/api-tokens/index.tsx
Normal file
88
web/src/features/users/api-tokens/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
web/src/features/users/components/sidebar-nav.tsx
Normal file
110
web/src/features/users/components/sidebar-nav.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
66
web/src/features/users/index.tsx
Normal file
66
web/src/features/users/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
364
web/src/features/users/roles/components/action-dialog.tsx
Normal file
364
web/src/features/users/roles/components/action-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
web/src/features/users/roles/components/columns.tsx
Normal file
112
web/src/features/users/roles/components/columns.tsx
Normal 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,
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user