Merge branch 'feat/phase2-signatures-sharing' into dev

# Conflicts:
#	runs/2026-08-07-v1.7.8-baseline/DEVELOPMENT-PLAN.md
This commit is contained in:
Bernd Rodler
2026-08-07 13:50:40 +02:00
62 changed files with 7794 additions and 125 deletions
+24
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
1.7.8
1.7.9
+27 -2
View File
@@ -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}
/>
)}
+27
View File
@@ -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>
)}
+23
View File
@@ -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 />}
+620
View File
@@ -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-----&#10;...&#10;-----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>
);
}
+2
View File
@@ -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 },
],
+2
View File
@@ -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 />;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=vncdirectory');
}
+146
View File
@@ -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 });
}
}
+11
View File
@@ -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
+32
View File
@@ -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 });
}
}
+40
View File
@@ -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 });
}
}
+27
View File
@@ -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 });
}
}
+59
View File
@@ -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 });
}
}
+361
View File
@@ -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 };
}
+49
View File
@@ -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 });
}
}
+64 -1
View File
@@ -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>
);
}
+89 -17
View File
@@ -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>
)}
+352 -20
View File
@@ -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")}
+352
View File
@@ -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">&nbsp;</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">&nbsp;</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>
);
}
+243
View File
@@ -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>
);
}
+276 -27
View File
@@ -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 })}
+69 -3
View File
@@ -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}
+111 -12
View File
@@ -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}
+106 -1
View File
@@ -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
+48
View File
@@ -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>
+174 -23
View File
@@ -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={() => {
+65
View File
@@ -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 />
+31
View File
@@ -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)}
/>
+245
View File
@@ -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>
);
}
+279
View File
@@ -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>
);
}
+273
View File
@@ -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')}
/>
</>
);
}
+383
View File
@@ -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>
);
}
+216
View File
@@ -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
View File
@@ -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';
+117
View File
@@ -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;
}
+180
View File
@@ -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;
}
+87
View File
@@ -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;
}
+290
View File
@@ -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;
}
+2
View File
@@ -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[]> {
+249
View File
@@ -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
View File
@@ -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";
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+287
View File
@@ -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;
}
}
+29
View File
@@ -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);
+56
View File
@@ -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
View File
@@ -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.
---
+1
View File
@@ -6,6 +6,7 @@ export const ADMIN_TABS = [
'settings',
'branding',
'auth',
'vncdirectory',
'policy',
'ai-policy',
'plugins',
+6
View File
@@ -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.
+154
View File
@@ -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: [] });
},
}));
+523
View File
@@ -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";
}
+131
View File
@@ -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',
}
)
);