Files
SRCmail/app/[locale]/contacts/page.tsx
T
Matthieu MALVACHEandMatthieu MALVACHE fb414bf2c9 feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA
- Contact groups/lists, vCard import/export (RFC 6350), bulk operations
- Advanced search with JMAP filter panel, search chips, cross-mailbox queries
- Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator
- TOTP two-factor authentication support
- Docker multi-stage build with standalone output and docker-compose
- CSP Report-Only headers and security headers via proxy middleware
- Virtual scrolling for large email lists
- Structured server-side logger (text/JSON, configurable level)
- 450+ tests (contacts, vCard, threads, headers, identity, components)
- Playwright E2E framework setup
- Updated README and ROADMAP with all new features
2026-02-16 18:51:30 +01:00

515 lines
16 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { ArrowLeft, Upload, Download, Users, BookUser } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ContactList } from "@/components/contacts/contact-list";
import { ContactDetail } from "@/components/contacts/contact-detail";
import { ContactForm } from "@/components/contacts/contact-form";
import { ContactGroupList } from "@/components/contacts/contact-group-list";
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
type View =
| "list"
| "detail"
| "create"
| "edit"
| "group-detail"
| "group-create"
| "group-edit"
| "import"
| "bulk-add-to-group";
export default function ContactsPage() {
const router = useRouter();
const t = useTranslations("contacts");
const { client, isAuthenticated } = useAuthStore();
const {
contacts,
selectedContactId,
searchQuery,
supportsSync,
activeTab,
selectedContactIds,
setSelectedContact,
setSearchQuery,
setActiveTab,
fetchContacts,
createContact,
updateContact,
deleteContact,
addLocalContact,
updateLocalContact,
deleteLocalContact,
getGroups,
getIndividuals,
getGroupMembers,
createGroup,
updateGroup,
addMembersToGroup,
removeMembersFromGroup,
deleteGroup,
toggleContactSelection,
selectAllContacts,
clearSelection,
bulkDeleteContacts,
bulkAddToGroup,
importContacts,
} = useContactStore();
const [view, setView] = useState<View>("list");
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const hasFetched = useRef(false);
useEffect(() => {
if (!isAuthenticated) {
router.push("/login");
}
}, [isAuthenticated, router]);
useEffect(() => {
if (client && supportsSync && !hasFetched.current) {
hasFetched.current = true;
fetchContacts(client);
}
}, [client, supportsSync, fetchContacts]);
const groups = useMemo(() => getGroups(), [contacts]);
const individuals = useMemo(() => getIndividuals(), [contacts]);
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
const handleSelectContact = (id: string) => {
setSelectedContact(id);
clearSelection();
setView("detail");
};
const handleCreateNew = () => {
setSelectedContact(null);
setView("create");
};
const handleEdit = () => {
setView("edit");
};
const handleDelete = async () => {
if (!selectedContact) return;
if (!window.confirm(t("delete_confirm"))) return;
try {
if (supportsSync && client) {
await deleteContact(client, selectedContact.id);
} else {
deleteLocalContact(selectedContact.id);
}
toast.success(t("toast.deleted"));
setView("list");
} catch (error) {
console.error('Failed to delete contact:', error);
toast.error(t("toast.error_delete"));
}
};
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
if (supportsSync && client) {
await createContact(client, data);
toast.success(t("toast.created"));
} else {
const localContact: ContactCard = {
id: `local-${crypto.randomUUID()}`,
addressBookIds: {},
...data,
};
addLocalContact(localContact);
toast.success(t("toast.created"));
}
setView("list");
}, [supportsSync, client, createContact, addLocalContact, t]);
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
if (!selectedContact) return;
if (supportsSync && client) {
await updateContact(client, selectedContact.id, data);
toast.success(t("toast.updated"));
} else {
updateLocalContact(selectedContact.id, data);
toast.success(t("toast.updated"));
}
setView("detail");
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
const handleCancel = () => {
if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list");
} else if (view === "import") {
setView("list");
} else if (view === "bulk-add-to-group") {
setView("list");
} else {
setView(selectedContact ? "detail" : "list");
}
};
const handleSelectGroup = (id: string) => {
setSelectedGroupId(id);
setView("group-detail");
};
const handleCreateGroup = () => {
setSelectedGroupId(null);
setView("group-create");
};
const handleEditGroup = () => {
setView("group-edit");
};
const handleDeleteGroup = async () => {
if (!selectedGroup) return;
if (!window.confirm(t("groups.delete_confirm"))) return;
try {
await deleteGroup(supportsSync && client ? client : null, selectedGroup.id);
toast.success(t("toast.deleted"));
setSelectedGroupId(null);
setView("list");
} catch (error) {
console.error('Failed to delete group:', error);
toast.error(t("toast.error_delete"));
}
};
const handleSaveGroup = useCallback(async (name: string, memberIds: string[]) => {
const jmapClient = supportsSync && client ? client : null;
if (view === "group-edit" && selectedGroup) {
await updateGroup(jmapClient, selectedGroup.id, name);
const currentMemberIds = selectedGroup.members
? Object.keys(selectedGroup.members).filter(k => selectedGroup.members![k])
: [];
const toAdd = memberIds.filter(id => !currentMemberIds.includes(id));
const toRemove = currentMemberIds.filter(id => !memberIds.includes(id));
if (toAdd.length > 0) await addMembersToGroup(jmapClient, selectedGroup.id, toAdd);
if (toRemove.length > 0) await removeMembersFromGroup(jmapClient, selectedGroup.id, toRemove);
toast.success(t("toast.updated"));
setView("group-detail");
} else {
await createGroup(jmapClient, name, memberIds);
toast.success(t("toast.created"));
setView("list");
}
}, [view, selectedGroup, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]);
const handleRemoveGroupMember = async (memberId: string) => {
if (!selectedGroup) return;
try {
await removeMembersFromGroup(
supportsSync && client ? client : null,
selectedGroup.id,
[memberId]
);
toast.success(t("toast.updated"));
} catch (error) {
console.error('Failed to remove group member:', error);
toast.error(t("toast.error_update"));
}
};
const handleBulkDelete = async () => {
if (selectedContactIds.size === 0) return;
if (!window.confirm(t("bulk.delete_confirm", { count: selectedContactIds.size }))) return;
try {
await bulkDeleteContacts(
supportsSync && client ? client : null,
Array.from(selectedContactIds)
);
toast.success(t("bulk.deleted", { count: selectedContactIds.size }));
setView("list");
} catch (error) {
console.error('Failed to bulk delete contacts:', error);
toast.error(t("toast.error_delete"));
}
};
const handleBulkAddToGroup = () => {
if (selectedContactIds.size === 0) return;
if (groups.length === 0) {
setView("group-create");
return;
}
setView("bulk-add-to-group");
};
const handleBulkExport = () => {
const toExport = contacts.filter(c => selectedContactIds.has(c.id));
if (toExport.length > 0) {
exportContacts(toExport);
toast.success(t("export.success", { count: toExport.length }));
clearSelection();
}
};
const handleBulkAddToGroupConfirm = async (groupId: string) => {
try {
await bulkAddToGroup(
supportsSync && client ? client : null,
groupId,
Array.from(selectedContactIds)
);
toast.success(t("bulk.added_to_group"));
setView("list");
} catch (error) {
console.error('Failed to add contacts to group:', error);
toast.error(t("toast.error_update"));
}
};
const handleImport = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
importedContacts
);
}, [supportsSync, client, importContacts]);
if (!isAuthenticated) return null;
const renderRightPanel = () => {
switch (view) {
case "create":
return <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit":
if (!selectedContact) return null;
return (
<ContactForm
contact={selectedContact}
onSave={handleSaveEdit}
onCancel={handleCancel}
/>
);
case "group-detail":
if (!selectedGroup) return null;
return (
<ContactGroupDetail
group={selectedGroup}
members={selectedGroupMembers}
onEdit={handleEditGroup}
onDelete={handleDeleteGroup}
onRemoveMember={handleRemoveGroupMember}
onSelectMember={(id) => {
setSelectedContact(id);
setActiveTab("all");
setView("detail");
}}
/>
);
case "group-create":
return (
<ContactGroupForm
individuals={individuals}
onSave={handleSaveGroup}
onCancel={handleCancel}
/>
);
case "group-edit":
if (!selectedGroup) return null;
return (
<ContactGroupForm
group={selectedGroup}
individuals={individuals}
currentMemberIds={selectedGroupMembers.map(m => m.id)}
onSave={handleSaveGroup}
onCancel={handleCancel}
/>
);
case "import":
return (
<ContactImportDialog
existingContacts={contacts}
onImport={handleImport}
onClose={handleCancel}
/>
);
case "bulk-add-to-group":
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{t("bulk.choose_group")}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("bulk.adding_contacts", { count: selectedContactIds.size })}
</p>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border">
{groups.map((group) => {
const gName = getContactDisplayName(group);
const memberCount = group.members
? Object.values(group.members).filter(Boolean).length
: 0;
return (
<button
key={group.id}
onClick={() => handleBulkAddToGroupConfirm(group.id)}
className="w-full flex items-center gap-3 px-6 py-3 text-left hover:bg-muted transition-colors"
>
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Users className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{gName}</div>
<div className="text-xs text-muted-foreground">
{t("groups.member_count", { count: memberCount })}
</div>
</div>
</button>
);
})}
</div>
<div className="px-6 py-4 border-t border-border">
<Button variant="outline" onClick={handleCancel} className="w-full">
{t("form.cancel")}
</Button>
</div>
</div>
);
default:
return (
<ContactDetail
contact={selectedContact}
onEdit={handleEdit}
onDelete={handleDelete}
/>
);
}
};
return (
<div className="flex h-screen bg-background">
<div className="w-80 border-r border-border flex flex-col">
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
</Button>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setView("import")}
title={t("import.title")}
>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
if (contacts.length > 0) {
exportContacts(contacts.filter(c => c.kind !== "group"));
toast.success(t("export.success", { count: contacts.filter(c => c.kind !== "group").length }));
}
}}
title={t("export.title")}
>
<Download className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab("all")}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors",
activeTab === "all"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<BookUser className="w-4 h-4" />
{t("tabs.all")}
</button>
<button
onClick={() => setActiveTab("groups")}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors",
activeTab === "groups"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<Users className="w-4 h-4" />
{t("tabs.groups")}
{groups.length > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-muted">
{groups.length}
</span>
)}
</button>
</div>
{activeTab === "all" ? (
<ContactList
contacts={contacts}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
supportsSync={supportsSync}
className="flex-1"
selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection}
onSelectAll={selectAllContacts}
onClearSelection={clearSelection}
onBulkDelete={handleBulkDelete}
onBulkAddToGroup={handleBulkAddToGroup}
onBulkExport={handleBulkExport}
/>
) : (
<ContactGroupList
groups={groups}
selectedGroupId={selectedGroupId}
onSelectGroup={handleSelectGroup}
onCreateGroup={handleCreateGroup}
searchQuery={searchQuery}
className="flex-1"
/>
)}
</div>
<div className="flex-1">
{renderRightPanel()}
</div>
</div>
);
}