refactor(webui): clarify shared controls and status feedback
This commit is contained in:
@@ -2,7 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
|||||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||||
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
|
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
|
||||||
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||||
import AppShell from "./layout/AppShell";
|
import AppShell from "./layout/AppShell";
|
||||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||||
@@ -32,6 +32,7 @@ export default function App() {
|
|||||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||||
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
||||||
|
const [backendReachable, setBackendReachable] = useState(true);
|
||||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||||
const [reloginMessage, setReloginMessage] = useState("");
|
const [reloginMessage, setReloginMessage] = useState("");
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetchPlatformStatus(settings);
|
const response = await fetchPlatformStatus(settings);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
setBackendReachable(true);
|
||||||
setMaintenanceMode(response.maintenance_mode);
|
setMaintenanceMode(response.maintenance_mode);
|
||||||
if (response.i18n) {
|
if (response.i18n) {
|
||||||
setSystemLanguages({
|
setSystemLanguages({
|
||||||
@@ -100,7 +102,10 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setMaintenanceMode({ enabled: false, message: null });
|
if (!cancelled) {
|
||||||
|
setBackendReachable(false);
|
||||||
|
setMaintenanceMode({ enabled: false, message: null });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +126,13 @@ export default function App() {
|
|||||||
async function bootstrapAuth() {
|
async function bootstrapAuth() {
|
||||||
try {
|
try {
|
||||||
const shellAuth = await fetchShellAuth(settings);
|
const shellAuth = await fetchShellAuth(settings);
|
||||||
if (!cancelled) setAuth(normalizeAuthInfo(shellAuth));
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
setBackendReachable(true);
|
||||||
|
setAuth(normalizeAuthInfo(shellAuth));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setBackendReachable(isApiError(error));
|
||||||
const cleared = { ...settings, accessToken: "" };
|
const cleared = { ...settings, accessToken: "" };
|
||||||
setSettings(cleared);
|
setSettings(cleared);
|
||||||
saveApiSettings(cleared);
|
saveApiSettings(cleared);
|
||||||
@@ -243,6 +252,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const sessionInfo = await fetchSession(settings);
|
const sessionInfo = await fetchSession(settings);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
setBackendReachable(true);
|
||||||
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
|
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
|
||||||
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
|
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
|
||||||
const shellAuth = await fetchShellAuth(settings);
|
const shellAuth = await fetchShellAuth(settings);
|
||||||
@@ -252,7 +262,10 @@ export default function App() {
|
|||||||
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
|
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
|
||||||
: normalizeAuthInfo(shellAuth));
|
: normalizeAuthInfo(shellAuth));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (!cancelled && !isApiError(error)) {
|
||||||
|
setBackendReachable(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// A background refresh must not log the user out on a transient network error.
|
// A background refresh must not log the user out on a transient network error.
|
||||||
@@ -273,7 +286,7 @@ export default function App() {
|
|||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={webModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<div className="public-landing">
|
<div className="public-landing">
|
||||||
<section className="public-card">
|
<section className="public-card">
|
||||||
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
|
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
|
||||||
@@ -293,7 +306,7 @@ export default function App() {
|
|||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={webModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
@@ -324,7 +337,7 @@ export default function App() {
|
|||||||
moduleTranslations={moduleTranslations}>
|
moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={webModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode}>
|
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { Archive, LockKeyhole, Paperclip } from "lucide-react";
|
import { Archive, Link2, LockKeyhole, Paperclip, X } from "lucide-react";
|
||||||
import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
|
import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
import SegmentedControl from "./SegmentedControl";
|
import SegmentedControl from "./SegmentedControl";
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ export type MessageDisplayAttachment = {
|
|||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
sizeBytes?: number | null;
|
sizeBytes?: number | null;
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
|
linkedToCampaign?: boolean | null;
|
||||||
archiveGroup?: string | null;
|
archiveGroup?: string | null;
|
||||||
archiveLabel?: string | null;
|
archiveLabel?: string | null;
|
||||||
protected?: boolean | null;
|
protected?: boolean | null;
|
||||||
@@ -159,9 +160,15 @@ function AttachmentRow({ attachment, index }: {attachment: MessageDisplayAttachm
|
|||||||
const contentType = formatContentType(attachment.contentType);
|
const contentType = formatContentType(attachment.contentType);
|
||||||
const size = formatBytes(attachment.sizeBytes);
|
const size = formatBytes(attachment.sizeBytes);
|
||||||
const hasMeta = Boolean(contentType || size);
|
const hasMeta = Boolean(contentType || size);
|
||||||
|
const LinkIcon = attachment.linkedToCampaign === true ? Link2 : attachment.linkedToCampaign === false ? X : Paperclip;
|
||||||
|
const linkStateClass = attachment.linkedToCampaign === true ?
|
||||||
|
" is-linked" :
|
||||||
|
attachment.linkedToCampaign === false ?
|
||||||
|
" is-unlinked" :
|
||||||
|
"";
|
||||||
return (
|
return (
|
||||||
<div className="message-display-attachment-row">
|
<div className={`message-display-attachment-row${linkStateClass}`}>
|
||||||
<Paperclip size={14} aria-hidden="true" />
|
<LinkIcon size={14} aria-hidden="true" />
|
||||||
<span>
|
<span>
|
||||||
<strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong>
|
<strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong>
|
||||||
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
|
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
|
||||||
|
|||||||
@@ -5,15 +5,21 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
|
|||||||
|
|
||||||
type ToggleSwitchProps = {
|
type ToggleSwitchProps = {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
|
activeLabel?: ReactNode;
|
||||||
|
inactiveLabel?: ReactNode;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange?: (checked: boolean) => void;
|
onChange?: (checked: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
help?: ReactNode;
|
help?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
|
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
|
||||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||||
|
const renderedInactiveLabel = typeof inactiveLabel === "string" ? translateText(inactiveLabel) : inactiveLabel;
|
||||||
|
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
|
||||||
|
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
|
||||||
return (
|
return (
|
||||||
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
|
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
@@ -21,12 +27,25 @@ export default function ToggleSwitch({ label, checked, onChange, disabled = fals
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-label={inputLabel}
|
||||||
onChange={(event) => onChange?.(event.target.checked)}
|
onChange={(event) => onChange?.(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
|
{hasStateLabels && inactiveLabel !== undefined &&
|
||||||
|
<span className="toggle-switch-state-label is-inactive" data-selected={!checked || undefined}>
|
||||||
|
{renderedInactiveLabel}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
|
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
|
||||||
|
{hasStateLabels && activeLabel !== undefined &&
|
||||||
|
<span className="toggle-switch-state-label is-active" data-selected={checked || undefined}>
|
||||||
|
{renderedActiveLabel}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
{!hasStateLabels &&
|
||||||
<span className="toggle-switch-copy">
|
<span className="toggle-switch-copy">
|
||||||
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
|
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
|
||||||
</span>
|
</span>
|
||||||
|
}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Button from "../Button";
|
import Button from "../Button";
|
||||||
import { CredentialFields } from "../CredentialPanel";
|
import { CredentialFields } from "../CredentialPanel";
|
||||||
import DismissibleAlert from "../DismissibleAlert";
|
import DismissibleAlert from "../DismissibleAlert";
|
||||||
import FormField from "../FormField";
|
import FormField from "../FormField";
|
||||||
import SegmentedControl from "../SegmentedControl";
|
import SegmentedControl from "../SegmentedControl";
|
||||||
import ToggleSwitch from "../ToggleSwitch";
|
|
||||||
|
|
||||||
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ export type MailServerFolderLookupResult = {
|
|||||||
details?: Record<string, unknown> | null;
|
details?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailServerSettingsSection = "smtp" | "imap" | "advanced";
|
export type MailServerSettingsSection = "smtp" | "imap";
|
||||||
export type MailServerSettingsMode = "all" | "server" | "credentials";
|
export type MailServerSettingsMode = "all" | "server" | "credentials";
|
||||||
|
|
||||||
export type MailServerSettingsPanelProps = {
|
export type MailServerSettingsPanelProps = {
|
||||||
@@ -72,30 +71,11 @@ export type MailServerSettingsPanelProps = {
|
|||||||
imapActionDisabled?: boolean;
|
imapActionDisabled?: boolean;
|
||||||
smtpTestLabel?: string;
|
smtpTestLabel?: string;
|
||||||
imapTestLabel?: string;
|
imapTestLabel?: string;
|
||||||
folderLookupLabel?: string;
|
|
||||||
busyAction?: "smtp" | "imap" | "folders" | string | null;
|
busyAction?: "smtp" | "imap" | "folders" | string | null;
|
||||||
onTestSmtp?: () => void;
|
onTestSmtp?: () => void;
|
||||||
onTestImap?: () => void;
|
onTestImap?: () => void;
|
||||||
onLookupFolders?: () => void;
|
|
||||||
smtpTestResult?: MailServerConnectionTestResult | null;
|
smtpTestResult?: MailServerConnectionTestResult | null;
|
||||||
imapTestResult?: MailServerConnectionTestResult | null;
|
imapTestResult?: MailServerConnectionTestResult | null;
|
||||||
folderLookupResult?: MailServerFolderLookupResult | null;
|
|
||||||
onUseDetectedFolder?: () => void;
|
|
||||||
useDetectedFolderDisabled?: boolean;
|
|
||||||
mockToggle?: {
|
|
||||||
label?: string;
|
|
||||||
checked: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
};
|
|
||||||
append?: {
|
|
||||||
enabled: boolean;
|
|
||||||
folder: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
folderDisabled?: boolean;
|
|
||||||
onEnabledChange: (enabled: boolean) => void;
|
|
||||||
onFolderChange: (folder: string) => void;
|
|
||||||
};
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
floatingResults?: boolean;
|
floatingResults?: boolean;
|
||||||
@@ -202,6 +182,19 @@ export function hasMailImapSettings(values: Array<string | number | null | undef
|
|||||||
return values.some((value) => String(value ?? "").trim() !== "");
|
return values.some((value) => String(value ?? "").trim() !== "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveMailServerSettingsActiveSection(
|
||||||
|
activeSection: MailServerSettingsSection,
|
||||||
|
initialSection: MailServerSettingsSection,
|
||||||
|
previousInitialSection: MailServerSettingsSection,
|
||||||
|
visibleSectionIds: readonly MailServerSettingsSection[])
|
||||||
|
: MailServerSettingsSection {
|
||||||
|
const fallbackSection = visibleSectionIds[0] ?? "smtp";
|
||||||
|
const initialSectionVisible = visibleSectionIds.includes(initialSection);
|
||||||
|
if (previousInitialSection !== initialSection && initialSectionVisible) return initialSection;
|
||||||
|
if (!visibleSectionIds.includes(activeSection)) return initialSectionVisible ? initialSection : fallbackSection;
|
||||||
|
return activeSection;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MailServerSettingsPanel({
|
export default function MailServerSettingsPanel({
|
||||||
smtp,
|
smtp,
|
||||||
imap,
|
imap,
|
||||||
@@ -223,18 +216,11 @@ export default function MailServerSettingsPanel({
|
|||||||
imapActionDisabled = imapServerDisabled,
|
imapActionDisabled = imapServerDisabled,
|
||||||
smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981",
|
smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981",
|
||||||
imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c",
|
imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c",
|
||||||
folderLookupLabel = "i18n:govoplan-core.folders.c603ab65",
|
|
||||||
busyAction = null,
|
busyAction = null,
|
||||||
onTestSmtp,
|
onTestSmtp,
|
||||||
onTestImap,
|
onTestImap,
|
||||||
onLookupFolders,
|
|
||||||
smtpTestResult = null,
|
smtpTestResult = null,
|
||||||
imapTestResult = null,
|
imapTestResult = null,
|
||||||
folderLookupResult = null,
|
|
||||||
onUseDetectedFolder,
|
|
||||||
useDetectedFolderDisabled = false,
|
|
||||||
mockToggle,
|
|
||||||
append,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className = "",
|
className = "",
|
||||||
floatingResults = false,
|
floatingResults = false,
|
||||||
@@ -254,35 +240,31 @@ export default function MailServerSettingsPanel({
|
|||||||
const imapSecurity = stringValue(imap.security, "tls");
|
const imapSecurity = stringValue(imap.security, "tls");
|
||||||
const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity)));
|
const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity)));
|
||||||
const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity)));
|
const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity)));
|
||||||
const appendTargetFolder = append ? append.folder : stringValue(imap.sent_folder, "auto");
|
|
||||||
const appendTargetDisabled = append ? disabled || append.folderDisabled : imapFieldsDisabled;
|
|
||||||
const canLookupAppendFolders = Boolean(onLookupFolders) && !appendTargetDisabled;
|
|
||||||
const appendTargetHelp = append ?
|
|
||||||
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" :
|
|
||||||
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e";
|
|
||||||
const allSections: {id: MailServerSettingsSection;label: string;}[] = [
|
const allSections: {id: MailServerSettingsSection;label: string;}[] = [
|
||||||
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
|
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
|
||||||
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" },
|
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" }];
|
||||||
{ id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }];
|
|
||||||
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
|
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
|
||||||
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
|
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
|
||||||
const fallbackSection = sections[0]?.id ?? "smtp";
|
const visibleSectionIds = sections.map((section) => section.id);
|
||||||
const resolvedInitialSection = sections.some((section) => section.id === initialSection) ? initialSection : fallbackSection;
|
const fallbackSection = visibleSectionIds[0] ?? "smtp";
|
||||||
const sectionKey = sections.map((section) => section.id).join("|");
|
const resolvedInitialSection = visibleSectionIds.includes(initialSection) ? initialSection : fallbackSection;
|
||||||
|
const sectionKey = visibleSectionIds.join("|");
|
||||||
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
|
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
|
||||||
|
const previousInitialSectionRef = useRef(initialSection);
|
||||||
const showSectionSwitcher = sections.length > 1;
|
const showSectionSwitcher = sections.length > 1;
|
||||||
const showServerFields = mode !== "credentials";
|
const showServerFields = mode !== "credentials";
|
||||||
const showCredentialFields = mode !== "server";
|
const showCredentialFields = mode !== "server";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sections.some((section) => section.id === activeSection)) {
|
const nextSection = resolveMailServerSettingsActiveSection(
|
||||||
setActiveSection(resolvedInitialSection);
|
activeSection,
|
||||||
return;
|
initialSection,
|
||||||
}
|
previousInitialSectionRef.current,
|
||||||
if (initialSection !== activeSection && sections.some((section) => section.id === initialSection)) {
|
visibleSectionIds
|
||||||
setActiveSection(initialSection);
|
);
|
||||||
}
|
previousInitialSectionRef.current = initialSection;
|
||||||
}, [activeSection, initialSection, resolvedInitialSection, sectionKey]);
|
if (nextSection !== activeSection) setActiveSection(nextSection);
|
||||||
|
}, [activeSection, initialSection, sectionKey]);
|
||||||
|
|
||||||
|
|
||||||
function patchSmtpSecurity(security: MailServerSecurity) {
|
function patchSmtpSecurity(security: MailServerSecurity) {
|
||||||
@@ -324,10 +306,10 @@ export default function MailServerSettingsPanel({
|
|||||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||||
{showServerFields &&
|
{showServerFields &&
|
||||||
<>
|
<>
|
||||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
|
|
||||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
|
||||||
|
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{showCredentialFields &&
|
{showCredentialFields &&
|
||||||
@@ -336,9 +318,7 @@ export default function MailServerSettingsPanel({
|
|||||||
onChange={patchSmtpCredentials}
|
onChange={patchSmtpCredentials}
|
||||||
disabled={smtpCredentialFieldsDisabled}
|
disabled={smtpCredentialFieldsDisabled}
|
||||||
savedPassword={smtpPasswordSaved}
|
savedPassword={smtpPasswordSaved}
|
||||||
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder}
|
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder} />
|
||||||
heading="i18n:govoplan-core.credentials.dd097a22"
|
|
||||||
headingClassName="mail-server-field-heading" />
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{onTestSmtp &&
|
{onTestSmtp &&
|
||||||
@@ -355,10 +335,10 @@ export default function MailServerSettingsPanel({
|
|||||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||||
{showServerFields &&
|
{showServerFields &&
|
||||||
<>
|
<>
|
||||||
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
|
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
|
|
||||||
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
|
||||||
|
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
{showCredentialFields &&
|
{showCredentialFields &&
|
||||||
@@ -367,9 +347,7 @@ export default function MailServerSettingsPanel({
|
|||||||
onChange={patchImapCredentials}
|
onChange={patchImapCredentials}
|
||||||
disabled={imapCredentialFieldsDisabled}
|
disabled={imapCredentialFieldsDisabled}
|
||||||
savedPassword={imapPasswordSaved}
|
savedPassword={imapPasswordSaved}
|
||||||
savedPasswordPlaceholder={imapSavedPasswordPlaceholder}
|
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
|
||||||
heading="i18n:govoplan-core.credentials.dd097a22"
|
|
||||||
headingClassName="mail-server-field-heading" />
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{onTestImap &&
|
{onTestImap &&
|
||||||
@@ -381,41 +359,6 @@ export default function MailServerSettingsPanel({
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
{activeSection === "advanced" &&
|
|
||||||
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.advanced_mail_settings.1f69439a">
|
|
||||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid mail-server-advanced-grid">
|
|
||||||
{mockToggle &&
|
|
||||||
<div className="mail-server-field-span mail-server-toggle-row">
|
|
||||||
<ToggleSwitch
|
|
||||||
label={mockToggle.label ?? "i18n:govoplan-core.mock_server_settings.9361d5c5"}
|
|
||||||
checked={mockToggle.checked}
|
|
||||||
disabled={disabled || mockToggle.disabled}
|
|
||||||
onChange={mockToggle.onChange} />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<FormField label="i18n:govoplan-core.smtp_timeout_seconds.ac87c8d2"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
|
|
||||||
<FormField label="i18n:govoplan-core.imap_timeout_seconds.489af2a4"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
|
|
||||||
{append &&
|
|
||||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
|
||||||
<ToggleSwitch label="i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help={appendTargetHelp}>
|
|
||||||
<div className="field-with-action mail-server-folder-field">
|
|
||||||
<input
|
|
||||||
value={appendTargetFolder}
|
|
||||||
disabled={appendTargetDisabled}
|
|
||||||
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
|
|
||||||
placeholder="i18n:govoplan-core.auto.0d612c12" />
|
|
||||||
|
|
||||||
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : folderLookupLabel}</Button>}
|
|
||||||
</div>
|
|
||||||
</FormField>
|
|
||||||
{canLookupAppendFolders && <MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled || appendTargetDisabled} onUseDetected={onUseDetectedFolder} />}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
@@ -437,20 +380,18 @@ export function MailServerActionResult({ result, floating = false }: {result: Ma
|
|||||||
export function MailServerFolderLookupResultView({
|
export function MailServerFolderLookupResultView({
|
||||||
result,
|
result,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onUseDetected
|
onUseDetected,
|
||||||
|
compact = true,
|
||||||
|
floatingFailures = false
|
||||||
|
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;compact?: boolean;floatingFailures?: boolean;}) {
|
||||||
|
|
||||||
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;}) {
|
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
|
return <DismissibleAlert tone="warning" compact={compact} resetKey={result.message} floating={floatingFailures}>{result.message}</DismissibleAlert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folders = result.folders ?? [];
|
const folders = result.folders ?? [];
|
||||||
return (
|
return (
|
||||||
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
<DismissibleAlert tone="success" compact={compact} resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
||||||
<p>{result.message}</p>
|
<p>{result.message}</p>
|
||||||
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p>
|
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p>
|
||||||
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>}
|
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Props = {
|
|||||||
publicMode?: boolean;
|
publicMode?: boolean;
|
||||||
navItems?: PlatformNavItem[];
|
navItems?: PlatformNavItem[];
|
||||||
maintenanceMode?: { enabled: boolean; message?: string | null };
|
maintenanceMode?: { enabled: boolean; message?: string | null };
|
||||||
|
backendReachable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AppShell({
|
export default function AppShell({
|
||||||
@@ -23,7 +24,8 @@ export default function AppShell({
|
|||||||
onAuthChange,
|
onAuthChange,
|
||||||
publicMode = false,
|
publicMode = false,
|
||||||
navItems = [],
|
navItems = [],
|
||||||
maintenanceMode
|
maintenanceMode,
|
||||||
|
backendReachable = true
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ export default function AppShell({
|
|||||||
<div className="app-shell public-shell">
|
<div className="app-shell public-shell">
|
||||||
<IconRail compact auth={auth} navItems={navItems} />
|
<IconRail compact auth={auth} navItems={navItems} />
|
||||||
<div className="app-main public-main">
|
<div className="app-main public-main">
|
||||||
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} />
|
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
|
||||||
<main className="public-content">{children}</main>
|
<main className="public-content">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +45,7 @@ export default function AppShell({
|
|||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<IconRail auth={auth} navItems={navItems} />
|
<IconRail auth={auth} navItems={navItems} />
|
||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} />
|
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
|
||||||
<BreadcrumbBar pathname={location.pathname} />
|
<BreadcrumbBar pathname={location.pathname} />
|
||||||
<main className="app-content">{children}</main>
|
<main className="app-content">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ type Props = {
|
|||||||
onSettingsChange: (settings: ApiSettings) => void;
|
onSettingsChange: (settings: ApiSettings) => void;
|
||||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||||
maintenanceMode?: {enabled: boolean;message?: string | null;};
|
maintenanceMode?: {enabled: boolean;message?: string | null;};
|
||||||
|
backendReachable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode }: Props) {
|
export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode, backendReachable = true }: Props) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const { requestNavigation } = useUnsavedChanges();
|
const { requestNavigation } = useUnsavedChanges();
|
||||||
const [accountOpen, setAccountOpen] = useState(false);
|
const [accountOpen, setAccountOpen] = useState(false);
|
||||||
@@ -161,7 +162,15 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="titlebar">
|
<header className="titlebar">
|
||||||
{maintenanceMode?.enabled &&
|
{!backendReachable ?
|
||||||
|
<div
|
||||||
|
className="backend-offline-topbar-alert"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
title="System not reachable / offline!">
|
||||||
|
System not reachable / offline!
|
||||||
|
</div> :
|
||||||
|
maintenanceMode?.enabled &&
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="maintenance-topbar-link"
|
className="maintenance-topbar-link"
|
||||||
|
|||||||
@@ -1564,20 +1564,31 @@
|
|||||||
box-shadow: var(--shadow-thumb);
|
box-shadow: var(--shadow-thumb);
|
||||||
transition: transform .16s ease;
|
transition: transform .16s ease;
|
||||||
}
|
}
|
||||||
.toggle-switch-input:checked + .toggle-switch-track {
|
.toggle-switch-input:checked ~ .toggle-switch-track {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
}
|
}
|
||||||
.toggle-switch-input:checked + .toggle-switch-track .toggle-switch-thumb {
|
.toggle-switch-input:checked ~ .toggle-switch-track .toggle-switch-thumb {
|
||||||
transform: translateX(18px);
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
.toggle-switch-input:focus-visible + .toggle-switch-track {
|
.toggle-switch-input:focus-visible ~ .toggle-switch-track {
|
||||||
box-shadow: var(--primary-focus-ring-soft), var(--shadow-control-inset-strong);
|
box-shadow: var(--primary-focus-ring-soft), var(--shadow-control-inset-strong);
|
||||||
}
|
}
|
||||||
.toggle-switch-input:disabled + .toggle-switch-track {
|
.toggle-switch-input:disabled ~ .toggle-switch-track {
|
||||||
filter: grayscale(.15);
|
filter: grayscale(.15);
|
||||||
opacity: .7;
|
opacity: .7;
|
||||||
}
|
}
|
||||||
|
.toggle-switch-state-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toggle-switch-state-label[data-selected="true"] {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
.toggle-switch-copy {
|
.toggle-switch-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -1949,6 +1960,21 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-alert {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-alert .alert-message > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-alert .alert-message > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.alert-floating-stack {
|
.alert-floating-stack {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 131px;
|
top: 131px;
|
||||||
@@ -2489,6 +2515,18 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-display-attachment-row > svg {
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-display-attachment-row.is-linked > svg {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-display-attachment-row.is-unlinked > svg {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
.message-display-attachment-row > span {
|
.message-display-attachment-row > span {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
@@ -2580,9 +2618,9 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.mail-server-segmented-control {
|
.mail-server-segmented-control {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
width: min(420px, 100%);
|
width: min(320px, 100%);
|
||||||
}
|
}
|
||||||
.mail-server-settings-view {
|
.mail-server-settings-view {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -2610,7 +2648,7 @@
|
|||||||
letter-spacing: .01em;
|
letter-spacing: .01em;
|
||||||
}
|
}
|
||||||
.form-grid.compact.mail-server-form-grid {
|
.form-grid.compact.mail-server-form-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.mail-server-form-grid {
|
.mail-server-form-grid {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
@@ -2668,7 +2706,7 @@
|
|||||||
.mail-server-settings-grid { grid-template-columns: 1fr; }
|
.mail-server-settings-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.form-grid.compact.mail-server-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.mail-server-segmented-control { width: min(320px, 100%); }
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.credential-panel-grid { grid-template-columns: 1fr; }
|
.credential-panel-grid { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -36,8 +36,11 @@
|
|||||||
.titlebar-notification-button { position: relative; }
|
.titlebar-notification-button { position: relative; }
|
||||||
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
|
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
|
||||||
.account-pill { color: var(--text); }
|
.account-pill { color: var(--text); }
|
||||||
.maintenance-topbar-link { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); border-radius: 6px; min-height: 32px; padding: 0 14px; font: inherit; font-weight: 800; box-shadow: 0 1px 2px var(--hover-tint); cursor: pointer; z-index: 1; }
|
.maintenance-topbar-link,
|
||||||
|
.backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border-radius: 6px; min-height: 32px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; font: inherit; font-weight: 800; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; white-space: nowrap; }
|
||||||
|
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); cursor: pointer; }
|
||||||
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
|
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
|
||||||
|
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--red); color: var(--on-accent); }
|
||||||
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
|
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
|
||||||
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
|
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
.language-menu { min-width: 210px; }
|
.language-menu { min-width: 210px; }
|
||||||
|
|||||||
@@ -16,12 +16,34 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import CredentialPanel from "../src/components/CredentialPanel";
|
import CredentialPanel from "../src/components/CredentialPanel";
|
||||||
import PasswordField from "../src/components/PasswordField";
|
import PasswordField from "../src/components/PasswordField";
|
||||||
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
|
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
|
||||||
import MailServerSettingsPanel from "../src/components/mail/MailServerSettingsPanel";
|
import MailServerSettingsPanel, { MailServerFolderLookupResultView, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
|
||||||
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
||||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
|
|
||||||
|
const allMailServerSections = ["smtp", "imap"] as const;
|
||||||
|
assertEqual(
|
||||||
|
resolveMailServerSettingsActiveSection("imap", "smtp", "smtp", allMailServerSections),
|
||||||
|
"imap",
|
||||||
|
"user-selected mail settings section is not reset to initialSection"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
resolveMailServerSettingsActiveSection("smtp", "imap", "smtp", allMailServerSections),
|
||||||
|
"imap",
|
||||||
|
"changed initialSection is applied when parent intentionally changes it"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
resolveMailServerSettingsActiveSection("imap", "smtp", "smtp", ["smtp"] as const),
|
||||||
|
"smtp",
|
||||||
|
"active mail settings section falls back when hidden by visibleSections"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
resolveMailServerSettingsActiveSection("smtp", "imap", "imap", ["smtp"] as const),
|
||||||
|
"smtp",
|
||||||
|
"hidden initialSection falls back to the first visible section"
|
||||||
|
);
|
||||||
|
|
||||||
const savedPassword = renderToStaticMarkup(
|
const savedPassword = renderToStaticMarkup(
|
||||||
<PasswordField value="" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
|
<PasswordField value="" saved savedPlaceholder="Saved password configured" onValueChange={noop} />
|
||||||
);
|
);
|
||||||
@@ -75,20 +97,14 @@ const settingsPanel = renderToStaticMarkup(
|
|||||||
imapSavedPasswordPlaceholder="Saved IMAP password"
|
imapSavedPasswordPlaceholder="Saved IMAP password"
|
||||||
onTestSmtp={noop}
|
onTestSmtp={noop}
|
||||||
onTestImap={noop}
|
onTestImap={noop}
|
||||||
onLookupFolders={noop}
|
|
||||||
folderLookupResult={{
|
|
||||||
ok: true,
|
|
||||||
protocol: "imap",
|
|
||||||
message: "Folders loaded",
|
|
||||||
detected_sent_folder: "Sent",
|
|
||||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
|
||||||
}}
|
|
||||||
onUseDetectedFolder={noop}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assert(settingsPanel.includes("i18n:govoplan-core.test_smtp.e5697981"), "SMTP test action is rendered");
|
assert(settingsPanel.includes("i18n:govoplan-core.test_smtp.e5697981"), "SMTP test action is rendered");
|
||||||
assert(settingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "server heading is rendered");
|
assert(settingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "server field is rendered");
|
||||||
assert(settingsPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "credentials heading is rendered");
|
assert(settingsPanel.includes("i18n:govoplan-core.username.84c29015"), "username field is rendered");
|
||||||
|
assert(settingsPanel.includes("i18n:govoplan-core.password.8be3c943"), "password field is rendered");
|
||||||
|
assert(settingsPanel.includes("i18n:govoplan-core.timeout_seconds.0bfc6553"), "timeout field is rendered with SMTP server settings");
|
||||||
|
assert(!settingsPanel.includes("i18n:govoplan-core.advanced.4d064726"), "advanced section tab is not rendered");
|
||||||
assert(!settingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered");
|
assert(!settingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered");
|
||||||
assert(settingsPanel.includes('placeholder="Saved SMTP password"'), "SMTP saved credential placeholder is rendered");
|
assert(settingsPanel.includes('placeholder="Saved SMTP password"'), "SMTP saved credential placeholder is rendered");
|
||||||
|
|
||||||
@@ -102,77 +118,45 @@ const imapSettingsPanel = renderToStaticMarkup(
|
|||||||
imapPasswordSaved
|
imapPasswordSaved
|
||||||
imapSavedPasswordPlaceholder="Saved IMAP password"
|
imapSavedPasswordPlaceholder="Saved IMAP password"
|
||||||
onTestImap={noop}
|
onTestImap={noop}
|
||||||
onLookupFolders={noop}
|
|
||||||
folderLookupResult={{
|
|
||||||
ok: true,
|
|
||||||
protocol: "imap",
|
|
||||||
message: "Folders loaded",
|
|
||||||
detected_sent_folder: "Sent",
|
|
||||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
|
||||||
}}
|
|
||||||
onUseDetectedFolder={noop}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assert(imapSettingsPanel.includes("i18n:govoplan-core.test_imap.ef1bd79c"), "IMAP test action is rendered");
|
assert(imapSettingsPanel.includes("i18n:govoplan-core.test_imap.ef1bd79c"), "IMAP test action is rendered");
|
||||||
assert(imapSettingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "IMAP server heading is rendered");
|
assert(imapSettingsPanel.includes("i18n:govoplan-core.server.cb0cb170"), "IMAP server field is rendered");
|
||||||
assert(imapSettingsPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "IMAP credentials heading is rendered");
|
assert(imapSettingsPanel.includes("i18n:govoplan-core.username.84c29015"), "IMAP username field is rendered");
|
||||||
|
assert(imapSettingsPanel.includes("i18n:govoplan-core.password.8be3c943"), "IMAP password field is rendered");
|
||||||
|
assert(imapSettingsPanel.includes("i18n:govoplan-core.timeout_seconds.0bfc6553"), "IMAP timeout field is rendered with IMAP server settings");
|
||||||
assert(!imapSettingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered in IMAP tab");
|
assert(!imapSettingsPanel.includes("Enable IMAP"), "legacy IMAP enable toggle is not rendered in IMAP tab");
|
||||||
assert(!imapSettingsPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is not rendered in the IMAP tab");
|
assert(!imapSettingsPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is not rendered in the IMAP tab");
|
||||||
assert(imapSettingsPanel.includes('placeholder="Saved IMAP password"'), "IMAP saved credential placeholder is rendered");
|
assert(imapSettingsPanel.includes('placeholder="Saved IMAP password"'), "IMAP saved credential placeholder is rendered");
|
||||||
assert(!imapSettingsPanel.includes("Folders loaded"), "folder lookup result is not rendered in the IMAP tab");
|
assert(!imapSettingsPanel.includes("Folders loaded"), "folder lookup result is not rendered in the IMAP tab");
|
||||||
assert(!imapSettingsPanel.includes("Default sent folder"), "default sent folder field is not rendered");
|
assert(!imapSettingsPanel.includes("Default sent folder"), "default sent folder field is not rendered");
|
||||||
|
|
||||||
const advancedSettingsPanel = renderToStaticMarkup(
|
const folderLookupError = renderToStaticMarkup(
|
||||||
<MailServerSettingsPanel
|
<MailServerFolderLookupResultView result={{ ok: false, protocol: "imap", message: "IMAP lookup timed out", folders: [] }} />
|
||||||
initialSection="advanced"
|
);
|
||||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
assert(folderLookupError.includes("compact-alert"), "folder lookup errors use the compact shared alert style");
|
||||||
imap={{ host: "imap.example.org", security: "tls", sent_folder: "Sent" }}
|
assert(folderLookupError.includes("warning"), "folder lookup errors are warnings, not page-level danger alerts");
|
||||||
onSmtpChange={noop}
|
assert(folderLookupError.includes("IMAP lookup timed out"), "folder lookup error message is rendered");
|
||||||
onImapChange={noop}
|
|
||||||
onLookupFolders={noop}
|
const floatingFolderLookupError = renderToStaticMarkup(
|
||||||
folderLookupResult={{
|
<MailServerFolderLookupResultView result={{ ok: false, protocol: "imap", message: "IMAP lookup timed out", folders: [] }} floatingFailures />
|
||||||
|
);
|
||||||
|
assert(floatingFolderLookupError.includes("alert-floating"), "folder lookup errors can be rendered in the floating alert stack");
|
||||||
|
|
||||||
|
const folderLookupSuccess = renderToStaticMarkup(
|
||||||
|
<MailServerFolderLookupResultView
|
||||||
|
result={{
|
||||||
ok: true,
|
ok: true,
|
||||||
protocol: "imap",
|
protocol: "imap",
|
||||||
message: "Folders loaded",
|
message: "Folders loaded",
|
||||||
detected_sent_folder: "Sent",
|
detected_sent_folder: "Sent",
|
||||||
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
folders: [{ name: "INBOX", flags: [] }, { name: "Sent", flags: ["\\Sent"] }]
|
||||||
}}
|
}}
|
||||||
onUseDetectedFolder={noop}
|
onUseDetected={noop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assert(advancedSettingsPanel.includes("i18n:govoplan-core.append_target_folder.0aaacc0c"), "append target folder is the advanced IMAP folder field");
|
assert(folderLookupSuccess.includes("compact-alert"), "folder lookup success uses the compact shared alert style");
|
||||||
assert(advancedSettingsPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is rendered near append target folder");
|
assert(folderLookupSuccess.includes("Folders loaded"), "folder lookup success message is rendered");
|
||||||
assert(advancedSettingsPanel.includes("Folders loaded"), "folder lookup result is rendered in Advanced");
|
|
||||||
assert(advancedSettingsPanel.includes("i18n:govoplan-core.use_detected_folder.5ec4965c"), "detected folder action is rendered near append target folder");
|
|
||||||
assert(advancedSettingsPanel.includes('value="Sent"'), "append target folder uses IMAP sent folder value without append override");
|
|
||||||
|
|
||||||
const disabledAppendFolderPanel = renderToStaticMarkup(
|
|
||||||
<MailServerSettingsPanel
|
|
||||||
initialSection="advanced"
|
|
||||||
smtp={{ host: "smtp.example.org", security: "starttls" }}
|
|
||||||
imap={{ host: "imap.example.org", security: "tls", sent_folder: "Sent" }}
|
|
||||||
onSmtpChange={noop}
|
|
||||||
onImapChange={noop}
|
|
||||||
onLookupFolders={noop}
|
|
||||||
folderLookupResult={{
|
|
||||||
ok: true,
|
|
||||||
protocol: "imap",
|
|
||||||
message: "Folders loaded",
|
|
||||||
detected_sent_folder: "Sent",
|
|
||||||
folders: [{ name: "Sent", flags: ["\\Sent"] }]
|
|
||||||
}}
|
|
||||||
onUseDetectedFolder={noop}
|
|
||||||
append={{
|
|
||||||
enabled: true,
|
|
||||||
folder: "Sent",
|
|
||||||
folderDisabled: true,
|
|
||||||
onEnabledChange: noop,
|
|
||||||
onFolderChange: noop
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is hidden when append target is disabled");
|
|
||||||
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.use_detected_folder.5ec4965c"), "detected folder action is hidden when append target is disabled");
|
|
||||||
|
|
||||||
const smtpServerOnlyPanel = renderToStaticMarkup(
|
const smtpServerOnlyPanel = renderToStaticMarkup(
|
||||||
<MailServerSettingsPanel
|
<MailServerSettingsPanel
|
||||||
@@ -185,7 +169,7 @@ const smtpServerOnlyPanel = renderToStaticMarkup(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assert(smtpServerOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused server panel renders server fields");
|
assert(smtpServerOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused server panel renders server fields");
|
||||||
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "focused server panel hides credential fields");
|
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.username.84c29015"), "focused server panel hides credential fields");
|
||||||
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.mail_server_settings_sections.40a163b7"), "single focused section hides section switcher");
|
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.mail_server_settings_sections.40a163b7"), "single focused section hides section switcher");
|
||||||
assert(!smtpServerOnlyPanel.includes("imap.example.org"), "focused SMTP server panel does not render IMAP fields");
|
assert(!smtpServerOnlyPanel.includes("imap.example.org"), "focused SMTP server panel does not render IMAP fields");
|
||||||
|
|
||||||
@@ -202,7 +186,7 @@ const imapCredentialsOnlyPanel = renderToStaticMarkup(
|
|||||||
imapSavedPasswordPlaceholder="Saved IMAP password"
|
imapSavedPasswordPlaceholder="Saved IMAP password"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
assert(imapCredentialsOnlyPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "focused credentials panel renders credential fields");
|
assert(imapCredentialsOnlyPanel.includes("i18n:govoplan-core.username.84c29015"), "focused credentials panel renders credential fields");
|
||||||
assert(!imapCredentialsOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused credentials panel hides server fields");
|
assert(!imapCredentialsOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused credentials panel hides server fields");
|
||||||
assert(imapCredentialsOnlyPanel.includes('placeholder="Saved IMAP password"'), "focused credentials panel preserves saved password UX");
|
assert(imapCredentialsOnlyPanel.includes('placeholder="Saved IMAP password"'), "focused credentials panel preserves saved password UX");
|
||||||
assert(!imapCredentialsOnlyPanel.includes("smtp.example.org"), "focused IMAP credentials panel does not render SMTP fields");
|
assert(!imapCredentialsOnlyPanel.includes("smtp.example.org"), "focused IMAP credentials panel does not render SMTP fields");
|
||||||
|
|||||||
Reference in New Issue
Block a user