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
This commit is contained in:
+383
-39
@@ -1,19 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
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 { useContactStore } from "@/stores/contact-store";
|
||||
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";
|
||||
type View =
|
||||
| "list"
|
||||
| "detail"
|
||||
| "create"
|
||||
| "edit"
|
||||
| "group-detail"
|
||||
| "group-create"
|
||||
| "group-edit"
|
||||
| "import"
|
||||
| "bulk-add-to-group";
|
||||
|
||||
export default function ContactsPage() {
|
||||
const router = useRouter();
|
||||
@@ -24,8 +39,11 @@ export default function ContactsPage() {
|
||||
selectedContactId,
|
||||
searchQuery,
|
||||
supportsSync,
|
||||
activeTab,
|
||||
selectedContactIds,
|
||||
setSelectedContact,
|
||||
setSearchQuery,
|
||||
setActiveTab,
|
||||
fetchContacts,
|
||||
createContact,
|
||||
updateContact,
|
||||
@@ -33,9 +51,24 @@ export default function ContactsPage() {
|
||||
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(() => {
|
||||
@@ -51,10 +84,15 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [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");
|
||||
};
|
||||
|
||||
@@ -79,7 +117,8 @@ export default function ContactsPage() {
|
||||
}
|
||||
toast.success(t("toast.deleted"));
|
||||
setView("list");
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error('Failed to delete contact:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
};
|
||||
@@ -114,57 +153,362 @@ export default function ContactsPage() {
|
||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setView(selectedContact ? "detail" : "list");
|
||||
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;
|
||||
|
||||
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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/")}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t("back_to_mail")}
|
||||
</Button>
|
||||
</div>
|
||||
const renderRightPanel = () => {
|
||||
switch (view) {
|
||||
case "create":
|
||||
return <ContactForm onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
|
||||
<ContactList
|
||||
contacts={contacts}
|
||||
selectedContactId={selectedContactId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
supportsSync={supportsSync}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{view === "create" && (
|
||||
<ContactForm onSave={handleSaveNew} onCancel={handleCancel} />
|
||||
)}
|
||||
{view === "edit" && selectedContact && (
|
||||
case "edit":
|
||||
if (!selectedContact) return null;
|
||||
return (
|
||||
<ContactForm
|
||||
contact={selectedContact}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
{(view === "list" || view === "detail") && (
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user