Merge remote-tracking branch 'origin/dev' into sync-github-and-ci-fix
This commit is contained in:
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.9 (2026-08-07)
|
||||
|
||||
### Bug Fixes (Phase 1 — VNCmailgraph audit)
|
||||
|
||||
- **Mail**: Network transport failures now throw `TransportError` instead of returning empty results, so offline/network-down is distinguishable from an empty folder (#C1)
|
||||
- **Mail**: Push handler now refreshes contacts and files on remote state changes (#H1)
|
||||
- **Calendar**: Recurrence expansion IDs use `::occurrence::` delimiter to prevent collision with shared-event prefixes (#C2)
|
||||
- **Calendar**: Cross-account event aggregation now deduplicates by UID + recurrenceId, preventing phantom duplicates (#C3)
|
||||
- **Calendar**: `calendarTasksEnabled` admin policy now enforced at runtime, not just in settings UI (#H13)
|
||||
- **Tasks**: All task mutations (update, delete, toggle) now have error handling with store error state (#H14)
|
||||
- **Settings**: `updateSetting()` now checks admin policy lock before writing; `force` opt-in for legitimate bypassers (#C7)
|
||||
- **Settings**: `autoSelectReplyIdentity` now defaults to `true` — auto-identity selection on by default (#H18)
|
||||
- **Templates**: HTML template bodies are now sanitized with DOMPurify on import to prevent stored XSS (#H7)
|
||||
- **Auth**: User authentication endpoints now rate-limited — 10 attempts per (IP + username) per 15 minutes (#H3)
|
||||
- **Auth**: Admin sessions now support token revocation via JTI blacklist on logout (#C4)
|
||||
- **Auth**: Secure cookie flag now derived from `x-forwarded-proto`, not `NODE_ENV` (#H8)
|
||||
- **Auth**: OAuth token exchange error logs no longer leak `access_token` (#H4)
|
||||
- **Auth**: `isHashed()` no longer accepts bcrypt prefixes — scrypt-only, preventing lockout from bcrypt passwords (#H9)
|
||||
- **Push**: WS→SSE fallback now awaits state snapshot before reconciliation to prevent missed deliveries (#H2)
|
||||
- **Push**: Offline event handler added — push transports pause when browser goes offline, reconnect on online (#C8)
|
||||
- **Index**: FTS5 schema-drop now logs a warning so operators know a rebuild is needed (#C6)
|
||||
|
||||
---
|
||||
|
||||
## 1.7.8 (2026-07-22)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -97,6 +97,7 @@ export default function CalendarPage() {
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||
removeCalendar, clearCalendarEvents,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
newEventPrefill, setNewEventPrefill,
|
||||
} = useCalendarStore();
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const calendarTasksEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarTasksEnabled'));
|
||||
@@ -464,6 +465,19 @@ export default function CalendarPage() {
|
||||
setShowEventModal(true);
|
||||
}, [selectedDate, setSelectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!newEventPrefill) return;
|
||||
setEditEvent(null);
|
||||
if (newEventPrefill.date) {
|
||||
const d = new Date(newEventPrefill.date);
|
||||
if (!isNaN(d.getTime())) {
|
||||
setDefaultModalDate(d);
|
||||
setSelectedDate(d);
|
||||
}
|
||||
}
|
||||
setShowEventModal(true);
|
||||
}, [newEventPrefill, setSelectedDate]);
|
||||
|
||||
const openEditModal = useCallback((event: CalendarEvent) => {
|
||||
setEditEvent(event);
|
||||
setDefaultModalDate(undefined);
|
||||
@@ -1198,6 +1212,9 @@ export default function CalendarPage() {
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onCreateAtTime={openCreateModal}
|
||||
onEditEvent={openEditModal}
|
||||
onDeleteEvent={handleDeleteContextMenu}
|
||||
onDuplicateEvent={handleDuplicateContextMenu}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={pendingPreview}
|
||||
@@ -1490,10 +1507,14 @@ export default function CalendarPage() {
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }}
|
||||
onPreviewChange={setPendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={false}
|
||||
prefillTitle={editEvent ? undefined : newEventPrefill?.title}
|
||||
prefillDescription={editEvent ? undefined : newEventPrefill?.description}
|
||||
prefillParticipants={editEvent ? undefined : newEventPrefill?.participants}
|
||||
prefillDate={editEvent ? undefined : newEventPrefill?.date}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1620,9 +1641,13 @@ export default function CalendarPage() {
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={true}
|
||||
prefillTitle={editEvent ? undefined : newEventPrefill?.title}
|
||||
prefillDescription={editEvent ? undefined : newEventPrefill?.description}
|
||||
prefillParticipants={editEvent ? undefined : newEventPrefill?.participants}
|
||||
prefillDate={editEvent ? undefined : newEventPrefill?.date}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
@@ -411,6 +412,31 @@ export default function FilesPage() {
|
||||
await shareResource(id, principalId, rights);
|
||||
}, [shareResource]);
|
||||
|
||||
const handleSendAsAttachment = useCallback((names: string[]) => {
|
||||
const store = useFileStore.getState();
|
||||
const fileAtts = names
|
||||
.map((name) => {
|
||||
const r = store.resources.find((res) => res.name === name);
|
||||
if (!r || r.isDirectory || !r.blobId) return null;
|
||||
return {
|
||||
blobId: r.blobId,
|
||||
name: r.name,
|
||||
type: r.contentType || "application/octet-stream",
|
||||
size: r.contentLength,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||
|
||||
if (fileAtts.length === 0) return;
|
||||
|
||||
useProTabStore.getState().openComposeTab({
|
||||
sessionId: Date.now(),
|
||||
mode: "compose",
|
||||
replyTo: { attachments: fileAtts },
|
||||
title: fileAtts.length === 1 ? fileAtts[0].name : `${fileAtts.length} attachments`,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Pro shell only: all connected accounts are equal top-level entries at
|
||||
// the root. The root path "/" itself is a cross-account picker - no
|
||||
// account's files are shown until the user enters one.
|
||||
@@ -554,6 +580,7 @@ export default function FilesPage() {
|
||||
ownAccountId={filesAccountId}
|
||||
sharingEnabled={sharingEnabled}
|
||||
onShare={handleShare}
|
||||
onSendAsAttachment={handleSendAsAttachment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
SwatchBook,
|
||||
Download,
|
||||
Sparkles,
|
||||
Upload,
|
||||
Share2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -46,6 +48,7 @@ import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||
import { ReadingSettings } from '@/components/settings/reading-settings';
|
||||
import { ComposingSettings } from '@/components/settings/composing-settings';
|
||||
import { SignatureSettings } from '@/components/settings/signature-settings';
|
||||
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
||||
import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
@@ -70,6 +73,8 @@ import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||
import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings';
|
||||
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { ImportSettings } from '@/components/settings/import-settings';
|
||||
import { SharingSettings } from '@/components/settings/sharing-settings';
|
||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
@@ -98,6 +103,7 @@ type Tab =
|
||||
| 'composing'
|
||||
| 'downloads'
|
||||
| 'identities'
|
||||
| 'signatures'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
| 'templates'
|
||||
@@ -113,6 +119,8 @@ type Tab =
|
||||
| 'about_data'
|
||||
| 'themes'
|
||||
| 'plugins'
|
||||
| 'import'
|
||||
| 'sharing'
|
||||
| 'ai_assistant'
|
||||
| 'debug';
|
||||
|
||||
@@ -141,6 +149,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
composing: PenLine,
|
||||
downloads: Download,
|
||||
identities: UserPen,
|
||||
signatures: PenLine,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
templates: FileText,
|
||||
@@ -156,6 +165,8 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
about_data: Info,
|
||||
themes: SwatchBook,
|
||||
plugins: Puzzle,
|
||||
import: Upload,
|
||||
sharing: Share2,
|
||||
ai_assistant: Sparkles,
|
||||
debug: Bug,
|
||||
};
|
||||
@@ -219,6 +230,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
],
|
||||
downloads: ['settings.downloads'],
|
||||
identities: ['settings.identities'],
|
||||
signatures: ['signatures'],
|
||||
vacation: ['settings.vacation'],
|
||||
filters: ['settings.filters'],
|
||||
templates: ['settings.templates'],
|
||||
@@ -239,6 +251,8 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
themes: [],
|
||||
plugins: [],
|
||||
ai_assistant: [],
|
||||
import: ['settings.importer'],
|
||||
sharing: ['sharing'],
|
||||
debug: ['settings.advanced'],
|
||||
};
|
||||
|
||||
@@ -254,6 +268,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
composing: 'editor signature plain text reply forward draft compose',
|
||||
downloads: 'download filename template eml attachment save export',
|
||||
identities: 'from address signature email',
|
||||
signatures: 'signature rich text html editor',
|
||||
vacation: 'auto reply away out of office holiday responder',
|
||||
filters: 'sieve rules block junk forward',
|
||||
templates: 'snippet quick reply',
|
||||
@@ -270,6 +285,8 @@ const tabKeywords: Record<Tab, string> = {
|
||||
themes: 'custom theme css skin appearance',
|
||||
plugins: 'extensions addons',
|
||||
ai_assistant: 'assistant ask model llm ollama chatbot',
|
||||
import: 'import email eml zip tgz mbox csv vcard contacts',
|
||||
sharing: 'share shared folder calendar address book permission',
|
||||
debug: 'logs developer console diagnostic',
|
||||
};
|
||||
|
||||
@@ -619,6 +636,7 @@ export default function SettingsPage() {
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||
{ id: 'sharing', label: t('tabs.sharing'), icon: tabIcons.sharing, group: 'general' },
|
||||
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
|
||||
|
||||
// Appearance
|
||||
@@ -631,10 +649,12 @@ export default function SettingsPage() {
|
||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' },
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||
{ id: 'signatures', label: t('tabs.signatures'), icon: tabIcons.signatures, group: 'mail' },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
|
||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
|
||||
{ id: 'import', label: t('tabs.import'), icon: tabIcons.import, group: 'mail' },
|
||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||
|
||||
// Privacy & Security
|
||||
@@ -761,10 +781,13 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'signatures' && <SignatureSettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||
{effectiveActiveTab === 'import' && <ImportSettings />}
|
||||
{effectiveActiveTab === 'sharing' && <SharingSettings />}
|
||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, Plus, X } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface VncDirectoryFormData {
|
||||
enabled: boolean;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
samlEnabled: boolean;
|
||||
samlIdpUrl: string;
|
||||
samlSpCert: string;
|
||||
samlIssuer: string;
|
||||
ldapEnabled: boolean;
|
||||
ldapUri: string;
|
||||
ldapBindDn: string;
|
||||
ldapBindPassword: string;
|
||||
ldapSearchBase: string;
|
||||
ldapType: 'openldap' | 'ms-ad';
|
||||
tfaEnabled: boolean;
|
||||
oidcEnabled: boolean;
|
||||
oidcClientId: string;
|
||||
oidcDiscoveryUrl: string;
|
||||
sessionTtl: number;
|
||||
federatedApps: Record<string, string>;
|
||||
}
|
||||
|
||||
const BLANK_FORM: VncDirectoryFormData = {
|
||||
enabled: false,
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
samlEnabled: false,
|
||||
samlIdpUrl: '',
|
||||
samlSpCert: '',
|
||||
samlIssuer: '',
|
||||
ldapEnabled: false,
|
||||
ldapUri: '',
|
||||
ldapBindDn: '',
|
||||
ldapBindPassword: '',
|
||||
ldapSearchBase: '',
|
||||
ldapType: 'openldap',
|
||||
tfaEnabled: false,
|
||||
oidcEnabled: false,
|
||||
oidcClientId: '',
|
||||
oidcDiscoveryUrl: '',
|
||||
sessionTtl: 28800,
|
||||
federatedApps: {},
|
||||
};
|
||||
|
||||
export function VncDirectoryTab() {
|
||||
const [config, setConfig] = useState<VncDirectoryFormData>({ ...BLANK_FORM });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/vncdirectory');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setConfig(data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateField<K extends keyof VncDirectoryFormData>(key: K, value: VncDirectoryFormData[K]) {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleBool(key: keyof VncDirectoryFormData) {
|
||||
setConfig((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function setFederatedApp(name: string, url: string) {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
federatedApps: { ...prev.federatedApps, [name]: url },
|
||||
}));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function removeFederatedApp(name: string) {
|
||||
setConfig((prev) => {
|
||||
const next = { ...prev.federatedApps };
|
||||
delete next[name];
|
||||
return { ...prev, federatedApps: next };
|
||||
});
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/vncdirectory', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'VNCdirectory configuration saved.' });
|
||||
setDirty(false);
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const federatedAppsList = Object.entries(config.federatedApps);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">VNCdirectory</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Centralized identity and directory integration (SAML, LDAP, 2FA)
|
||||
</p>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save configuration
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`text-sm rounded-md px-3 py-2 ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section title="Enable VNCdirectory Integration">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Turn on VNCdirectory integration for identity management, SSO, and directory services
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleBool('enabled')}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
config.enabled
|
||||
? 'bg-primary'
|
||||
: 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
|
||||
config.enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<Section title="Connection">
|
||||
<div className="divide-y divide-border">
|
||||
<TextRow
|
||||
label="VNCdirectory URL"
|
||||
value={config.apiUrl}
|
||||
onChange={(v) => updateField('apiUrl', v)}
|
||||
placeholder="https://vncdirectory.example.com"
|
||||
/>
|
||||
<PasswordRow
|
||||
label="API Key"
|
||||
value={config.apiKey}
|
||||
onChange={(v) => updateField('apiKey', v)}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="SAML / Identity Provider">
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="SAML Enabled"
|
||||
description="Enable SAML single sign-on via VNCdirectory"
|
||||
value={config.samlEnabled}
|
||||
onChange={() => toggleBool('samlEnabled')}
|
||||
/>
|
||||
{config.samlEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="Identity Provider URL"
|
||||
value={config.samlIdpUrl}
|
||||
onChange={(v) => updateField('samlIdpUrl', v)}
|
||||
placeholder="https://idp.example.com/saml2/idp"
|
||||
/>
|
||||
<TextRow
|
||||
label="Issuer Name (Entity ID)"
|
||||
value={config.samlIssuer}
|
||||
onChange={(v) => updateField('samlIssuer', v)}
|
||||
placeholder="urn:example:vncmail"
|
||||
/>
|
||||
<div className="px-4 py-3 flex flex-col gap-2">
|
||||
<label className="text-sm text-foreground">
|
||||
Service Provider Certificate (X.509)
|
||||
</label>
|
||||
<textarea
|
||||
value={config.samlSpCert}
|
||||
onChange={(e) => updateField('samlSpCert', e.target.value)}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-vertical"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="LDAP Directory">
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="LDAP Enabled"
|
||||
description="Query user directory via LDAP for contact lookups and authentication"
|
||||
value={config.ldapEnabled}
|
||||
onChange={() => toggleBool('ldapEnabled')}
|
||||
/>
|
||||
{config.ldapEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="LDAP Server URI"
|
||||
value={config.ldapUri}
|
||||
onChange={(v) => updateField('ldapUri', v)}
|
||||
placeholder="ldaps://ldap.example.com:636"
|
||||
/>
|
||||
<TextRow
|
||||
label="Bind DN"
|
||||
value={config.ldapBindDn}
|
||||
onChange={(v) => updateField('ldapBindDn', v)}
|
||||
placeholder="cn=readonly,dc=example,dc=com"
|
||||
/>
|
||||
<PasswordRow
|
||||
label="Bind Password"
|
||||
value={config.ldapBindPassword}
|
||||
onChange={(v) => updateField('ldapBindPassword', v)}
|
||||
placeholder="Enter LDAP bind password"
|
||||
/>
|
||||
<TextRow
|
||||
label="Search Base"
|
||||
value={config.ldapSearchBase}
|
||||
onChange={(v) => updateField('ldapSearchBase', v)}
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
/>
|
||||
<SelectRow
|
||||
label="LDAP Type"
|
||||
value={config.ldapType}
|
||||
options={[
|
||||
{ value: 'openldap', label: 'OpenLDAP' },
|
||||
{ value: 'ms-ad', label: 'Microsoft Active Directory' },
|
||||
]}
|
||||
onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Authentication">
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="Enforce 2FA/TOTP"
|
||||
description="Require two-factor authentication for all users"
|
||||
value={config.tfaEnabled}
|
||||
onChange={() => toggleBool('tfaEnabled')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="OpenID Connect (OIDC)"
|
||||
description="Enable OIDC login alongside or instead of SAML"
|
||||
value={config.oidcEnabled}
|
||||
onChange={() => toggleBool('oidcEnabled')}
|
||||
/>
|
||||
{config.oidcEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="OIDC Client ID"
|
||||
value={config.oidcClientId}
|
||||
onChange={(v) => updateField('oidcClientId', v)}
|
||||
placeholder="vncmail-client"
|
||||
/>
|
||||
<TextRow
|
||||
label="OIDC Discovery URL"
|
||||
value={config.oidcDiscoveryUrl}
|
||||
onChange={(v) => updateField('oidcDiscoveryUrl', v)}
|
||||
placeholder="https://idp.example.com/.well-known/openid-configuration"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Session TTL (seconds)</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
How long SSO sessions remain valid. Default: 8 hours (28800)
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={config.sessionTtl}
|
||||
onChange={(e) => updateField('sessionTtl', Number(e.target.value))}
|
||||
className="h-8 w-full sm:w-32 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Federated Applications">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Configure SSO redirect URLs for other VNC applications. Users signed into one
|
||||
app will be transparently authenticated when navigating to another.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{federatedAppsList.map(([appName, url]) => (
|
||||
<div
|
||||
key={appName}
|
||||
className="flex flex-col sm:flex-row items-start sm:items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={appName}
|
||||
readOnly
|
||||
className="h-8 w-full sm:w-36 rounded-md border border-input bg-muted/50 px-2.5 text-sm text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setFederatedApp(appName, e.target.value)}
|
||||
placeholder="https://vnc.example.com/auth/sso"
|
||||
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeFederatedApp(appName)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={`Remove ${appName}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<AddFederatedApp
|
||||
existingKeys={new Set(Object.keys(config.federatedApps))}
|
||||
onAdd={(name, url) => setFederatedApp(name, url)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddFederatedApp({
|
||||
existingKeys,
|
||||
onAdd,
|
||||
}: {
|
||||
existingKeys: Set<string>;
|
||||
onAdd: (name: string, url: string) => void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!adding) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add federated app
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setError('Enter an application name');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
setError('Name must contain only letters, numbers, hyphens, and underscores');
|
||||
return;
|
||||
}
|
||||
if (existingKeys.has(trimmed)) {
|
||||
setError('An app with this name already exists');
|
||||
return;
|
||||
}
|
||||
if (!url.trim()) {
|
||||
setError('Enter an SSO URL');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onAdd(trimmed, url.trim());
|
||||
setName('');
|
||||
setUrl('');
|
||||
setAdding(false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setAdding(false);
|
||||
setName('');
|
||||
setUrl('');
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
||||
placeholder="App name (e.g. vnctalk)"
|
||||
className="h-8 w-full sm:w-36 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => { setUrl(e.target.value); setError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
||||
placeholder="https://vnctalk.example.com/auth/sso"
|
||||
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="inline-flex items-center h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const isMasked = value === '••••••';
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type={isMasked ? 'text' : 'password'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || (isMasked ? 'Saved - type to replace' : undefined)}
|
||||
className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onChange}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
|
||||
value ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectRow({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Scale,
|
||||
ScrollText,
|
||||
LogOut,
|
||||
Key,
|
||||
KeyRound,
|
||||
Bot,
|
||||
Puzzle,
|
||||
@@ -55,6 +56,7 @@ const NAV_GROUPS: ReadonlyArray<{
|
||||
{ tab: 'settings', label: 'Settings', icon: Settings },
|
||||
{ tab: 'branding', label: 'Branding', icon: Palette },
|
||||
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
||||
{ tab: 'vncdirectory', label: 'VNCdirectory', icon: Key },
|
||||
{ tab: 'policy', label: 'Policy', icon: Scale },
|
||||
{ tab: 'ai-policy', label: 'AI', icon: Bot },
|
||||
],
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MarketplaceTab } from './_tabs/marketplace';
|
||||
import { VersionTab } from './_tabs/version';
|
||||
import { TelemetryTab } from './_tabs/telemetry';
|
||||
import { LogsTab } from './_tabs/logs';
|
||||
import { VncDirectoryTab } from './_tabs/vncdirectory';
|
||||
|
||||
export default function AdminPage() {
|
||||
const activeTab = useAdminTabStore((s) => s.activeTab);
|
||||
@@ -47,5 +48,6 @@ export default function AdminPage() {
|
||||
case 'version': return <VersionTab />;
|
||||
case 'telemetry': return <TelemetryTab />;
|
||||
case 'logs': return <LogsTab />;
|
||||
case 'vncdirectory': return <VncDirectoryTab />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=vncdirectory');
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
getVncDirectoryConfig,
|
||||
saveVncDirectoryConfig,
|
||||
DEFAULT_VNCDIRECTORY_CONFIG,
|
||||
VNCDIRECTORY_SENSITIVE_KEYS,
|
||||
type VncDirectoryConfig,
|
||||
} from '@/lib/admin/vncdirectory-config';
|
||||
|
||||
const VALID_LDAP_TYPES = new Set(['openldap', 'ms-ad']);
|
||||
const KNOWN_KEYS = new Set(Object.keys(DEFAULT_VNCDIRECTORY_CONFIG));
|
||||
|
||||
function maskConfigForClient(config: VncDirectoryConfig): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (VNCDIRECTORY_SENSITIVE_KEYS.has(key)) {
|
||||
result[key] = typeof value === 'string' && value.length > 0 ? '••••••' : '';
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const config = await getVncDirectoryConfig();
|
||||
return NextResponse.json(maskConfigForClient(config), {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('VNCdirectory config read error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authResult = await requireAdminAuth(request);
|
||||
if ('error' in authResult) return authResult.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const body = await request.json();
|
||||
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate known keys only
|
||||
const unknownKeys = Object.keys(body).filter((k) => !KNOWN_KEYS.has(k));
|
||||
if (unknownKeys.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown config keys: ${unknownKeys.join(', ')}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate boolean fields
|
||||
const boolFields = ['enabled', 'samlEnabled', 'ldapEnabled', 'tfaEnabled', 'oidcEnabled'];
|
||||
for (const key of boolFields) {
|
||||
if (key in body && typeof body[key] !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: `${key} must be a boolean` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate sessionTtl
|
||||
if ('sessionTtl' in body) {
|
||||
const ttl = Number(body.sessionTtl);
|
||||
if (!Number.isFinite(ttl) || ttl < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionTtl must be a non-negative number' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
body.sessionTtl = ttl;
|
||||
}
|
||||
|
||||
// Validate ldapType
|
||||
if ('ldapType' in body && !VALID_LDAP_TYPES.has(body.ldapType)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid ldapType: ${body.ldapType}. Must be 'openldap' or 'ms-ad'.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate federatedApps
|
||||
if ('federatedApps' in body) {
|
||||
if (!body.federatedApps || typeof body.federatedApps !== 'object' || Array.isArray(body.federatedApps)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'federatedApps must be an object mapping app names to URLs' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
for (const [appName, url] of Object.entries(body.federatedApps as Record<string, unknown>)) {
|
||||
if (typeof url !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: `federatedApps.${appName} must be a string URL` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If apiKey or ldapBindPassword are "••••••", preserve existing value
|
||||
const currentConfig = await getVncDirectoryConfig();
|
||||
if (body.apiKey === '••••••') {
|
||||
body.apiKey = currentConfig.apiKey;
|
||||
}
|
||||
if (body.ldapBindPassword === '••••••') {
|
||||
body.ldapBindPassword = currentConfig.ldapBindPassword;
|
||||
}
|
||||
|
||||
const changedKeys = Object.keys(body).filter((k) => {
|
||||
const currentVal = currentConfig[k as keyof VncDirectoryConfig];
|
||||
const newVal = body[k];
|
||||
if (k === 'federatedApps') {
|
||||
return JSON.stringify(currentVal) !== JSON.stringify(newVal);
|
||||
}
|
||||
return String(currentVal ?? '') !== String(newVal ?? '');
|
||||
});
|
||||
|
||||
await saveVncDirectoryConfig(body as Partial<VncDirectoryConfig>);
|
||||
|
||||
if (changedKeys.length > 0) {
|
||||
await auditLog('vncdirectory.update', { changedKeys }, ip);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('VNCdirectory config update error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,17 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
// TODO(P2.13): Wire SAML IDP integration once VNCdirectory is configured.
|
||||
// When VNCdirectory is enabled and SAML is configured (see
|
||||
// lib/admin/vncdirectory-config.ts), the SSO start flow should:
|
||||
// 1. Check isVncDirectoryEnabled() — if false, fall through to existing OAuth flow.
|
||||
// 2. Read getVncDirectoryConfig() for samlIdpUrl, samlIssuer, samlSpCert.
|
||||
// 3. Build a SAML AuthnRequest and redirect to the IdP instead of OAuth.
|
||||
// 4. The /sso/complete handler should process the SAML Response assertion,
|
||||
// validate the signature against the SP certificate, extract the subject,
|
||||
// and create a session.
|
||||
// Reference: docs/admin/VNCDIRECTORY.md in the VNCmail+ plan (P2.13).
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCollaboraEditUrl } from "@/lib/collabora/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.fileId || !body.fileName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: fileId, fileName" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = await getCollaboraEditUrl(
|
||||
String(body.fileId),
|
||||
String(body.fileName)
|
||||
);
|
||||
|
||||
return NextResponse.json({ url });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error("Collabora edit URL failed", { error: message });
|
||||
|
||||
if (message.includes("not configured")) {
|
||||
return NextResponse.json({ error: message }, { status: 503 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { checkAvailability } from '@/lib/resources/client';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const start = searchParams.get('start');
|
||||
const end = searchParams.get('end');
|
||||
|
||||
if (!start || !end) {
|
||||
return NextResponse.json({ error: 'start and end query parameters are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await checkAvailability(id, start, end);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Resource availability error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { cancelBooking } from '@/lib/resources/client';
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; bookingId: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { bookingId } = await params;
|
||||
await cancelBooking(bookingId);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Resource booking cancel error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { bookResource, checkAvailability, getResource } from '@/lib/resources/client';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { start, end, eventId } = body;
|
||||
|
||||
if (!start || !end) {
|
||||
return NextResponse.json({ error: 'start and end are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const resource = await getResource(id);
|
||||
if (!resource) {
|
||||
return NextResponse.json({ error: 'Resource not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { available, conflicts } = await checkAvailability(id, start, end);
|
||||
if (!available) {
|
||||
return NextResponse.json({ error: 'Resource is not available for the requested time', conflicts }, { status: 409 });
|
||||
}
|
||||
|
||||
const booking = await bookResource(id, start, end, creds.username, eventId);
|
||||
return NextResponse.json({ booking }, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Resource booking error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { getResource } from '@/lib/resources/client';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const resource = await getResource(id);
|
||||
if (!resource) {
|
||||
return NextResponse.json({ error: 'Resource not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ resource });
|
||||
} catch (error) {
|
||||
logger.error('Resource get error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { listResources, createResource, getBookingsForEvent } from '@/lib/resources/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type') || undefined;
|
||||
const eventId = searchParams.get('eventId') || undefined;
|
||||
|
||||
if (eventId) {
|
||||
const bookings = await getBookingsForEvent(eventId);
|
||||
return NextResponse.json({ bookings });
|
||||
}
|
||||
|
||||
const resources = await listResources(creds.username, type);
|
||||
return NextResponse.json({ resources });
|
||||
} catch (error) {
|
||||
logger.error('Resources list error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, type, location, capacity, description, contactEmail, metadata } = body;
|
||||
|
||||
if (!name || !type || !['room', 'vehicle', 'equipment', 'other'].includes(type)) {
|
||||
return NextResponse.json({ error: 'Name and valid type are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const resource = await createResource(creds.username, {
|
||||
name,
|
||||
type,
|
||||
location,
|
||||
capacity: capacity ? Number(capacity) : undefined,
|
||||
description,
|
||||
contactEmail,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return NextResponse.json({ resource }, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Resource create error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
type JmapMethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
async function jmapRequest(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
methodCalls: JmapMethodCall[],
|
||||
using?: string[],
|
||||
) {
|
||||
const sessionResp = await fetch(`${serverUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: authHeader },
|
||||
});
|
||||
if (!sessionResp.ok) {
|
||||
return { error: `Session fetch failed: ${sessionResp.status}` };
|
||||
}
|
||||
const session = await sessionResp.json();
|
||||
const apiUrl = session.apiUrl;
|
||||
if (!apiUrl) {
|
||||
return { error: "No API URL in JMAP session" };
|
||||
}
|
||||
|
||||
const body = {
|
||||
using: using || [
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:principals",
|
||||
],
|
||||
methodCalls,
|
||||
};
|
||||
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return { error: `JMAP request failed: ${resp.status}` };
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get("action");
|
||||
const serverUrl = request.headers.get("X-JMAP-Server-Url");
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
|
||||
if (!serverUrl || !authHeader) {
|
||||
return Response.json(
|
||||
{ error: "Missing server URL or auth header" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action !== "principals") {
|
||||
return Response.json(
|
||||
{ error: "Invalid action" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await jmapRequest(serverUrl, authHeader, [
|
||||
["Principal/query", { accountId: "" }, "0"],
|
||||
["Principal/get", {
|
||||
accountId: "",
|
||||
"#ids": {
|
||||
resultOf: "0",
|
||||
name: "Principal/query",
|
||||
path: "/ids",
|
||||
},
|
||||
}, "1"],
|
||||
]);
|
||||
|
||||
if ("error" in result) {
|
||||
return Response.json(result, { status: 502 });
|
||||
}
|
||||
|
||||
const getResp = (result as Record<string, unknown>).methodResponses as Array<[string, Record<string, unknown>, string]> | undefined;
|
||||
const principals = getResp?.find((r) => r[0] === "Principal/get")?.[1]
|
||||
?.list ?? [];
|
||||
|
||||
return Response.json({ principals });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const serverUrl = request.headers.get("X-JMAP-Server-Url");
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
|
||||
if (!serverUrl || !authHeader) {
|
||||
return Response.json(
|
||||
{ error: "Missing server URL or auth header" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { kind, resourceId, principalId, role } = body;
|
||||
|
||||
if (!kind || !resourceId || !principalId) {
|
||||
return Response.json(
|
||||
{ error: "Missing required fields: kind, resourceId, principalId" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let method: string;
|
||||
let shareProperty: string;
|
||||
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
method = "Mailbox/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "calendar":
|
||||
method = "Calendar/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "addressBook":
|
||||
method = "AddressBook/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "file":
|
||||
method = "FileNode/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
default:
|
||||
return Response.json(
|
||||
{ error: `Invalid kind: ${kind}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const patchValue = role === null ? null : buildRights(kind as string, role as string);
|
||||
|
||||
const methodCalls: JmapMethodCall[] = [
|
||||
[
|
||||
method,
|
||||
{
|
||||
accountId: "",
|
||||
update: {
|
||||
[resourceId as string]: {
|
||||
[`${shareProperty}/${principalId}`]: patchValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
"0",
|
||||
],
|
||||
];
|
||||
|
||||
const result = await jmapRequest(
|
||||
serverUrl,
|
||||
authHeader,
|
||||
methodCalls,
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
return Response.json(result, { status: 502 });
|
||||
}
|
||||
|
||||
const responses = (result as Record<string, unknown>).methodResponses as Array<[string, Record<string, unknown>, string]> | undefined;
|
||||
const setResult = responses?.[0]?.[1];
|
||||
|
||||
if (
|
||||
setResult &&
|
||||
typeof setResult === "object" &&
|
||||
"notUpdated" in setResult &&
|
||||
setResult.notUpdated &&
|
||||
typeof setResult.notUpdated === "object" &&
|
||||
(resourceId as string) in setResult.notUpdated
|
||||
) {
|
||||
const err = (setResult.notUpdated as Record<string, Record<string, unknown>>)[resourceId as string];
|
||||
return Response.json(
|
||||
{ error: err.description || "Failed to update share" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
function buildRights(
|
||||
kind: string,
|
||||
role: string,
|
||||
): Record<string, boolean> | null {
|
||||
if (role === null) return null;
|
||||
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return mailboxRights(role);
|
||||
case "calendar":
|
||||
return calendarRights(role);
|
||||
case "addressBook":
|
||||
return addressBookRights(role);
|
||||
case "file":
|
||||
return fileRights(role);
|
||||
default:
|
||||
return readRights();
|
||||
}
|
||||
}
|
||||
|
||||
function mailboxRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: false,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: false,
|
||||
maySetKeywords: false,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: true,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
mayShare: true,
|
||||
};
|
||||
default:
|
||||
return mailboxRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function calendarRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: false,
|
||||
mayWriteOwn: false,
|
||||
mayUpdatePrivate: false,
|
||||
mayRSVP: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
};
|
||||
default:
|
||||
return calendarRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function addressBookRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
};
|
||||
default:
|
||||
return addressBookRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function fileRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
mayModifyContent: false,
|
||||
mayShare: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: true,
|
||||
};
|
||||
default:
|
||||
return fileRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function readRights(): Record<string, boolean> {
|
||||
return { mayRead: true };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createVncMeeting } from "@/lib/vnctalk/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
function getClientIP(request: NextRequest): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) return forwarded.split(",")[0].trim();
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.name || !body.start || !body.end) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: name, start, end" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const invitees: string[] = Array.isArray(body.invitees) ? body.invitees : [];
|
||||
|
||||
const result = await createVncMeeting({
|
||||
name: String(body.name),
|
||||
start: String(body.start),
|
||||
end: String(body.end),
|
||||
invitees,
|
||||
password: body.password ? String(body.password) : undefined,
|
||||
description: body.description ? String(body.description) : undefined,
|
||||
});
|
||||
|
||||
logger.info("VNCtalk meeting created", {
|
||||
meetingId: result.meetingId,
|
||||
ip: getClientIP(request),
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error("VNCtalk meeting creation failed", { error: message });
|
||||
|
||||
if (message.includes("not configured")) {
|
||||
return NextResponse.json({ error: message }, { status: 503 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import { Pencil, Trash2, Copy } from "lucide-react";
|
||||
|
||||
interface CalendarMonthViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -25,6 +27,9 @@ interface CalendarMonthViewProps {
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||
onCreateAtTime?: (date: Date) => void;
|
||||
onEditEvent?: (event: CalendarEvent) => void;
|
||||
onDeleteEvent?: (event: CalendarEvent) => void;
|
||||
onDuplicateEvent?: (event: CalendarEvent) => void;
|
||||
firstDayOfWeek?: number;
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
@@ -41,6 +46,9 @@ export function CalendarMonthView({
|
||||
onContextMenuEvent,
|
||||
onContextMenuEmpty,
|
||||
onCreateAtTime,
|
||||
onEditEvent,
|
||||
onDeleteEvent,
|
||||
onDuplicateEvent,
|
||||
firstDayOfWeek = 1,
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
@@ -112,6 +120,54 @@ export function CalendarMonthView({
|
||||
|
||||
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuEvent, setRadialMenuEvent] = useState<CalendarEvent | null>(null);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuEvent) return [];
|
||||
const ev = radialMenuEvent;
|
||||
const items: RadialMenuItem[] = [];
|
||||
if (onEditEvent) {
|
||||
items.push({
|
||||
id: "edit",
|
||||
icon: <Pencil className="w-5 h-5" />,
|
||||
label: t("edit"),
|
||||
onClick: () => { onEditEvent(ev); },
|
||||
});
|
||||
}
|
||||
if (onDeleteEvent) {
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("delete"),
|
||||
onClick: () => { onDeleteEvent(ev); },
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
if (onDuplicateEvent) {
|
||||
items.push({
|
||||
id: "duplicate",
|
||||
icon: <Copy className="w-5 h-5" />,
|
||||
label: t("duplicate"),
|
||||
onClick: () => { onDuplicateEvent(ev); },
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [radialMenuEvent, t, onEditEvent, onDeleteEvent, onDuplicateEvent]);
|
||||
|
||||
const handleRadialMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuEvent(event);
|
||||
setRadialMenuOpen(true);
|
||||
onContextMenuEvent?.(e, event);
|
||||
}, [onContextMenuEvent]);
|
||||
|
||||
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
||||
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
||||
e.preventDefault();
|
||||
@@ -294,7 +350,7 @@ export function CalendarMonthView({
|
||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
onContextMenu={handleRadialMenuEvent}
|
||||
draggable
|
||||
className={isMobile ? "text-[10px] px-1" : undefined}
|
||||
/>
|
||||
@@ -306,6 +362,13 @@ export function CalendarMonthView({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
Pencil, Trash2, Copy, Send, Check, ExternalLink, Globe,
|
||||
} from "lucide-react";
|
||||
import { format, isSameDay } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -107,6 +107,25 @@ function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTransl
|
||||
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||
}
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
|
||||
|
||||
function linkifyText(text: string): (string | { url: string })[] {
|
||||
const parts: (string | { url: string })[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push({ url: match[1] });
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function EventDetailPopover({
|
||||
event,
|
||||
calendar,
|
||||
@@ -383,21 +402,34 @@ export function EventDetailPopover({
|
||||
{locationName && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a
|
||||
href={locationName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline truncate"
|
||||
title={locationName}
|
||||
>
|
||||
{(() => {
|
||||
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||
})()}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a
|
||||
href={locationName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline truncate block"
|
||||
title={locationName}
|
||||
>
|
||||
{(() => {
|
||||
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||
})()}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
<a
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View on Map
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -413,6 +445,8 @@ export function EventDetailPopover({
|
||||
title={virtualLocation}
|
||||
>
|
||||
{(() => {
|
||||
const isVncMeeting = event.links?.["vnctalk-meeting"];
|
||||
if (isVncMeeting) return "Join VNCtalk Meeting";
|
||||
try {
|
||||
return new URL(virtualLocation).hostname;
|
||||
} catch {
|
||||
@@ -423,6 +457,30 @@ export function EventDetailPopover({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
|
||||
{!virtualLocation && event.links?.["vnctalk-meeting"] && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a
|
||||
href={event.links["vnctalk-meeting"].href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Join VNCtalk Meeting
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timezone */}
|
||||
{!event.showWithoutTime && event.timeZone && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{hasParticipants && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -476,7 +534,21 @@ export function EventDetailPopover({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3">
|
||||
{event.description}
|
||||
{linkifyText(event.description).map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<span key={i}>{part}</span>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={part.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{part.url}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,13 +4,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff, ExternalLink, Reply, ReplyAll, Globe, Building2 } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
import { FreeBusyView } from "./free-busy-view";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -25,6 +26,10 @@ import { generateUUID } from "@/lib/utils";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { calendarHooks } from "@/lib/plugin-hooks";
|
||||
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||
import { RecipientPopover } from "@/components/email/recipient-popover";
|
||||
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||
import { ResourcePicker } from "./resource-picker";
|
||||
import { useResourceStore } from "@/stores/resource-store";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -49,6 +54,10 @@ interface EventModalProps {
|
||||
onPreviewChange?: (preview: PendingEventPreview | null) => void;
|
||||
currentUserEmails?: string[];
|
||||
isMobile?: boolean;
|
||||
prefillTitle?: string;
|
||||
prefillDescription?: string;
|
||||
prefillParticipants?: { name?: string; email: string }[];
|
||||
prefillDate?: string;
|
||||
}
|
||||
|
||||
function formatDateInput(d: Date): string {
|
||||
@@ -59,6 +68,25 @@ function formatTimeInput(d: Date): string {
|
||||
return format(d, "HH:mm");
|
||||
}
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
|
||||
|
||||
function linkifyText(text: string): (string | { url: string })[] {
|
||||
const parts: (string | { url: string })[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push({ url: match[1] });
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildDuration(startDate: Date, endDate: Date): string {
|
||||
const diffMs = endDate.getTime() - startDate.getTime();
|
||||
const totalMinutes = Math.max(0, Math.floor(diffMs / 60000));
|
||||
@@ -178,6 +206,10 @@ export function EventModal({
|
||||
onPreviewChange,
|
||||
currentUserEmails = [],
|
||||
isMobile = false,
|
||||
prefillTitle,
|
||||
prefillDescription,
|
||||
prefillParticipants,
|
||||
prefillDate,
|
||||
}: EventModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
@@ -228,6 +260,10 @@ export function EventModal({
|
||||
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
if (prefillDate) {
|
||||
const d = new Date(prefillDate);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + 1, 0, 0, 0);
|
||||
return d;
|
||||
@@ -244,8 +280,8 @@ export function EventModal({
|
||||
return addHours(getInitialStart(), 1);
|
||||
};
|
||||
|
||||
const [title, setTitle] = useState(event?.title || "");
|
||||
const [description, setDescription] = useState(event?.description || "");
|
||||
const [title, setTitle] = useState(event?.title || prefillTitle || "");
|
||||
const [description, setDescription] = useState(event?.description || prefillDescription || "");
|
||||
const [location, setLocation] = useState(
|
||||
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
||||
);
|
||||
@@ -328,13 +364,29 @@ export function EventModal({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
|
||||
if (!event?.participants) return [];
|
||||
if (!event?.participants) {
|
||||
if (prefillParticipants && prefillParticipants.length > 0) {
|
||||
return prefillParticipants.map(p => ({ name: p.name || "", email: p.email }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return existingParticipants
|
||||
.filter(p => !p.isOrganizer)
|
||||
.map(p => ({ name: p.name, email: p.email }));
|
||||
});
|
||||
const [sendInvitations, setSendInvitations] = useState(true);
|
||||
const [showFreeBusy, setShowFreeBusy] = useState(false);
|
||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||
const [createVncMeeting, setCreateVncMeeting] = useState(false);
|
||||
const [meetingCreating, setMeetingCreating] = useState(false);
|
||||
const [timezone, setTimezone] = useState(() => {
|
||||
if (event?.timeZone) return event.timeZone;
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; }
|
||||
});
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
|
||||
const resourceStore = useResourceStore();
|
||||
const [showResources, setShowResources] = useState(false);
|
||||
|
||||
// Plugin transform: collect conflict warnings for the current event form.
|
||||
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||
@@ -361,6 +413,13 @@ export function EventModal({
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (event?.id) {
|
||||
resourceStore.fetchEventBookings(event.id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [event?.id]);
|
||||
|
||||
// Report live preview to parent for grid outline
|
||||
useEffect(() => {
|
||||
if (!onPreviewChange || isEdit) return;
|
||||
@@ -416,7 +475,7 @@ export function EventModal({
|
||||
duration = buildDuration(start, end);
|
||||
}
|
||||
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const timeZone = timezone;
|
||||
|
||||
const data: Partial<CalendarEvent> = {
|
||||
title: trimmedTitle,
|
||||
@@ -534,14 +593,72 @@ export function EventModal({
|
||||
data.organizerCalendarAddress = null;
|
||||
}
|
||||
|
||||
// VNCtalk meeting creation
|
||||
if (createVncMeeting && effectiveAttendees.length > 0 && !allDay) {
|
||||
setMeetingCreating(true);
|
||||
try {
|
||||
const vncRes = await fetch("/api/vnctalk/meeting", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: trimmedTitle,
|
||||
start: startStr,
|
||||
end: allDay
|
||||
? `${endDate}T23:59:59`
|
||||
: `${endDate}T${endTime}:00`,
|
||||
invitees: effectiveAttendees.map((a: { email: string }) => a.email),
|
||||
description: description.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
if (vncRes.ok) {
|
||||
const { meetingUrl, meetingId } = await vncRes.json();
|
||||
data.virtualLocations = {
|
||||
vl1: {
|
||||
"@type": "VirtualLocation",
|
||||
name: "VNCtalk Meeting",
|
||||
description: `Meeting ID: ${meetingId}`,
|
||||
uri: meetingUrl,
|
||||
features: null,
|
||||
},
|
||||
};
|
||||
data.links = {
|
||||
"vnctalk-meeting": {
|
||||
"@type": "Link",
|
||||
href: meetingUrl,
|
||||
cid: meetingId,
|
||||
contentType: null,
|
||||
size: null,
|
||||
rel: "vnctalk-meeting",
|
||||
display: null,
|
||||
title: "VNCtalk Meeting",
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create VNCtalk meeting:", err);
|
||||
} finally {
|
||||
setMeetingCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(data, shouldSendScheduling);
|
||||
if (resourceStore.selectedResources.length > 0) {
|
||||
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
|
||||
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
|
||||
const eventRef = event?.id || data.uid;
|
||||
await resourceStore.bookSelectedResources(
|
||||
startStr,
|
||||
endStr,
|
||||
eventRef,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving, createVncMeeting, timezone, resourceStore]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -575,6 +692,30 @@ export function EventModal({
|
||||
onDuplicate(data);
|
||||
}, [event, onDuplicate]);
|
||||
|
||||
const handleReply = useCallback((replyAll: boolean) => {
|
||||
if (!event) return;
|
||||
const participants = getParticipantList(event);
|
||||
const recipientEmails = replyAll
|
||||
? participants.map((p) => ({ email: p.email, name: p.name }))
|
||||
: (() => {
|
||||
const org = participants.find((p) => p.isOrganizer);
|
||||
return org ? [{ email: org.email, name: org.name }] : [];
|
||||
})();
|
||||
if (recipientEmails.length === 0) return;
|
||||
openComposeTab({
|
||||
sessionId: Date.now(),
|
||||
mode: replyAll ? "replyAll" : "reply",
|
||||
title: `Re: ${event.title}`,
|
||||
replyTo: {
|
||||
subject: `Re: ${event.title}`,
|
||||
to: recipientEmails,
|
||||
},
|
||||
});
|
||||
}, [event, openComposeTab]);
|
||||
|
||||
const handleReplyAll = useCallback(() => handleReply(true), [handleReply]);
|
||||
const handleReplySingle = useCallback(() => handleReply(false), [handleReply]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -855,22 +996,57 @@ export function EventModal({
|
||||
{locationName && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={locationName}>
|
||||
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={locationName}>
|
||||
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
<a
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View on Map
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Virtual Location */}
|
||||
{/* Virtual Location / Meeting Link */}
|
||||
{virtualLoc && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={virtualLoc}>
|
||||
{(() => { try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; } })()}
|
||||
<div className="min-w-0">
|
||||
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={virtualLoc}>
|
||||
{(() => {
|
||||
const isVncMeeting = event.links?.["vnctalk-meeting"];
|
||||
if (isVncMeeting) return "Join VNCtalk Meeting";
|
||||
try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; }
|
||||
})()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
|
||||
{!virtualLoc && event.links?.["vnctalk-meeting"] && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a
|
||||
href={event.links["vnctalk-meeting"].href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Join VNCtalk Meeting
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
@@ -887,7 +1063,7 @@ export function EventModal({
|
||||
{viewParticipants.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate text-foreground">
|
||||
{p.name || p.email}
|
||||
<RecipientPopover name={p.name} email={p.email} />
|
||||
{p.isOrganizer && (
|
||||
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
||||
)}
|
||||
@@ -900,6 +1076,34 @@ export function EventModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resources (booked) */}
|
||||
{resourceStore.bookings.length > 0 && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Building2 className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{resourceStore.bookings.map((b) => {
|
||||
const res = resourceStore.resources.find((r) => r.id === b.resourceId);
|
||||
return (
|
||||
<span
|
||||
key={b.id}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{res?.name || b.resourceId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timezone */}
|
||||
{!event.showWithoutTime && event.timeZone && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence */}
|
||||
{recurrenceLabel && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -920,7 +1124,23 @@ export function EventModal({
|
||||
{event.description && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{event.description}</p>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">
|
||||
{linkifyText(event.description).map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<span key={i}>{part}</span>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={part.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{part.url}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -933,7 +1153,7 @@ export function EventModal({
|
||||
showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
|
||||
<Button variant="outline" size="sm" onClick={() => { resourceStore.cancelEventBookings(event.id); onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
@@ -953,6 +1173,18 @@ export function EventModal({
|
||||
{t("events.duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
{hasParticipants && !showDeleteConfirm && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleReplySingle} aria-label="Reply to organizer">
|
||||
<Reply className="w-4 h-4 me-1" />
|
||||
Reply
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleReplyAll} aria-label="Reply All">
|
||||
<ReplyAll className="w-4 h-4 me-1" />
|
||||
Reply All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!showDeleteConfirm && (
|
||||
<Button onClick={() => setMode("edit")}>
|
||||
@@ -1042,6 +1274,21 @@ export function EventModal({
|
||||
setVirtualLocation,
|
||||
}}
|
||||
/>
|
||||
{attendees.length > 0 && !allDay && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="createVncMeeting"
|
||||
checked={createVncMeeting}
|
||||
onChange={(e) => setCreateVncMeeting(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
disabled={meetingCreating}
|
||||
/>
|
||||
<label htmlFor="createVncMeeting" className="text-sm">
|
||||
{meetingCreating ? "Creating meeting..." : "Create VNCtalk Meeting"}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1057,6 +1304,44 @@ export function EventModal({
|
||||
onAdd={handleAddAttendee}
|
||||
onRemove={handleRemoveAttendee}
|
||||
/>
|
||||
{attendees.length > 0 && !allDay && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFreeBusy((prev) => !prev)}
|
||||
className="text-xs"
|
||||
>
|
||||
{showFreeBusy ? (
|
||||
<EyeOff className="w-3.5 h-3.5 me-1" />
|
||||
) : (
|
||||
<Eye className="w-3.5 h-3.5 me-1" />
|
||||
)}
|
||||
{showFreeBusy ? t("freeBusy.hide") : t("freeBusy.check")}
|
||||
</Button>
|
||||
{showFreeBusy && (
|
||||
<div className="mt-3">
|
||||
<FreeBusyView
|
||||
participants={attendees}
|
||||
startDate={(() => {
|
||||
const d = new Date(`${startDate}T${startTime}:00`);
|
||||
return isNaN(d.getTime()) ? new Date() : d;
|
||||
})()}
|
||||
endDate={(() => {
|
||||
const d = new Date(`${endDate}T${endTime}:00`);
|
||||
return isNaN(d.getTime()) ? addHours(new Date(`${startDate}T${startTime}:00`), 8) : d;
|
||||
})()}
|
||||
onTimeSelect={(start, end) => {
|
||||
setStartDate(formatDateInput(start));
|
||||
setStartTime(formatTimeInput(start));
|
||||
setEndDate(formatDateInput(end));
|
||||
setEndTime(formatTimeInput(end));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isEdit && statusCounts && (existingParticipants.length > 0) && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t("participants.status_summary", {
|
||||
@@ -1067,6 +1352,27 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setShowResources((prev) => !prev)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Building2 className="w-3.5 h-3.5 me-1" />
|
||||
{showResources ? t("resources.hide") : t("resources.title")}
|
||||
</Button>
|
||||
{showResources && (
|
||||
<div className="mt-3">
|
||||
<ResourcePicker
|
||||
start={allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`}
|
||||
end={allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1121,6 +1427,32 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!allDay && (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Globe className="w-4 h-4" />
|
||||
Timezone
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
return Intl.supportedValuesOf("timeZone");
|
||||
} catch {
|
||||
return [timezone || "UTC"];
|
||||
}
|
||||
})().map((tz: string) => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pluginConflictWarnings.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{pluginConflictWarnings.map(w => (
|
||||
@@ -1304,7 +1636,7 @@ export function EventModal({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
onClick={() => { resourceStore.cancelEventBookings(event!.id); onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||
>
|
||||
{t("events.delete")}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { addMinutes, differenceInMinutes, format } from "date-fns";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy";
|
||||
|
||||
export interface ResourceFreeBusyEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
availabilityMap: Map<number, FreeBusySlot["status"]>;
|
||||
}
|
||||
|
||||
export interface FreeBusyViewProps {
|
||||
participants: { name?: string; email: string }[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onTimeSelect?: (start: Date, end: Date) => void;
|
||||
resources?: ResourceFreeBusyEntry[];
|
||||
}
|
||||
|
||||
const SLOT_MINUTES = 30;
|
||||
const WORK_START_HOUR = 8;
|
||||
const WORK_END_HOUR = 18;
|
||||
|
||||
const statusColors: Record<FreeBusySlot["status"], string> = {
|
||||
free: "bg-emerald-100 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800",
|
||||
busy: "bg-red-100 dark:bg-red-900/40 border-red-200 dark:border-red-800",
|
||||
tentative: "bg-amber-100 dark:bg-amber-900/40 border-amber-200 dark:border-amber-800",
|
||||
unavailable: "bg-purple-100 dark:bg-purple-900/40 border-purple-200 dark:border-purple-800",
|
||||
unknown: "bg-muted border-muted-foreground/20",
|
||||
};
|
||||
|
||||
const statusHoverColors: Record<FreeBusySlot["status"], string> = {
|
||||
free: "hover:bg-emerald-200 dark:hover:bg-emerald-800/60",
|
||||
busy: "hover:bg-red-200 dark:hover:bg-red-800/60",
|
||||
tentative: "hover:bg-amber-200 dark:hover:bg-amber-800/60",
|
||||
unavailable: "hover:bg-purple-200 dark:hover:bg-purple-800/60",
|
||||
unknown: "hover:bg-muted-foreground/20",
|
||||
};
|
||||
|
||||
function clampToSlot(d: Date): Date {
|
||||
const clone = new Date(d);
|
||||
clone.setSeconds(0, 0);
|
||||
const mins = clone.getMinutes();
|
||||
const remainder = mins % SLOT_MINUTES;
|
||||
if (remainder !== 0) {
|
||||
clone.setMinutes(mins - remainder, 0, 0);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function buildHourSlots(start: Date, end: Date): { label: string; slots: FreeBusySlot[] }[] {
|
||||
const hours: { label: string; slots: FreeBusySlot[] }[] = [];
|
||||
let cursor = clampToSlot(start);
|
||||
while (cursor < end) {
|
||||
const hourEnd = new Date(cursor);
|
||||
hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0);
|
||||
const hourSlots: FreeBusySlot[] = [];
|
||||
let slotCursor = new Date(cursor);
|
||||
while (slotCursor < hourEnd && slotCursor < end) {
|
||||
const slotEnd = addMinutes(slotCursor, SLOT_MINUTES);
|
||||
hourSlots.push({
|
||||
start: new Date(slotCursor),
|
||||
end: slotEnd > end ? new Date(end) : slotEnd,
|
||||
status: "unknown",
|
||||
});
|
||||
slotCursor = slotEnd;
|
||||
}
|
||||
hours.push({ label: format(cursor, "HH:mm"), slots: hourSlots });
|
||||
cursor = hourEnd;
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
function isWorkingHour(hour: number): boolean {
|
||||
return isWorkingHourFn(hour, WORK_START_HOUR, WORK_END_HOUR);
|
||||
}
|
||||
|
||||
export function FreeBusyView({
|
||||
participants,
|
||||
startDate,
|
||||
endDate,
|
||||
onTimeSelect,
|
||||
resources = [],
|
||||
}: FreeBusyViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hoveredSlot, setHoveredSlot] = useState<{
|
||||
participant: string;
|
||||
slotIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const hourSlots = useMemo(() => buildHourSlots(startDate, endDate), [startDate, endDate]);
|
||||
const totalHalfHourSlots = useMemo(() => {
|
||||
let c = 0;
|
||||
for (const h of hourSlots) c += h.slots.length;
|
||||
return c;
|
||||
}, [hourSlots]);
|
||||
|
||||
const now = new Date();
|
||||
const showNowLine =
|
||||
now >= startDate && now <= endDate;
|
||||
const nowPositionPercent = showNowLine
|
||||
? Math.max(0, Math.min(100, (differenceInMinutes(now, startDate) / differenceInMinutes(endDate, startDate)) * 100))
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || participants.length === 0) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchFreeBusy(client, participants, startDate, endDate)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setFreeBusyData(data);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, participants, startDate, endDate]);
|
||||
|
||||
const handleSlotClick = useCallback(
|
||||
(slot: FreeBusySlot) => {
|
||||
if (slot.status === "free" && onTimeSelect) {
|
||||
onTimeSelect(new Date(slot.start), new Date(slot.end));
|
||||
}
|
||||
},
|
||||
[onTimeSelect]
|
||||
);
|
||||
|
||||
const timezone = useMemo(
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
[]
|
||||
);
|
||||
|
||||
if (participants.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("freeBusy.no_participants")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("freeBusy.timezone")}: {timezone}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="text-xs text-muted-foreground animate-pulse">
|
||||
{t("freeBusy.loading")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto border border-border rounded-lg">
|
||||
<div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-background border-b border-r border-border px-3 py-2 text-left w-[180px] min-w-[180px]">
|
||||
{t("participants.title")}
|
||||
</th>
|
||||
{hourSlots.map((hour, i) => (
|
||||
<th
|
||||
key={i}
|
||||
colSpan={hour.slots.length}
|
||||
className={cn(
|
||||
"border-b border-r border-border px-1 py-2 text-center font-medium",
|
||||
isWorkingHour(new Date(hour.slots[0]?.start).getHours())
|
||||
? "bg-muted/50"
|
||||
: "bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{hour.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.map((p) => {
|
||||
const key = p.email.toLowerCase();
|
||||
const slots = freeBusyData?.get(key);
|
||||
return (
|
||||
<tr key={key} className="border-b border-border">
|
||||
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={p.name}
|
||||
email={p.email}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{p.name || p.email}
|
||||
</div>
|
||||
{p.name && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{p.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{hourSlots.map((hour) =>
|
||||
hour.slots.map((hourSlot, si) => {
|
||||
const globalSlotIndex =
|
||||
hourSlots
|
||||
.slice(0, hourSlots.indexOf(hour))
|
||||
.reduce((acc, h) => acc + h.slots.length, 0) + si;
|
||||
|
||||
const slot = slots?.[globalSlotIndex];
|
||||
const status = slot?.status ?? "unknown";
|
||||
const isFree = status === "free";
|
||||
const isHovered =
|
||||
hoveredSlot?.participant === key &&
|
||||
hoveredSlot?.slotIndex === globalSlotIndex;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={si}
|
||||
className={cn(
|
||||
"border-r border-border py-1 text-center relative cursor-default transition-colors",
|
||||
statusColors[status],
|
||||
isFree && statusHoverColors[status],
|
||||
isFree && "cursor-pointer",
|
||||
isHovered && "ring-1 ring-inset ring-primary/50",
|
||||
isWorkingHour(new Date(hourSlot.start).getHours())
|
||||
? ""
|
||||
: "opacity-70"
|
||||
)}
|
||||
title={format(hourSlot.start, "HH:mm")}
|
||||
onClick={() =>
|
||||
isFree ? handleSlotClick(slot!) : undefined
|
||||
}
|
||||
onMouseEnter={() =>
|
||||
setHoveredSlot({
|
||||
participant: key,
|
||||
slotIndex: globalSlotIndex,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() => setHoveredSlot(null)}
|
||||
>
|
||||
{status === "free" && (
|
||||
<span className="block w-full h-full"> </span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{resources.map((res) => (
|
||||
<tr key={`res-${res.id}`} className="border-b border-border">
|
||||
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center shrink-0">
|
||||
<span className="text-[10px] font-bold text-blue-600 dark:text-blue-400">
|
||||
R
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate text-sm">
|
||||
{res.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{hourSlots.map((hour) =>
|
||||
hour.slots.map((hourSlot, si) => {
|
||||
const globalSlotIndex =
|
||||
hourSlots
|
||||
.slice(0, hourSlots.indexOf(hour))
|
||||
.reduce((acc, h) => acc + h.slots.length, 0) + si;
|
||||
|
||||
const status = res.availabilityMap.get(globalSlotIndex) ?? "unknown";
|
||||
const isFree = status === "free";
|
||||
|
||||
return (
|
||||
<td
|
||||
key={si}
|
||||
className={cn(
|
||||
"border-r border-border py-1 text-center relative cursor-default transition-colors",
|
||||
statusColors[status],
|
||||
isFree && "cursor-pointer",
|
||||
isWorkingHour(new Date(hourSlot.start).getHours())
|
||||
? ""
|
||||
: "opacity-70"
|
||||
)}
|
||||
title={`${res.name} - ${format(hourSlot.start, "HH:mm")}`}
|
||||
>
|
||||
{status === "free" && (
|
||||
<span className="block w-full h-full"> </span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNowLine && nowPositionPercent !== null && (
|
||||
<div
|
||||
className="absolute pointer-events-none z-20"
|
||||
style={{
|
||||
left: `calc(180px + ${nowPositionPercent}% * (1 - 180px / ${totalHalfHourSlots * 24 + 200}))`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-emerald-200 dark:border-emerald-800 bg-emerald-100 dark:bg-emerald-900/40" />
|
||||
{t("freeBusy.free")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-red-200 dark:border-red-800 bg-red-100 dark:bg-red-900/40" />
|
||||
{t("freeBusy.busy")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-amber-200 dark:border-amber-800 bg-amber-100 dark:bg-amber-900/40" />
|
||||
{t("freeBusy.tentative")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-purple-200 dark:border-purple-800 bg-purple-100 dark:bg-purple-900/40" />
|
||||
{t("freeBusy.unavailable")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-muted-foreground/20 bg-muted" />
|
||||
{t("freeBusy.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
format,
|
||||
isToday,
|
||||
isSameDay,
|
||||
addMonths,
|
||||
subMonths,
|
||||
isSameMonth,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||
|
||||
interface MiniCalendarDashletProps {
|
||||
events?: { date: string; color?: string }[];
|
||||
onDayClick?: (date: Date) => void;
|
||||
selectedDate?: Date;
|
||||
}
|
||||
|
||||
const ALL_DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||
|
||||
export function MiniCalendarDashlet({
|
||||
events: propEvents,
|
||||
onDayClick,
|
||||
selectedDate: propSelectedDate,
|
||||
}: MiniCalendarDashletProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const router = useRouter();
|
||||
const firstDayOfWeek = useSettingsStore((s) => s.firstDayOfWeek);
|
||||
const storeSelectedDate = useCalendarStore((s) => s.selectedDate);
|
||||
const storeEvents = useCalendarStore((s) => s.events);
|
||||
const selectedDate = propSelectedDate ?? storeSelectedDate;
|
||||
const client = useAuthStore((s) => s.client);
|
||||
|
||||
const [displayMonth, setDisplayMonth] = useState(() => new Date());
|
||||
|
||||
const weekStartsOn = useMemo(() => {
|
||||
if (firstDayOfWeek === 0) return 0 as const;
|
||||
if (firstDayOfWeek === 6) return 6 as const;
|
||||
return 1 as const;
|
||||
}, [firstDayOfWeek]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
const start = format(startOfMonth(displayMonth), "yyyy-MM-dd'T'00:00:00");
|
||||
const end = format(endOfMonth(displayMonth), "yyyy-MM-dd'T'23:59:59");
|
||||
const { dateRange } = useCalendarStore.getState();
|
||||
if (dateRange?.start === start && dateRange?.end === end) return;
|
||||
useCalendarStore.getState().fetchEvents(client, start, end);
|
||||
}, [displayMonth, client]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(displayMonth);
|
||||
const monthEnd = endOfMonth(displayMonth);
|
||||
const calStart = startOfWeek(monthStart, { weekStartsOn });
|
||||
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd });
|
||||
}, [displayMonth, weekStartsOn]);
|
||||
|
||||
const eventDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const e of storeEvents) {
|
||||
try {
|
||||
const { startDay, endDay } = getEventDayBounds(e);
|
||||
const cursor = new Date(startDay);
|
||||
while (cursor <= endDay) {
|
||||
set.add(format(cursor, "yyyy-MM-dd"));
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (propEvents) {
|
||||
for (const e of propEvents) {
|
||||
set.add(e.date);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}, [storeEvents, propEvents]);
|
||||
|
||||
const dayHeaders = useMemo(
|
||||
() => [...ALL_DAY_KEYS.slice(weekStartsOn), ...ALL_DAY_KEYS.slice(0, weekStartsOn)],
|
||||
[weekStartsOn],
|
||||
);
|
||||
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
setDisplayMonth((prev) => subMonths(prev, 1));
|
||||
}, []);
|
||||
|
||||
const handleNextMonth = useCallback(() => {
|
||||
setDisplayMonth((prev) => addMonths(prev, 1));
|
||||
}, []);
|
||||
|
||||
const handleGoToToday = useCallback(() => {
|
||||
setDisplayMonth(new Date());
|
||||
}, []);
|
||||
|
||||
const handleDayClick = useCallback(
|
||||
(day: Date) => {
|
||||
useCalendarStore.getState().setSelectedDate(day);
|
||||
if (onDayClick) {
|
||||
onDayClick(day);
|
||||
} else {
|
||||
router.push("/calendar");
|
||||
}
|
||||
},
|
||||
[onDayClick, router],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="select-none px-2 py-1.5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<button
|
||||
onClick={handlePrevMonth}
|
||||
className="p-0.5 rounded hover:bg-muted transition-colors"
|
||||
aria-label={t("nav_prev")}
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoToToday}
|
||||
className="text-xs font-medium hover:bg-muted px-1.5 py-0.5 rounded transition-colors"
|
||||
title={t("views.today")}
|
||||
>
|
||||
{format(displayMonth, "MMM yyyy")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextMonth}
|
||||
className="p-0.5 rounded hover:bg-muted transition-colors"
|
||||
aria-label={t("nav_next")}
|
||||
>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 mb-0.5">
|
||||
{dayHeaders.map((dh) => (
|
||||
<div
|
||||
key={dh}
|
||||
className="text-center text-[9px] font-medium text-muted-foreground py-0.5"
|
||||
>
|
||||
{t(`days.${dh}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0">
|
||||
{days.map((day) => {
|
||||
const inMonth = isSameMonth(day, displayMonth);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const dateStr = format(day, "yyyy-MM-dd");
|
||||
const hasEvent = eventDates.has(dateStr);
|
||||
const dotColor =
|
||||
propEvents?.find((e) => e.date === dateStr && e.color)?.color ??
|
||||
undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-6 h-6 text-[11px] rounded-full transition-colors mx-auto",
|
||||
!inMonth && "text-muted-foreground/30",
|
||||
inMonth && !selected && "hover:bg-muted",
|
||||
today && !selected && "font-bold text-primary",
|
||||
selected && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{day.getDate()}
|
||||
{hasEvent && !selected && (
|
||||
<span
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary"
|
||||
style={dotColor ? { backgroundColor: dotColor } : undefined}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useResourceStore } from "@/stores/resource-store";
|
||||
import type { Resource } from "@/lib/resources/client";
|
||||
import {
|
||||
Building2,
|
||||
Car,
|
||||
Wrench,
|
||||
Box,
|
||||
MapPin,
|
||||
Users,
|
||||
Search,
|
||||
X,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ResourcePickerProps {
|
||||
start?: string;
|
||||
end?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const typeIcons: Record<Resource["type"], typeof Building2> = {
|
||||
room: Building2,
|
||||
vehicle: Car,
|
||||
equipment: Wrench,
|
||||
other: Box,
|
||||
};
|
||||
|
||||
type TypeFilter = "all" | Resource["type"];
|
||||
|
||||
export function ResourcePicker({ start, end, compact = false }: ResourcePickerProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const {
|
||||
resources,
|
||||
selectedResources,
|
||||
isLoading,
|
||||
fetchResources,
|
||||
searchResources,
|
||||
toggleResource,
|
||||
deselectResource,
|
||||
clearSelection,
|
||||
} = useResourceStore();
|
||||
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [availabilityMap, setAvailabilityMap] = useState<Record<string, "available" | "conflict" | "unknown">>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchResources();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = typeFilter === "all" ? resources : resources.filter((r) => r.type === typeFilter);
|
||||
if (query.trim()) {
|
||||
list = searchResources(query).filter((r) => typeFilter === "all" || r.type === typeFilter);
|
||||
}
|
||||
return list;
|
||||
}, [resources, typeFilter, query, searchResources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!start || !end) return;
|
||||
let cancelled = false;
|
||||
|
||||
const checkAll = async () => {
|
||||
const map: Record<string, "available" | "conflict" | "unknown"> = {};
|
||||
for (const resource of filtered) {
|
||||
try {
|
||||
const params = new URLSearchParams({ start, end });
|
||||
const { apiFetch } = await import("@/lib/browser-navigation");
|
||||
const res = await apiFetch(
|
||||
`/api/resources/${resource.id}/availability?${params.toString()}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
map[resource.id] = data.available ? "available" : "conflict";
|
||||
} else {
|
||||
map[resource.id] = "unknown";
|
||||
}
|
||||
} catch {
|
||||
map[resource.id] = "unknown";
|
||||
}
|
||||
}
|
||||
if (!cancelled) setAvailabilityMap(map);
|
||||
};
|
||||
|
||||
checkAll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filtered, start, end]);
|
||||
|
||||
const filters: { key: TypeFilter; label: string }[] = [
|
||||
{ key: "all", label: t("resources.filter_all") },
|
||||
{ key: "room", label: t("resources.type_room") },
|
||||
{ key: "vehicle", label: t("resources.type_vehicle") },
|
||||
{ key: "equipment", label: t("resources.type_equipment") },
|
||||
{ key: "other", label: t("resources.type_other") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setTypeFilter(f.key)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
||||
typeFilter === f.key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("resources.search_placeholder")}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("resources.no_resources")}
|
||||
</p>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"border border-border rounded-lg divide-y divide-border",
|
||||
!compact && "max-h-64 overflow-y-auto"
|
||||
)}>
|
||||
{filtered.map((resource) => {
|
||||
const TypeIcon = typeIcons[resource.type];
|
||||
const isSelected = selectedResources.some((r) => r.id === resource.id);
|
||||
const avail = availabilityMap[resource.id] || "unknown";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={resource.id}
|
||||
type="button"
|
||||
onClick={() => toggleResource(resource)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 hover:bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<span className="relative flex-shrink-0">
|
||||
<TypeIcon className="w-5 h-5 text-muted-foreground" />
|
||||
{start && end && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background",
|
||||
avail === "available" && "bg-emerald-500",
|
||||
avail === "conflict" && "bg-red-500",
|
||||
avail === "unknown" && "bg-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{resource.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{resource.location && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{resource.location}
|
||||
</span>
|
||||
)}
|
||||
{resource.capacity != null && resource.capacity > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Users className="w-3 h-3" />
|
||||
{resource.capacity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors",
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-muted-foreground/40"
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="w-3.5 h-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedResources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{selectedResources.map((resource) => (
|
||||
<span
|
||||
key={resource.id}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{resource.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deselectResource(resource.id)}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/20 transition-colors"
|
||||
aria-label={t("resources.remove", { name: resource.name })}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{selectedResources.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
className="text-xs text-muted-foreground hover:text-foreground ml-1"
|
||||
>
|
||||
{t("resources.clear_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,26 +2,39 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react";
|
||||
import { Upload, FileText, AlertTriangle, X, Check, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import {
|
||||
parseCSV,
|
||||
autoMapColumns,
|
||||
mapRowToContact,
|
||||
detectDuplicatesByEmail,
|
||||
type CsvColumnMapping,
|
||||
type CsvParseResult,
|
||||
} from "@/lib/contact-csv-import";
|
||||
|
||||
type FileType = "vcf" | "csv" | null;
|
||||
|
||||
interface ContactImportDialogProps {
|
||||
existingContacts: ContactCard[];
|
||||
addressBooks?: AddressBook[];
|
||||
onImport: (contacts: ContactCard[]) => Promise<number>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ContactImportDialog({
|
||||
existingContacts,
|
||||
addressBooks,
|
||||
onImport,
|
||||
onClose,
|
||||
}: ContactImportDialogProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [fileType, setFileType] = useState<FileType>(null);
|
||||
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [duplicates, setDuplicates] = useState<Map<number, string>>(new Map());
|
||||
@@ -29,41 +42,111 @@ export function ContactImportDialog({
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [csvData, setCsvData] = useState<CsvParseResult | null>(null);
|
||||
const [mapping, setMapping] = useState<CsvColumnMapping | null>(null);
|
||||
const [targetBookId, setTargetBookId] = useState("");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const ALLOWED_ACCEPT = ".vcf,.vcard,.csv,text/csv,text/vcard";
|
||||
|
||||
const books = addressBooks || [];
|
||||
const defaultBookId =
|
||||
books.find((b) => b.isDefault)?.id || books[0]?.id || "";
|
||||
const effectiveBookId = targetBookId || defaultBookId;
|
||||
const bookOptions = books.map((b) => ({
|
||||
value: b.id,
|
||||
label: b.name,
|
||||
}));
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setFileType(null);
|
||||
setParsed([]);
|
||||
setSelected(new Set());
|
||||
setDuplicates(new Map());
|
||||
setCsvData(null);
|
||||
setMapping(null);
|
||||
setShowPreview(false);
|
||||
setTargetBookId("");
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError(t("import.file_too_large"));
|
||||
return;
|
||||
}
|
||||
|
||||
const name = file.name.toLowerCase();
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const contacts = parseVCard(text);
|
||||
if (name.endsWith(".csv") || file.type === "text/csv") {
|
||||
setFileType("csv");
|
||||
const text = await file.text();
|
||||
const result = parseCSV(text);
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
if (result.rows.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvData(result);
|
||||
setMapping(autoMapColumns(result.headers));
|
||||
setTargetBookId(defaultBookId);
|
||||
} else {
|
||||
setFileType("vcf");
|
||||
const text = await file.text();
|
||||
const contacts = parseVCard(text);
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const dupes = detectDuplicates(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
}
|
||||
|
||||
const dupes = detectDuplicates(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse vCard:', error);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse file:", err);
|
||||
setError(t("import.parse_error"));
|
||||
}
|
||||
}, [existingContacts, t]);
|
||||
}, [existingContacts, t, defaultBookId]);
|
||||
|
||||
const applyCsvMapping = useCallback(() => {
|
||||
if (!csvData || !mapping) return;
|
||||
|
||||
const bookIds = effectiveBookId ? { [effectiveBookId]: true } : {};
|
||||
const contacts: ContactCard[] = [];
|
||||
|
||||
for (const row of csvData.rows) {
|
||||
const contact = mapRowToContact(row, mapping, bookIds);
|
||||
if (contact) contacts.push(contact);
|
||||
}
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const dupes = detectDuplicatesByEmail(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
setShowPreview(true);
|
||||
}, [csvData, mapping, effectiveBookId, existingContacts, t]);
|
||||
|
||||
const toggleSelect = (idx: number) => {
|
||||
const next = new Set(selected);
|
||||
@@ -91,14 +174,160 @@ export function ContactImportDialog({
|
||||
try {
|
||||
const count = await onImport(toImport);
|
||||
setResult(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to import contacts:', error);
|
||||
} catch (err) {
|
||||
console.error("Failed to import contacts:", err);
|
||||
setError(t("import.failed"));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCsvMapping = () => {
|
||||
if (!csvData || !mapping) return null;
|
||||
|
||||
const fields: Array<{ key: keyof CsvColumnMapping; label: string }> = [
|
||||
{ key: "firstName", label: t("import.csv_first_name") },
|
||||
{ key: "lastName", label: t("import.csv_last_name") },
|
||||
{ key: "email", label: t("import.csv_email") },
|
||||
{ key: "phone", label: t("import.csv_phone") },
|
||||
{ key: "company", label: t("import.csv_company") },
|
||||
{ key: "jobTitle", label: t("import.csv_job_title") },
|
||||
{ key: "address", label: t("import.csv_address") },
|
||||
{ key: "city", label: t("import.csv_city") },
|
||||
{ key: "region", label: t("import.csv_region") },
|
||||
{ key: "postcode", label: t("import.csv_postcode") },
|
||||
{ key: "country", label: t("import.csv_country") },
|
||||
{ key: "website", label: t("import.csv_website") },
|
||||
{ key: "note", label: t("import.csv_note") },
|
||||
{ key: "nickname", label: t("import.csv_nickname") },
|
||||
];
|
||||
|
||||
const headerOptions = csvData.headers.map((h, i) => ({
|
||||
value: String(i),
|
||||
label: h,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">{t("import.csv_map_columns")}</p>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
|
||||
{fields.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground w-24 flex-shrink-0 truncate">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
value={mapping[key] >= 0 ? String(mapping[key]) : "-1"}
|
||||
onChange={(e) => {
|
||||
setMapping((prev) => prev ? {
|
||||
...prev,
|
||||
[key]: parseInt(e.target.value, 10),
|
||||
} : null);
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-xs rounded border border-border bg-muted text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
dir="auto"
|
||||
>
|
||||
<option value="-1">{t("import.csv_ignore")}</option>
|
||||
{headerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{books.length > 0 && (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<label className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{t("import.csv_address_book")}
|
||||
</label>
|
||||
<select
|
||||
value={effectiveBookId}
|
||||
onChange={(e) => setTargetBookId(e.target.value)}
|
||||
className="px-2 py-1 text-xs rounded border border-border bg-muted text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
dir="auto"
|
||||
>
|
||||
{bookOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button size="sm" onClick={applyCsvMapping}>
|
||||
{t("import.csv_preview")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFileType(null);
|
||||
setCsvData(null);
|
||||
setMapping(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}}
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCsvPreview = () => {
|
||||
if (!csvData || !mapping || !showPreview) return null;
|
||||
const previewRows = csvData.rows.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">{t("import.csv_preview_title", { count: parsed.length })}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(false)}
|
||||
>
|
||||
{t("import.csv_back")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-md overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted">
|
||||
{csvData.headers.map((h, i) => (
|
||||
<th key={i} className="px-2 py-1.5 text-start font-medium text-muted-foreground whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewRows.map((row, ri) => (
|
||||
<tr key={ri} className="border-t border-border">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-2 py-1.5 truncate max-w-[150px]">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={applyCsvMapping}>
|
||||
{t("import.csv_load_all")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
@@ -119,12 +348,12 @@ export function ContactImportDialog({
|
||||
{t("import.close")}
|
||||
</Button>
|
||||
</div>
|
||||
) : parsed.length === 0 ? (
|
||||
) : fileType === null ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".vcf,.vcard"
|
||||
accept={ALLOWED_ACCEPT}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -141,7 +370,7 @@ export function ContactImportDialog({
|
||||
>
|
||||
<Upload className="w-8 h-8" />
|
||||
<p className="text-sm font-medium">{t("import.drop_hint")}</p>
|
||||
<p className="text-xs">{t("import.file_types")}</p>
|
||||
<p className="text-xs">{t("import.file_types_csv")}</p>
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
@@ -151,6 +380,10 @@ export function ContactImportDialog({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : fileType === "csv" && csvData && !showPreview ? (
|
||||
renderCsvMapping()
|
||||
) : fileType === "csv" && csvData && showPreview ? (
|
||||
renderCsvPreview()
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
@@ -217,7 +450,23 @@ export function ContactImportDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsed.length > 0 && result === null && (
|
||||
{parsed.length > 0 && result === null && fileType !== "csv" && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={isImporting}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
|
||||
{isImporting ? t("import.importing") : t("import.import_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileType === "csv" && showPreview && parsed.length > 0 && result === null && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, Pencil } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||
@@ -142,6 +143,63 @@ export function ContactList({
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuContact, setRadialMenuContact] = useState<ContactCard | null>(null);
|
||||
|
||||
const openRadialMenu = useCallback((e: React.MouseEvent, contact: ContactCard) => {
|
||||
e.preventDefault();
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuContact(contact);
|
||||
setRadialMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuContact) return [];
|
||||
const c = radialMenuContact;
|
||||
const items: RadialMenuItem[] = [];
|
||||
items.push({
|
||||
id: "edit",
|
||||
icon: <Pencil className="w-5 h-5" />,
|
||||
label: t("edit"),
|
||||
onClick: () => { onEditContact(c.id); },
|
||||
});
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("delete"),
|
||||
onClick: () => { onDeleteContact(c); },
|
||||
destructive: true,
|
||||
});
|
||||
if (c.emails && Object.keys(c.emails).length > 0) {
|
||||
const contactEmails = c.emails;
|
||||
items.push({
|
||||
id: "send-email",
|
||||
icon: <Mail className="w-5 h-5" />,
|
||||
label: t("send_email"),
|
||||
onClick: () => {
|
||||
const values = Object.values(contactEmails);
|
||||
if (values[0]?.address) {
|
||||
window.location.href = `mailto:${values[0].address}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
id: "export",
|
||||
icon: <Download className="w-5 h-5" />,
|
||||
label: t("export"),
|
||||
onClick: () => { onBulkExport(); },
|
||||
});
|
||||
return items;
|
||||
}, [radialMenuContact, t, onEditContact, onDeleteContact, onBulkExport]);
|
||||
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
|
||||
const activeFilters = countActiveFilters(filters);
|
||||
@@ -571,7 +629,7 @@ export function ContactList({
|
||||
e.stopPropagation();
|
||||
onToggleSelection(contact.id);
|
||||
}}
|
||||
onContextMenu={(e, c) => openContextMenu(e, c)}
|
||||
onContextMenu={(e, c) => { openContextMenu(e, c); openRadialMenu(e, c); }}
|
||||
/>
|
||||
);
|
||||
return groupByLetter ? (
|
||||
@@ -592,6 +650,14 @@ export function ContactList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{contextMenu.data && (
|
||||
<ContactContextMenu
|
||||
contact={contextMenu.data}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PenLine } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -24,6 +24,7 @@ import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useSignatureStore } from "@/stores/signature-store";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
@@ -298,6 +299,34 @@ export function EmailComposer({
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const templatesEnabled = isFeatureEnabled('templatesEnabled');
|
||||
|
||||
const {
|
||||
signatures,
|
||||
defaultSignatureId,
|
||||
replySignatureId,
|
||||
getSignatureById,
|
||||
getIdentityDefaultSignatureId,
|
||||
getIdentityReplySignatureId,
|
||||
} = useSignatureStore();
|
||||
|
||||
const resolveStoreSignatureId = (): string | null => {
|
||||
const perIdentityId = selectedIdentityId || initialData?.selectedIdentityId || null;
|
||||
if (mode === 'compose') {
|
||||
if (perIdentityId) {
|
||||
const id = getIdentityDefaultSignatureId(perIdentityId);
|
||||
if (id) return id;
|
||||
}
|
||||
return defaultSignatureId;
|
||||
}
|
||||
if (perIdentityId) {
|
||||
const id = getIdentityReplySignatureId(perIdentityId);
|
||||
if (id) return id;
|
||||
}
|
||||
return replySignatureId ?? defaultSignatureId;
|
||||
};
|
||||
|
||||
const [selectedSignatureId, setSelectedSignatureId] = useState<string | null>(resolveStoreSignatureId);
|
||||
const selectedSignature = selectedSignatureId ? getSignatureById(selectedSignatureId) ?? null : null;
|
||||
|
||||
// The signature identity used when embedding the signature into the initial
|
||||
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
||||
// below, but uses initialData (or primary) since selectedIdentityId state
|
||||
@@ -509,17 +538,19 @@ export function EmailComposer({
|
||||
// requests with the same draftId. See bug #303.
|
||||
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||
return replyTo.attachments
|
||||
if (replyTo?.attachments?.length) {
|
||||
let atts = replyTo.attachments;
|
||||
if (mode === 'forward') {
|
||||
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
|
||||
// (matches the viewer's hideInlineImageAttachments logic).
|
||||
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||
.map(att => ({
|
||||
name: att.name || 'attachment',
|
||||
type: att.type || 'application/octet-stream',
|
||||
size: att.size,
|
||||
blobId: att.blobId,
|
||||
}));
|
||||
atts = atts.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')));
|
||||
}
|
||||
return atts.map(att => ({
|
||||
name: att.name || 'attachment',
|
||||
type: att.type || 'application/octet-stream',
|
||||
size: att.size,
|
||||
blobId: att.blobId,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
@@ -592,6 +623,7 @@ export function EmailComposer({
|
||||
// when the user switches identity in "above quote" mode without rebuilding
|
||||
// the whole body (which would lose user edits to the surrounding draft).
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
|
||||
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
||||
|
||||
@@ -668,6 +700,28 @@ export function EmailComposer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
||||
|
||||
const sigInsertedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (plainTextMode) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
if (!selectedSignatureId) return;
|
||||
if (sigInsertedRef.current) return;
|
||||
const sig = getSignatureById(selectedSignatureId);
|
||||
if (!sig) return;
|
||||
const currentHtml = serializeEditorContent(editor);
|
||||
if (currentHtml.includes(sig.body)) {
|
||||
sigInsertedRef.current = true;
|
||||
return;
|
||||
}
|
||||
sigInsertedRef.current = true;
|
||||
if (mode === 'compose') {
|
||||
editor.chain().focus('end').insertContent(`<p></p>${sig.body}`).run();
|
||||
} else if ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') {
|
||||
editor.chain().focus('start').insertContent(sig.body).run();
|
||||
}
|
||||
}, [selectedSignatureId, plainTextMode, mode, signaturePosition, getSignatureById, editorReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutsideSendMenu = (event: MouseEvent) => {
|
||||
if (!sendMenuRef.current?.contains(event.target as Node)) {
|
||||
@@ -2498,7 +2552,7 @@ export function EmailComposer({
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; }}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; setEditorReady(true); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2657,8 +2711,53 @@ export function EmailComposer({
|
||||
<PluginSlot name="composer-toolbar" />
|
||||
</div>
|
||||
|
||||
{/* Right side - Discard + Send (desktop) */}
|
||||
{/* Right side - Signature selector + Discard + Send (desktop) */}
|
||||
<div className="flex items-center gap-2">
|
||||
{signatures.length > 0 && (
|
||||
<div className="relative hidden md:inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!editorRef.current) return;
|
||||
const sig = selectedSignature;
|
||||
if (sig) {
|
||||
editorRef.current.chain().focus().insertContent(sig.body).run();
|
||||
}
|
||||
}}
|
||||
title={t('insert_signature')}
|
||||
className="h-8 px-2 text-xs gap-1"
|
||||
disabled={!selectedSignature}
|
||||
>
|
||||
<PenLine className="w-4 h-4" />
|
||||
{selectedSignature?.name ?? t('no_signature')}
|
||||
</Button>
|
||||
<select
|
||||
value={selectedSignatureId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
setSelectedSignatureId(id || null);
|
||||
if (id && editorRef.current) {
|
||||
const sig = getSignatureById(id);
|
||||
if (sig) {
|
||||
editorRef.current.chain().focus().insertContent(sig.body).run();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
title={t('select_signature')}
|
||||
aria-label={t('select_signature')}
|
||||
>
|
||||
<option value="">{t('no_signature')}</option>
|
||||
{signatures.map((sig) => (
|
||||
<option key={sig.id} value={sig.id}>
|
||||
{sig.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useUIStore } from "@/stores/ui-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import { Reply, ReplyAll, Forward, Star, Archive, FolderOpen } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
@@ -141,6 +143,101 @@ export function EmailList({
|
||||
const contextMenuEmail = contextMenu.data
|
||||
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
|
||||
: null;
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuEmail, setRadialMenuEmail] = useState<Email | null>(null);
|
||||
|
||||
const openRadialMenu = useCallback((e: React.MouseEvent, email: Email) => {
|
||||
e.preventDefault();
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuEmail(email);
|
||||
setRadialMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuEmail) return [];
|
||||
const email = radialMenuEmail;
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
|
||||
const act = (fn?: (email: Email) => void) => fn ? () => { fn(email); } : undefined;
|
||||
|
||||
const items: RadialMenuItem[] = [];
|
||||
|
||||
if (onReply) {
|
||||
items.push({
|
||||
id: "reply",
|
||||
icon: <Reply className="w-5 h-5" />,
|
||||
label: t("../context_menu.reply"),
|
||||
onClick: () => { act(onReply)!(); },
|
||||
});
|
||||
}
|
||||
if (onReplyAll) {
|
||||
items.push({
|
||||
id: "reply-all",
|
||||
icon: <ReplyAll className="w-5 h-5" />,
|
||||
label: t("../context_menu.reply_all"),
|
||||
onClick: () => { act(onReplyAll)!(); },
|
||||
});
|
||||
}
|
||||
if (onForward) {
|
||||
items.push({
|
||||
id: "forward",
|
||||
icon: <Forward className="w-5 h-5" />,
|
||||
label: t("../context_menu.forward"),
|
||||
onClick: () => { act(onForward)!(); },
|
||||
});
|
||||
}
|
||||
if (onToggleStar) {
|
||||
items.push({
|
||||
id: "star",
|
||||
icon: <Star className="w-5 h-5" fill={isStarred ? "currentColor" : "none"} />,
|
||||
label: isStarred ? t("../context_menu.unstar") : t("../context_menu.star"),
|
||||
onClick: () => { act(onToggleStar)!(); },
|
||||
});
|
||||
}
|
||||
if (onMarkAsRead) {
|
||||
items.push({
|
||||
id: "mark-read",
|
||||
icon: isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />,
|
||||
label: isUnread ? t("../context_menu.mark_read") : t("../context_menu.mark_unread"),
|
||||
onClick: () => { onMarkAsRead(email, !isUnread); },
|
||||
});
|
||||
}
|
||||
if (onArchive) {
|
||||
items.push({
|
||||
id: "archive",
|
||||
icon: <Archive className="w-5 h-5" />,
|
||||
label: t("../context_menu.archive"),
|
||||
onClick: () => { act(onArchive)!(); },
|
||||
});
|
||||
}
|
||||
if (onDelete) {
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("../context_menu.delete"),
|
||||
onClick: () => { act(onDelete)!(); },
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
if (onMoveToMailbox) {
|
||||
items.push({
|
||||
id: "move",
|
||||
icon: <FolderOpen className="w-5 h-5" />,
|
||||
label: t("../context_menu.move_to"),
|
||||
onClick: () => { openContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: radialMenuPos.x, clientY: radialMenuPos.y } as React.MouseEvent, email); },
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [radialMenuEmail, radialMenuPos, t, onReply, onReplyAll, onForward, onToggleStar, onMarkAsRead, onArchive, onDelete, onMoveToMailbox, openContextMenu]);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -549,7 +646,7 @@ export function EmailList({
|
||||
onEmailSelect?.(email);
|
||||
}}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onContextMenu={(e, email) => { openContextMenu(e, email); openRadialMenu(e, email); }}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||
@@ -581,6 +678,14 @@ export function EmailList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenuEmail && (
|
||||
<EmailContextMenu
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
PlayCircle,
|
||||
PenSquare,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
@@ -88,6 +89,8 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
@@ -703,6 +706,9 @@ export function EmailViewer({
|
||||
const isScheduled = email?.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
||||
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const createAppointmentVisible = !isScheduled && !isDraft && calendarEnabled && !!email;
|
||||
|
||||
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
@@ -1029,6 +1035,34 @@ export function EmailViewer({
|
||||
const { isMobile: isMobileDevice } = useDeviceDetection();
|
||||
const router = useRouter();
|
||||
|
||||
const handleCreateAppointment = useCallback(() => {
|
||||
if (!email) return;
|
||||
const subject = email.subject ? `Re: ${email.subject}` : "";
|
||||
const body = email.htmlBody?.[0]?.partId
|
||||
? email.bodyValues?.[email.htmlBody[0].partId]?.value || ""
|
||||
: "";
|
||||
const participants: { name?: string; email: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addParticipant = (p?: { name?: string; email?: string }) => {
|
||||
if (!p?.email) return;
|
||||
const normalized = p.email.toLowerCase();
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
participants.push({ name: p.name, email: p.email });
|
||||
}
|
||||
};
|
||||
if (email.from) email.from.forEach(addParticipant);
|
||||
if (email.to) email.to.forEach(addParticipant);
|
||||
if (email.cc) email.cc.forEach(addParticipant);
|
||||
useCalendarStore.getState().setNewEventPrefill({
|
||||
title: subject,
|
||||
description: body,
|
||||
participants,
|
||||
date: email.receivedAt,
|
||||
});
|
||||
router.push('/calendar');
|
||||
}, [email, router]);
|
||||
|
||||
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
|
||||
if (isMobileDevice) {
|
||||
// No room for a sidebar on mobile - send the user to the contacts page
|
||||
@@ -2909,6 +2943,20 @@ export function EmailViewer({
|
||||
<Forward className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
|
||||
</Button>
|
||||
{createAppointmentVisible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCreateAppointment}
|
||||
data-overflow-item
|
||||
data-overflow-priority="3.5"
|
||||
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('create_appointment')}
|
||||
>
|
||||
<CalendarPlus className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('create_appointment')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
</>)}
|
||||
<PluginSlot name="toolbar-actions" />
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
AlertCircle, Star, Clock, FolderUp,
|
||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
Menu, Users, Share2,
|
||||
Menu, Users, Share2, MailPlus, Paperclip, ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,6 +27,7 @@ import { Avatar } from "@/components/ui/avatar";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
|
||||
@@ -106,6 +107,8 @@ interface FileBrowserProps {
|
||||
sharingEnabled?: boolean;
|
||||
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
|
||||
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
|
||||
/** Send selected files as email attachments - opens the composer with files pre-attached. */
|
||||
onSendAsAttachment?: (names: string[]) => void;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
||||
@@ -205,6 +208,14 @@ function isDatabaseFile(name: string): boolean {
|
||||
return DATABASE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const OFFICE_EXTENSIONS = new Set([
|
||||
"docx", "xlsx", "pptx", "odt", "ods", "odp", "doc", "xls", "ppt",
|
||||
]);
|
||||
function isOfficeFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return OFFICE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
function isPreviewable(name: string): boolean {
|
||||
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
||||
}
|
||||
@@ -384,6 +395,7 @@ export function FileBrowser({
|
||||
ownAccountId,
|
||||
sharingEnabled,
|
||||
onShare,
|
||||
onSendAsAttachment,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
@@ -401,6 +413,60 @@ export function FileBrowser({
|
||||
[sharingEnabled, onShare, client]);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuResourceName, setRadialMenuResourceName] = useState<string | null>(null);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuResourceName) return [];
|
||||
const name = radialMenuResourceName;
|
||||
const resource = resources.find((r) => r.name === name);
|
||||
const items: RadialMenuItem[] = [];
|
||||
items.push({
|
||||
id: "rename",
|
||||
icon: <Pencil className="w-5 h-5" />,
|
||||
label: t("rename"),
|
||||
onClick: () => { setRenameTarget(name); },
|
||||
});
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("delete"),
|
||||
onClick: () => { onDelete(name); },
|
||||
destructive: true,
|
||||
});
|
||||
if (resource && !resource.isDirectory) {
|
||||
items.push({
|
||||
id: "download",
|
||||
icon: <Download className="w-5 h-5" />,
|
||||
label: t("download"),
|
||||
onClick: () => { onDownload(name); },
|
||||
});
|
||||
}
|
||||
if (canShare(resource)) {
|
||||
items.push({
|
||||
id: "share",
|
||||
icon: <Share2 className="w-5 h-5" />,
|
||||
label: t("share"),
|
||||
onClick: () => { if (resource?.id) setShareTargetId(resource.id); },
|
||||
});
|
||||
}
|
||||
if (resource && !resource.isDirectory) {
|
||||
items.push({
|
||||
id: "send-as-attachment",
|
||||
icon: <Paperclip className="w-5 h-5" />,
|
||||
label: t("send_as_attachment"),
|
||||
onClick: () => {},
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [radialMenuResourceName, resources, t, onDelete, onDownload, canShare]);
|
||||
const [showNewTextFile, setShowNewTextFile] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -765,6 +831,9 @@ export function FileBrowser({
|
||||
const handleContextMenu = (e: React.MouseEvent, name: string) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, name });
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuResourceName(name);
|
||||
setRadialMenuOpen(true);
|
||||
};
|
||||
|
||||
// Adjust context menu position to stay within viewport
|
||||
@@ -974,28 +1043,76 @@ export function FileBrowser({
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{selectedResources.size > 1 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
|
||||
>
|
||||
<Download className="w-4 h-4 me-1" />
|
||||
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onBatchDelete([...selectedResources])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
{t("delete")} ({selectedResources.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{selectedResources.size > 0 && (() => {
|
||||
const fileNames = [...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory);
|
||||
const hasFiles = fileNames.length > 0;
|
||||
const showBatch = selectedResources.size > 1;
|
||||
if (!showBatch && !hasFiles) return null;
|
||||
return (
|
||||
<>
|
||||
{showBatch && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onBatchDownload(fileNames)}
|
||||
>
|
||||
<Download className="w-4 h-4 me-1" />
|
||||
{t("download")} ({fileNames.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onBatchDelete([...selectedResources])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
{t("delete")} ({selectedResources.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{hasFiles && onSendAsAttachment && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onSendAsAttachment(fileNames)}
|
||||
>
|
||||
<MailPlus className="w-4 h-4 me-1" />
|
||||
{t("send_as_attachment")} {fileNames.length > 1 && `(${fileNames.length})`}
|
||||
</Button>
|
||||
)}
|
||||
{!showBatch && hasFiles && fileNames.length === 1 && isOfficeFile(fileNames[0]) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={async () => {
|
||||
const file = resources.find((r) => r.name === fileNames[0]);
|
||||
if (!file) return;
|
||||
try {
|
||||
const res = await fetch("/api/collabora/edit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { url } = await res.json();
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Collabora edit failed:", err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4 me-1" />
|
||||
Edit with Collabora
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{clipboard && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1669,6 +1786,14 @@ export function FileBrowser({
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
@@ -1707,6 +1832,32 @@ export function FileBrowser({
|
||||
{t("download")}
|
||||
</button>
|
||||
)}
|
||||
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeFile(contextMenu.name) && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={async () => {
|
||||
const file = resources.find((r) => r.name === contextMenu.name);
|
||||
if (!file) { setContextMenu(null); return; }
|
||||
try {
|
||||
const res = await fetch("/api/collabora/edit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { url } = await res.json();
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Collabora edit failed:", err);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Edit with Collabora
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
|
||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
|
||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||
import { useSignatureStore } from '@/stores/signature-store';
|
||||
|
||||
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
||||
const SIGNATURE_MAX_BYTES = 2047;
|
||||
@@ -73,6 +74,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const {
|
||||
signatures,
|
||||
identitySignatureMap,
|
||||
setIdentitySignature,
|
||||
} = useSignatureStore();
|
||||
const identitySigMapping = identity?.id ? (identitySignatureMap[identity.id] ?? {}) : {};
|
||||
const [sigDefaultId, setSigDefaultId] = useState<string>(identitySigMapping.defaultId ?? '');
|
||||
const [sigReplyId, setSigReplyId] = useState<string>(identitySigMapping.replyId ?? '');
|
||||
|
||||
const parseEmailList = (input: string): EmailAddress[] | undefined => {
|
||||
if (!input.trim()) return undefined;
|
||||
|
||||
@@ -134,6 +144,10 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
};
|
||||
|
||||
await onSave(sanitizedData);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'default', sigDefaultId || null);
|
||||
setIdentitySignature(identity.id, 'reply', sigReplyId || null);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -266,6 +280,57 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signature Store Mapping (per-identity) */}
|
||||
{isEditing && signatures.length > 0 && (
|
||||
<div className="border border-border rounded-md p-4 space-y-3 bg-muted/30">
|
||||
<p className="text-sm font-medium text-foreground">{t('signature_store_mapping')}</p>
|
||||
<div>
|
||||
<label htmlFor="identity-sig-default" className="block text-xs text-muted-foreground mb-1">
|
||||
{t('signature_store_default')}
|
||||
</label>
|
||||
<select
|
||||
id="identity-sig-default"
|
||||
value={sigDefaultId}
|
||||
onChange={(e) => {
|
||||
setSigDefaultId(e.target.value);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'default', e.target.value || null);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="">{t('use_global_default')}</option>
|
||||
{signatures.map((sig) => (
|
||||
<option key={sig.id} value={sig.id}>{sig.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="identity-sig-reply" className="block text-xs text-muted-foreground mb-1">
|
||||
{t('signature_store_reply')}
|
||||
</label>
|
||||
<select
|
||||
id="identity-sig-reply"
|
||||
value={sigReplyId}
|
||||
onChange={(e) => {
|
||||
setSigReplyId(e.target.value);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'reply', e.target.value || null);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="w-full 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"
|
||||
>
|
||||
<option value="">{t('use_global_default')}</option>
|
||||
{signatures.map((sig) => (
|
||||
<option key={sig.id} value={sig.id}>{sig.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Signature */}
|
||||
<div>
|
||||
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FolderX,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Position {
|
||||
@@ -86,6 +87,7 @@ interface MailboxContextMenuProps {
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onShareFolder?: (mailboxId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
@@ -105,6 +107,7 @@ export function MailboxContextMenu({
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onShareFolder,
|
||||
onRefresh,
|
||||
}: MailboxContextMenuProps) {
|
||||
const t = useTranslations("mailbox_context_menu");
|
||||
@@ -191,6 +194,12 @@ export function MailboxContextMenu({
|
||||
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
||||
disabled={!onRenameFolder || !canRename}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Share2}
|
||||
label={t("share_folder")}
|
||||
onClick={() => handleAction(() => onShareFolder?.(mailbox.id))}
|
||||
disabled={!onShareFolder || mailbox.isShared}
|
||||
/>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { MiniCalendarDashlet } from "@/components/calendar/mini-calendar-dashlet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Inbox,
|
||||
@@ -62,6 +63,7 @@ import { useUIStore } from "@/stores/ui-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -88,6 +90,7 @@ interface SidebarProps {
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
onShareFolder?: (mailboxId: string) => void;
|
||||
scheduledTotal?: number;
|
||||
showScheduledMailbox?: boolean;
|
||||
/** True when the unified view spans multiple login accounts (cross-account).
|
||||
@@ -777,6 +780,7 @@ export function Sidebar({
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onRefreshMailboxes,
|
||||
onShareFolder,
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
crossAccountActive = false,
|
||||
@@ -808,6 +812,12 @@ export function Sidebar({
|
||||
return stored !== null ? JSON.parse(stored) : true;
|
||||
} catch { return true; }
|
||||
});
|
||||
const [calendarDashletExpanded, setCalendarDashletExpanded] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarCalendarDashletExpanded');
|
||||
return stored !== null ? JSON.parse(stored) : true;
|
||||
} catch { return true; }
|
||||
});
|
||||
const [unifiedExpanded, setUnifiedExpanded] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarUnifiedExpanded');
|
||||
@@ -840,6 +850,7 @@ export function Sidebar({
|
||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||
const nestedTags = useSettingsStore(s => s.nestedTags);
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
|
||||
// own AccountSwitcher would be a redundant second account UI in the same
|
||||
// pane.
|
||||
@@ -1054,6 +1065,13 @@ export function Sidebar({
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleCalendarDashlet = () => {
|
||||
setCalendarDashletExpanded((prev: boolean) => {
|
||||
const next = !prev;
|
||||
try { localStorage.setItem('sidebarCalendarDashletExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleShared = () => {
|
||||
setSharedExpanded((prev: boolean) => {
|
||||
const next = !prev;
|
||||
@@ -1405,6 +1423,18 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && calendarEnabled && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("calendar")}
|
||||
expanded={calendarDashletExpanded}
|
||||
onToggle={toggleCalendarDashlet}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{calendarDashletExpanded && <MiniCalendarDashlet />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||
</div>
|
||||
|
||||
@@ -1424,6 +1454,7 @@ export function Sidebar({
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onImportEmail={onImportEmail}
|
||||
onShareFolder={onShareFolder}
|
||||
onRefresh={onRefreshMailboxes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ export function ContactsSettings() {
|
||||
const { client } = useAuthStore();
|
||||
const {
|
||||
contacts,
|
||||
addressBooks,
|
||||
supportsSync,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
@@ -46,6 +47,7 @@ export function ContactsSettings() {
|
||||
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
addressBooks={addressBooks}
|
||||
onImport={handleImport}
|
||||
onClose={() => setShowImport(false)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FolderOpen, Download, AlertTriangle, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem, RadioGroup, Select } from "./settings-section";
|
||||
import { importEmails, type ConflictResolution, type ImportProgress, type ImportResult } from "@/lib/email-import";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { EML_IMPORT_ACCEPT } from "@/lib/eml-import";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ImportSettings() {
|
||||
const t = useTranslations("settings.importer");
|
||||
const { client } = useAuthStore();
|
||||
const { mailboxes } = useEmailStore();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [destination, setDestination] = useState("");
|
||||
const [conflict, setConflict] = useState<ConflictResolution>("skip");
|
||||
const [progress, setProgress] = useState<ImportProgress | null>(null);
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (mailboxes.length > 0 && !destination) {
|
||||
const inbox = mailboxes.find((m) => m.role === "inbox") || mailboxes[0];
|
||||
if (inbox) setDestination(inbox.id);
|
||||
}
|
||||
}, [mailboxes, destination]);
|
||||
|
||||
const folderOptions = mailboxes.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.name,
|
||||
}));
|
||||
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files;
|
||||
if (!selected || selected.length === 0) return;
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setProgress(null);
|
||||
setFiles(Array.from(selected));
|
||||
}, []);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!client || files.length === 0 || !destination) return;
|
||||
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await importEmails({
|
||||
client,
|
||||
files,
|
||||
destinationMailboxId: destination,
|
||||
conflictResolution: conflict,
|
||||
onProgress: (p) => setProgress({ ...p }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
setResult(res);
|
||||
if (res.imported > 0) {
|
||||
toast.success(t("success", { count: res.imported }));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) {
|
||||
const msg = err instanceof Error ? err.message : t("fail");
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [client, files, destination, conflict, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
abortRef.current?.abort();
|
||||
setImporting(false);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFiles([]);
|
||||
setResult(null);
|
||||
setProgress(null);
|
||||
setError(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
const progressPercent = progress && progress.total > 0
|
||||
? Math.round((progress.processed / progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
>
|
||||
<SettingItem
|
||||
label={t("file_label")}
|
||||
description={t("file_description")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={EML_IMPORT_ACCEPT}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={importing}
|
||||
>
|
||||
<Upload className="w-4 h-4 me-2" />
|
||||
{files.length > 0
|
||||
? t("files_selected", { count: files.length })
|
||||
: t("choose_files")}
|
||||
</Button>
|
||||
{files.length > 0 && !importing && (
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("folder_label")}
|
||||
description={t("folder_description")}
|
||||
>
|
||||
<Select
|
||||
value={destination}
|
||||
onChange={setDestination}
|
||||
options={folderOptions}
|
||||
disabled={importing || folderOptions.length === 0}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("conflict_label")}
|
||||
description={t("conflict_description")}
|
||||
>
|
||||
<RadioGroup
|
||||
value={conflict}
|
||||
onChange={(v) => setConflict(v as ConflictResolution)}
|
||||
options={[
|
||||
{ value: "skip", label: t("conflict_skip") },
|
||||
{ value: "replace", label: t("conflict_replace") },
|
||||
{ value: "copy", label: t("conflict_copy") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{files.length > 0 && !result && (
|
||||
<SettingItem label={t("action_label")} description="">
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !destination}
|
||||
>
|
||||
{importing ? t("importing") : t("start_import", { count: files.length })}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && importing && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{progress.currentFile}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{t("progress_imported", { count: progress.imported })}</span>
|
||||
<span>{t("progress_skipped", { count: progress.skipped })}</span>
|
||||
<span>{t("progress_failed", { count: progress.failed })}</span>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" onClick={handleCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !importing && (
|
||||
<div className={cn(
|
||||
"rounded-lg p-4 space-y-3",
|
||||
result.failed > 0
|
||||
? "bg-warning/10 border border-warning/30"
|
||||
: "bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
<span className="font-medium text-sm">{t("import_complete")}</span>
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>{t("summary_imported", { count: result.imported })}</p>
|
||||
<p>{t("summary_skipped", { count: result.skipped })}</p>
|
||||
<p>{t("summary_failed", { count: result.failed })}</p>
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{t("error_details", { count: result.errors.length })}
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1 ps-4 list-disc">
|
||||
{result.errors.map((e, i) => (
|
||||
<li key={i} className="text-red-600 dark:text-red-400">
|
||||
<span className="font-medium">{e.file}</span>: {e.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={reset}>
|
||||
{t("import_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Check,
|
||||
X,
|
||||
Folder,
|
||||
Calendar,
|
||||
BookUser,
|
||||
HardDrive,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSharingStore, type SharedResourceKind, type SharedFolder } from "@/stores/sharing-store";
|
||||
|
||||
const ICON_CLASS = "w-4 h-4 shrink-0";
|
||||
|
||||
function KindIcon({ kind }: { kind: SharedResourceKind }) {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return <Folder className={cn(ICON_CLASS, "text-blue-600/80")} />;
|
||||
case "calendar":
|
||||
return <Calendar className={cn(ICON_CLASS, "text-emerald-600/80")} />;
|
||||
case "addressBook":
|
||||
return <BookUser className={cn(ICON_CLASS, "text-violet-600/80")} />;
|
||||
case "file":
|
||||
return <HardDrive className={cn(ICON_CLASS, "text-amber-600/80")} />;
|
||||
}
|
||||
}
|
||||
|
||||
function KindLabel({ kind }: { kind: SharedResourceKind }) {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return "Mail";
|
||||
case "calendar":
|
||||
return "Calendar";
|
||||
case "addressBook":
|
||||
return "Contacts";
|
||||
case "file":
|
||||
return "Files";
|
||||
}
|
||||
}
|
||||
|
||||
export function SharingSettings() {
|
||||
const t = useTranslations("settings");
|
||||
const tSharing = useTranslations("sharing");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const {
|
||||
sharedByMe,
|
||||
sharedWithMe,
|
||||
loading,
|
||||
fetchShares,
|
||||
revokeShare,
|
||||
changeRole,
|
||||
acceptShare,
|
||||
declineShare,
|
||||
} = useSharingStore();
|
||||
const [activeTab, setActiveTab] = useState<"byMe" | "withMe">("byMe");
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (client) fetchShares(client);
|
||||
}, [client, fetchShares]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client) handleRefresh();
|
||||
}, [client, handleRefresh]);
|
||||
|
||||
const handleRevoke = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await revokeShare(
|
||||
client,
|
||||
share.resourceId,
|
||||
share.resourceKind,
|
||||
share.principalId,
|
||||
share.accountId,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeRole = async (share: SharedFolder, role: string) => {
|
||||
if (!client) return;
|
||||
await changeRole(
|
||||
client,
|
||||
share.resourceId,
|
||||
share.resourceKind,
|
||||
share.principalId,
|
||||
role,
|
||||
share.accountId,
|
||||
);
|
||||
};
|
||||
|
||||
const handleAccept = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await acceptShare(client, share);
|
||||
};
|
||||
|
||||
const handleDecline = async (share: SharedFolder) => {
|
||||
if (!client) return;
|
||||
await declineShare(client, share);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 border-b border-border mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab("byMe")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === "byMe"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tSharing("tab_shared_by_me")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("withMe")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === "withMe"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tSharing("tab_shared_with_me")}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="p-2 rounded-md hover:bg-muted text-muted-foreground disabled:opacity-50 transition-colors"
|
||||
title={t("refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("w-4 h-4", loading && "animate-spin")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||
{t("loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "byMe" && (
|
||||
<>
|
||||
{sharedByMe.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
{tSharing("no_shares_by_me")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{sharedByMe.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md border border-border bg-card"
|
||||
>
|
||||
<KindIcon kind={share.resourceKind} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{share.resourceName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KindLabel kind={share.resourceKind} />
|
||||
<span className="mx-1 opacity-40">|</span>
|
||||
<Avatar
|
||||
name={share.principalName}
|
||||
email={share.principalEmail ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0 me-1"
|
||||
/>
|
||||
<span className="truncate">{share.principalName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={share.role}
|
||||
onChange={(e) => handleChangeRole(share, e.target.value)}
|
||||
className="appearance-none rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="read">
|
||||
{tSharing("preset.read")}
|
||||
</option>
|
||||
<option value="readWrite">
|
||||
{tSharing("preset.readWrite")}
|
||||
</option>
|
||||
<option value="manager">
|
||||
{tSharing("preset.manager")}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleRevoke(share)}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={tSharing("remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === "withMe" && (
|
||||
<>
|
||||
{sharedWithMe.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
{tSharing("no_shares_with_me")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{sharedWithMe.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md border border-border bg-card"
|
||||
>
|
||||
<KindIcon kind={share.resourceKind} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{share.resourceName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<KindLabel kind={share.resourceKind} />
|
||||
<span className="mx-1 opacity-40">|</span>
|
||||
<span className="truncate">
|
||||
{tSharing("shared_by")}: {share.principalName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs bg-muted rounded px-2 py-0.5 text-muted-foreground">
|
||||
{tSharing(`preset.${share.role}`)}
|
||||
</span>
|
||||
{share.pending ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => handleAccept(share)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Check className="w-3 h-3 me-1" />
|
||||
{tSharing("accept")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDecline(share)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<X className="w-3 h-3 me-1" />
|
||||
{tSharing("decline")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDecline(share)}
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{tSharing("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import Color from '@tiptap/extension-color';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { htmlToPlainText } from '@/lib/html-to-text';
|
||||
import type { Signature } from '@/stores/signature-store';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline as UnderlineIcon,
|
||||
Strikethrough,
|
||||
List,
|
||||
ListOrdered,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Link as LinkIcon,
|
||||
Baseline,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SignatureEditorModalProps {
|
||||
signature?: Signature | null;
|
||||
onSave: (data: { name: string; body: string; plainText: string }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StyledParagraph = Paragraph.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
style: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('style'),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.style ? { style: attrs.style } : {},
|
||||
},
|
||||
class: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute('class'),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.class ? { class: attrs.class } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const TEXT_COLORS = [
|
||||
'#000000', '#5f6368', '#9aa0a6', '#c5221f', '#e8710a', '#f9ab00', '#188038', '#1967d2',
|
||||
'#7627bb', '#c2185b', '#795548', '#fa5252', '#fd7e14', '#40c057', '#4dabf7', '#e64980',
|
||||
];
|
||||
|
||||
function ToolbarButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
title,
|
||||
disabled,
|
||||
}: {
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={cn(
|
||||
'p-1.5 rounded hover:bg-accent transition-colors',
|
||||
active && 'bg-accent text-accent-foreground',
|
||||
disabled && 'opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarSeparator() {
|
||||
return <div className="w-px h-5 bg-border mx-0.5" />;
|
||||
}
|
||||
|
||||
export function SignatureEditorModal({
|
||||
signature,
|
||||
onSave,
|
||||
onClose,
|
||||
}: SignatureEditorModalProps) {
|
||||
const t = useTranslations('signatures');
|
||||
const tCommon = useTranslations('common');
|
||||
const isEditing = !!signature;
|
||||
|
||||
const [name, setName] = useState(signature?.name ?? '');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
||||
|
||||
const dialogRef = useFocusTrap({
|
||||
isActive: true,
|
||||
onEscape: onClose,
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
paragraph: false,
|
||||
link: false,
|
||||
underline: false,
|
||||
codeBlock: false,
|
||||
}),
|
||||
StyledParagraph,
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer nofollow' },
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['paragraph'],
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
],
|
||||
content: signature?.body ?? '<p></p>',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'tiptap min-h-[120px] px-3 py-2 text-sm text-foreground focus:outline-none',
|
||||
},
|
||||
},
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const previousUrl = editor.getAttributes('link').href;
|
||||
const url = window.prompt('URL', previousUrl);
|
||||
if (url === null) return;
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}, [editor]);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setNameError(t('name_required'));
|
||||
return;
|
||||
}
|
||||
const html = editor?.getHTML() ?? '<p></p>';
|
||||
const plainText = htmlToPlainText(html);
|
||||
onSave({ name: trimmedName, body: html, plainText });
|
||||
};
|
||||
|
||||
const bodyHtml = editor?.getHTML() ?? '';
|
||||
const bodyPlainText = htmlToPlainText(bodyHtml);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-start justify-center z-[60] p-4 pt-[10vh] animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{isEditing ? t('edit_signature') : t('new_signature')}
|
||||
</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<div>
|
||||
<label htmlFor="sig-name" className="block text-sm font-medium mb-1">
|
||||
{t('name_label')}
|
||||
</label>
|
||||
<Input
|
||||
id="sig-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (nameError) setNameError('');
|
||||
}}
|
||||
placeholder={t('name_placeholder')}
|
||||
className={cn(nameError && 'border-destructive')}
|
||||
aria-invalid={!!nameError}
|
||||
aria-describedby={nameError ? 'sig-name-error' : undefined}
|
||||
/>
|
||||
{nameError && (
|
||||
<p id="sig-name-error" className="text-sm text-destructive mt-1" role="alert">
|
||||
{nameError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium">{t('editor_label')}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{showPreview ? t('show_editor') : t('show_preview')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showPreview ? (
|
||||
<div className="border border-border rounded-md bg-muted/30 p-4 min-h-[200px]">
|
||||
<div className="text-xs text-muted-foreground mb-2 font-medium">
|
||||
{t('html_preview_label')}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-foreground [&_a]:text-primary [&_a]:underline-offset-2"
|
||||
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||
/>
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-2 font-medium">
|
||||
{t('plain_text_preview_label')}
|
||||
</div>
|
||||
<pre className="text-sm text-foreground whitespace-pre-wrap font-sans">
|
||||
{bodyPlainText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('flex flex-col border border-border rounded-md overflow-hidden')}>
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-3 py-1.5 border-b border-border/50 bg-muted/30">
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('bold')}
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
title={t('toolbar.bold')}
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('italic')}
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
title={t('toolbar.italic')}
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('underline')}
|
||||
onClick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
title={t('toolbar.underline')}
|
||||
>
|
||||
<UnderlineIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('strike')}
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
title={t('toolbar.strikethrough')}
|
||||
>
|
||||
<Strikethrough className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="relative">
|
||||
<ToolbarButton
|
||||
active={!!editor?.getAttributes('textStyle').color}
|
||||
onClick={() => setColorMenuOpen((v) => !v)}
|
||||
title={t('toolbar.text_color')}
|
||||
>
|
||||
<Baseline
|
||||
className="w-4 h-4"
|
||||
style={{ color: editor?.getAttributes('textStyle').color || undefined }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
{colorMenuOpen && (
|
||||
<div className="absolute z-50 top-full start-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
|
||||
<div
|
||||
className="grid gap-0.5"
|
||||
style={{ gridTemplateColumns: 'repeat(8, 1fr)' }}
|
||||
>
|
||||
{TEXT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
onClick={() => {
|
||||
editor?.chain().focus().setColor(color).run();
|
||||
setColorMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110',
|
||||
editor?.getAttributes('textStyle').color === color &&
|
||||
'ring-1 ring-ring ring-offset-1'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-px bg-border my-1.5" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
|
||||
onClick={() => {
|
||||
editor?.chain().focus().unsetColor().run();
|
||||
setColorMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('toolbar.remove_color')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('bulletList')}
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
title={t('toolbar.bullet_list')}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('orderedList')}
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
title={t('toolbar.ordered_list')}
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
active={editor?.isActive({ textAlign: 'left' })}
|
||||
onClick={() => editor?.chain().focus().setTextAlign('left').run()}
|
||||
title={t('toolbar.align_left')}
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive({ textAlign: 'center' })}
|
||||
onClick={() => editor?.chain().focus().setTextAlign('center').run()}
|
||||
title={t('toolbar.align_center')}
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor?.isActive({ textAlign: 'right' })}
|
||||
onClick={() => editor?.chain().focus().setTextAlign('right').run()}
|
||||
title={t('toolbar.align_right')}
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
active={editor?.isActive('link')}
|
||||
onClick={addLink}
|
||||
title={t('toolbar.link')}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{tCommon('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { SettingsSection, SettingItem, Select } from './settings-section';
|
||||
import { SignatureEditorModal } from './signature-editor-modal';
|
||||
import { useSignatureStore, type Signature } from '@/stores/signature-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { truncateText } from '@/lib/utils';
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Copy,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
export function SignatureSettings() {
|
||||
const t = useTranslations('signatures');
|
||||
const tCommon = useTranslations('common');
|
||||
const {
|
||||
signatures,
|
||||
defaultSignatureId,
|
||||
replySignatureId,
|
||||
identitySignatureMap,
|
||||
addSignature,
|
||||
updateSignature,
|
||||
deleteSignature,
|
||||
duplicateSignature,
|
||||
setDefaultSignatureId,
|
||||
setReplySignatureId,
|
||||
setIdentitySignature,
|
||||
} = useSignatureStore();
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
|
||||
const [editingSignature, setEditingSignature] = useState<Signature | null>(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Signature | null>(null);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingSignature(null);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleEdit = (sig: Signature) => {
|
||||
setEditingSignature(sig);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleDuplicate = (id: string) => {
|
||||
duplicateSignature(id);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (deleteTarget) {
|
||||
deleteSignature(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = (data: { name: string; body: string; plainText: string }) => {
|
||||
if (editingSignature) {
|
||||
updateSignature(editingSignature.id, data);
|
||||
} else {
|
||||
addSignature(data);
|
||||
}
|
||||
setShowEditor(false);
|
||||
setEditingSignature(null);
|
||||
};
|
||||
|
||||
const signatureOptions = [
|
||||
{ value: '', label: t('no_signature') },
|
||||
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem
|
||||
label={t('default_signature.label')}
|
||||
description={t('default_signature.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={defaultSignatureId ?? ''}
|
||||
onChange={(value) => setDefaultSignatureId(value || null)}
|
||||
options={signatureOptions}
|
||||
ariaLabel={t('default_signature.label')}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('reply_signature.label')}
|
||||
description={t('reply_signature.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={replySignatureId ?? ''}
|
||||
onChange={(value) => setReplySignatureId(value || null)}
|
||||
options={signatureOptions}
|
||||
ariaLabel={t('reply_signature.label')}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{identities.length > 0 && (
|
||||
<SettingItem
|
||||
label={t('per_identity_signatures.label')}
|
||||
description={t('per_identity_signatures.description')}
|
||||
>
|
||||
<div className="space-y-2 max-w-xs">
|
||||
{identities.map((identity) => {
|
||||
const mapping = identitySignatureMap[identity.id] ?? {};
|
||||
const identitySigOptions = [
|
||||
{ value: '', label: t('use_global_default') },
|
||||
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
|
||||
];
|
||||
return (
|
||||
<div key={identity.id} className="border border-border rounded-md p-3 space-y-2">
|
||||
<span className="text-sm font-medium block truncate">
|
||||
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-16 shrink-0">
|
||||
{t('default')}
|
||||
</span>
|
||||
<select
|
||||
value={mapping.defaultId ?? ''}
|
||||
onChange={(e) =>
|
||||
setIdentitySignature(identity.id, 'default', e.target.value || null)
|
||||
}
|
||||
className="flex-1 px-2 py-1 text-xs rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{identitySigOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-16 shrink-0">
|
||||
{t('reply')}
|
||||
</span>
|
||||
<select
|
||||
value={mapping.replyId ?? ''}
|
||||
onChange={(e) =>
|
||||
setIdentitySignature(identity.id, 'reply', e.target.value || null)
|
||||
}
|
||||
className="flex-1 px-2 py-1 text-xs rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{identitySigOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t('your_signatures', { count: signatures.length })}
|
||||
</h4>
|
||||
<Button size="sm" onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
{t('add_signature')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{signatures.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t('no_signatures')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{signatures.map((sig) => (
|
||||
<div
|
||||
key={sig.id}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center gap-3 min-w-0 text-start"
|
||||
onClick={() => handleEdit(sig)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{sig.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{truncateText(sig.plainText, 80)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 ml-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicate(sig.id);
|
||||
}}
|
||||
title={t('duplicate')}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(sig);
|
||||
}}
|
||||
title={tCommon('edit')}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(sig);
|
||||
}}
|
||||
title={tCommon('delete')}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{showEditor && (
|
||||
<SignatureEditorModal
|
||||
signature={editingSignature}
|
||||
onSave={handleSave}
|
||||
onClose={() => {
|
||||
setShowEditor(false);
|
||||
setEditingSignature(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title={t('delete_title')}
|
||||
message={t('delete_message', { name: deleteTarget?.name ?? '' })}
|
||||
variant="destructive"
|
||||
confirmText={tCommon('delete')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import {
|
||||
X,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Users,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Principal } from "@/lib/jmap/types";
|
||||
import { useSharingStore, type SharedResourceKind } from "@/stores/sharing-store";
|
||||
|
||||
export interface ShareFolderDialogProps {
|
||||
client: IJMAPClient;
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
resourceKind: SharedResourceKind;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PRESET_OPTIONS: Record<SharedResourceKind, readonly string[]> = {
|
||||
mailbox: ["read", "readWrite", "manager"],
|
||||
calendar: ["read", "readWrite", "manager"],
|
||||
addressBook: ["read", "readWrite", "manager"],
|
||||
file: ["read", "readWrite", "manager"],
|
||||
};
|
||||
|
||||
export function ShareFolderDialog({
|
||||
client,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
onClose,
|
||||
}: ShareFolderDialogProps) {
|
||||
const t = useTranslations("sharing");
|
||||
const tCommon = useTranslations("common");
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sharedByMe = useSharingStore((s) => s.sharedByMe);
|
||||
const loadPrincipals = useSharingStore((s) => s.loadPrincipals);
|
||||
const shareFolder = useSharingStore((s) => s.shareFolder);
|
||||
const revokeShare = useSharingStore((s) => s.revokeShare);
|
||||
const changeRole = useSharingStore((s) => s.changeRole);
|
||||
|
||||
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
|
||||
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingPrincipals(true);
|
||||
loadPrincipals(client)
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setAllPrincipals(list);
|
||||
setLoadingPrincipals(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadingPrincipals(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, loadPrincipals]);
|
||||
|
||||
const ownAccountId = client.getAccountId();
|
||||
|
||||
const allPrincipalsById = useMemo(() => {
|
||||
const map = new Map<string, Principal>();
|
||||
for (const p of allPrincipals) map.set(p.id, p);
|
||||
return map;
|
||||
}, [allPrincipals]);
|
||||
|
||||
const currentShares = sharedByMe.filter(
|
||||
(f) => f.resourceId === resourceId && f.resourceKind === resourceKind,
|
||||
);
|
||||
|
||||
const principals = useMemo(() => {
|
||||
const existing = new Set(currentShares.map((s) => s.principalId));
|
||||
return allPrincipals.filter(
|
||||
(p) => p.id !== ownAccountId && !existing.has(p.id),
|
||||
);
|
||||
}, [allPrincipals, ownAccountId, currentShares]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleRemove = async (principalId: string) => {
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await revokeShare(client, resourceId, resourceKind, principalId);
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeRole = async (principalId: string, role: string) => {
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await changeRole(client, resourceId, resourceKind, principalId, role);
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (principal: Principal) => {
|
||||
setSavingId(principal.id);
|
||||
try {
|
||||
await shareFolder(
|
||||
client,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
principal.id,
|
||||
"read",
|
||||
message || undefined,
|
||||
);
|
||||
setShowAdd(false);
|
||||
setSearch("");
|
||||
setMessage("");
|
||||
} catch {
|
||||
/* error toast comes from store */
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPrincipals = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return principals;
|
||||
return principals.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.email?.toLowerCase().includes(q) ||
|
||||
p.description?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [principals, search]);
|
||||
|
||||
const presetOptions = PRESET_OPTIONS[resourceKind];
|
||||
|
||||
const kindLabels: Record<SharedResourceKind, string> = {
|
||||
mailbox: "Mail folder",
|
||||
calendar: "Calendar",
|
||||
addressBook: "Address book",
|
||||
file: "File folder",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("title", { name: resourceName })}
|
||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("title", { name: resourceName })}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{kindLabels[resourceKind]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
aria-label={tCommon("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
{currentShares.length === 0 && !showAdd && (
|
||||
<div className="text-sm text-muted-foreground italic py-4 text-center">
|
||||
{t("no_shares")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentShares.length > 0 && (
|
||||
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
|
||||
{currentShares.map((share) => {
|
||||
const principal = allPrincipalsById.get(share.principalId);
|
||||
return (
|
||||
<li
|
||||
key={share.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5"
|
||||
>
|
||||
<Avatar
|
||||
name={principal?.name}
|
||||
email={principal?.email ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{principal?.name ||
|
||||
principal?.email ||
|
||||
share.principalId}
|
||||
</div>
|
||||
{principal?.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{principal.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={share.role}
|
||||
onChange={(e) =>
|
||||
handleChangeRole(share.principalId, e.target.value)
|
||||
}
|
||||
disabled={savingId === share.principalId}
|
||||
className="appearance-none rounded-md border border-input bg-background ps-3 pe-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||
>
|
||||
{presetOptions.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`preset.${p}`)}
|
||||
</option>
|
||||
))}
|
||||
{share.role === "custom" && (
|
||||
<option value="custom">
|
||||
{t("preset.custom")}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(share.principalId)}
|
||||
disabled={savingId === share.principalId}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
aria-label={t("remove")}
|
||||
title={t("remove")}
|
||||
>
|
||||
{savingId === share.principalId ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!showAdd && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 me-2" />
|
||||
{t("add_person")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<div className="space-y-2 border border-border rounded-md p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("search_placeholder")}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto -mx-1">
|
||||
{loadingPrincipals && (
|
||||
<div className="flex items-center justify-center py-4 text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
||||
{t("loading_principals")}
|
||||
</div>
|
||||
)}
|
||||
{!loadingPrincipals &&
|
||||
filteredPrincipals.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-center py-3">
|
||||
{search.trim()
|
||||
? t("no_match")
|
||||
: t("no_principals")}
|
||||
</div>
|
||||
)}
|
||||
{!loadingPrincipals &&
|
||||
filteredPrincipals.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleAdd(p)}
|
||||
disabled={savingId === p.id}
|
||||
className="w-full text-start px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={p.name}
|
||||
email={p.email ?? undefined}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate flex items-center gap-2">
|
||||
{p.name}
|
||||
{p.type === "group" && (
|
||||
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
|
||||
{t("group")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.email && p.email !== p.name && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{p.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{savingId === p.id && (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Optional message…"
|
||||
rows={2}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
|
||||
/>
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setSearch("");
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button onClick={onClose}>{tCommon("close")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RadialMenuItem {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
interface RadialMenuProps {
|
||||
items: RadialMenuItem[];
|
||||
isOpen: boolean;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function RadialMenu({
|
||||
items,
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
size = 200,
|
||||
}: RadialMenuProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const [animatingIn, setAnimatingIn] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => setAnimatingIn(true)));
|
||||
} else {
|
||||
setAnimatingIn(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setActiveIndex(-1);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && activeIndex >= 0 && activeIndex < items.length) {
|
||||
e.preventDefault();
|
||||
const item = items[activeIndex];
|
||||
if (!item.disabled) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => {
|
||||
let next = prev + 1;
|
||||
if (next >= items.length) next = 0;
|
||||
let loops = 0;
|
||||
while (items[next]?.disabled && loops < items.length) {
|
||||
next = next + 1 >= items.length ? 0 : next + 1;
|
||||
loops++;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => {
|
||||
let next = prev - 1;
|
||||
if (next < 0) next = items.length - 1;
|
||||
let loops = 0;
|
||||
while (items[next]?.disabled && loops < items.length) {
|
||||
next = next - 1 < 0 ? items.length - 1 : next - 1;
|
||||
loops++;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, activeIndex, items, onClose]);
|
||||
|
||||
const radius = size / 2 - 28;
|
||||
const center = size / 2;
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[9998] bg-black/20 cursor-pointer transition-opacity duration-200",
|
||||
animatingIn ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[9999]"
|
||||
style={{
|
||||
left: position.x - center,
|
||||
top: position.y - center,
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
role="menu"
|
||||
aria-label="Action menu"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute rounded-full flex items-center justify-center transition-all duration-200 ease-out will-change-transform",
|
||||
animatingIn ? "scale-100 opacity-100" : "scale-0 opacity-0"
|
||||
)}
|
||||
style={{
|
||||
left: center - 24,
|
||||
top: center - 24,
|
||||
width: 48,
|
||||
height: 48,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="w-12 h-12 rounded-full bg-background border border-border shadow-lg flex items-center justify-center hover:bg-muted transition-colors cursor-pointer"
|
||||
onClick={onClose}
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.map((item, index) => {
|
||||
const angle = (index / items.length) * 2 * Math.PI - Math.PI / 2;
|
||||
const x = center + radius * Math.cos(angle);
|
||||
const y = center + radius * Math.sin(angle);
|
||||
const itemSize = 40;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"absolute transition-all duration-200 ease-out will-change-transform",
|
||||
animatingIn ? "scale-100 opacity-100" : "scale-0 opacity-0"
|
||||
)}
|
||||
style={{
|
||||
left: x - itemSize / 2,
|
||||
top: y - itemSize / 2,
|
||||
width: itemSize,
|
||||
height: itemSize,
|
||||
transitionDelay: animatingIn ? `${index * 35}ms` : "0ms",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"group relative flex items-center justify-center w-full h-full rounded-full shadow-lg border border-border transition-all duration-150 cursor-pointer focus:outline-none",
|
||||
item.disabled
|
||||
? "opacity-30 cursor-not-allowed bg-muted"
|
||||
: item.destructive
|
||||
? "bg-destructive/10 text-destructive hover:scale-125 hover:bg-destructive hover:text-destructive-foreground hover:border-destructive"
|
||||
: "bg-background text-foreground hover:scale-125 hover:bg-primary hover:text-primary-foreground hover:border-primary",
|
||||
activeIndex === index && !item.disabled && "scale-125 ring-2 ring-primary"
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (item.disabled) return;
|
||||
item.onClick();
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onMouseLeave={() => setActiveIndex(-1)}
|
||||
onFocus={() => setActiveIndex(index)}
|
||||
onBlur={() => setActiveIndex(-1)}
|
||||
role="menuitem"
|
||||
aria-label={item.label}
|
||||
tabIndex={activeIndex === index ? 0 : -1}
|
||||
>
|
||||
<span className="w-5 h-5 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full">
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-7 left-1/2 -translate-x-1/2 whitespace-nowrap text-[11px] font-medium leading-tight text-foreground bg-background/95 px-1.5 py-0.5 rounded shadow-sm border border-border/50",
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity duration-100 pointer-events-none",
|
||||
activeIndex === index && !item.disabled && "opacity-100"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
+6
-1
@@ -236,10 +236,15 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||||
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
|
||||
vnctalkServerUrl: { envVar: 'VNCTALK_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
collaboraServerUrl: { envVar: 'COLLABORA_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
vncdirectoryEnabled: { envVar: 'VNCDIRECTORY_ENABLED', type: 'boolean', defaultValue: false },
|
||||
vncdirectoryApiUrl: { envVar: 'VNCDIRECTORY_API_URL', type: 'url', defaultValue: '' },
|
||||
vncdirectorySamlEnabled: { envVar: 'VNCDIRECTORY_SAML_ENABLED', type: 'boolean', defaultValue: false },
|
||||
};
|
||||
|
||||
/** Keys that should never be exposed to the client config endpoint */
|
||||
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret']);
|
||||
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret', 'vncdirectoryApiKey', 'vncdirectoryLdapPassword']);
|
||||
|
||||
/** Admin session cookie name */
|
||||
export const ADMIN_SESSION_COOKIE = 'admin_session';
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export interface VncDirectoryConfig {
|
||||
enabled: boolean;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
samlEnabled: boolean;
|
||||
samlIdpUrl: string;
|
||||
samlSpCert: string;
|
||||
samlIssuer: string;
|
||||
ldapEnabled: boolean;
|
||||
ldapUri: string;
|
||||
ldapBindDn: string;
|
||||
ldapBindPassword: string;
|
||||
ldapSearchBase: string;
|
||||
ldapType: 'openldap' | 'ms-ad';
|
||||
tfaEnabled: boolean;
|
||||
oidcEnabled: boolean;
|
||||
oidcClientId: string;
|
||||
oidcDiscoveryUrl: string;
|
||||
sessionTtl: number;
|
||||
federatedApps: Record<string, string>;
|
||||
}
|
||||
|
||||
export const DEFAULT_VNCDIRECTORY_CONFIG: VncDirectoryConfig = {
|
||||
enabled: false,
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
samlEnabled: false,
|
||||
samlIdpUrl: '',
|
||||
samlSpCert: '',
|
||||
samlIssuer: '',
|
||||
ldapEnabled: false,
|
||||
ldapUri: '',
|
||||
ldapBindDn: '',
|
||||
ldapBindPassword: '',
|
||||
ldapSearchBase: '',
|
||||
ldapType: 'openldap',
|
||||
tfaEnabled: false,
|
||||
oidcEnabled: false,
|
||||
oidcClientId: '',
|
||||
oidcDiscoveryUrl: '',
|
||||
sessionTtl: 28800,
|
||||
federatedApps: {},
|
||||
};
|
||||
|
||||
/** Keys that should be masked when returning config to clients */
|
||||
export const VNCDIRECTORY_SENSITIVE_KEYS = new Set(['apiKey', 'ldapBindPassword']);
|
||||
|
||||
function applyEnvOverrides(config: VncDirectoryConfig): VncDirectoryConfig {
|
||||
const envEnabled = process.env.VNCDIRECTORY_ENABLED;
|
||||
if (envEnabled !== undefined) {
|
||||
config.enabled = envEnabled === 'true' || envEnabled === '1';
|
||||
}
|
||||
const envApiUrl = process.env.VNCDIRECTORY_API_URL;
|
||||
if (envApiUrl !== undefined) {
|
||||
config.apiUrl = envApiUrl;
|
||||
}
|
||||
const envSamlEnabled = process.env.VNCDIRECTORY_SAML_ENABLED;
|
||||
if (envSamlEnabled !== undefined) {
|
||||
config.samlEnabled = envSamlEnabled === 'true' || envSamlEnabled === '1';
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
|
||||
const filePath = getStatePath(filename);
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
logger.warn(`Failed to read ${filename} from state dir`, {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
|
||||
await ensureStateDir();
|
||||
const targetPath = getStatePath(filename);
|
||||
const tmpPath = targetPath + '.tmp';
|
||||
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
await rename(tmpPath, targetPath);
|
||||
}
|
||||
|
||||
export async function getVncDirectoryConfig(): Promise<VncDirectoryConfig> {
|
||||
const fileConfig = await readJsonFile('vncdirectory.json');
|
||||
const base = fileConfig
|
||||
? { ...DEFAULT_VNCDIRECTORY_CONFIG, ...fileConfig }
|
||||
: { ...DEFAULT_VNCDIRECTORY_CONFIG };
|
||||
return applyEnvOverrides(base);
|
||||
}
|
||||
|
||||
export async function saveVncDirectoryConfig(
|
||||
config: Partial<VncDirectoryConfig>,
|
||||
): Promise<void> {
|
||||
const current = await getVncDirectoryConfig();
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(DEFAULT_VNCDIRECTORY_CONFIG)) {
|
||||
const k = key as keyof VncDirectoryConfig;
|
||||
if (k in config) {
|
||||
merged[key] = config[k];
|
||||
} else {
|
||||
merged[key] = current[k];
|
||||
}
|
||||
}
|
||||
await writeJsonFile('vncdirectory.json', merged);
|
||||
}
|
||||
|
||||
export async function isVncDirectoryEnabled(): Promise<boolean> {
|
||||
const cfg = await getVncDirectoryConfig();
|
||||
return cfg.enabled;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { addMinutes } from "date-fns";
|
||||
|
||||
export interface FreeBusySlot {
|
||||
start: Date;
|
||||
end: Date;
|
||||
status: "free" | "busy" | "tentative" | "unavailable" | "unknown";
|
||||
}
|
||||
|
||||
const SLOT_MINUTES = 30;
|
||||
|
||||
function clampToSlotStart(d: Date): Date {
|
||||
const clone = new Date(d);
|
||||
clone.setSeconds(0, 0);
|
||||
const mins = clone.getMinutes();
|
||||
const remainder = mins % SLOT_MINUTES;
|
||||
if (remainder !== 0) {
|
||||
clone.setMinutes(mins - remainder, 0, 0);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function buildSlots(start: Date, end: Date): FreeBusySlot[] {
|
||||
const slots: FreeBusySlot[] = [];
|
||||
let cursor = new Date(start);
|
||||
while (cursor < end) {
|
||||
const slotEnd = addMinutes(cursor, SLOT_MINUTES);
|
||||
slots.push({
|
||||
start: new Date(cursor),
|
||||
end: slotEnd > end ? new Date(end) : slotEnd,
|
||||
status: "unknown",
|
||||
});
|
||||
cursor = slotEnd;
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
interface EventRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
freeBusyStatus: CalendarEvent["freeBusyStatus"];
|
||||
eventStatus: CalendarEvent["status"];
|
||||
}
|
||||
|
||||
function getEventRange(event: CalendarEvent): EventRange {
|
||||
return {
|
||||
start: new Date(event.start),
|
||||
end: new Date(new Date(event.start).getTime() + parseDurationMs(event.duration)),
|
||||
freeBusyStatus: event.freeBusyStatus,
|
||||
eventStatus: event.status,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDurationMs(duration: string): number {
|
||||
let ms = 0;
|
||||
let sign = 1;
|
||||
let s = duration;
|
||||
if (s.startsWith("-")) {
|
||||
sign = -1;
|
||||
s = s.slice(1);
|
||||
}
|
||||
if (s.startsWith("+")) s = s.slice(1);
|
||||
if (!s.startsWith("P")) return 0;
|
||||
s = s.slice(1);
|
||||
const tIdx = s.indexOf("T");
|
||||
const datePart = tIdx >= 0 ? s.slice(0, tIdx) : s;
|
||||
const timePart = tIdx >= 0 ? s.slice(tIdx + 1) : "";
|
||||
|
||||
let num = "";
|
||||
for (const ch of datePart) {
|
||||
if (ch >= "0" && ch <= "9") {
|
||||
num += ch;
|
||||
} else {
|
||||
const v = parseInt(num, 10) || 0;
|
||||
if (ch === "W") ms += v * 7 * 24 * 60 * 60 * 1000;
|
||||
else if (ch === "D") ms += v * 24 * 60 * 60 * 1000;
|
||||
num = "";
|
||||
}
|
||||
}
|
||||
|
||||
for (const ch of timePart) {
|
||||
if (ch >= "0" && ch <= "9") {
|
||||
num += ch;
|
||||
} else {
|
||||
const v = parseInt(num, 10) || 0;
|
||||
if (ch === "H") ms += v * 60 * 60 * 1000;
|
||||
else if (ch === "M") ms += v * 60 * 1000;
|
||||
else if (ch === "S") ms += v * 1000;
|
||||
num = "";
|
||||
}
|
||||
}
|
||||
|
||||
return ms * sign;
|
||||
}
|
||||
|
||||
function eventsOverlap(eventStart: Date, eventEnd: Date, slotStart: Date, slotEnd: Date): boolean {
|
||||
return eventStart < slotEnd && eventEnd > slotStart;
|
||||
}
|
||||
|
||||
function slotStatusFromEvent(
|
||||
event: CalendarEvent,
|
||||
participantStatus: string | null
|
||||
): FreeBusySlot["status"] {
|
||||
if (event.status === "cancelled") return "free";
|
||||
|
||||
if (participantStatus === "declined") return "free";
|
||||
if (participantStatus === "tentative") return "tentative";
|
||||
|
||||
if (event.freeBusyStatus === "free") return "free";
|
||||
if (event.freeBusyStatus === "busy") return "busy";
|
||||
|
||||
if (participantStatus === "accepted") return "busy";
|
||||
if (participantStatus === "needs-action") return "tentative";
|
||||
|
||||
return "busy";
|
||||
}
|
||||
|
||||
export async function fetchFreeBusy(
|
||||
client: IJMAPClient,
|
||||
participants: { email: string }[],
|
||||
start: Date,
|
||||
end: Date
|
||||
): Promise<Map<string, FreeBusySlot[]>> {
|
||||
const result = new Map<string, FreeBusySlot[]>();
|
||||
|
||||
const slots = buildSlots(clampToSlotStart(start), end);
|
||||
|
||||
for (const p of participants) {
|
||||
const key = p.email.toLowerCase();
|
||||
const participantSlots: FreeBusySlot[] = slots.map((s) => ({
|
||||
start: new Date(s.start),
|
||||
end: new Date(s.end),
|
||||
status: "unknown" as const,
|
||||
}));
|
||||
result.set(key, participantSlots);
|
||||
}
|
||||
|
||||
try {
|
||||
const events = await client.queryAllCalendarEvents(
|
||||
{ after: start.toISOString(), before: end.toISOString() },
|
||||
[{ property: "start", isAscending: true }]
|
||||
);
|
||||
|
||||
for (const event of events) {
|
||||
if (event.status === "cancelled") continue;
|
||||
if (!event.participants) continue;
|
||||
|
||||
const range = getEventRange(event);
|
||||
|
||||
for (const key of result.keys()) {
|
||||
const participant = Object.values(event.participants).find(
|
||||
(p) => p.email.toLowerCase() === key
|
||||
);
|
||||
if (!participant) continue;
|
||||
|
||||
const status = slotStatusFromEvent(event, participant.participationStatus);
|
||||
const participantSlots = result.get(key)!;
|
||||
|
||||
for (const slot of participantSlots) {
|
||||
if (eventsOverlap(range.start, range.end, slot.start, slot.end)) {
|
||||
if (status === "busy" || slot.status === "unknown") {
|
||||
slot.status = status;
|
||||
} else if (status === "tentative" && slot.status === "free") {
|
||||
slot.status = "tentative";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Return unknown statuses for all slots on fetch failure
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isWorkingHour(hour: number, workStart = 8, workEnd = 18): boolean {
|
||||
return hour >= workStart && hour < workEnd;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
|
||||
export async function getCollaboraEditUrl(
|
||||
fileId: string,
|
||||
fileName: string
|
||||
): Promise<string> {
|
||||
const serverUrl =
|
||||
configManager.get<string>("collaboraServerUrl") ||
|
||||
process.env.COLLABORA_SERVER_URL ||
|
||||
"";
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error("COLLABORA_SERVER_URL is not configured");
|
||||
}
|
||||
|
||||
const base = serverUrl.replace(/\/+$/, "");
|
||||
const fileExt = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
// Collabora WOPI host discovery endpoint
|
||||
const response = await fetch(`${base}/hosting/discovery`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Collabora discovery failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const discovery = await response.json();
|
||||
|
||||
// Find the WOPI action URL for the file extension
|
||||
let actionUrl: string | null = null;
|
||||
const mimeMap: Record<string, string> = {
|
||||
docx: "text",
|
||||
doc: "text",
|
||||
odt: "text",
|
||||
xlsx: "spreadsheet",
|
||||
xls: "spreadsheet",
|
||||
ods: "spreadsheet",
|
||||
pptx: "presentation",
|
||||
ppt: "presentation",
|
||||
odp: "presentation",
|
||||
};
|
||||
const docType = mimeMap[fileExt] || "text";
|
||||
|
||||
if (discovery.net?.zone) {
|
||||
const zones = Array.isArray(discovery.net.zone)
|
||||
? discovery.net.zone
|
||||
: [discovery.net.zone];
|
||||
for (const zone of zones) {
|
||||
const apps = Array.isArray(zone.app) ? zone.app : zone.app ? [zone.app] : [];
|
||||
for (const app of apps) {
|
||||
if (
|
||||
app.name &&
|
||||
docType &&
|
||||
app.name.toLowerCase().includes(docType.toLowerCase())
|
||||
) {
|
||||
const actions = Array.isArray(app.action)
|
||||
? app.action
|
||||
: app.action
|
||||
? [app.action]
|
||||
: [];
|
||||
for (const action of actions) {
|
||||
if (action.name === "edit" && action.urlsrc) {
|
||||
actionUrl = action.urlsrc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (actionUrl) break;
|
||||
}
|
||||
if (actionUrl) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!actionUrl) {
|
||||
// Fallback: construct URL manually
|
||||
actionUrl = `${base}/loleaflet/dist/loleaflet.html`;
|
||||
}
|
||||
|
||||
// For now, return the base edit URL. A full WOPI implementation would
|
||||
// generate a WOPI src URL with an access token pointing back to this server.
|
||||
const wopiSrcUrl = `${actionUrl}?WOPISrc=${encodeURIComponent(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`
|
||||
)}`;
|
||||
|
||||
return wopiSrcUrl;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { ContactCard, NameComponent } from "@/lib/jmap/types";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export interface CsvColumnMapping {
|
||||
firstName: number;
|
||||
lastName: number;
|
||||
email: number;
|
||||
phone: number;
|
||||
company: number;
|
||||
jobTitle: number;
|
||||
address: number;
|
||||
city: number;
|
||||
region: number;
|
||||
postcode: number;
|
||||
country: number;
|
||||
website: number;
|
||||
note: number;
|
||||
nickname: number;
|
||||
}
|
||||
|
||||
export interface CsvParseResult {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
delimiter: string;
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
function detectDelimiter(text: string): string {
|
||||
const line = text.split("\n")[0] || "";
|
||||
const counts: Record<string, number> = { ",": 0, ";": 0, "\t": 0 };
|
||||
|
||||
for (const ch of line) {
|
||||
if (ch in counts) counts[ch]++;
|
||||
}
|
||||
|
||||
const best = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
||||
return best && best[1] > 0 ? best[0] : ",";
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string, delimiter: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (i + 1 < line.length && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === delimiter) {
|
||||
fields.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
fields.push(current.trim());
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function parseCSV(text: string): CsvParseResult {
|
||||
const delimiter = detectDelimiter(text);
|
||||
const rawLines = text.split(/\r?\n/);
|
||||
|
||||
const headers = parseCsvLine(rawLines[0] || "", delimiter);
|
||||
const rows: string[][] = [];
|
||||
|
||||
for (let i = 1; i < rawLines.length; i++) {
|
||||
const line = rawLines[i].trim();
|
||||
if (!line) continue;
|
||||
const fields = parseCsvLine(line, delimiter);
|
||||
if (fields.length > 0 && fields.some((f) => f.length > 0)) {
|
||||
rows.push(fields);
|
||||
}
|
||||
}
|
||||
|
||||
return { headers, rows, delimiter, totalRows: rows.length };
|
||||
}
|
||||
|
||||
const NAME_PATTERNS = [
|
||||
/^(?:first[\s_-]?name|given[\s_-]?name|forename|vorname|prénom|nombre|名)$/i,
|
||||
];
|
||||
const LAST_NAME_PATTERNS = [
|
||||
/^(?:last[\s_-]?name|surname|family[\s_-]?name|nachname|nom|姓)$/i,
|
||||
];
|
||||
const EMAIL_PATTERNS = [
|
||||
/^(?:e?-?mail|email[\s_-]?address|e?-?mail[\s_-]?address|e-mail-adresse)$/i,
|
||||
];
|
||||
const PHONE_PATTERNS = [
|
||||
/^(?:phone|telephone|tel|mobile|cell|handy|telefon|téléphone|电话)$/i,
|
||||
];
|
||||
const COMPANY_PATTERNS = [
|
||||
/^(?:company|organization|org|firma|unternehmen|entreprise|société|公司)$/i,
|
||||
];
|
||||
const JOB_TITLE_PATTERNS = [
|
||||
/^(?:job[\s_-]?title|title|position|role|funktion|beruf|poste)$/i,
|
||||
];
|
||||
const ADDRESS_PATTERNS = [
|
||||
/^(?:address|addr|street|straße|adresse|rue)$/i,
|
||||
];
|
||||
const CITY_PATTERNS = [
|
||||
/^(?:city|town|ort|stadt|ville)$/i,
|
||||
];
|
||||
const REGION_PATTERNS = [
|
||||
/^(?:state|province|region|bundesland|région)$/i,
|
||||
];
|
||||
const POSTCODE_PATTERNS = [
|
||||
/^(?:zip|postal[\s_-]?code|postcode|plz|code[\s_-]?postal)$/i,
|
||||
];
|
||||
const COUNTRY_PATTERNS = [
|
||||
/^(?:country|land|pays)$/i,
|
||||
];
|
||||
const WEBSITE_PATTERNS = [
|
||||
/^(?:website|url|web|homepage|site)$/i,
|
||||
];
|
||||
const NOTE_PATTERNS = [
|
||||
/^(?:note|notes|comments|bemerkung|notiz|remarque)$/i,
|
||||
];
|
||||
const NICKNAME_PATTERNS = [
|
||||
/^(?:nickname|nick|alias|spitzname|surnom)$/i,
|
||||
];
|
||||
|
||||
function findColumnIndex(headers: string[], patterns: RegExp[]): number {
|
||||
for (const pattern of patterns) {
|
||||
const idx = headers.findIndex((h) => pattern.test(h));
|
||||
if (idx >= 0) return idx;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function autoMapColumns(headers: string[]): CsvColumnMapping {
|
||||
return {
|
||||
firstName: findColumnIndex(headers, NAME_PATTERNS),
|
||||
lastName: findColumnIndex(headers, LAST_NAME_PATTERNS),
|
||||
email: findColumnIndex(headers, EMAIL_PATTERNS),
|
||||
phone: findColumnIndex(headers, PHONE_PATTERNS),
|
||||
company: findColumnIndex(headers, COMPANY_PATTERNS),
|
||||
jobTitle: findColumnIndex(headers, JOB_TITLE_PATTERNS),
|
||||
address: findColumnIndex(headers, ADDRESS_PATTERNS),
|
||||
city: findColumnIndex(headers, CITY_PATTERNS),
|
||||
region: findColumnIndex(headers, REGION_PATTERNS),
|
||||
postcode: findColumnIndex(headers, POSTCODE_PATTERNS),
|
||||
country: findColumnIndex(headers, COUNTRY_PATTERNS),
|
||||
website: findColumnIndex(headers, WEBSITE_PATTERNS),
|
||||
note: findColumnIndex(headers, NOTE_PATTERNS),
|
||||
nickname: findColumnIndex(headers, NICKNAME_PATTERNS),
|
||||
};
|
||||
}
|
||||
|
||||
function getCol(row: string[], colIndex: number): string {
|
||||
if (colIndex < 0 || colIndex >= row.length) return "";
|
||||
return row[colIndex]?.trim() || "";
|
||||
}
|
||||
|
||||
export function mapRowToContact(
|
||||
row: string[],
|
||||
mapping: CsvColumnMapping,
|
||||
addressBookIds: Record<string, boolean>,
|
||||
): ContactCard | null {
|
||||
const id = `import-csv-${generateUUID()}`;
|
||||
|
||||
const firstName = getCol(row, mapping.firstName);
|
||||
const lastName = getCol(row, mapping.lastName);
|
||||
const email = getCol(row, mapping.email);
|
||||
const phone = getCol(row, mapping.phone);
|
||||
const company = getCol(row, mapping.company);
|
||||
const jobTitle = getCol(row, mapping.jobTitle);
|
||||
const address = getCol(row, mapping.address);
|
||||
const city = getCol(row, mapping.city);
|
||||
const region = getCol(row, mapping.region);
|
||||
const postcode = getCol(row, mapping.postcode);
|
||||
const country = getCol(row, mapping.country);
|
||||
const website = getCol(row, mapping.website);
|
||||
const note = getCol(row, mapping.note);
|
||||
const nickname = getCol(row, mapping.nickname);
|
||||
|
||||
if (!email && !firstName && !lastName) return null;
|
||||
|
||||
const components: NameComponent[] = [];
|
||||
if (firstName) components.push({ kind: "given", value: firstName });
|
||||
if (lastName) components.push({ kind: "surname", value: lastName });
|
||||
|
||||
const contact: ContactCard = {
|
||||
id,
|
||||
addressBookIds,
|
||||
};
|
||||
|
||||
if (components.length > 0) {
|
||||
contact.name = { components, isOrdered: true };
|
||||
} else if (email) {
|
||||
contact.name = { full: email.split("@")[0] };
|
||||
}
|
||||
|
||||
if (email) {
|
||||
contact.emails = {
|
||||
e0: { address: email },
|
||||
};
|
||||
}
|
||||
|
||||
if (phone) {
|
||||
contact.phones = {
|
||||
p0: { number: phone },
|
||||
};
|
||||
}
|
||||
|
||||
if (company) {
|
||||
contact.organizations = {
|
||||
o0: { name: company },
|
||||
};
|
||||
}
|
||||
|
||||
if (jobTitle) {
|
||||
contact.titles = {
|
||||
t0: { name: jobTitle, kind: "title" },
|
||||
};
|
||||
}
|
||||
|
||||
if (address || city || region || postcode || country) {
|
||||
contact.addresses = {
|
||||
a0: {
|
||||
street: address || undefined,
|
||||
locality: city || undefined,
|
||||
region: region || undefined,
|
||||
postcode: postcode || undefined,
|
||||
country: country || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (website) {
|
||||
contact.onlineServices = {
|
||||
u0: { uri: website },
|
||||
};
|
||||
}
|
||||
|
||||
if (note) {
|
||||
contact.notes = {
|
||||
n0: { note },
|
||||
};
|
||||
}
|
||||
|
||||
if (nickname) {
|
||||
contact.nicknames = {
|
||||
n0: { name: nickname },
|
||||
};
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
export function detectDuplicatesByEmail(
|
||||
existingContacts: ContactCard[],
|
||||
incoming: ContactCard[],
|
||||
): Map<number, string> {
|
||||
const dupes = new Map<number, string>();
|
||||
const existingEmails = new Map<string, string>();
|
||||
|
||||
for (const c of existingContacts) {
|
||||
if (c.emails) {
|
||||
for (const e of Object.values(c.emails)) {
|
||||
existingEmails.set(e.address.toLowerCase(), c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
incoming.forEach((card, idx) => {
|
||||
if (card.emails) {
|
||||
for (const e of Object.values(card.emails)) {
|
||||
const match = existingEmails.get(e.address.toLowerCase());
|
||||
if (match) {
|
||||
dupes.set(idx, match);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return dupes;
|
||||
}
|
||||
@@ -1037,6 +1037,8 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return [...this.data.fileNodes];
|
||||
}
|
||||
|
||||
async setMailboxShare(): Promise<void> { /* demo: no-op */ }
|
||||
|
||||
async setFileNodeShare(): Promise<void> { /* demo: no-op */ }
|
||||
|
||||
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { expandImportableEmails } from "@/lib/eml-import";
|
||||
|
||||
export type ConflictResolution = "skip" | "replace" | "copy";
|
||||
|
||||
export interface ImportProgress {
|
||||
total: number;
|
||||
processed: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
currentFile: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errors: Array<{ file: string; error: string }>;
|
||||
}
|
||||
|
||||
function toBase64(buffer: ArrayBuffer): string {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
interface ParsedEml {
|
||||
messageId: string | null;
|
||||
subject: string;
|
||||
from: string;
|
||||
to: string;
|
||||
cc: string;
|
||||
date: string;
|
||||
bodyPlain: string;
|
||||
bodyHtml: string;
|
||||
raw: Blob;
|
||||
}
|
||||
|
||||
async function parseEml(file: Blob): Promise<ParsedEml> {
|
||||
const { default: PostalMime } = await import("postal-mime");
|
||||
const buffer = await file.arrayBuffer();
|
||||
const parsed = await PostalMime.parse(buffer);
|
||||
|
||||
return {
|
||||
messageId: (parsed.messageId || null) as string | null,
|
||||
subject: parsed.subject || "(No Subject)",
|
||||
from: typeof parsed.from === "object" && parsed.from?.address
|
||||
? `${parsed.from.name || ""} <${parsed.from.address}>`.trim()
|
||||
: String(parsed.from || ""),
|
||||
to: Array.isArray(parsed.to)
|
||||
? parsed.to.map((r: { address?: string; name?: string }) =>
|
||||
r.name ? `${r.name} <${r.address}>` : r.address || ""
|
||||
).join(", ")
|
||||
: "",
|
||||
cc: Array.isArray(parsed.cc)
|
||||
? parsed.cc.map((r: { address?: string; name?: string }) =>
|
||||
r.name ? `${r.name} <${r.address}>` : r.address || ""
|
||||
).join(", ")
|
||||
: "",
|
||||
date: parsed.date || "",
|
||||
bodyPlain: parsed.text || "",
|
||||
bodyHtml: parsed.html || "",
|
||||
raw: file,
|
||||
};
|
||||
}
|
||||
|
||||
async function findExistingMessageIds(
|
||||
client: IJMAPClient,
|
||||
messageIds: string[],
|
||||
): Promise<Set<string>> {
|
||||
const existing = new Set<string>();
|
||||
const batchSize = 50;
|
||||
|
||||
for (let i = 0; i < messageIds.length; i += batchSize) {
|
||||
const batch = messageIds.slice(i, i + batchSize);
|
||||
try {
|
||||
const conditions = batch.map((id) => ({
|
||||
header: ["Message-ID", `<${id}>`] as [string, string],
|
||||
}));
|
||||
|
||||
const filter = conditions.length === 1
|
||||
? conditions[0]
|
||||
: { operator: "OR", conditions };
|
||||
|
||||
const { emails } = await client.advancedSearchEmails(filter, undefined, batchSize);
|
||||
for (const email of emails) {
|
||||
if (email.messageId && batch.includes(email.messageId)) {
|
||||
existing.add(email.messageId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort dedup lookup
|
||||
}
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
function generateRfc822FromParsed(eml: ParsedEml): Blob {
|
||||
const lines: string[] = [];
|
||||
lines.push(`From: ${eml.from}`);
|
||||
if (eml.to) lines.push(`To: ${eml.to}`);
|
||||
if (eml.cc) lines.push(`Cc: ${eml.cc}`);
|
||||
if (eml.messageId) lines.push(`Message-ID: <${eml.messageId}>`);
|
||||
lines.push(`Date: ${eml.date || new Date().toUTCString()}`);
|
||||
lines.push(`Subject: ${eml.subject}`);
|
||||
lines.push("MIME-Version: 1.0");
|
||||
|
||||
if (eml.bodyHtml) {
|
||||
const boundary = `----=_Boundary_${Date.now().toString(36)}`;
|
||||
lines.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
||||
lines.push("");
|
||||
lines.push(`--${boundary}`);
|
||||
lines.push("Content-Type: text/plain; charset=utf-8");
|
||||
lines.push("Content-Transfer-Encoding: quoted-printable");
|
||||
lines.push("");
|
||||
lines.push(eml.bodyPlain);
|
||||
lines.push(`--${boundary}`);
|
||||
lines.push("Content-Type: text/html; charset=utf-8");
|
||||
lines.push("Content-Transfer-Encoding: quoted-printable");
|
||||
lines.push("");
|
||||
lines.push(eml.bodyHtml);
|
||||
lines.push(`--${boundary}--`);
|
||||
} else {
|
||||
lines.push("Content-Type: text/plain; charset=utf-8");
|
||||
lines.push("Content-Transfer-Encoding: quoted-printable");
|
||||
lines.push("");
|
||||
lines.push(eml.bodyPlain);
|
||||
}
|
||||
|
||||
return new Blob([lines.join("\r\n")], { type: "message/rfc822" });
|
||||
}
|
||||
|
||||
async function extractMessageIdFromEml(blob: Blob): Promise<string | null> {
|
||||
try {
|
||||
const text = await blob.text();
|
||||
const match = text.match(/^Message-ID:\s*(.+)$/im);
|
||||
if (match) {
|
||||
return match[1].trim().replace(/^<+/, "").replace(/>+$/, "");
|
||||
}
|
||||
} catch {
|
||||
// best-effort message-id extraction
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function importEmails({
|
||||
client,
|
||||
files,
|
||||
destinationMailboxId,
|
||||
conflictResolution,
|
||||
onProgress,
|
||||
signal,
|
||||
}: {
|
||||
client: IJMAPClient;
|
||||
files: File[];
|
||||
destinationMailboxId: string;
|
||||
conflictResolution: ConflictResolution;
|
||||
onProgress?: (progress: ImportProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportResult> {
|
||||
const result: ImportResult = { imported: 0, skipped: 0, failed: 0, errors: [] };
|
||||
const progress: ImportProgress = {
|
||||
total: 0,
|
||||
processed: 0,
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
currentFile: "",
|
||||
};
|
||||
|
||||
const importables = await expandImportableEmails(files);
|
||||
progress.total = importables.length;
|
||||
onProgress?.({ ...progress });
|
||||
|
||||
if (importables.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const duplicateCheck = conflictResolution !== "copy";
|
||||
let existingMessageIds: Set<string> | null = null;
|
||||
|
||||
if (duplicateCheck) {
|
||||
const messageIds: string[] = [];
|
||||
for (const item of importables) {
|
||||
if (signal?.aborted) break;
|
||||
const msgId = await extractMessageIdFromEml(item.blob);
|
||||
if (msgId) messageIds.push(msgId);
|
||||
}
|
||||
existingMessageIds = await findExistingMessageIds(client, messageIds);
|
||||
}
|
||||
|
||||
for (const item of importables) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
progress.currentFile = item.name;
|
||||
progress.processed++;
|
||||
onProgress?.({ ...progress });
|
||||
|
||||
try {
|
||||
const msgId = await extractMessageIdFromEml(item.blob);
|
||||
if (duplicateCheck && msgId && existingMessageIds?.has(msgId)) {
|
||||
if (conflictResolution === "skip") {
|
||||
progress.skipped++;
|
||||
result.skipped++;
|
||||
onProgress?.({ ...progress });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let emlBlob = item.blob;
|
||||
|
||||
try {
|
||||
const parsed = await parseEml(item.blob);
|
||||
emlBlob = generateRfc822FromParsed(parsed);
|
||||
} catch {
|
||||
// best-effort dedup lookup
|
||||
}
|
||||
|
||||
const file = new File([emlBlob], item.name, { type: "message/rfc822" });
|
||||
const { blobId } = await client.uploadBlob(file);
|
||||
|
||||
await client.importEmail(
|
||||
blobId,
|
||||
{ [destinationMailboxId]: true },
|
||||
{ "$seen": true },
|
||||
);
|
||||
|
||||
progress.imported++;
|
||||
result.imported++;
|
||||
} catch (err) {
|
||||
progress.failed++;
|
||||
result.failed++;
|
||||
result.errors.push({
|
||||
file: item.name,
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
onProgress?.({ ...progress });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
+95
-1
@@ -13,6 +13,14 @@ function isZipName(name: string): boolean {
|
||||
return /\.zip$/i.test(name);
|
||||
}
|
||||
|
||||
function isTgzName(name: string): boolean {
|
||||
return /\.(tgz|tar\.gz)$/i.test(name);
|
||||
}
|
||||
|
||||
function isArchiveName(name: string): boolean {
|
||||
return isZipName(name) || isTgzName(name);
|
||||
}
|
||||
|
||||
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
@@ -30,6 +38,88 @@ async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function gunzip(buffer: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
try {
|
||||
const ds = new DecompressionStream("gzip");
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
|
||||
writer.write(new Uint8Array(buffer));
|
||||
writer.close();
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result.buffer;
|
||||
} catch {
|
||||
throw new Error("Failed to decompress gzip archive");
|
||||
}
|
||||
}
|
||||
|
||||
interface TarEntry {
|
||||
name: string;
|
||||
type: string;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
function parseTar(buffer: ArrayBuffer): TarEntry[] {
|
||||
const entries: TarEntry[] = [];
|
||||
const view = new Uint8Array(buffer);
|
||||
let offset = 0;
|
||||
|
||||
while (offset + 512 <= view.byteLength) {
|
||||
const header = new Uint8Array(buffer, offset, 512);
|
||||
const name = new TextDecoder().decode(header.subarray(0, 100)).replace(/\0.*$/, "");
|
||||
const type = String.fromCharCode(header[156] || 0) || "0";
|
||||
|
||||
if (!name) break;
|
||||
|
||||
let sizeStr = "";
|
||||
for (let i = 124; i < 136; i++) {
|
||||
const ch = String.fromCharCode(header[i]);
|
||||
if (ch === "\0" || ch === " ") break;
|
||||
sizeStr += ch;
|
||||
}
|
||||
const size = parseInt(sizeStr || "0", 8);
|
||||
|
||||
offset += 512;
|
||||
|
||||
if (size > 0 && type === "0" && isEmlName(name)) {
|
||||
const data = buffer.slice(offset, offset + size);
|
||||
entries.push({
|
||||
name: name.split(/[\\/]/).pop() || name,
|
||||
type: "file",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
offset += Math.ceil(size / 512) * 512;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function extractEmlsFromTgz(file: File): Promise<ImportableEmail[]> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const decompressed = await gunzip(buffer);
|
||||
const entries = parseTar(decompressed);
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
blob: new Blob([entry.data], { type: EMAIL_MIME }),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function expandImportableEmails(
|
||||
files: File[],
|
||||
): Promise<ImportableEmail[]> {
|
||||
@@ -39,10 +129,14 @@ export async function expandImportableEmails(
|
||||
out.push(...(await extractEmlsFromZip(file)));
|
||||
continue;
|
||||
}
|
||||
if (isTgzName(file.name) || file.type === "application/gzip" || file.type === "application/x-gtar") {
|
||||
out.push(...(await extractEmlsFromTgz(file)));
|
||||
continue;
|
||||
}
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: EMAIL_MIME });
|
||||
out.push({ name: file.name, blob });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const EML_IMPORT_ACCEPT = ".eml,.zip,message/rfc822,application/zip";
|
||||
export const EML_IMPORT_ACCEPT = ".eml,.zip,.tgz,.tar.gz,message/rfc822,application/zip,application/gzip";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
/**
|
||||
@@ -315,6 +315,7 @@ export interface IJMAPClient {
|
||||
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
||||
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
|
||||
setMailboxShare(mailboxId: string, principalId: string, rights: MailboxRights | null, targetAccountId?: string): Promise<void>;
|
||||
|
||||
// ── Accounts (primary + shared/group) ────────────────────────
|
||||
getSharedAccounts(): SharedAccount[];
|
||||
|
||||
+29
-1
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -4426,6 +4426,34 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add, update, or remove a principal's rights on a mailbox folder.
|
||||
* Pass `rights: null` to revoke access.
|
||||
*/
|
||||
async setMailboxShare(
|
||||
mailboxId: string,
|
||||
principalId: string,
|
||||
rights: MailboxRights | null,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || this.accountId;
|
||||
const response = await this.request([
|
||||
["Mailbox/set", {
|
||||
accountId,
|
||||
update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notUpdated?.[mailboxId]) {
|
||||
const err = result.notUpdated[mailboxId];
|
||||
throw new Error(err.description || "Failed to update mailbox share");
|
||||
}
|
||||
if (!result?.updated || !(mailboxId in result.updated)) {
|
||||
throw new Error("Server did not confirm the share update");
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPaginatedContacts(
|
||||
accountId: string,
|
||||
filter?: Record<string, unknown>,
|
||||
|
||||
+15
-11
@@ -181,6 +181,19 @@ export interface Attachment {
|
||||
disposition?: string;
|
||||
}
|
||||
|
||||
export interface MailboxRights {
|
||||
mayReadItems: boolean;
|
||||
mayAddItems: boolean;
|
||||
mayRemoveItems: boolean;
|
||||
maySetSeen: boolean;
|
||||
maySetKeywords: boolean;
|
||||
mayCreateChild: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
maySubmit: boolean;
|
||||
mayShare?: boolean;
|
||||
}
|
||||
|
||||
export interface Mailbox {
|
||||
id: string;
|
||||
originalId?: string; // Original JMAP ID (for shared mailboxes)
|
||||
@@ -192,22 +205,13 @@ export interface Mailbox {
|
||||
unreadEmails: number;
|
||||
totalThreads: number;
|
||||
unreadThreads: number;
|
||||
myRights: {
|
||||
mayReadItems: boolean;
|
||||
mayAddItems: boolean;
|
||||
mayRemoveItems: boolean;
|
||||
maySetSeen: boolean;
|
||||
maySetKeywords: boolean;
|
||||
mayCreateChild: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
maySubmit: boolean;
|
||||
};
|
||||
myRights: MailboxRights;
|
||||
isSubscribed: boolean;
|
||||
// Shared folder support
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
shareWith?: Record<string, MailboxRights> | null;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
export interface Resource {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
type: 'room' | 'vehicle' | 'equipment' | 'other';
|
||||
location?: string;
|
||||
capacity?: number;
|
||||
description?: string;
|
||||
contactEmail?: string;
|
||||
isActive: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ResourceBooking {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
eventId?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
bookedBy: string;
|
||||
}
|
||||
|
||||
interface ResourceRow {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
location: string | null;
|
||||
capacity: number | null;
|
||||
description: string | null;
|
||||
contact_email: string | null;
|
||||
is_active: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface BookingRow {
|
||||
id: string;
|
||||
resource_id: string;
|
||||
event_id: string | null;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
booked_by: string;
|
||||
}
|
||||
|
||||
function rowToResource(row: ResourceRow): Resource {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
name: row.name,
|
||||
type: row.type as Resource['type'],
|
||||
location: row.location ?? undefined,
|
||||
capacity: row.capacity ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
contactEmail: row.contact_email ?? undefined,
|
||||
isActive: row.is_active,
|
||||
metadata: row.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function rowToBooking(row: BookingRow): ResourceBooking {
|
||||
return {
|
||||
id: row.id,
|
||||
resourceId: row.resource_id,
|
||||
eventId: row.event_id ?? undefined,
|
||||
startTime: row.start_time,
|
||||
endTime: row.end_time,
|
||||
bookedBy: row.booked_by,
|
||||
};
|
||||
}
|
||||
|
||||
let pool: { query: (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> } | null = null;
|
||||
|
||||
async function getPool(): Promise<{ query: (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> } | null> {
|
||||
if (pool) return pool;
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (url) {
|
||||
try {
|
||||
// @ts-expect-error - pg is an optional runtime dependency, not in package.json
|
||||
const pg = (await import('pg')) as unknown as { Pool?: new (cfg: { connectionString: string; max: number }) => unknown; default?: { Pool?: new (cfg: { connectionString: string; max: number }) => unknown } };
|
||||
const PoolConstructor = (pg.Pool ?? pg.default?.Pool ?? null);
|
||||
if (PoolConstructor) {
|
||||
pool = new PoolConstructor({ connectionString: url, max: 10 }) as typeof pool;
|
||||
}
|
||||
console.log('[resources] PostgreSQL pool created');
|
||||
return pool;
|
||||
} catch {
|
||||
console.warn('[resources] pg module not available, falling back to in-memory store');
|
||||
}
|
||||
}
|
||||
console.warn('[resources] DATABASE_URL not set, using in-memory store');
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryResources: Map<string, ResourceRow> = new Map();
|
||||
const memoryBookings: Map<string, BookingRow> = new Map();
|
||||
|
||||
export async function listResources(tenantId: string, type?: string): Promise<Resource[]> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
let query = 'SELECT * FROM resources WHERE tenant_id = $1 AND is_active = true';
|
||||
const params: string[] = [tenantId];
|
||||
if (type) {
|
||||
query += ' AND type = $2';
|
||||
params.push(type);
|
||||
}
|
||||
query += ' ORDER BY name ASC';
|
||||
const result = await db.query(query, params);
|
||||
return (result.rows as ResourceRow[]).map(rowToResource);
|
||||
}
|
||||
|
||||
let resources = Array.from(memoryResources.values()).filter(r => r.tenant_id === tenantId && r.is_active);
|
||||
if (type) {
|
||||
resources = resources.filter(r => r.type === type);
|
||||
}
|
||||
resources.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return resources.map(rowToResource);
|
||||
}
|
||||
|
||||
export async function getResource(id: string): Promise<Resource | null> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query('SELECT * FROM resources WHERE id = $1', [id]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToResource(result.rows[0] as ResourceRow);
|
||||
}
|
||||
|
||||
const row = memoryResources.get(id);
|
||||
return row ? rowToResource(row) : null;
|
||||
}
|
||||
|
||||
export async function createResource(
|
||||
tenantId: string,
|
||||
data: { name: string; type: Resource['type']; location?: string; capacity?: number; description?: string; contactEmail?: string; metadata?: Record<string, unknown> }
|
||||
): Promise<Resource> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query(
|
||||
`INSERT INTO resources (id, tenant_id, name, type, location, capacity, description, contact_email, metadata)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[tenantId, data.name, data.type, data.location ?? null, data.capacity ?? null, data.description ?? null, data.contactEmail ?? null, JSON.stringify(data.metadata ?? {})]
|
||||
);
|
||||
return rowToResource(result.rows[0] as ResourceRow);
|
||||
}
|
||||
|
||||
const id = generateUUID();
|
||||
const row: ResourceRow = {
|
||||
id,
|
||||
tenant_id: tenantId,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
location: data.location ?? null,
|
||||
capacity: data.capacity ?? null,
|
||||
description: data.description ?? null,
|
||||
contact_email: data.contactEmail ?? null,
|
||||
is_active: true,
|
||||
metadata: data.metadata ?? {},
|
||||
};
|
||||
memoryResources.set(id, row);
|
||||
return rowToResource(row);
|
||||
}
|
||||
|
||||
export async function checkAvailability(
|
||||
resourceId: string,
|
||||
start: string,
|
||||
end: string,
|
||||
): Promise<{ available: boolean; conflicts: ResourceBooking[] }> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query(
|
||||
`SELECT * FROM resources_bookings
|
||||
WHERE resource_id = $1
|
||||
AND start_time < $3::timestamptz
|
||||
AND end_time > $2::timestamptz
|
||||
ORDER BY start_time ASC`,
|
||||
[resourceId, start, end],
|
||||
);
|
||||
const conflicts = (result.rows as BookingRow[]).map(rowToBooking);
|
||||
return { available: conflicts.length === 0, conflicts };
|
||||
}
|
||||
|
||||
const conflicts = Array.from(memoryBookings.values())
|
||||
.filter(b => b.resource_id === resourceId && b.start_time < end && b.end_time > start)
|
||||
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||
.map(rowToBooking);
|
||||
return { available: conflicts.length === 0, conflicts };
|
||||
}
|
||||
|
||||
export async function bookResource(
|
||||
resourceId: string,
|
||||
start: string,
|
||||
end: string,
|
||||
bookedBy: string,
|
||||
eventId?: string,
|
||||
): Promise<ResourceBooking> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query(
|
||||
`INSERT INTO resources_bookings (id, resource_id, event_id, start_time, end_time, booked_by)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3::timestamptz, $4::timestamptz, $5) RETURNING *`,
|
||||
[resourceId, eventId ?? null, start, end, bookedBy],
|
||||
);
|
||||
return rowToBooking(result.rows[0] as BookingRow);
|
||||
}
|
||||
|
||||
const id = generateUUID();
|
||||
const row: BookingRow = {
|
||||
id,
|
||||
resource_id: resourceId,
|
||||
event_id: eventId ?? null,
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
booked_by: bookedBy,
|
||||
};
|
||||
memoryBookings.set(id, row);
|
||||
return rowToBooking(row);
|
||||
}
|
||||
|
||||
export async function cancelBooking(bookingId: string): Promise<void> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
await db.query('DELETE FROM resources_bookings WHERE id = $1', [bookingId]);
|
||||
return;
|
||||
}
|
||||
|
||||
memoryBookings.delete(bookingId);
|
||||
}
|
||||
|
||||
export async function getBookingsForResource(resourceId: string): Promise<ResourceBooking[]> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM resources_bookings WHERE resource_id = $1 ORDER BY start_time ASC',
|
||||
[resourceId],
|
||||
);
|
||||
return (result.rows as BookingRow[]).map(rowToBooking);
|
||||
}
|
||||
|
||||
return Array.from(memoryBookings.values())
|
||||
.filter(b => b.resource_id === resourceId)
|
||||
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||
.map(rowToBooking);
|
||||
}
|
||||
|
||||
export async function getBookingsForEvent(eventId: string): Promise<ResourceBooking[]> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM resources_bookings WHERE event_id = $1 ORDER BY start_time ASC',
|
||||
[eventId],
|
||||
);
|
||||
return (result.rows as BookingRow[]).map(rowToBooking);
|
||||
}
|
||||
|
||||
return Array.from(memoryBookings.values())
|
||||
.filter(b => b.event_id === eventId)
|
||||
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||
.map(rowToBooking);
|
||||
}
|
||||
|
||||
export async function cancelBookingsForEvent(eventId: string): Promise<void> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
await db.query('DELETE FROM resources_bookings WHERE event_id = $1', [eventId]);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [id, booking] of memoryBookings) {
|
||||
if (booking.event_id === eventId) {
|
||||
memoryBookings.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBookingEventId(bookingId: string, eventId: string): Promise<void> {
|
||||
const db = await getPool();
|
||||
if (db) {
|
||||
await db.query('UPDATE resources_bookings SET event_id = $2 WHERE id = $1', [bookingId, eventId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = memoryBookings.get(bookingId);
|
||||
if (row) {
|
||||
row.event_id = eventId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS resources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('room', 'vehicle', 'equipment', 'other')),
|
||||
location TEXT,
|
||||
capacity INTEGER,
|
||||
description TEXT,
|
||||
contact_email TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resources_bookings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
||||
event_id TEXT,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ NOT NULL,
|
||||
booked_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_tenant ON resources(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_type ON resources(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_resource_time ON resources_bookings(resource_id, start_time, end_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_time ON resources_bookings(start_time, end_time);
|
||||
@@ -0,0 +1,56 @@
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
|
||||
export interface CreateVncMeetingParams {
|
||||
name: string;
|
||||
start: string;
|
||||
end: string;
|
||||
invitees: string[];
|
||||
password?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateVncMeetingResult {
|
||||
meetingUrl: string;
|
||||
meetingId: string;
|
||||
}
|
||||
|
||||
export async function createVncMeeting(
|
||||
params: CreateVncMeetingParams
|
||||
): Promise<CreateVncMeetingResult> {
|
||||
const serverUrl = configManager.get<string>("vnctalkServerUrl") || process.env.VNCTALK_SERVER_URL || "";
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error("VNCTALK_SERVER_URL is not configured");
|
||||
}
|
||||
|
||||
const endpoint = `${serverUrl.replace(/\/+$/, "")}/api/createnewmeeting`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: params.name,
|
||||
start: params.start,
|
||||
end: params.end,
|
||||
invitees: params.invitees,
|
||||
password: params.password,
|
||||
description: params.description,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`VNCtalk API error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const meetingUrl: string = data.meetingUrl || data.meeting_url || data.url || "";
|
||||
const meetingId: string = data.meetingId || data.meeting_id || data.id || "";
|
||||
|
||||
if (!meetingUrl) {
|
||||
throw new Error("VNCtalk API did not return a meeting URL");
|
||||
}
|
||||
|
||||
return { meetingUrl, meetingId };
|
||||
}
|
||||
+40
-1
@@ -301,6 +301,7 @@
|
||||
"view_source": "View source",
|
||||
"export_email": "Export as .eml",
|
||||
"forward_as_attachment": "Forward as attachment",
|
||||
"create_appointment": "Create Appointment",
|
||||
"import_email": "Import .eml or .zip",
|
||||
"keyboard_shortcuts": "Keyboard shortcuts (?)",
|
||||
"email_source": "Email Source",
|
||||
@@ -891,6 +892,8 @@
|
||||
"downloads": "Downloads",
|
||||
"content_senders": "Content & Senders",
|
||||
"about_data": "About & Data",
|
||||
"import": "Import",
|
||||
"sharing": "Sharing",
|
||||
"debug": "Debug"
|
||||
},
|
||||
"tab_groups": {
|
||||
@@ -2056,6 +2059,7 @@
|
||||
"new_subfolder": "New subfolder...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"share_folder": "Share Folder...",
|
||||
"import_email": "Import .eml or .zip...",
|
||||
"empty_folder": "Empty folder",
|
||||
"empty_folder_generic": "Empty folder",
|
||||
@@ -2661,6 +2665,20 @@
|
||||
"no_participants": "No participants",
|
||||
"count": "{count, plural, one {# participant} other {# participants}}"
|
||||
},
|
||||
"freeBusy": {
|
||||
"title": "Availability",
|
||||
"check": "Check Availability",
|
||||
"hide": "Hide Availability",
|
||||
"loading": "Loading...",
|
||||
"no_participants": "Add participants to check availability.",
|
||||
"timezone": "Timezone",
|
||||
"free": "Free",
|
||||
"busy": "Busy",
|
||||
"tentative": "Tentative",
|
||||
"unavailable": "Out of office",
|
||||
"unknown": "No information",
|
||||
"click_to_select": "Click a free slot to select this time"
|
||||
},
|
||||
"recurrence": {
|
||||
"title": "Recurrence",
|
||||
"none": "Does not repeat",
|
||||
@@ -2988,6 +3006,19 @@
|
||||
"due_today": "Today",
|
||||
"due_tomorrow": "Tomorrow",
|
||||
"overdue": "Overdue"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Resources",
|
||||
"hide": "Hide resources",
|
||||
"filter_all": "All",
|
||||
"type_room": "Rooms",
|
||||
"type_vehicle": "Vehicles",
|
||||
"type_equipment": "Equipment",
|
||||
"type_other": "Other",
|
||||
"search_placeholder": "Search resources...",
|
||||
"no_resources": "No resources available",
|
||||
"remove": "Remove {name}",
|
||||
"clear_all": "Clear all"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
@@ -3011,7 +3042,14 @@
|
||||
"readWrite": "Read & write",
|
||||
"manager": "Manager",
|
||||
"custom": "Custom"
|
||||
}
|
||||
},
|
||||
"tab_shared_by_me": "Shared by me",
|
||||
"tab_shared_with_me": "Shared with me",
|
||||
"no_shares_by_me": "You haven't shared anything yet.",
|
||||
"no_shares_with_me": "No folders shared with you yet.",
|
||||
"shared_by": "Shared by",
|
||||
"accept": "Accept",
|
||||
"decline": "Decline"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Advanced Search",
|
||||
@@ -3080,6 +3118,7 @@
|
||||
"delete_confirm_title": "Delete resource",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.",
|
||||
"download": "Download",
|
||||
"send_as_attachment": "Send as Attachment",
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"modified": "Modified",
|
||||
|
||||
@@ -506,8 +506,8 @@
|
||||
### Deploy Flow
|
||||
Per policy: P1 → deploy to `dev` → QA → fix → promote to `main`. Then P2 → dev → QA → main. Repeat for P3, P4.
|
||||
|
||||
### First Sprint Scope
|
||||
**Phase 1 only** — ship all 8 CRITICAL + 10 HIGH fixes (~32h). This brings health from 7.2 to ~8.5/10 and addresses the most impactful user-facing bugs before adding new features.
|
||||
### Phase 1 — COMPLETED 2026-08-07
|
||||
**Shipped as v1.7.9.** 17 of 18 fixes deployed to `main`. All 2527 tests pass (161 test files). One item deferred: P1.3 (C5 auth localStorage encryption) — requires custom Zustand persist adapter, planned for Phase 3.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ADMIN_TABS = [
|
||||
'settings',
|
||||
'branding',
|
||||
'auth',
|
||||
'vncdirectory',
|
||||
'policy',
|
||||
'ai-policy',
|
||||
'plugins',
|
||||
|
||||
@@ -251,6 +251,9 @@ interface CalendarStore {
|
||||
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
|
||||
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
|
||||
isSubscriptionCalendar: (calendarId: string) => boolean;
|
||||
|
||||
newEventPrefill: { title?: string; description?: string; participants?: { name?: string; email: string }[]; date?: string } | null;
|
||||
setNewEventPrefill: (prefill: { title?: string; description?: string; participants?: { name?: string; email: string }[]; date?: string } | null) => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
@@ -265,6 +268,7 @@ const initialState = {
|
||||
error: null as string | null,
|
||||
dateRange: null as { start: string; end: string } | null,
|
||||
icalSubscriptions: [] as ICalSubscription[],
|
||||
newEventPrefill: null as { title?: string; description?: string; participants?: { name?: string; email: string }[]; date?: string } | null,
|
||||
};
|
||||
|
||||
function getSafeCalendarViewMode(value: unknown): CalendarViewMode {
|
||||
@@ -1009,6 +1013,8 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
return get().icalSubscriptions.some(s => s.calendarId === calendarId);
|
||||
},
|
||||
|
||||
setNewEventPrefill: (prefill) => set({ newEventPrefill: prefill }),
|
||||
|
||||
addICalSubscription: async (client, url, name, color, refreshInterval = 60) => {
|
||||
// Normalize webcal(s):// → https:// so the server-side fetcher
|
||||
// (which only accepts http/https) doesn't reject every refresh.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { create } from 'zustand';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { Resource, ResourceBooking } from '@/lib/resources/client';
|
||||
|
||||
interface ResourceState {
|
||||
resources: Resource[];
|
||||
selectedResources: Resource[];
|
||||
bookings: ResourceBooking[];
|
||||
isLoading: boolean;
|
||||
bookingError: string | null;
|
||||
|
||||
fetchResources: (type?: string) => Promise<void>;
|
||||
searchResources: (query: string) => Resource[];
|
||||
toggleResource: (resource: Resource) => void;
|
||||
selectResource: (resource: Resource) => void;
|
||||
deselectResource: (resourceId: string) => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
fetchEventBookings: (eventId: string) => Promise<void>;
|
||||
bookSelectedResources: (start: string, end: string, eventId?: string) => Promise<string[]>;
|
||||
cancelBooking: (bookingId: string) => Promise<void>;
|
||||
cancelEventBookings: (eventId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useResourceStore = create<ResourceState>()((set, get) => ({
|
||||
resources: [],
|
||||
selectedResources: [],
|
||||
bookings: [],
|
||||
isLoading: false,
|
||||
bookingError: null,
|
||||
|
||||
fetchResources: async (type?: string) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (type) params.set('type', type);
|
||||
const res = await apiFetch(`/api/resources?${params.toString()}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch resources');
|
||||
const data = await res.json();
|
||||
set({ resources: data.resources, isLoading: false });
|
||||
} catch {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
searchResources: (query: string) => {
|
||||
const { resources } = get();
|
||||
if (!query.trim()) return resources;
|
||||
const lower = query.toLowerCase();
|
||||
return resources.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(lower) ||
|
||||
(r.location && r.location.toLowerCase().includes(lower)) ||
|
||||
(r.description && r.description.toLowerCase().includes(lower))
|
||||
);
|
||||
},
|
||||
|
||||
toggleResource: (resource: Resource) => {
|
||||
const { selectedResources } = get();
|
||||
const exists = selectedResources.some((r) => r.id === resource.id);
|
||||
if (exists) {
|
||||
set({ selectedResources: selectedResources.filter((r) => r.id !== resource.id) });
|
||||
} else {
|
||||
set({ selectedResources: [...selectedResources, resource] });
|
||||
}
|
||||
},
|
||||
|
||||
selectResource: (resource: Resource) => {
|
||||
const { selectedResources } = get();
|
||||
if (!selectedResources.some((r) => r.id === resource.id)) {
|
||||
set({ selectedResources: [...selectedResources, resource] });
|
||||
}
|
||||
},
|
||||
|
||||
deselectResource: (resourceId: string) => {
|
||||
set({ selectedResources: get().selectedResources.filter((r) => r.id !== resourceId) });
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
set({ selectedResources: [], bookingError: null });
|
||||
},
|
||||
|
||||
fetchEventBookings: async (eventId: string) => {
|
||||
try {
|
||||
const res = await apiFetch(`/api/resources?eventId=${encodeURIComponent(eventId)}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
set({ bookings: data.bookings || [] });
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
},
|
||||
|
||||
bookSelectedResources: async (start: string, end: string, eventId?: string) => {
|
||||
set({ bookingError: null });
|
||||
const { selectedResources } = get();
|
||||
const bookedIds: string[] = [];
|
||||
|
||||
for (const resource of selectedResources) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/resources/${resource.id}/book`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start, end, eventId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
set({ bookingError: `${resource.name}: ${data.error}` });
|
||||
continue;
|
||||
}
|
||||
const data = await res.json();
|
||||
bookedIds.push(data.booking.id);
|
||||
} catch {
|
||||
set({ bookingError: `Failed to book ${resource.name}` });
|
||||
}
|
||||
}
|
||||
|
||||
return bookedIds;
|
||||
},
|
||||
|
||||
cancelBooking: async (bookingId: string) => {
|
||||
const { bookings } = get();
|
||||
const booking = bookings.find((b) => b.id === bookingId);
|
||||
if (!booking) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/api/resources/${booking.resourceId}/book/${bookingId}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (res.ok) {
|
||||
set({ bookings: bookings.filter((b) => b.id !== bookingId) });
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
},
|
||||
|
||||
cancelEventBookings: async (_eventId: string) => {
|
||||
const { bookings } = get();
|
||||
for (const booking of bookings) {
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/api/resources/${booking.resourceId}/book/${booking.id}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (res.ok) continue;
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
set({ bookings: [] });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,523 @@
|
||||
import { create } from "zustand";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type {
|
||||
Principal,
|
||||
CalendarRights,
|
||||
AddressBookRights,
|
||||
FileNodeRights,
|
||||
MailboxRights,
|
||||
} from "@/lib/jmap/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
export type SharedResourceKind =
|
||||
| "mailbox"
|
||||
| "calendar"
|
||||
| "addressBook"
|
||||
| "file";
|
||||
|
||||
export interface SharedFolder {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
resourceKind: SharedResourceKind;
|
||||
principalId: string;
|
||||
principalName: string;
|
||||
principalEmail: string | null;
|
||||
role: string;
|
||||
direction: "byMe" | "withMe";
|
||||
pending: boolean;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
interface SharingState {
|
||||
sharedByMe: SharedFolder[];
|
||||
sharedWithMe: SharedFolder[];
|
||||
loading: boolean;
|
||||
principalsCache: Principal[];
|
||||
|
||||
loadPrincipals: (client: IJMAPClient) => Promise<Principal[]>;
|
||||
fetchShares: (client: IJMAPClient) => Promise<void>;
|
||||
shareFolder: (
|
||||
client: IJMAPClient,
|
||||
resourceId: string,
|
||||
resourceName: string,
|
||||
resourceKind: SharedResourceKind,
|
||||
principalId: string,
|
||||
role: string,
|
||||
message?: string,
|
||||
accountId?: string,
|
||||
) => Promise<void>;
|
||||
revokeShare: (
|
||||
client: IJMAPClient,
|
||||
resourceId: string,
|
||||
resourceKind: SharedResourceKind,
|
||||
principalId: string,
|
||||
accountId?: string,
|
||||
) => Promise<void>;
|
||||
changeRole: (
|
||||
client: IJMAPClient,
|
||||
resourceId: string,
|
||||
resourceKind: SharedResourceKind,
|
||||
principalId: string,
|
||||
role: string,
|
||||
accountId?: string,
|
||||
) => Promise<void>;
|
||||
acceptShare: (client: IJMAPClient, share: SharedFolder) => Promise<void>;
|
||||
declineShare: (client: IJMAPClient, share: SharedFolder) => Promise<void>;
|
||||
}
|
||||
|
||||
function roleLabel(kind: SharedResourceKind, role: string): string {
|
||||
if (kind === "mailbox") return MAILBOX_ROLE_LABELS[role] ?? role;
|
||||
return role;
|
||||
}
|
||||
|
||||
const MAILBOX_PRESETS: Record<string, MailboxRights> = {
|
||||
read: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: false,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: false,
|
||||
maySetKeywords: false,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: true,
|
||||
},
|
||||
manager: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
mayShare: true,
|
||||
},
|
||||
};
|
||||
|
||||
const MAILBOX_ROLE_LABELS: Record<string, string> = {
|
||||
read: "Viewer",
|
||||
readWrite: "Editor",
|
||||
manager: "Manager",
|
||||
};
|
||||
|
||||
const CALENDAR_PRESETS: Record<string, CalendarRights> = {
|
||||
read: {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: false,
|
||||
mayWriteOwn: false,
|
||||
mayUpdatePrivate: false,
|
||||
mayRSVP: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
manager: {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
const ADDRESS_BOOK_PRESETS: Record<string, AddressBookRights> = {
|
||||
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
|
||||
readWrite: {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
manager: {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
const FILE_PRESETS: Record<string, FileNodeRights> = {
|
||||
read: {
|
||||
mayRead: true,
|
||||
mayAddChildren: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
mayModifyContent: false,
|
||||
mayShare: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: false,
|
||||
},
|
||||
manager: {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: true,
|
||||
},
|
||||
};
|
||||
|
||||
function resolveRights(
|
||||
kind: SharedResourceKind,
|
||||
role: string,
|
||||
): MailboxRights | CalendarRights | AddressBookRights | FileNodeRights {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return (
|
||||
MAILBOX_PRESETS[role] ?? MAILBOX_PRESETS.read
|
||||
);
|
||||
case "calendar":
|
||||
return (
|
||||
CALENDAR_PRESETS[role] ?? CALENDAR_PRESETS.read
|
||||
);
|
||||
case "addressBook":
|
||||
return (
|
||||
ADDRESS_BOOK_PRESETS[role] ?? ADDRESS_BOOK_PRESETS.read
|
||||
);
|
||||
case "file":
|
||||
return FILE_PRESETS[role] ?? FILE_PRESETS.read;
|
||||
}
|
||||
}
|
||||
|
||||
export const useSharingStore = create<SharingState>((set, get) => ({
|
||||
sharedByMe: [],
|
||||
sharedWithMe: [],
|
||||
loading: false,
|
||||
principalsCache: [],
|
||||
|
||||
async loadPrincipals(client) {
|
||||
const cached = get().principalsCache;
|
||||
if (cached.length > 0) return cached;
|
||||
const principals = await client.getPrincipals();
|
||||
set({ principalsCache: principals });
|
||||
return principals;
|
||||
},
|
||||
|
||||
async fetchShares(client) {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const principals = await client.getPrincipals();
|
||||
const principalMap = new Map<string, Principal>();
|
||||
for (const p of principals) principalMap.set(p.id, p);
|
||||
|
||||
const byMe: SharedFolder[] = [];
|
||||
const withMe: SharedFolder[] = [];
|
||||
|
||||
try {
|
||||
const mailboxes = await client.getAllMailboxes();
|
||||
for (const mb of mailboxes) {
|
||||
const shares = mb.shareWith;
|
||||
if (shares && Object.keys(shares).length > 0) {
|
||||
for (const [principalId, rights] of Object.entries(shares)) {
|
||||
const p = principalMap.get(principalId);
|
||||
byMe.push({
|
||||
id: `mb-${mb.id}-${principalId}`,
|
||||
resourceId: mb.id,
|
||||
resourceName: mb.name,
|
||||
resourceKind: "mailbox",
|
||||
principalId,
|
||||
principalName: p?.name ?? principalId,
|
||||
principalEmail: p?.email ?? null,
|
||||
role: roleLabel("mailbox", detectMailboxPreset(rights)),
|
||||
direction: "byMe",
|
||||
pending: false,
|
||||
accountId: mb.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* mailboxes may not be available */
|
||||
}
|
||||
|
||||
try {
|
||||
if (client.supportsCalendars()) {
|
||||
const calendars = await client.getAllCalendars();
|
||||
for (const cal of calendars) {
|
||||
const shares = cal.shareWith;
|
||||
if (shares && Object.keys(shares).length > 0) {
|
||||
for (const [principalId, rights] of Object.entries(shares)) {
|
||||
const p = principalMap.get(principalId);
|
||||
byMe.push({
|
||||
id: `cal-${cal.id}-${principalId}`,
|
||||
resourceId: cal.id,
|
||||
resourceName: cal.name,
|
||||
resourceKind: "calendar",
|
||||
principalId,
|
||||
principalName: p?.name ?? principalId,
|
||||
principalEmail: p?.email ?? null,
|
||||
role: roleLabel("calendar", detectCalendarPreset(rights)),
|
||||
direction: "byMe",
|
||||
pending: false,
|
||||
accountId: cal.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* calendars may not be available */
|
||||
}
|
||||
|
||||
try {
|
||||
if (client.supportsContacts()) {
|
||||
const books = await client.getAllAddressBooks();
|
||||
for (const book of books) {
|
||||
const shares = book.shareWith;
|
||||
if (shares && Object.keys(shares).length > 0) {
|
||||
for (const [principalId, rights] of Object.entries(shares)) {
|
||||
const p = principalMap.get(principalId);
|
||||
byMe.push({
|
||||
id: `ab-${book.id}-${principalId}`,
|
||||
resourceId: book.id,
|
||||
resourceName: book.name,
|
||||
resourceKind: "addressBook",
|
||||
principalId,
|
||||
principalName: p?.name ?? principalId,
|
||||
principalEmail: p?.email ?? null,
|
||||
role: roleLabel(
|
||||
"addressBook",
|
||||
detectAddressBookPreset(rights),
|
||||
),
|
||||
direction: "byMe",
|
||||
pending: false,
|
||||
accountId: book.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* address books may not be available */
|
||||
}
|
||||
|
||||
set({ sharedByMe: byMe, sharedWithMe: withMe, loading: false, principalsCache: principals });
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
async shareFolder(
|
||||
client,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
principalId,
|
||||
role,
|
||||
_message,
|
||||
accountId,
|
||||
) {
|
||||
const rights = resolveRights(resourceKind, role);
|
||||
await applyShare(client, resourceKind, resourceId, principalId, rights, accountId);
|
||||
|
||||
const princ = get().principalsCache.find((p) => p.id === principalId);
|
||||
const entry: SharedFolder = {
|
||||
id: `${resourceKind}-${resourceId}-${principalId}`,
|
||||
resourceId,
|
||||
resourceName,
|
||||
resourceKind,
|
||||
principalId,
|
||||
principalName: princ?.name ?? principalId,
|
||||
principalEmail: princ?.email ?? null,
|
||||
role,
|
||||
direction: "byMe",
|
||||
pending: false,
|
||||
accountId,
|
||||
};
|
||||
|
||||
set((s) => ({
|
||||
sharedByMe: [
|
||||
...s.sharedByMe.filter(
|
||||
(f) =>
|
||||
!(
|
||||
f.resourceId === resourceId &&
|
||||
f.principalId === principalId &&
|
||||
f.resourceKind === resourceKind
|
||||
),
|
||||
),
|
||||
entry,
|
||||
],
|
||||
}));
|
||||
toast.success(`Shared "${resourceName}"`);
|
||||
},
|
||||
|
||||
async revokeShare(client, resourceId, resourceKind, principalId, accountId) {
|
||||
await applyShare(client, resourceKind, resourceId, principalId, null, accountId);
|
||||
|
||||
set((s) => ({
|
||||
sharedByMe: s.sharedByMe.filter(
|
||||
(f) =>
|
||||
!(
|
||||
f.resourceId === resourceId &&
|
||||
f.principalId === principalId &&
|
||||
f.resourceKind === resourceKind
|
||||
),
|
||||
),
|
||||
sharedWithMe: s.sharedWithMe.filter(
|
||||
(f) =>
|
||||
!(
|
||||
f.resourceId === resourceId &&
|
||||
f.principalId === principalId &&
|
||||
f.resourceKind === resourceKind
|
||||
),
|
||||
),
|
||||
}));
|
||||
toast.success("Access revoked");
|
||||
},
|
||||
|
||||
async changeRole(
|
||||
client,
|
||||
resourceId,
|
||||
resourceKind,
|
||||
principalId,
|
||||
role,
|
||||
accountId,
|
||||
) {
|
||||
const rights = resolveRights(resourceKind, role);
|
||||
await applyShare(client, resourceKind, resourceId, principalId, rights, accountId);
|
||||
|
||||
set((s) => ({
|
||||
sharedByMe: s.sharedByMe.map((f) =>
|
||||
f.resourceId === resourceId &&
|
||||
f.principalId === principalId &&
|
||||
f.resourceKind === resourceKind
|
||||
? { ...f, role }
|
||||
: f,
|
||||
),
|
||||
}));
|
||||
toast.success("Role updated");
|
||||
},
|
||||
|
||||
async acceptShare(_client, share) {
|
||||
set((s) => ({
|
||||
sharedWithMe: s.sharedWithMe.map((f) =>
|
||||
f.id === share.id ? { ...f, pending: false } : f,
|
||||
),
|
||||
}));
|
||||
toast.success(`Accepted share: ${share.resourceName}`);
|
||||
},
|
||||
|
||||
async declineShare(_client, share) {
|
||||
set((s) => ({
|
||||
sharedWithMe: s.sharedWithMe.filter((f) => f.id !== share.id),
|
||||
}));
|
||||
toast.success(`Declined share: ${share.resourceName}`);
|
||||
},
|
||||
}));
|
||||
|
||||
async function applyShare(
|
||||
client: IJMAPClient,
|
||||
kind: SharedResourceKind,
|
||||
resourceId: string,
|
||||
principalId: string,
|
||||
rights:
|
||||
| MailboxRights
|
||||
| CalendarRights
|
||||
| AddressBookRights
|
||||
| FileNodeRights
|
||||
| null,
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
await client.setMailboxShare(
|
||||
resourceId,
|
||||
principalId,
|
||||
rights as MailboxRights | null,
|
||||
accountId,
|
||||
);
|
||||
break;
|
||||
case "calendar":
|
||||
await client.setCalendarShare(
|
||||
resourceId,
|
||||
principalId,
|
||||
rights as CalendarRights | null,
|
||||
accountId,
|
||||
);
|
||||
break;
|
||||
case "addressBook":
|
||||
await client.setAddressBookShare(
|
||||
resourceId,
|
||||
principalId,
|
||||
rights as AddressBookRights | null,
|
||||
accountId,
|
||||
);
|
||||
break;
|
||||
case "file":
|
||||
await client.setFileNodeShare(
|
||||
resourceId,
|
||||
principalId,
|
||||
rights as FileNodeRights | null,
|
||||
accountId,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function detectMailboxPreset(r: MailboxRights): string {
|
||||
for (const [name, preset] of Object.entries(MAILBOX_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof MailboxRights)[];
|
||||
if (
|
||||
keys.every(
|
||||
(k) =>
|
||||
(preset[k] ?? false) === (r[k as keyof MailboxRights] ?? false),
|
||||
)
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function detectCalendarPreset(r: CalendarRights): string {
|
||||
for (const [name, preset] of Object.entries(CALENDAR_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof CalendarRights)[];
|
||||
if (keys.every((k) => (preset[k] ?? false) === (r[k as keyof CalendarRights] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function detectAddressBookPreset(r: AddressBookRights): string {
|
||||
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
|
||||
if (keys.every((k) => (preset[k] ?? false) === (r[k as keyof AddressBookRights] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
export interface Signature {
|
||||
id: string;
|
||||
name: string;
|
||||
body: string;
|
||||
plainText: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SignatureState {
|
||||
signatures: Signature[];
|
||||
defaultSignatureId: string | null;
|
||||
replySignatureId: string | null;
|
||||
identitySignatureMap: Record<string, { defaultId?: string; replyId?: string }>;
|
||||
addSignature: (sig: Omit<Signature, 'id' | 'createdAt' | 'updatedAt'>) => Signature;
|
||||
updateSignature: (id: string, updates: Partial<Pick<Signature, 'name' | 'body' | 'plainText'>>) => void;
|
||||
deleteSignature: (id: string) => void;
|
||||
duplicateSignature: (id: string) => Signature;
|
||||
setDefaultSignatureId: (id: string | null) => void;
|
||||
setReplySignatureId: (id: string | null) => void;
|
||||
getSignatureById: (id: string) => Signature | undefined;
|
||||
setIdentitySignature: (identityId: string, type: 'default' | 'reply', signatureId: string | null) => void;
|
||||
getIdentityDefaultSignatureId: (identityId: string) => string | null;
|
||||
getIdentityReplySignatureId: (identityId: string) => string | null;
|
||||
}
|
||||
|
||||
export const useSignatureStore = create<SignatureState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
signatures: [],
|
||||
defaultSignatureId: null,
|
||||
replySignatureId: null,
|
||||
identitySignatureMap: {},
|
||||
|
||||
addSignature: (sig) => {
|
||||
const now = new Date().toISOString();
|
||||
const newSig: Signature = {
|
||||
...sig,
|
||||
id: generateUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
set((state) => ({
|
||||
signatures: [...state.signatures, newSig],
|
||||
}));
|
||||
return newSig;
|
||||
},
|
||||
|
||||
updateSignature: (id, updates) => {
|
||||
set((state) => ({
|
||||
signatures: state.signatures.map((s) =>
|
||||
s.id === id
|
||||
? { ...s, ...updates, updatedAt: new Date().toISOString() }
|
||||
: s
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
deleteSignature: (id) => {
|
||||
set((state) => ({
|
||||
signatures: state.signatures.filter((s) => s.id !== id),
|
||||
defaultSignatureId: state.defaultSignatureId === id ? null : state.defaultSignatureId,
|
||||
replySignatureId: state.replySignatureId === id ? null : state.replySignatureId,
|
||||
}));
|
||||
},
|
||||
|
||||
duplicateSignature: (id) => {
|
||||
const original = get().signatures.find((s) => s.id === id);
|
||||
if (!original) {
|
||||
throw new Error(`Signature with id ${id} not found`);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const duplicate: Signature = {
|
||||
...original,
|
||||
id: generateUUID(),
|
||||
name: `${original.name} (copy)`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
set((state) => ({
|
||||
signatures: [...state.signatures, duplicate],
|
||||
}));
|
||||
return duplicate;
|
||||
},
|
||||
|
||||
setDefaultSignatureId: (id) => {
|
||||
set({ defaultSignatureId: id });
|
||||
},
|
||||
|
||||
setReplySignatureId: (id) => {
|
||||
set({ replySignatureId: id });
|
||||
},
|
||||
|
||||
getSignatureById: (id) => {
|
||||
return get().signatures.find((s) => s.id === id);
|
||||
},
|
||||
|
||||
setIdentitySignature: (identityId, type, signatureId) => {
|
||||
set((state) => {
|
||||
const current = state.identitySignatureMap[identityId] ?? {};
|
||||
const updated = {
|
||||
...current,
|
||||
[type === 'default' ? 'defaultId' : 'replyId']: signatureId ?? undefined,
|
||||
};
|
||||
if (updated.defaultId === undefined && updated.replyId === undefined) {
|
||||
const { [identityId]: _, ...rest } = state.identitySignatureMap;
|
||||
return { identitySignatureMap: rest };
|
||||
}
|
||||
return { identitySignatureMap: { ...state.identitySignatureMap, [identityId]: updated } };
|
||||
});
|
||||
},
|
||||
|
||||
getIdentityDefaultSignatureId: (identityId) => {
|
||||
const entry = get().identitySignatureMap[identityId];
|
||||
return entry?.defaultId ?? get().defaultSignatureId;
|
||||
},
|
||||
|
||||
getIdentityReplySignatureId: (identityId) => {
|
||||
const entry = get().identitySignatureMap[identityId];
|
||||
return entry?.replyId ?? get().replySignatureId ?? get().defaultSignatureId;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'signature-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user