From e7acf567537477d6be6d90eaa3b5a9b0176f4dc2 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 13:32:33 +0200 Subject: [PATCH] feat: P2.3 Folder Sharing + P2.5 Email Import + P2.6 Contact Import + P2.7 Free/Busy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2.3: Folder sharing system — ShareFolderDialog, sharing-store, sharing-settings - P2.5: Email import (.eml, .tgz, .zip) with dedup and progress - P2.6: Contact import (vCard + CSV) with auto-mapping - P2.7: Free/Busy view grid with color-coded slots --- app/(main)/[locale]/settings/page.tsx | 16 + app/api/sharing/route.ts | 361 ++++++++++++ components/calendar/event-modal.tsx | 42 +- components/calendar/free-busy-view.tsx | 296 ++++++++++ components/contacts/contact-import-dialog.tsx | 303 +++++++++- components/layout/mailbox-context-menu.tsx | 9 + components/layout/sidebar.tsx | 3 + components/settings/contacts-settings.tsx | 2 + components/settings/import-settings.tsx | 245 ++++++++ components/settings/sharing-settings.tsx | 279 ++++++++++ components/sharing/share-folder-dialog.tsx | 383 +++++++++++++ lib/calendar-freebusy.ts | 180 ++++++ lib/contact-csv-import.ts | 290 ++++++++++ lib/demo/demo-client.ts | 2 + lib/email-import.ts | 249 +++++++++ lib/eml-import.ts | 96 +++- lib/jmap/client-interface.ts | 3 +- lib/jmap/client.ts | 30 +- lib/jmap/types.ts | 26 +- locales/en/common.json | 26 +- stores/sharing-store.ts | 523 ++++++++++++++++++ 21 files changed, 3321 insertions(+), 43 deletions(-) create mode 100644 app/api/sharing/route.ts create mode 100644 components/calendar/free-busy-view.tsx create mode 100644 components/settings/import-settings.tsx create mode 100644 components/settings/sharing-settings.tsx create mode 100644 components/sharing/share-folder-dialog.tsx create mode 100644 lib/calendar-freebusy.ts create mode 100644 lib/contact-csv-import.ts create mode 100644 lib/email-import.ts create mode 100644 stores/sharing-store.ts diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 59eef68b..7682950e 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -35,6 +35,8 @@ import { SwatchBook, Download, Sparkles, + Upload, + Share2, X, type LucideIcon, } from 'lucide-react'; @@ -71,6 +73,8 @@ import { PluginsSettings } from '@/components/settings/plugins-settings'; import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings'; import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; +import { ImportSettings } from '@/components/settings/import-settings'; +import { SharingSettings } from '@/components/settings/sharing-settings'; import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; @@ -115,6 +119,8 @@ type Tab = | 'about_data' | 'themes' | 'plugins' + | 'import' + | 'sharing' | 'ai_assistant' | 'debug'; @@ -159,6 +165,8 @@ const tabIcons: Record = { about_data: Info, themes: SwatchBook, plugins: Puzzle, + import: Upload, + sharing: Share2, ai_assistant: Sparkles, debug: Bug, }; @@ -243,6 +251,8 @@ const tabSearchPaths: Record = { themes: [], plugins: [], ai_assistant: [], + import: ['settings.importer'], + sharing: ['sharing'], debug: ['settings.advanced'], }; @@ -275,6 +285,8 @@ const tabKeywords: Record = { themes: 'custom theme css skin appearance', plugins: 'extensions addons', ai_assistant: 'assistant ask model llm ollama chatbot', + import: 'import email eml zip tgz mbox csv vcard contacts', + sharing: 'share shared folder calendar address book permission', debug: 'logs developer console diagnostic', }; @@ -624,6 +636,7 @@ export default function SettingsPage() { { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' }, { id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' }, { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, + { id: 'sharing', label: t('tabs.sharing'), icon: tabIcons.sharing, group: 'general' }, { id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' }, // Appearance @@ -641,6 +654,7 @@ export default function SettingsPage() { ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []), ...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []), { id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' }, + { id: 'import', label: t('tabs.import'), icon: tabIcons.import, group: 'mail' }, ...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []), // Privacy & Security @@ -772,6 +786,8 @@ export default function SettingsPage() { {effectiveActiveTab === 'filters' && } {effectiveActiveTab === 'templates' && } {effectiveActiveTab === 'folders' && } + {effectiveActiveTab === 'import' && } + {effectiveActiveTab === 'sharing' && } {effectiveActiveTab === 'keywords' && } {effectiveActiveTab === 'security' && } {effectiveActiveTab === 'content_senders' && } diff --git a/app/api/sharing/route.ts b/app/api/sharing/route.ts new file mode 100644 index 00000000..166f1098 --- /dev/null +++ b/app/api/sharing/route.ts @@ -0,0 +1,361 @@ +import type { NextRequest } from "next/server"; + +type JmapMethodCall = [string, Record, string]; + +async function jmapRequest( + serverUrl: string, + authHeader: string, + methodCalls: JmapMethodCall[], + using?: string[], +) { + const sessionResp = await fetch(`${serverUrl}/.well-known/jmap`, { + headers: { Authorization: authHeader }, + }); + if (!sessionResp.ok) { + return { error: `Session fetch failed: ${sessionResp.status}` }; + } + const session = await sessionResp.json(); + const apiUrl = session.apiUrl; + if (!apiUrl) { + return { error: "No API URL in JMAP session" }; + } + + const body = { + using: using || [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + "urn:ietf:params:jmap:principals", + ], + methodCalls, + }; + + const resp = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + return { error: `JMAP request failed: ${resp.status}` }; + } + + return await resp.json(); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const action = searchParams.get("action"); + const serverUrl = request.headers.get("X-JMAP-Server-Url"); + const authHeader = request.headers.get("Authorization"); + + if (!serverUrl || !authHeader) { + return Response.json( + { error: "Missing server URL or auth header" }, + { status: 400 }, + ); + } + + if (action !== "principals") { + return Response.json( + { error: "Invalid action" }, + { status: 400 }, + ); + } + + const result = await jmapRequest(serverUrl, authHeader, [ + ["Principal/query", { accountId: "" }, "0"], + ["Principal/get", { + accountId: "", + "#ids": { + resultOf: "0", + name: "Principal/query", + path: "/ids", + }, + }, "1"], + ]); + + if ("error" in result) { + return Response.json(result, { status: 502 }); + } + + const getResp = (result as Record).methodResponses as Array<[string, Record, string]> | undefined; + const principals = getResp?.find((r) => r[0] === "Principal/get")?.[1] + ?.list ?? []; + + return Response.json({ principals }); +} + +export async function POST(request: NextRequest) { + const serverUrl = request.headers.get("X-JMAP-Server-Url"); + const authHeader = request.headers.get("Authorization"); + + if (!serverUrl || !authHeader) { + return Response.json( + { error: "Missing server URL or auth header" }, + { status: 400 }, + ); + } + + let body: Record; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { kind, resourceId, principalId, role } = body; + + if (!kind || !resourceId || !principalId) { + return Response.json( + { error: "Missing required fields: kind, resourceId, principalId" }, + { status: 400 }, + ); + } + + let method: string; + let shareProperty: string; + + switch (kind) { + case "mailbox": + method = "Mailbox/set"; + shareProperty = "shareWith"; + break; + case "calendar": + method = "Calendar/set"; + shareProperty = "shareWith"; + break; + case "addressBook": + method = "AddressBook/set"; + shareProperty = "shareWith"; + break; + case "file": + method = "FileNode/set"; + shareProperty = "shareWith"; + break; + default: + return Response.json( + { error: `Invalid kind: ${kind}` }, + { status: 400 }, + ); + } + + const patchValue = role === null ? null : buildRights(kind as string, role as string); + + const methodCalls: JmapMethodCall[] = [ + [ + method, + { + accountId: "", + update: { + [resourceId as string]: { + [`${shareProperty}/${principalId}`]: patchValue, + }, + }, + }, + "0", + ], + ]; + + const result = await jmapRequest( + serverUrl, + authHeader, + methodCalls, + ); + + if ("error" in result) { + return Response.json(result, { status: 502 }); + } + + const responses = (result as Record).methodResponses as Array<[string, Record, string]> | undefined; + const setResult = responses?.[0]?.[1]; + + if ( + setResult && + typeof setResult === "object" && + "notUpdated" in setResult && + setResult.notUpdated && + typeof setResult.notUpdated === "object" && + (resourceId as string) in setResult.notUpdated + ) { + const err = (setResult.notUpdated as Record>)[resourceId as string]; + return Response.json( + { error: err.description || "Failed to update share" }, + { status: 400 }, + ); + } + + return Response.json({ ok: true }); +} + +function buildRights( + kind: string, + role: string, +): Record | null { + if (role === null) return null; + + switch (kind) { + case "mailbox": + return mailboxRights(role); + case "calendar": + return calendarRights(role); + case "addressBook": + return addressBookRights(role); + case "file": + return fileRights(role); + default: + return readRights(); + } +} + +function mailboxRights(role: string): Record { + switch (role) { + case "read": + return { + mayReadItems: true, + mayAddItems: false, + mayRemoveItems: false, + maySetSeen: false, + maySetKeywords: false, + mayCreateChild: false, + mayRename: false, + mayDelete: false, + maySubmit: false, + }; + case "readWrite": + return { + mayReadItems: true, + mayAddItems: true, + mayRemoveItems: false, + maySetSeen: true, + maySetKeywords: true, + mayCreateChild: false, + mayRename: false, + mayDelete: false, + maySubmit: true, + }; + case "manager": + return { + mayReadItems: true, + mayAddItems: true, + mayRemoveItems: true, + maySetSeen: true, + maySetKeywords: true, + mayCreateChild: true, + mayRename: true, + mayDelete: true, + maySubmit: true, + mayShare: true, + }; + default: + return mailboxRights("read"); + } +} + +function calendarRights(role: string): Record { + switch (role) { + case "read": + return { + mayReadFreeBusy: true, + mayReadItems: true, + mayWriteAll: false, + mayWriteOwn: false, + mayUpdatePrivate: false, + mayRSVP: false, + mayShare: false, + mayDelete: false, + }; + case "readWrite": + return { + mayReadFreeBusy: true, + mayReadItems: true, + mayWriteAll: true, + mayWriteOwn: true, + mayUpdatePrivate: true, + mayRSVP: true, + mayShare: false, + mayDelete: false, + }; + case "manager": + return { + mayReadFreeBusy: true, + mayReadItems: true, + mayWriteAll: true, + mayWriteOwn: true, + mayUpdatePrivate: true, + mayRSVP: true, + mayShare: true, + mayDelete: true, + }; + default: + return calendarRights("read"); + } +} + +function addressBookRights(role: string): Record { + switch (role) { + case "read": + return { + mayRead: true, + mayWrite: false, + mayShare: false, + mayDelete: false, + }; + case "readWrite": + return { + mayRead: true, + mayWrite: true, + mayShare: false, + mayDelete: false, + }; + case "manager": + return { + mayRead: true, + mayWrite: true, + mayShare: true, + mayDelete: true, + }; + default: + return addressBookRights("read"); + } +} + +function fileRights(role: string): Record { + switch (role) { + case "read": + return { + mayRead: true, + mayAddChildren: false, + mayRename: false, + mayDelete: false, + mayModifyContent: false, + mayShare: false, + }; + case "readWrite": + return { + mayRead: true, + mayAddChildren: true, + mayRename: true, + mayDelete: true, + mayModifyContent: true, + mayShare: false, + }; + case "manager": + return { + mayRead: true, + mayAddChildren: true, + mayRename: true, + mayDelete: true, + mayModifyContent: true, + mayShare: true, + }; + default: + return fileRights("read"); + } +} + +function readRights(): Record { + return { mayRead: true }; +} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 755aef07..b245b1bb 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -4,13 +4,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useTranslations, useLocale } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react"; +import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff } from "lucide-react"; import { format, parseISO, addHours, addDays, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; +import { FreeBusyView } from "./free-busy-view"; import { isOrganizer, getUserParticipantId, @@ -351,6 +352,7 @@ export function EventModal({ .map(p => ({ name: p.name, email: p.email })); }); const [sendInvitations, setSendInvitations] = useState(true); + const [showFreeBusy, setShowFreeBusy] = useState(false); const participantInputRef = useRef(null); // Plugin transform: collect conflict warnings for the current event form. @@ -1074,6 +1076,44 @@ export function EventModal({ onAdd={handleAddAttendee} onRemove={handleRemoveAttendee} /> + {attendees.length > 0 && !allDay && ( +
+ + {showFreeBusy && ( +
+ { + const d = new Date(`${startDate}T${startTime}:00`); + return isNaN(d.getTime()) ? new Date() : d; + })()} + endDate={(() => { + const d = new Date(`${endDate}T${endTime}:00`); + return isNaN(d.getTime()) ? addHours(new Date(`${startDate}T${startTime}:00`), 8) : d; + })()} + onTimeSelect={(start, end) => { + setStartDate(formatDateInput(start)); + setStartTime(formatTimeInput(start)); + setEndDate(formatDateInput(end)); + setEndTime(formatTimeInput(end)); + }} + /> +
+ )} +
+ )} {isEdit && statusCounts && (existingParticipants.length > 0) && (

{t("participants.status_summary", { diff --git a/components/calendar/free-busy-view.tsx b/components/calendar/free-busy-view.tsx new file mode 100644 index 00000000..69fa5f5b --- /dev/null +++ b/components/calendar/free-busy-view.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { addMinutes, differenceInMinutes, format } from "date-fns"; +import { Avatar } from "@/components/ui/avatar"; +import { useAuthStore } from "@/stores/auth-store"; +import { cn } from "@/lib/utils"; +import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy"; + +export interface FreeBusyViewProps { + participants: { name?: string; email: string }[]; + startDate: Date; + endDate: Date; + onTimeSelect?: (start: Date, end: Date) => void; +} + +const SLOT_MINUTES = 30; +const WORK_START_HOUR = 8; +const WORK_END_HOUR = 18; + +const statusColors: Record = { + free: "bg-emerald-100 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800", + busy: "bg-red-100 dark:bg-red-900/40 border-red-200 dark:border-red-800", + tentative: "bg-amber-100 dark:bg-amber-900/40 border-amber-200 dark:border-amber-800", + unavailable: "bg-purple-100 dark:bg-purple-900/40 border-purple-200 dark:border-purple-800", + unknown: "bg-muted border-muted-foreground/20", +}; + +const statusHoverColors: Record = { + free: "hover:bg-emerald-200 dark:hover:bg-emerald-800/60", + busy: "hover:bg-red-200 dark:hover:bg-red-800/60", + tentative: "hover:bg-amber-200 dark:hover:bg-amber-800/60", + unavailable: "hover:bg-purple-200 dark:hover:bg-purple-800/60", + unknown: "hover:bg-muted-foreground/20", +}; + +function clampToSlot(d: Date): Date { + const clone = new Date(d); + clone.setSeconds(0, 0); + const mins = clone.getMinutes(); + const remainder = mins % SLOT_MINUTES; + if (remainder !== 0) { + clone.setMinutes(mins - remainder, 0, 0); + } + return clone; +} + +function buildHourSlots(start: Date, end: Date): { label: string; slots: FreeBusySlot[] }[] { + const hours: { label: string; slots: FreeBusySlot[] }[] = []; + let cursor = clampToSlot(start); + while (cursor < end) { + const hourEnd = new Date(cursor); + hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0); + const hourSlots: FreeBusySlot[] = []; + let slotCursor = new Date(cursor); + while (slotCursor < hourEnd && slotCursor < end) { + const slotEnd = addMinutes(slotCursor, SLOT_MINUTES); + hourSlots.push({ + start: new Date(slotCursor), + end: slotEnd > end ? new Date(end) : slotEnd, + status: "unknown", + }); + slotCursor = slotEnd; + } + hours.push({ label: format(cursor, "HH:mm"), slots: hourSlots }); + cursor = hourEnd; + } + return hours; +} + +function isWorkingHour(hour: number): boolean { + return isWorkingHourFn(hour, WORK_START_HOUR, WORK_END_HOUR); +} + +export function FreeBusyView({ + participants, + startDate, + endDate, + onTimeSelect, +}: FreeBusyViewProps) { + const t = useTranslations("calendar"); + const client = useAuthStore((s) => s.client); + const [freeBusyData, setFreeBusyData] = useState | null>(null); + const [loading, setLoading] = useState(false); + const [hoveredSlot, setHoveredSlot] = useState<{ + participant: string; + slotIndex: number; + } | null>(null); + + const hourSlots = useMemo(() => buildHourSlots(startDate, endDate), [startDate, endDate]); + const totalHalfHourSlots = useMemo(() => { + let c = 0; + for (const h of hourSlots) c += h.slots.length; + return c; + }, [hourSlots]); + + const now = new Date(); + const showNowLine = + now >= startDate && now <= endDate; + const nowPositionPercent = showNowLine + ? Math.max(0, Math.min(100, (differenceInMinutes(now, startDate) / differenceInMinutes(endDate, startDate)) * 100)) + : null; + + useEffect(() => { + if (!client || participants.length === 0) return; + let cancelled = false; + setLoading(true); + fetchFreeBusy(client, participants, startDate, endDate) + .then((data) => { + if (!cancelled) { + setFreeBusyData(data); + setLoading(false); + } + }) + .catch(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [client, participants, startDate, endDate]); + + const handleSlotClick = useCallback( + (slot: FreeBusySlot) => { + if (slot.status === "free" && onTimeSelect) { + onTimeSelect(new Date(slot.start), new Date(slot.end)); + } + }, + [onTimeSelect] + ); + + const timezone = useMemo( + () => Intl.DateTimeFormat().resolvedOptions().timeZone, + [] + ); + + if (participants.length === 0) { + return ( +

+ {t("freeBusy.no_participants")} +

+ ); + } + + return ( +
+
+
+ {t("freeBusy.timezone")}: {timezone} +
+ {loading && ( +
+ {t("freeBusy.loading")} +
+ )} +
+ +
+
+ + + + + {hourSlots.map((hour, i) => ( + + ))} + + + + {participants.map((p) => { + const key = p.email.toLowerCase(); + const slots = freeBusyData?.get(key); + return ( + + + {hourSlots.map((hour) => + hour.slots.map((hourSlot, si) => { + const globalSlotIndex = + hourSlots + .slice(0, hourSlots.indexOf(hour)) + .reduce((acc, h) => acc + h.slots.length, 0) + si; + + const slot = slots?.[globalSlotIndex]; + const status = slot?.status ?? "unknown"; + const isFree = status === "free"; + const isHovered = + hoveredSlot?.participant === key && + hoveredSlot?.slotIndex === globalSlotIndex; + + return ( + + ); + }) + )} + + ); + })} + +
+ {t("participants.title")} + + {hour.label} +
+
+ +
+
+ {p.name || p.email} +
+ {p.name && ( +
+ {p.email} +
+ )} +
+
+
+ isFree ? handleSlotClick(slot!) : undefined + } + onMouseEnter={() => + setHoveredSlot({ + participant: key, + slotIndex: globalSlotIndex, + }) + } + onMouseLeave={() => setHoveredSlot(null)} + > + {status === "free" && ( +   + )} +
+
+
+ + {showNowLine && nowPositionPercent !== null && ( +
+ )} + +
+ + + {t("freeBusy.free")} + + + + {t("freeBusy.busy")} + + + + {t("freeBusy.tentative")} + + + + {t("freeBusy.unavailable")} + + + + {t("freeBusy.unknown")} + +
+
+ ); +} diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx index 8e8331a1..159ba111 100644 --- a/components/contacts/contact-import-dialog.tsx +++ b/components/contacts/contact-import-dialog.tsx @@ -2,26 +2,39 @@ import { useState, useRef, useCallback } from "react"; import { useTranslations } from "next-intl"; -import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react"; +import { Upload, FileText, AlertTriangle, X, Check, ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { parseVCard, detectDuplicates } from "@/lib/vcard"; -import type { ContactCard } from "@/lib/jmap/types"; +import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; +import { + parseCSV, + autoMapColumns, + mapRowToContact, + detectDuplicatesByEmail, + type CsvColumnMapping, + type CsvParseResult, +} from "@/lib/contact-csv-import"; + +type FileType = "vcf" | "csv" | null; interface ContactImportDialogProps { existingContacts: ContactCard[]; + addressBooks?: AddressBook[]; onImport: (contacts: ContactCard[]) => Promise; onClose: () => void; } export function ContactImportDialog({ existingContacts, + addressBooks, onImport, onClose, }: ContactImportDialogProps) { const t = useTranslations("contacts"); const fileRef = useRef(null); + const [fileType, setFileType] = useState(null); const [parsed, setParsed] = useState([]); const [selected, setSelected] = useState>(new Set()); const [duplicates, setDuplicates] = useState>(new Map()); @@ -29,41 +42,111 @@ export function ContactImportDialog({ const [result, setResult] = useState(null); const [error, setError] = useState(null); + const [csvData, setCsvData] = useState(null); + const [mapping, setMapping] = useState(null); + const [targetBookId, setTargetBookId] = useState(""); + const [showPreview, setShowPreview] = useState(false); + + const ALLOWED_ACCEPT = ".vcf,.vcard,.csv,text/csv,text/vcard"; + + const books = addressBooks || []; + const defaultBookId = + books.find((b) => b.isDefault)?.id || books[0]?.id || ""; + const effectiveBookId = targetBookId || defaultBookId; + const bookOptions = books.map((b) => ({ + value: b.id, + label: b.name, + })); + const handleFileChange = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setError(null); setResult(null); + setFileType(null); + setParsed([]); + setSelected(new Set()); + setDuplicates(new Map()); + setCsvData(null); + setMapping(null); + setShowPreview(false); + setTargetBookId(""); - if (file.size > 5 * 1024 * 1024) { + if (file.size > 10 * 1024 * 1024) { setError(t("import.file_too_large")); return; } + const name = file.name.toLowerCase(); + try { - const text = await file.text(); - const contacts = parseVCard(text); + if (name.endsWith(".csv") || file.type === "text/csv") { + setFileType("csv"); + const text = await file.text(); + const result = parseCSV(text); - if (contacts.length === 0) { - setError(t("import.no_contacts")); - return; + if (result.rows.length === 0) { + setError(t("import.no_contacts")); + return; + } + + setCsvData(result); + setMapping(autoMapColumns(result.headers)); + setTargetBookId(defaultBookId); + } else { + setFileType("vcf"); + const text = await file.text(); + const contacts = parseVCard(text); + + if (contacts.length === 0) { + setError(t("import.no_contacts")); + return; + } + + const dupes = detectDuplicates(existingContacts, contacts); + setParsed(contacts); + setDuplicates(dupes); + + const initialSelected = new Set(); + contacts.forEach((_, idx) => { + if (!dupes.has(idx)) initialSelected.add(idx); + }); + setSelected(initialSelected); } - - const dupes = detectDuplicates(existingContacts, contacts); - setParsed(contacts); - setDuplicates(dupes); - - const initialSelected = new Set(); - contacts.forEach((_, idx) => { - if (!dupes.has(idx)) initialSelected.add(idx); - }); - setSelected(initialSelected); - } catch (error) { - console.error('Failed to parse vCard:', error); + } catch (err) { + console.error("Failed to parse file:", err); setError(t("import.parse_error")); } - }, [existingContacts, t]); + }, [existingContacts, t, defaultBookId]); + + const applyCsvMapping = useCallback(() => { + if (!csvData || !mapping) return; + + const bookIds = effectiveBookId ? { [effectiveBookId]: true } : {}; + const contacts: ContactCard[] = []; + + for (const row of csvData.rows) { + const contact = mapRowToContact(row, mapping, bookIds); + if (contact) contacts.push(contact); + } + + if (contacts.length === 0) { + setError(t("import.no_contacts")); + return; + } + + const dupes = detectDuplicatesByEmail(existingContacts, contacts); + setParsed(contacts); + setDuplicates(dupes); + + const initialSelected = new Set(); + contacts.forEach((_, idx) => { + if (!dupes.has(idx)) initialSelected.add(idx); + }); + setSelected(initialSelected); + setShowPreview(true); + }, [csvData, mapping, effectiveBookId, existingContacts, t]); const toggleSelect = (idx: number) => { const next = new Set(selected); @@ -91,14 +174,160 @@ export function ContactImportDialog({ try { const count = await onImport(toImport); setResult(count); - } catch (error) { - console.error('Failed to import contacts:', error); + } catch (err) { + console.error("Failed to import contacts:", err); setError(t("import.failed")); } finally { setIsImporting(false); } }; + const renderCsvMapping = () => { + if (!csvData || !mapping) return null; + + const fields: Array<{ key: keyof CsvColumnMapping; label: string }> = [ + { key: "firstName", label: t("import.csv_first_name") }, + { key: "lastName", label: t("import.csv_last_name") }, + { key: "email", label: t("import.csv_email") }, + { key: "phone", label: t("import.csv_phone") }, + { key: "company", label: t("import.csv_company") }, + { key: "jobTitle", label: t("import.csv_job_title") }, + { key: "address", label: t("import.csv_address") }, + { key: "city", label: t("import.csv_city") }, + { key: "region", label: t("import.csv_region") }, + { key: "postcode", label: t("import.csv_postcode") }, + { key: "country", label: t("import.csv_country") }, + { key: "website", label: t("import.csv_website") }, + { key: "note", label: t("import.csv_note") }, + { key: "nickname", label: t("import.csv_nickname") }, + ]; + + const headerOptions = csvData.headers.map((h, i) => ({ + value: String(i), + label: h, + })); + + return ( +
+

{t("import.csv_map_columns")}

+
+ {fields.map(({ key, label }) => ( +
+ + +
+ ))} +
+ + {books.length > 0 && ( +
+ + +
+ )} + +
+ + +
+
+ ); + }; + + const renderCsvPreview = () => { + if (!csvData || !mapping || !showPreview) return null; + const previewRows = csvData.rows.slice(0, 5); + + return ( +
+
+

{t("import.csv_preview_title", { count: parsed.length })}

+ +
+
+ + + + {csvData.headers.map((h, i) => ( + + ))} + + + + {previewRows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {h} +
+ {cell} +
+
+
+ +
+
+ ); + }; + return (
@@ -119,12 +348,12 @@ export function ContactImportDialog({ {t("import.close")}
- ) : parsed.length === 0 ? ( + ) : fileType === null ? ( <> @@ -141,7 +370,7 @@ export function ContactImportDialog({ >

{t("import.drop_hint")}

-

{t("import.file_types")}

+

{t("import.file_types_csv")}

{error && ( @@ -151,6 +380,10 @@ export function ContactImportDialog({
)} + ) : fileType === "csv" && csvData && !showPreview ? ( + renderCsvMapping() + ) : fileType === "csv" && csvData && showPreview ? ( + renderCsvPreview() ) : ( <> {error && ( @@ -217,7 +450,23 @@ export function ContactImportDialog({ )}
- {parsed.length > 0 && result === null && ( + {parsed.length > 0 && result === null && fileType !== "csv" && ( +
+

+ {t("import.selected", { count: selected.size })} +

+
+ + +
+
+ )} + + {fileType === "csv" && showPreview && parsed.length > 0 && result === null && (

{t("import.selected", { count: selected.size })} diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx index 5807d085..e3882fc8 100644 --- a/components/layout/mailbox-context-menu.tsx +++ b/components/layout/mailbox-context-menu.tsx @@ -21,6 +21,7 @@ import { FolderX, RefreshCw, Upload, + Share2, } from "lucide-react"; interface Position { @@ -86,6 +87,7 @@ interface MailboxContextMenuProps { onRenameFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void; onImportEmail?: (mailboxId: string) => void; + onShareFolder?: (mailboxId: string) => void; onRefresh?: () => void; } @@ -105,6 +107,7 @@ export function MailboxContextMenu({ onRenameFolder, onDeleteFolder, onImportEmail, + onShareFolder, onRefresh, }: MailboxContextMenuProps) { const t = useTranslations("mailbox_context_menu"); @@ -191,6 +194,12 @@ export function MailboxContextMenu({ onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))} disabled={!onRenameFolder || !canRename} /> + handleAction(() => onShareFolder?.(mailbox.id))} + disabled={!onShareFolder || mailbox.isShared} + /> diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index a7999b58..ab87f6a8 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -90,6 +90,7 @@ interface SidebarProps { onDeleteFolder?: (mailboxId: string) => void; onImportEmail?: (mailboxId: string) => void; onRefreshMailboxes?: () => void; + onShareFolder?: (mailboxId: string) => void; scheduledTotal?: number; showScheduledMailbox?: boolean; /** True when the unified view spans multiple login accounts (cross-account). @@ -779,6 +780,7 @@ export function Sidebar({ onDeleteFolder, onImportEmail, onRefreshMailboxes, + onShareFolder, scheduledTotal = 0, showScheduledMailbox = false, crossAccountActive = false, @@ -1452,6 +1454,7 @@ export function Sidebar({ onRenameFolder={onRenameFolder} onDeleteFolder={onDeleteFolder} onImportEmail={onImportEmail} + onShareFolder={onShareFolder} onRefresh={onRefreshMailboxes} />

diff --git a/components/settings/contacts-settings.tsx b/components/settings/contacts-settings.tsx index 8661979f..14fd2b56 100644 --- a/components/settings/contacts-settings.tsx +++ b/components/settings/contacts-settings.tsx @@ -18,6 +18,7 @@ export function ContactsSettings() { const { client } = useAuthStore(); const { contacts, + addressBooks, supportsSync, importContacts, } = useContactStore(); @@ -46,6 +47,7 @@ export function ContactsSettings() {
setShowImport(false)} /> diff --git a/components/settings/import-settings.tsx b/components/settings/import-settings.tsx new file mode 100644 index 00000000..461e8582 --- /dev/null +++ b/components/settings/import-settings.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Upload, FolderOpen, Download, AlertTriangle, Check, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { SettingsSection, SettingItem, RadioGroup, Select } from "./settings-section"; +import { importEmails, type ConflictResolution, type ImportProgress, type ImportResult } from "@/lib/email-import"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { EML_IMPORT_ACCEPT } from "@/lib/eml-import"; +import { toast } from "@/stores/toast-store"; +import { cn } from "@/lib/utils"; + +export function ImportSettings() { + const t = useTranslations("settings.importer"); + const { client } = useAuthStore(); + const { mailboxes } = useEmailStore(); + const fileRef = useRef(null); + const [files, setFiles] = useState([]); + const [destination, setDestination] = useState(""); + const [conflict, setConflict] = useState("skip"); + const [progress, setProgress] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const abortRef = useRef(null); + + useEffect(() => { + if (mailboxes.length > 0 && !destination) { + const inbox = mailboxes.find((m) => m.role === "inbox") || mailboxes[0]; + if (inbox) setDestination(inbox.id); + } + }, [mailboxes, destination]); + + const folderOptions = mailboxes.map((m) => ({ + value: m.id, + label: m.name, + })); + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + const selected = e.target.files; + if (!selected || selected.length === 0) return; + setError(null); + setResult(null); + setProgress(null); + setFiles(Array.from(selected)); + }, []); + + const handleImport = useCallback(async () => { + if (!client || files.length === 0 || !destination) return; + + setImporting(true); + setError(null); + setResult(null); + const controller = new AbortController(); + abortRef.current = controller; + + try { + const res = await importEmails({ + client, + files, + destinationMailboxId: destination, + conflictResolution: conflict, + onProgress: (p) => setProgress({ ...p }), + signal: controller.signal, + }); + setResult(res); + if (res.imported > 0) { + toast.success(t("success", { count: res.imported })); + } + } catch (err) { + if (!controller.signal.aborted) { + const msg = err instanceof Error ? err.message : t("fail"); + setError(msg); + toast.error(msg); + } + } finally { + setImporting(false); + abortRef.current = null; + } + }, [client, files, destination, conflict, t]); + + const handleCancel = () => { + abortRef.current?.abort(); + setImporting(false); + }; + + const reset = () => { + setFiles([]); + setResult(null); + setProgress(null); + setError(null); + if (fileRef.current) fileRef.current.value = ""; + }; + + const progressPercent = progress && progress.total > 0 + ? Math.round((progress.processed / progress.total) * 100) + : 0; + + return ( + + +
+ + + {files.length > 0 && !importing && ( + + )} +
+
+ + + handleChangeRole(share, e.target.value)} + className="appearance-none rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-ring" + > + + + + + +
+ ))} + + )} + + )} + + {!loading && activeTab === "withMe" && ( + <> + {sharedWithMe.length === 0 ? ( +
+ {tSharing("no_shares_with_me")} +
+ ) : ( +
+ {sharedWithMe.map((share) => ( +
+ +
+
+ {share.resourceName} +
+
+ + | + + {tSharing("shared_by")}: {share.principalName} + +
+
+ + {tSharing(`preset.${share.role}`)} + + {share.pending ? ( +
+ + +
+ ) : ( + + )} +
+ ))} +
+ )} + + )} + + ); +} diff --git a/components/sharing/share-folder-dialog.tsx b/components/sharing/share-folder-dialog.tsx new file mode 100644 index 00000000..11da414a --- /dev/null +++ b/components/sharing/share-folder-dialog.tsx @@ -0,0 +1,383 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Avatar } from "@/components/ui/avatar"; +import { + X, + Loader2, + UserPlus, + Trash2, + Users, + ChevronDown, +} from "lucide-react"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { Principal } from "@/lib/jmap/types"; +import { useSharingStore, type SharedResourceKind } from "@/stores/sharing-store"; + +export interface ShareFolderDialogProps { + client: IJMAPClient; + resourceId: string; + resourceName: string; + resourceKind: SharedResourceKind; + onClose: () => void; +} + +const PRESET_OPTIONS: Record = { + mailbox: ["read", "readWrite", "manager"], + calendar: ["read", "readWrite", "manager"], + addressBook: ["read", "readWrite", "manager"], + file: ["read", "readWrite", "manager"], +}; + +export function ShareFolderDialog({ + client, + resourceId, + resourceName, + resourceKind, + onClose, +}: ShareFolderDialogProps) { + const t = useTranslations("sharing"); + const tCommon = useTranslations("common"); + const modalRef = useRef(null); + + const sharedByMe = useSharingStore((s) => s.sharedByMe); + const loadPrincipals = useSharingStore((s) => s.loadPrincipals); + const shareFolder = useSharingStore((s) => s.shareFolder); + const revokeShare = useSharingStore((s) => s.revokeShare); + const changeRole = useSharingStore((s) => s.changeRole); + + const [allPrincipals, setAllPrincipals] = useState([]); + const [loadingPrincipals, setLoadingPrincipals] = useState(true); + const [search, setSearch] = useState(""); + const [savingId, setSavingId] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [message, setMessage] = useState(""); + + useEffect(() => { + let cancelled = false; + setLoadingPrincipals(true); + loadPrincipals(client) + .then((list) => { + if (cancelled) return; + setAllPrincipals(list); + setLoadingPrincipals(false); + }) + .catch(() => { + if (!cancelled) setLoadingPrincipals(false); + }); + return () => { + cancelled = true; + }; + }, [client, loadPrincipals]); + + const ownAccountId = client.getAccountId(); + + const allPrincipalsById = useMemo(() => { + const map = new Map(); + for (const p of allPrincipals) map.set(p.id, p); + return map; + }, [allPrincipals]); + + const currentShares = sharedByMe.filter( + (f) => f.resourceId === resourceId && f.resourceKind === resourceKind, + ); + + const principals = useMemo(() => { + const existing = new Set(currentShares.map((s) => s.principalId)); + return allPrincipals.filter( + (p) => p.id !== ownAccountId && !existing.has(p.id), + ); + }, [allPrincipals, ownAccountId, currentShares]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + + const handleRemove = async (principalId: string) => { + setSavingId(principalId); + try { + await revokeShare(client, resourceId, resourceKind, principalId); + } catch { + /* error toast comes from store */ + } finally { + setSavingId(null); + } + }; + + const handleChangeRole = async (principalId: string, role: string) => { + setSavingId(principalId); + try { + await changeRole(client, resourceId, resourceKind, principalId, role); + } catch { + /* error toast comes from store */ + } finally { + setSavingId(null); + } + }; + + const handleAdd = async (principal: Principal) => { + setSavingId(principal.id); + try { + await shareFolder( + client, + resourceId, + resourceName, + resourceKind, + principal.id, + "read", + message || undefined, + ); + setShowAdd(false); + setSearch(""); + setMessage(""); + } catch { + /* error toast comes from store */ + } finally { + setSavingId(null); + } + }; + + const filteredPrincipals = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return principals; + return principals.filter( + (p) => + p.name.toLowerCase().includes(q) || + p.email?.toLowerCase().includes(q) || + p.description?.toLowerCase().includes(q), + ); + }, [principals, search]); + + const presetOptions = PRESET_OPTIONS[resourceKind]; + + const kindLabels: Record = { + mailbox: "Mail folder", + calendar: "Calendar", + addressBook: "Address book", + file: "File folder", + }; + + return ( +
+