- You have changes that haven't been saved. If you leave now, they will be lost. -
-- Taking you to the admin dashboard… + {t('done.redirecting')}
); } function InsecureContextScreen({ onContinue }: { onContinue: () => void }) { + const t = useTranslations('setup'); const httpsUrl = typeof window !== 'undefined' ? `https://${window.location.host}${window.location.pathname}${window.location.search}` @@ -374,10 +386,9 @@ function InsecureContextScreen({ onContinue }: { onContinue: () => void }) {- The setup token and admin password you enter here will travel in cleartext. - Please use HTTPS if at all possible - terminate TLS on the container or a reverse proxy in front of it. + {t('insecure.body')}
- Bulwark Webmail is configured. Sign in to continue. + {t('already.body', { appName: 'Bulwark Webmail' })}
{friendlyError(error)}
+{friendlyError(error, t)}
Loading…
; + return{t('loading')}
; } } @@ -570,6 +577,7 @@ function StepContent({ stepIndex, config, setConfig, onNext, onBack, onFinish }: type ProbeStatus = 'jmap_detected' | 'reachable_no_jmap' | 'unreachable' | 'invalid_url'; function ServerStep({ config, setConfig, onNext }: Pick- This URL uses plain HTTP. + {t('server.http_title')}
- Passwords and email contents will travel unencrypted between users and your server. Use https:// in production - terminate TLS on the mail server or a reverse proxy in front of it.
+ {t('server.http_body', { https: 'https://' })}
- This URL only resolves locally. + {t('server.local_title')}
- Mail is fetched directly from the user's browser, so the JMAP URL must be reachable from anywhere users sign in - not just this machine or LAN. Use a public hostname (e.g. https://mail.example.com) in production.
+ {t('server.local_body', { example: 'https://mail.example.com' })}
{probe.message}
- This can happen when a reverse proxy routes JMAP separately on the same domain. Otherwise, it usually means the URL is wrong. + {t('server.not_jmap_hint')}
- Optional. Surface multiple servers in the login dropdown - useful for hosts running several Stalwart instances. + {t('server.additional_hint')}
- A 32-byte secret was created for you. You only need to change this if you have a specific reason. + {t('security.generated_hint')}
{hint}
}{t("period_hint")}
++ {text} +
); } diff --git a/components/filters/sieve-editor-modal.tsx b/components/filters/sieve-editor-modal.tsx index 0aeee9ecc..4f47f4d2c 100644 --- a/components/filters/sieve-editor-modal.tsx +++ b/components/filters/sieve-editor-modal.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useRef, useCallback } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; -import { X, AlertTriangle, CheckCircle, Loader2 } from "@/components/icons"; -import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { Dialog } from "@/components/ui/dialog"; +import { AlertTriangle, CheckCircle, Loader2 } from "@/components/icons"; +import { useDiscardChanges } from "@/components/settings/use-discard-changes"; interface SieveEditorModalProps { content: string; @@ -28,8 +29,14 @@ export function SieveEditorModal({ } | null>(null); const [showSaveWarning, setShowSaveWarning] = useState(false); - const modalRef = useFocusTrap({ isActive: true, onEscape: onClose }); const textareaRef = useRef{t("warning")}
{tk('none')}
)} +{tk('none')}
)} +{t('email_client.description')}
{announcement}
{t('link_device.description')}
@@ -1517,7 +1551,7 @@ export function AccountSecuritySettings() {- {t('confirm_delete', { name: cal.name })} -
-- {t('confirm_clear', { name: cal.name })} -
-- {tSub('confirm_delete', { name: sub.name })} -
-
@@ -772,6 +756,7 @@ export function FilterSettings() {
- {t('confirm_delete', { name: mb.name })} -
-
@@ -523,6 +531,7 @@ function UnrecognizedKeywords() {
export function KeywordSettings() {
const t = useTranslations("settings.keywords");
+ const tNotifications = useTranslations("notifications");
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
useSettingsStore();
const { client } = useAuthStore();
@@ -634,8 +643,25 @@ export function KeywordSettings() {
setEditingId(null);
};
+ // A tag can be put back as it was, so it goes at once with an Undo (R3).
const handleDelete = (id: string) => {
+ const before = useSettingsStore.getState().emailKeywords;
+ const index = before.findIndex((k) => k.id === id);
+ const removed = before[index];
+ if (!removed) return;
removeKeyword(id);
+ toast.success(t("deleted"), {
+ action: {
+ label: tNotifications("rule_undo"),
+ onClick: () => {
+ const now = useSettingsStore.getState().emailKeywords;
+ if (now.some((k) => k.id === id)) return;
+ const restored = [...now];
+ restored.splice(Math.min(index, restored.length), 0, removed);
+ reorderKeywords(restored);
+ },
+ },
+ });
};
const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx
index acdb7a001..e5e24d08b 100644
--- a/components/settings/settings-app.tsx
+++ b/components/settings/settings-app.tsx
@@ -28,6 +28,7 @@ import {
type AppIcon,
} from '@/components/icons';
import { Button } from '@/components/ui/button';
+import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot';
@@ -76,16 +77,18 @@ import { productName } from '@/lib/product-name';
import { usePolicyStore } from '@/stores/policy-store';
import { cn } from '@/lib/utils';
import {
+ type SettingsSearchEntry,
type SettingsSearchTab,
type SubResult,
- collectSubResults,
- flattenStrings,
- withAppName,
getByPath,
+ matchesAllWords,
+ settingsSubResults,
tabKeywords,
- tabSearchPaths,
+ tabSearchEntries,
archiveActionSearchPaths,
} from '@/lib/settings-search';
+import { useAccountStore } from '@/stores/account-store';
+import { useMediaQuery } from '@/hooks/use-media-query';
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { MvTopBar } from "@/components/layout/mv-top-bar";
@@ -193,6 +196,10 @@ function migrateTarget(raw: string, section: string | null = null): { tab: strin
return { tab, section: section ?? (raw in LEGACY_SECTION_MAP ? LEGACY_SECTION_MAP[raw] : null) };
}
+function hasOneShotDeepLink(): boolean {
+ try { return !!sessionStorage.getItem('settings-deep-link-tab'); } catch { return false; }
+}
+
function readPersistedTab(): SettingsTabId {
try {
// One-shot deep link from the sidebar section gears (Folders / Tags).
@@ -276,9 +283,15 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const target = targetFromDeepLink(linkSegments);
return target?.section ? { tab: target.tab, id: target.section } : null;
});
+ // On the phone the tab list is a screen of its own: a link or event that
+ // names a page opens that page, with the list one Back below it.
+ const [mobileShowContent, setMobileShowContent] = useState(
+ () => targetFromDeepLink(linkSegments) !== null || hasOneShotDeepLink(),
+ );
const goTo = useCallback((target: SettingsTarget) => {
setActiveTab(target.tab);
setPendingSection(target.section ? { tab: target.tab, id: target.section } : null);
+ setMobileShowContent(true);
}, []);
// Active plugins that expose a `settings-section` slot — each becomes its own
// Settings menu entry. Referentially stable per registry mutation, so it is
@@ -318,9 +331,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
if (target) goTo(target);
});
}, [isEmbedded, goTo]);
- const [mobileShowContent, setMobileShowContent] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
- const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
+ const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string; advanced?: boolean } | null>(null);
const isDesktop = useIsDesktop();
const messages = useMessages() as Record{activeTabLabel}
{t('title')}
@@ -898,14 +922,13 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
aria-label={t('search_placeholder')}
/>
{searchQuery && (
-
+