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>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-52
@@ -1,39 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { notFound } from "next/navigation";
|
||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { locales } from "@/i18n/routing";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "JMAP Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
// Validate that the incoming `locale` parameter is valid
|
||||
if (!(locales as readonly string[]).includes(locale)) notFound();
|
||||
|
||||
// Load messages for the current locale
|
||||
let messages;
|
||||
try {
|
||||
messages = (await import(`@/locales/${locale}/common.json`)).default;
|
||||
@@ -42,36 +22,10 @@ export default async function LocaleLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
const stored = localStorage.getItem('theme-storage');
|
||||
const theme = stored ? JSON.parse(stored).state.theme : 'system';
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const resolved = theme === 'system' ? systemTheme : theme;
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(resolved);
|
||||
} catch (e) {
|
||||
document.documentElement.classList.add('light');
|
||||
}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
</body>
|
||||
</html>
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { Mail, AlertCircle, Loader2, X } from "lucide-react";
|
||||
import { Mail, AlertCircle, Loader2, X, ShieldCheck } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -20,6 +20,8 @@ export default function LoginPage() {
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
const [showTotpField, setShowTotpField] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
|
||||
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
@@ -223,7 +225,8 @@ export default function LoginPage() {
|
||||
const success = await login(
|
||||
serverUrl,
|
||||
formData.username,
|
||||
formData.password
|
||||
formData.password,
|
||||
showTotpField && totpCode ? totpCode : undefined
|
||||
);
|
||||
|
||||
if (success) {
|
||||
@@ -250,7 +253,9 @@ export default function LoginPage() {
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t(`error.${error}`) || t("error.generic")}
|
||||
{error === 'invalid_credentials' && showTotpField
|
||||
? t('error.totp_invalid')
|
||||
: t(`error.${error}`) || t("error.generic")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -315,6 +320,34 @@ export default function LoginPage() {
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
{/* TOTP Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowTotpField(!showTotpField);
|
||||
if (showTotpField) setTotpCode("");
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
{showTotpField ? t("totp_hide") : t("totp_toggle")}
|
||||
</button>
|
||||
|
||||
{/* TOTP Input */}
|
||||
{showTotpField && (
|
||||
<Input
|
||||
id="totp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono text-lg tracking-widest"
|
||||
placeholder={t("totp_placeholder")}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
+32
-1
@@ -27,6 +27,8 @@ import {
|
||||
ComposerErrorFallback,
|
||||
} from "@/components/error";
|
||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel";
|
||||
import { isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
@@ -77,6 +79,12 @@ export default function Home() {
|
||||
clearNewEmailNotification,
|
||||
markAsSpam,
|
||||
undoSpam,
|
||||
searchFilters,
|
||||
isAdvancedSearchOpen,
|
||||
setSearchFilters,
|
||||
clearSearchFilters,
|
||||
toggleAdvancedSearch,
|
||||
advancedSearch,
|
||||
} = useEmailStore();
|
||||
|
||||
// Play notification sound for new emails
|
||||
@@ -575,16 +583,27 @@ export default function Home() {
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!client) return;
|
||||
await searchEmails(client, query);
|
||||
setSearchQuery(query);
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
await advancedSearch(client);
|
||||
} else {
|
||||
await searchEmails(client, query);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSearch = async () => {
|
||||
setSearchQuery("");
|
||||
clearSearchFilters();
|
||||
if (client && selectedMailbox) {
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdvancedSearch = async () => {
|
||||
if (!client) return;
|
||||
await advancedSearch(client);
|
||||
};
|
||||
|
||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -786,6 +805,18 @@ export default function Home() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AdvancedSearchPanel
|
||||
filters={searchFilters}
|
||||
isOpen={isAdvancedSearchOpen}
|
||||
onFiltersChange={setSearchFilters}
|
||||
onClear={() => {
|
||||
clearSearchFilters();
|
||||
if (client) advancedSearch(client);
|
||||
}}
|
||||
onSearch={handleAdvancedSearch}
|
||||
onClose={toggleAdvancedSearch}
|
||||
/>
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
<EmailList
|
||||
emails={emails}
|
||||
|
||||
@@ -9,21 +9,27 @@ import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||
import { EmailSettings } from '@/components/settings/email-settings';
|
||||
import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
import { VacationSettings } from '@/components/settings/vacation-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'advanced';
|
||||
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'advanced';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations('settings');
|
||||
const { client } = useAuthStore();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
||||
|
||||
const supportsVacation = client?.supportsVacationResponse() ?? false;
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'appearance', label: t('tabs.appearance') },
|
||||
{ id: 'email', label: t('tabs.email') },
|
||||
{ id: 'account', label: t('tabs.account') },
|
||||
{ id: 'identities', label: t('tabs.identities') },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []),
|
||||
{ id: 'advanced', label: t('tabs.advanced') },
|
||||
];
|
||||
|
||||
@@ -82,6 +88,7 @@ export default function SettingsPage() {
|
||||
{activeTab === 'email' && <EmailSettings />}
|
||||
{activeTab === 'account' && <AccountSettings />}
|
||||
{activeTab === 'identities' && <IdentitySettings />}
|
||||
{activeTab === 'vacation' && <VacationSettings />}
|
||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -13,6 +14,7 @@ import { NextResponse } from 'next/server';
|
||||
* 3. Default values
|
||||
*/
|
||||
export async function GET() {
|
||||
logger.debug('Config requested');
|
||||
return NextResponse.json({
|
||||
appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail',
|
||||
jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Health check thresholds
|
||||
const MEMORY_WARNING_THRESHOLD = 0.85; // 85% heap usage
|
||||
@@ -86,6 +87,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Health check', { status, detailed });
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: httpStatus,
|
||||
headers: {
|
||||
@@ -96,6 +99,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Health check failed', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'unhealthy',
|
||||
|
||||
+58
-8
@@ -1,11 +1,61 @@
|
||||
import { ReactNode } from 'react';
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import "./globals.css";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "JMAP Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
};
|
||||
|
||||
// This is the root layout that wraps all pages
|
||||
// The actual layout with providers and styles is in [locale]/layout.tsx
|
||||
export default function RootLayout({ children }: Props) {
|
||||
return children;
|
||||
}
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const locale = await getLocale();
|
||||
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
nonce={nonce}
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
const stored = localStorage.getItem('theme-storage');
|
||||
const theme = stored ? JSON.parse(stored).state.theme : 'system';
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const resolved = theme === 'system' ? systemTheme : theme;
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(resolved);
|
||||
} catch (e) {
|
||||
document.documentElement.classList.add('light');
|
||||
}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user