mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
initial commit
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
//
|
||||
// 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 { 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'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentRow: AccountModel
|
||||
}
|
||||
|
||||
export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DialogContent className='max-w-5xl'>
|
||||
<DialogHeader className='text-left mb-4'>
|
||||
<DialogTitle>{currentRow.email}</DialogTitle>
|
||||
<DialogDescription>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[35rem] w-full pr-4 -mr-4 py-1">
|
||||
<Tabs defaultValue="account" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-1">
|
||||
<TabsTrigger value="account">Account Details</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="account">
|
||||
<div className="mt-4 space-y-6">
|
||||
{/* Account Details Card */}
|
||||
<Card>
|
||||
<CardContent className="mt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">ID:</span>
|
||||
<span>{currentRow.id}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Email:</span>
|
||||
<span>{currentRow.email}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Name:</span>
|
||||
<span>{currentRow.name ?? "n/a"}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Enabled:</span>
|
||||
<Checkbox checked={currentRow.enabled} disabled />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Incremental Sync Interval:</span>
|
||||
<span>every {currentRow.sync_interval_min} minutes</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground">Capabilities:</span>
|
||||
<code className="rounded-md bg-muted/50 px-2 py-1 text-sm border overflow-x-auto inline-block">
|
||||
{currentRow.capabilities ? currentRow.capabilities.join(", ") : "n/a"}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Date Selection:</span>
|
||||
<span>
|
||||
{currentRow.date_since?.fixed
|
||||
? currentRow.date_since.fixed
|
||||
: currentRow.date_since?.relative
|
||||
? `recent ${currentRow.date_since.relative.value} ${currentRow.date_since.relative.unit}`
|
||||
: "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Folder Limit:</span>
|
||||
<span>{currentRow.folder_limit ? currentRow.folder_limit : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Server Configuration Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Server Configuration (IMAP)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Host:</span>
|
||||
<span>{currentRow.imap?.host}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Port:</span>
|
||||
<span>{currentRow.imap?.port}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Encryption:</span>
|
||||
<span>{currentRow.imap?.encryption}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Auth:</span>
|
||||
{currentRow.imap?.auth.auth_type === "OAuth2" ? (
|
||||
<Badge variant="outline" className="bg-blue-100 text-blue-800">
|
||||
OAuth2
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-blue-100 text-blue-800">
|
||||
Password
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Use Proxy:</span>
|
||||
<span>{currentRow.imap?.use_proxy ? "true" : "false"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sync Folders Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sync Folders</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{currentRow.sync_folders?.length ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm mt-2 text-muted-foreground">
|
||||
{currentRow.sync_folders.length} folder(s) configured for sync
|
||||
</div>
|
||||
<ScrollArea className="h-[300px] rounded-md border">
|
||||
<div className="p-2">
|
||||
{currentRow.sync_folders.map((folder, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center py-2 px-3 hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium">{folder}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No folders configured for sync
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
//
|
||||
// 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 { 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';
|
||||
import Step1 from './step1';
|
||||
import Step2 from './step2';
|
||||
import Step3 from './step3';
|
||||
import Step4 from './step4';
|
||||
import CompleteStep from './complete-step';
|
||||
import { create_account, autoconfig, update_account } from '@/api/account/api';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ToastAction } from '@/components/ui/toast';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
const encryptionSchema = z.union([
|
||||
z.literal('Ssl'),
|
||||
z.literal('StartTls'),
|
||||
z.literal('None'),
|
||||
]);
|
||||
|
||||
const authTypeSchema = z.union([
|
||||
z.literal('Password'),
|
||||
z.literal('OAuth2'),
|
||||
]);
|
||||
|
||||
const authConfigSchema = (isEdit: boolean) =>
|
||||
z.object({
|
||||
auth_type: authTypeSchema,
|
||||
password: z.string().optional(), // Always optional at base level
|
||||
}).refine(
|
||||
(data) => {
|
||||
// Only validate password when:
|
||||
// 1. Auth type is Password
|
||||
// 2. In create mode (not edit)
|
||||
if (data.auth_type === 'Password' && !isEdit) {
|
||||
return !!data.password?.trim();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Password is required when auth method is Password',
|
||||
path: ['password'],
|
||||
}
|
||||
);
|
||||
|
||||
const imapConfigSchema = (isEdit: boolean) =>
|
||||
z.object({
|
||||
host: z.string({ required_error: 'IMAP host is required' }).min(1, { message: 'IMAP host cannot be empty' }),
|
||||
port: z.number().int().min(0, { message: 'IMAP port must be a positive integer' }).max(65535, { message: 'IMAP port must be less than 65536' }),
|
||||
encryption: encryptionSchema,
|
||||
auth: authConfigSchema(isEdit),
|
||||
use_proxy: z.number().optional(),
|
||||
});
|
||||
|
||||
|
||||
const relativeDateSchema = z.object({
|
||||
unit: z.enum(["Days", "Months", "Years"], { message: "Please select a unit" }),
|
||||
value: z.number({ message: 'Please enter a value' }).int().min(1, "Must be at least 1"),
|
||||
});
|
||||
|
||||
const dateSelectionSchema = z.union([
|
||||
z.object({ fixed: z.string({ message: "Please select a date" }) },),
|
||||
z.object({ relative: relativeDateSchema }),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
// Define static Account type to avoid z.infer issue with dynamic schema
|
||||
export type Account = {
|
||||
name?: string;
|
||||
email: string;
|
||||
imap: {
|
||||
host: string;
|
||||
port: number;
|
||||
encryption: 'Ssl' | 'StartTls' | 'None';
|
||||
auth: {
|
||||
auth_type: 'Password' | 'OAuth2';
|
||||
password?: string;
|
||||
};
|
||||
use_proxy?: number;
|
||||
};
|
||||
enabled: boolean;
|
||||
date_since?: {
|
||||
fixed?: string;
|
||||
relative?: {
|
||||
unit?: 'Days' | 'Months' | 'Years';
|
||||
value?: number;
|
||||
};
|
||||
};
|
||||
folder_limit?: number;
|
||||
sync_interval_min: number;
|
||||
};
|
||||
|
||||
const accountSchema = (isEdit: boolean) =>
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }),
|
||||
imap: imapConfigSchema(isEdit),
|
||||
enabled: z.boolean(),
|
||||
date_since: dateSelectionSchema.optional(),
|
||||
folder_limit: z
|
||||
.number({ invalid_type_error: 'Folder limit must be a number' })
|
||||
.int()
|
||||
.min(100, { message: 'Folder limit must be at least 100' })
|
||||
.optional(),
|
||||
sync_interval_min: z.number({ invalid_type_error: 'Incremental sync interval must be a number' }).int().min(10, { message: 'Incremental sync interval must be at least 10 minutes' }),
|
||||
});
|
||||
|
||||
type Step = {
|
||||
id: `step-${number}`;
|
||||
name: string;
|
||||
fields: (keyof Account)[];
|
||||
};
|
||||
|
||||
export type Steps = [
|
||||
{ id: "complete"; name: "Complete"; fields: [] },
|
||||
...Step[]
|
||||
];
|
||||
|
||||
const steps: Steps = [
|
||||
{ id: "complete", name: "Complete", fields: [] },
|
||||
{
|
||||
id: "step-1",
|
||||
name: "Email Address",
|
||||
fields: ["email"],
|
||||
},
|
||||
{
|
||||
id: "step-2",
|
||||
name: "IMAP",
|
||||
fields: ["imap"],
|
||||
},
|
||||
{ id: "step-3", name: "Sync Preferences", fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
|
||||
{ id: "step-4", name: "Summary", fields: [] },
|
||||
];
|
||||
|
||||
const LAST_STEP = steps.length - 1;
|
||||
const COMPLETE_STEP = 0;
|
||||
|
||||
interface Props {
|
||||
currentRow?: AccountModel;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const defaultValues: Account = {
|
||||
name: undefined,
|
||||
email: '',
|
||||
imap: {
|
||||
host: "",
|
||||
port: 993,
|
||||
encryption: 'Ssl',
|
||||
auth: {
|
||||
auth_type: 'Password',
|
||||
password: undefined,
|
||||
},
|
||||
use_proxy: undefined
|
||||
},
|
||||
enabled: true,
|
||||
date_since: undefined,
|
||||
folder_limit: undefined,
|
||||
sync_interval_min: 10,
|
||||
};
|
||||
|
||||
const emptyImap: ImapConfig = {
|
||||
host: "",
|
||||
port: 0,
|
||||
encryption: "None",
|
||||
auth: { auth_type: "Password", password: undefined },
|
||||
use_proxy: undefined,
|
||||
};
|
||||
|
||||
const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
|
||||
const imap = { ...(currentRow.imap ?? emptyImap) };
|
||||
// Handle password and use_proxy conversion
|
||||
imap.auth = { ...imap.auth, password: undefined };
|
||||
if (imap.use_proxy === null) {
|
||||
imap.use_proxy = undefined;
|
||||
}
|
||||
|
||||
let account = {
|
||||
name: currentRow.name === null ? undefined : currentRow.name,
|
||||
email: currentRow.email,
|
||||
imap,
|
||||
enabled: currentRow.enabled,
|
||||
date_since: currentRow.date_since ?? undefined,
|
||||
folder_limit: currentRow.folder_limit ?? undefined,
|
||||
sync_interval_min: currentRow.sync_interval_min ?? 10,
|
||||
};
|
||||
|
||||
return account;
|
||||
};
|
||||
|
||||
export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const isEdit = !!currentRow;
|
||||
const [currentStep, setCurrentStep] = React.useState(1);
|
||||
const { toast } = useToast();
|
||||
const [autoConfigLoading, setAutoConfigLoading] = React.useState(false);
|
||||
|
||||
const form = useForm<Account>({
|
||||
mode: "all",
|
||||
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
|
||||
resolver: zodResolver(accountSchema(isEdit)),
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: create_account,
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, any>) => update_account(currentRow?.id!, data),
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: `Account ${isEdit ? 'Updated' : 'Created'}`,
|
||||
description: `Your account has been successfully ${isEdit ? 'updated' : 'created'}.`,
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['account-list'] });
|
||||
form.reset();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleError(error: AxiosError) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
`${isEdit ? 'Update' : 'Creation'} failed, please try again later`;
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `Account ${isEdit ? 'Update' : 'Creation'} Failed`,
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
(data: Account) => {
|
||||
const commonData = {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
imap: {
|
||||
...data.imap,
|
||||
auth: {
|
||||
...data.imap.auth,
|
||||
password: data.imap.auth.auth_type === 'OAuth2'
|
||||
? undefined
|
||||
: (isEdit && !data.imap.auth.password ? undefined : data.imap.auth.password),
|
||||
},
|
||||
},
|
||||
enabled: data.enabled,
|
||||
date_since: data.date_since,
|
||||
folder_limit: data.folder_limit,
|
||||
sync_interval_min: data.sync_interval_min,
|
||||
};
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(commonData);
|
||||
} else {
|
||||
const payload = {
|
||||
...commonData,
|
||||
account_type: "IMAP",
|
||||
};
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
},
|
||||
[isEdit, updateMutation, createMutation]
|
||||
);
|
||||
|
||||
const handleNav = async (index: number) => {
|
||||
let isValid = true;
|
||||
let failedStep = currentStep;
|
||||
for (let i = currentStep; i < index && isValid; i++) {
|
||||
isValid = await form.trigger(steps[i].fields);
|
||||
if (!isValid) {
|
||||
failedStep = i;
|
||||
}
|
||||
}
|
||||
if (isValid) {
|
||||
setCurrentStep(index);
|
||||
} else {
|
||||
setCurrentStep(failedStep);
|
||||
}
|
||||
};
|
||||
|
||||
async function handleContinue() {
|
||||
const isValid = await form.trigger(steps[currentStep].fields);
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
if (currentStep === 1) {
|
||||
let allValues = form.getValues();
|
||||
if (
|
||||
allValues.imap.host.trim() !== "" &&
|
||||
allValues.imap.port > 0
|
||||
) {
|
||||
handleNav(currentStep + 1);
|
||||
return;
|
||||
}
|
||||
setAutoConfigLoading(true);
|
||||
const email = form.getValues('email');
|
||||
|
||||
try {
|
||||
const result = await autoconfig(email);
|
||||
if (result) {
|
||||
form.setValue('imap.host', result.imap.host);
|
||||
form.setValue('imap.port', result.imap.port);
|
||||
form.setValue('imap.encryption', result.imap.encryption);
|
||||
if (result.oauth2) {
|
||||
form.setValue('imap.auth.auth_type', 'OAuth2');
|
||||
}
|
||||
}
|
||||
setAutoConfigLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Auto-configuration failed:', error);
|
||||
setAutoConfigLoading(false);
|
||||
}
|
||||
handleNav(currentStep + 1);
|
||||
} else {
|
||||
handleNav(currentStep + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(state) => {
|
||||
form.reset();
|
||||
setCurrentStep(1);
|
||||
onOpenChange(state);
|
||||
}}
|
||||
>
|
||||
<DialogContent className='max-w-5xl'>
|
||||
<DialogHeader className='text-left mb-4'>
|
||||
<DialogTitle>{isEdit ? "Update Account" : "Add Account"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the email account here. ' : 'Add new email account here. '}
|
||||
Click save when you're done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[38rem] w-full pr-4 -mr-4 py-1">
|
||||
<>
|
||||
{/* Mobile Steps (hidden on desktop) */}
|
||||
{currentStep !== COMPLETE_STEP && (
|
||||
<div className="flex my-5 space-x-4 md:hidden">
|
||||
{steps.map(
|
||||
(step, index) =>
|
||||
index !== COMPLETE_STEP && (
|
||||
<div className="z-20 my-3 ml-2 flex items-center" key={step.id}>
|
||||
<Button
|
||||
className={`size-9 rounded-full border font-bold ${`step-${currentStep}` === step.id ? "" : "bg-gray-200 text-black"
|
||||
}`}
|
||||
disabled={`step-${currentStep}` === step.id || currentStep === COMPLETE_STEP}
|
||||
onClick={() => handleNav(index)}
|
||||
>
|
||||
{index}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full max-w-full p-4">
|
||||
<div className="flex md:h-min rounded-xl md:rounded-2xl p-4">
|
||||
{currentStep !== COMPLETE_STEP && (
|
||||
<div className="hidden md:block w-[260px] flex-shrink-0 rounded-xl p-5 pt-7 fixed">
|
||||
{steps.map(
|
||||
(step, index) =>
|
||||
index !== COMPLETE_STEP && (
|
||||
<div className="my-3 ml-2 flex items-center" key={step.id}>
|
||||
<Button
|
||||
className={`size-8 border rounded-full text-sm font-bold ${`step-${currentStep}` === step.id
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-200 text-black"
|
||||
}`}
|
||||
disabled={`step-${currentStep}` === step.id || currentStep === COMPLETE_STEP}
|
||||
onClick={() => handleNav(index)}
|
||||
>
|
||||
{index}
|
||||
</Button>
|
||||
<div className="flex flex-col items-baseline uppercase ml-5">
|
||||
<span className="text-xs">Step {index}</span>
|
||||
<span className="font-bold text-sm tracking-wider">{step.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="account-register-form"
|
||||
className={`flex-grow flex flex-col px-4 md:px-8 lg:px-12 ${currentStep !== COMPLETE_STEP ? 'ml-[240px]' : ''
|
||||
}`}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
{currentStep === 1 && <Step1 isEdit={isEdit} />}
|
||||
{currentStep === 2 && <Step2 isEdit={isEdit} />}
|
||||
{currentStep === 3 && <Step3 />}
|
||||
{currentStep === 4 && <Step4 />}
|
||||
{currentStep === COMPLETE_STEP && <CompleteStep />}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</ScrollArea>
|
||||
<DialogFooter className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={currentStep === 1 || currentStep === COMPLETE_STEP}
|
||||
type="button"
|
||||
className="flex-grow sm:flex-grow-0 shadow-none text-nowrap text-sm disabled:invisible"
|
||||
onClick={() => {
|
||||
handleNav(currentStep - 1);
|
||||
}}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={currentStep === LAST_STEP || currentStep === COMPLETE_STEP}
|
||||
type="button"
|
||||
className="flex-grow sm:flex-grow-0 rounded-md md:rounded-lg px-6 disabled:hidden text-sm"
|
||||
onClick={handleContinue}
|
||||
>
|
||||
{autoConfigLoading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 mr-3 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Auto-configuring...</span>
|
||||
</>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={currentStep !== LAST_STEP}
|
||||
type="submit"
|
||||
form="account-register-form"
|
||||
className="flex-grow sm:flex-grow-0 rounded-md text-sm px-7 disabled:hidden md:rounded-lg"
|
||||
>
|
||||
{isEdit ? "Save changes" : "Submit"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// 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 { AccountModel } from '../data/schema'
|
||||
import { DataTableColumnHeader } from './data-table-column-header'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
import { format } from 'date-fns'
|
||||
import { OAuth2Action } from './oauth2-action'
|
||||
import { RunningStateCellAction } from './running-state-action'
|
||||
import { EnableAction } from './enable-action'
|
||||
|
||||
export const columns: ColumnDef<AccountModel>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='ID' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <LongText>{row.original.id}</LongText>
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Email' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <LongText>{row.original.email}</LongText>
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader className="ml-4" column={column} title='Enabled' />
|
||||
),
|
||||
cell: EnableAction,
|
||||
meta: { className: 'w-8 text-center' },
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: 'auth_type',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Auth' />
|
||||
),
|
||||
cell: OAuth2Action,
|
||||
meta: { className: 'w-8' },
|
||||
enableHiding: false,
|
||||
enableSorting: false
|
||||
},
|
||||
{
|
||||
id: 'account_type',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Type' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <LongText>{row.original.account_type}</LongText>
|
||||
},
|
||||
meta: { className: 'w-8' },
|
||||
enableHiding: false,
|
||||
enableSorting: false
|
||||
},
|
||||
{
|
||||
accessorKey: "sync_interval_sec",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Inc Sync' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
let account_type = row.original.account_type;
|
||||
if (account_type === "NoSync") {
|
||||
return <LongText className='max-w-12'>n/a</LongText>
|
||||
}
|
||||
return <LongText className='max-w-12'>{row.original.sync_interval_min} minutes</LongText>
|
||||
},
|
||||
meta: { className: 'w-12 text-center' },
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: 'running_state',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='State' />
|
||||
),
|
||||
cell: RunningStateCellAction,
|
||||
meta: { className: 'w-36' },
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Created At' />
|
||||
),
|
||||
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='Updated At' />
|
||||
),
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: DataTableRowActions,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// 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 { CheckCircle } from "lucide-react"; // Using Lucide icon library
|
||||
import { Button } from "@/components/ui/button"; // Using custom button component
|
||||
|
||||
export default function CompleteStep() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[25rem] p-6">
|
||||
{/* Success Icon */}
|
||||
<div className="mb-6 text-green-500">
|
||||
<CheckCircle className="w-16 h-16" />
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
<h1 className="text-3xl font-bold mb-4">Registration Successful!</h1>
|
||||
<p className="text-lg text-gray-600 mb-8 text-center">
|
||||
Your email account has been successfully added.
|
||||
</p>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
// Redirect to home page
|
||||
window.location.href = "/";
|
||||
}}
|
||||
>
|
||||
Authorize via OAuth2
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// View details
|
||||
window.location.href = "/accounts";
|
||||
}}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// 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,
|
||||
EyeNoneIcon,
|
||||
} 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,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
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' />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//
|
||||
// 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'
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>
|
||||
showSelected?: boolean,
|
||||
showPageSizeSelector?: boolean
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
showSelected = true,
|
||||
showPageSizeSelector = true
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
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>}
|
||||
<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()}
|
||||
</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'>Go to first page</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'>Go to previous page</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'>Go to next page</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'>Go to last page</span>
|
||||
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//
|
||||
// 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 { IconEdit, IconTrash } from '@tabler/icons-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAccountContext } from '../context'
|
||||
import { AccountModel } from '../data/schema'
|
||||
import { Mailbox, MessageSquareMore } from 'lucide-react'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<AccountModel>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentRow } = useAccountContext()
|
||||
|
||||
const account_type = row.original.account_type;
|
||||
|
||||
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)
|
||||
if (account_type === "IMAP") {
|
||||
setOpen("edit-imap");
|
||||
}
|
||||
if (account_type === "NoSync") {
|
||||
setOpen("edit-nosync");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
<DropdownMenuShortcut>
|
||||
<IconEdit size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{account_type === "IMAP" && <DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
setOpen('sync-folders')
|
||||
}}
|
||||
>
|
||||
Sync Folders
|
||||
<DropdownMenuShortcut>
|
||||
<Mailbox size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>}
|
||||
{account_type === "IMAP" && <DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
setOpen('detail')
|
||||
}}
|
||||
>
|
||||
Detail
|
||||
<DropdownMenuShortcut>
|
||||
<MessageSquareMore size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
setOpen('delete')
|
||||
}}
|
||||
className='!text-red-500'
|
||||
>
|
||||
Delete
|
||||
<DropdownMenuShortcut>
|
||||
<IconTrash size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// 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'
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
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='Filter account...'
|
||||
value={(table.getState().globalFilter as string) ?? ''}
|
||||
onChange={(event) => {
|
||||
table.setGlobalFilter(event.target.value);
|
||||
}}
|
||||
className='h-8 w-80'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// 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 { IconAlertCircle, 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 { 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'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentRow: AccountModel
|
||||
}
|
||||
|
||||
export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: 'Account Deleted',
|
||||
description: 'Your account has been successfully deleted.',
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['account-list'] });
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleError(error: AxiosError) {
|
||||
const errorMessage = error.response?.data ||
|
||||
error.message ||
|
||||
`Delete failed, please try again later`;
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `Account delete Failed`,
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => remove_account(id),
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError
|
||||
})
|
||||
|
||||
const handleDelete = () => {
|
||||
if (value.trim() !== currentRow.email) return
|
||||
deleteMutation.mutate(currentRow.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
handleConfirm={handleDelete}
|
||||
disabled={value.trim() !== currentRow.email}
|
||||
className="max-w-2xl"
|
||||
title={
|
||||
<span className='text-destructive'>
|
||||
<IconAlertTriangle
|
||||
className='mr-1 inline-block stroke-destructive'
|
||||
size={18}
|
||||
/>{' '}
|
||||
Delete Account Permanently
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className='space-y-4'>
|
||||
<p className='mb-2'>
|
||||
You are deleting <span className='font-bold'>{currentRow.email}</span>.
|
||||
This will permanently remove:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-1 text-sm text-muted-foreground">
|
||||
<li>Account credentials and settings</li>
|
||||
<li>Local cached metadata and sync status</li>
|
||||
<li>IMAP synchronization data</li>
|
||||
<li>OAuth tokens and API credentials</li>
|
||||
</ul>
|
||||
|
||||
<div className="pt-2">
|
||||
<Label>
|
||||
Type the account email to confirm:
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={`Type "${currentRow.email}" to confirm`}
|
||||
className="mt-2"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Alert variant='destructive'>
|
||||
<IconAlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>This action cannot be undone!</AlertTitle>
|
||||
<AlertDescription>
|
||||
All related resources will be permanently erased.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText={
|
||||
deleteMutation.isPending ? 'Deleting...' : 'Permanently Delete Account'
|
||||
}
|
||||
isLoading={deleteMutation.isPending}
|
||||
destructive
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// 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 { 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 { toast } from '@/hooks/use-toast'
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<AccountModel>
|
||||
}
|
||||
|
||||
export function EnableAction({ row }: DataTableRowActionsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) =>
|
||||
update_account(row.original.id, { enabled }),
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
toast({
|
||||
title: 'Account Updated',
|
||||
description: `Account has been successfully ${row.original.enabled ? 'disabled' : 'enabled'}.`,
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['account-list'] })
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
'Status update failed, please try again later'
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: 'Update Failed',
|
||||
description: errorMessage,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
updateMutation.mutate(!row.original.enabled)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
onCheckedChange={() => setOpen(true)}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={`${row.original.enabled ? 'Disable' : 'Enable'} Account`}
|
||||
desc={
|
||||
`Are you sure you want to ${row.original.enabled ? 'disable' : 'enable'} this account?` +
|
||||
(row.original.enabled ? ' This will prevent the account from being used.' : '')
|
||||
}
|
||||
destructive={row.original.enabled}
|
||||
confirmText={row.original.enabled ? 'Disable' : 'Enable'}
|
||||
isLoading={updateMutation.isPending}
|
||||
handleConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// 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 { ProgressMap } from "@/api/account/api";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface Props {
|
||||
progressMap: ProgressMap | undefined | null;
|
||||
}
|
||||
|
||||
export function FolderSyncProgress({ progressMap }: Props) {
|
||||
|
||||
if (!progressMap || Object.keys(progressMap).length === 0) {
|
||||
return <div className="text-muted-foreground text-sm">No Data</div>;
|
||||
}
|
||||
|
||||
const folderNames = Object.keys(progressMap);
|
||||
|
||||
if (folderNames.length === 0) {
|
||||
return <div className="text-muted-foreground text-sm">No folders to sync</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{folderNames.map((folder) => {
|
||||
const progress = progressMap[folder];
|
||||
const percentage =
|
||||
progress.total_batches > 0
|
||||
? Math.min((progress.current_batch / progress.total_batches) * 100, 100)
|
||||
: 0;
|
||||
const isComplete = progress.current_batch >= progress.total_batches;
|
||||
const textColor = isComplete ? "text-green-800" : "text-blue-800";
|
||||
const progressColor = isComplete ? "bg-gray-200 [&>div]:bg-green-800 [&>div]:rounded-full h-1.5" : "bg-gray-200 [&>div]:bg-blue-800 [&>div]:rounded-full h-1.5";
|
||||
|
||||
return (
|
||||
<div key={folder} className="space-y-1">
|
||||
<div className={`flex justify-between text-sm ${textColor}`}>
|
||||
<span className="truncate max-w-[70%]">{folder}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{progress.current_batch}/{progress.total_batches} batches
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={percentage} className={progressColor} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//
|
||||
// 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 { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ToastAction } from '@/components/ui/toast';
|
||||
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 { 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';
|
||||
|
||||
|
||||
const accountSchema = () =>
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }),
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
|
||||
export type NoSyncAccount = {
|
||||
name?: string;
|
||||
email: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
|
||||
|
||||
interface Props {
|
||||
currentRow?: AccountModel;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
|
||||
const defaultValues: NoSyncAccount = {
|
||||
name: '',
|
||||
email: '',
|
||||
enabled: true
|
||||
};
|
||||
|
||||
|
||||
const mapCurrentRowToFormValues = (currentRow: AccountModel): NoSyncAccount => {
|
||||
let account = {
|
||||
name: currentRow.name === null ? '' : currentRow.name,
|
||||
email: currentRow.email,
|
||||
enabled: currentRow.enabled
|
||||
};
|
||||
return account;
|
||||
};
|
||||
|
||||
|
||||
export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const isEdit = !!currentRow;
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<NoSyncAccount>({
|
||||
mode: "all",
|
||||
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
|
||||
resolver: zodResolver(accountSchema()),
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: create_account,
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, any>) => update_account(currentRow?.id!, data),
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: `Account ${isEdit ? 'Updated' : 'Created'}`,
|
||||
description: `Your account has been successfully ${isEdit ? 'updated' : 'created'}.`,
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['account-list'] });
|
||||
form.reset();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleError(error: AxiosError) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
`${isEdit ? 'Update' : 'Creation'} failed, please try again later`;
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `Account ${isEdit ? 'Update' : 'Creation'} Failed`,
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
(data: NoSyncAccount) => {
|
||||
const commonData = {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
enabled: data.enabled
|
||||
};
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(commonData);
|
||||
} else {
|
||||
const payload = {
|
||||
...commonData,
|
||||
account_type: "NoSync"
|
||||
};
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
},
|
||||
[isEdit, updateMutation, createMutation]
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(state) => {
|
||||
form.reset();
|
||||
onOpenChange(state);
|
||||
}}
|
||||
>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader className='text-left mb-4'>
|
||||
<DialogTitle>{isEdit ? "Update Account" : "Add Account"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the email account here. ' : 'Add new email account here. '}
|
||||
Click save when you're done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className='h-[23rem] w-full pr-4 -mr-4 py-1'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='nosync-account-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-4 p-0.5'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Email Address:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g john.doe@gmail.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This account is used for identification purposes only and does not require syncing with an email server. It helps with importing email data.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Name:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g john.doe" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Optional</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col items-start gap-y-1'>
|
||||
<FormLabel>Enabled:</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Determines whether this account is active. If disabled, the account will not be able to import data or perform queries.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='submit'
|
||||
form='nosync-account-form'
|
||||
disabled={isEdit ? updateMutation.isPending : createMutation.isPending}
|
||||
>
|
||||
{isEdit ? (
|
||||
updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save changes"
|
||||
)
|
||||
) : (
|
||||
createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create"
|
||||
)
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// 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 { Row } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAccountContext } from '../context'
|
||||
import { AccountModel } from '../data/schema'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<AccountModel>
|
||||
}
|
||||
export function OAuth2Action({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentRow } = useAccountContext()
|
||||
const mailer = row.original
|
||||
const account_type = mailer.account_type;
|
||||
|
||||
if (account_type === "NoSync") {
|
||||
return <span className="text-xs text-muted-foreground">n/a</span>
|
||||
}
|
||||
|
||||
const isOAuth2 = mailer.imap?.auth.auth_type === "OAuth2"
|
||||
|
||||
if (isOAuth2) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-blue-500 hover:text-blue-700 underline"
|
||||
onClick={() => {
|
||||
setCurrentRow(mailer)
|
||||
setOpen("oauth2")
|
||||
}}
|
||||
>
|
||||
OAuth2
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className="text-xs text-muted-foreground">Password</span>
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//
|
||||
// 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 { AccountModel } from '../data/schema'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { get_oauth2_tokens } from '@/api/oauth2/api'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { FileIcon } from 'lucide-react'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import LongText from '@/components/long-text'
|
||||
import { useCallback } from 'react'
|
||||
import { IconCopy } from '@tabler/icons-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
interface Props {
|
||||
currentRow: AccountModel
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const { data: oauth2Tokens, isLoading } = useQuery({
|
||||
queryKey: ['oauth2-tokens', currentRow.id],
|
||||
queryFn: () => get_oauth2_tokens(currentRow.id),
|
||||
enabled: open && !!currentRow.id,
|
||||
retry: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
})
|
||||
|
||||
|
||||
const onCopy = useCallback(async (access: boolean, token: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
if (access) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Access token copied to clipboard",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Refresh token copied to clipboard",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to copy text",
|
||||
description: (err as Error).message,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(state) => {
|
||||
onOpenChange(state)
|
||||
}}
|
||||
>
|
||||
<DialogContent className='sm:max-w-3xl'>
|
||||
<DialogHeader className='text-left'>
|
||||
<DialogTitle>OAuth2 Tokens</DialogTitle>
|
||||
<DialogDescription>
|
||||
Details of the OAuth2 tokens for the account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Card>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<TableSkeleton columns={2} rows={10} />
|
||||
) : oauth2Tokens ? (
|
||||
<Table className='w-full'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Field</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>OAuth2 Name</TableCell>
|
||||
<TableCell>
|
||||
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.oauth2_name}</LongText>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Access Token</TableCell>
|
||||
<TableCell>
|
||||
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.access_token}</LongText>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className='text-xs px-1.5 py-0.5' onClick={() => onCopy(true, oauth2Tokens.access_token)}>
|
||||
<IconCopy className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Refresh Token</TableCell>
|
||||
<TableCell>
|
||||
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.refresh_token}</LongText>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className='text-xs px-1.5 py-0.5' onClick={() => onCopy(false, oauth2Tokens.refresh_token)}>
|
||||
<IconCopy className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Created At</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(oauth2Tokens.created_at), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Updated At</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="flex h-[250px] mt-4 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">
|
||||
<FileIcon className="h-10 w-10 text-muted-foreground" />
|
||||
<h3 className="mt-4 text-lg font-semibold">No OAuth2 Tokens</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
The account has not completed the authorization process. Please
|
||||
<a onClick={() => navigate({ to: '/oauth2' })} className="ml-1 text-blue-500 underline cursor-pointer">click here</a> to authorize the account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// 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 { Row } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AccountModel } from '../data/schema';
|
||||
import { useAccountContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
row: Row<AccountModel>
|
||||
}
|
||||
|
||||
export function RunningStateCellAction({ row }: Props) {
|
||||
const { setOpen, setCurrentRow } = useAccountContext()
|
||||
|
||||
let account_type = row.original.account_type;
|
||||
if (account_type === "NoSync") {
|
||||
return <span className="text-xs text-muted-foreground">n/a</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant='ghost' className="h-auto p-1" onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
setOpen('running-state')
|
||||
}}>
|
||||
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">view details</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//
|
||||
// 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,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
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 { formatDistanceToNow, formatDuration, intervalToDuration } from 'date-fns'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CheckCircle, Clock, Loader2, PlayCircle, FolderSync, FolderCheck } from 'lucide-react'
|
||||
import { FolderSyncProgress } from './folder-sync-progress'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentRow: AccountModel
|
||||
}
|
||||
|
||||
export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { data: state, isLoading } = useQuery({
|
||||
queryKey: ['running-state', currentRow.id],
|
||||
queryFn: () => account_state(currentRow.id),
|
||||
retry: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
// Helper function to calculate duration
|
||||
const calculateDuration = (start?: number, end?: number) => {
|
||||
if (!start) {
|
||||
return (
|
||||
<span className="text-yellow-600 flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" /> Not Started
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!end) {
|
||||
return (
|
||||
<span className="text-blue-600 flex items-center gap-1">
|
||||
<PlayCircle className="w-4 h-4" /> In Progress
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const duration = intervalToDuration({ start: new Date(start), end: new Date(end) })
|
||||
return (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<CheckCircle className="w-4 h-4" /> {formatDuration(duration, { format: ['hours', 'minutes', 'seconds'] })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-7xl sm:rounded-xl p-0 overflow-hidden">
|
||||
<DialogHeader className="text-left space-y-2 px-4 sm:px-6 pt-4 sm:pt-6">
|
||||
<DialogTitle className="flex flex-wrap items-center gap-2 text-base sm:text-lg">
|
||||
<span className="text-blue-500 font-medium truncate">{currentRow.email}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[85vh] px-4 sm:px-6 pb-6">
|
||||
{isLoading && (
|
||||
<div className="space-y-4 py-6">
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<div className="flex justify-center items-center py-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && state && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 sm:gap-6 mt-4">
|
||||
<div className="xl:col-span-2 space-y-1 sm:space-y-1">
|
||||
{/* Initial Sync */}
|
||||
<div className="p-4 border rounded-lg bg-card">
|
||||
<div className="flex flex-wrap items-center justify-between mb-3 gap-2">
|
||||
<h3 className="text-base sm:text-lg font-semibold flex items-center gap-2">
|
||||
{state.is_initial_sync_completed ? (
|
||||
<FolderCheck className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<FolderSync className="w-5 h-5 text-blue-500" />
|
||||
)}
|
||||
Initial Sync
|
||||
</h3>
|
||||
{state.is_initial_sync_completed ? (
|
||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
|
||||
Completed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||||
In Progress
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-6 w-full">
|
||||
{/* Start Time */}
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="text-muted-foreground">Start Time:</span>
|
||||
<span className="font-medium">
|
||||
{state.initial_sync_start_time ? (
|
||||
<span className="text-green-600">
|
||||
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-yellow-600">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-yellow-500"></span>
|
||||
</span>
|
||||
Not Started
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="text-muted-foreground">End Time:</span>
|
||||
<span className="font-medium">
|
||||
{state.initial_sync_end_time ? (
|
||||
<span className="text-green-600">
|
||||
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true })}
|
||||
</span>
|
||||
) : state.initial_sync_start_time ? (
|
||||
<span className="flex items-center gap-1 text-blue-600">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
|
||||
</span>
|
||||
In Progress
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">Not Started</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-medium">
|
||||
{(() => {
|
||||
if (!state.initial_sync_start_time)
|
||||
return <span className="text-yellow-600">Not Started</span>;
|
||||
if (!state.initial_sync_end_time)
|
||||
return <span className="text-blue-600 animate-pulse">Calculating...</span>;
|
||||
|
||||
const diff = new Date(state.initial_sync_end_time).getTime() - new Date(state.initial_sync_start_time).getTime();
|
||||
const h = Math.floor(diff / 3600000);
|
||||
const m = Math.floor((diff % 3600000) / 60000);
|
||||
const s = Math.floor((diff % 60000) / 1000);
|
||||
return <span className="text-foreground">{h}h {m}m {s}s</span>;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-card">
|
||||
{state && (
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-3">Sync Progres</h3>
|
||||
<ScrollArea className="h-70 sm:h-70 border rounded-md p-2">
|
||||
<div className='space-y-4'>
|
||||
<FolderSyncProgress progressMap={state.progress} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Incremental Sync */}
|
||||
<div className="p-4 border rounded-lg bg-card">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-3">Incremental Sync</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Start Time:</span>
|
||||
<span className="font-medium">
|
||||
{state.last_incremental_sync_start ? (
|
||||
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true })
|
||||
) : (
|
||||
<span className="text-yellow-600">Not Started</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">End Time:</span>
|
||||
<span className="font-medium">
|
||||
{state.last_incremental_sync_end ? (
|
||||
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true })
|
||||
) : state.last_incremental_sync_start ? (
|
||||
<span className="text-blue-600">In Progress</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">Not Started</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-medium">
|
||||
{calculateDuration(state.last_incremental_sync_start, state.last_incremental_sync_end)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Sync Interval:</span>
|
||||
<span className="font-medium">
|
||||
Every {currentRow.sync_interval_min} minutes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg bg-card">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-3">Error Logs</h3>
|
||||
<ScrollArea className="h-[20rem] sm:h-[32rem]">
|
||||
<div className="space-y-3">
|
||||
{state.errors.length ? (
|
||||
state.errors
|
||||
.sort((a, b) => b.at - a.at)
|
||||
.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-xs sm:text-sm transition-all hover:bg-accent"
|
||||
>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(item.at), { addSuffix: true })}
|
||||
</div>
|
||||
<div className="font-medium break-words">{item.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="h-full flex justify-center items-center py-8">
|
||||
<p className="text-sm text-muted-foreground">No error logs available.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !state && (
|
||||
<div className="h-full flex flex-col justify-center items-center py-8 text-center space-y-2">
|
||||
<p className="text-sm text-muted-foreground">No active state available</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Account synchronization may not have started yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="pt-4 px-4 sm:px-6 pb-4 border-t">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" className="w-full sm:w-auto">
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// 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 { useFormContext } from "react-hook-form";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Account } from "./action-dialog";
|
||||
|
||||
interface StepProps {
|
||||
isEdit: boolean;
|
||||
}
|
||||
|
||||
export default function Step1({ isEdit }: StepProps) {
|
||||
const { control } = useFormContext<Account>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="my-3 md:mt-8">Email Account Registration</h1>
|
||||
<p className="mb-5 md:mb-8">
|
||||
Please provide your email address. In the next steps, you will configure the IMAP/SMTP details. Using this email address, we will attempt to automatically retrieve the SMTP/IMAP server addresses.
|
||||
</p>
|
||||
<div className="space-y-8">
|
||||
<FormField
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Email Address:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g john.doe@example.com" readOnly={isEdit} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{isEdit && (
|
||||
<FormDescription>
|
||||
The email account address cannot be modified when editing.
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Name:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g john.doe" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Optional</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//
|
||||
// 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 {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { Account } from "./action-dialog";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import useProxyList from "@/hooks/use-proxy";
|
||||
|
||||
interface StepProps {
|
||||
isEdit: boolean;
|
||||
}
|
||||
|
||||
export default function Step2({ isEdit }: StepProps) {
|
||||
const { control } = useFormContext<Account>();
|
||||
const { proxyOptions } = useProxyList();
|
||||
const imapAuthMethod = useWatch({
|
||||
control,
|
||||
name: "imap.auth.auth_type",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-8">
|
||||
<FormField
|
||||
control={control}
|
||||
name="imap.host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
IMAP Host:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g imap.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="imap.port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
IMAP Port:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="e.g 993" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="imap.encryption"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IMAP Auth Method:</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an authentication method" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Ssl">Ssl</SelectItem>
|
||||
<SelectItem value="StartTls">StartTls</SelectItem>
|
||||
<SelectItem value="None">None</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the authentication method for IMAP.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="imap.auth.auth_type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IMAP Auth Method:</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an authentication method" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="OAuth2">OAuth2</SelectItem>
|
||||
<SelectItem value="Password">Password</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the authentication method for IMAP.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{imapAuthMethod === "Password" && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="imap.auth.password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
IMAP Password:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={isEdit ? "Leave empty to keep current password" : "Enter your password"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{isEdit && (
|
||||
<FormDescription>
|
||||
Leave empty to keep the existing password, or enter a new password to update it.
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={control}
|
||||
name='imap.use_proxy'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">Use Proxy(optional):</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(val) => field.onChange(Number(val))}
|
||||
defaultValue={field.value?.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a proxy" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{proxyOptions && proxyOptions.length > 0 ? (
|
||||
proxyOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem disabled value="__none__">No proxy available</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription className='flex-1'>
|
||||
Use a SOCKS5 proxy for IMAP connections.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//
|
||||
// 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 {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { Account } from "./action-dialog";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
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 { useState } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
export default function Step3() {
|
||||
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')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-8">
|
||||
<FormField
|
||||
control={control}
|
||||
name="sync_interval_min"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Incremental Sync(minutes):
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="e.g 300" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col items-start gap-y-1'>
|
||||
<FormLabel>Enabled:</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className='mt-2'
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Determines whether this account is active. If disabled, related syncs will not run.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Date Since:
|
||||
</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'>None</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className='flex items-center space-x-3'>
|
||||
<RadioGroupItem value='fixed' />
|
||||
<FormLabel className='font-normal'>Fixed</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className='flex items-center space-x-3'>
|
||||
<RadioGroupItem value='relative' />
|
||||
<FormLabel className='font-normal'>Relative</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
<FormDescription>defines the sync start date—either specific or relative to now. Preceding emails are excluded,{rangeType === 'fixed' ? " syncs data after a set date" : " shifts the sync date over time, syncing only recent data."}</FormDescription>
|
||||
{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-sm text-brand-marine-blue",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, "PPP")
|
||||
) : (
|
||||
<span>Pick a date</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}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select unit" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Days">Days</SelectItem>
|
||||
<SelectItem value="Months">Months</SelectItem>
|
||||
<SelectItem value="Years">Years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
<FormField
|
||||
control={control}
|
||||
name="folder_limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Folder Sync Limit:
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Limit the number of emails to sync per folder (minimum 100). Leave empty for no limit.
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g. 1000"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// 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 { useFormContext } from "react-hook-form";
|
||||
import { Account } from "./action-dialog";
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
|
||||
|
||||
export default function Step4() {
|
||||
const { getValues } = useFormContext<Account>();
|
||||
const summaryData = getValues();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-5 rounded-xl">
|
||||
<Accordion type="multiple" defaultValue={['email', 'name', 'minimal_sync', 'isolated_index', 'imap', 'smtp', 'date_since', 'folder_limit', 'sync_folders', 'language', 'sync_interval']}>
|
||||
<AccordionItem key="email" value="email">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Email:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{summaryData.email}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="name" value="name">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Name:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{summaryData.name ?? "n/a"}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="imap" value='imap'>
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Imap:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y">
|
||||
<tbody className="divide-y">
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">host:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.host}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">port:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.port}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">encryption:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.encryption}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">auth_type:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.auth.auth_type}</td>
|
||||
</tr>
|
||||
{summaryData.imap.auth.auth_type === 'Password' && (
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">password:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm break-words">
|
||||
{summaryData.imap.auth.password}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">use proxy:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.use_proxy ? "true" : "false"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="date_since" value='date_since'>
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Date Selection:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{summaryData.date_since?.fixed ?
|
||||
'since ' + summaryData.date_since.fixed
|
||||
: summaryData.date_since?.relative && summaryData.date_since.relative.value && summaryData.date_since.relative.unit ?
|
||||
'recent ' + summaryData.date_since.relative.value + ' ' + summaryData.date_since.relative.unit
|
||||
: 'n/a'}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="folder_limit" value='folder_limit'>
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Folder Sync Limit:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{summaryData.folder_limit ? summaryData.folder_limit : 'n/a'}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="sync_interval" value='sync_interval'>
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Incremental Sync Interval:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{summaryData.sync_interval_min} minutes
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//
|
||||
// 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,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { AccountModel } from '../data/schema'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { list_mailboxes } from '@/api/mailbox/api'
|
||||
import { buildTree } from '@/lib/build-tree'
|
||||
import { TreeDataItem, TreeView } from '@/components/tree-view'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { update_account } from '@/api/account/api'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentRow: AccountModel
|
||||
}
|
||||
|
||||
export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const [selectedFolders, setSelectedFolders] = useState<string[]>(currentRow.sync_folders || []);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: mailboxes, isLoading } = useQuery({
|
||||
queryKey: ['account-mailboxes', currentRow.id],
|
||||
queryFn: () => list_mailboxes(currentRow.id, true),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
// Convert mailbox names to IDs for initial selection
|
||||
const initialSelectedItemIds = useMemo(() => {
|
||||
if (!mailboxes) return [];
|
||||
return mailboxes
|
||||
.filter(mailbox => selectedFolders.includes(mailbox.name))
|
||||
.map(mailbox => mailbox.id.toString());
|
||||
}, [mailboxes, selectedFolders]);
|
||||
|
||||
// Convert data to tree structure
|
||||
const treeData = useMemo(() => {
|
||||
if (!mailboxes) return [];
|
||||
return buildTree(mailboxes, undefined, true, true);
|
||||
}, [mailboxes]);
|
||||
|
||||
const handleSelectItems = useCallback((selectedItems: TreeDataItem[]) => {
|
||||
const selected = selectedItems
|
||||
.map(item => mailboxes?.find(m => m.id === parseInt(item.id, 10))?.name)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
setSelectedFolders(selected);
|
||||
}, [mailboxes]);
|
||||
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, any>) => update_account(currentRow?.id ?? '', data),
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError
|
||||
})
|
||||
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: 'Account Sync Folders Updated',
|
||||
description: 'Account has been successfully updated.',
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['account-list'] });
|
||||
setIsSubmitting(false);
|
||||
onOpenChange(false);
|
||||
}
|
||||
function handleError(error: AxiosError) {
|
||||
const errorMessage = (error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
'Update failed, please try again later';
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: 'Account Sync Folders Update Failed',
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedFolders.length === 0) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please select at least one folder',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
updateMutation.mutate({
|
||||
sync_folders: selectedFolders,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Sync Folders</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose folders to sync for {currentRow.email}, Newly added folders will begin downloading during the next sync cycle.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedFolders.length} folder(s) selected
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<TreeView
|
||||
data={treeData}
|
||||
multiple
|
||||
expandAll
|
||||
clickRowToSelect={false}
|
||||
initialSelectedItemIds={initialSelectedItemIds}
|
||||
onSelectItemsChange={handleSelectItems}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || isLoading}
|
||||
>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//
|
||||
// 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 { AccountModel } from '../data/schema'
|
||||
import { DataTablePagination } from './data-table-pagination'
|
||||
import { DataTableToolbar } from './data-table-toolbar'
|
||||
|
||||
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<AccountModel>[]
|
||||
data: AccountModel[]
|
||||
}
|
||||
|
||||
|
||||
export function AccountTable({ columns, data }: DataTableProps) {
|
||||
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(),
|
||||
})
|
||||
|
||||
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'
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// 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 { AccountModel } from '../data/schema'
|
||||
|
||||
export type AccountDialogType = 'add-imap' | 'add-nosync' | 'edit-imap' | 'edit-nosync' | 'delete' | 'detail' | 'oauth2' | 'running-state' | 'sync-folders'
|
||||
|
||||
interface AccountContextType {
|
||||
open: AccountDialogType | null
|
||||
setOpen: (str: AccountDialogType | null) => void
|
||||
currentRow: AccountModel | null
|
||||
setCurrentRow: React.Dispatch<React.SetStateAction<AccountModel | null>>
|
||||
}
|
||||
|
||||
const AccountContext = React.createContext<AccountContextType | null>(null)
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
value: AccountContextType
|
||||
}
|
||||
|
||||
export default function AccountProvider({ children, value }: Props) {
|
||||
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>
|
||||
}
|
||||
|
||||
export const useAccountContext = () => {
|
||||
const accountContext = React.useContext(AccountContext)
|
||||
|
||||
if (!accountContext) {
|
||||
throw new Error(
|
||||
'useAccountContext has to be used within <AccountContext.Provider>'
|
||||
)
|
||||
}
|
||||
|
||||
return accountContext
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
@@ -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 { useState } from 'react'
|
||||
import useDialogState from '@/hooks/use-dialog-state'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Main } from '@/components/layout/main'
|
||||
import { AccountActionDialog } from './components/action-dialog'
|
||||
import { columns } from './components/columns'
|
||||
import { AccountDeleteDialog } from './components/delete-dialog'
|
||||
import { AccountTable } from './components/table'
|
||||
import AccountProvider, {
|
||||
type AccountDialogType,
|
||||
} 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 { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { OAuth2TokensDialog } from './components/oauth2-tokens'
|
||||
import { RunningStateDialog } from './components/running-state-dialog'
|
||||
import { FixedHeader } from '@/components/layout/fixed-header'
|
||||
import { SyncFoldersDialog } from './components/sync-folders'
|
||||
import { NoSyncAccountDialog } from './components/nosync-dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
export default function Accounts() {
|
||||
// Dialog states
|
||||
const [currentRow, setCurrentRow] = useState<AccountModel | null>(null)
|
||||
const [open, setOpen] = useDialogState<AccountDialogType>(null)
|
||||
|
||||
const { data: accountList, isLoading } = useQuery({
|
||||
queryKey: ['account-list'],
|
||||
queryFn: list_accounts,
|
||||
})
|
||||
|
||||
const hasAccounts = accountList != null && accountList.items.length > 0;
|
||||
|
||||
return (
|
||||
<AccountProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
|
||||
<FixedHeader />
|
||||
|
||||
<Main>
|
||||
<div className="mx-auto w-full max-w-7xl 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'>Email Accounts</h2>
|
||||
<p className='text-muted-foreground'>
|
||||
Manage and configure your email accounts.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Button
|
||||
onClick={() => setOpen("add-imap")}
|
||||
className="rounded-r-none border-r-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add IMAP
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-l-none border-l-0"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">More account types</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setOpen("add-nosync")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add NoSync
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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} />
|
||||
) : hasAccounts ? (
|
||||
<AccountTable data={accountList.items} 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="RustMailer Logo"
|
||||
/>
|
||||
<h3 className="mt-4 text-lg font-semibold">No Account Configurations</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
You haven't added any Account configurations yet. Add one to start using Account features.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col items-center gap-3 sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4">
|
||||
<Button variant="default" className="w-64" onClick={() => setOpen("add-imap")}>
|
||||
Add Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Main>
|
||||
|
||||
|
||||
<AccountActionDialog
|
||||
key='imap-account-add'
|
||||
open={open === 'add-imap'}
|
||||
onOpenChange={() => setOpen('add-imap')}
|
||||
/>
|
||||
|
||||
<NoSyncAccountDialog
|
||||
key='nosync-account-add'
|
||||
open={open === 'add-nosync'}
|
||||
onOpenChange={() => setOpen('add-nosync')}
|
||||
/>
|
||||
|
||||
{currentRow && (
|
||||
<>
|
||||
<AccountActionDialog
|
||||
key={`imap-account-edit-${currentRow.id}`}
|
||||
open={open === 'edit-imap'}
|
||||
onOpenChange={() => {
|
||||
setOpen('edit-imap')
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
<NoSyncAccountDialog
|
||||
key={`nosync-account-edit-${currentRow.id}`}
|
||||
open={open === 'edit-nosync'}
|
||||
onOpenChange={() => {
|
||||
setOpen('edit-nosync')
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
<RunningStateDialog
|
||||
key='running-state'
|
||||
open={open === 'running-state'}
|
||||
onOpenChange={() => {
|
||||
setOpen('running-state')
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
<AccountDeleteDialog
|
||||
key={`account-delete-${currentRow.id}`}
|
||||
open={open === 'delete'}
|
||||
onOpenChange={() => {
|
||||
setOpen('delete')
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
<SyncFoldersDialog
|
||||
key={`sync-folders-${currentRow.id}`}
|
||||
open={open === 'sync-folders'}
|
||||
onOpenChange={() => {
|
||||
setOpen('sync-folders')
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
|
||||
<AccountDetailDrawer
|
||||
open={open === 'detail'}
|
||||
onOpenChange={() => setOpen('detail')}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
|
||||
<OAuth2TokensDialog open={open === 'oauth2'}
|
||||
onOpenChange={() => setOpen('oauth2')}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AccountProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user