From 0d28d811a85e59cd38e37cffc28a736e09155b10 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:07:30 +0100 Subject: [PATCH 01/15] feat: support uploading folders via drag-and-drop and toolbar button --- components/files/file-browser.tsx | 39 ++++++-- components/files/file-upload-area.tsx | 16 +++- lib/webdav/drop-utils.ts | 126 ++++++++++++++++++++++++++ locales/de/common.json | 2 +- locales/en/common.json | 2 +- locales/es/common.json | 2 +- locales/fr/common.json | 2 +- locales/it/common.json | 2 +- locales/ja/common.json | 2 +- locales/nl/common.json | 2 +- locales/pt/common.json | 2 +- stores/file-store.ts | 5 +- 12 files changed, 179 insertions(+), 23 deletions(-) create mode 100644 lib/webdav/drop-utils.ts diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index e62042f8..ef62d814 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -21,6 +21,7 @@ import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog"; import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar"; import { ResizeHandle } from "@/components/layout/resize-handle"; +import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils"; import type { FileResource } from "@/stores/file-store"; type SortKey = "name" | "size" | "modified"; @@ -624,16 +625,20 @@ export function FileBrowser({ e.stopPropagation(); setIsDraggingOver(false); - const files = Array.from(e.dataTransfer.files); - if (files.length > 0) { - setIsUploading(true); - try { - await onUploadFiles(files); - } finally { - setIsUploading(false); + setIsUploading(true); + try { + const { files, hasDirectories } = await getDroppedFilesAndFolders(e.dataTransfer); + if (files.length > 0) { + if (hasDirectories) { + await onUploadFolder(files); + } else { + await onUploadFiles(files); + } } + } finally { + setIsUploading(false); } - }, [onUploadFiles]); + }, [onUploadFiles, onUploadFolder]); const handleFileInputChange = async (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); @@ -945,6 +950,16 @@ export function FileBrowser({ > + + + ); })} diff --git a/components/settings/calendar-settings.tsx b/components/settings/calendar-settings.tsx index 6f858f4a..942a57a7 100644 --- a/components/settings/calendar-settings.tsx +++ b/components/settings/calendar-settings.tsx @@ -15,6 +15,7 @@ export function CalendarSettings() { timeFormat, firstDayOfWeek, showTimeInMonthView, + showWeekNumbers, calendarNotificationsEnabled, calendarNotificationSound, calendarInvitationParsingEnabled, @@ -68,6 +69,16 @@ export function CalendarSettings() { /> + + updateSetting('showWeekNumbers', checked)} + /> + + ()( calendarNotificationSound: state.calendarNotificationSound, calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled, showTimeInMonthView: state.showTimeInMonthView, + showWeekNumbers: state.showWeekNumbers, toolbarPosition: state.toolbarPosition, senderFavicons: state.senderFavicons, folderIcons: state.folderIcons, From 4c2d185be4b90b022142cc0952388b1794d506ba Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:02:42 +0100 Subject: [PATCH 04/15] fix: refactor logout to use synchronous flow with full page redirect - Rewrite logout() from async to synchronous to prevent React re-renders with stale state - Replace router.push('/login') with redirectToLogin() (window.location.replace) in all page auth guards for reliable navigation in Edge/Safari - Add performFullLogout() helper that clears auth state, feature stores, and localStorage - Fix persist middleware partialize to return {} when not authenticated, preventing state resurrection - Use keepalive fetch for background cookie/token cleanup so redirect fires immediately - Remove unused useRouter imports from page.tsx and contacts/page.tsx - Simplify all page logout handlers to directly call logout() Fixes #63 --- app/[locale]/calendar/page.tsx | 6 +- app/[locale]/contacts/page.tsx | 10 +- app/[locale]/files/page.tsx | 8 +- app/[locale]/page.tsx | 15 +- app/[locale]/settings/page.tsx | 10 +- components/layout/account-switcher.tsx | 4 - stores/auth-store.ts | 284 ++++++++----------------- 7 files changed, 104 insertions(+), 233 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 3ff546ed..56276b30 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -11,7 +11,7 @@ import { } from "date-fns"; import { useCalendarStore } from "@/stores/calendar-store"; import { isCalendarViewMode } from "@/stores/calendar-store"; -import { useAuthStore } from "@/stores/auth-store"; +import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; @@ -105,7 +105,7 @@ export default function CalendarPage() { useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } - router.push("/login"); + redirectToLogin(); } else if (client && !supportsCalendar) { router.push("/"); } @@ -721,7 +721,7 @@ export default function CalendarPage() { collapsed quota={quota} isPushConnected={isPushConnected} - onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} + onLogout={logout} onManageApps={handleManageApps} onInlineApp={handleInlineApp} onCloseInlineApp={closeInlineApp} diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 1b8bc214..2c232552 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { ArrowLeft, Users } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -16,7 +15,7 @@ import { ContactsSidebar, type ContactCategory } from "@/components/contacts/con 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 { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { cn } from "@/lib/utils"; @@ -39,7 +38,6 @@ type View = | "bulk-add-to-group"; export default function ContactsPage() { - const router = useRouter(); const t = useTranslations("contacts"); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); @@ -109,9 +107,9 @@ export default function ContactsPage() { useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } - router.push("/login"); + redirectToLogin(); } - }, [initialCheckDone, isAuthenticated, authLoading, router]); + }, [initialCheckDone, isAuthenticated, authLoading]); useEffect(() => { if (client && supportsSync && !hasFetched.current) { @@ -594,7 +592,7 @@ export default function ContactsPage() { collapsed quota={quota} isPushConnected={isPushConnected} - onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} + onLogout={logout} onManageApps={handleManageApps} onInlineApp={handleInlineApp} onCloseInlineApp={closeInlineApp} diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx index e7b45c82..aade290f 100644 --- a/app/[locale]/files/page.tsx +++ b/app/[locale]/files/page.tsx @@ -7,7 +7,7 @@ import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; -import { useAuthStore } from "@/stores/auth-store"; +import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useFileStore } from "@/stores/file-store"; import { toast } from "@/stores/toast-store"; @@ -112,9 +112,9 @@ export default function FilesPage() { useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } - router.push("/login"); + redirectToLogin(); } - }, [initialCheckDone, isAuthenticated, authLoading, router]); + }, [initialCheckDone, isAuthenticated, authLoading]); // Initialize JMAP files client useEffect(() => { @@ -357,7 +357,7 @@ export default function FilesPage() { collapsed quota={quota} isPushConnected={isPushConnected} - onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }} + onLogout={logout} onManageApps={handleManageApps} onInlineApp={handleInlineApp} onCloseInlineApp={closeInlineApp} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index c0b39e5a..0f5852ff 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState, useRef, useMemo, useCallback } from "react"; -import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { Sidebar } from "@/components/layout/sidebar"; import { EmailList } from "@/components/email/email-list"; @@ -13,7 +12,7 @@ import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-hea import { ThreadGroup, Email } from "@/lib/jmap/types"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { useEmailStore } from "@/stores/email-store"; -import { useAuthStore } from "@/stores/auth-store"; +import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useUIStore } from "@/stores/ui-store"; @@ -48,7 +47,6 @@ import { Button } from "@/components/ui/button"; import { useConfig } from "@/hooks/use-config"; export default function Home() { - const router = useRouter(); const t = useTranslations(); const tCommon = useTranslations('common'); const { appName } = useConfig(); @@ -285,9 +283,9 @@ export default function Home() { useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } - router.push('/login'); + redirectToLogin(); } - }, [initialCheckDone, isAuthenticated, authLoading, router]); + }, [initialCheckDone, isAuthenticated, authLoading]); // Load mailboxes and emails when authenticated (only if not already loaded) useEffect(() => { @@ -768,12 +766,7 @@ export default function Home() { } }; - const handleLogout = () => { - logout(); - if (!useAuthStore.getState().isAuthenticated) { - router.push('/login'); - } - }; + const handleLogout = logout; const handleSearch = async (query: string) => { if (!client) return; diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 8b70b6cd..8ccd3179 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -44,7 +44,7 @@ import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings'; import { SmimeSettings } from '@/components/settings/smime-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; -import { useAuthStore } from '@/stores/auth-store'; +import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { useIsDesktop } from '@/hooks/use-media-query'; import { NavigationRail } from '@/components/layout/navigation-rail'; @@ -122,9 +122,9 @@ export default function SettingsPage() { useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } - router.push('/login'); + redirectToLogin(); } - }, [initialCheckDone, isAuthenticated, authLoading, router]); + }, [initialCheckDone, isAuthenticated, authLoading]); if (!isAuthenticated) { return null; @@ -286,7 +286,7 @@ export default function SettingsPage() { {/* Logout */}
- {isSaving && ( -
- - {t("saving")} -
- )} +
+ {isSaving && ( +
+ + {t("saving")} +
+ )} + {!isOpaque && rules.length > 0 && ( +
+ {t("expanded_view")} + updateSetting("expandedFilterView", v)} + /> +
+ )} +
{showRuleModal && ( diff --git a/locales/de/common.json b/locales/de/common.json index e346d95d..810f9138 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1183,6 +1183,12 @@ "opaque_warning": "Dieses Skript wurde außerhalb des visuellen Builders bearbeitet. Nur die Sieve-Skriptbearbeitung ist verfügbar.", "open_sieve_editor": "Sieve-Skript-Editor öffnen", "fetch_error": "Filter konnten nicht geladen werden", + "expanded_view": "Erweiterte Ansicht", + "expanded_view_description": "Filterregeln mit detaillierten Bedingungs- und Aktionsblöcken anzeigen", + "if": "Wenn", + "then": "Dann", + "match_all_conditions": "alle zutreffen", + "match_any_condition": "eine zutrifft", "and": "und", "or": "oder", "cancel": "Abbrechen", diff --git a/locales/en/common.json b/locales/en/common.json index 3c1c3a78..a28fe788 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1183,6 +1183,12 @@ "opaque_warning": "This script was edited outside the visual builder. Only raw Sieve editing is available.", "open_sieve_editor": "Open raw Sieve editor", "fetch_error": "Failed to load filters", + "expanded_view": "Expanded view", + "expanded_view_description": "Show filter rules with detailed condition and action blocks", + "if": "If", + "then": "Then", + "match_all_conditions": "all match", + "match_any_condition": "any matches", "and": "and", "or": "or", "cancel": "Cancel", diff --git a/locales/es/common.json b/locales/es/common.json index 70186d5f..607d0b97 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1183,6 +1183,12 @@ "opaque_warning": "Este script fue editado fuera del constructor visual. Solo está disponible la edición Sieve sin formato.", "open_sieve_editor": "Abrir editor Sieve", "fetch_error": "Error al cargar los filtros", + "expanded_view": "Vista expandida", + "expanded_view_description": "Mostrar reglas de filtro con bloques detallados de condiciones y acciones", + "if": "Si", + "then": "Entonces", + "match_all_conditions": "todas coinciden", + "match_any_condition": "alguna coincide", "and": "y", "or": "o", "cancel": "Cancelar", diff --git a/locales/fr/common.json b/locales/fr/common.json index 62c22f76..518c219f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1183,6 +1183,12 @@ "opaque_warning": "Ce script a été modifié en dehors du constructeur visuel. Seule l'édition Sieve brute est disponible.", "open_sieve_editor": "Ouvrir l'éditeur Sieve brut", "fetch_error": "Échec du chargement des filtres", + "expanded_view": "Vue étendue", + "expanded_view_description": "Afficher les règles de filtre avec des blocs de conditions et d'actions détaillés", + "if": "Si", + "then": "Alors", + "match_all_conditions": "toutes correspondent", + "match_any_condition": "une correspond", "and": "et", "or": "ou", "cancel": "Annuler", diff --git a/locales/it/common.json b/locales/it/common.json index 677512b2..3a204e69 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1183,6 +1183,12 @@ "opaque_warning": "Questo script è stato modificato al di fuori del costruttore visuale. È disponibile solo la modifica Sieve grezza.", "open_sieve_editor": "Apri editor Sieve", "fetch_error": "Impossibile caricare i filtri", + "expanded_view": "Vista espansa", + "expanded_view_description": "Mostra le regole dei filtri con blocchi dettagliati di condizioni e azioni", + "if": "Se", + "then": "Allora", + "match_all_conditions": "tutte corrispondono", + "match_any_condition": "una corrisponde", "and": "e", "or": "o", "cancel": "Annulla", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7711294e..5834dd51 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1183,6 +1183,12 @@ "opaque_warning": "このスクリプトはビジュアルビルダーの外部で編集されました。Sieveスクリプトの直接編集のみ可能です。", "open_sieve_editor": "Sieveスクリプトエディタを開く", "fetch_error": "フィルターの読み込みに失敗しました", + "expanded_view": "詳細表示", + "expanded_view_description": "フィルタールールを条件とアクションのブロックで表示", + "if": "条件", + "then": "実行", + "match_all_conditions": "すべて一致", + "match_any_condition": "いずれか一致", "and": "かつ", "or": "または", "cancel": "キャンセル", diff --git a/locales/nl/common.json b/locales/nl/common.json index 73b0b0cd..edd39f68 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1183,6 +1183,12 @@ "opaque_warning": "Dit script is buiten de visuele builder bewerkt. Alleen Sieve-scriptbewerking is beschikbaar.", "open_sieve_editor": "Sieve-scripteditor openen", "fetch_error": "Filters konden niet worden geladen", + "expanded_view": "Uitgebreide weergave", + "expanded_view_description": "Filterregels weergeven met gedetailleerde voorwaarde- en actieblokken", + "if": "Als", + "then": "Dan", + "match_all_conditions": "alle overeenkomen", + "match_any_condition": "een overeenkomt", "and": "en", "or": "of", "cancel": "Annuleren", diff --git a/locales/pt/common.json b/locales/pt/common.json index 1a5b55a8..c7f0e5e4 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1183,6 +1183,12 @@ "opaque_warning": "Este script foi editado fora do construtor visual. Apenas a edição Sieve bruta está disponível.", "open_sieve_editor": "Abrir editor Sieve", "fetch_error": "Falha ao carregar filtros", + "expanded_view": "Vista expandida", + "expanded_view_description": "Mostrar regras de filtro com blocos detalhados de condições e ações", + "if": "Se", + "then": "Então", + "match_all_conditions": "todas correspondem", + "match_any_condition": "uma corresponde", "and": "e", "or": "ou", "cancel": "Cancelar", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 7b85f8d2..84b5fce3 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -119,6 +119,9 @@ interface SettingsState { sessionTimeout: number; // minutes (0 = never) trustedSenders: string[]; // Email addresses that can load external content + // Filters + expandedFilterView: boolean; + // Calendar showTimeInMonthView: boolean; showWeekNumbers: boolean; @@ -220,6 +223,9 @@ const DEFAULT_SETTINGS = { sessionTimeout: 0, // Never trustedSenders: [] as string[], + // Filters + expandedFilterView: false, + // Calendar showTimeInMonthView: false, showWeekNumbers: false, @@ -308,6 +314,7 @@ export const useSettingsStore = create()( calendarNotificationsEnabled: state.calendarNotificationsEnabled, calendarNotificationSound: state.calendarNotificationSound, calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled, + expandedFilterView: state.expandedFilterView, showTimeInMonthView: state.showTimeInMonthView, showWeekNumbers: state.showWeekNumbers, toolbarPosition: state.toolbarPosition, From 8b1b3ad57b83086f3b86a5e998057f857dc7c9f7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:21:14 +0100 Subject: [PATCH 06/15] fix: update iframe sandbox attributes to allow popups to escape sandbox --- components/email/email-viewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 550f569b..f7a833f5 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -4449,7 +4449,7 @@ export function EmailViewer({