mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
565 lines
23 KiB
TypeScript
565 lines
23 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
|
import { Button } from '@/components/ui/button';
|
|
import { RichTextEditor } from '@/components/email/rich-text-editor';
|
|
import { VacationFiltersError, useVacationStore, type VacationForwardSettings } from '@/stores/vacation-store';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useIdentityStore } from '@/stores/identity-store';
|
|
import { useManagedAccountStore } from '@/stores/managed-account-store';
|
|
import type { VacationForward } from '@/lib/jmap/sieve-types';
|
|
import { isPeriodBoundary } from '@/lib/sieve/period';
|
|
import { ownDomains } from '@/lib/sieve/vacation-audience';
|
|
import { sanitizeEmailHtml } from '@/lib/email-sanitization';
|
|
import { htmlToPlainText } from '@/lib/html-to-text';
|
|
import { isValidEmail } from '@/lib/validation';
|
|
import { Loader2, AlertTriangle, Eye, EyeOff } from '@/components/icons';
|
|
import { toast } from '@/stores/toast-store';
|
|
|
|
const STALWART_VACATION_LIMITS = { subject: 511, body: 2047 };
|
|
|
|
function byteLength(value: string): number {
|
|
return new TextEncoder().encode(value).length;
|
|
}
|
|
|
|
/** A switch with its state written out next to it ("Active" / "Inactive"). */
|
|
function StatusSwitch({ checked, onChange, activeLabel, inactiveLabel }: {
|
|
checked: boolean;
|
|
onChange: (checked: boolean) => void;
|
|
activeLabel: string;
|
|
inactiveLabel: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
|
checked
|
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
|
: 'bg-muted text-muted-foreground'
|
|
}`}>
|
|
{checked ? activeLabel : inactiveLabel}
|
|
</span>
|
|
<ToggleSwitch checked={checked} onChange={onChange} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function utcToLocalDatetime(utcIso: string): string {
|
|
const d = new Date(utcIso);
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
// The latest moment the date fields offer: a later year has no room in the
|
|
// four digits a stored date has.
|
|
const DATE_INPUT_MAX = '9999-12-31T23:59';
|
|
|
|
/**
|
|
* The moment a date field holds: '' when it is empty, null when it holds none
|
|
* that can be stored (a five-digit year). A field left as it was loaded keeps
|
|
* the stored value exactly.
|
|
*/
|
|
function fieldMoment(input: string, stored: string | null): string | null {
|
|
if (!input) return '';
|
|
if (stored && input === utcToLocalDatetime(stored)) return stored;
|
|
const moment = new Date(input);
|
|
if (Number.isNaN(moment.getTime())) return null;
|
|
const iso = moment.toISOString();
|
|
return isPeriodBoundary(iso) ? iso : null;
|
|
}
|
|
|
|
function sameForward(a: VacationForwardSettings | null, b: VacationForward | null): boolean {
|
|
if (!a || !b) return a === b;
|
|
return a.enabled === b.enabled && a.to === b.to && a.keepCopy === b.keepCopy;
|
|
}
|
|
|
|
export function VacationSettings() {
|
|
const t = useTranslations('settings.vacation');
|
|
const tFilters = useTranslations('settings.filters');
|
|
const tNotifications = useTranslations('notifications');
|
|
const { client } = useAuthStore();
|
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
|
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
|
// What is typed here belongs to the account it was typed for: on a switch
|
|
// the fields start over, even where both accounts store the same values.
|
|
const accountKey = `${activeAccountId ?? ''}/${managedAccountId ?? ''}`;
|
|
const {
|
|
isEnabled,
|
|
fromDate,
|
|
toDate,
|
|
subject,
|
|
textBody,
|
|
htmlBody,
|
|
forward,
|
|
forwardAvailable,
|
|
audience,
|
|
audienceAvailable,
|
|
notRunning,
|
|
otherForwards,
|
|
isLoading,
|
|
isSaving,
|
|
error,
|
|
isSupported,
|
|
fetchVacationResponse,
|
|
updateVacationResponse,
|
|
} = useVacationStore();
|
|
|
|
const [localEnabled, setLocalEnabled] = useState(isEnabled);
|
|
// The date fields as typed ("yyyy-mm-ddThh:mm" on the browser's clock).
|
|
const [fromInput, setFromInput] = useState(() => (fromDate ? utcToLocalDatetime(fromDate) : ''));
|
|
const [toInput, setToInput] = useState(() => (toDate ? utcToLocalDatetime(toDate) : ''));
|
|
// A field the browser holds half filled in (a date without its time) reads
|
|
// as empty, without an event when it was empty before: told apart here, so
|
|
// it does not pass for "no date", which leaves that end of the period open.
|
|
const [halfFilled, setHalfFilled] = useState({ from: false, to: false });
|
|
const fromRef = useRef<HTMLInputElement>(null);
|
|
const toRef = useRef<HTMLInputElement>(null);
|
|
const localFromDate = fieldMoment(fromInput, fromDate);
|
|
const localToDate = fieldMoment(toInput, toDate);
|
|
const datesUnusable = localFromDate === null || localToDate === null || halfFilled.from || halfFilled.to;
|
|
const [localSubject, setLocalSubject] = useState(subject);
|
|
const [localTextBody, setLocalTextBody] = useState(textBody);
|
|
const [htmlEnabled, setHtmlEnabled] = useState(!!htmlBody);
|
|
const [localHtmlBody, setLocalHtmlBody] = useState(htmlBody || '');
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
|
|
const [forwardEnabled, setForwardEnabled] = useState(forward?.enabled ?? false);
|
|
const [forwardTo, setForwardTo] = useState(forward?.to ?? '');
|
|
const [forwardKeep, setForwardKeep] = useState(forward?.keepCopy ?? false);
|
|
const [audienceOnly, setAudienceOnly] = useState<'all' | 'internal' | 'external'>(audience?.only ?? 'all');
|
|
// "Internal" are the account's own domains, as its identities give them.
|
|
const identities = useIdentityStore((s) => s.identities);
|
|
const domains = useMemo(() => ownDomains(identities.map((i) => i.email)), [identities]);
|
|
|
|
useEffect(() => {
|
|
if (client && isSupported) {
|
|
void fetchVacationResponse(client, managedAccountId ?? undefined);
|
|
}
|
|
}, [client, isSupported, managedAccountId, fetchVacationResponse]);
|
|
|
|
useEffect(() => {
|
|
setLocalEnabled(isEnabled);
|
|
setFromInput(fromDate ? utcToLocalDatetime(fromDate) : '');
|
|
setToInput(toDate ? utcToLocalDatetime(toDate) : '');
|
|
setHalfFilled({ from: false, to: false });
|
|
setLocalSubject(subject);
|
|
setLocalTextBody(textBody);
|
|
setHtmlEnabled(!!htmlBody);
|
|
setLocalHtmlBody(htmlBody || '');
|
|
}, [accountKey, isEnabled, fromDate, toDate, subject, textBody, htmlBody]);
|
|
|
|
useEffect(() => {
|
|
setForwardEnabled(forward?.enabled ?? false);
|
|
setForwardTo(forward?.to ?? '');
|
|
setForwardKeep(forward?.keepCopy ?? false);
|
|
}, [accountKey, forward]);
|
|
|
|
useEffect(() => {
|
|
setAudienceOnly(audience?.only ?? 'all');
|
|
}, [accountKey, audience]);
|
|
|
|
// Reads a date field as the browser holds it, half filled in or not.
|
|
const readDateField = (field: 'from' | 'to', input: HTMLInputElement) => {
|
|
(field === 'from' ? setFromInput : setToInput)(input.value);
|
|
const half = input.validity.badInput;
|
|
setHalfFilled((current) => (current[field] === half ? current : { ...current, [field]: half }));
|
|
};
|
|
|
|
// Who gets the auto-reply as a save stores it: everyone, or the senders
|
|
// from (or not from) the account's domains as they are now. The domains
|
|
// come from the signed-in user's identities, so a managed (shared) account
|
|
// offers no narrowing rather than one built from the wrong domains.
|
|
const canNarrow = audienceAvailable && !managedAccountId && domains.length > 0;
|
|
const audienceSettings = !canNarrow
|
|
? undefined
|
|
: audienceOnly === 'all' ? null : { only: audienceOnly, domains };
|
|
const audienceChanged = canNarrow && (
|
|
audienceOnly !== (audience?.only ?? 'all') ||
|
|
(audienceOnly !== 'all' && domains.join(',') !== (audience?.domains ?? []).join(','))
|
|
);
|
|
|
|
// Forwarding as a save stores it: an address that is off is kept for next
|
|
// time if it is usable, and dropped otherwise.
|
|
const forwardInvalid = forwardAvailable && forwardEnabled && !isValidEmail(forwardTo);
|
|
const forwardSettings = !forwardAvailable
|
|
? undefined
|
|
: forwardEnabled || isValidEmail(forwardTo)
|
|
? { enabled: forwardEnabled, to: forwardTo, keepCopy: forwardKeep }
|
|
: null;
|
|
// Only what was changed here is sent: the rest stays as the server has it,
|
|
// which another tab or device may have changed since this one loaded.
|
|
const forwardChanged = forwardSettings !== undefined && !sameForward(forwardSettings, forward);
|
|
// Kept here, the message goes on through the filter rules, whose forwards
|
|
// share the server's limit with this one.
|
|
const forwardLimit = client?.getSieveCapabilities(managedAccountId ?? undefined)?.maxNumberRedirects;
|
|
const forwardOverLimit =
|
|
forwardAvailable && forwardEnabled && forwardKeep &&
|
|
typeof forwardLimit === 'number' && forwardLimit > 0 && 1 + otherForwards > forwardLimit;
|
|
|
|
// What a save sends: the sanitized HTML and a plain-text part, derived
|
|
// from the HTML when the user left it blank (for clients without HTML).
|
|
const payload = useMemo(() => {
|
|
const sanitizedHtml =
|
|
htmlEnabled && htmlToPlainText(localHtmlBody).trim()
|
|
? sanitizeEmailHtml(localHtmlBody)
|
|
: null;
|
|
const text =
|
|
localTextBody.trim() || !sanitizedHtml
|
|
? localTextBody
|
|
: htmlToPlainText(sanitizedHtml, { paragraphSpacing: true });
|
|
return { sanitizedHtml, textBody: text };
|
|
}, [htmlEnabled, localHtmlBody, localTextBody]);
|
|
|
|
// Stalwart refuses a subject of 512 bytes or more and a text or HTML body
|
|
// of 2048 bytes or more, answering only "Field could not be set."
|
|
const sizeLimits = client?.hasAccountCapability('urn:stalwart:jmap') ? STALWART_VACATION_LIMITS : null;
|
|
const oversize = useMemo(() => {
|
|
if (!sizeLimits) return { subject: false, body: false };
|
|
return {
|
|
subject: byteLength(localSubject) > sizeLimits.subject,
|
|
body: byteLength(payload.textBody) > sizeLimits.body ||
|
|
(payload.sanitizedHtml !== null && byteLength(payload.sanitizedHtml) > sizeLimits.body),
|
|
};
|
|
}, [sizeLimits, localSubject, payload]);
|
|
|
|
const validate = useCallback(() => {
|
|
const warnings: string[] = [];
|
|
|
|
if (datesUnusable) {
|
|
warnings.push(t('warnings.invalid_date'));
|
|
}
|
|
if (localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) {
|
|
warnings.push(t('warnings.end_before_start'));
|
|
}
|
|
|
|
const todayStart = new Date();
|
|
todayStart.setHours(0, 0, 0, 0);
|
|
if (localFromDate && new Date(localFromDate) < todayStart) {
|
|
warnings.push(t('warnings.start_in_past'));
|
|
}
|
|
|
|
const hasHtmlContent = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
|
|
if (localEnabled && !localTextBody.trim() && !hasHtmlContent) {
|
|
warnings.push(t('warnings.empty_body'));
|
|
}
|
|
|
|
if (oversize.subject && sizeLimits) {
|
|
warnings.push(t('warnings.subject_too_long', { max: sizeLimits.subject }));
|
|
}
|
|
if (oversize.body && sizeLimits) {
|
|
warnings.push(t('warnings.body_too_long', { max: sizeLimits.body }));
|
|
}
|
|
|
|
if (forwardInvalid) {
|
|
warnings.push(t('warnings.forward_address'));
|
|
}
|
|
if (forwardOverLimit) {
|
|
warnings.push(tFilters('forward_limit', { count: forwardLimit }));
|
|
}
|
|
if (notRunning) {
|
|
warnings.push(t('warnings.not_running'));
|
|
}
|
|
|
|
setValidationWarnings(warnings);
|
|
return warnings;
|
|
}, [datesUnusable, localFromDate, localToDate, localEnabled, localTextBody, htmlEnabled, localHtmlBody, oversize, sizeLimits, forwardInvalid, forwardOverLimit, forwardLimit, notRunning, t, tFilters]);
|
|
|
|
useEffect(() => {
|
|
validate();
|
|
}, [validate]);
|
|
|
|
const hasChanges =
|
|
localEnabled !== isEnabled ||
|
|
(localFromDate || null) !== (fromDate || null) ||
|
|
(localToDate || null) !== (toDate || null) ||
|
|
localSubject !== subject ||
|
|
localTextBody !== textBody ||
|
|
(htmlEnabled ? localHtmlBody : '') !== (htmlBody || '') ||
|
|
forwardChanged ||
|
|
audienceChanged ||
|
|
// Saving sets right what is stored but does not run.
|
|
notRunning;
|
|
|
|
const hasBlockingError =
|
|
datesUnusable ||
|
|
!!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) ||
|
|
oversize.subject || oversize.body || forwardInvalid;
|
|
|
|
const handleSave = async () => {
|
|
if (!client) return;
|
|
// Half filled in without a single event: only the field itself knows.
|
|
const half = { from: !!fromRef.current?.validity.badInput, to: !!toRef.current?.validity.badInput };
|
|
if (half.from || half.to) {
|
|
setHalfFilled(half);
|
|
return;
|
|
}
|
|
validate();
|
|
if (hasBlockingError) return;
|
|
|
|
const { sanitizedHtml, textBody } = payload;
|
|
|
|
try {
|
|
const saved = await updateVacationResponse(client, {
|
|
isEnabled: localEnabled,
|
|
fromDate: localFromDate || null,
|
|
toDate: localToDate || null,
|
|
subject: localSubject,
|
|
textBody,
|
|
htmlBody: sanitizedHtml,
|
|
forward: forwardChanged ? forwardSettings : undefined,
|
|
audience: audienceChanged ? audienceSettings : undefined,
|
|
}, managedAccountId ?? undefined);
|
|
|
|
// false: a plugin stopped the change and is expected to have said why.
|
|
if (saved) toast.success(tNotifications('vacation_saved'));
|
|
} catch (error) {
|
|
console.error('Failed to save vacation response:', error);
|
|
toast.error(tNotifications(
|
|
error instanceof VacationFiltersError ? 'vacation_filters_save_failed' : 'vacation_save_failed',
|
|
));
|
|
}
|
|
};
|
|
|
|
if (!isSupported) {
|
|
return (
|
|
<SettingsSection title={t('title')} description={t('description')}>
|
|
<div className="text-sm text-muted-foreground py-4">
|
|
{t('not_supported')}
|
|
</div>
|
|
</SettingsSection>
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<SettingsSection title={t('title')} description={t('description')}>
|
|
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{t('loading')}
|
|
</div>
|
|
</SettingsSection>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<SettingsSection title={t('title')} description={t('description')}>
|
|
<div className="text-sm text-red-600 dark:text-red-400 py-4">
|
|
{t('fetch_error')}
|
|
</div>
|
|
</SettingsSection>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* The period is the absence: the auto-reply and forwarding both keep to it. */}
|
|
<SettingsSection title={t('title')} description={t('description')}>
|
|
<SettingItem
|
|
label={t('date_range.start')}
|
|
description={t('date_range.start_description')}
|
|
>
|
|
<input
|
|
ref={fromRef}
|
|
type="datetime-local"
|
|
max={DATE_INPUT_MAX}
|
|
value={fromInput}
|
|
onChange={(e) => readDateField('from', e.currentTarget)}
|
|
onBlur={(e) => readDateField('from', e.currentTarget)}
|
|
aria-invalid={localFromDate === null || halfFilled.from}
|
|
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
|
/>
|
|
</SettingItem>
|
|
<SettingItem
|
|
label={t('date_range.end')}
|
|
description={t('date_range.end_description')}
|
|
>
|
|
<input
|
|
ref={toRef}
|
|
type="datetime-local"
|
|
max={DATE_INPUT_MAX}
|
|
value={toInput}
|
|
onChange={(e) => readDateField('to', e.currentTarget)}
|
|
onBlur={(e) => readDateField('to', e.currentTarget)}
|
|
aria-invalid={localToDate === null || halfFilled.to}
|
|
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
|
/>
|
|
</SettingItem>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection id="reply" title={t('auto_reply.title')} description={t('status.description')}>
|
|
<SettingItem label={t('status.label')}>
|
|
<StatusSwitch
|
|
checked={localEnabled}
|
|
onChange={setLocalEnabled}
|
|
activeLabel={t('status.active')}
|
|
inactiveLabel={t('status.inactive')}
|
|
/>
|
|
</SettingItem>
|
|
{canNarrow && (
|
|
<SettingItem
|
|
label={t('audience.label')}
|
|
description={t('audience.description', { domains: domains.join(', ') })}
|
|
>
|
|
<Select
|
|
value={audienceOnly}
|
|
onChange={(value) => setAudienceOnly(value as 'all' | 'internal' | 'external')}
|
|
options={[
|
|
{ value: 'all', label: t('audience.all') },
|
|
{ value: 'internal', label: t('audience.internal') },
|
|
{ value: 'external', label: t('audience.external') },
|
|
]}
|
|
/>
|
|
</SettingItem>
|
|
)}
|
|
<SettingItem
|
|
label={t('message.subject_label')}
|
|
description={t('message.subject_description')}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={localSubject}
|
|
onChange={(e) => setLocalSubject(e.target.value)}
|
|
placeholder={t('message.subject_placeholder')}
|
|
className="w-64 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
|
/>
|
|
</SettingItem>
|
|
<div className="py-3">
|
|
<label htmlFor="vacation-body" className="text-sm font-medium text-foreground block mb-1">
|
|
{t('message.body_label')}
|
|
</label>
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
{t('message.body_description')}
|
|
</p>
|
|
<textarea
|
|
id="vacation-body"
|
|
value={localTextBody}
|
|
onChange={(e) => setLocalTextBody(e.target.value)}
|
|
placeholder={t('message.body_placeholder')}
|
|
rows={6}
|
|
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground resize-y"
|
|
/>
|
|
</div>
|
|
<SettingItem
|
|
label={t('message.html_label')}
|
|
description={t('message.html_description')}
|
|
>
|
|
<ToggleSwitch checked={htmlEnabled} onChange={setHtmlEnabled} />
|
|
</SettingItem>
|
|
{htmlEnabled && (
|
|
<div className="pb-3">
|
|
<div className="rounded-md border border-border overflow-hidden">
|
|
<RichTextEditor
|
|
content={localHtmlBody}
|
|
onChange={setLocalHtmlBody}
|
|
placeholder={t('message.html_placeholder')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</SettingsSection>
|
|
|
|
{(() => {
|
|
const showHtmlPreview = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
|
|
if (!localTextBody.trim() && !showHtmlPreview) return null;
|
|
return (
|
|
<SettingsSection title={t('preview.title')}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
|
>
|
|
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
{showPreview ? t('preview.hide') : t('preview.show')}
|
|
</button>
|
|
{showPreview && (
|
|
<div className="mt-3 p-4 rounded border border-border bg-background">
|
|
{localSubject && (
|
|
<p className="font-medium text-foreground mb-2">{localSubject}</p>
|
|
)}
|
|
{showHtmlPreview ? (
|
|
<div
|
|
className="text-sm text-foreground [&_a]:text-primary [&_a]:underline"
|
|
// Preview renders into the app's own DOM. Intercept anchor
|
|
// clicks so following a link doesn't navigate the whole app
|
|
// away (and lose the unsaved responder), opening a new tab.
|
|
onClick={(e) => {
|
|
const anchor = (e.target as HTMLElement).closest('a');
|
|
if (anchor?.href) {
|
|
e.preventDefault();
|
|
window.open(anchor.href, '_blank', 'noopener,noreferrer');
|
|
}
|
|
}}
|
|
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(localHtmlBody) }}
|
|
/>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</SettingsSection>
|
|
);
|
|
})()}
|
|
|
|
{forwardAvailable && (
|
|
<SettingsSection id="forward" title={t('forward.title')} description={t('forward.description')}>
|
|
<SettingItem label={t('forward.enabled_label')}>
|
|
<StatusSwitch
|
|
checked={forwardEnabled}
|
|
onChange={setForwardEnabled}
|
|
activeLabel={t('status.active')}
|
|
inactiveLabel={t('status.inactive')}
|
|
/>
|
|
</SettingItem>
|
|
{forwardEnabled && (
|
|
<>
|
|
<SettingItem label={t('forward.to_label')}>
|
|
<input
|
|
type="email"
|
|
value={forwardTo}
|
|
onChange={(e) => setForwardTo(e.target.value)}
|
|
placeholder={t('forward.to_placeholder')}
|
|
aria-label={t('forward.to_label')}
|
|
aria-invalid={forwardInvalid}
|
|
className="w-64 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
|
/>
|
|
</SettingItem>
|
|
<SettingItem label={t('forward.keep_label')} description={t('forward.keep_description')}>
|
|
<ToggleSwitch checked={forwardKeep} onChange={setForwardKeep} />
|
|
</SettingItem>
|
|
</>
|
|
)}
|
|
</SettingsSection>
|
|
)}
|
|
|
|
{validationWarnings.length > 0 && (
|
|
<div className="space-y-2">
|
|
{validationWarnings.map((warning, i) => (
|
|
<div key={i} className="flex items-start gap-2 text-sm text-warning">
|
|
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
|
<span>{warning}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={isSaving || !hasChanges || hasBlockingError}
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 me-2 animate-spin" />
|
|
{t('saving')}
|
|
</>
|
|
) : (
|
|
t('save')
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|