feat: expand internationalization and add identity management
This release significantly expands internationalization support and adds comprehensive identity management features. Internationalization (i18n): - Add 5 new languages: Spanish, Italian, German, Dutch, Portuguese - Expand from 3 to 8 total supported languages - Redesign language switcher for better scalability (dropdown UI) - Complete translations for all features across all languages Identity Management: - Multiple sender identities with per-identity signatures - Sub-addressing support (user+tag@domain.com) - Context-aware tag suggestions for sub-addresses - Identity badges in email viewer and list - Full CRUD operations for managing identities Newsletter Management: - RFC 2369 List-Unsubscribe support (one-click unsubscribe) - HTTP and mailto unsubscribe methods - Security validation prevents XSS attacks - Two-step confirmation with persistent dismissal Security & Accessibility: - Dark mode email readability (intelligent color transformation) - WCAG 2.0 Level AA color contrast compliance - Comprehensive XSS prevention with validation utilities - Unit test coverage for security-critical code (57 validation tests) Testing: - Add unit tests for validation utilities - Add unit tests for email sanitization - Add unit tests for color transformation - Full test coverage for XSS attack vectors
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||
|
||||
interface IdentityFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo?: EmailAddress[];
|
||||
bcc?: EmailAddress[];
|
||||
textSignature?: string;
|
||||
htmlSignature?: string;
|
||||
}
|
||||
|
||||
interface IdentityFormProps {
|
||||
identity?: Identity;
|
||||
onSave: (data: IdentityFormData) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps) {
|
||||
const t = useTranslations('identities.form');
|
||||
const tValidation = useTranslations('identities.validation_errors');
|
||||
const tDisplay = useTranslations('identities.display');
|
||||
const isEditing = !!identity;
|
||||
|
||||
const [formData, setFormData] = useState<IdentityFormData>({
|
||||
name: identity?.name || '',
|
||||
email: identity?.email || '',
|
||||
replyTo: identity?.replyTo,
|
||||
bcc: identity?.bcc,
|
||||
textSignature: identity?.textSignature || '',
|
||||
htmlSignature: identity?.htmlSignature || '',
|
||||
});
|
||||
|
||||
const [replyToInput, setReplyToInput] = useState(
|
||||
identity?.replyTo?.map(a => a.email).join(', ') || ''
|
||||
);
|
||||
const [bccInput, setBccInput] = useState(
|
||||
identity?.bcc?.map(a => a.email).join(', ') || ''
|
||||
);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const parseEmailList = (input: string): EmailAddress[] | undefined => {
|
||||
if (!input.trim()) return undefined;
|
||||
|
||||
const emails = input.split(',').map(e => e.trim()).filter(Boolean);
|
||||
return emails.map(email => ({ email }));
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = t('name_required');
|
||||
}
|
||||
|
||||
// Use secure email validation
|
||||
const emailError = getEmailValidationError(formData.email);
|
||||
if (emailError) {
|
||||
newErrors.email = emailError;
|
||||
}
|
||||
|
||||
// Validate reply-to email list
|
||||
if (replyToInput.trim()) {
|
||||
const validation = validateEmailList(replyToInput);
|
||||
if (!validation.valid) {
|
||||
newErrors.replyTo = tValidation('invalid_emails', { emails: validation.invalidEmails.join(', ') });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate bcc email list
|
||||
if (bccInput.trim()) {
|
||||
const validation = validateEmailList(bccInput);
|
||||
if (!validation.valid) {
|
||||
newErrors.bcc = tValidation('invalid_emails', { emails: validation.invalidEmails.join(', ') });
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Sanitize HTML signature before sending to server
|
||||
const sanitizedData: IdentityFormData = {
|
||||
...formData,
|
||||
replyTo: parseEmailList(replyToInput),
|
||||
bcc: parseEmailList(bccInput),
|
||||
htmlSignature: formData.htmlSignature
|
||||
? sanitizeSignatureHtml(formData.htmlSignature)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
await onSave(sanitizedData);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="identity-name" className="block text-sm font-medium mb-1">
|
||||
{t('name_label')} <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="identity-name"
|
||||
type="text"
|
||||
maxLength={256}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t('name_placeholder')}
|
||||
disabled={isSubmitting}
|
||||
className={errors.name ? 'border-destructive' : ''}
|
||||
aria-describedby={errors.name ? 'name-error' : undefined}
|
||||
aria-invalid={!!errors.name}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p
|
||||
id="name-error"
|
||||
className="text-sm text-destructive mt-1"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{errors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="identity-email" className="block text-sm font-medium mb-1">
|
||||
{t('email_label')} <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="identity-email"
|
||||
type="email"
|
||||
maxLength={254}
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder={t('email_placeholder')}
|
||||
disabled={isSubmitting || isEditing}
|
||||
className={errors.email ? 'border-destructive' : ''}
|
||||
aria-describedby={errors.email ? 'email-error' : undefined}
|
||||
aria-invalid={!!errors.email}
|
||||
/>
|
||||
{isEditing && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('email_immutable')}
|
||||
</p>
|
||||
)}
|
||||
{errors.email && (
|
||||
<p
|
||||
id="email-error"
|
||||
className="text-sm text-destructive mt-1"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{errors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply-To */}
|
||||
<div>
|
||||
<label htmlFor="identity-reply-to" className="block text-sm font-medium mb-1">
|
||||
{t('reply_to_label')}
|
||||
</label>
|
||||
<Input
|
||||
id="identity-reply-to"
|
||||
type="text"
|
||||
maxLength={512}
|
||||
value={replyToInput}
|
||||
onChange={(e) => setReplyToInput(e.target.value)}
|
||||
placeholder={t('reply_to_placeholder')}
|
||||
disabled={isSubmitting}
|
||||
className={errors.replyTo ? 'border-destructive' : ''}
|
||||
aria-describedby={errors.replyTo ? 'reply-to-error' : undefined}
|
||||
aria-invalid={!!errors.replyTo}
|
||||
/>
|
||||
{errors.replyTo && (
|
||||
<p
|
||||
id="reply-to-error"
|
||||
className="text-sm text-destructive mt-1"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{errors.replyTo}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* BCC */}
|
||||
<div>
|
||||
<label htmlFor="identity-bcc" className="block text-sm font-medium mb-1">
|
||||
{t('bcc_label')}
|
||||
</label>
|
||||
<Input
|
||||
id="identity-bcc"
|
||||
type="text"
|
||||
maxLength={512}
|
||||
value={bccInput}
|
||||
onChange={(e) => setBccInput(e.target.value)}
|
||||
placeholder={t('bcc_placeholder')}
|
||||
disabled={isSubmitting}
|
||||
className={errors.bcc ? 'border-destructive' : ''}
|
||||
aria-describedby={errors.bcc ? 'bcc-error' : undefined}
|
||||
aria-invalid={!!errors.bcc}
|
||||
/>
|
||||
{errors.bcc && (
|
||||
<p
|
||||
id="bcc-error"
|
||||
className="text-sm text-destructive mt-1"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{errors.bcc}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text Signature */}
|
||||
<div>
|
||||
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
|
||||
{t('text_signature_label')}
|
||||
</label>
|
||||
<textarea
|
||||
id="identity-text-sig"
|
||||
maxLength={2000}
|
||||
value={formData.textSignature}
|
||||
onChange={(e) => setFormData({ ...formData, textSignature: e.target.value })}
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
aria-label={t('text_signature_label')}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTML Signature */}
|
||||
<div>
|
||||
<label htmlFor="identity-html-sig" className="block text-sm font-medium mb-1">
|
||||
{t('html_signature_label')}
|
||||
</label>
|
||||
<textarea
|
||||
id="identity-html-sig"
|
||||
maxLength={5000}
|
||||
value={formData.htmlSignature}
|
||||
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
|
||||
rows={5}
|
||||
disabled={isSubmitting}
|
||||
aria-label={t('html_signature_label')}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
{formData.htmlSignature && (
|
||||
<div className="mt-2 p-2 border rounded bg-muted">
|
||||
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeSignatureHtml(formData.htmlSignature)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? isEditing
|
||||
? t('updating')
|
||||
: t('creating')
|
||||
: t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { X, Mail, Pencil, Trash2, Plus, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IdentityForm } from './identity-form';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
|
||||
interface IdentityFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo?: EmailAddress[];
|
||||
bcc?: EmailAddress[];
|
||||
textSignature?: string;
|
||||
htmlSignature?: string;
|
||||
}
|
||||
|
||||
interface IdentityManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalProps) {
|
||||
const t = useTranslations('identities');
|
||||
const tNotif = useTranslations('notifications');
|
||||
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore();
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// Focus trap with Escape handling
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: () => {
|
||||
if (isCreating || editingId) {
|
||||
setIsCreating(false);
|
||||
setEditingId(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen, onClose, modalRef]);
|
||||
|
||||
const handleCreate = useCallback(async (data: IdentityFormData) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const newIdentity = await client.createIdentity(
|
||||
data.name,
|
||||
data.email,
|
||||
data.replyTo,
|
||||
data.bcc,
|
||||
data.textSignature,
|
||||
data.htmlSignature
|
||||
);
|
||||
|
||||
addIdentity(newIdentity);
|
||||
setIsCreating(false);
|
||||
toast.success(tNotif('identity_created'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
|
||||
toast.error(tNotif('identity_create_failed', { error: message }));
|
||||
throw error;
|
||||
}
|
||||
}, [client, addIdentity, t, tNotif]);
|
||||
|
||||
const handleUpdate = useCallback(async (identity: Identity, data: IdentityFormData) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.updateIdentity(identity.id, {
|
||||
name: data.name,
|
||||
replyTo: data.replyTo,
|
||||
bcc: data.bcc,
|
||||
textSignature: data.textSignature,
|
||||
htmlSignature: data.htmlSignature,
|
||||
});
|
||||
|
||||
updateIdentityLocal(identity.id, data);
|
||||
setEditingId(null);
|
||||
toast.success(tNotif('identity_updated'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
|
||||
toast.error(tNotif('identity_update_failed', { error: message }));
|
||||
throw error;
|
||||
}
|
||||
}, [client, updateIdentityLocal, t, tNotif]);
|
||||
|
||||
const handleDelete = useCallback(async (identity: Identity) => {
|
||||
if (!client) return;
|
||||
if (!identity.mayDelete) {
|
||||
toast.error(t('cannot_delete'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(t('delete_confirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingId(identity.id);
|
||||
|
||||
try {
|
||||
await client.deleteIdentity(identity.id);
|
||||
removeIdentity(identity.id);
|
||||
toast.success(tNotif('identity_deleted'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
|
||||
toast.error(tNotif('identity_delete_failed', { error: message }));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}, [client, removeIdentity, t, tNotif]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="identity-modal-title"
|
||||
className={cn(
|
||||
'bg-background border border-border rounded-lg shadow-xl',
|
||||
'w-full max-w-3xl max-h-[90vh] overflow-hidden',
|
||||
'animate-in zoom-in-95 duration-200'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-5 h-5 text-muted-foreground" />
|
||||
<h2 id="identity-modal-title" className="text-lg font-semibold text-foreground">
|
||||
{t('modal_title')}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
{/* Create New Form */}
|
||||
{isCreating && (
|
||||
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('create_new')}</h3>
|
||||
<IdentityForm
|
||||
onSave={handleCreate}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Button */}
|
||||
{!isCreating && !editingId && (
|
||||
<Button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="mb-6 w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('create_new')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Identities List */}
|
||||
<div className="space-y-4">
|
||||
{identities.map((identity) => (
|
||||
<div
|
||||
key={identity.id}
|
||||
className="border border-border rounded-lg overflow-hidden"
|
||||
>
|
||||
{editingId === identity.id ? (
|
||||
<div className="p-4 bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">
|
||||
{t('edit_identity')}
|
||||
</h3>
|
||||
<IdentityForm
|
||||
identity={identity}
|
||||
onSave={(data) => handleUpdate(identity, data)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-foreground truncate">
|
||||
{identity.name}
|
||||
</h3>
|
||||
{identities[0]?.id === identity.id && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
|
||||
{t('primary_identity')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{identity.email}
|
||||
</p>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||
{identity.replyTo && identity.replyTo.length > 0 && (
|
||||
<p>
|
||||
{t('display.reply_to')} {identity.replyTo.map((a) => a.email).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{identity.bcc && identity.bcc.length > 0 && (
|
||||
<p>
|
||||
{t('display.bcc')} {identity.bcc.map((a) => a.email).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{identity.textSignature && (
|
||||
<p className="line-clamp-2">
|
||||
{t('display.signature')} {identity.textSignature}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingId(identity.id)}
|
||||
disabled={!!editingId || isCreating}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(identity)}
|
||||
disabled={
|
||||
!identity.mayDelete ||
|
||||
!!editingId ||
|
||||
isCreating ||
|
||||
deletingId === identity.id
|
||||
}
|
||||
className={!identity.mayDelete ? 'opacity-30' : ''}
|
||||
>
|
||||
{!identity.mayDelete ? (
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!identity.mayDelete && (
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-500 mt-2 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{t('cannot_delete')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{identities.length === 0 && !isCreating && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Mail className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('no_identities')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Plus, Tag, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import {
|
||||
generateSubAddress,
|
||||
extractDomain,
|
||||
suggestTagsForDomain,
|
||||
getTagValidationError,
|
||||
MAX_TAG_LENGTH,
|
||||
} from '@/lib/sub-addressing';
|
||||
|
||||
interface SubAddressHelperProps {
|
||||
baseEmail: string;
|
||||
recipientEmails: string[];
|
||||
onSelectTag: (tag: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SubAddressHelper({
|
||||
baseEmail,
|
||||
recipientEmails,
|
||||
onSelectTag,
|
||||
disabled = false,
|
||||
}: SubAddressHelperProps) {
|
||||
const t = useTranslations('identities.sub_address');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tag, setTag] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore();
|
||||
|
||||
// Get suggestions based on recipient (memoized for performance)
|
||||
const suggestions = useMemo(() => {
|
||||
return recipientEmails
|
||||
.map(extractDomain)
|
||||
.filter(Boolean)
|
||||
.flatMap((domain) => suggestTagsForDomain(domain!))
|
||||
.filter((tag, index, self) => self.indexOf(tag) === index)
|
||||
.slice(0, 5);
|
||||
}, [recipientEmails]);
|
||||
|
||||
// Generate preview
|
||||
const preview = tag ? generateSubAddress(baseEmail, tag) : baseEmail;
|
||||
|
||||
// Close popover when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleTagChange = (value: string) => {
|
||||
setTag(value);
|
||||
const errorCode = getTagValidationError(value);
|
||||
|
||||
// Translate error code to localized message
|
||||
let errorMessage: string | null = null;
|
||||
if (errorCode === 'EMPTY') {
|
||||
errorMessage = t('validation.empty');
|
||||
} else if (errorCode === 'TOO_LONG') {
|
||||
errorMessage = t('validation.too_long', { max: MAX_TAG_LENGTH });
|
||||
} else if (errorCode === 'INVALID_CHARS') {
|
||||
errorMessage = t('validation.invalid_chars');
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
};
|
||||
|
||||
const handleSelectTag = (selectedTag: string) => {
|
||||
const errorCode = getTagValidationError(selectedTag);
|
||||
if (errorCode) {
|
||||
// Translate error code to localized message
|
||||
let errorMessage: string | null = null;
|
||||
if (errorCode === 'EMPTY') {
|
||||
errorMessage = t('validation.empty');
|
||||
} else if (errorCode === 'TOO_LONG') {
|
||||
errorMessage = t('validation.too_long', { max: MAX_TAG_LENGTH });
|
||||
} else if (errorCode === 'INVALID_CHARS') {
|
||||
errorMessage = t('validation.invalid_chars');
|
||||
}
|
||||
setError(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to recent tags and suggestions
|
||||
addRecentTag(selectedTag);
|
||||
const domain = recipientEmails.map(extractDomain).find(Boolean);
|
||||
if (domain) {
|
||||
addTagSuggestion(domain, selectedTag);
|
||||
}
|
||||
|
||||
onSelectTag(selectedTag);
|
||||
setIsOpen(false);
|
||||
setTag('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleUseAddress = () => {
|
||||
if (!tag) return;
|
||||
handleSelectTag(tag);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Trigger Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
title={t('button_tooltip')}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
<Tag className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* Popover */}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={cn(
|
||||
'absolute top-full left-0 mt-1 z-50',
|
||||
'bg-background border border-border rounded-lg shadow-lg',
|
||||
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{t('popover_title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tag Input */}
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
type="text"
|
||||
value={tag}
|
||||
onChange={(e) => handleTagChange(e.target.value)}
|
||||
placeholder={t('tag_input_placeholder')}
|
||||
className={cn(error && 'border-destructive')}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && tag && !error) {
|
||||
e.preventDefault();
|
||||
handleUseAddress();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive mt-1">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="mb-3 p-2 bg-muted rounded text-sm">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{t('preview_label')}
|
||||
</div>
|
||||
<div className="font-mono text-foreground break-all">
|
||||
{preview}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Tags */}
|
||||
{subAddress.recentTags.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
{t('recent_tags')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{subAddress.recentTags.slice(0, 5).map((recentTag) => (
|
||||
<button
|
||||
key={recentTag}
|
||||
onClick={() => handleSelectTag(recentTag)}
|
||||
className="px-2 py-1 text-xs rounded bg-secondary hover:bg-accent text-foreground transition-colors"
|
||||
>
|
||||
{recentTag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested Tags */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
{t('suggested_tags')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
onClick={() => handleSelectTag(suggestion)}
|
||||
className="px-2 py-1 text-xs rounded bg-primary/10 hover:bg-primary/20 text-primary transition-colors"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="mb-3 text-xs text-muted-foreground">
|
||||
{t('help_text')}
|
||||
</div>
|
||||
|
||||
{/* Use Address Button */}
|
||||
<Button
|
||||
onClick={handleUseAddress}
|
||||
disabled={!tag || !!error}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
{t('use_address')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user